// app/api/shareholder-changes/create // POST /api/shareholder-changes/create // // Creates a draft RESOLUTION PASSED BY THE SHAREHOLDERS for a change of // shareholding. Creates: // - 1 resolution row (kind = "shareholder_change", status = "draft") // - 1 attendee row per meeting attendee (director_role = "existing") // - 1 attendee row per transferor / transferee // // Body: // { // company_id: string, // meeting_date: "YYYY-MM-DD", // path: "letter_generated" | "letters_received", // votes_percent: string, // attendees: [{ director_id, name, surname, email, position, signs }], // transferors: [{ source, source_id, name, surname, email, id_number, // shares_held, shares, certificate_no, percentage, // distinctive_from, distinctive_to, // shares_retained, retained_from, retained_to, // consideration, payment_method }], // transferees: [{ source, source_id, name, surname, email, id_number, shares }], // notes?: string, // } // // Returns: { resolution_id, attendee_ids, totals } // // The endpoint does NOT generate or send anything — it produces a draft ready // for the operator to preview, then mail from the row's action menu, exactly // like section 4. Nothing is emailed automatically. // // THREE SEPARATE LISTS // ------------------- // attendees who was present to approve (directors only) // transferors who is selling (director OR company, one or more) // transferees who is buying (director OR company, one or more) // // A director can be an attendee AND a transferor; they become two attendee rows // because the letter states both things about them. Conflating the lists would // put the wrong names in the table at the top of a legal document. import { NextRequest, NextResponse } from "next/server"; import { COL } from "@/lib/shareholder-changes"; import { attendeeRowFields, parseChangeBody, serialiseDetail, str, } from "@/lib/shareholder-change-payload"; import { createRecord, fetchCollection, type PBRecord } from "@/lib/pb"; interface IncomingBody { company_id?: string; meeting_date?: string; path?: string; votes_percent?: string; attendees?: unknown; transferors?: unknown; transferees?: unknown; notes?: string; } 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 ───────────────────────────────────────────────────────────── const companyId = str(body.company_id); if (!companyId) { return NextResponse.json({ error: "company_id is required" }, { status: 400 }); } // The same parser and the same rules the edit path uses, so a resolution can // never be created in a shape that an edit would reject. let change: ReturnType; try { change = parseChangeBody(body as Record); } catch (err) { return NextResponse.json( { error: err instanceof Error ? err.message : "Invalid party data" }, { status: 400 } ); } const { attendees, transferors, transferees, sold, acquired, signers } = change; try { try { const found = await fetchCollection(COL.companies, { filter: `id="${safeEscape(companyId)}"`, perPage: 1, }); if (!found.items[0]) throw new Error("not found"); } catch { return NextResponse.json({ error: `Company ${companyId} not found` }, { status: 404 }); } // ─── Resolution row ───────────────────────────────────────────────────── // The operator's free-text note travels INSIDE the JSON document. Appending // it after the JSON made the whole `notes` field unparseable, which silently // cost us every certificate-level detail in the letter. const notesPayload = serialiseDetail(companyId, body.votes_percent, change, str(body.notes, 2000)); const resolution = await createRecord(COL.resolutions, { company: companyId, meeting_date: body.meeting_date, status: "draft", path: body.path, kind: "shareholder_change", resigned_directors: [], appointed_directors: [], notes: notesPayload, }); const resolutionId = resolution.id; // ─── Attendee rows ─────────────────────────────────────────────────────── const attendeeIds: string[] = []; for (const row of attendeeRowFields(resolutionId, String(body.meeting_date), change)) { const created = await createRecord(COL.attendees, row); attendeeIds.push(created.id); } return NextResponse.json({ resolution_id: resolutionId, attendee_ids: attendeeIds, totals: { attendees: attendees.length, signers: signers.length, transferors: transferors.length, transferees: transferees.length, shares_sold: sold, shares_acquired: acquired, }, // Parties that cannot be emailed — surfaced now, not at send time. without_email: change.withoutEmail, }); } catch (err) { return NextResponse.json( { error: err instanceof Error ? err.message : "Unexpected error" }, { status: 500 } ); } }