feat(ara-projects): work-orders API — filters, keyset pagination, shared derivations (#419)

GET /api/projects/[projectId]/work-orders serves one page of the admin
work-orders table: workstream / contractor / stage / overdue-only /
missing-required-evidence filters, keyset-paginated by work_order.id so a
cursor stays meaningful under any filter combination.

The rules stay in one place. Every derived filter is split at its AND, the
way #418's dashboard repository splits it, and the TypeScript half crosses
into SQL as data rather than as a re-implemented predicate:

  - overdue: buildStageLadders classifies the project's stages in JS and
    stageIdsByProgress("complete") hands the terminal ids to the query;
    SQL only counts forecast_end < asOf.
  - missing evidence: requiredEvidenceRequirementIds decides which
    requirement applies at which stage and hands over an explicit
    (stage_id, requirement_id) list.

Row badges come from toWorkOrderRow, which calls isOverdue /
progressOfWorkOrder / evidenceCompleteness directly, so the table and the
dashboard reconcile by construction.

Authz is canManageProject (internal + client-org). A contractor gets 403
rather than the whole programme; row scoping is a later issue.

Tests mock @/app/db/db and open no connection.
This commit is contained in:
Daniel Roth 2026-07-23 12:38:39 +00:00
parent bf3f8aeb20
commit bbc2843fdf
9 changed files with 1570 additions and 0 deletions

View file

@ -0,0 +1,63 @@
/**
* Request authorization for the work-orders endpoint (issue #419).
*
* Wiring only, mirroring `../workstreams/authorize.ts`: the session comes from
* NextAuth, the facts from the projects authz repository, and every *decision*
* from `@/lib/projects/authz` (#408). No permission rule is re-implemented here.
*
* 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.
*/
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import {
loadAuthzUser,
loadProjectAuthzFacts,
} from "@/app/repositories/projects/authzRepository";
import { canManageProject, canViewProject } from "@/lib/projects/authz";
export type AuthorizeFailure = { error: string; status: 400 | 401 | 403 | 404 };
export type AuthorizeResult =
| { ok: true; projectId: bigint }
| ({ ok: false } & AuthorizeFailure);
/** Resolve the caller's standing on `projectIdParam` for the admin table. */
export async function authorizeWorkOrdersRequest(
projectIdParam: string,
): Promise<AuthorizeResult> {
const session = await getServerSession(AuthOptions);
if (!session?.user?.dbId) {
return { ok: false, error: "Unauthorised", status: 401 };
}
if (!/^\d+$/.test(projectIdParam)) {
return { ok: false, error: "Invalid project", status: 400 };
}
const projectId = BigInt(projectIdParam);
const [user, project] = await Promise.all([
loadAuthzUser(BigInt(session.user.dbId)),
loadProjectAuthzFacts(projectId),
]);
if (!user) return { ok: false, error: "Unauthorised", status: 401 };
if (!project) return { ok: false, error: "Project not found", status: 404 };
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 };
}

View file

@ -0,0 +1,150 @@
/**
* The work-orders query string (issue #419).
*
* Pure module, pure test: no database, no network. The round trip matters most
* the client builds the params and the server parses them, so anything that
* survives `workOrderSearchParams` must come back out of `parseWorkOrderQuery`
* unchanged, or the table and its filter bar silently disagree.
*/
import { describe, expect, it } from "vitest";
import {
DEFAULT_PAGE_SIZE,
MAX_PAGE_SIZE,
NO_FILTERS,
hasActiveFilters,
parseWorkOrderQuery,
workOrderSearchParams,
type WorkOrderFilterSelection,
} from "./filters";
const CONTRACTOR = "22222222-2222-2222-2222-222222222222";
const parse = (query: string) => parseWorkOrderQuery(new URLSearchParams(query));
describe("parseWorkOrderQuery", () => {
it("treats an empty query string as no filters and the default page size", () => {
const result = parse("");
expect(result).toEqual({
ok: true,
query: {
workstreamId: null,
contractorOrganisationId: null,
stageId: null,
overdueOnly: false,
missingEvidence: false,
cursor: null,
limit: DEFAULT_PAGE_SIZE,
},
});
});
it("parses every filter at once — they compose rather than override", () => {
const result = parse(
`workstreamId=10&stageId=102&contractorOrganisationId=${CONTRACTOR}` +
"&overdue=true&missingEvidence=true&cursor=812&limit=25",
);
expect(result).toEqual({
ok: true,
query: {
workstreamId: 10n,
contractorOrganisationId: CONTRACTOR,
stageId: 102n,
overdueOnly: true,
missingEvidence: true,
cursor: 812n,
limit: 25,
},
});
});
it("rejects a non-numeric id rather than silently dropping the filter", () => {
// A dropped filter shows a table that does not match the controls above it.
expect(parse("workstreamId=windows")).toEqual({
ok: false,
error: "Invalid workstreamId",
});
expect(parse("stageId=-1")).toEqual({ ok: false, error: "Invalid stageId" });
expect(parse("cursor=0")).toEqual({ ok: false, error: "Invalid cursor" });
});
it("rejects a contractor id that is not a uuid", () => {
expect(parse("contractorOrganisationId=42")).toEqual({
ok: false,
error: "Invalid contractorOrganisationId",
});
});
it("rejects a boolean it does not recognise, to catch a typo'd flag", () => {
expect(parse("overdue=yes")).toEqual({ ok: false, error: "Invalid overdue" });
expect(parse("missingEvidence=on")).toEqual({
ok: false,
error: "Invalid missingEvidence",
});
});
it("accepts the falsey spellings of a flag", () => {
expect(parse("overdue=false")).toMatchObject({
ok: true,
query: { overdueOnly: false },
});
expect(parse("overdue=1")).toMatchObject({
ok: true,
query: { overdueOnly: true },
});
});
it("refuses to stream the whole programme in one response", () => {
expect(parse(`limit=${MAX_PAGE_SIZE}`)).toMatchObject({ ok: true });
expect(parse(`limit=${MAX_PAGE_SIZE + 1}`)).toEqual({
ok: false,
error: "Invalid limit",
});
expect(parse("limit=0")).toEqual({ ok: false, error: "Invalid limit" });
});
it("rejects a SQL fragment in an id parameter", () => {
expect(parse("workstreamId=1%3B+DROP+TABLE+work_order")).toEqual({
ok: false,
error: "Invalid workstreamId",
});
});
});
describe("workOrderSearchParams", () => {
const selection: WorkOrderFilterSelection = {
workstreamId: "10",
contractorOrganisationId: CONTRACTOR,
stageId: "102",
overdueOnly: true,
missingEvidence: true,
};
it("round-trips a full selection back through the parser", () => {
const params = workOrderSearchParams(selection, { cursor: "812", limit: 25 });
expect(parseWorkOrderQuery(params)).toEqual({
ok: true,
query: {
workstreamId: 10n,
contractorOrganisationId: CONTRACTOR,
stageId: 102n,
overdueOnly: true,
missingEvidence: true,
cursor: 812n,
limit: 25,
},
});
});
it("omits everything falsey, so the unfiltered page has a stable cache key", () => {
expect(workOrderSearchParams(NO_FILTERS).toString()).toBe("");
expect(workOrderSearchParams(NO_FILTERS, { cursor: null }).toString()).toBe("");
});
});
describe("hasActiveFilters", () => {
it("is false only when nothing at all is narrowed", () => {
expect(hasActiveFilters(NO_FILTERS)).toBe(false);
expect(hasActiveFilters({ ...NO_FILTERS, overdueOnly: true })).toBe(true);
expect(hasActiveFilters({ ...NO_FILTERS, stageId: "1" })).toBe(true);
});
});

View file

@ -0,0 +1,204 @@
/**
* The work-orders table's query string, in one place (issue #419).
*
* The filter bar, the `useQuery` key, the fetch URL and the route handler all
* have to agree on exactly what `?overdue=true&cursor=812` means. They agree
* because they all go through this module: the client *builds* the search
* params with `workOrderSearchParams`, the handler *parses* them back with
* `parseWorkOrderQuery`, and neither reads a raw parameter name of its own.
*
* Pure by construction no database client, no React so it is safe to import
* from a client component and it unit-tests without a connection.
*
* ## Two representations, deliberately
*
* - **Selection** (`WorkOrderFilterSelection`) is what the UI holds: ids as
* strings, because bigint ids do not survive JSON and a `<select>` value is a
* string anyway.
* - **Query** (`WorkOrderQuery`) is what the server acts on: ids parsed to
* `bigint`, ready for Drizzle. Parsing is the boundary where a junk id
* becomes a 400 rather than a database error.
*/
/** `project_workstream.id` / `project_workstream_stage.id` as a wire string. */
const NUMERIC_ID = /^\d+$/;
/** `organisation.id` is a uuid; anything else is not a contractor. */
const UUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** Rows per page when the caller does not ask for a size. */
export const DEFAULT_PAGE_SIZE = 50;
/**
* The most rows one request may ask for. A programme runs to thousands of work
* orders, so an unbounded `limit` is a way to ask the server to stream the
* whole table into one response.
*/
export const MAX_PAGE_SIZE = 200;
/** The filter bar's state: what the user has narrowed the table to. */
export interface WorkOrderFilterSelection {
/** `project_workstream.id` — the Project Workstream, not the catalogue row. */
workstreamId: string | null;
/** `organisation.id` of the contractor. */
contractorOrganisationId: string | null;
/** `project_workstream_stage.id`. */
stageId: string | null;
/** Only work orders that are overdue by the shared derivation rule. */
overdueOnly: boolean;
/** Only work orders with at least one applicable required document missing. */
missingEvidence: boolean;
}
/** No filters at all — the table's opening state. */
export const NO_FILTERS: WorkOrderFilterSelection = {
workstreamId: null,
contractorOrganisationId: null,
stageId: null,
overdueOnly: false,
missingEvidence: false,
};
/** True when nothing is narrowed, so the UI can label the empty state honestly. */
export function hasActiveFilters(selection: WorkOrderFilterSelection): boolean {
return (
selection.workstreamId !== null ||
selection.contractorOrganisationId !== null ||
selection.stageId !== null ||
selection.overdueOnly ||
selection.missingEvidence
);
}
/** The parsed, server-side form of one page request. */
export interface WorkOrderQuery {
workstreamId: bigint | null;
contractorOrganisationId: string | null;
stageId: bigint | null;
overdueOnly: boolean;
missingEvidence: boolean;
/**
* Keyset cursor: the `work_order.id` of the last row of the previous page.
* Null asks for the first page. Rows are ordered by `work_order.id`, so a
* cursor stays meaningful under any combination of filters the whole point
* of paginating by key rather than by offset.
*/
cursor: bigint | null;
limit: number;
}
/** The query a request with no parameters at all asks for. */
export const DEFAULT_QUERY: WorkOrderQuery = {
workstreamId: null,
contractorOrganisationId: null,
stageId: null,
overdueOnly: false,
missingEvidence: false,
cursor: null,
limit: DEFAULT_PAGE_SIZE,
};
export type ParsedQuery =
| { ok: true; query: WorkOrderQuery }
| { ok: false; error: string };
/**
* Parse a request's search params into a `WorkOrderQuery`.
*
* Every failure is reported rather than silently defaulted: a filter the server
* quietly dropped shows the user a table that does not match the controls above
* it, which is worse than an error. An *absent* parameter is not a failure it
* simply means "not filtered".
*/
export function parseWorkOrderQuery(params: URLSearchParams): ParsedQuery {
const workstreamId = parseId(params.get("workstreamId"));
if (workstreamId === INVALID) {
return { ok: false, error: "Invalid workstreamId" };
}
const stageId = parseId(params.get("stageId"));
if (stageId === INVALID) return { ok: false, error: "Invalid stageId" };
const cursor = parseId(params.get("cursor"));
if (cursor === INVALID) return { ok: false, error: "Invalid cursor" };
const contractor = params.get("contractorOrganisationId");
if (contractor !== null && !UUID.test(contractor)) {
return { ok: false, error: "Invalid contractorOrganisationId" };
}
const overdueOnly = parseBoolean(params.get("overdue"));
if (overdueOnly === INVALID) return { ok: false, error: "Invalid overdue" };
const missingEvidence = parseBoolean(params.get("missingEvidence"));
if (missingEvidence === INVALID) {
return { ok: false, error: "Invalid missingEvidence" };
}
const limit = parseLimit(params.get("limit"));
if (limit === INVALID) return { ok: false, error: "Invalid limit" };
return {
ok: true,
query: {
workstreamId,
contractorOrganisationId: contractor,
stageId,
overdueOnly,
missingEvidence,
cursor,
limit,
},
};
}
/**
* Build the search params for one page request the exact inverse of
* `parseWorkOrderQuery`, and the only place the client names a parameter.
*
* Falsey filters are omitted rather than serialised as `false`/empty, so the
* unfiltered first page has a stable, empty query string and therefore a stable
* React Query cache key.
*/
export function workOrderSearchParams(
selection: WorkOrderFilterSelection,
page: { cursor?: string | null; limit?: number } = {},
): URLSearchParams {
const params = new URLSearchParams();
if (selection.workstreamId) params.set("workstreamId", selection.workstreamId);
if (selection.contractorOrganisationId) {
params.set("contractorOrganisationId", selection.contractorOrganisationId);
}
if (selection.stageId) params.set("stageId", selection.stageId);
if (selection.overdueOnly) params.set("overdue", "true");
if (selection.missingEvidence) params.set("missingEvidence", "true");
if (page.cursor) params.set("cursor", page.cursor);
if (page.limit !== undefined) params.set("limit", String(page.limit));
return params;
}
/** Sentinel distinguishing "not supplied" (null) from "supplied but wrong". */
const INVALID = Symbol("invalid");
function parseId(raw: string | null): bigint | null | typeof INVALID {
if (raw === null) return null;
if (!NUMERIC_ID.test(raw)) return INVALID;
const parsed = BigInt(raw);
return parsed > 0n ? parsed : INVALID;
}
function parseBoolean(raw: string | null): boolean | typeof INVALID {
if (raw === null) return false;
if (raw === "true" || raw === "1") return true;
if (raw === "false" || raw === "0") return false;
return INVALID;
}
function parseLimit(raw: string | null): number | typeof INVALID {
if (raw === null) return DEFAULT_PAGE_SIZE;
if (!NUMERIC_ID.test(raw)) return INVALID;
const parsed = Number(raw);
if (parsed < 1 || parsed > MAX_PAGE_SIZE) return INVALID;
return parsed;
}

