From 806f282c5972e1c95721d32ac4190ec092429b85 Mon Sep 17 00:00:00 2001 From: Musiker15 Date: Thu, 2 Jul 2026 08:00:30 +0200 Subject: [PATCH] fix: guild-language DMs + one active submission per person MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two customer-reported issues. 1) Bot DMs ignored the guild's configured language. Applicant status/message DMs were localized to the recipient's own Discord locale, so a guild that set its bot language to (e.g.) Hungarian still saw English/German DMs. DMs now use the guild's botConfig.locale when set (matching the review embeds and activity log), falling back to the applicant's Discord locale, then English. 2) A signed-in applicant could submit the same form repeatedly. New per-form setting "One active submission per person" (default on, toggle in the builder): while the applicant has an open (non-terminal) submission they are redirected to its status page instead of the form, and the submit endpoint rejects a duplicate with 409 + the existing submission id (the renderer then redirects). Once a reviewer sets a terminal status (accepted/rejected/ withdrawn or a custom terminal status) they may apply again. Enforced only for signed-in applicants — anonymous submits can't be tied to a person. - shared: isTerminalStatus() + singleSubmission flag/helper on formSettings - lib/forms: findActiveSubmissionId() - i18n: builder.singleSubmissionLabel/Hint in all 7 locales - tests for isTerminalStatus and singleSubmissionEnforced --- apps/bot/src/notifications.ts | 23 +++++++-------- .../src/app/api/forms/[slug]/submit/route.ts | 17 ++++++++++- .../[guildId]/forms/[formId]/edit/page.tsx | 1 + .../dashboard/[guildId]/forms/new/page.tsx | 1 + apps/web/src/app/f/[slug]/page.tsx | 24 +++++++++++++--- .../src/components/builder/form-builder.tsx | 13 +++++++++ .../web/src/components/form/form-renderer.tsx | 10 ++++++- apps/web/src/i18n/dictionaries.ts | 7 +++++ apps/web/src/lib/forms.ts | 28 +++++++++++++++++++ packages/shared/src/form-settings.test.ts | 13 +++++++-- packages/shared/src/form-settings.ts | 17 ++++++++++- packages/shared/src/status-defs.test.ts | 22 ++++++++++++++- packages/shared/src/status-defs.ts | 16 +++++++++++ 13 files changed, 171 insertions(+), 21 deletions(-) diff --git a/apps/bot/src/notifications.ts b/apps/bot/src/notifications.ts index 833a37c..849fa8d 100644 --- a/apps/bot/src/notifications.ts +++ b/apps/bot/src/notifications.ts @@ -251,18 +251,19 @@ async function deliverOne(client: Client, row: PendingRow): Promise { const discordId = row.user?.discordId; if (!discordId) return true; - // DM in the applicant's own locale; fall back to the guild's configured bot - // language when the applicant has none (rare — OAuth users always have one). + // Applicant DMs speak the guild's configured bot language when it's set (the + // community deliberately chose it, so the whole bot — embeds, logs AND DMs — + // stays in that language). Only when the guild left it unset do we fall back + // to the applicant's own Discord locale, then to English. let dmLocale = row.user?.locale || undefined; - if (!dmLocale) { - const p = row.payload as { submissionId?: string }; - if (p.submissionId) { - const sub = await prisma.submission.findUnique({ - where: { id: p.submissionId }, - select: { guild: { select: { botConfig: true } } }, - }); - dmLocale = parseBotConfig(sub?.guild.botConfig).locale || undefined; - } + const p = row.payload as { submissionId?: string }; + if (p.submissionId) { + const sub = await prisma.submission.findUnique({ + where: { id: p.submissionId }, + select: { guild: { select: { botConfig: true } } }, + }); + const guildLocale = parseBotConfig(sub?.guild.botConfig).locale; + if (guildLocale) dmLocale = guildLocale; } const message = buildMessage(row, dmLocale); diff --git a/apps/web/src/app/api/forms/[slug]/submit/route.ts b/apps/web/src/app/api/forms/[slug]/submit/route.ts index 9d60d25..c4b48af 100644 --- a/apps/web/src/app/api/forms/[slug]/submit/route.ts +++ b/apps/web/src/app/api/forms/[slug]/submit/route.ts @@ -18,6 +18,7 @@ import { isLayoutField, parseFormSettings, scoreSubmission, + singleSubmissionEnforced, type FileAnswer, type FormSpec, type StatusChangeNotification, @@ -30,7 +31,7 @@ import { captchaEnabled, verifyCaptcha } from "@/lib/captcha"; import { isPrimaryHostname, requestHostname } from "@/lib/custom-domain"; import { recordExperimentConversion } from "@/lib/experiment"; import { getGuildCaptchaSecret } from "@/lib/guild-captcha"; -import { parseFormSpec, resolveStatus } from "@/lib/forms"; +import { findActiveSubmissionId, parseFormSpec, resolveStatus } from "@/lib/forms"; import { getGuildPlan } from "@/lib/plan"; import { clientIp, rateLimit } from "@/lib/rate-limit"; import { headObject } from "@/lib/s3"; @@ -198,6 +199,20 @@ export async function POST( } const user = await getCurrentUser(); + + // One active submission per person: block a signed-in applicant who already + // has an open (non-terminal) submission and point them at its status page. + // Enforced only for signed-in applicants (anonymous submits can't be deduped). + if (user && singleSubmissionEnforced(parseFormSettings(form.settings))) { + const activeId = await findActiveSubmissionId(form.id, user.id, form.guildId); + if (activeId) { + return NextResponse.json( + { error: "You already have an active submission for this form.", submissionId: activeId }, + { status: 409 }, + ); + } + } + // Quiz score (null when the form has no scored options). const score = scoreSubmission(spec, data); 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 dcfa40c..c4f06b9 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 @@ -71,6 +71,7 @@ export default async function EditFormPage({ openAt: form.openAt?.toISOString() ?? "", closeAt: form.closeAt?.toISOString() ?? "", showCountdown: settings.showCountdown === true, + singleSubmission: settings.singleSubmission !== false, categoryId: form.categoryId, pages: spec?.pages.length ? spec.pages : [{ id: "p1", title: "", fields: [] }], automations: settings.automations, 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 2d84af6..e53a8ba 100644 --- a/apps/web/src/app/dashboard/[guildId]/forms/new/page.tsx +++ b/apps/web/src/app/dashboard/[guildId]/forms/new/page.tsx @@ -85,6 +85,7 @@ export default async function NewFormPage({ openAt: "", closeAt: "", showCountdown: false, + singleSubmission: true, categoryId: null, pages: [{ id: "p1", title: "", fields: [] }], automations: [], diff --git a/apps/web/src/app/f/[slug]/page.tsx b/apps/web/src/app/f/[slug]/page.tsx index 98c30b4..7410221 100644 --- a/apps/web/src/app/f/[slug]/page.tsx +++ b/apps/web/src/app/f/[slug]/page.tsx @@ -1,10 +1,16 @@ import { cookies, headers } from "next/headers"; -import { notFound } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; import Script from "next/script"; import type { CSSProperties } from "react"; -import { experimentActive, formScheduleStatus, parseFormSettings, pickVariant } from "@msk-forms/shared"; +import { + experimentActive, + formScheduleStatus, + parseFormSettings, + pickVariant, + singleSubmissionEnforced, +} from "@msk-forms/shared"; import { CustomCss } from "@/components/branding/custom-css"; import { FormRenderer } from "@/components/form/form-renderer"; @@ -19,7 +25,7 @@ import { getGuildCaptchaSiteKey } from "@/lib/guild-captcha"; import { getGuildByDomain, isPrimaryHostname, requestHostname } from "@/lib/custom-domain"; import { isGuildPro } from "@/lib/plan"; import { captchaSiteKey } from "@/lib/captcha"; -import { getLiveFormBySlug } from "@/lib/forms"; +import { findActiveSubmissionId, getLiveFormBySlug } from "@/lib/forms"; import { getDict } from "@/i18n"; export const runtime = "nodejs"; @@ -62,6 +68,17 @@ export default async function PublicFormPage({ ); } + // One active submission per person: a signed-in applicant who already has an + // open (non-terminal) submission for this form is sent to its status page + // rather than the form. Only enforceable for signed-in applicants — anonymous + // submits can't be tied to a person. Once a reviewer sets a terminal status + // they may apply again. + const user = await getCurrentUser(); + if (user && singleSubmissionEnforced(parseFormSettings(form.settings))) { + const activeId = await findActiveSubmissionId(form.id, user.id, form.guildId); + if (activeId) redirect(`/s/${activeId}`); + } + // Scheduling: a live form may not be open yet, or may already have closed. const schedule = formScheduleStatus(form.openAt, form.closeAt, new Date()); if (schedule.state === "scheduled") { @@ -87,7 +104,6 @@ export default async function PublicFormPage({ } if (form.visibility === "authenticated") { - const user = await getCurrentUser(); if (!user) { return ( diff --git a/apps/web/src/components/builder/form-builder.tsx b/apps/web/src/components/builder/form-builder.tsx index bca4957..2ae3cb1 100644 --- a/apps/web/src/components/builder/form-builder.tsx +++ b/apps/web/src/components/builder/form-builder.tsx @@ -85,6 +85,8 @@ export interface FormBuilderInitial { closeAt: string; /** Tease the scheduled form with a live countdown + confetti on the hub. */ showCountdown: boolean; + /** Limit each signed-in applicant to one active (non-terminal) submission. */ + singleSubmission: boolean; /** Selected category id, or null when uncategorized. */ categoryId: string | null; pages: FormPage[]; @@ -153,6 +155,7 @@ export function FormBuilder({ const [openAt, setOpenAt] = useState(""); const [closeAt, setCloseAt] = useState(""); const [showCountdown, setShowCountdown] = useState(initial.showCountdown); + const [singleSubmission, setSingleSubmission] = useState(initial.singleSubmission); useEffect(() => { setOpenAt(isoToLocalInput(initial.openAt)); setCloseAt(isoToLocalInput(initial.closeAt)); @@ -240,6 +243,7 @@ export function FormBuilder({ if (automations.length > 0) settings.automations = automations; if (experiment.enabled) settings.experiment = experiment; if (showCountdown) settings.showCountdown = true; + settings.singleSubmission = singleSubmission; const payload = { title: title.trim(), description: description.trim() || null, @@ -377,6 +381,15 @@ export function FormBuilder({ />

{t.countdownHint}

+
+ setSingleSubmission(e.target.checked)} + label={t.singleSubmissionLabel} + /> +

{t.singleSubmissionHint}

+
{pages.map((page, pi) => ( diff --git a/apps/web/src/components/form/form-renderer.tsx b/apps/web/src/components/form/form-renderer.tsx index 4e05940..7069166 100644 --- a/apps/web/src/components/form/form-renderer.tsx +++ b/apps/web/src/components/form/form-renderer.tsx @@ -183,7 +183,15 @@ export function FormRenderer({ body: JSON.stringify({ answers, captchaToken, experimentVariant }), }); if (!res.ok) { - const data = (await res.json().catch(() => null)) as { error?: string } | null; + const data = (await res.json().catch(() => null)) as + | { error?: string; submissionId?: string } + | null; + // Already has an active submission → send them to its status page + // rather than showing an error. + if (res.status === 409 && data?.submissionId) { + router.push(`/s/${data.submissionId}` as Route); + return; + } throw new Error(data?.error ?? labels.submitFailed); } const { submissionId } = (await res.json()) as { submissionId: string }; diff --git a/apps/web/src/i18n/dictionaries.ts b/apps/web/src/i18n/dictionaries.ts index 9c2c9bf..1e07f01 100644 --- a/apps/web/src/i18n/dictionaries.ts +++ b/apps/web/src/i18n/dictionaries.ts @@ -472,6 +472,7 @@ const en = { changeTypeWarn: "Switching type resets options, scale and other type-specific settings.", opensLabel: "Opens at", closesLabel: "Closes at", scheduleHint: "Optional. Your local time.", countdownLabel: "Countdown on the public hub", countdownHint: "For a scheduled form, show a live countdown on the public hub and form page, and celebrate with confetti when it opens.", + singleSubmissionLabel: "One active submission per person", singleSubmissionHint: "While an applicant's submission is still open they can't submit again and are sent to its status page; they can reapply once a reviewer sets a terminal status (accepted/rejected/withdrawn). Only applies to signed-in applicants.", saving: "Saving…", saveChanges: "Save changes", createForm: "Create form", cancel: "Cancel", errTitle: "Title is required.", errSlug: "Slug is required.", errFields: "Add at least one field.", errSave: "Could not save the form.", @@ -1012,6 +1013,7 @@ const de: Dictionary = { changeTypeWarn: "Beim Typwechsel werden Optionen, Skala und andere typabhängige Einstellungen zurückgesetzt.", opensLabel: "Öffnet am", closesLabel: "Schließt am", scheduleHint: "Optional. Deine lokale Zeit.", countdownLabel: "Countdown im öffentlichen Hub", countdownHint: "Zeigt bei einem geplanten Formular einen Live-Countdown im öffentlichen Hub und auf der Formularseite und feiert das Öffnen mit Konfetti.", + singleSubmissionLabel: "Nur eine aktive Einreichung pro Person", singleSubmissionHint: "Solange die Einreichung eines Bewerbers offen ist, kann er nicht erneut absenden und landet auf deren Status-Seite. Erneutes Bewerben ist möglich, sobald ein Prüfer einen terminalen Status setzt (Angenommen/Abgelehnt/Zurückgezogen). Gilt nur für eingeloggte Bewerber.", saving: "Wird gespeichert…", saveChanges: "Änderungen speichern", createForm: "Formular erstellen", cancel: "Abbrechen", errTitle: "Titel ist erforderlich.", errSlug: "Slug ist erforderlich.", errFields: "Füge mindestens ein Feld hinzu.", errSave: "Formular konnte nicht gespeichert werden.", @@ -1550,6 +1552,7 @@ const hu: Dictionary = { changeTypeWarn: "Típusváltáskor az opciók, a skála és a többi típusfüggő beállítás visszaáll.", opensLabel: "Nyitás", closesLabel: "Zárás", scheduleHint: "Opcionális. A helyi időd.", countdownLabel: "Visszaszámlálás a nyilvános hubon", countdownHint: "Ütemezett űrlaphoz élő visszaszámlálást mutat a nyilvános hubon és az űrlapoldalon, és konfettivel ünnepli a megnyitást.", + singleSubmissionLabel: "Személyenként egy aktív beküldés", singleSubmissionHint: "Amíg egy jelentkező beküldése nyitva van, nem tud újra beküldeni, és az állapotoldalára kerül. Újra jelentkezhet, ha egy elbíráló terminális állapotot állít be (elfogadva/elutasítva/visszavonva). Csak bejelentkezett jelentkezőkre vonatkozik.", saving: "Mentés…", saveChanges: "Módosítások mentése", createForm: "Űrlap létrehozása", cancel: "Mégse", errTitle: "A cím kötelező.", errSlug: "A slug kötelező.", errFields: "Adj hozzá legalább egy mezőt.", errSave: "Az űrlap mentése sikertelen.", @@ -2088,6 +2091,7 @@ const fr: Dictionary = { changeTypeWarn: "Changer de type réinitialise les options, l’échelle et les autres réglages propres au type.", opensLabel: "Ouvre à", closesLabel: "Ferme à", scheduleHint: "Optionnel. Votre heure locale.", countdownLabel: "Compte à rebours sur le hub public", countdownHint: "Pour un formulaire planifié, affiche un compte à rebours en direct sur le hub public et la page du formulaire, et célèbre son ouverture avec des confettis.", + singleSubmissionLabel: "Une seule candidature active par personne", singleSubmissionHint: "Tant que la candidature d'un postulant est ouverte, il ne peut pas en renvoyer une et est dirigé vers sa page de statut. Il peut postuler à nouveau lorsqu'un évaluateur définit un statut terminal (accepté/refusé/retiré). S'applique uniquement aux postulants connectés.", saving: "Enregistrement…", saveChanges: "Enregistrer les modifications", createForm: "Créer le formulaire", cancel: "Annuler", errTitle: "Le titre est obligatoire.", errSlug: "Le slug est obligatoire.", errFields: "Ajoutez au moins un champ.", errSave: "Impossible d'enregistrer le formulaire.", @@ -2627,6 +2631,7 @@ const es: Dictionary = { changeTypeWarn: "Cambiar el tipo restablece las opciones, la escala y otros ajustes propios del tipo.", opensLabel: "Abre el", closesLabel: "Cierra el", scheduleHint: "Opcional. Tu hora local.", countdownLabel: "Cuenta atrás en el hub público", countdownHint: "Para un formulario programado, muestra una cuenta atrás en vivo en el hub público y la página del formulario, y lo celebra con confeti al abrirse.", + singleSubmissionLabel: "Una sola solicitud activa por persona", singleSubmissionHint: "Mientras la solicitud de un candidato siga abierta, no puede volver a enviarla y se le dirige a su página de estado. Puede volver a solicitar cuando un revisor establece un estado terminal (aceptado/rechazado/retirado). Solo se aplica a candidatos con sesión iniciada.", saving: "Guardando…", saveChanges: "Guardar cambios", createForm: "Crear formulario", cancel: "Cancelar", errTitle: "El título es obligatorio.", errSlug: "El slug es obligatorio.", errFields: "Agrega al menos un campo.", errSave: "No se pudo guardar el formulario.", @@ -3166,6 +3171,7 @@ const pt: Dictionary = { changeTypeWarn: "Alterar o tipo redefine as opções, a escala e outras configurações específicas do tipo.", opensLabel: "Abre em", closesLabel: "Fecha em", scheduleHint: "Opcional. Seu horário local.", countdownLabel: "Contagem regressiva no hub público", countdownHint: "Para um formulário agendado, mostra uma contagem regressiva ao vivo no hub público e na página do formulário, e comemora a abertura com confetes.", + singleSubmissionLabel: "Uma única submissão ativa por pessoa", singleSubmissionHint: "Enquanto a submissão de um candidato estiver aberta, ele não pode enviar novamente e é direcionado para a página de status. Pode candidatar-se de novo quando um revisor define um status terminal (aceito/rejeitado/retirado). Aplica-se apenas a candidatos com sessão iniciada.", saving: "Salvando…", saveChanges: "Salvar alterações", createForm: "Criar formulário", cancel: "Cancelar", errTitle: "Título é obrigatório.", errSlug: "Slug é obrigatório.", errFields: "Adicione pelo menos um campo.", errSave: "Não foi possível salvar o formulário.", @@ -3705,6 +3711,7 @@ const pl: Dictionary = { changeTypeWarn: "Zmiana typu resetuje opcje, skalę i inne ustawienia zależne od typu.", opensLabel: "Otwiera się o", closesLabel: "Zamyka się o", scheduleHint: "Opcjonalnie. Twój czas lokalny.", countdownLabel: "Odliczanie w publicznym hubie", countdownHint: "Dla zaplanowanego formularza pokazuje odliczanie na żywo w publicznym hubie i na stronie formularza oraz świętuje otwarcie konfetti.", + singleSubmissionLabel: "Jedno aktywne zgłoszenie na osobę", singleSubmissionHint: "Dopóki zgłoszenie kandydata jest otwarte, nie może wysłać kolejnego i trafia na stronę jego statusu. Może zgłosić się ponownie, gdy recenzent ustawi status końcowy (zaakceptowano/odrzucono/wycofano). Dotyczy tylko zalogowanych kandydatów.", saving: "Zapisywanie…", saveChanges: "Zapisz zmiany", createForm: "Utwórz formularz", cancel: "Anuluj", errTitle: "Tytuł jest wymagany.", errSlug: "Slug jest wymagany.", errFields: "Dodaj co najmniej jedno pole.", errSave: "Nie udało się zapisać formularza.", diff --git a/apps/web/src/lib/forms.ts b/apps/web/src/lib/forms.ts index 366408e..82e68ff 100644 --- a/apps/web/src/lib/forms.ts +++ b/apps/web/src/lib/forms.ts @@ -6,6 +6,7 @@ import { formatAnswerValue, formSpecSchema, isLayoutField, + isTerminalStatus, parseFormSettings, type FormSpec, } from "@msk-forms/shared"; @@ -279,6 +280,33 @@ export async function getLiveFormBySlug(slug: string) { return { ...form, spec: parseFormSpec(form.schema) }; } +/** + * The id of a signed-in applicant's active (non-terminal) submission for a form, + * or null when they have none. Used to enforce "one active submission per + * person": while such a submission exists the applicant is sent to its status + * page instead of the form. Considers only their most recent submission — once + * it reaches a terminal status they may apply again. + */ +export async function findActiveSubmissionId( + formId: string, + userId: string, + guildId: string, +): Promise { + const [latest, defs] = await Promise.all([ + prisma.submission.findFirst({ + where: { formId, userId }, + orderBy: { submittedAt: "desc" }, + select: { id: true, status: true }, + }), + prisma.formStatusDef.findMany({ + where: { guildId }, + select: { key: true, isTerminal: true }, + }), + ]); + if (!latest) return null; + return isTerminalStatus(latest.status, defs) ? null : latest.id; +} + /** * Load a submission by its UUID (the public status link) together with the * form, the guild's status definitions, and the applicant-visible events. diff --git a/packages/shared/src/form-settings.test.ts b/packages/shared/src/form-settings.test.ts index 4e9d3af..afac40d 100644 --- a/packages/shared/src/form-settings.test.ts +++ b/packages/shared/src/form-settings.test.ts @@ -5,6 +5,7 @@ import { experimentActive, parseFormSettings, pickVariant, + singleSubmissionEnforced, type AutomationRule, type ExperimentVariant, } from "./form-settings.js"; @@ -39,8 +40,8 @@ describe("evaluateAutomations", () => { }); describe("parseFormSettings", () => { - it("defaults automations to an empty array", () => { - expect(parseFormSettings(undefined)).toEqual({ automations: [] }); + it("defaults automations to an empty array and single-submission on", () => { + expect(parseFormSettings(undefined)).toEqual({ automations: [], singleSubmission: true }); expect(parseFormSettings({ acceptedRoleId: "123456789012345678" }).automations).toEqual([]); }); @@ -51,6 +52,14 @@ describe("parseFormSettings", () => { expect(parsed.automations).toHaveLength(1); expect(parseFormSettings({ automations: [{ setStatus: "" }] }).automations).toEqual([]); }); + + it("treats single-submission as on unless explicitly false", () => { + expect(parseFormSettings(undefined).singleSubmission).toBe(true); + expect(parseFormSettings({ singleSubmission: false }).singleSubmission).toBe(false); + expect(singleSubmissionEnforced({})).toBe(true); + expect(singleSubmissionEnforced({ singleSubmission: true })).toBe(true); + expect(singleSubmissionEnforced({ singleSubmission: false })).toBe(false); + }); }); describe("experiment helpers", () => { diff --git a/packages/shared/src/form-settings.ts b/packages/shared/src/form-settings.ts index 0dd4bfb..298895a 100644 --- a/packages/shared/src/form-settings.ts +++ b/packages/shared/src/form-settings.ts @@ -68,8 +68,23 @@ export const formSettingsSchema = z.object({ * the plain "opens at " text. */ showCountdown: z.boolean().optional(), + /** + * Limit each signed-in applicant to one active (non-terminal) submission: + * while their submission is open they can't submit again and are sent to its + * status page. Once a reviewer sets a terminal status (accepted/rejected/ + * withdrawn or a custom terminal status) they may apply again. Default on; + * turn off for forms that accept repeated submissions (surveys/feedback). + * Only enforceable for signed-in applicants (anonymous submits can't be tied + * to a person). Treat `undefined` as on — see `singleSubmissionEnforced`. + */ + singleSubmission: z.boolean().default(true), }); +/** Whether the "one active submission per person" rule is on (undefined = on). */ +export function singleSubmissionEnforced(s: { singleSubmission?: boolean }): boolean { + return s.singleSubmission !== false; +} + /** True when an experiment is live (enabled with at least two variants). */ export function experimentActive(exp: Experiment | undefined): boolean { return Boolean(exp?.enabled && (exp.variants?.length ?? 0) >= 2); @@ -97,7 +112,7 @@ export type FormSettings = z.infer; /** Parse a stored Form.settings JSON blob, falling back to empty settings. */ export function parseFormSettings(json: unknown): FormSettings { const result = formSettingsSchema.safeParse(json); - return result.success ? result.data : { automations: [] }; + return result.success ? result.data : { automations: [], singleSubmission: true }; } /** diff --git a/packages/shared/src/status-defs.test.ts b/packages/shared/src/status-defs.test.ts index abe6fe5..89aa4de 100644 --- a/packages/shared/src/status-defs.test.ts +++ b/packages/shared/src/status-defs.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { statusDefsSchema } from "./status-defs.js"; +import { isTerminalStatus, statusDefsSchema } from "./status-defs.js"; describe("statusDefsSchema", () => { const valid = { key: "shortlisted", label: "Shortlisted", color: "#00E676" }; @@ -26,3 +26,23 @@ describe("statusDefsSchema", () => { expect(statusDefsSchema.safeParse([]).success).toBe(true); }); }); + +describe("isTerminalStatus", () => { + it("uses the built-in terminal flags when there's no custom def", () => { + expect(isTerminalStatus("submitted", [])).toBe(false); + expect(isTerminalStatus("in_review", [])).toBe(false); + expect(isTerminalStatus("accepted", [])).toBe(true); + expect(isTerminalStatus("rejected", [])).toBe(true); + expect(isTerminalStatus("withdrawn", [])).toBe(true); + expect(isTerminalStatus("unknown", [])).toBe(false); + }); + + it("lets a custom def flag its own status terminal", () => { + expect(isTerminalStatus("shortlisted", [{ key: "shortlisted", isTerminal: true }])).toBe(true); + expect(isTerminalStatus("shortlisted", [{ key: "shortlisted", isTerminal: false }])).toBe(false); + }); + + it("lets a custom def override a built-in terminal flag", () => { + expect(isTerminalStatus("accepted", [{ key: "accepted", isTerminal: false }])).toBe(false); + }); +}); diff --git a/packages/shared/src/status-defs.ts b/packages/shared/src/status-defs.ts index 4b45ead..d36ac08 100644 --- a/packages/shared/src/status-defs.ts +++ b/packages/shared/src/status-defs.ts @@ -1,5 +1,21 @@ import { z } from "zod"; +import { DEFAULT_STATUSES } from "./constants"; + +/** + * True when a status is terminal — an "ending" state that closes the review + * (built-in accepted/rejected/withdrawn, or any custom def flagged terminal). + * A guild's custom def sharing a built-in key overrides the built-in flag. + */ +export function isTerminalStatus( + key: string, + defs: { key: string; isTerminal?: boolean }[], +): boolean { + const custom = defs.find((d) => d.key === key); + if (custom) return custom.isTerminal === true; + return DEFAULT_STATUSES.find((s) => s.key === key)?.terminal ?? false; +} + /** * Guild-defined custom statuses (stored as FormStatusDef rows with formId null). * They extend or override the built-in default pipeline (see DEFAULT_STATUSES):