Workflow-App/Workflow/app/api/resolutions/create/route.ts
sjcoetzeesj-coder 11d0178ab1 feat: commit Workflow app source; move CIPC credentials to env
Adds the full Next.js app (app/, components/, lib/, scripts/, assets/,
docs/, Dockerfile, compose, configs) to version control.

Security:
- CIPC BizPortal customer-code logins (Login 1 + Login 3) now read
  CIPC_LOGIN1_CODE / CIPC_LOGIN1_PASSWORD / CIPC_LOGIN3_CODE /
  CIPC_LOGIN3_PASSWORD from .env.local instead of source.
- BizPortal personal login in apply-bizportal route reads
  BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD.
- Added .env.example as the committed template.

.gitignore excludes .env*, node_modules, .next, logs, sqlite, Playwright
storage_state.json (live CIPC cookies), scratch dirs, client PDFs and
debug screenshots.
2026-10-18 15:21:45 +02:00

288 lines
No EOL
10 KiB
TypeScript

// app/api/resolutions/create/route.ts
// POST /api/resolutions/create
//
// Creates a Resolution of Directors (Phase 3 wizard). Creates:
// - 1 resolution record (status = "draft")
// - 1 attendee row per resigned director (must_sign=true, role=resigned)
// - 1 attendee row per appointed director (must_sign=true, role=appointed)
// - 1 attendee row per existing director (linked to company but not in
// resigned/appointed lists) — must_sign=true, role=existing, attended=yes
//
// Body shape:
// {
// company_id: string,
// path: "letters_received" | "letter_generated",
// meeting_date: "YYYY-MM-DD",
// resigned: [{ director_id, effective_date }],
// appointed: [{ director_id, effective_date }],
// notes?: string,
// }
//
// Returns: { resolution_id, attendee_ids, director_role_counts }
//
// The endpoint does NOT send anything. It creates a draft ready for the
// Phase 6 send-for-signature step. SJ can also edit attendees before sending.
import { NextRequest, NextResponse } from "next/server";
import { createRecord, fetchCollection, type PBRecord } from "@/lib/pb";
const RES_COL = "pbc_266170881";
const ATTENDEE_COL = "pbc_1608833669";
const JUNCTION_COL = "pbc_2497518006";
const COMPANIES_COL = "pbc_3748838150";
const DIRECTORS_COL = "pbc_4221287672";
interface IncomingBody {
company_id?: string;
path?: "letters_received" | "letter_generated";
meeting_date?: string;
resigned?: Array<{ director_id: string; effective_date: string }>;
appointed?: Array<{ director_id: string; effective_date: string }>;
notes?: string;
}
interface PBRecordLoose extends PBRecord {
id: string;
}
function isValidISODate(s: string): boolean {
return /^\d{4}-\d{2}-\d{2}$/.test(s);
}
function safeEscape(s: string): string {
return s.replace(/'/g, "''");
}
export async function POST(req: NextRequest) {
let body: IncomingBody;
try {
body = (await req.json()) as IncomingBody;
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
// ─── Validate ────────────────────────────────────────────────────────────
if (!body.company_id) {
return NextResponse.json({ error: "company_id is required" }, { status: 400 });
}
if (!body.path || !["letters_received", "letter_generated"].includes(body.path)) {
return NextResponse.json(
{ error: "path must be 'letters_received' or 'letter_generated'" },
{ status: 400 }
);
}
if (!body.meeting_date || !isValidISODate(body.meeting_date)) {
return NextResponse.json(
{ error: "meeting_date is required (YYYY-MM-DD)" },
{ status: 400 }
);
}
const resigned = body.resigned || [];
const appointed = body.appointed || [];
// Note: the API no longer requires at least one resigned AND at least one
// appointed. Resolutions can be:
// - resignation-only
// - appointment-only
// - both
// - neither (a "minute of meeting" with only existing directors present)
// The wizard passes empty arrays as needed. The full validation surface
// for an empty-everything resolution is left to the wizard; the API only
// enforces that the company exists. Per-director validation below still
// applies.
for (const r of resigned) {
if (!r.director_id || !isValidISODate(r.effective_date)) {
return NextResponse.json(
{ error: "Every resigned entry needs director_id and effective_date" },
{ status: 400 }
);
}
}
for (const a of appointed) {
if (!a.director_id || !isValidISODate(a.effective_date)) {
return NextResponse.json(
{ error: "Every appointed entry needs director_id and effective_date" },
{ status: 400 }
);
}
}
// No director in both lists at once
const resignedIds = new Set(resigned.map((r) => r.director_id));
const appointedIds = new Set(appointed.map((a) => a.director_id));
for (const id of resignedIds) {
if (appointedIds.has(id)) {
return NextResponse.json(
{ error: `Director ${id} appears in both resigned and appointed lists` },
{ status: 400 }
);
}
}
try {
// ─── 1. Confirm company exists ──────────────────────────────────────────
let company: PBRecordLoose;
try {
company = await fetchCollection<PBRecordLoose>(
COMPANIES_COL,
{ filter: `id="${safeEscape(body.company_id)}"`, perPage: 1 }
).then((r) => r.items[0]);
if (!company) throw new Error("not found");
} catch {
return NextResponse.json(
{ error: `Company ${body.company_id} not found` },
{ status: 404 }
);
}
// ─── 2. Create the resolution record (draft) ───────────────────────────
const resolution = await createRecord<PBRecordLoose>(RES_COL, {
company: body.company_id,
meeting_date: body.meeting_date,
status: "draft",
path: body.path,
resigned_directors: Array.from(resignedIds),
appointed_directors: Array.from(appointedIds),
notes: body.notes || null,
});
const resolutionId = resolution.id;
// ─── 3. Build attendees ────────────────────────────────────────────────
// Effective dates by director id
const effectiveByDir: Record<string, string> = {};
for (const r of resigned) effectiveByDir[r.director_id] = r.effective_date;
for (const a of appointed) effectiveByDir[a.director_id] = a.effective_date;
// Load director records (so we have name + email)
const allDirIds = [...resignedIds, ...appointedIds];
const directorMap: Record<string, {
id: string; first_names: string; surname: string; email: string | null;
}> = {};
if (allDirIds.length > 0) {
// PB filter doesn't support IN, so OR-chain
const filter = allDirIds.map((id) => `id="${id}"`).join(" || ");
const dirList = await fetchCollection<PBRecordLoose>(DIRECTORS_COL, { filter, perPage: allDirIds.length });
for (const d of dirList.items) {
directorMap[d.id] = d as unknown as {
id: string; first_names: string; surname: string; email: string | null;
};
}
}
// Find existing directors: linked to company but NOT in resigned/appointed.
const junction = await fetchCollection<PBRecordLoose>(JUNCTION_COL, {
filter: `company="${safeEscape(body.company_id)}"`,
perPage: 500,
});
const existingIds = new Set<string>();
for (const j of junction.items) {
const directorId = (j as unknown as { director: string }).director;
if (!resignedIds.has(directorId) && !appointedIds.has(directorId)) {
existingIds.add(directorId);
}
}
// Load existing director records we haven't already loaded
const needExisting = [...existingIds].filter((id) => !directorMap[id]);
if (needExisting.length > 0) {
const filter = needExisting.map((id) => `id="${id}"`).join(" || ");
const dirList = await fetchCollection<PBRecordLoose>(DIRECTORS_COL, { filter, perPage: needExisting.length });
for (const d of dirList.items) {
directorMap[d.id] = d as unknown as {
id: string; first_names: string; surname: string; email: string | null;
};
}
}
// Build attendee payloads
interface AttendeePayload {
[key: string]: unknown;
resolution: string;
director: string;
name: string;
surname: string;
email: string | null;
attended: boolean;
must_sign: boolean;
director_role: "existing" | "resigned" | "appointed";
effective_date: string;
}
const attendeePayloads: AttendeePayload[] = [];
// Resigned first (so they appear at top of the table)
let signOrder = 1;
for (const id of resignedIds) {
const d = directorMap[id];
if (!d) continue;
attendeePayloads.push({
resolution: resolutionId,
director: id,
name: d.first_names || "",
surname: d.surname || "",
email: d.email,
attended: true,
must_sign: true,
director_role: "resigned",
effective_date: effectiveByDir[id],
});
}
for (const id of appointedIds) {
const d = directorMap[id];
if (!d) continue;
attendeePayloads.push({
resolution: resolutionId,
director: id,
name: d.first_names || "",
surname: d.surname || "",
email: d.email,
attended: true,
must_sign: true,
director_role: "appointed",
effective_date: effectiveByDir[id],
});
}
for (const id of existingIds) {
const d = directorMap[id];
if (!d) continue;
attendeePayloads.push({
resolution: resolutionId,
director: id,
name: d.first_names || "",
surname: d.surname || "",
email: d.email,
attended: true,
must_sign: true,
director_role: "existing",
effective_date: body.meeting_date,
});
}
// Create all attendee rows
const attendeeIds: string[] = [];
for (const p of attendeePayloads) {
const created = await createRecord<PBRecordLoose>(ATTENDEE_COL, p);
attendeeIds.push(created.id);
}
// Counts by role
const counts: Record<string, number> = {
resigned: 0, appointed: 0, existing: 0, external: 0,
};
for (const p of attendeePayloads) counts[p.director_role] += 1;
return NextResponse.json({
resolution_id: resolutionId,
attendee_ids: attendeeIds,
director_role_counts: counts,
company: {
id: company.id,
enterprise_name: (company as { enterprise_name?: string }).enterprise_name ?? "",
},
});
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Unexpected error" },
{ status: 500 }
);
}
}