View file

@ -0,0 +1,209 @@
/**
* Query construction for the work-orders table (issue #419).
*
* The db module is stubbed *before* it is imported, so no `pg.Pool` is ever
* constructed and this suite cannot open a connection. What is under test is
* the SQL these builders emit, not its execution in particular that the
* derived filters cross into SQL as *data* (terminal stage ids, applicable
* requirement pairs) rather than as re-implemented rules.
*/
import { describe, expect, it, vi } from "vitest";
import { PgDialect } from "drizzle-orm/pg-core";
vi.mock("@/app/db/db", () => ({
db: {
execute: () => {
throw new Error("queries.test must not execute queries");
},
},
pool: {},
}));
import { buildWorkOrderCountQuery, buildWorkOrderPageQuery } from "./queries";
import { DEFAULT_QUERY, type WorkOrderQuery } from "./filters";
const dialect = new PgDialect();
const render = (query: ReturnType<typeof buildWorkOrderPageQuery>) =>
dialect.sqlToQuery(query);
const ASOF = new Date("2026-07-23T09:30:00Z");
const CONTRACTOR = "22222222-2222-2222-2222-222222222222";
const TERMINAL = [103n, 203n];
const query = (overrides: Partial<WorkOrderQuery> = {}): WorkOrderQuery => ({
...DEFAULT_QUERY,
...overrides,
});
const page = (q: Partial<WorkOrderQuery> = {}, terminal = TERMINAL) =>
render(buildWorkOrderPageQuery(7n, query(q), ASOF, [], terminal));
describe("buildWorkOrderPageQuery", () => {
it("scopes to the project and binds the id as a parameter", () => {
const { sql, params } = page();
expect(sql).toContain("pw.project_id =");
expect(params).toContain(7n);
});
it("asks for one row more than the page size, to detect a next page", () => {
const { params } = page({ limit: 50 });
expect(params).toContain(51);
});
it("orders by work_order id so a cursor survives any filter combination", () => {
expect(page().sql).toContain("ORDER BY id ASC");
});
it("applies the cursor as a keyset comparison, not an offset", () => {
const { sql, params } = page({ cursor: 812n });
expect(sql).toContain("id >");
expect(sql).not.toContain("OFFSET");
expect(params).toContain(812n);
});
it("emits no WHERE over the aggregate when nothing needs one", () => {
expect(page().sql).not.toContain("satisfied_count <");
});
describe("filters", () => {
it("narrows to a Project Workstream", () => {
const { sql, params } = page({ workstreamId: 10n });
expect(sql).toContain("pw.id =");
expect(params).toContain(10n);
});
it("narrows to a stage", () => {
const { sql, params } = page({ stageId: 102n });
expect(sql).toContain("wo.project_workstream_stage_id =");
expect(params).toContain(102n);
});
it("narrows to a contractor organisation, cast to uuid", () => {
const { sql, params } = page({ contractorOrganisationId: CONTRACTOR });
expect(sql).toContain("pwc.organisation_id =");
expect(sql).toContain("::uuid");
expect(params).toContain(CONTRACTOR);
});
it("composes several filters rather than replacing one with another", () => {
const { sql, params } = page({
workstreamId: 10n,
stageId: 102n,
contractorOrganisationId: CONTRACTOR,
overdueOnly: true,
missingEvidence: true,
});
expect(sql).toContain("pw.id =");
expect(sql).toContain("wo.project_workstream_stage_id =");
expect(sql).toContain("pwc.organisation_id =");
expect(sql).toContain("wo.forecast_end <");
expect(sql).toContain("satisfied_count < required_count");
expect(params).toEqual(
expect.arrayContaining([7n, 10n, 102n, CONTRACTOR, "2026-07-23"]),
);
});
});
describe("overdue", () => {
it("binds the caller's day rather than trusting the database clock", () => {
const { sql, params } = page({ overdueOnly: true });
expect(params).toContain("2026-07-23");
expect(sql).not.toContain("CURRENT_DATE");
});
it("excludes terminal stages using ids the ladders classified in JS", () => {
const { sql } = page({ overdueOnly: true });
expect(sql).toContain(
"wo.project_workstream_stage_id NOT IN (103, 203)",
);
// Terminal-ness is a ladder question; the query must not attempt it.
expect(sql).not.toMatch(/max\s*\(\s*"?order"?\s*\)/i);
});
it("omits the exclusion when no stage is terminal, rather than emitting NOT IN ()", () => {
const { sql } = page({ overdueOnly: true }, []);
expect(sql).toContain("wo.forecast_end <");
expect(sql).not.toContain("NOT IN ()");
});
it("does not touch forecast_end at all when the filter is off", () => {
expect(page().sql).not.toContain("wo.forecast_end <");
});
});
describe("evidence", () => {
it("filters missing evidence before LIMIT, so pagination stays stable", () => {
const { sql } = page({ missingEvidence: true });
const whereAt = sql.indexOf("satisfied_count < required_count");
const limitAt = sql.lastIndexOf("LIMIT");
expect(whereAt).toBeGreaterThan(-1);
expect(whereAt).toBeLessThan(limitAt);
});
it("counts against the applicable pairs the derivations module supplied", () => {
const { sql } = render(
buildWorkOrderPageQuery(
7n,
query(),
ASOF,
[
{ stageId: 102n, requirementId: 7n },
{ stageId: 103n, requirementId: 8n },
],
TERMINAL,
),
);
expect(sql).toContain("(102::bigint, 7::bigint)");
expect(sql).toContain("(103::bigint, 8::bigint)");
expect(sql).toContain("AS v(stage_id, requirement_id)");
});
it("emits a well-typed empty CTE when nothing is required, not empty VALUES", () => {
const { sql } = page();
expect(sql).toContain("WHERE false");
expect(sql).not.toMatch(/VALUES\s*\)/);
});
it("only counts Projects-sourced files as Evidence", () => {
expect(page().sql).toContain("uf.file_source = 'projects'");
});
});
it("left-joins the contractor assignment so an unassigned order still appears", () => {
expect(page().sql).toContain("LEFT JOIN project_workstream_contractor");
});
it("reads the stage and workstream names live, never from the work order", () => {
const { sql } = page();
expect(sql).toContain("pws.name");
expect(sql).toContain("w.name");
});
it("rejects an invalid asOf rather than issuing a query with a null date", () => {
expect(() =>
buildWorkOrderPageQuery(7n, query(), new Date("nope"), [], TERMINAL),
).toThrow();
});
});
describe("buildWorkOrderCountQuery", () => {
const count = (q: Partial<WorkOrderQuery> = {}) =>
render(buildWorkOrderCountQuery(7n, query(q), ASOF, [], TERMINAL));
it("counts the filtered set", () => {
const { sql } = count({ overdueOnly: true });
expect(sql).toContain("count(*)::int");
expect(sql).toContain("wo.forecast_end <");
});
it("ignores the cursor — the footer counts matches, not what is left", () => {
const { sql } = count({ cursor: 812n });
expect(sql).not.toContain("id >");
});
it("still applies the evidence filter", () => {
expect(count({ missingEvidence: true }).sql).toContain(
"satisfied_count < required_count",
);
});
});

View file

@ -0,0 +1,421 @@
/**
* Persistence for the admin work-orders table (issue #419).
*
* The rule for this file is the one `@/app/repositories/projects/dashboardRepository`
* set for the dashboard: **SQL counts facts, TypeScript makes judgements.**
* A programme runs to thousands of work orders, so filtering and pagination
* must happen in the database but no rule may be restated here in SQL, or the
* table's badges and the dashboard's KPIs will eventually disagree.
*
* So each derived filter is split at its `AND`, exactly as the dashboard splits
* it, and the TypeScript half crosses into SQL **as data**:
*
* - **overdue** `buildStageLadders` classifies the project's few dozen
* stages in JS; `stageIdsByProgress("complete")` hands the terminal ids to
* the query, which then counts `forecast_end < asOf AND stage NOT IN
* (terminal)`. SQL is never asked what "terminal" means.
* - **missing required evidence** `requiredEvidenceRequirementIds` decides
* which requirement applies at which stage and hands over an explicit
* `(stage_id, requirement_id)` list. The query counts against that list.
* `evidenceCompleteness` clamps `satisfied` into `[0, required]`, so
* `isMissingRequiredEvidence` is exactly `satisfied_count < required_count`
* the predicate the outer `WHERE` uses to filter *before* `LIMIT`, which
* is the one thing a JS-side filter could not do correctly.
*
* Every row the query returns still gets its badges from `toWorkOrderRow`, so
* what the user sees is always the pure module's answer.
*
* 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 {
projectWorkstream,
projectWorkstreamContractor,
projectWorkstreamStage,
workstream,
} from "@/app/db/schema/projects/projects";
import { organisation } from "@/app/db/schema/organisation";
import { buildStageLadders, toCalendarDay } from "@/lib/projects/derivations";
import {
buildApplicableEvidencePairs,
loadEvidenceRequirements,
loadStageLadderEntries,
type ApplicableEvidencePair,
} from "@/app/repositories/projects/dashboardRepository";
import type { WorkOrderQuery } from "./filters";
import { toWorkOrderRow, type RawWorkOrderRow, type WorkOrderPage } from "./rows";
/** The choices the filter bar offers, all read live from the project's setup. */
export interface WorkOrderFilterOptions {
/** Project Workstreams, named from the `workstream` reference data. */
workstreams: { id: string; name: string }[];
/** Every organisation assigned as contractor to any of the project's workstreams. */
contractors: { organisationId: string; name: string }[];
/** Every stage of every workstream, so the stage filter can narrow to one workstream. */
stages: { id: string; name: string; workstreamId: string; order: number }[];
}
/**
* Load one page of the table.
*
* The stage ladders and evidence requirements are read first because the
* filters that depend on them (`overdue`, `missingEvidence`) can only be pushed
* into SQL once the pure module has been asked its questions. `asOf` is bound
* as a date parameter and reused for the row badges, never `CURRENT_DATE`.
*/
export async function loadWorkOrderPage(
projectId: bigint,
query: WorkOrderQuery,
asOf: Date,
): Promise<WorkOrderPage> {
const [stages, requirements] = await Promise.all([
loadStageLadderEntries(projectId),
loadEvidenceRequirements(projectId),
]);
const ladders = buildStageLadders(stages);
const applicableEvidence = buildApplicableEvidencePairs(
ladders,
stages,
requirements,
);
const terminalStageIds = ladders.stageIdsByProgress("complete");
const [pageRows, totalRows] = await Promise.all([
db.execute<WorkOrderQueryRow>(
buildWorkOrderPageQuery(
projectId,
query,
asOf,
applicableEvidence,
terminalStageIds,
),
),
db.execute<{ total: number }>(
buildWorkOrderCountQuery(
projectId,
query,
asOf,
applicableEvidence,
terminalStageIds,
),
),
]);
// One row over the page size was requested: its presence — not a count
// comparison — is what proves another page exists.
const rows = pageRows.rows.map(toRawWorkOrderRow);
const hasMore = rows.length > query.limit;
const page = hasMore ? rows.slice(0, query.limit) : rows;
return {
workOrders: page.map((raw) => toWorkOrderRow(ladders, raw, asOf)),
nextCursor: hasMore ? page[page.length - 1].id.toString() : null,
total: Number(totalRows.rows[0]?.total ?? 0),
};
}
/** The choices for the three dropdowns, in one round trip each. */
export async function loadFilterOptions(
projectId: bigint,
): Promise<WorkOrderFilterOptions> {
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))
.orderBy(workstream.name),
db
.selectDistinct({
organisationId: projectWorkstreamContractor.organisationId,
name: organisation.name,
})
.from(projectWorkstreamContractor)
.innerJoin(
projectWorkstream,
eq(projectWorkstreamContractor.projectWorkstreamId, projectWorkstream.id),
)
.innerJoin(
organisation,
eq(organisation.id, projectWorkstreamContractor.organisationId),
)
.where(eq(projectWorkstream.projectId, projectId))
.orderBy(organisation.name),
db
.select({
id: projectWorkstreamStage.id,
name: projectWorkstreamStage.name,
workstreamId: projectWorkstreamStage.projectWorkstreamId,
order: projectWorkstreamStage.order,
})
.from(projectWorkstreamStage)
.innerJoin(
projectWorkstream,
eq(projectWorkstreamStage.projectWorkstreamId, projectWorkstream.id),
)
.where(eq(projectWorkstream.projectId, projectId))
.orderBy(
asc(projectWorkstreamStage.projectWorkstreamId),
asc(projectWorkstreamStage.order),
),
]);
return {
workstreams: workstreams.map((row) => ({
id: row.id.toString(),
name: row.name,
})),
// `organisation.name` is nullable; a contractor with no name still has to
// be selectable, so it is labelled by its id rather than dropped.
contractors: contractors.map((row) => ({
organisationId: row.organisationId,
name: row.name ?? `Organisation ${row.organisationId}`,
})),
stages: stages.map((row) => ({
id: row.id.toString(),
name: row.name,
workstreamId: row.workstreamId.toString(),
order: row.order,
})),
};
}
/** The raw column shape the page query returns. Postgres counts arrive as strings. */
interface WorkOrderQueryRow extends Record<string, unknown> {
id: string;
reference: string;
landlord_property_id: string | null;
address: string | null;
postcode: string | null;
workstream_id: string;
workstream_name: string;
contractor_organisation_id: string | null;
contractor_name: string | null;
stage_id: string;
stage_name: string;
forecast_end: string | null;
required_count: string | number;
satisfied_count: string | number;
}
function toRawWorkOrderRow(row: WorkOrderQueryRow): RawWorkOrderRow {
return {
id: BigInt(row.id),
reference: row.reference,
landlordPropertyId: row.landlord_property_id,
address: row.address,
postcode: row.postcode,
workstreamId: BigInt(row.workstream_id),
workstreamName: row.workstream_name,
contractorOrganisationId: row.contractor_organisation_id,
contractorName: row.contractor_name,
stageId: BigInt(row.stage_id),
stageName: row.stage_name,
// A `date` column arrives as `YYYY-MM-DD`; take the day part defensively so
// a driver that ever hands back a timestamp cannot leak a time into the UI.
forecastEnd: toCalendarDay(row.forecast_end),
requiredEvidence: Number(row.required_count),
satisfiedEvidence: Number(row.satisfied_count),
};
}
/**
* Build the page query, separately from running it, so a unit test can render
* and assert on the SQL without opening a connection.
*
* `limit + 1` rows are requested: see `loadWorkOrderPage`.
*/
export function buildWorkOrderPageQuery(
projectId: bigint,
query: WorkOrderQuery,
asOf: Date,
applicableEvidence: readonly ApplicableEvidencePair[],
terminalStageIds: readonly bigint[],
) {
const scope = workOrderScope(
projectId,
query,
asOf,
applicableEvidence,
terminalStageIds,
);
return sql`${scope} SELECT * FROM per_order ${outerWhere(
query,
true,
)} ORDER BY id ASC LIMIT ${query.limit + 1}`;
}
/**
* The same filtered set, counted. The cursor is deliberately *not* applied
* the footer says how many work orders match the filters, not how many are left
* after the page the user is on.
*/
export function buildWorkOrderCountQuery(
projectId: bigint,
query: WorkOrderQuery,
asOf: Date,
applicableEvidence: readonly ApplicableEvidencePair[],
terminalStageIds: readonly bigint[],
) {
const scope = workOrderScope(
projectId,
query,
asOf,
applicableEvidence,
terminalStageIds,
);
return sql`${scope} SELECT count(*)::int AS total FROM per_order ${outerWhere(
query,
false,
)}`;
}
/**
* The `WITH applicable … per_order` prelude both queries share: the joins, the
* filters that can be pushed down, and the per-work-order evidence counts.
*
* The evidence counts are aggregated per work order here rather than in the
* outer query so the `missingEvidence` filter can be a plain `WHERE` over
* `per_order` and therefore run *before* `LIMIT`, which is what makes
* pagination stable under that filter.
*/
function workOrderScope(
projectId: bigint,
query: WorkOrderQuery,
asOf: Date,
applicableEvidence: readonly ApplicableEvidencePair[],
terminalStageIds: readonly bigint[],
) {
const asOfDay = toCalendarDay(asOf);
if (asOfDay === null) throw new Error("loadWorkOrderPage: invalid asOf date");
// Ids are interpolated as bigint literals, never as user text: they come from
// rows this process just read, and `asBigIntLiteral` re-checks each one.
const pairsSql = applicableEvidence.length
? sql.raw(
applicableEvidence
.map(
(pair) =>
`(${asBigIntLiteral(pair.stageId)}::bigint, ${asBigIntLiteral(
pair.requirementId,
)}::bigint)`,
)
.join(", "),
)
: null;
// `VALUES` with no rows is not legal SQL, so an empty applicable set still
// needs a well-typed, empty CTE.
const applicableCte = pairsSql
? sql`SELECT * FROM (VALUES ${pairsSql}) AS v(stage_id, requirement_id)`
: sql`SELECT NULL::bigint AS stage_id, NULL::bigint AS requirement_id WHERE false`;
const conditions = [sql`pw.project_id = ${projectId}`];
if (query.workstreamId !== null) {
conditions.push(sql`pw.id = ${query.workstreamId}`);
}
if (query.stageId !== null) {
conditions.push(sql`wo.project_workstream_stage_id = ${query.stageId}`);
}
if (query.contractorOrganisationId !== null) {
conditions.push(
sql`pwc.organisation_id = ${query.contractorOrganisationId}::uuid`,
);
}
if (query.overdueOnly) {
// The SQL half of `isOverdue`. A work order with no forecast end is never
// overdue, and `NULL < date` is NULL, so it drops out without a special case.
conditions.push(sql`wo.forecast_end < ${asOfDay}::date`);
// The TypeScript half, pushed down as data: complete work orders are never
// overdue however late they finished. An empty ladder set means no stage is
// terminal, so there is nothing to exclude.
if (terminalStageIds.length > 0) {
conditions.push(
sql`wo.project_workstream_stage_id NOT IN (${sql.raw(
terminalStageIds.map(asBigIntLiteral).join(", "),
)})`,
);
}
}
return sql`
WITH applicable AS (${applicableCte}),
per_order AS (
SELECT
wo.id,
wo.reference,
wo.forecast_end,
wo.project_workstream_stage_id AS stage_id,
pws.name AS stage_name,
pw.id AS workstream_id,
w.name AS workstream_name,
p.landlord_property_id,
p.address,
p.postcode,
pwc.organisation_id AS contractor_organisation_id,
org.name AS contractor_name,
(
SELECT count(*) FROM applicable a
WHERE a.stage_id = wo.project_workstream_stage_id
) AS required_count,
count(DISTINCT uf.project_workstream_evidence_requirement_id)
AS satisfied_count
FROM work_order wo
JOIN project_workstream_stage pws
ON pws.id = wo.project_workstream_stage_id
JOIN project_workstream pw
ON pw.id = pws.project_workstream_id
JOIN workstream w
ON w.id = pw.workstream_id
JOIN property p
ON p.id = wo.property_id
LEFT JOIN project_workstream_contractor pwc
ON pwc.id = wo.project_workstream_contractor_id
LEFT JOIN organisation org
ON org.id = pwc.organisation_id
LEFT JOIN uploaded_files uf
ON uf.work_order_id = wo.id
AND uf.file_source = 'projects'
AND EXISTS (
SELECT 1 FROM applicable a
WHERE a.stage_id = wo.project_workstream_stage_id
AND a.requirement_id = uf.project_workstream_evidence_requirement_id
)
WHERE ${and(...conditions)}
GROUP BY wo.id, wo.reference, wo.forecast_end,
wo.project_workstream_stage_id, pws.name, pw.id, w.name,
p.landlord_property_id, p.address, p.postcode,
pwc.organisation_id, org.name
)
`;
}
/**
* The conditions that can only be applied once the per-work-order evidence
* counts exist, plus the keyset cursor.
*/
function outerWhere(query: WorkOrderQuery, withCursor: boolean) {
const conditions: SQL[] = [];
if (query.missingEvidence) {
// `isMissingRequiredEvidence(evidenceCompleteness(required, satisfied))`,
// expressed over the counted columns — see the module header.
conditions.push(sql`satisfied_count < required_count`);
}
if (withCursor && query.cursor !== null) {
conditions.push(sql`id > ${query.cursor}`);
}
return conditions.length ? sql` WHERE ${and(...conditions)}` : sql``;
}
/** Render a bigint as a SQL literal, rejecting anything that is not one. */
function asBigIntLiteral(value: bigint): string {
if (typeof value !== "bigint") {
throw new Error("asBigIntLiteral: expected a bigint id");
}
return value.toString();
}

