Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 12 additions & 11 deletions apps/bot/src/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,18 +251,19 @@ async function deliverOne(client: Client, row: PendingRow): Promise<boolean> {
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);
Expand Down
17 changes: 16 additions & 1 deletion apps/web/src/app/api/forms/[slug]/submit/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
isLayoutField,
parseFormSettings,
scoreSubmission,
singleSubmissionEnforced,
type FileAnswer,
type FormSpec,
type StatusChangeNotification,
Expand All @@ -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";
Expand Down Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/app/dashboard/[guildId]/forms/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export default async function NewFormPage({
openAt: "",
closeAt: "",
showCountdown: false,
singleSubmission: true,
categoryId: null,
pages: [{ id: "p1", title: "", fields: [] }],
automations: [],
Expand Down
24 changes: 20 additions & 4 deletions apps/web/src/app/f/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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") {
Expand All @@ -87,7 +104,6 @@ export default async function PublicFormPage({
}

if (form.visibility === "authenticated") {
const user = await getCurrentUser();
if (!user) {
return (
<Shell guildName={form.guild.name} title={form.title} style={brand} logoSrc={logo} customCss={branding.customCss} poweredBy={badge}>
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/components/builder/form-builder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -377,6 +381,15 @@ export function FormBuilder({
/>
<p className="ms-[1.9rem] text-xs text-muted-foreground">{t.countdownHint}</p>
</div>
<div>
<Checkbox
id="single-submission"
checked={singleSubmission}
onChange={(e) => setSingleSubmission(e.target.checked)}
label={t.singleSubmissionLabel}
/>
<p className="ms-[1.9rem] text-xs text-muted-foreground">{t.singleSubmissionHint}</p>
</div>
</Card>

{pages.map((page, pi) => (
Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/components/form/form-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/i18n/dictionaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down
28 changes: 28 additions & 0 deletions apps/web/src/lib/forms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
formatAnswerValue,
formSpecSchema,
isLayoutField,
isTerminalStatus,
parseFormSettings,
type FormSpec,
} from "@msk-forms/shared";
Expand Down Expand Up @@ -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<string | null> {
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.
Expand Down
13 changes: 11 additions & 2 deletions packages/shared/src/form-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
experimentActive,
parseFormSettings,
pickVariant,
singleSubmissionEnforced,
type AutomationRule,
type ExperimentVariant,
} from "./form-settings.js";
Expand Down Expand Up @@ -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([]);
});

Expand All @@ -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", () => {
Expand Down
Loading