Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions apps/web/src/app/api/submissions/[id]/export/route.ts
Original file line number Diff line number Diff line change
@@ -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",
},
});
}
36 changes: 36 additions & 0 deletions apps/web/src/app/api/submissions/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
52 changes: 52 additions & 0 deletions apps/web/src/app/api/submissions/[id]/withdraw/route.ts
Original file line number Diff line number Diff line change
@@ -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<string>(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 });
}
21 changes: 21 additions & 0 deletions apps/web/src/app/s/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<string>(DEFAULT_STATUSES.filter((s) => s.terminal).map((s) => s.key));

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -73,6 +77,23 @@ export default async function SubmissionStatusPage({
/>
</section>
)}

<section className="rounded-lg border border-border bg-card p-6 shadow-sm">
<SubmissionActions
id={submission.id}
canWithdraw={!TERMINAL.has(submission.status)}
t={{
yourData: t.yourData,
withdraw: t.withdraw,
withdrawConfirm: t.withdrawConfirm,
exportData: t.exportData,
deleteData: t.deleteData,
deleteConfirm: t.deleteConfirm,
deleted: t.deleted,
actionFailed: t.actionFailed,
}}
/>
</section>
</main>
);
}
102 changes: 102 additions & 0 deletions apps/web/src/components/submission/submission-actions.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 <p className="text-sm text-muted-foreground">{t.deleted}</p>;

return (
<div className="flex flex-col gap-3">
<h2 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t.yourData}
</h2>
<div className="flex flex-wrap gap-3">
{canWithdraw && (
<Button variant="ghost" onClick={withdraw} disabled={busy}>
{t.withdraw}
</Button>
)}
<a
href={`/api/submissions/${id}/export`}
className="inline-flex items-center justify-center gap-2 rounded-md border border-input bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground"
>
{t.exportData}
</a>
<Button
variant="ghost"
onClick={remove}
disabled={busy}
className="text-destructive hover:text-destructive"
>
{t.deleteData}
</Button>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
);
}
16 changes: 16 additions & 0 deletions apps/web/src/i18n/dictionaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
18 changes: 17 additions & 1 deletion apps/web/src/lib/s3.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<void> {
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,
Expand Down