View file

@ -0,0 +1,194 @@
/**
* GET `/api/projects/[projectId]/work-orders` (issue #419).
*
* The database is mocked at the module boundary (`./queries` and the authz
* repository); no connection is opened. The authorization *decisions* are the
* real ones from `@/lib/projects/authz` only the facts they read are faked.
*/
import { describe, expect, it, beforeEach, vi } from "vitest";
import { NextRequest } from "next/server";
const {
mockGetServerSession,
mockLoadAuthzUser,
mockLoadProjectAuthzFacts,
mockLoadWorkOrderPage,
} = vi.hoisted(() => ({
mockGetServerSession: vi.fn(),
mockLoadAuthzUser: vi.fn(),
mockLoadProjectAuthzFacts: vi.fn(),
mockLoadWorkOrderPage: vi.fn(),
}));
vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession }));
vi.mock("@/app/api/auth/[...nextauth]/authOptions", () => ({ AuthOptions: {} }));
vi.mock("@/app/repositories/projects/authzRepository", () => ({
loadAuthzUser: mockLoadAuthzUser,
loadProjectAuthzFacts: mockLoadProjectAuthzFacts,
}));
vi.mock("./queries", () => ({ loadWorkOrderPage: mockLoadWorkOrderPage }));
import { GET } from "./route";
import { DEFAULT_PAGE_SIZE } from "./filters";
const CLIENT_ORG = "11111111-1111-1111-1111-111111111111";
const CONTRACTOR_ORG = "22222222-2222-2222-2222-222222222222";
const OTHER_ORG = "33333333-3333-3333-3333-333333333333";
const EMPTY_PAGE = { workOrders: [], nextCursor: null, total: 0 };
function signedInAs(organisationIds: string[], email = "someone@landlord.example") {
mockGetServerSession.mockResolvedValue({ user: { dbId: "7" } });
mockLoadAuthzUser.mockResolvedValue({ id: 7n, email, organisationIds });
}
function projectExists() {
mockLoadProjectAuthzFacts.mockResolvedValue({
id: 5n,
organisationId: CLIENT_ORG,
domnaAdminAccess: false,
contractorOrganisationIds: [CONTRACTOR_ORG],
});
}
const params = (projectId = "5") => ({ params: Promise.resolve({ projectId }) });
const request = (search = "", projectId = "5") =>
new NextRequest(
`http://localhost/api/projects/${projectId}/work-orders${search}`,
);
beforeEach(() => {
vi.clearAllMocks();
mockLoadWorkOrderPage.mockResolvedValue(EMPTY_PAGE);
});
describe("GET", () => {
it("401s without a session", async () => {
mockGetServerSession.mockResolvedValue(null);
const res = await GET(request(), params());
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: "Unauthorised" });
});
it("400s on a non-numeric project id", async () => {
signedInAs([CLIENT_ORG]);
const res = await GET(request("", "abc"), params("abc"));
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "Invalid project" });
});
it("404s when the project does not exist", async () => {
signedInAs([CLIENT_ORG]);
mockLoadProjectAuthzFacts.mockResolvedValue(null);
const res = await GET(request(), params());
expect(res.status).toBe(404);
});
it("404s rather than 403s for a user outside the project", async () => {
signedInAs([OTHER_ORG]);
projectExists();
const res = await GET(request(), params());
expect(res.status).toBe(404);
expect(mockLoadWorkOrderPage).not.toHaveBeenCalled();
});
it("403s a contractor: this is the admin table, and row scoping is a later issue", 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();
});
it("serves a client-org member the unfiltered first page", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
const res = await GET(request(), params());
expect(res.status).toBe(200);
expect(await res.json()).toEqual(EMPTY_PAGE);
expect(mockLoadWorkOrderPage).toHaveBeenCalledWith(
5n,
expect.objectContaining({
workstreamId: null,
overdueOnly: false,
cursor: null,
limit: DEFAULT_PAGE_SIZE,
}),
expect.any(Date),
);
});
it("serves an internal user on an admin-access project", async () => {
signedInAs([], "someone@domna.homes");
mockLoadProjectAuthzFacts.mockResolvedValue({
id: 5n,
organisationId: CLIENT_ORG,
domnaAdminAccess: true,
contractorOrganisationIds: [],
});
const res = await GET(request(), params());
expect(res.status).toBe(200);
});
it("passes the parsed filters and cursor through to the query", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
const res = await GET(
request(
`?workstreamId=10&stageId=102&contractorOrganisationId=${CONTRACTOR_ORG}` +
"&overdue=true&missingEvidence=true&cursor=812&limit=25",
),
params(),
);
expect(res.status).toBe(200);
expect(mockLoadWorkOrderPage).toHaveBeenCalledWith(
5n,
{
workstreamId: 10n,
contractorOrganisationId: CONTRACTOR_ORG,
stageId: 102n,
overdueOnly: true,
missingEvidence: true,
cursor: 812n,
limit: 25,
},
expect.any(Date),
);
});
it("400s a malformed filter instead of quietly ignoring it", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
const res = await GET(request("?overdue=yes"), params());
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "Invalid overdue" });
expect(mockLoadWorkOrderPage).not.toHaveBeenCalled();
});
it("returns the page's cursor and total untouched", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
mockLoadWorkOrderPage.mockResolvedValue({
workOrders: [{ id: "812", reference: "WO-23881" }],
nextCursor: "812",
total: 1231,
});
const res = await GET(request(), params());
expect(await res.json()).toMatchObject({ nextCursor: "812", total: 1231 });
});
it("500s without leaking the driver error", async () => {
signedInAs([CLIENT_ORG]);
projectExists();
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
mockLoadWorkOrderPage.mockRejectedValue(new Error("connection reset"));
const res = await GET(request(), params());
expect(res.status).toBe(500);
expect(await res.json()).toEqual({
error: "Couldn't load the work orders",
});
consoleError.mockRestore();
});
});

