From bf098ad616cb4e4c6795b4ed75cbce8f7601ff88 Mon Sep 17 00:00:00 2001 From: Musiker15 Date: Tue, 21 Jul 2026 17:20:47 +0200 Subject: [PATCH] feat(review): automatic per-status messages to the applicant Send an automatic message to the applicant when a submission enters a status, with a different message per status, editable per change, and overridable per form. - Guild-wide templates: new Guild.statusMessages map { statusKey: message } (+ migration), edited on a new manager-only "Messages" tab that lists every status (built-in + custom). - Per-form override: Form.settings.statusMessages (no schema change), edited in the form builder; overrides the guild template for that form. - Applied in the shared changeSubmissionStatus chokepoint, so it covers web review, bulk actions, the bot's Accept/Reject buttons and automations: a public status change resolves the template (form -> guild) and records it as a message event + applicant DM. A resolved message supersedes the generic status-change DM (one meaningful notification); hidden changes stay silent. - At-change override: the review panel prefills the target status' template into an editable box; send it as-is, edit it, or clear it to send no message (status action gained an optional `message`). Shared resolveStatusMessage/parseStatusMessages (+ tests). Localized in all 7 languages. Typecheck, lint, tests (shared 186) and build green. --- .../guilds/[guildId]/status-messages/route.ts | 39 ++++++++ .../submissions/[id]/events/route.ts | 3 + .../[guildId]/forms/[formId]/edit/page.tsx | 1 + .../dashboard/[guildId]/forms/new/page.tsx | 1 + .../src/app/dashboard/[guildId]/layout.tsx | 1 + .../app/dashboard/[guildId]/messages/page.tsx | 59 +++++++++++ .../[guildId]/submissions/[id]/page.tsx | 19 ++++ .../src/components/builder/form-builder.tsx | 33 +++++++ .../messages/status-messages-form.tsx | 87 ++++++++++++++++ .../src/components/review/review-panel.tsx | 31 +++++- apps/web/src/i18n/dictionaries.ts | 42 ++++---- apps/web/src/lib/submission-action.ts | 5 + .../migration.sql | 3 + packages/db/prisma/schema.prisma | 4 + packages/db/src/submission-status.ts | 98 +++++++++++++++++-- packages/shared/src/form-settings.test.ts | 21 ++++ packages/shared/src/form-settings.ts | 48 +++++++++ 17 files changed, 466 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/app/api/guilds/[guildId]/status-messages/route.ts create mode 100644 apps/web/src/app/dashboard/[guildId]/messages/page.tsx create mode 100644 apps/web/src/components/messages/status-messages-form.tsx create mode 100644 packages/db/prisma/migrations/20260721120000_guild_status_messages/migration.sql diff --git a/apps/web/src/app/api/guilds/[guildId]/status-messages/route.ts b/apps/web/src/app/api/guilds/[guildId]/status-messages/route.ts new file mode 100644 index 0000000..c8cc380 --- /dev/null +++ b/apps/web/src/app/api/guilds/[guildId]/status-messages/route.ts @@ -0,0 +1,39 @@ +import { Prisma, prisma } from "@msk-forms/db"; +import { parseStatusMessages, statusMessagesSchema } from "@msk-forms/shared"; +import { NextResponse, type NextRequest } from "next/server"; + +import { getCurrentUser } from "@/lib/auth"; +import { canManageForms } from "@/lib/guild"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * Save the guild-wide automatic per-status messages (manager-only). Blank + * entries are dropped, so clearing a status' textbox removes its template. + */ +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ guildId: string }> }, +) { + const { guildId } = await params; + + const user = await getCurrentUser(); + if (!user) return NextResponse.json({ error: "Unauthorized." }, { status: 401 }); + if (!(await canManageForms(guildId, user.id))) { + return NextResponse.json({ error: "Forbidden." }, { status: 403 }); + } + + const body = (await request.json().catch(() => null)) as { messages?: unknown } | null; + const parsed = statusMessagesSchema.safeParse(body?.messages); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid messages." }, { status: 422 }); + } + + const messages = parseStatusMessages(parsed.data); + await prisma.guild.update({ + where: { id: guildId }, + data: { statusMessages: messages as Prisma.InputJsonValue }, + }); + return NextResponse.json({ ok: true }); +} diff --git a/apps/web/src/app/api/guilds/[guildId]/submissions/[id]/events/route.ts b/apps/web/src/app/api/guilds/[guildId]/submissions/[id]/events/route.ts index 14341bd..a0c3be9 100644 --- a/apps/web/src/app/api/guilds/[guildId]/submissions/[id]/events/route.ts +++ b/apps/web/src/app/api/guilds/[guildId]/submissions/[id]/events/route.ts @@ -97,6 +97,9 @@ export async function POST( actorName: user.username, toStatusLabel, eventVisibility: action.hidden ? "internal" : "public", + // Omitted -> auto per-status template; provided -> reviewer override + // (empty string opts out). Hidden changes never carry a message anyway. + applicantMessage: action.message, notify: notify ? { userId: submission.userId!, type: "status_change", payload: notify } : null, diff --git a/apps/web/src/app/dashboard/[guildId]/forms/[formId]/edit/page.tsx b/apps/web/src/app/dashboard/[guildId]/forms/[formId]/edit/page.tsx index 062962f..9c80aa2 100644 --- a/apps/web/src/app/dashboard/[guildId]/forms/[formId]/edit/page.tsx +++ b/apps/web/src/app/dashboard/[guildId]/forms/[formId]/edit/page.tsx @@ -76,6 +76,7 @@ export default async function EditFormPage({ pages: spec?.pages.length ? spec.pages : [{ id: "p1", title: "", fields: [] }], automations: settings.automations, experiment: settings.experiment ?? { enabled: false, variants: [] }, + statusMessages: settings.statusMessages ?? {}, }} /> diff --git a/apps/web/src/app/dashboard/[guildId]/forms/new/page.tsx b/apps/web/src/app/dashboard/[guildId]/forms/new/page.tsx index e53a8ba..25bf240 100644 --- a/apps/web/src/app/dashboard/[guildId]/forms/new/page.tsx +++ b/apps/web/src/app/dashboard/[guildId]/forms/new/page.tsx @@ -90,6 +90,7 @@ export default async function NewFormPage({ pages: [{ id: "p1", title: "", fields: [] }], automations: [], experiment: { enabled: false, variants: [] }, + statusMessages: {}, }} /> diff --git a/apps/web/src/app/dashboard/[guildId]/layout.tsx b/apps/web/src/app/dashboard/[guildId]/layout.tsx index aaddd55..4a43da9 100644 --- a/apps/web/src/app/dashboard/[guildId]/layout.tsx +++ b/apps/web/src/app/dashboard/[guildId]/layout.tsx @@ -38,6 +38,7 @@ export default async function GuildLayout({ ...(canManage ? [ { href: `/dashboard/${guildId}/statuses`, label: t.statusesTab, prefix: true }, + { href: `/dashboard/${guildId}/messages`, label: t.messagesTab, prefix: true }, { href: `/dashboard/${guildId}/categories`, label: t.categoriesTab, prefix: true }, { href: `/dashboard/${guildId}/branding`, label: t.brandingTab, prefix: true }, { href: `/dashboard/${guildId}/bot`, label: t.botTab, prefix: true }, diff --git a/apps/web/src/app/dashboard/[guildId]/messages/page.tsx b/apps/web/src/app/dashboard/[guildId]/messages/page.tsx new file mode 100644 index 0000000..f2c8909 --- /dev/null +++ b/apps/web/src/app/dashboard/[guildId]/messages/page.tsx @@ -0,0 +1,59 @@ +import { prisma } from "@msk-forms/db"; +import { parseStatusMessages } from "@msk-forms/shared"; +import { Card } from "@msk-forms/ui"; + +import { StatusMessagesForm } from "@/components/messages/status-messages-form"; +import { requireUser } from "@/lib/auth"; +import { getStatusOptionsForGuild } from "@/lib/forms"; +import { canManageForms } from "@/lib/guild"; +import { getDict } from "@/i18n"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export default async function StatusMessagesPage({ + params, +}: { + params: Promise<{ guildId: string }>; +}) { + const { guildId } = await params; + const user = await requireUser(`/dashboard/${guildId}/messages`); + const dict = await getDict(); + const t = dict.dashboard; + + if (!(await canManageForms(guildId, user.id))) { + return ( + +

