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