mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
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:
parent
bf3f8aeb20
commit
bbc2843fdf
9 changed files with 1570 additions and 0 deletions
63
src/app/api/projects/[projectId]/work-orders/authorize.ts
Normal file
63
src/app/api/projects/[projectId]/work-orders/authorize.ts
Normal 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 };
|
||||
}
|
||||
150
src/app/api/projects/[projectId]/work-orders/filters.test.ts
Normal file
150
src/app/api/projects/[projectId]/work-orders/filters.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
204
src/app/api/projects/[projectId]/work-orders/filters.ts
Normal file
204
src/app/api/projects/[projectId]/work-orders/filters.ts
Normal 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;
|
||||
}
|
||||
209
src/app/api/projects/[projectId]/work-orders/queries.test.ts
Normal file
209
src/app/api/projects/[projectId]/work-orders/queries.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
421
src/app/api/projects/[projectId]/work-orders/queries.ts
Normal file
421
src/app/api/projects/[projectId]/work-orders/queries.ts
Normal 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();
|
||||
}
|
||||
194
src/app/api/projects/[projectId]/work-orders/route.test.ts
Normal file
194
src/app/api/projects/[projectId]/work-orders/route.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
44
src/app/api/projects/[projectId]/work-orders/route.ts
Normal file
44
src/app/api/projects/[projectId]/work-orders/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
150
src/app/api/projects/[projectId]/work-orders/rows.test.ts
Normal file
150
src/app/api/projects/[projectId]/work-orders/rows.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
135
src/app/api/projects/[projectId]/work-orders/rows.ts
Normal file
135
src/app/api/projects/[projectId]/work-orders/rows.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue