mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
Merge pull request #457 from Hestia-Homes/issue-421-contractor-scoped-list
feat(ara-projects): contractor-scoped work orders and per-project view (#421)
This commit is contained in:
commit
a8a3222ca6
15 changed files with 1148 additions and 56 deletions
136
cypress/e2e/projects/contractor-scoped-list.cy.js
Normal file
136
cypress/e2e/projects/contractor-scoped-list.cy.js
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/**
|
||||
* Ara Projects — a contractor sees a scoped list and per-project view (#421)
|
||||
*
|
||||
* Two groups, mirroring create-project.cy.js:
|
||||
*
|
||||
* 1. The shell group runs anywhere JWT_SECRET is set. It signs in as a
|
||||
* contractor and checks they reach /projects — the entry point to the
|
||||
* scoped journey — without asserting on data a scratch DB may not have.
|
||||
*
|
||||
* 2. The scoped-data group is gated behind CONTRACTOR_SCOPE_E2E and needs a
|
||||
* seeded project where the signed-in contractor's organisation holds a
|
||||
* `project_workstream_contractor` assignment (see
|
||||
* src/app/db/seed/seed-projects-dashboard.ts). Point CONTRACTOR_PROJECT_ID
|
||||
* at that project and CONTRACTOR_USER_EMAIL at a user whose team resolves
|
||||
* to the assigned org. It asserts the acceptance criteria directly:
|
||||
* - the assigned project appears in the contractor's list;
|
||||
* - /projects/<id> renders the contractor view (KPI band, assigned-
|
||||
* workstream chips, "What you can do" panel) and NOT the admin
|
||||
* dashboard;
|
||||
* - the work-orders API is scoped server-side: a contractor asking for
|
||||
* another org's orders via the client filter gets none back — the core
|
||||
* "can never retrieve another org's orders" criterion, checked at the
|
||||
* route-handler level, not just the UI.
|
||||
*
|
||||
* Like the sibling specs this uses `cy.login` (cypress/support/commands.ts) to
|
||||
* mint a real next-auth JWE cookie, so NEXTAUTH_JWT_SECRET must be set.
|
||||
*
|
||||
* NOT YET RUN. The environment this was written in has DATABASE_URL pointing at
|
||||
* production, so Cypress was deliberately not executed. Treat the selectors and
|
||||
* assertions below as unverified until someone runs them against a scratch DB.
|
||||
*/
|
||||
|
||||
const JWT_SECRET = Cypress.env("NEXTAUTH_JWT_SECRET");
|
||||
const SCOPE_E2E = Cypress.env("CONTRACTOR_SCOPE_E2E");
|
||||
const PROJECT_ID = Cypress.env("CONTRACTOR_PROJECT_ID");
|
||||
const OTHER_ORG = Cypress.env("OTHER_CONTRACTOR_ORG");
|
||||
|
||||
const CONTRACTOR_USER = {
|
||||
email: Cypress.env("CONTRACTOR_USER_EMAIL") || "site@contractor.example",
|
||||
name: "Contractor User",
|
||||
onboarded: true,
|
||||
sub: "cypress-contractor",
|
||||
};
|
||||
|
||||
describe("Ara Projects — contractor scoped list (shell)", function () {
|
||||
beforeEach(function () {
|
||||
if (!JWT_SECRET) {
|
||||
cy.log("NEXTAUTH_JWT_SECRET not set — skipping");
|
||||
this.skip();
|
||||
}
|
||||
cy.login(CONTRACTOR_USER);
|
||||
});
|
||||
|
||||
it("lets an onboarded contractor reach the projects list", function () {
|
||||
cy.visit("/projects");
|
||||
cy.location("pathname").should("eq", "/projects");
|
||||
cy.get("[data-testid=projects-shell]").should("exist");
|
||||
// Either the scoped list or the welcome empty state — both are valid for a
|
||||
// contractor depending on whether their org has any assignments here.
|
||||
cy.get("body").then(($body) => {
|
||||
const hasList = $body.find("[data-testid=projects-list]").length > 0;
|
||||
const hasEmpty = $body.find("[data-testid=projects-list-empty]").length > 0;
|
||||
expect(hasList || hasEmpty, "list or empty state renders").to.be.true;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ara Projects — contractor scoped list (seeded data)", function () {
|
||||
beforeEach(function () {
|
||||
if (!JWT_SECRET || !SCOPE_E2E || !PROJECT_ID) {
|
||||
cy.log("CONTRACTOR_SCOPE_E2E / CONTRACTOR_PROJECT_ID not set — skipping");
|
||||
this.skip();
|
||||
}
|
||||
cy.login(CONTRACTOR_USER);
|
||||
});
|
||||
|
||||
it("shows the assigned project in the contractor's list", function () {
|
||||
cy.visit("/projects");
|
||||
cy.get("[data-testid=projects-list]").should("exist");
|
||||
cy.get("[data-testid=projects-list-item]").should("have.length.at.least", 1);
|
||||
});
|
||||
|
||||
it("renders the scoped per-project view, not the admin dashboard", function () {
|
||||
cy.visit(`/projects/${PROJECT_ID}`);
|
||||
|
||||
cy.get("[data-testid=contractor-project-view]").should("be.visible");
|
||||
cy.get("[data-testid=contractor-kpi-band]").should("be.visible");
|
||||
cy.get("[data-testid=contractor-kpi-assigned]").should("be.visible");
|
||||
cy.get("[data-testid=contractor-assigned-workstreams]").should("exist");
|
||||
cy.get("[data-testid=contractor-permissions-panel]").should("be.visible");
|
||||
|
||||
// The admin dashboard's bands must never render for a contractor.
|
||||
cy.get("[data-testid=dashboard-kpi-band]").should("not.exist");
|
||||
cy.get("[data-testid=dashboard-contractors]").should("not.exist");
|
||||
});
|
||||
|
||||
it("shows the scoped work-orders table with a working row action", function () {
|
||||
cy.visit(`/projects/${PROJECT_ID}/work-orders`);
|
||||
cy.get("[data-testid=work-orders-view]").should("be.visible");
|
||||
|
||||
// If the contractor has any orders, the row action links to the work-order
|
||||
// detail route (built in #422); the link shape is asserted regardless.
|
||||
cy.get("body").then(($body) => {
|
||||
if ($body.find("[data-testid=work-order-open]").length === 0) {
|
||||
cy.log("contractor has no work orders in this project — action skipped");
|
||||
return;
|
||||
}
|
||||
cy.get("[data-testid=work-order-open]")
|
||||
.first()
|
||||
.should("have.attr", "href")
|
||||
.and("match", new RegExp(`^/projects/${PROJECT_ID}/work-orders/\\d+$`));
|
||||
});
|
||||
});
|
||||
|
||||
it("never returns another org's orders from the work-orders API", function () {
|
||||
// The route-handler-level acceptance criterion, exercised over HTTP: even
|
||||
// when the contractor explicitly filters by another organisation, the
|
||||
// server scopes the result to their own — so the rows come back empty
|
||||
// rather than leaking the other org's work.
|
||||
const search = OTHER_ORG
|
||||
? `?contractorOrganisationId=${OTHER_ORG}`
|
||||
: "";
|
||||
cy.request({
|
||||
url: `/api/projects/${PROJECT_ID}/work-orders${search}`,
|
||||
failOnStatusCode: false,
|
||||
}).then((res) => {
|
||||
// Never 403 now — a contractor is served their own scoped table.
|
||||
expect(res.status).to.eq(200);
|
||||
if (OTHER_ORG) {
|
||||
// Filtering by an org they don't belong to composes with their scope to
|
||||
// an empty page, never that org's rows.
|
||||
expect(res.body.workOrders).to.have.length(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
76
docs/adr/0023-work-orders-endpoint-is-one-org-scoped-read.md
Normal file
76
docs/adr/0023-work-orders-endpoint-is-one-org-scoped-read.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# 23. The work-orders endpoint is one organisation-scoped read, not two
|
||||
|
||||
Date: 2026-07-24
|
||||
|
||||
## Status
|
||||
|
||||
Accepted. Completes the deferral recorded in the work-orders endpoint's
|
||||
authorization note (#419), which 403'd contractors and left row scoping to "a
|
||||
later issue" — this one (#421).
|
||||
|
||||
## Context
|
||||
|
||||
The admin work-orders table (#419) served internal and client-org members the
|
||||
whole programme and refused contractors outright: `authorizeWorkOrdersRequest`
|
||||
required `canManageProject`, so a contractor — who resolves a role and passes
|
||||
`canViewProject` — got a 403 rather than their own orders.
|
||||
|
||||
Issue #421 adds the contractor journey: a contractor lands on the project and
|
||||
sees only the work orders issued to *their* organisation, never another
|
||||
contractor's, with the hard acceptance criterion that this holds **at the route
|
||||
handler**, not merely in the UI. Two shapes were available:
|
||||
|
||||
1. a second, contractor-only endpoint and table component, scoped to the
|
||||
caller's org; or
|
||||
2. the *same* endpoint and table, with an organisation scope resolved per
|
||||
request — null for the privileged roles, the caller's own orgs for a
|
||||
contractor.
|
||||
|
||||
The permission flags that decide *what a contractor may do* already live per
|
||||
assignment (`project_workstream_contractor`, ADR-0022) and are read by the
|
||||
per-work-order guards. What #421 needed was orthogonal: *which orders may they
|
||||
see at all.*
|
||||
|
||||
## Decision
|
||||
|
||||
**One endpoint, one table, a nullable scope.** `workOrderOrganisationScope`
|
||||
(`@/lib/projects/authz`) is the single definition of "whose orders may this
|
||||
caller see":
|
||||
|
||||
- internal / client → **null**, meaning *no scope* — the whole programme;
|
||||
- contractor → the intersection of their memberships and the project's
|
||||
contractor orgs, **never** another org's, even one also assigned here;
|
||||
- no role → an empty array (scoped to nobody), the safe answer.
|
||||
|
||||
Null is deliberately distinct from `[]`: "everyone" and "no-one" must not
|
||||
collapse to the same value. The authz layer returns the scope, the route
|
||||
handler passes it to `loadWorkOrderPage`, and the query pushes it into SQL as
|
||||
`pwc.organisation_id IN (…)` (or a literal `false` for the empty scope). Because
|
||||
the scope is a separate `AND`, it composes with — and can never be widened by —
|
||||
the caller's own `contractorOrganisationId` filter: a contractor asking for
|
||||
another org's orders gets their scope *and* that filter, i.e. an empty page, not
|
||||
a leak.
|
||||
|
||||
The same scope also drives the contractor's per-project view. A contractor on
|
||||
`/projects/[projectId]` does **not** see the programme dashboard — its KPIs
|
||||
aggregate every org's work — but a scoped view built from the same
|
||||
`summariseDashboard` fold over organisation-scoped groups, plus assigned-
|
||||
workstream chips and a "What you can do" panel driven by their permission flags.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The security boundary is the SQL scope in one query builder, exercised
|
||||
directly by unit tests (route-handler and query-construction level) and a
|
||||
Cypress check — not spread across two endpoints that could drift.
|
||||
- Internal and client callers are unaffected: their scope is null, so the query
|
||||
is byte-for-byte what #419 emitted.
|
||||
- The table component (#419) is reused verbatim; only its server-provided seed
|
||||
page and filter options are scoped. Filter options are narrowed to the
|
||||
contractor's own workstreams and org so the filter bar cannot even name
|
||||
another contractor — a cosmetic follow-on to the row scoping, not a second
|
||||
enforcement point.
|
||||
- A contractor's KPI tiles and their work-orders table are derived from the
|
||||
same organisation-scoped groups, so the tiles and the table reconcile the way
|
||||
the admin dashboard and admin table already do (#418).
|
||||
- The row action links to the work-order detail route (#422, built in
|
||||
parallel); this issue only points at it.
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Request authorization for the work-orders endpoint (issue #419).
|
||||
* Request authorization for the work-orders endpoint (issues #419, #421).
|
||||
*
|
||||
* Wiring only, mirroring `../workstreams/authorize.ts`: the session comes from
|
||||
* NextAuth, the facts from the projects authz repository, and every *decision*
|
||||
|
|
@ -8,13 +8,18 @@
|
|||
* A project the caller cannot see is reported as 404, never 403, so project
|
||||
* existence does not leak outside its organisation.
|
||||
*
|
||||
* Scoping note: this endpoint is the **admin** table — internal and client-org
|
||||
* members, per the issue's acceptance criteria — so it requires
|
||||
* `canManageProject`. A contractor resolves a role on the project and would
|
||||
* pass `canViewProject`, but must not see other contractors' work orders;
|
||||
* narrowing the rows to their own assignments is a later issue, and until it
|
||||
* lands the honest answer for a contractor is 403 rather than a table showing
|
||||
* them the whole programme.
|
||||
* Scoping note: this one endpoint serves both the admin table and the
|
||||
* contractor-scoped table. Any role that can *view* the project may call it;
|
||||
* what differs is the `organisationScope` it returns, resolved once by
|
||||
* `workOrderOrganisationScope`:
|
||||
*
|
||||
* - internal / client → **null** (no scope — the whole programme);
|
||||
* - contractor → their **own** assigned organisations, never another's.
|
||||
*
|
||||
* The caller passes that scope straight to `loadWorkOrderPage`, which pushes it
|
||||
* into SQL. This is why a contractor can never retrieve another org's orders
|
||||
* from the API: the narrowing happens at the data layer, independently of any
|
||||
* filter the client supplies (issue #421 acceptance criterion).
|
||||
*/
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
|
|
@ -22,15 +27,20 @@ import {
|
|||
loadAuthzUser,
|
||||
loadProjectAuthzFacts,
|
||||
} from "@/app/repositories/projects/authzRepository";
|
||||
import { canManageProject, canViewProject } from "@/lib/projects/authz";
|
||||
import { canViewProject, workOrderOrganisationScope } from "@/lib/projects/authz";
|
||||
|
||||
export type AuthorizeFailure = { error: string; status: 400 | 401 | 403 | 404 };
|
||||
|
||||
export type AuthorizeResult =
|
||||
| { ok: true; projectId: bigint }
|
||||
| {
|
||||
ok: true;
|
||||
projectId: bigint;
|
||||
/** null = unscoped (internal/client); an array = a contractor's own orgs. */
|
||||
organisationScope: string[] | null;
|
||||
}
|
||||
| ({ ok: false } & AuthorizeFailure);
|
||||
|
||||
/** Resolve the caller's standing on `projectIdParam` for the admin table. */
|
||||
/** Resolve the caller's standing on `projectIdParam` and the scope to apply. */
|
||||
export async function authorizeWorkOrdersRequest(
|
||||
projectIdParam: string,
|
||||
): Promise<AuthorizeResult> {
|
||||
|
|
@ -55,9 +65,10 @@ export async function authorizeWorkOrdersRequest(
|
|||
if (!canViewProject(user, project)) {
|
||||
return { ok: false, error: "Project not found", status: 404 };
|
||||
}
|
||||
if (!canManageProject(user, project)) {
|
||||
return { ok: false, error: "Forbidden", status: 403 };
|
||||
}
|
||||
|
||||
return { ok: true, projectId };
|
||||
return {
|
||||
ok: true,
|
||||
projectId,
|
||||
organisationScope: workOrderOrganisationScope(user, project),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -191,6 +191,44 @@ describe("buildWorkOrderPageQuery", () => {
|
|||
buildWorkOrderPageQuery(7n, query(), new Date("nope"), [], TERMINAL),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
describe("contractor organisation scope (issue #421)", () => {
|
||||
const scoped = (
|
||||
scope: readonly string[] | null,
|
||||
q: Partial<WorkOrderQuery> = {},
|
||||
) => render(buildWorkOrderPageQuery(7n, query(q), ASOF, [], TERMINAL, scope));
|
||||
|
||||
it("adds no scope predicate for an unscoped (internal/client) caller", () => {
|
||||
expect(scoped(null).sql).not.toContain("pwc.organisation_id IN");
|
||||
});
|
||||
|
||||
it("narrows to the caller's own organisations, cast to uuid and bound", () => {
|
||||
const { sql, params } = scoped([CONTRACTOR]);
|
||||
expect(sql).toContain("pwc.organisation_id IN (");
|
||||
expect(sql).toContain("::uuid");
|
||||
expect(params).toContain(CONTRACTOR);
|
||||
});
|
||||
|
||||
it("keeps the scope even when the caller filters by another contractor", () => {
|
||||
// The core security property: a contractor asking for another org still
|
||||
// gets their own scope AND the other filter — an empty result, not a leak.
|
||||
const other = "99999999-9999-9999-9999-999999999999";
|
||||
const { sql, params } = scoped([CONTRACTOR], {
|
||||
contractorOrganisationId: other,
|
||||
});
|
||||
expect(sql).toContain("pwc.organisation_id IN (");
|
||||
expect(sql).toContain("pwc.organisation_id =");
|
||||
expect(params).toContain(CONTRACTOR);
|
||||
expect(params).toContain(other);
|
||||
});
|
||||
|
||||
it("emits `false` for an empty scope, returning nobody rather than everybody", () => {
|
||||
const { sql } = scoped([]);
|
||||
// A contractor resolved to no assigned org must see nothing, never all.
|
||||
expect(sql).toContain("false");
|
||||
expect(sql).not.toContain("pwc.organisation_id IN");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWorkOrderCountQuery", () => {
|
||||
|
|
@ -213,4 +251,12 @@ describe("buildWorkOrderCountQuery", () => {
|
|||
"satisfied_count < required_count",
|
||||
);
|
||||
});
|
||||
|
||||
it("scopes the count to the contractor too, so the footer total matches the rows", () => {
|
||||
const { sql, params } = render(
|
||||
buildWorkOrderCountQuery(7n, query(), ASOF, [], TERMINAL, [CONTRACTOR]),
|
||||
);
|
||||
expect(sql).toContain("pwc.organisation_id IN (");
|
||||
expect(params).toContain(CONTRACTOR);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@
|
|||
* Read-only: this module issues `SELECT`s and nothing else.
|
||||
*/
|
||||
import { db } from "@/app/db/db";
|
||||
import { and, asc, eq, sql, type SQL } from "drizzle-orm";
|
||||
import { and, asc, eq, inArray, sql, type SQL } from "drizzle-orm";
|
||||
import {
|
||||
projectWorkstream,
|
||||
projectWorkstreamContractor,
|
||||
|
|
@ -41,6 +41,7 @@ import {
|
|||
buildApplicableEvidencePairs,
|
||||
loadEvidenceRequirements,
|
||||
loadStageLadderEntries,
|
||||
organisationScopeCondition,
|
||||
type ApplicableEvidencePair,
|
||||
} from "@/app/repositories/projects/dashboardRepository";
|
||||
import type { WorkOrderFilterOptions, WorkOrderQuery } from "./filters";
|
||||
|
|
@ -58,6 +59,7 @@ export async function loadWorkOrderPage(
|
|||
projectId: bigint,
|
||||
query: WorkOrderQuery,
|
||||
asOf: Date,
|
||||
scopeOrganisationIds: readonly string[] | null = null,
|
||||
): Promise<WorkOrderPage> {
|
||||
const [stages, requirements] = await Promise.all([
|
||||
loadStageLadderEntries(projectId),
|
||||
|
|
@ -80,6 +82,7 @@ export async function loadWorkOrderPage(
|
|||
asOf,
|
||||
applicableEvidence,
|
||||
terminalStageIds,
|
||||
scopeOrganisationIds,
|
||||
),
|
||||
),
|
||||
db.execute<{ total: number }>(
|
||||
|
|
@ -89,6 +92,7 @@ export async function loadWorkOrderPage(
|
|||
asOf,
|
||||
applicableEvidence,
|
||||
terminalStageIds,
|
||||
scopeOrganisationIds,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
|
@ -106,16 +110,59 @@ export async function loadWorkOrderPage(
|
|||
};
|
||||
}
|
||||
|
||||
/** The choices for the three dropdowns, in one round trip each. */
|
||||
/**
|
||||
* The choices for the three dropdowns, in one round trip each.
|
||||
*
|
||||
* `scopeOrganisationIds` narrows the choices to a contractor's own world
|
||||
* (issue #421): only the workstreams they are assigned to, only their own
|
||||
* organisation, and only those workstreams' stages — so the filter bar never
|
||||
* offers another contractor's name or a workstream they hold nothing on. Null
|
||||
* (internal/client) offers the whole programme. This only shapes the *choices*;
|
||||
* the rows themselves are scoped in `loadWorkOrderPage`, which is the security
|
||||
* boundary — a hidden option is not a permission.
|
||||
*/
|
||||
export async function loadFilterOptions(
|
||||
projectId: bigint,
|
||||
scopeOrganisationIds: readonly string[] | null = null,
|
||||
): Promise<WorkOrderFilterOptions> {
|
||||
// The project workstreams the contractor is assigned to, as a subquery the
|
||||
// workstream and stage filters restrict to. Absent for internal/client.
|
||||
const scopedWorkstreamIds =
|
||||
scopeOrganisationIds === null
|
||||
? null
|
||||
: db
|
||||
.select({ id: projectWorkstreamContractor.projectWorkstreamId })
|
||||
.from(projectWorkstreamContractor)
|
||||
.innerJoin(
|
||||
projectWorkstream,
|
||||
eq(
|
||||
projectWorkstreamContractor.projectWorkstreamId,
|
||||
projectWorkstream.id,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkstream.projectId, projectId),
|
||||
inArray(
|
||||
projectWorkstreamContractor.organisationId,
|
||||
scopeOrganisationIds as string[],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const [workstreams, contractors, stages] = await Promise.all([
|
||||
db
|
||||
.select({ id: projectWorkstream.id, name: workstream.name })
|
||||
.from(projectWorkstream)
|
||||
.innerJoin(workstream, eq(projectWorkstream.workstreamId, workstream.id))
|
||||
.where(eq(projectWorkstream.projectId, projectId))
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkstream.projectId, projectId),
|
||||
scopedWorkstreamIds
|
||||
? inArray(projectWorkstream.id, scopedWorkstreamIds)
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(workstream.name),
|
||||
|
||||
db
|
||||
|
|
@ -132,7 +179,17 @@ export async function loadFilterOptions(
|
|||
organisation,
|
||||
eq(organisation.id, projectWorkstreamContractor.organisationId),
|
||||
)
|
||||
.where(eq(projectWorkstream.projectId, projectId))
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkstream.projectId, projectId),
|
||||
scopeOrganisationIds
|
||||
? inArray(
|
||||
projectWorkstreamContractor.organisationId,
|
||||
scopeOrganisationIds as string[],
|
||||
)
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(organisation.name),
|
||||
|
||||
db
|
||||
|
|
@ -147,7 +204,14 @@ export async function loadFilterOptions(
|
|||
projectWorkstream,
|
||||
eq(projectWorkstreamStage.projectWorkstreamId, projectWorkstream.id),
|
||||
)
|
||||
.where(eq(projectWorkstream.projectId, projectId))
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkstream.projectId, projectId),
|
||||
scopedWorkstreamIds
|
||||
? inArray(projectWorkstream.id, scopedWorkstreamIds)
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(
|
||||
asc(projectWorkstreamStage.projectWorkstreamId),
|
||||
asc(projectWorkstreamStage.order),
|
||||
|
|
@ -229,6 +293,7 @@ export function buildWorkOrderPageQuery(
|
|||
asOf: Date,
|
||||
applicableEvidence: readonly ApplicableEvidencePair[],
|
||||
terminalStageIds: readonly bigint[],
|
||||
scopeOrganisationIds: readonly string[] | null = null,
|
||||
) {
|
||||
const scope = workOrderScope(
|
||||
projectId,
|
||||
|
|
@ -236,6 +301,7 @@ export function buildWorkOrderPageQuery(
|
|||
asOf,
|
||||
applicableEvidence,
|
||||
terminalStageIds,
|
||||
scopeOrganisationIds,
|
||||
);
|
||||
return sql`${scope} SELECT * FROM per_order ${outerWhere(
|
||||
query,
|
||||
|
|
@ -254,6 +320,7 @@ export function buildWorkOrderCountQuery(
|
|||
asOf: Date,
|
||||
applicableEvidence: readonly ApplicableEvidencePair[],
|
||||
terminalStageIds: readonly bigint[],
|
||||
scopeOrganisationIds: readonly string[] | null = null,
|
||||
) {
|
||||
const scope = workOrderScope(
|
||||
projectId,
|
||||
|
|
@ -261,6 +328,7 @@ export function buildWorkOrderCountQuery(
|
|||
asOf,
|
||||
applicableEvidence,
|
||||
terminalStageIds,
|
||||
scopeOrganisationIds,
|
||||
);
|
||||
return sql`${scope} SELECT count(*)::int AS total FROM per_order ${outerWhere(
|
||||
query,
|
||||
|
|
@ -283,6 +351,7 @@ function workOrderScope(
|
|||
asOf: Date,
|
||||
applicableEvidence: readonly ApplicableEvidencePair[],
|
||||
terminalStageIds: readonly bigint[],
|
||||
scopeOrganisationIds: readonly string[] | null = null,
|
||||
) {
|
||||
const asOfDay = toCalendarDay(asOf);
|
||||
if (asOfDay === null) throw new Error("loadWorkOrderPage: invalid asOf date");
|
||||
|
|
@ -310,6 +379,15 @@ function workOrderScope(
|
|||
|
||||
const conditions = [sql`pw.project_id = ${projectId}`];
|
||||
|
||||
// Contractor scope (issue #421), applied server-side and *independently* of
|
||||
// the caller's own filters: a contractor whose `contractorOrganisationId`
|
||||
// filter names another org still gets this `AND`, so the two compose to an
|
||||
// empty page rather than another org's rows. Null for internal/client, who
|
||||
// see the whole programme. This is the enforcement point for the acceptance
|
||||
// criterion — a client filter can never widen it.
|
||||
const orgScope = organisationScopeCondition(scopeOrganisationIds);
|
||||
if (orgScope) conditions.push(orgScope);
|
||||
|
||||
if (query.workstreamId !== null) {
|
||||
conditions.push(sql`pw.id = ${query.workstreamId}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,16 +93,42 @@ describe("GET", () => {
|
|||
expect(mockLoadWorkOrderPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("403s a contractor: this is the admin table, and row scoping is a later issue", async () => {
|
||||
it("serves a contractor their own org's page, scoped server-side (issue #421)", async () => {
|
||||
signedInAs([CONTRACTOR_ORG]);
|
||||
projectExists();
|
||||
const res = await GET(request(), params());
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.json()).toEqual({ error: "Forbidden" });
|
||||
expect(mockLoadWorkOrderPage).not.toHaveBeenCalled();
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual(EMPTY_PAGE);
|
||||
// The fourth argument is the organisation scope: a contractor is confined
|
||||
// to their own org, so the query can only ever return their orders.
|
||||
expect(mockLoadWorkOrderPage).toHaveBeenCalledWith(
|
||||
5n,
|
||||
expect.any(Object),
|
||||
expect.any(Date),
|
||||
[CONTRACTOR_ORG],
|
||||
);
|
||||
});
|
||||
|
||||
it("serves a client-org member the unfiltered first page", async () => {
|
||||
it("never scopes a contractor to another org, even when they filter by one", async () => {
|
||||
// The critical acceptance criterion: a contractor who asks for another
|
||||
// org's orders via the client filter is still scoped to their own — the
|
||||
// scope argument is theirs regardless of what the query string says.
|
||||
signedInAs([CONTRACTOR_ORG]);
|
||||
projectExists();
|
||||
const res = await GET(
|
||||
request(`?contractorOrganisationId=${OTHER_ORG}`),
|
||||
params(),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockLoadWorkOrderPage).toHaveBeenCalledWith(
|
||||
5n,
|
||||
expect.objectContaining({ contractorOrganisationId: OTHER_ORG }),
|
||||
expect.any(Date),
|
||||
[CONTRACTOR_ORG],
|
||||
);
|
||||
});
|
||||
|
||||
it("serves a client-org member the unfiltered first page, unscoped", async () => {
|
||||
signedInAs([CLIENT_ORG]);
|
||||
projectExists();
|
||||
const res = await GET(request(), params());
|
||||
|
|
@ -117,10 +143,12 @@ describe("GET", () => {
|
|||
limit: DEFAULT_PAGE_SIZE,
|
||||
}),
|
||||
expect.any(Date),
|
||||
// null scope: a client sees the whole programme.
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("serves an internal user on an admin-access project", async () => {
|
||||
it("serves an internal user on an admin-access project, unscoped", async () => {
|
||||
signedInAs([], "someone@domna.homes");
|
||||
mockLoadProjectAuthzFacts.mockResolvedValue({
|
||||
id: 5n,
|
||||
|
|
@ -130,6 +158,12 @@ describe("GET", () => {
|
|||
});
|
||||
const res = await GET(request(), params());
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockLoadWorkOrderPage).toHaveBeenCalledWith(
|
||||
5n,
|
||||
expect.any(Object),
|
||||
expect.any(Date),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the parsed filters and cursor through to the query", async () => {
|
||||
|
|
@ -155,6 +189,7 @@ describe("GET", () => {
|
|||
limit: 25,
|
||||
},
|
||||
expect.any(Date),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,16 @@ export async function GET(req: NextRequest, props: Params) {
|
|||
// One `asOf` per request, passed to both the SQL filter and the row badges,
|
||||
// so a request that straddles midnight cannot classify a work order as
|
||||
// overdue in the WHERE clause and not-overdue in its badge.
|
||||
const page = await loadWorkOrderPage(auth.projectId, parsed.query, new Date());
|
||||
//
|
||||
// `auth.organisationScope` is the contractor scope resolved by the authz
|
||||
// layer (null for internal/client); passing it here is what confines a
|
||||
// contractor to their own org's orders (issue #421).
|
||||
const page = await loadWorkOrderPage(
|
||||
auth.projectId,
|
||||
parsed.query,
|
||||
new Date(),
|
||||
auth.organisationScope,
|
||||
);
|
||||
return NextResponse.json(page);
|
||||
} catch (err) {
|
||||
console.error("GET /api/projects/[projectId]/work-orders failed:", err);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,280 @@
|
|||
/**
|
||||
* The per-project contractor view (issue #421).
|
||||
*
|
||||
* A contractor's home on a project. Where the client/internal dashboard shows
|
||||
* the whole programme, this shows only the caller's own slice: KPI tiles that
|
||||
* count only their work orders, chips for the workstreams they are assigned to,
|
||||
* and a "What you can do" panel driven by their permission flags. It links out
|
||||
* to the scoped work-orders table (the route handler applies the same
|
||||
* organisation scope, so the table can only ever show their orders).
|
||||
*
|
||||
* A Server Component holding no rules: every number and flag arrives already
|
||||
* derived — `kpi` from `summariseDashboard` over organisation-scoped groups,
|
||||
* `capabilities` from `summariseContractorCapabilities`. Layout follows the
|
||||
* contractor wireframe (`02-contractor-a-view.html`); the impersonation banner
|
||||
* there is an admin affordance and is not part of a real contractor's screen.
|
||||
*/
|
||||
import Link from "next/link";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
ClipboardList,
|
||||
FileCheck2,
|
||||
UploadCloud,
|
||||
} from "lucide-react";
|
||||
import { formatRatio } from "@/lib/projects/dashboardSummary";
|
||||
import type { DashboardKpi } from "@/lib/projects/dashboardSummary";
|
||||
import type { ContractorCapabilities } from "@/lib/projects/authz";
|
||||
|
||||
const count = (n: number) => n.toLocaleString("en-GB");
|
||||
|
||||
export function ContractorProjectView({
|
||||
projectId,
|
||||
projectName,
|
||||
kpi,
|
||||
capabilities,
|
||||
}: {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
kpi: DashboardKpi;
|
||||
capabilities: ContractorCapabilities;
|
||||
}) {
|
||||
const workOrdersHref = `/projects/${projectId}/work-orders`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="max-w-6xl mx-auto px-6 py-10 space-y-10"
|
||||
data-testid="contractor-project-view"
|
||||
>
|
||||
<header>
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">
|
||||
Ara Projects · Assigned work
|
||||
</p>
|
||||
<h1
|
||||
className="text-3xl font-extrabold text-gray-900 tracking-tight"
|
||||
data-testid="contractor-project-heading"
|
||||
>
|
||||
{projectName}
|
||||
</h1>
|
||||
<AssignedWorkstreams workstreams={capabilities.assignedWorkstreams} />
|
||||
</header>
|
||||
|
||||
<KpiTiles kpi={kpi} workOrdersHref={workOrdersHref} />
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2">
|
||||
<WhatYouCanDo capabilities={capabilities} />
|
||||
</div>
|
||||
<Link
|
||||
href={workOrdersHref}
|
||||
data-testid="contractor-view-work-orders"
|
||||
className="flex flex-col justify-between rounded-xl border border-gray-200 bg-white px-5 py-5 transition-colors hover:border-gray-300 hover:bg-gray-50"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900">Your work orders</p>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Open the work orders assigned to your organisation.
|
||||
</p>
|
||||
</div>
|
||||
<span className="mt-4 inline-flex items-center gap-1 text-sm font-medium text-blue-600">
|
||||
View all {count(kpi.totalOrders)} orders
|
||||
<ArrowRight className="h-4 w-4" aria-hidden />
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="flex items-center gap-2 text-xs text-gray-400"
|
||||
data-testid="contractor-scope-note"
|
||||
>
|
||||
You see work orders assigned to your organisation. The client and Domna
|
||||
admins manage the wider programme.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The sub-line of assigned-workstream chips (Windows, Roofs, Doors, …). */
|
||||
function AssignedWorkstreams({ workstreams }: { workstreams: string[] }) {
|
||||
if (workstreams.length === 0) {
|
||||
return (
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
You are not assigned to any workstreams on this project yet.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mt-3 flex flex-wrap items-center gap-2"
|
||||
data-testid="contractor-assigned-workstreams"
|
||||
>
|
||||
<span className="text-xs font-medium text-gray-500">
|
||||
Assigned workstreams:
|
||||
</span>
|
||||
{workstreams.map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
data-testid="contractor-workstream-chip"
|
||||
className="inline-flex rounded-full border border-blue-200 bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Three tiles: assigned orders, overdue, required-docs complete. */
|
||||
function KpiTiles({
|
||||
kpi,
|
||||
workOrdersHref,
|
||||
}: {
|
||||
kpi: DashboardKpi;
|
||||
workOrdersHref: string;
|
||||
}) {
|
||||
return (
|
||||
<section aria-label="Your work at a glance" data-testid="contractor-kpi-band">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<Tile
|
||||
testId="contractor-kpi-assigned"
|
||||
icon={<ClipboardList className="h-4 w-4" aria-hidden />}
|
||||
label="Assigned work orders"
|
||||
value={count(kpi.totalOrders)}
|
||||
/>
|
||||
<Link href={workOrdersHref} className="block">
|
||||
<Tile
|
||||
testId="contractor-kpi-overdue"
|
||||
icon={<AlertTriangle className="h-4 w-4" aria-hidden />}
|
||||
label="Overdue"
|
||||
value={count(kpi.overdue)}
|
||||
tone={kpi.overdue > 0}
|
||||
interactive
|
||||
/>
|
||||
</Link>
|
||||
<Tile
|
||||
testId="contractor-kpi-docs"
|
||||
icon={<FileCheck2 className="h-4 w-4" aria-hidden />}
|
||||
label="Docs complete"
|
||||
value={formatRatio(kpi.evidence.ratio)}
|
||||
hint={
|
||||
kpi.evidence.ratio === null
|
||||
? "No required documents"
|
||||
: `${count(kpi.evidence.satisfied)} of ${count(kpi.evidence.required)}`
|
||||
}
|
||||
progress={kpi.evidence.ratio}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Tile({
|
||||
testId,
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
tone,
|
||||
interactive,
|
||||
progress,
|
||||
}: {
|
||||
testId: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
tone?: boolean;
|
||||
interactive?: boolean;
|
||||
progress?: number | null;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-testid={testId}
|
||||
className={`rounded-xl border bg-white px-4 py-4 ${
|
||||
tone ? "border-red-200" : "border-gray-200"
|
||||
} ${interactive ? "transition-colors hover:bg-gray-50" : ""}`}
|
||||
>
|
||||
<p className="mb-1 flex items-center gap-1.5 text-xs font-medium text-gray-500">
|
||||
<span className={tone ? "text-red-500" : "text-gray-400"}>{icon}</span>
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
className={`text-2xl font-extrabold tracking-tight tabular-nums ${
|
||||
tone ? "text-red-600" : "text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
{hint ? <p className="mt-0.5 text-xs text-gray-400">{hint}</p> : null}
|
||||
{progress != null ? (
|
||||
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-gray-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-emerald-500"
|
||||
style={{ width: `${Math.round(progress * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The permission-driven panel. Each action is offered only when at least one of
|
||||
* the contractor's assignments grants the corresponding flag — the summary of
|
||||
* per-assignment permissions the schema stores.
|
||||
*/
|
||||
function WhatYouCanDo({ capabilities }: { capabilities: ContractorCapabilities }) {
|
||||
const actions = [
|
||||
{
|
||||
allowed: capabilities.canUpdateStages,
|
||||
icon: <CheckCircle2 className="h-4 w-4" aria-hidden />,
|
||||
text: "Update the stage on your work orders.",
|
||||
},
|
||||
{
|
||||
allowed: capabilities.canUploadDocuments,
|
||||
icon: <UploadCloud className="h-4 w-4" aria-hidden />,
|
||||
text: "Upload evidence against required documents.",
|
||||
},
|
||||
].filter((action) => action.allowed);
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="What you can do"
|
||||
data-testid="contractor-permissions-panel"
|
||||
className="rounded-xl border border-gray-200 bg-white px-5 py-5"
|
||||
>
|
||||
<h2 className="text-sm font-semibold text-gray-900">What you can do</h2>
|
||||
|
||||
{actions.length > 0 ? (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{actions.map((action) => (
|
||||
<li
|
||||
key={action.text}
|
||||
data-testid="contractor-permission-allowed"
|
||||
className="flex items-center gap-2 text-sm text-gray-700"
|
||||
>
|
||||
<span className="text-emerald-500">{action.icon}</span>
|
||||
{action.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p
|
||||
className="mt-3 text-sm text-gray-500"
|
||||
data-testid="contractor-permission-none"
|
||||
>
|
||||
Your assignments are read-only. You can view your work orders but not
|
||||
change them.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-4 flex items-start gap-2 border-t border-gray-100 pt-3 text-xs text-gray-500">
|
||||
<Ban className="mt-0.5 h-3.5 w-3.5 shrink-0 text-gray-400" aria-hidden />
|
||||
You cannot change costs or omit orders — contact a client or Domna admin.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,13 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { requireProjectAccess } from "../guards";
|
||||
import { loadDashboardData } from "@/app/repositories/projects/dashboardRepository";
|
||||
import { loadContractorProjectView } from "@/app/repositories/projects/contractorProjectRepository";
|
||||
import { buildStageLadders } from "@/lib/projects/derivations";
|
||||
import { summariseDashboard } from "@/lib/projects/dashboardSummary";
|
||||
import {
|
||||
resolveProjectRole,
|
||||
workOrderOrganisationScope,
|
||||
} from "@/lib/projects/authz";
|
||||
import {
|
||||
ContractorsTable,
|
||||
KpiBand,
|
||||
|
|
@ -10,6 +15,7 @@ import {
|
|||
RecentWorkOrders,
|
||||
WorkstreamsTable,
|
||||
} from "./components/DashboardBands";
|
||||
import { ContractorProjectView } from "./components/ContractorProjectView";
|
||||
|
||||
export const metadata = {
|
||||
title: "Project dashboard | Ara",
|
||||
|
|
@ -44,9 +50,27 @@ export default async function ProjectDashboardPage(props: {
|
|||
params: Promise<{ projectId: string }>;
|
||||
}) {
|
||||
const { projectId } = await props.params;
|
||||
const { project } = await requireProjectAccess(projectId);
|
||||
const { user, project } = await requireProjectAccess(projectId);
|
||||
|
||||
const asOf = new Date();
|
||||
|
||||
// A contractor sees their own scoped view, never the programme dashboard —
|
||||
// its KPIs and tables aggregate every org's work orders (issue #421). Client
|
||||
// and internal roles fall through to the full dashboard below, unaffected.
|
||||
if (resolveProjectRole(user, project) === "contractor") {
|
||||
const scope = workOrderOrganisationScope(user, project) ?? [];
|
||||
const view = await loadContractorProjectView(project.id, scope, asOf);
|
||||
if (!view) notFound();
|
||||
return (
|
||||
<ContractorProjectView
|
||||
projectId={projectId}
|
||||
projectName={view.projectName}
|
||||
kpi={view.kpi}
|
||||
capabilities={view.capabilities}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const data = await loadDashboardData(project.id, asOf, RECENT_LIMIT);
|
||||
if (!data) notFound();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import Link from "next/link";
|
||||
import { requireProjectAccess } from "../../guards";
|
||||
import { canManageProject } from "@/lib/projects/authz";
|
||||
import {
|
||||
canManageProject,
|
||||
workOrderOrganisationScope,
|
||||
} from "@/lib/projects/authz";
|
||||
import { DEFAULT_QUERY } from "@/app/api/projects/[projectId]/work-orders/filters";
|
||||
import {
|
||||
loadFilterOptions,
|
||||
|
|
@ -13,7 +16,7 @@ export const metadata = {
|
|||
};
|
||||
|
||||
/**
|
||||
* The admin work-orders table (issue #419).
|
||||
* The work-orders table (issues #419, #421).
|
||||
*
|
||||
* A Server Component that renders the *first* page — filter choices and the
|
||||
* unfiltered first 50 rows — and hands them to the client table as seed data.
|
||||
|
|
@ -23,16 +26,18 @@ export const metadata = {
|
|||
* produced by identical code rather than by two implementations that have to be
|
||||
* kept in step.
|
||||
*
|
||||
* Authorization: `requireProjectAccess` (#409) settles visibility, and
|
||||
* `canManageProject` settles *this* table. It is the admin view — internal and
|
||||
* client-org, per the issue. A contractor can see the project, so a 404 would
|
||||
* be a lie; they get a note instead, and their own scoped view lands in a later
|
||||
* issue. The route handler enforces the same rule independently: this page is
|
||||
* not the security boundary.
|
||||
* Authorization: `requireProjectAccess` (#409) settles visibility. Both the
|
||||
* admin and the contractor land on the *same* table component; what differs is
|
||||
* the **organisation scope** from `workOrderOrganisationScope` — null for
|
||||
* internal/client (the whole programme), a contractor's own orgs otherwise.
|
||||
* That scope is passed to the initial load *and* enforced independently by the
|
||||
* route handler on every refetch, so this page is not the security boundary —
|
||||
* the scope cannot be widened from the client.
|
||||
*
|
||||
* The wireframe's Omit / Export controls are out of scope (#426). Import links
|
||||
* to the import flow, which is where work orders come from (#417) — until it
|
||||
* runs, this table renders its empty state.
|
||||
* runs, this table renders its empty state. Import is an admin-only affordance,
|
||||
* so a contractor does not see the link.
|
||||
*/
|
||||
export default async function WorkOrdersPage(props: {
|
||||
params: Promise<{ projectId: string }>;
|
||||
|
|
@ -40,28 +45,18 @@ export default async function WorkOrdersPage(props: {
|
|||
const { projectId } = await props.params;
|
||||
const { user, project } = await requireProjectAccess(projectId);
|
||||
|
||||
if (!canManageProject(user, project)) {
|
||||
return (
|
||||
<Shell projectId={projectId} showImport={false}>
|
||||
<p
|
||||
className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-8 text-center text-sm text-gray-500"
|
||||
data-testid="work-orders-contractor-notice"
|
||||
>
|
||||
Work orders issued to your organisation will appear here. The
|
||||
contractor view is still being built.
|
||||
</p>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
// null (admin) sees the whole programme; a contractor sees only their orgs.
|
||||
const scope = workOrderOrganisationScope(user, project);
|
||||
const isAdmin = canManageProject(user, project);
|
||||
|
||||
const asOf = new Date();
|
||||
const [options, initialPage] = await Promise.all([
|
||||
loadFilterOptions(project.id),
|
||||
loadWorkOrderPage(project.id, DEFAULT_QUERY, asOf),
|
||||
loadFilterOptions(project.id, scope),
|
||||
loadWorkOrderPage(project.id, DEFAULT_QUERY, asOf, scope),
|
||||
]);
|
||||
|
||||
return (
|
||||
<Shell projectId={projectId} showImport>
|
||||
<Shell projectId={projectId} showImport={isAdmin}>
|
||||
<WorkOrdersView
|
||||
projectId={projectId}
|
||||
options={options}
|
||||
|
|
|
|||
130
src/app/repositories/projects/contractorProjectRepository.ts
Normal file
130
src/app/repositories/projects/contractorProjectRepository.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* Ara Projects — the per-project contractor view's data (issue #421).
|
||||
*
|
||||
* A contractor landing on `/projects/[projectId]` does not see the programme
|
||||
* dashboard; they see their *own* slice of it — KPI tiles counting only their
|
||||
* work orders, the workstreams they are assigned to, and what they may do. This
|
||||
* module composes that slice from the existing dashboard persistence, applying
|
||||
* the contractor's organisation scope so nothing here can count another org's
|
||||
* work.
|
||||
*
|
||||
* Same boundary as its siblings: Drizzle and SQL live here, the rules live in
|
||||
* `@/lib/projects/*`. The KPI fold reuses `summariseDashboard` over
|
||||
* organisation-scoped groups, so a contractor's "assigned orders / overdue /
|
||||
* docs complete" tiles are derived by the very same code as the admin
|
||||
* dashboard's — they cannot drift.
|
||||
*/
|
||||
import { db } from "@/app/db/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
projectWorkstream,
|
||||
projectWorkstreamContractor,
|
||||
workstream,
|
||||
} from "@/app/db/schema/projects/projects";
|
||||
import { buildStageLadders } from "@/lib/projects/derivations";
|
||||
import {
|
||||
summariseDashboard,
|
||||
type DashboardKpi,
|
||||
} from "@/lib/projects/dashboardSummary";
|
||||
import {
|
||||
summariseContractorCapabilities,
|
||||
type ContractorAssignmentFacts,
|
||||
type ContractorCapabilities,
|
||||
} from "@/lib/projects/authz";
|
||||
import {
|
||||
buildApplicableEvidencePairs,
|
||||
loadDashboardProject,
|
||||
loadEvidenceRequirements,
|
||||
loadStageLadderEntries,
|
||||
loadWorkOrderGroups,
|
||||
loadWorkstreams,
|
||||
} from "./dashboardRepository";
|
||||
|
||||
/** Everything the contractor per-project view renders, in one load. */
|
||||
export interface ContractorProjectView {
|
||||
projectName: string;
|
||||
/** KPI tiles: assigned orders (`totalOrders`), overdue, required-docs `evidence`. */
|
||||
kpi: DashboardKpi;
|
||||
/** Chips and the "What you can do" panel. */
|
||||
capabilities: ContractorCapabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* A contractor's assignments on a project, narrowed to their own organisations
|
||||
* — the raw rows behind the chips and the permission panel. An empty
|
||||
* organisation set (a caller with no standing) loads nothing rather than the
|
||||
* whole project's assignments.
|
||||
*/
|
||||
export async function loadContractorAssignments(
|
||||
projectId: bigint,
|
||||
organisationIds: readonly string[],
|
||||
): Promise<ContractorAssignmentFacts[]> {
|
||||
if (organisationIds.length === 0) return [];
|
||||
|
||||
return db
|
||||
.select({
|
||||
projectWorkstreamId: projectWorkstreamContractor.projectWorkstreamId,
|
||||
workstreamName: workstream.name,
|
||||
updateStagesPermission: projectWorkstreamContractor.updateStagesPermission,
|
||||
uploadDocumentsPermission:
|
||||
projectWorkstreamContractor.uploadDocumentsPermission,
|
||||
})
|
||||
.from(projectWorkstreamContractor)
|
||||
.innerJoin(
|
||||
projectWorkstream,
|
||||
eq(projectWorkstreamContractor.projectWorkstreamId, projectWorkstream.id),
|
||||
)
|
||||
.innerJoin(workstream, eq(projectWorkstream.workstreamId, workstream.id))
|
||||
.where(
|
||||
and(
|
||||
eq(projectWorkstream.projectId, projectId),
|
||||
inArray(
|
||||
projectWorkstreamContractor.organisationId,
|
||||
organisationIds as string[],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the contractor per-project view, scoped to `organisationIds` (the
|
||||
* caller's own orgs, from `workOrderOrganisationScope`). The stage ladders and
|
||||
* evidence configuration are read first because the scoped work-order aggregate
|
||||
* counts against the applicable-evidence pairs derived from them — the same
|
||||
* sequencing `loadDashboardData` uses. Returns null when the project is absent.
|
||||
*/
|
||||
export async function loadContractorProjectView(
|
||||
projectId: bigint,
|
||||
organisationIds: readonly string[],
|
||||
asOf: Date,
|
||||
): Promise<ContractorProjectView | null> {
|
||||
const [projectRow, stages, requirements, workstreams, assignments] =
|
||||
await Promise.all([
|
||||
loadDashboardProject(projectId),
|
||||
loadStageLadderEntries(projectId),
|
||||
loadEvidenceRequirements(projectId),
|
||||
loadWorkstreams(projectId),
|
||||
loadContractorAssignments(projectId, organisationIds),
|
||||
]);
|
||||
if (!projectRow) return null;
|
||||
|
||||
const ladders = buildStageLadders(stages);
|
||||
const groups = await loadWorkOrderGroups(
|
||||
projectId,
|
||||
asOf,
|
||||
buildApplicableEvidencePairs(ladders, stages, requirements),
|
||||
organisationIds,
|
||||
);
|
||||
|
||||
const summary = summariseDashboard(
|
||||
ladders,
|
||||
groups,
|
||||
new Map(workstreams.map((w) => [w.id, w.name])),
|
||||
);
|
||||
|
||||
return {
|
||||
projectName: projectRow.name,
|
||||
kpi: summary.kpi,
|
||||
capabilities: summariseContractorCapabilities(assignments),
|
||||
};
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ vi.mock("@/app/db/db", () => ({
|
|||
import {
|
||||
buildApplicableEvidencePairs,
|
||||
buildWorkOrderGroupsQuery,
|
||||
organisationScopeCondition,
|
||||
overdueOf,
|
||||
parseProjectId,
|
||||
type WorkOrderGroup,
|
||||
|
|
@ -90,6 +91,53 @@ describe("buildWorkOrderGroupsQuery", () => {
|
|||
it("rejects an invalid asOf rather than issuing a query with a null date", () => {
|
||||
expect(() => buildWorkOrderGroupsQuery(1n, new Date("nope"), [])).toThrow();
|
||||
});
|
||||
|
||||
describe("contractor organisation scope (issue #421)", () => {
|
||||
const CONTRACTOR = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
it("adds no scope predicate when unscoped (internal/client KPIs)", () => {
|
||||
const { sql } = render(buildWorkOrderGroupsQuery(1n, ASOF, [], null));
|
||||
expect(sql).not.toContain("pwc.organisation_id IN");
|
||||
});
|
||||
|
||||
it("narrows the aggregate to a contractor's own organisations", () => {
|
||||
const { sql, params } = render(
|
||||
buildWorkOrderGroupsQuery(1n, ASOF, [], [CONTRACTOR]),
|
||||
);
|
||||
expect(sql).toContain("pwc.organisation_id IN (");
|
||||
expect(params).toContain(CONTRACTOR);
|
||||
});
|
||||
|
||||
it("counts nobody rather than everybody for an empty scope", () => {
|
||||
const { sql } = render(buildWorkOrderGroupsQuery(1n, ASOF, [], []));
|
||||
// Belt and braces: `... AND false` returns zero rows, so a contractor
|
||||
// resolved to no org sees empty KPIs, never the whole programme's.
|
||||
expect(sql).toContain("false");
|
||||
expect(sql).not.toContain("pwc.organisation_id IN");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("organisationScopeCondition", () => {
|
||||
const CONTRACTOR = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
it("returns null when unscoped, so no predicate is added", () => {
|
||||
expect(organisationScopeCondition(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns an IN predicate binding each org id for a non-empty scope", () => {
|
||||
const condition = organisationScopeCondition([CONTRACTOR]);
|
||||
const { sql, params } = dialect.sqlToQuery(condition!);
|
||||
expect(sql).toContain("pwc.organisation_id IN (");
|
||||
expect(sql).toContain("::uuid");
|
||||
expect(params).toContain(CONTRACTOR);
|
||||
});
|
||||
|
||||
it("returns a literal false for an empty scope — nobody, not everybody", () => {
|
||||
const condition = organisationScopeCondition([]);
|
||||
const { sql } = dialect.sqlToQuery(condition!);
|
||||
expect(sql).toContain("false");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildApplicableEvidencePairs", () => {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
* rows — a few hundred at programme scale, independent of the work-order count.
|
||||
*/
|
||||
import { db } from "@/app/db/db";
|
||||
import { desc, eq, sql } from "drizzle-orm";
|
||||
import { desc, eq, sql, type SQL } from "drizzle-orm";
|
||||
import {
|
||||
project,
|
||||
projectWorkstream,
|
||||
|
|
@ -219,9 +219,15 @@ export async function loadWorkOrderGroups(
|
|||
projectId: bigint,
|
||||
asOf: Date,
|
||||
applicableEvidence: readonly ApplicableEvidencePair[],
|
||||
scopeOrganisationIds: readonly string[] | null = null,
|
||||
): Promise<WorkOrderGroup[]> {
|
||||
const rows = await db.execute<WorkOrderGroupRow>(
|
||||
buildWorkOrderGroupsQuery(projectId, asOf, applicableEvidence),
|
||||
buildWorkOrderGroupsQuery(
|
||||
projectId,
|
||||
asOf,
|
||||
applicableEvidence,
|
||||
scopeOrganisationIds,
|
||||
),
|
||||
);
|
||||
|
||||
return rows.rows.map((r) => ({
|
||||
|
|
@ -258,10 +264,16 @@ export function buildWorkOrderGroupsQuery(
|
|||
projectId: bigint,
|
||||
asOf: Date,
|
||||
applicableEvidence: readonly ApplicableEvidencePair[],
|
||||
scopeOrganisationIds: readonly string[] | null = null,
|
||||
) {
|
||||
const asOfDay = toCalendarDay(asOf);
|
||||
if (asOfDay === null) throw new Error("loadWorkOrderGroups: invalid asOf date");
|
||||
|
||||
// Contractor scope (issue #421): narrow the aggregate to the caller's own
|
||||
// organisations so a contractor's KPI tiles count only their own work orders.
|
||||
// Null for internal/client, who see the whole programme.
|
||||
const scope = organisationScopeCondition(scopeOrganisationIds);
|
||||
|
||||
// Ids are interpolated as bigint literals, never as user text — they come
|
||||
// from rows this module just read, and each is re-checked below.
|
||||
const pairsSql = applicableEvidence.length
|
||||
|
|
@ -316,7 +328,7 @@ export function buildWorkOrderGroupsQuery(
|
|||
WHERE a.stage_id = wo.project_workstream_stage_id
|
||||
AND a.requirement_id = uf.project_workstream_evidence_requirement_id
|
||||
)
|
||||
WHERE pw.project_id = ${projectId}
|
||||
WHERE pw.project_id = ${projectId}${scope ? sql` AND ${scope}` : sql``}
|
||||
GROUP BY wo.id, wo.forecast_end, wo.project_workstream_stage_id,
|
||||
pws.project_workstream_id, pwc.organisation_id, org.name
|
||||
)
|
||||
|
|
@ -436,6 +448,37 @@ function asBigIntLiteral(value: bigint): string {
|
|||
return value.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* The contractor scope predicate for a work-order query, over the `pwc`
|
||||
* (`project_workstream_contractor`) alias every Ara Projects work-order query
|
||||
* uses (issue #421).
|
||||
*
|
||||
* `scope` is the value `workOrderOrganisationScope` (`@/lib/projects/authz`)
|
||||
* resolved:
|
||||
*
|
||||
* - **null** — no scope. Internal and client callers see the whole
|
||||
* programme, so no predicate is added and this returns null.
|
||||
* - **a non-empty array** — a contractor's own organisations. The rows are
|
||||
* narrowed to `pwc.organisation_id IN (…)`, and each id is bound as a
|
||||
* parameter (never interpolated as text), cast to uuid to match the column.
|
||||
* - **an empty array** — "scoped to nobody". Emitted as a literal `false` so
|
||||
* the query returns no rows rather than, wrongly, all of them.
|
||||
*
|
||||
* This is the one definition of the SQL scope: the work-orders route handler
|
||||
* and the contractor KPI aggregate both apply it, so neither can drift from the
|
||||
* domain rule the array came from.
|
||||
*/
|
||||
export function organisationScopeCondition(
|
||||
scope: readonly string[] | null,
|
||||
): SQL | null {
|
||||
if (scope === null) return null;
|
||||
if (scope.length === 0) return sql`false`;
|
||||
return sql`pwc.organisation_id IN (${sql.join(
|
||||
scope.map((id) => sql`${id}::uuid`),
|
||||
sql`, `,
|
||||
)})`;
|
||||
}
|
||||
|
||||
/** Parse a `[projectId]` route segment, or null when it is not a positive id. */
|
||||
export function parseProjectId(raw: string): bigint | null {
|
||||
if (!/^\d+$/.test(raw)) return null;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ import {
|
|||
canViewProject,
|
||||
isInternalEmail,
|
||||
resolveProjectRole,
|
||||
summariseContractorCapabilities,
|
||||
workOrderOrganisationScope,
|
||||
type AuthzUser,
|
||||
type ContractorAssignmentFacts,
|
||||
type ProjectAuthzFacts,
|
||||
type WorkOrderAuthzFacts,
|
||||
} from "./authz";
|
||||
|
|
@ -268,3 +271,96 @@ describe("canUploadEvidence", () => {
|
|||
expect(canUploadEvidence(contractor, project, uploadOnly)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workOrderOrganisationScope", () => {
|
||||
const project = makeProject();
|
||||
|
||||
it("leaves internal and client unscoped (null), so they see the whole programme", () => {
|
||||
expect(workOrderOrganisationScope(internal, project)).toBeNull();
|
||||
expect(workOrderOrganisationScope(client, project)).toBeNull();
|
||||
});
|
||||
|
||||
it("scopes a contractor to their own assigned organisation", () => {
|
||||
expect(workOrderOrganisationScope(contractor, project)).toEqual([
|
||||
CONTRACTOR_ORG,
|
||||
]);
|
||||
});
|
||||
|
||||
it("never includes another contractor org, even one also on the project", () => {
|
||||
// The project has both contractor orgs assigned; this user belongs only to
|
||||
// one, and the scope must be that one alone — the core acceptance criterion.
|
||||
const scope = workOrderOrganisationScope(contractor, project);
|
||||
expect(scope).toEqual([CONTRACTOR_ORG]);
|
||||
expect(scope).not.toContain(OTHER_CONTRACTOR_ORG);
|
||||
});
|
||||
|
||||
it("returns every assigned org a multi-org contractor user belongs to", () => {
|
||||
const both = makeUser({
|
||||
organisationIds: [CONTRACTOR_ORG, OTHER_CONTRACTOR_ORG],
|
||||
});
|
||||
expect(workOrderOrganisationScope(both, project)?.sort()).toEqual(
|
||||
[CONTRACTOR_ORG, OTHER_CONTRACTOR_ORG].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("scopes a user with no role to nobody (empty), never null", () => {
|
||||
expect(workOrderOrganisationScope(stranger, project)).toEqual([]);
|
||||
});
|
||||
|
||||
it("ranks client above contractor — an owner-and-contractor org is unscoped", () => {
|
||||
const owned = makeProject({ contractorOrganisationIds: [CLIENT_ORG] });
|
||||
expect(workOrderOrganisationScope(client, owned)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("summariseContractorCapabilities", () => {
|
||||
const assignment = (
|
||||
overrides: Partial<ContractorAssignmentFacts> = {},
|
||||
): ContractorAssignmentFacts => ({
|
||||
projectWorkstreamId: 1n,
|
||||
workstreamName: "Windows",
|
||||
updateStagesPermission: false,
|
||||
uploadDocumentsPermission: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("lists the assigned workstreams as sorted, de-duplicated chips", () => {
|
||||
const caps = summariseContractorCapabilities([
|
||||
assignment({ projectWorkstreamId: 1n, workstreamName: "Roofs" }),
|
||||
assignment({ projectWorkstreamId: 2n, workstreamName: "Doors" }),
|
||||
// A second assignment on Roofs must not produce a second chip.
|
||||
assignment({ projectWorkstreamId: 3n, workstreamName: "Roofs" }),
|
||||
]);
|
||||
expect(caps.assignedWorkstreams).toEqual(["Doors", "Roofs"]);
|
||||
});
|
||||
|
||||
it("offers update-stages when any assignment grants it", () => {
|
||||
const caps = summariseContractorCapabilities([
|
||||
assignment({ workstreamName: "Windows", updateStagesPermission: false }),
|
||||
assignment({ workstreamName: "Roofs", updateStagesPermission: true }),
|
||||
]);
|
||||
expect(caps.canUpdateStages).toBe(true);
|
||||
});
|
||||
|
||||
it("offers upload-documents when any assignment grants it", () => {
|
||||
const caps = summariseContractorCapabilities([
|
||||
assignment({ uploadDocumentsPermission: true }),
|
||||
]);
|
||||
expect(caps.canUploadDocuments).toBe(true);
|
||||
});
|
||||
|
||||
it("withholds both actions when no assignment grants them", () => {
|
||||
const caps = summariseContractorCapabilities([assignment(), assignment()]);
|
||||
expect(caps.canUpdateStages).toBe(false);
|
||||
expect(caps.canUploadDocuments).toBe(false);
|
||||
});
|
||||
|
||||
it("summarises an empty assignment set to no chips and no actions", () => {
|
||||
const caps = summariseContractorCapabilities([]);
|
||||
expect(caps).toEqual({
|
||||
assignedWorkstreams: [],
|
||||
canUpdateStages: false,
|
||||
canUploadDocuments: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -176,3 +176,88 @@ function allowsWorkOrderAction(
|
|||
permissionFlag
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The organisation scope a work-order listing must be narrowed to for this user
|
||||
* — the single definition of "whose orders may they see" (issue #421).
|
||||
*
|
||||
* - `internal` / `client` → **null**, meaning *no scope*: they see the whole
|
||||
* programme. Null is deliberately distinct from an empty array, which would
|
||||
* mean "scoped to nobody".
|
||||
* - `contractor` → the caller's **own** assigned organisations on this project
|
||||
* (the intersection of their memberships and the project's contractor orgs).
|
||||
* Never another contractor's org, even one also assigned to the project. This
|
||||
* is what the route handler pushes into SQL so the acceptance criterion — a
|
||||
* contractor can never retrieve another org's orders — holds at the data
|
||||
* layer, not merely in the UI.
|
||||
* - no role → an empty array. A caller with no standing is turned away before a
|
||||
* query in practice, but "scoped to nobody" is the safe answer if one is not.
|
||||
*
|
||||
* A non-null result is always the set the caller belongs to, so it is safe to
|
||||
* trust as an `IN (…)` filter without re-checking membership downstream.
|
||||
*/
|
||||
export function workOrderOrganisationScope(
|
||||
user: AuthzUser,
|
||||
project: ProjectAuthzFacts,
|
||||
): string[] | null {
|
||||
const role = resolveProjectRole(user, project);
|
||||
if (role === "internal" || role === "client") return null;
|
||||
if (role !== "contractor") return [];
|
||||
|
||||
const memberships = new Set(user.organisationIds);
|
||||
return project.contractorOrganisationIds.filter((orgId) =>
|
||||
memberships.has(orgId),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One of a contractor's assignments on a project, reduced to what the
|
||||
* per-project contractor view needs: the workstream it covers and the two
|
||||
* per-assignment permission flags. Structurally a
|
||||
* `project_workstream_contractor` row joined to its workstream's name.
|
||||
*/
|
||||
export interface ContractorAssignmentFacts {
|
||||
projectWorkstreamId: bigint;
|
||||
workstreamName: string;
|
||||
updateStagesPermission: boolean;
|
||||
uploadDocumentsPermission: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a contractor may do on a project, folded from their assignments — the
|
||||
* data behind the assigned-workstream chips and the "What you can do" panel.
|
||||
*
|
||||
* The permission flags are per assignment (a contractor may update stages on
|
||||
* Windows but not on Roofs), so the panel's two actions are offered when *any*
|
||||
* assignment grants them; the panel is a summary, and the per-work-order guards
|
||||
* (`canUpdateStage`, `canUploadEvidence`) still decide each individual action.
|
||||
*/
|
||||
export interface ContractorCapabilities {
|
||||
/** Distinct workstream names the contractor is assigned to, sorted — the chips. */
|
||||
assignedWorkstreams: string[];
|
||||
/** True when at least one assignment carries `update_stages_permission`. */
|
||||
canUpdateStages: boolean;
|
||||
/** True when at least one assignment carries `upload_documents_permission`. */
|
||||
canUploadDocuments: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a contractor's assignments into their project capabilities. Pure, so the
|
||||
* chips and the "What you can do" panel are unit-testable without a database.
|
||||
*
|
||||
* Workstream names are de-duplicated (a contractor holding two assignments on
|
||||
* one workstream is one chip) and sorted for a stable render.
|
||||
*/
|
||||
export function summariseContractorCapabilities(
|
||||
assignments: readonly ContractorAssignmentFacts[],
|
||||
): ContractorCapabilities {
|
||||
const workstreams = [
|
||||
...new Set(assignments.map((a) => a.workstreamName)),
|
||||
].sort((a, b) => a.localeCompare(b));
|
||||
|
||||
return {
|
||||
assignedWorkstreams: workstreams,
|
||||
canUpdateStages: assignments.some((a) => a.updateStagesPermission),
|
||||
canUploadDocuments: assignments.some((a) => a.uploadDocumentsPermission),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue