diff --git a/apps/web/src/app/api/guilds/[guildId]/submissions/[id]/archive/route.ts b/apps/web/src/app/api/guilds/[guildId]/submissions/[id]/archive/route.ts new file mode 100644 index 0000000..26391d7 --- /dev/null +++ b/apps/web/src/app/api/guilds/[guildId]/submissions/[id]/archive/route.ts @@ -0,0 +1,48 @@ +import { prisma } from "@msk-forms/db"; +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; + +import { getCurrentUser } from "@/lib/auth"; +import { canReviewForm } from "@/lib/guild"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const bodySchema = z.object({ archived: z.boolean() }); + +/** + * Archive (soft-hide) or restore a submission. Archived submissions drop out of + * the active Submissions list and Board but are never deleted — they live on the + * dedicated "Archived" page. Reviewer-gated to the submission's form. + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ guildId: string; id: string }> }, +) { + const { guildId, id } = await params; + + const user = await getCurrentUser(); + if (!user) return NextResponse.json({ error: "Unauthorized." }, { status: 401 }); + + const submission = await prisma.submission.findUnique({ + where: { id }, + select: { guildId: true, formId: true }, + }); + if (!submission || submission.guildId !== guildId) { + return NextResponse.json({ error: "Submission not found." }, { status: 404 }); + } + if (!(await canReviewForm(guildId, user.id, submission.formId))) { + return NextResponse.json({ error: "Forbidden." }, { status: 403 }); + } + + const parsed = bodySchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid request." }, { status: 422 }); + } + + await prisma.submission.update({ + where: { id }, + data: { archivedAt: parsed.data.archived ? new Date() : null }, + }); + return NextResponse.json({ ok: true }); +} diff --git a/apps/web/src/app/dashboard/[guildId]/submissions/archived/page.tsx b/apps/web/src/app/dashboard/[guildId]/submissions/archived/page.tsx new file mode 100644 index 0000000..e44f8f8 --- /dev/null +++ b/apps/web/src/app/dashboard/[guildId]/submissions/archived/page.tsx @@ -0,0 +1,118 @@ +import { prisma } from "@msk-forms/db"; +import { Card } from "@msk-forms/ui"; +import { IconArrowLeft } from "@tabler/icons-react"; +import type { Route } from "next"; +import Link from "next/link"; + +import { SubmissionsTable } from "@/components/dashboard/submissions-table"; +import { requireUser } from "@/lib/auth"; +import { resolveStatus, statusOptions } from "@/lib/forms"; +import { getGuildSubmissions, getReviewScope } from "@/lib/guild"; +import { getDict } from "@/i18n"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export default async function ArchivedSubmissionsPage({ + params, +}: { + params: Promise<{ guildId: string }>; +}) { + const { guildId } = await params; + const user = await requireUser(`/dashboard/${guildId}/submissions/archived`); + const dict = await getDict(); + const t = dict.dashboard; + + const scope = await getReviewScope(guildId, user.id); + const canReview = scope.all || scope.formIds.length > 0; + if (!canReview) { + return ( + +

{t.board.noPerm}

+
+ ); + } + + const [submissions, defs] = await Promise.all([ + getGuildSubmissions(guildId, scope, { archived: true }), + prisma.formStatusDef.findMany({ + where: { guildId }, + orderBy: { order: "asc" }, + select: { key: true, label: true, color: true, order: true }, + }), + ]); + + const backLink = ( + + + {t.backToSubmissions} + + ); + + const header = ( +
+ {backLink} +

{t.archivedTitle}

+
+ ); + + if (submissions.length === 0) { + return ( +
+ {header} + +

{t.noArchived}

+
+
+ ); + } + + const options = statusOptions(defs, dict.statusLabels); + const present = new Set(submissions.map((s) => s.status)); + const extra = [...present] + .filter((k) => !options.some((o) => o.key === k)) + .map((k) => resolveStatus(k, defs, dict.statusLabels)); + + const rows = submissions.map((s) => ({ + id: s.id, + applicant: s.user?.username ?? t.anonymous, + avatar: s.user?.avatar ?? null, + formId: s.formId, + formTitle: s.form.title, + date: s.submittedAt.toISOString().slice(0, 10), + status: s.status, + score: s.score, + })); + + return ( +
+ {header} + +
+ ); +} diff --git a/apps/web/src/app/dashboard/[guildId]/submissions/page.tsx b/apps/web/src/app/dashboard/[guildId]/submissions/page.tsx index 51da8d2..260bffb 100644 --- a/apps/web/src/app/dashboard/[guildId]/submissions/page.tsx +++ b/apps/web/src/app/dashboard/[guildId]/submissions/page.tsx @@ -1,11 +1,13 @@ import { prisma } from "@msk-forms/db"; import { Card } from "@msk-forms/ui"; +import type { Route } from "next"; +import Link from "next/link"; import { DashboardLive } from "@/components/dashboard/dashboard-live"; import { SubmissionsTable } from "@/components/dashboard/submissions-table"; import { requireUser } from "@/lib/auth"; import { resolveStatus, statusOptions } from "@/lib/forms"; -import { getGuildSubmissions, getReviewScope } from "@/lib/guild"; +import { countArchivedSubmissions, getGuildSubmissions, getReviewScope } from "@/lib/guild"; import { signGuildRealtimeToken } from "@/lib/realtime-token"; import { getDict } from "@/i18n"; @@ -36,19 +38,33 @@ export default async function GuildSubmissionsPage({ const liveToken = signGuildRealtimeToken(guildId); const live = liveToken ? : null; - const [submissions, defs] = await Promise.all([ + const [submissions, defs, archivedCount] = await Promise.all([ getGuildSubmissions(guildId, scope), prisma.formStatusDef.findMany({ where: { guildId }, orderBy: { order: "asc" }, select: { key: true, label: true, color: true, order: true }, }), + countArchivedSubmissions(guildId, scope), ]); + const archivedLink = + archivedCount > 0 ? ( +
+ + {t.viewArchived} ({archivedCount}) + +
+ ) : null; + if (submissions.length === 0) { return ( <> {live} + {archivedLink}

{t.noSubmissions}

@@ -77,6 +93,7 @@ export default async function GuildSubmissionsPage({ return ( <> {live} + {archivedLink} (null); const [formFilter, setFormFilter] = useState("all"); + const [busyId, setBusyId] = useState(null); // Distinct forms present in the current submissions, for the filter dropdown. const forms = [...new Map(rows.map((r) => [r.formId, r.formTitle])).entries()] @@ -87,6 +93,29 @@ export function SubmissionsTable({ setSelected(allSelected ? new Set() : new Set(visibleRows.map((r) => r.id))); } + async function setArchived(id: string, value: boolean) { + setError(null); + setBusyId(id); + try { + const res = await fetch(`/api/guilds/${guildId}/submissions/${id}/archive`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ archived: value }), + }); + if (!res.ok) throw new Error(labels.bulkFailed); + setSelected((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + router.refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : labels.bulkFailed); + } finally { + setBusyId(null); + } + } + async function apply() { setError(null); setApplying(true); @@ -216,13 +245,25 @@ export function SubmissionsTable({ - - - {labels.open} - + +
+ + {labels.open} + + {canReview && ( + + )} +
); diff --git a/apps/web/src/i18n/dictionaries.ts b/apps/web/src/i18n/dictionaries.ts index 4e69d05..755694e 100644 --- a/apps/web/src/i18n/dictionaries.ts +++ b/apps/web/src/i18n/dictionaries.ts @@ -283,6 +283,8 @@ const en = { colApplicant: "Applicant", colForm: "Form", colDate: "Date", colStatus: "Status", colScore: "Score", allForms: "All forms", anonymous: "Anonymous", noSubmissions: "No submissions yet.", open: "Open", + archive: "Archive", restore: "Restore", viewArchived: "Archived", + archivedTitle: "Archived submissions", noArchived: "No archived submissions.", backToSubmissions: "Back to submissions", mySubmissionsTitle: "My submissions", noMySubmissions: "You haven’t submitted any forms yet.", newFormTitle: "New form", editFormTitle: "Edit form", noPermCreate: "You don’t have permission to create forms.", @@ -829,6 +831,8 @@ const de: Dictionary = { colApplicant: "Bewerber", colForm: "Formular", colDate: "Datum", colStatus: "Status", colScore: "Punkte", allForms: "Alle Formulare", anonymous: "Anonym", noSubmissions: "Noch keine Einreichungen.", open: "Öffnen", + archive: "Archivieren", restore: "Wiederherstellen", viewArchived: "Archiviert", + archivedTitle: "Archivierte Einreichungen", noArchived: "Keine archivierten Einreichungen.", backToSubmissions: "Zurück zu den Einreichungen", mySubmissionsTitle: "Meine Einreichungen", noMySubmissions: "Du hast noch keine Formulare abgeschickt.", newFormTitle: "Neues Formular", editFormTitle: "Formular bearbeiten", noPermCreate: "Du hast keine Berechtigung, Formulare zu erstellen.", @@ -1373,6 +1377,8 @@ const hu: Dictionary = { colApplicant: "Jelentkező", colForm: "Űrlap", colDate: "Dátum", colStatus: "Állapot", colScore: "Pont", allForms: "Minden űrlap", anonymous: "Névtelen", noSubmissions: "Még nincsenek beküldések.", open: "Megnyitás", + archive: "Archiválás", restore: "Visszaállítás", viewArchived: "Archivált", + archivedTitle: "Archivált beküldések", noArchived: "Nincsenek archivált beküldések.", backToSubmissions: "Vissza a beküldésekhez", mySubmissionsTitle: "Beküldéseim", noMySubmissions: "Még nem küldtél be egyetlen űrlapot sem.", newFormTitle: "Új űrlap", editFormTitle: "Űrlap szerkesztése", noPermCreate: "Nincs jogosultságod űrlapok létrehozásához.", @@ -1917,6 +1923,8 @@ const fr: Dictionary = { colApplicant: "Candidat", colForm: "Formulaire", colDate: "Date", colStatus: "Statut", colScore: "Score", allForms: "Tous les formulaires", anonymous: "Anonyme", noSubmissions: "Aucune soumission pour l'instant.", open: "Ouvrir", + archive: "Archiver", restore: "Restaurer", viewArchived: "Archivées", + archivedTitle: "Candidatures archivées", noArchived: "Aucune candidature archivée.", backToSubmissions: "Retour aux candidatures", mySubmissionsTitle: "Mes soumissions", noMySubmissions: "Vous n'avez encore soumis aucun formulaire.", newFormTitle: "Nouveau formulaire", editFormTitle: "Modifier le formulaire", noPermCreate: "Vous n'avez pas la permission de créer des formulaires.", @@ -2462,6 +2470,8 @@ const es: Dictionary = { colApplicant: "Solicitante", colForm: "Formulario", colDate: "Fecha", colStatus: "Estado", colScore: "Puntuación", allForms: "Todos los formularios", anonymous: "Anónimo", noSubmissions: "Aún no hay envíos.", open: "Abrir", + archive: "Archivar", restore: "Restaurar", viewArchived: "Archivadas", + archivedTitle: "Envíos archivados", noArchived: "No hay envíos archivados.", backToSubmissions: "Volver a los envíos", mySubmissionsTitle: "Mis envíos", noMySubmissions: "Aún no has enviado ningún formulario.", newFormTitle: "Nuevo formulario", editFormTitle: "Editar formulario", noPermCreate: "No tienes permiso para crear formularios.", @@ -3007,6 +3017,8 @@ const pt: Dictionary = { colApplicant: "Candidato", colForm: "Formulário", colDate: "Data", colStatus: "Status", colScore: "Pontuação", allForms: "Todos os formulários", anonymous: "Anônimo", noSubmissions: "Nenhuma candidatura ainda.", open: "Abrir", + archive: "Arquivar", restore: "Restaurar", viewArchived: "Arquivadas", + archivedTitle: "Candidaturas arquivadas", noArchived: "Nenhuma candidatura arquivada.", backToSubmissions: "Voltar às candidaturas", mySubmissionsTitle: "Minhas candidaturas", noMySubmissions: "Você ainda não enviou nenhum formulário.", newFormTitle: "Novo formulário", editFormTitle: "Editar formulário", noPermCreate: "Você não tem permissão para criar formulários.", @@ -3552,6 +3564,8 @@ const pl: Dictionary = { colApplicant: "Kandydat", colForm: "Formularz", colDate: "Data", colStatus: "Status", colScore: "Wynik", allForms: "Wszystkie formularze", anonymous: "Anonimowy", noSubmissions: "Brak zgłoszeń.", open: "Otwórz", + archive: "Archiwizuj", restore: "Przywróć", viewArchived: "Zarchiwizowane", + archivedTitle: "Zarchiwizowane zgłoszenia", noArchived: "Brak zarchiwizowanych zgłoszeń.", backToSubmissions: "Powrót do zgłoszeń", mySubmissionsTitle: "Moje zgłoszenia", noMySubmissions: "Nie wysłałeś jeszcze żadnych formularzy.", newFormTitle: "Nowy formularz", editFormTitle: "Edytuj formularz", noPermCreate: "Nie masz uprawnień do tworzenia formularzy.", diff --git a/apps/web/src/lib/guild.ts b/apps/web/src/lib/guild.ts index 011e236..2c210cc 100644 --- a/apps/web/src/lib/guild.ts +++ b/apps/web/src/lib/guild.ts @@ -155,10 +155,18 @@ export async function getGuildForms(guildId: string) { * Pass a {@link ReviewScope}: `all` returns everything; otherwise only the * granted forms' submissions (empty grants → no rows). */ -export async function getGuildSubmissions(guildId: string, scope: ReviewScope) { +export async function getGuildSubmissions( + guildId: string, + scope: ReviewScope, + opts: { archived?: boolean } = {}, +) { if (!scope.all && scope.formIds.length === 0) return []; return prisma.submission.findMany({ - where: { guildId, ...(scope.all ? {} : { formId: { in: scope.formIds } }) }, + where: { + guildId, + ...(scope.all ? {} : { formId: { in: scope.formIds } }), + archivedAt: opts.archived ? { not: null } : null, + }, orderBy: { submittedAt: "desc" }, take: 100, select: { @@ -173,6 +181,21 @@ export async function getGuildSubmissions(guildId: string, scope: ReviewScope) { }); } +/** Count a guild's archived submissions within the reviewer's scope. */ +export async function countArchivedSubmissions( + guildId: string, + scope: ReviewScope, +): Promise { + if (!scope.all && scope.formIds.length === 0) return 0; + return prisma.submission.count({ + where: { + guildId, + ...(scope.all ? {} : { formId: { in: scope.formIds } }), + archivedAt: { not: null }, + }, + }); +} + /** Submissions the user themselves made, across all guilds, for "My Submissions". */ export async function getUserSubmissions(userId: string) { return prisma.submission.findMany({ diff --git a/packages/db/prisma/migrations/20260703120000_submission_archive/migration.sql b/packages/db/prisma/migrations/20260703120000_submission_archive/migration.sql new file mode 100644 index 0000000..3791738 --- /dev/null +++ b/packages/db/prisma/migrations/20260703120000_submission_archive/migration.sql @@ -0,0 +1,4 @@ +-- Archive a submission: soft-hide it from the active list without deleting it. +ALTER TABLE "submissions" ADD COLUMN "archived_at" TIMESTAMP(3); + +CREATE INDEX "submissions_guild_id_archived_at_idx" ON "submissions" ("guild_id", "archived_at"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 6c7487d..3871f92 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -263,8 +263,9 @@ model Submission { status String @default("submitted") score Int? meta Json @default("{}") - submittedAt DateTime @default(now()) @map("submitted_at") - updatedAt DateTime @updatedAt @map("updated_at") + submittedAt DateTime @default(now()) @map("submitted_at") + updatedAt DateTime @updatedAt @map("updated_at") + archivedAt DateTime? @map("archived_at") form Form @relation(fields: [formId], references: [id], onDelete: Cascade) guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) @@ -275,6 +276,7 @@ model Submission { @@index([formId, status]) @@index([userId]) @@index([guildId, submittedAt]) + @@index([guildId, archivedAt]) @@map("submissions") }