Workflow-App/Workflow/app/api/resolutions/[id]/document/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

197 lines
No EOL
6.2 KiB
TypeScript

// app/api/resolutions/[id]/document/route.ts
// GET /api/resolutions/<id>/document?collection=<name>&field=<name>
// DELETE /api/resolutions/<id>/document?collection=<name>&field=<name>
//
// GET streams a document ALREADY STORED on the record, so the operator can open
// the signed copy they uploaded and see exactly what the director signed.
//
// DELETE clears ONE file slot. It never deletes the record — only the file
// field, which is what "remove this document" means to the operator. PocketBase
// file deletion is irreversible, so the client confirms first.
//
// This is deliberately different from /letter, which REGENERATES a letter from
// current attendee data for preview purposes. Use /document to look at the
// filed artifact; use /letter only to see what a fresh letter would look like.
//
// Query params:
// collection — ysterbaard_resolutions | ysterbaard_director_resignations
// | ysterbaard_director_appointments (default:
// ysterbaard_resolutions)
// field — which file slot. Defaults per collection:
// resolutions -> signed_letter
// resignations -> signature
// appointments -> signature
// download — GET only. "false" renders inline in the browser (default),
// anything else sends it as an attachment.
import { NextRequest, NextResponse } from "next/server";
import { pbFetch, updateRecord } from "@/lib/pb";
/** Same allowlist as the upload route — collection -> file field -> collection id. */
const TARGETS: Record<string, { fields: Record<string, string>; defaultField: string }> = {
ysterbaard_resolutions: {
fields: {
signed_letter: "pbc_266170881",
generated_letter: "pbc_266170881",
},
defaultField: "signed_letter",
},
ysterbaard_director_resignations: {
fields: {
signature: "ysterbaard_director_resignations",
letter: "ysterbaard_director_resignations",
},
defaultField: "signature",
},
ysterbaard_director_appointments: {
fields: {
signature: "ysterbaard_director_appointments",
letter: "ysterbaard_director_appointments",
},
defaultField: "signature",
},
};
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const id = params.id;
if (!id) {
return NextResponse.json({ error: "Missing record id" }, { status: 400 });
}
const url = new URL(req.url);
const collection = url.searchParams.get("collection") ?? "ysterbaard_resolutions";
const target = TARGETS[collection];
if (!target) {
return NextResponse.json(
{ error: `Unknown collection "${collection}".` },
{ status: 400 }
);
}
const field = url.searchParams.get("field") ?? target.defaultField;
const col = target.fields[field];
if (!col) {
return NextResponse.json(
{ error: `Unknown field "${field}" for ${collection}.` },
{ status: 400 }
);
}
const download = url.searchParams.get("download") !== "false";
try {
const rec = await pbFetch<Record<string, unknown>>(
`/api/collections/${col}/records/${encodeURIComponent(id)}`
);
const stored = rec[field];
if (typeof stored !== "string" || stored.length === 0) {
return NextResponse.json(
{
error: `No document has been uploaded to "${field}" on this record yet.`,
field,
},
{ status: 404 }
);
}
const fileUrl = `${process.env.PB_URL}/api/files/${col}/${rec.id ?? id}/${encodeURIComponent(stored)}`;
const token = process.env.PB_TOKEN;
const fileRes = await fetch(fileUrl, {
headers: token ? { Authorization: token } : {},
cache: "no-store",
});
if (!fileRes.ok) {
// Surfaced rather than swallowed — a silent empty PDF is worse.
return NextResponse.json(
{
error: `Could not read the stored file from PocketBase (HTTP ${fileRes.status}).`,
},
{ status: 502 }
);
}
const buf = Buffer.from(await fileRes.arrayBuffer());
const filename = stored.replace(/[^\w.\- ]+/g, "_");
return new NextResponse(buf, {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Length": String(buf.length),
"Content-Disposition": download
? `attachment; filename="${filename}"`
: `inline; filename="${filename}"`,
"Cache-Control": "no-store",
},
});
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Could not open the document" },
{ status: 500 }
);
}
}
/**
* Clear one file slot. The record itself (and its other documents) is untouched.
*/
export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const id = params.id;
if (!id) {
return NextResponse.json({ error: "Missing record id" }, { status: 400 });
}
const url = new URL(req.url);
const collection = url.searchParams.get("collection") ?? "ysterbaard_resolutions";
const target = TARGETS[collection];
if (!target) {
return NextResponse.json(
{ error: `Unknown collection "${collection}".` },
{ status: 400 }
);
}
const field = url.searchParams.get("field") ?? target.defaultField;
const col = target.fields[field];
if (!col) {
return NextResponse.json(
{ error: `Unknown field "${field}" for ${collection}.` },
{ status: 400 }
);
}
try {
// Confirm the record exists and see whether anything is actually stored.
const rec = await pbFetch<Record<string, unknown>>(
`/api/collections/${col}/records/${encodeURIComponent(id)}`
);
const current = rec[field];
if (typeof current !== "string" || current.length === 0) {
return NextResponse.json(
{ error: `Nothing is stored in "${field}" on this record.`, field },
{ status: 404 }
);
}
// PocketBase removes the file when the field is set to an empty string.
await updateRecord(col, id, { [field]: "" });
return NextResponse.json({ ok: true, collection, field, removed: current });
} catch (err) {
return NextResponse.json(
{
error:
err instanceof Error ? err.message : "Could not remove the document",
},
{ status: 500 }
);
}
}