View file

@ -0,0 +1,44 @@
/**
* `GET /api/projects/[projectId]/work-orders` (issue #419).
*
* One page of the admin work-orders table, filtered and keyset-paginated in the
* database. The client calls this on every filter change and page step through
* TanStack Query; the page itself server-renders the first page from the same
* `loadWorkOrderPage`, so the first paint and every refetch afterwards are
* produced by identical code.
*
* Read-only this route issues `SELECT`s and nothing else.
*/
import { NextRequest, NextResponse } from "next/server";
import { authorizeWorkOrdersRequest } from "./authorize";
import { parseWorkOrderQuery } from "./filters";
import { loadWorkOrderPage } from "./queries";
type Params = { params: Promise<{ projectId: string }> };
export async function GET(req: NextRequest, props: Params) {
const { projectId } = await props.params;
const auth = await authorizeWorkOrdersRequest(projectId);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const parsed = parseWorkOrderQuery(req.nextUrl.searchParams);
if (!parsed.ok) {
return NextResponse.json({ error: parsed.error }, { status: 400 });
}
try {
// 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());
return NextResponse.json(page);
} catch (err) {
console.error("GET /api/projects/[projectId]/work-orders failed:", err);
return NextResponse.json(
{ error: "Couldn't load the work orders" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,150 @@
/**
* Row derivation for the work-orders table (issue #419).
*
* The point of these tests is not to re-test `@/lib/projects/derivations`
* #418 owns that but to prove this table *asks* it rather than deciding for
* itself: a complete work order past its forecast day is not overdue, the
* evidence counts are clamped, and an unknown stage degrades honestly.
*
* In-memory fixtures throughout; no database is imported.
*/
import { describe, expect, it } from "vitest";
import { buildStageLadders, type StageLadderEntry } from "@/lib/projects/derivations";
import { toWorkOrderRow, type RawWorkOrderRow } from "./rows";
const WINDOWS = 10n;
const STAGES: StageLadderEntry[] = [
{ id: 101n, projectWorkstreamId: WINDOWS, order: 1 }, // not started
{ id: 102n, projectWorkstreamId: WINDOWS, order: 2 }, // in progress
{ id: 103n, projectWorkstreamId: WINDOWS, order: 3 }, // terminal
];
const LADDERS = buildStageLadders(STAGES);
const ASOF = new Date("2026-07-23T09:30:00Z");
function raw(overrides: Partial<RawWorkOrderRow> = {}): RawWorkOrderRow {
return {
id: 812n,
reference: "WO-23881",
landlordPropertyId: "100245",
address: "12 High St",
postcode: "M1 2AB",
workstreamId: WINDOWS,
workstreamName: "Windows",
contractorOrganisationId: "22222222-2222-2222-2222-222222222222",
contractorName: "Contractor A",
stageId: 102n,
stageName: "In progress",
forecastEnd: "2026-07-30",
requiredEvidence: 4,
satisfiedEvidence: 2,
...overrides,
};
}
describe("toWorkOrderRow", () => {
it("serialises every bigint id, which would not survive JSON", () => {
const row = toWorkOrderRow(LADDERS, raw(), ASOF);
expect(row.id).toBe("812");
expect(row.workstreamId).toBe("10");
expect(row.stageId).toBe("102");
});
it("keeps the forecast end in YYYY-MM-DD — the table does the formatting", () => {
expect(toWorkOrderRow(LADDERS, raw(), ASOF).forecastEnd).toBe("2026-07-30");
});
it("derives progress from the ladder, not from the stage name", () => {
expect(toWorkOrderRow(LADDERS, raw({ stageId: 101n }), ASOF).progress).toBe(
"not_started",
);
expect(toWorkOrderRow(LADDERS, raw({ stageId: 102n }), ASOF).progress).toBe(
"in_progress",
);
expect(toWorkOrderRow(LADDERS, raw({ stageId: 103n }), ASOF).progress).toBe(
"complete",
);
});
it("marks a work order past its forecast day overdue", () => {
const row = toWorkOrderRow(LADDERS, raw({ forecastEnd: "2026-07-22" }), ASOF);
expect(row.overdue).toBe(true);
});
it("never marks a completed work order overdue, however late it finished", () => {
const row = toWorkOrderRow(
LADDERS,
raw({ stageId: 103n, forecastEnd: "2026-01-01" }),
ASOF,
);
expect(row.overdue).toBe(false);
});
it("does not treat due-today as overdue", () => {
expect(
toWorkOrderRow(LADDERS, raw({ forecastEnd: "2026-07-23" }), ASOF).overdue,
).toBe(false);
});
it("treats a missing forecast end as never overdue", () => {
expect(
toWorkOrderRow(LADDERS, raw({ forecastEnd: null }), ASOF).overdue,
).toBe(false);
});
it("reports advisory evidence counts, flagging a shortfall", () => {
const row = toWorkOrderRow(LADDERS, raw(), ASOF);
expect(row.evidence).toEqual({
required: 4,
satisfied: 2,
missing: 2,
ratio: 0.5,
});
expect(row.missingRequiredEvidence).toBe(true);
});
it("distinguishes 'nothing required' from '0% of requirements met'", () => {
const row = toWorkOrderRow(
LADDERS,
raw({ requiredEvidence: 0, satisfiedEvidence: 0 }),
ASOF,
);
expect(row.evidence.ratio).toBeNull();
expect(row.missingRequiredEvidence).toBe(false);
});
it("cannot report over 100% from a stray duplicate submission", () => {
const row = toWorkOrderRow(
LADDERS,
raw({ requiredEvidence: 2, satisfiedEvidence: 5 }),
ASOF,
);
expect(row.evidence).toEqual({
required: 2,
satisfied: 2,
missing: 0,
ratio: 1,
});
expect(row.missingRequiredEvidence).toBe(false);
});
it("degrades honestly on a stage outside the loaded ladders", () => {
// Not complete, so still eligible to be overdue — the derivations module's
// documented edge case, reached rather than re-decided here.
const row = toWorkOrderRow(
LADDERS,
raw({ stageId: 999n, forecastEnd: "2026-07-01" }),
ASOF,
);
expect(row.progress).toBeNull();
expect(row.overdue).toBe(true);
});
it("carries the live stage name through, so a rename propagates", () => {
const row = toWorkOrderRow(
LADDERS,
raw({ stageName: "Awaiting authorisation" }),
ASOF,
);
expect(row.stageName).toBe("Awaiting authorisation");
});
});

View file

@ -0,0 +1,135 @@
/**
* Turning a `work_order` row into a table row (issue #419).
*
* This is where the shared derivations from `@/lib/projects/derivations` (#418)
* are applied to a single work order. Nothing here decides what *overdue*,
* *complete* or *evidence completeness* mean every one of those is a call
* into that module, so a badge in this table and a KPI on the programme
* dashboard cannot drift apart.
*
* Pure, like the module it wraps: no database client, no React. The query in
* `./queries` produces `RawWorkOrderRow` (facts, no judgement); this produces
* `WorkOrderRow` (facts plus the derived flags the table renders); the client
* component only ever sees the latter.
*
* Ids are serialised to strings on the way out because `bigint` does not
* survive `JSON.stringify` the same convention the workstreams endpoints use.
*/
import {
evidenceCompleteness,
isMissingRequiredEvidence,
isOverdue,
progressOfWorkOrder,
type EvidenceCompleteness,
type StageLadders,
type WorkOrderProgress,
} from "@/lib/projects/derivations";
/**
* One work order as the SQL returns it: joined-up facts, pre-judgement.
*
* `requiredEvidence` / `satisfiedEvidence` are counted against the applicable
* `(stage, requirement)` pairs that `requiredEvidenceRequirementIds` produced
* the query never decides applicability itself.
*/
export interface RawWorkOrderRow {
id: bigint;
reference: string;
/** `property.landlord_property_id` — the landlord's own asset reference. */
landlordPropertyId: string | null;
address: string | null;
postcode: string | null;
/** `project_workstream.id`. */
workstreamId: bigint;
/** `workstream.name` — reference data, so a rename propagates. */
workstreamName: string;
contractorOrganisationId: string | null;
contractorName: string | null;
stageId: bigint;
/**
* `project_workstream_stage.name`, read live through the work order's stage
* FK. The name is never denormalised onto the work order, so a stage rename
* shows up here on the next read an acceptance criterion of #419.
*/
stageName: string;
/** `work_order.forecast_end` as Drizzle hands back a `date`: `YYYY-MM-DD`. */
forecastEnd: string | null;
requiredEvidence: number;
satisfiedEvidence: number;
}
/** One rendered row of the work-orders table. */
export interface WorkOrderRow {
id: string;
reference: string;
landlordPropertyId: string | null;
address: string | null;
postcode: string | null;
workstreamId: string;
workstreamName: string;
contractorOrganisationId: string | null;
contractorName: string | null;
stageId: string;
stageName: string;
/** `YYYY-MM-DD`, the only date format that crosses this boundary. The table formats it. */
forecastEnd: string | null;
/** Null when the row's stage was not loaded into the ladders — see derivations. */
progress: WorkOrderProgress | null;
overdue: boolean;
/** Advisory only (CONTEXT.md): counts and badges, never a gate. */
evidence: EvidenceCompleteness;
missingRequiredEvidence: boolean;
}
/**
* Apply the shared rules to one row.
*
* `asOf` is the day the caller is measuring against the *same* day the SQL
* filter binds, so a request that straddles midnight cannot classify a row as
* overdue in the WHERE clause and not-overdue in the badge.
*/
export function toWorkOrderRow(
ladders: StageLadders,
raw: RawWorkOrderRow,
asOf: Date,
): WorkOrderRow {
const derivationInput = {
projectWorkstreamStageId: raw.stageId,
forecastEnd: raw.forecastEnd,
};
const evidence = evidenceCompleteness(
raw.requiredEvidence,
raw.satisfiedEvidence,
);
return {
id: raw.id.toString(),
reference: raw.reference,
landlordPropertyId: raw.landlordPropertyId,
address: raw.address,
postcode: raw.postcode,
workstreamId: raw.workstreamId.toString(),
workstreamName: raw.workstreamName,
contractorOrganisationId: raw.contractorOrganisationId,
contractorName: raw.contractorName,
stageId: raw.stageId.toString(),
stageName: raw.stageName,
forecastEnd: raw.forecastEnd,
progress: progressOfWorkOrder(ladders, derivationInput),
overdue: isOverdue(ladders, derivationInput, asOf),
evidence,
missingRequiredEvidence: isMissingRequiredEvidence(evidence),
};
}
/** One page of the table, as the route handler returns it. */
export interface WorkOrderPage {
workOrders: WorkOrderRow[];
/**
* The cursor that fetches the following page, or null on the last page.
* Its presence is how the client knows there is a next page at all.
*/
nextCursor: string | null;
/** Work orders matching the filters, ignoring pagination. */
total: number;
}