Workflow-App/Workflow/lib/resolution-documents.ts

211 lines
7.1 KiB
TypeScript
Raw Normal View History

// lib/resolution-documents.ts
//
// Collects EVERY document that belongs to a resolution — not just the one file
// slot on the resolution record itself.
//
// Why this exists: the paperwork for one resolution is spread across several
// PocketBase records. The resolution carries its own copy, but each director's
// resignation/appointment lives on its OWN record in
// `ysterbaard_director_resignations` / `ysterbaard_director_appointments`, with
// its own `letter` and `signature` file fields.
//
// "View signed document" used to read only `signed_letter` off the resolution,
// so the operator saw one document and concluded the rest had gone missing.
//
// This module is the single source of truth for that list; both the
// /documents page and any API consumer read from here.
import { pbFetch, type PBList, type PBRecord } from "./pb";
export const RESOLUTIONS = "pbc_266170881";
export const RESIGNATIONS = "ysterbaard_director_resignations";
export const APPOINTMENTS = "ysterbaard_director_appointments";
export interface ResolutionDocument {
/** Stable key for React lists. */
key: string;
/** Human label, e.g. "Sune Pieters — signed resignation". */
label: string;
/** Which group it belongs to, for section headings. */
group: "Resolution" | "Resignation" | "Appointment";
/** PocketBase collection KEY as the /document route expects it. */
collection: string;
/** Record id inside that collection. */
recordId: string;
/** File field name. */
field: string;
/** Stored filename. */
filename: string;
/** App URL that streams this file inline. */
href: string;
}
interface DirectorLike {
id: string;
first_names?: string;
surname?: string;
}
function directorName(d: unknown): string {
if (!d || typeof d !== "object") return "";
const x = d as DirectorLike;
return [x.first_names, x.surname].filter(Boolean).join(" ").trim();
}
/** Build the app URL that streams one stored file inline. */
function hrefFor(collection: string, recordId: string, field: string): string {
const q = new URLSearchParams({ collection, field, download: "false" });
return `/api/resolutions/${encodeURIComponent(recordId)}/document?${q.toString()}`;
}
function pushFile(
out: ResolutionDocument[],
opts: {
group: ResolutionDocument["group"];
label: string;
collection: string;
recordId: string;
field: string;
value: unknown;
}
) {
const filename = typeof opts.value === "string" ? opts.value : "";
if (!filename) return; // field is empty — nothing to show
out.push({
key: `${opts.collection}:${opts.recordId}:${opts.field}`,
label: opts.label,
group: opts.group,
collection: opts.collection,
recordId: opts.recordId,
field: opts.field,
filename,
href: hrefFor(opts.collection, opts.recordId, opts.field),
});
}
function asArray(v: unknown): string[] {
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
}
/**
* Every document tied to a resolution.
*
* Matching rules for the per-director records:
* - same company, AND
* - the director appears in the resolution's resigned_directors
* (for resignations) or appointed_directors (for appointments).
*
* A resignation is also matched when the director is merely named on the
* resolution: the operator may have attached paperwork without ticking the
* right box, and losing sight of an uploaded document is worse than showing one
* that turns out to be unrelated.
*/
export async function collectResolutionDocuments(
resolutionId: string
): Promise<{ resolution: PBRecord; documents: ResolutionDocument[] }> {
const resolution = await pbFetch<PBRecord>(
`/api/collections/${RESOLUTIONS}/records/${encodeURIComponent(resolutionId)}`
);
const docs: ResolutionDocument[] = [];
// 1. The resolution's own files.
pushFile(docs, {
group: "Resolution",
label: "Signed resolution",
collection: "ysterbaard_resolutions",
recordId: resolution.id,
field: "signed_letter",
value: resolution.signed_letter,
});
pushFile(docs, {
group: "Resolution",
label: "Signed share certificates",
collection: "ysterbaard_resolutions",
recordId: resolution.id,
field: "signed_certificates",
value: resolution.signed_certificates,
});
pushFile(docs, {
group: "Resolution",
label: "Generated resolution letter",
collection: "ysterbaard_resolutions",
recordId: resolution.id,
field: "generated_letter",
value: resolution.generated_letter,
});
const resignedIds = asArray(resolution.resigned_directors);
const appointedIds = asArray(resolution.appointed_directors);
const namedIds = Array.from(new Set([...resignedIds, ...appointedIds]));
const company = typeof resolution.company === "string" ? resolution.company : "";
if (company && namedIds.length > 0) {
// 2. Resignations for any named director at the same company.
// NOTE: PB filter fields are NOT collection-qualified. Writing
// `ysterbaard_director_resignations.company = "..."` returns HTTP 400 — the
// filter is already scoped to the collection being queried.
const resignations = await pbFetch<PBList<PBRecord>>(
`/api/collections/${RESIGNATIONS}/records`,
{
perPage: 200,
filter: `company = "${company}"`,
expand: "director",
}
);
for (const r of resignations.items ?? []) {
const dirId = typeof r.director === "string" ? r.director : "";
if (dirId && !namedIds.includes(dirId)) continue;
const who = directorName((r.expand as Record<string, unknown> | undefined)?.director) || "Director";
pushFile(docs, {
group: "Resignation",
label: `${who} — resignation letter`,
collection: "ysterbaard_director_resignations",
recordId: r.id,
field: "letter",
value: r.letter,
});
pushFile(docs, {
group: "Resignation",
label: `${who} — signed resignation`,
collection: "ysterbaard_director_resignations",
recordId: r.id,
field: "signature",
value: r.signature,
});
}
// 3. Appointments for any named director at the same company.
const appointments = await pbFetch<PBList<PBRecord>>(
`/api/collections/${APPOINTMENTS}/records`,
{
perPage: 200,
filter: `company = "${company}"`,
expand: "director",
}
);
for (const a of appointments.items ?? []) {
const dirId = typeof a.director === "string" ? a.director : "";
if (dirId && !namedIds.includes(dirId)) continue;
const who = directorName((a.expand as Record<string, unknown> | undefined)?.director) || "Director";
pushFile(docs, {
group: "Appointment",
label: `${who} — appointment letter`,
collection: "ysterbaard_director_appointments",
recordId: a.id,
field: "letter",
value: a.letter,
});
pushFile(docs, {
group: "Appointment",
label: `${who} — signed appointment`,
collection: "ysterbaard_director_appointments",
recordId: a.id,
field: "signature",
value: a.signature,
});
}
}
return { resolution, documents: docs };
}