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.
288 lines
No EOL
10 KiB
TypeScript
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 }
|
|
);
|
|
}
|
|
} |