diff --git a/apps/web/src/app/api/submissions/[id]/export/route.ts b/apps/web/src/app/api/submissions/[id]/export/route.ts new file mode 100644 index 0000000..1e65f18 --- /dev/null +++ b/apps/web/src/app/api/submissions/[id]/export/route.ts @@ -0,0 +1,43 @@ +import { prisma } from "@msk-forms/db"; +import { NextResponse, type NextRequest } from "next/server"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * Export the applicant's own submission as JSON (data portability, §19). + * Capability model: access by the submission UUID. + */ +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + + const submission = await prisma.submission.findUnique({ + where: { id }, + select: { + id: true, + status: true, + answers: true, + submittedAt: true, + updatedAt: true, + form: { select: { title: true } }, + events: { + where: { visibility: "public" }, + orderBy: { createdAt: "asc" }, + select: { type: true, fromStatus: true, toStatus: true, message: true, createdAt: true }, + }, + files: { select: { filename: true, mime: true, size: true } }, + }, + }); + if (!submission) return NextResponse.json({ error: "Not found." }, { status: 404 }); + + return new NextResponse(JSON.stringify(submission, null, 2), { + headers: { + "Content-Type": "application/json", + "Content-Disposition": `attachment; filename="submission-${id}.json"`, + "Cache-Control": "no-store", + }, + }); +} diff --git a/apps/web/src/app/api/submissions/[id]/route.ts b/apps/web/src/app/api/submissions/[id]/route.ts new file mode 100644 index 0000000..6813b2b --- /dev/null +++ b/apps/web/src/app/api/submissions/[id]/route.ts @@ -0,0 +1,36 @@ +import { prisma } from "@msk-forms/db"; +import { NextResponse, type NextRequest } from "next/server"; + +import { clientIp, rateLimit } from "@/lib/rate-limit"; +import { deleteObject } from "@/lib/s3"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * Applicant deletes their own submission (right to erasure, §19). Removes the + * submission (cascading its events + file rows) and the stored file objects. + * Capability model: access by the submission UUID. + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const rl = await rateLimit(`delete:${clientIp(request.headers)}`, 10, 60); + if (!rl.allowed) { + return NextResponse.json({ error: "Too many requests." }, { status: 429 }); + } + + const submission = await prisma.submission.findUnique({ + where: { id }, + select: { files: { select: { storageKey: true } } }, + }); + if (!submission) return NextResponse.json({ error: "Not found." }, { status: 404 }); + + // Remove stored files first (best-effort), then the row (cascades events/files). + await Promise.all(submission.files.map((f) => deleteObject(f.storageKey))); + await prisma.submission.delete({ where: { id } }); + + return NextResponse.json({ ok: true }); +} diff --git a/apps/web/src/app/api/submissions/[id]/withdraw/route.ts b/apps/web/src/app/api/submissions/[id]/withdraw/route.ts new file mode 100644 index 0000000..0eb5b6a --- /dev/null +++ b/apps/web/src/app/api/submissions/[id]/withdraw/route.ts @@ -0,0 +1,52 @@ +import { prisma } from "@msk-forms/db"; +import { DEFAULT_STATUSES } from "@msk-forms/shared"; +import { NextResponse, type NextRequest } from "next/server"; + +import { clientIp, rateLimit } from "@/lib/rate-limit"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const TERMINAL = new Set(DEFAULT_STATUSES.filter((s) => s.terminal).map((s) => s.key)); + +/** + * Applicant withdraws their own submission. Capability model: access by the + * submission UUID (the private status link), no login required. + */ +export async function POST( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const rl = await rateLimit(`withdraw:${clientIp(_request.headers)}`, 10, 60); + if (!rl.allowed) { + return NextResponse.json({ error: "Too many requests." }, { status: 429 }); + } + + const submission = await prisma.submission.findUnique({ + where: { id }, + select: { status: true }, + }); + if (!submission) return NextResponse.json({ error: "Not found." }, { status: 404 }); + + if (TERMINAL.has(submission.status)) { + return NextResponse.json( + { error: "This submission can no longer be withdrawn." }, + { status: 409 }, + ); + } + + await prisma.$transaction([ + prisma.submission.update({ where: { id }, data: { status: "withdrawn" } }), + prisma.submissionEvent.create({ + data: { + submissionId: id, + type: "status_change", + fromStatus: submission.status, + toStatus: "withdrawn", + visibility: "public", + }, + }), + ]); + return NextResponse.json({ ok: true }); +} diff --git a/apps/web/src/app/s/[id]/page.tsx b/apps/web/src/app/s/[id]/page.tsx index 077484a..8e53b80 100644 --- a/apps/web/src/app/s/[id]/page.tsx +++ b/apps/web/src/app/s/[id]/page.tsx @@ -1,11 +1,15 @@ +import { DEFAULT_STATUSES } from "@msk-forms/shared"; import { StatusBadge } from "@msk-forms/ui"; import { notFound } from "next/navigation"; import { AnswerSummary } from "@/components/submission/answer-summary"; +import { SubmissionActions } from "@/components/submission/submission-actions"; import { brandStyle, parseBranding } from "@/lib/branding"; import { getSubmissionForStatus, resolveStatus } from "@/lib/forms"; import { getDict } from "@/i18n"; +const TERMINAL = new Set(DEFAULT_STATUSES.filter((s) => s.terminal).map((s) => s.key)); + export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -73,6 +77,23 @@ export default async function SubmissionStatusPage({ /> )} + +
+ +
); } diff --git a/apps/web/src/components/submission/submission-actions.tsx b/apps/web/src/components/submission/submission-actions.tsx new file mode 100644 index 0000000..38a8bc3 --- /dev/null +++ b/apps/web/src/components/submission/submission-actions.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { Button } from "@msk-forms/ui"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +export interface SubmissionActionLabels { + yourData: string; + withdraw: string; + withdrawConfirm: string; + exportData: string; + deleteData: string; + deleteConfirm: string; + deleted: string; + actionFailed: string; +} + +/** + * Applicant self-service on the public status page (capability = the UUID): + * withdraw, export own data as JSON, or delete the submission entirely (§19). + */ +export function SubmissionActions({ + id, + canWithdraw, + t, +}: { + id: string; + canWithdraw: boolean; + t: SubmissionActionLabels; +}) { + const router = useRouter(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [deleted, setDeleted] = useState(false); + + async function withdraw() { + if (!window.confirm(t.withdrawConfirm)) return; + setError(null); + setBusy(true); + try { + const res = await fetch(`/api/submissions/${id}/withdraw`, { method: "POST" }); + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error ?? t.actionFailed); + } + router.refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : t.actionFailed); + } finally { + setBusy(false); + } + } + + async function remove() { + if (!window.confirm(t.deleteConfirm)) return; + setError(null); + setBusy(true); + try { + const res = await fetch(`/api/submissions/${id}`, { method: "DELETE" }); + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error ?? t.actionFailed); + } + setDeleted(true); + } catch (err) { + setError(err instanceof Error ? err.message : t.actionFailed); + setBusy(false); + } + } + + if (deleted) return

