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) => ( + +