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:
Daniel Roth 2026-07-27 12:00:26 +01:00 committed by GitHub
commit a8a3222ca6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1148 additions and 56 deletions

View 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);
}
});
});
});

View 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.

View file

@ -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),
};
}

View file

@ -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);
});
});

View file

@ -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}`);
}

View file

@ -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,
);
});

View file

@ -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);

View file

@ -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>
);
}

View file

@ -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();

View file

@ -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}

View 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),
};
}

View file

@ -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", () => {

View file

@ -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;

View file

@ -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,
});
});
});

View file

@ -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),
};
}