{t.deleted}

; + + return ( +
+

+ {t.yourData} +

+
+ {canWithdraw && ( + + )} + + {t.exportData} + + +
+ {error &&

{error}

} +
+ ); +} diff --git a/apps/web/src/i18n/dictionaries.ts b/apps/web/src/i18n/dictionaries.ts index 896ae9a..51996e1 100644 --- a/apps/web/src/i18n/dictionaries.ts +++ b/apps/web/src/i18n/dictionaries.ts @@ -53,6 +53,14 @@ const en = { yourSubmission: "Your submission", activity: "Activity", yourAnswers: "Your answers", submitted: "Submitted", notAnswered: "Not answered", statusChangedTo: "Status changed to", update: "Update", yes: "Yes", no: "No", + yourData: "Your data", + withdraw: "Withdraw submission", + withdrawConfirm: "Withdraw this submission? You may not be able to re-apply right away.", + exportData: "Download my data (JSON)", + deleteData: "Delete my submission", + deleteConfirm: "Permanently delete this submission and its uploaded files? This cannot be undone.", + deleted: "Your submission has been deleted.", + actionFailed: "Action failed. Please try again.", }, preview: { submission: "Your submission", title: "Whitelist application", inReview: "In review", @@ -187,6 +195,14 @@ const de: Dictionary = { yourSubmission: "Deine Einreichung", activity: "Aktivität", yourAnswers: "Deine Antworten", submitted: "Eingereicht", notAnswered: "Nicht beantwortet", statusChangedTo: "Status geändert zu", update: "Update", yes: "Ja", no: "Nein", + yourData: "Deine Daten", + withdraw: "Einreichung zurückziehen", + withdrawConfirm: "Diese Einreichung zurückziehen? Eine erneute Bewerbung ist evtl. nicht sofort möglich.", + exportData: "Meine Daten herunterladen (JSON)", + deleteData: "Einreichung löschen", + deleteConfirm: "Diese Einreichung und alle hochgeladenen Dateien endgültig löschen? Das kann nicht rückgängig gemacht werden.", + deleted: "Deine Einreichung wurde gelöscht.", + actionFailed: "Aktion fehlgeschlagen. Bitte versuche es erneut.", }, preview: { submission: "Deine Einreichung", title: "Whitelist-Bewerbung", inReview: "In Prüfung", diff --git a/apps/web/src/lib/s3.ts b/apps/web/src/lib/s3.ts index d4004be..0926f18 100644 --- a/apps/web/src/lib/s3.ts +++ b/apps/web/src/lib/s3.ts @@ -1,6 +1,11 @@ import "server-only"; -import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; +import { + DeleteObjectCommand, + GetObjectCommand, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; // Reuse one client across hot reloads (mirrors the Prisma/Redis singletons). // `undefined` = unresolved; `null` = object storage not configured. @@ -45,6 +50,17 @@ export async function putObject(key: string, body: Uint8Array, contentType: stri ); } +/** Delete an object. Best-effort — never throws (storage may be unconfigured). */ +export async function deleteObject(key: string): Promise { + const s3 = getS3(); + if (!s3) return; + try { + await s3.send(new DeleteObjectCommand({ Bucket: s3Bucket(), Key: key })); + } catch (err) { + console.error("[s3] delete failed:", (err as Error).message); + } +} + /** Fetch an object as a web stream plus its metadata, or null if missing. */ export async function getObject( key: string,