{t.msgNoPerm}

+
+ ); + } + + const [statuses, guild] = await Promise.all([ + getStatusOptionsForGuild(guildId, dict.statusLabels), + prisma.guild.findUnique({ where: { id: guildId }, select: { statusMessages: true } }), + ]); + const messages = parseStatusMessages(guild?.statusMessages); + + return ( +
+
+

{t.msgTitle}

+

{t.msgIntro}

+
+ +
+ ); +} diff --git a/apps/web/src/app/dashboard/[guildId]/submissions/[id]/page.tsx b/apps/web/src/app/dashboard/[guildId]/submissions/[id]/page.tsx index 88d0272..efb2fb6 100644 --- a/apps/web/src/app/dashboard/[guildId]/submissions/[id]/page.tsx +++ b/apps/web/src/app/dashboard/[guildId]/submissions/[id]/page.tsx @@ -1,3 +1,5 @@ +import { prisma } from "@msk-forms/db"; +import { parseStatusMessages, resolveStatusMessage } from "@msk-forms/shared"; import type { Route } from "next"; import { StatusBadge } from "@msk-forms/ui"; import Link from "next/link"; @@ -37,6 +39,22 @@ export default async function SubmissionDetailPage({ const status = resolveStatus(submission.status, submission.statusDefs, dict.statusLabels); const answers = (submission.answers ?? {}) as Record; + // Resolve the per-status message template (form override -> guild) so the + // review panel can prefill the applicant message when a status is picked. + const [guildCfg, formCfg] = await Promise.all([ + prisma.guild.findUnique({ where: { id: guildId }, select: { statusMessages: true } }), + prisma.form.findUnique({ where: { id: submission.formId }, select: { settings: true } }), + ]); + const guildMsgs = parseStatusMessages(guildCfg?.statusMessages); + const formMsgs = parseStatusMessages( + (formCfg?.settings as { statusMessages?: unknown } | null)?.statusMessages, + ); + const statusMessages: Record = {}; + for (const opt of submission.options) { + const msg = resolveStatusMessage(guildMsgs, formMsgs, opt.key); + if (msg) statusMessages[opt.key] = msg; + } + return (
@@ -137,6 +155,7 @@ export default async function SubmissionDetailPage({ submissionId={submission.id} currentStatus={submission.status} options={submission.options} + statusMessages={statusMessages} t={t} /> ) : ( diff --git a/apps/web/src/components/builder/form-builder.tsx b/apps/web/src/components/builder/form-builder.tsx index 2ae3cb1..3ef36eb 100644 --- a/apps/web/src/components/builder/form-builder.tsx +++ b/apps/web/src/components/builder/form-builder.tsx @@ -92,6 +92,8 @@ export interface FormBuilderInitial { pages: FormPage[]; automations: AutomationRule[]; experiment: Experiment; + /** Per-form status message overrides, keyed by status key. */ + statusMessages: Record; } /** ISO → `datetime-local` input value (YYYY-MM-DDTHH:MM) in the viewer's tz. */ @@ -163,6 +165,9 @@ export function FormBuilder({ const [pages, setPages] = useState(initial.pages); const [automations, setAutomations] = useState(initial.automations); const [experiment, setExperiment] = useState(initial.experiment); + const [statusMessages, setStatusMessages] = useState>( + initial.statusMessages, + ); const [error, setError] = useState(null); const [saving, setSaving] = useState(false); @@ -244,6 +249,13 @@ export function FormBuilder({ if (experiment.enabled) settings.experiment = experiment; if (showCountdown) settings.showCountdown = true; settings.singleSubmission = singleSubmission; + // Keep only non-blank per-status overrides. + const trimmedMessages = Object.fromEntries( + Object.entries(statusMessages) + .map(([k, v]) => [k, v.trim()] as const) + .filter(([, v]) => v), + ); + if (Object.keys(trimmedMessages).length > 0) settings.statusMessages = trimmedMessages; const payload = { title: title.trim(), description: description.trim() || null, @@ -354,6 +366,27 @@ export function FormBuilder({ />
+
+ + {t.statusMessagesLabel} + +

{t.statusMessagesHint}

+
+ {statusOptions.map((s) => ( + +