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) => (
+
+
+ ))}
+
+
;
+ t: StatusMessagesLabels;
+}) {
+ const router = useRouter();
+ const [messages, setMessages] = useState>(initial);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+ const [saved, setSaved] = useState(false);
+
+ function set(key: string, value: string) {
+ setMessages((m) => ({ ...m, [key]: value }));
+ setSaved(false);
+ }
+
+ async function save() {
+ setSaving(true);
+ setError(null);
+ try {
+ const res = await fetch(`/api/guilds/${guildId}/status-messages`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ messages }),
+ });
+ if (!res.ok) throw new Error(t.errSave);
+ setSaved(true);
+ router.refresh();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : t.errSave);
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+
+
+ {statuses.map((s) => (
+
+
+ ))}
+
+ {t.hint}
+
+
+ {saved && {t.saved}}
+ {error && {error}}
+
+
+ );
+}
diff --git a/apps/web/src/components/review/review-panel.tsx b/apps/web/src/components/review/review-panel.tsx
index 321b1be..645b334 100644
--- a/apps/web/src/components/review/review-panel.tsx
+++ b/apps/web/src/components/review/review-panel.tsx
@@ -2,7 +2,7 @@
import { Button, Card, Checkbox, Field, Select, Textarea } from "@msk-forms/ui";
import { useRouter } from "next/navigation";
-import { useState } from "react";
+import { useEffect, useState } from "react";
import type { Dictionary } from "@/i18n";
import type { SubmissionAction } from "@/lib/submission-action";
@@ -19,12 +19,15 @@ export function ReviewPanel({
submissionId,
currentStatus,
options,
+ statusMessages,
t,
}: {
guildId: string;
submissionId: string;
currentStatus: string;
options: { key: string; label: string }[];
+ /** Resolved per-status message template (form override -> guild) for this form. */
+ statusMessages: Record;
t: ReviewDict;
}) {
const router = useRouter();
@@ -32,6 +35,12 @@ export function ReviewPanel({
const [hidden, setHidden] = useState(false);
const [note, setNote] = useState("");
const [message, setMessage] = useState("");
+ // Editable applicant message that travels with the status change, prefilled
+ // from the target status' template. Reset whenever the target status changes.
+ const [statusMsg, setStatusMsg] = useState(statusMessages[currentStatus] ?? "");
+ useEffect(() => {
+ setStatusMsg(statusMessages[status] ?? "");
+ }, [status, statusMessages]);
const [pending, setPending] = useState(null);
const [error, setError] = useState(null);
@@ -77,11 +86,29 @@ export function ReviewPanel({
type="button"
className="shrink-0"
disabled={pending !== null || status === currentStatus}
- onClick={() => submit({ kind: "status", status, hidden })}
+ onClick={() => {
+ const original = statusMessages[status] ?? "";
+ // Unchanged prefill -> let the server auto-apply the template;
+ // otherwise send the (edited or cleared) message as an override.
+ const msg = statusMsg === original ? undefined : statusMsg;
+ submit({ kind: "status", status, hidden, message: msg });
+ }}
>
{pending === "status" ? t.saving : t.apply}
+ {!hidden && (
+ <>
+