diff --git a/cypress/e2e/projects/contractor-scoped-list.cy.js b/cypress/e2e/projects/contractor-scoped-list.cy.js new file mode 100644 index 00000000..a2cb9910 --- /dev/null +++ b/cypress/e2e/projects/contractor-scoped-list.cy.js @@ -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/ 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); + } + }); + }); +}); diff --git a/docs/adr/0023-work-orders-endpoint-is-one-org-scoped-read.md b/docs/adr/0023-work-orders-endpoint-is-one-org-scoped-read.md new file mode 100644 index 00000000..eb92da01 --- /dev/null +++ b/docs/adr/0023-work-orders-endpoint-is-one-org-scoped-read.md @@ -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. diff --git a/src/app/api/projects/[projectId]/work-orders/authorize.ts b/src/app/api/projects/[projectId]/work-orders/authorize.ts index 29f5ead4..68d58dbf 100644 --- a/src/app/api/projects/[projectId]/work-orders/authorize.ts +++ b/src/app/api/projects/[projectId]/work-orders/authorize.ts @@ -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 { @@ -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), + }; } diff --git a/src/app/api/projects/[projectId]/work-orders/queries.test.ts b/src/app/api/projects/[projectId]/work-orders/queries.test.ts index eb258530..9e457825 100644 --- a/src/app/api/projects/[projectId]/work-orders/queries.test.ts +++ b/src/app/api/projects/[projectId]/work-orders/queries.test.ts @@ -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 = {}, + ) => 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); + }); }); diff --git a/src/app/api/projects/[projectId]/work-orders/queries.ts b/src/app/api/projects/[projectId]/work-orders/queries.ts index bf8eacd6..cd1249b0 100644 --- a/src/app/api/projects/[projectId]/work-orders/queries.ts +++ b/src/app/api/projects/[projectId]/work-orders/queries.ts @@ -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 { 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 { + // 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}`); } diff --git a/src/app/api/projects/[projectId]/work-orders/route.test.ts b/src/app/api/projects/[projectId]/work-orders/route.test.ts index ebb7f19c..c1e20b78 100644 --- a/src/app/api/projects/[projectId]/work-orders/route.test.ts +++ b/src/app/api/projects/[projectId]/work-orders/route.test.ts @@ -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, ); }); diff --git a/src/app/api/projects/[projectId]/work-orders/route.ts b/src/app/api/projects/[projectId]/work-orders/route.ts index b4cdec61..e63ce822 100644 --- a/src/app/api/projects/[projectId]/work-orders/route.ts +++ b/src/app/api/projects/[projectId]/work-orders/route.ts @@ -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); diff --git a/src/app/projects/[projectId]/components/ContractorProjectView.tsx b/src/app/projects/[projectId]/components/ContractorProjectView.tsx new file mode 100644 index 00000000..396eb5b3 --- /dev/null +++ b/src/app/projects/[projectId]/components/ContractorProjectView.tsx @@ -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 ( +
+
+

+ Ara Projects · Assigned work +

+

+ {projectName} +

+ +
+ + + +
+
+ +
+ +
+

Your work orders

+

+ Open the work orders assigned to your organisation. +

+
+ + View all {count(kpi.totalOrders)} orders + + + +
+ +

+ You see work orders assigned to your organisation. The client and Domna + admins manage the wider programme. +

+
+ ); +} + +/** The sub-line of assigned-workstream chips (Windows, Roofs, Doors, …). */ +function AssignedWorkstreams({ workstreams }: { workstreams: string[] }) { + if (workstreams.length === 0) { + return ( +

+ You are not assigned to any workstreams on this project yet. +

+ ); + } + + return ( +
+ + Assigned workstreams: + + {workstreams.map((name) => ( + + {name} + + ))} +
+ ); +} + +/** Three tiles: assigned orders, overdue, required-docs complete. */ +function KpiTiles({ + kpi, + workOrdersHref, +}: { + kpi: DashboardKpi; + workOrdersHref: string; +}) { + return ( +
+
+ } + label="Assigned work orders" + value={count(kpi.totalOrders)} + /> + + } + label="Overdue" + value={count(kpi.overdue)} + tone={kpi.overdue > 0} + interactive + /> + + } + 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} + /> +
+
+ ); +} + +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 ( +
+

+ {icon} + {label} +

+

+ {value} +

+ {hint ?

{hint}

: null} + {progress != null ? ( +
+
+
+ ) : null} +
+ ); +} + +/** + * 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: , + text: "Update the stage on your work orders.", + }, + { + allowed: capabilities.canUploadDocuments, + icon: , + text: "Upload evidence against required documents.", + }, + ].filter((action) => action.allowed); + + return ( +
+

What you can do

+ + {actions.length > 0 ? ( +
    + {actions.map((action) => ( +
  • + {action.icon} + {action.text} +
  • + ))} +
+ ) : ( +

+ Your assignments are read-only. You can view your work orders but not + change them. +

+ )} + +

+ + You cannot change costs or omit orders — contact a client or Domna admin. +

+
+ ); +} diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index d48e370d..9c64e36f 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -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 ( + + ); + } + const data = await loadDashboardData(project.id, asOf, RECENT_LIMIT); if (!data) notFound(); diff --git a/src/app/projects/[projectId]/work-orders/page.tsx b/src/app/projects/[projectId]/work-orders/page.tsx index 19806f5a..1f071b49 100644 --- a/src/app/projects/[projectId]/work-orders/page.tsx +++ b/src/app/projects/[projectId]/work-orders/page.tsx @@ -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 ( - -

- Work orders issued to your organisation will appear here. The - contractor view is still being built. -

-
- ); - } + // 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 ( - + { + 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 { + 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), + }; +} diff --git a/src/app/repositories/projects/dashboardRepository.test.ts b/src/app/repositories/projects/dashboardRepository.test.ts index b43dbfd4..aac9fb96 100644 --- a/src/app/repositories/projects/dashboardRepository.test.ts +++ b/src/app/repositories/projects/dashboardRepository.test.ts @@ -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", () => { diff --git a/src/app/repositories/projects/dashboardRepository.ts b/src/app/repositories/projects/dashboardRepository.ts index fe0abe43..96177603 100644 --- a/src/app/repositories/projects/dashboardRepository.ts +++ b/src/app/repositories/projects/dashboardRepository.ts @@ -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 { const rows = await db.execute( - 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; diff --git a/src/lib/projects/authz.test.ts b/src/lib/projects/authz.test.ts index 11681c59..1a1e4c24 100644 --- a/src/lib/projects/authz.test.ts +++ b/src/lib/projects/authz.test.ts @@ -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 => ({ + 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, + }); + }); +}); diff --git a/src/lib/projects/authz.ts b/src/lib/projects/authz.ts index 7fbd15c4..32776552 100644 --- a/src/lib/projects/authz.ts +++ b/src/lib/projects/authz.ts @@ -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), + }; +}