// 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( 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(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 = {}; 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 = {}; 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(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(JUNCTION_COL, { filter: `company="${safeEscape(body.company_id)}"`, perPage: 500, }); const existingIds = new Set(); 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(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(ATTENDEE_COL, p); attendeeIds.push(created.id); } // Counts by role const counts: Record = { 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 } ); } }