From 22d3bee99e320e306db4e9a64f78988cf9a214dc Mon Sep 17 00:00:00 2001 From: Musiker15 Date: Thu, 29 Oct 2026 06:49:40 +0100 Subject: [PATCH] Answer dashboard validation errors in the viewer's language Thirteen dashboard routes answered a rejected body with the first zod issue's message, so every language saw English: our own schema texts ("Enter a valid Discord ID.") as well as zod's built-in ones. The custom messages now live as constants in VALIDATION_MESSAGES (packages/shared) and the schemas use them. validationMessage() in lib/validation-error.ts maps a message back to its key and answers from the new `validation` dictionary section; zod's built-in messages come from zod's own locales. A wrong type gets a generic text, because a finished issue no longer has the input and zod would claim it received "undefined". The public REST API (api/v1) stays English on purpose. "Duplicate key." reads "Each key can only be used once." now, and the category colour message uses the same example as the others. --- .../[guildId]/audit-log/[hookId]/route.ts | 3 +- .../api/guilds/[guildId]/audit-log/route.ts | 3 +- .../api/guilds/[guildId]/bot-config/route.ts | 3 +- .../api/guilds/[guildId]/branding/route.ts | 3 +- .../api/guilds/[guildId]/categories/route.ts | 7 +- .../app/api/guilds/[guildId]/domain/route.ts | 8 +- .../guilds/[guildId]/forms/[formId]/route.ts | 10 +- .../guilds/[guildId]/forms/import/route.ts | 3 +- .../app/api/guilds/[guildId]/forms/route.ts | 3 +- .../app/api/guilds/[guildId]/handle/route.ts | 9 +- .../api/guilds/[guildId]/statuses/route.ts | 7 +- .../submissions/[id]/events/route.ts | 3 +- .../api/guilds/[guildId]/webhooks/route.ts | 8 +- apps/web/src/i18n/dictionaries.ts | 116 ++++++++++++++++++ apps/web/src/lib/validation-error.ts | 18 +++ apps/web/src/lib/validation-message.test.ts | 51 ++++++++ apps/web/src/lib/validation-message.ts | 33 +++++ packages/shared/src/audit-log.ts | 7 +- packages/shared/src/bot-config.ts | 3 +- packages/shared/src/branding.ts | 3 +- packages/shared/src/categories.ts | 3 +- packages/shared/src/domains.ts | 3 +- packages/shared/src/form-definition.ts | 3 +- packages/shared/src/form-settings.ts | 3 +- packages/shared/src/handle.ts | 8 +- packages/shared/src/index.ts | 1 + packages/shared/src/status-defs.ts | 13 +- packages/shared/src/validation-messages.ts | 32 +++++ packages/shared/src/webhooks.ts | 11 +- 29 files changed, 335 insertions(+), 43 deletions(-) create mode 100644 apps/web/src/lib/validation-error.ts create mode 100644 apps/web/src/lib/validation-message.test.ts create mode 100644 apps/web/src/lib/validation-message.ts create mode 100644 packages/shared/src/validation-messages.ts diff --git a/apps/web/src/app/api/guilds/[guildId]/audit-log/[hookId]/route.ts b/apps/web/src/app/api/guilds/[guildId]/audit-log/[hookId]/route.ts index 6aa09e9..b5cc208 100644 --- a/apps/web/src/app/api/guilds/[guildId]/audit-log/[hookId]/route.ts +++ b/apps/web/src/app/api/guilds/[guildId]/audit-log/[hookId]/route.ts @@ -12,6 +12,7 @@ import { getCurrentUser } from "@/lib/auth"; import { actorLine, postDiscordEmbed } from "@/lib/discord-webhook"; import { canManageForms } from "@/lib/guild"; import { getDict } from "@/i18n"; +import { validationMessage } from "@/lib/validation-error"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -41,7 +42,7 @@ export async function PATCH(request: NextRequest, { params }: Params) { const parsed = auditWebhookUpdateSchema.safeParse(await request.json().catch(() => null)); if (!parsed.success) { return NextResponse.json( - { error: parsed.error.issues[0]?.message ?? "Invalid request." }, + { error: (await validationMessage(parsed.error)) ?? "Invalid request." }, { status: 422 }, ); } diff --git a/apps/web/src/app/api/guilds/[guildId]/audit-log/route.ts b/apps/web/src/app/api/guilds/[guildId]/audit-log/route.ts index eafc00a..03da259 100644 --- a/apps/web/src/app/api/guilds/[guildId]/audit-log/route.ts +++ b/apps/web/src/app/api/guilds/[guildId]/audit-log/route.ts @@ -14,6 +14,7 @@ import { getCurrentUser } from "@/lib/auth"; import { checkDiscordWebhook } from "@/lib/discord-webhook"; import { canManageForms } from "@/lib/guild"; import { rateLimit } from "@/lib/rate-limit"; +import { validationMessage } from "@/lib/validation-error"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -46,7 +47,7 @@ export async function POST( const parsed = auditWebhookInputSchema.safeParse(await request.json().catch(() => null)); if (!parsed.success) { return NextResponse.json( - { error: parsed.error.issues[0]?.message ?? "Invalid webhook.", code: "invalid" }, + { error: (await validationMessage(parsed.error)) ?? "Invalid webhook.", code: "invalid" }, { status: 422 }, ); } diff --git a/apps/web/src/app/api/guilds/[guildId]/bot-config/route.ts b/apps/web/src/app/api/guilds/[guildId]/bot-config/route.ts index ef35a99..12a9fea 100644 --- a/apps/web/src/app/api/guilds/[guildId]/bot-config/route.ts +++ b/apps/web/src/app/api/guilds/[guildId]/bot-config/route.ts @@ -5,6 +5,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { actor } from "@/lib/audit"; import { getCurrentUser } from "@/lib/auth"; import { canManageForms } from "@/lib/guild"; +import { validationMessage } from "@/lib/validation-error"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -25,7 +26,7 @@ export async function PATCH( const parsed = botConfigSchema.safeParse(await request.json().catch(() => null)); if (!parsed.success) { return NextResponse.json( - { error: parsed.error.issues[0]?.message ?? "Invalid configuration." }, + { error: (await validationMessage(parsed.error)) ?? "Invalid configuration." }, { status: 422 }, ); } diff --git a/apps/web/src/app/api/guilds/[guildId]/branding/route.ts b/apps/web/src/app/api/guilds/[guildId]/branding/route.ts index 5812ed1..900dfcb 100644 --- a/apps/web/src/app/api/guilds/[guildId]/branding/route.ts +++ b/apps/web/src/app/api/guilds/[guildId]/branding/route.ts @@ -7,6 +7,7 @@ import { getCurrentUser } from "@/lib/auth"; import { parseBranding } from "@/lib/branding"; import { canManageForms } from "@/lib/guild"; import { isGuildPro } from "@/lib/plan"; +import { validationMessage } from "@/lib/validation-error"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -27,7 +28,7 @@ export async function PATCH( const parsed = brandingColorSchema.safeParse(await request.json().catch(() => null)); if (!parsed.success) { return NextResponse.json( - { error: parsed.error.issues[0]?.message ?? "Invalid branding." }, + { error: (await validationMessage(parsed.error)) ?? "Invalid branding." }, { status: 422 }, ); } diff --git a/apps/web/src/app/api/guilds/[guildId]/categories/route.ts b/apps/web/src/app/api/guilds/[guildId]/categories/route.ts index 42b3972..06fbbc6 100644 --- a/apps/web/src/app/api/guilds/[guildId]/categories/route.ts +++ b/apps/web/src/app/api/guilds/[guildId]/categories/route.ts @@ -5,6 +5,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { actor } from "@/lib/audit"; import { getCurrentUser } from "@/lib/auth"; import { canManageForms } from "@/lib/guild"; +import { validationMessage } from "@/lib/validation-error"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -51,7 +52,7 @@ export async function PUT( const parsed = categoriesSchema.safeParse(await request.json().catch(() => null)); if (!parsed.success) { return NextResponse.json( - { error: parsed.error.issues[0]?.message ?? "Invalid categories." }, + { error: (await validationMessage(parsed.error)) ?? "Invalid categories." }, { status: 422 }, ); } @@ -85,7 +86,9 @@ export async function PUT( await logGuildActivitySafe(guildId, { action: "categories_updated", ...actor(user), - detail: parsed.data.length ? parsed.data.map((c) => c.name).join(", ") : "All categories removed", + detail: parsed.data.length + ? parsed.data.map((c) => c.name).join(", ") + : "All categories removed", }); // Return the saved set so the client can pick up new ids (avoids re-creating diff --git a/apps/web/src/app/api/guilds/[guildId]/domain/route.ts b/apps/web/src/app/api/guilds/[guildId]/domain/route.ts index ab12297..7e95740 100644 --- a/apps/web/src/app/api/guilds/[guildId]/domain/route.ts +++ b/apps/web/src/app/api/guilds/[guildId]/domain/route.ts @@ -10,6 +10,7 @@ import { primaryHostname } from "@/lib/custom-domain"; import { requestDomainSync } from "@/lib/domain-sync"; import { canManageForms } from "@/lib/guild"; import { isGuildPro } from "@/lib/plan"; +import { validationMessage } from "@/lib/validation-error"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -27,13 +28,16 @@ export async function PATCH( return NextResponse.json({ error: "Forbidden." }, { status: 403 }); } if (!(await isGuildPro(guildId))) { - return NextResponse.json({ error: "Pro plan required.", code: "pro_required" }, { status: 402 }); + return NextResponse.json( + { error: "Pro plan required.", code: "pro_required" }, + { status: 402 }, + ); } const parsed = customDomainSchema.safeParse(await request.json().catch(() => null)); if (!parsed.success) { return NextResponse.json( - { error: parsed.error.issues[0]?.message ?? "Invalid domain." }, + { error: (await validationMessage(parsed.error)) ?? "Invalid domain." }, { status: 422 }, ); } diff --git a/apps/web/src/app/api/guilds/[guildId]/forms/[formId]/route.ts b/apps/web/src/app/api/guilds/[guildId]/forms/[formId]/route.ts index 89b3a34..175edbb 100644 --- a/apps/web/src/app/api/guilds/[guildId]/forms/[formId]/route.ts +++ b/apps/web/src/app/api/guilds/[guildId]/forms/[formId]/route.ts @@ -8,6 +8,7 @@ import { resolveGuildCategoryId } from "@/lib/forms"; import { canDeleteForms, canManageForm } from "@/lib/guild"; import { isGuildPro } from "@/lib/plan"; import { deleteObject } from "@/lib/s3"; +import { validationMessage } from "@/lib/validation-error"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -36,7 +37,7 @@ export async function PATCH( const parsed = formInputSchema.safeParse(await request.json().catch(() => null)); if (!parsed.success) { return NextResponse.json( - { error: parsed.error.issues[0]?.message ?? "Invalid form." }, + { error: (await validationMessage(parsed.error)) ?? "Invalid form." }, { status: 422 }, ); } @@ -105,7 +106,12 @@ export async function DELETE( const existing = await prisma.form.findUnique({ where: { id: formId }, - select: { guildId: true, title: true, archivedAt: true, _count: { select: { submissions: true } } }, + select: { + guildId: true, + title: true, + archivedAt: true, + _count: { select: { submissions: true } }, + }, }); if (!existing || existing.guildId !== guildId) { return NextResponse.json({ error: "Form not found." }, { status: 404 }); diff --git a/apps/web/src/app/api/guilds/[guildId]/forms/import/route.ts b/apps/web/src/app/api/guilds/[guildId]/forms/import/route.ts index 7f9eecb..e865d85 100644 --- a/apps/web/src/app/api/guilds/[guildId]/forms/import/route.ts +++ b/apps/web/src/app/api/guilds/[guildId]/forms/import/route.ts @@ -10,6 +10,7 @@ import { getCurrentUser } from "@/lib/auth"; import { resolveOrCreateCategoryByName } from "@/lib/forms"; import { canManageForms } from "@/lib/guild"; import { isGuildPro } from "@/lib/plan"; +import { validationMessage } from "@/lib/validation-error"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -52,7 +53,7 @@ export async function POST( const parsed = importBodySchema.safeParse(await request.json().catch(() => null)); if (!parsed.success) { return NextResponse.json( - { error: parsed.error.issues[0]?.message ?? "Invalid form file." }, + { error: (await validationMessage(parsed.error)) ?? "Invalid form file." }, { status: 422 }, ); } diff --git a/apps/web/src/app/api/guilds/[guildId]/forms/route.ts b/apps/web/src/app/api/guilds/[guildId]/forms/route.ts index 387c6b6..31652ee 100644 --- a/apps/web/src/app/api/guilds/[guildId]/forms/route.ts +++ b/apps/web/src/app/api/guilds/[guildId]/forms/route.ts @@ -8,6 +8,7 @@ import { formInputSchema } from "@/lib/form-input"; import { resolveGuildCategoryId } from "@/lib/forms"; import { canManageForms } from "@/lib/guild"; import { isGuildPro } from "@/lib/plan"; +import { validationMessage } from "@/lib/validation-error"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -28,7 +29,7 @@ export async function POST( const parsed = formInputSchema.safeParse(await request.json().catch(() => null)); if (!parsed.success) { return NextResponse.json( - { error: parsed.error.issues[0]?.message ?? "Invalid form." }, + { error: (await validationMessage(parsed.error)) ?? "Invalid form." }, { status: 422 }, ); } diff --git a/apps/web/src/app/api/guilds/[guildId]/handle/route.ts b/apps/web/src/app/api/guilds/[guildId]/handle/route.ts index 952b1fa..6db836a 100644 --- a/apps/web/src/app/api/guilds/[guildId]/handle/route.ts +++ b/apps/web/src/app/api/guilds/[guildId]/handle/route.ts @@ -5,6 +5,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { actor } from "@/lib/audit"; import { getCurrentUser } from "@/lib/auth"; import { canManageForms } from "@/lib/guild"; +import { validationMessage } from "@/lib/validation-error"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -31,7 +32,11 @@ export async function PATCH( // Empty/null clears the handle. if (raw == null || (typeof raw === "string" && raw.trim() === "")) { await prisma.guild.update({ where: { id: guildId }, data: { handle: null } }); - await logGuildActivitySafe(guildId, { action: "handle_updated", ...actor(user), detail: "Removed" }); + await logGuildActivitySafe(guildId, { + action: "handle_updated", + ...actor(user), + detail: "Removed", + }); return NextResponse.json({ handle: null }); } if (typeof raw !== "string") { @@ -41,7 +46,7 @@ export async function PATCH( const parsed = handleSchema.safeParse(normalizeHandle(raw)); if (!parsed.success) { return NextResponse.json( - { error: parsed.error.issues[0]?.message ?? "Invalid handle." }, + { error: (await validationMessage(parsed.error)) ?? "Invalid handle." }, { status: 422 }, ); } diff --git a/apps/web/src/app/api/guilds/[guildId]/statuses/route.ts b/apps/web/src/app/api/guilds/[guildId]/statuses/route.ts index 752d708..361e2a8 100644 --- a/apps/web/src/app/api/guilds/[guildId]/statuses/route.ts +++ b/apps/web/src/app/api/guilds/[guildId]/statuses/route.ts @@ -5,6 +5,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { actor } from "@/lib/audit"; import { getCurrentUser } from "@/lib/auth"; import { canManageForms } from "@/lib/guild"; +import { validationMessage } from "@/lib/validation-error"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -47,7 +48,7 @@ export async function PUT( const parsed = statusDefsSchema.safeParse(body?.statuses); if (!parsed.success) { return NextResponse.json( - { error: parsed.error.issues[0]?.message ?? "Invalid statuses." }, + { error: (await validationMessage(parsed.error)) ?? "Invalid statuses." }, { status: 422 }, ); } @@ -70,7 +71,9 @@ export async function PUT( await logGuildActivitySafe(guildId, { action: "statuses_updated", ...actor(user), - detail: parsed.data.length ? parsed.data.map((d) => d.label).join(", ") : "Custom statuses removed", + detail: parsed.data.length + ? parsed.data.map((d) => d.label).join(", ") + : "Custom statuses removed", }); 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 21c9301..bafec37 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 @@ -18,6 +18,7 @@ import { resolveStatus } from "@/lib/forms"; import { canReviewForm } from "@/lib/guild"; import { submissionActionSchema } from "@/lib/submission-action"; import { getDict } from "@/i18n"; +import { validationMessage } from "@/lib/validation-error"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -57,7 +58,7 @@ export async function POST( const parsed = submissionActionSchema.safeParse(await request.json().catch(() => null)); if (!parsed.success) { return NextResponse.json( - { error: parsed.error.issues[0]?.message ?? "Invalid action." }, + { error: (await validationMessage(parsed.error)) ?? "Invalid action." }, { status: 422 }, ); } diff --git a/apps/web/src/app/api/guilds/[guildId]/webhooks/route.ts b/apps/web/src/app/api/guilds/[guildId]/webhooks/route.ts index 64b01fc..c3e8da8 100644 --- a/apps/web/src/app/api/guilds/[guildId]/webhooks/route.ts +++ b/apps/web/src/app/api/guilds/[guildId]/webhooks/route.ts @@ -8,6 +8,7 @@ import { actor } from "@/lib/audit"; import { getCurrentUser } from "@/lib/auth"; import { canManageForms } from "@/lib/guild"; import { isGuildPro } from "@/lib/plan"; +import { validationMessage } from "@/lib/validation-error"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -58,13 +59,16 @@ export async function POST( return NextResponse.json({ error: "Forbidden." }, { status: 403 }); } if (!(await isGuildPro(guildId))) { - return NextResponse.json({ error: "Pro plan required.", code: "pro_required" }, { status: 402 }); + return NextResponse.json( + { error: "Pro plan required.", code: "pro_required" }, + { status: 402 }, + ); } const parsed = webhookInputSchema.safeParse(await request.json().catch(() => null)); if (!parsed.success) { return NextResponse.json( - { error: parsed.error.issues[0]?.message ?? "Invalid webhook." }, + { error: (await validationMessage(parsed.error)) ?? "Invalid webhook." }, { status: 422 }, ); } diff --git a/apps/web/src/i18n/dictionaries.ts b/apps/web/src/i18n/dictionaries.ts index a020a61..92cbdad 100644 --- a/apps/web/src/i18n/dictionaries.ts +++ b/apps/web/src/i18n/dictionaries.ts @@ -1265,6 +1265,23 @@ const en = { authError: "Login failed. Please try again.", // The dashboard kit (components/dashboard-kit): app shell, server switcher // and the shared controls. Pages pass these to the client components. + // Request validation messages of the dashboard API (keys of VALIDATION_MESSAGES). + validation: { + discordId: "Enter a valid Discord ID.", + discordWebhookUrl: "Enter a valid Discord channel webhook URL.", + discordUrlInGeneric: "This looks like a Discord webhook URL. Choose the Discord format.", + url: "Enter a valid URL.", + domain: "Enter a valid domain like apply.example.com.", + slug: "Use lowercase letters, numbers and hyphens only.", + handle: "Use lowercase letters, numbers and hyphens (no leading or trailing hyphen).", + handleReserved: "That handle is reserved.", + statusKey: "Use 1–32 lowercase letters, digits or underscores.", + duplicateKey: "Each key can only be used once.", + hexColor: "Use a hex color like #00E676.", + oneEvent: "Select at least one event.", + oneLogEvent: "Select at least one thing to log.", + invalidValue: "This value isn’t valid.", + }, kit: { close: "Close", shell: { @@ -2579,6 +2596,23 @@ const de: Dictionary = { dpaNoPerm: "Nur wer Formulare verwalten darf, kann diese Vereinbarung annehmen.", }, authError: "Anmeldung fehlgeschlagen. Bitte versuche es erneut.", + validation: { + discordId: "Gib eine gültige Discord-ID ein.", + discordWebhookUrl: "Gib eine gültige Webhook-URL eines Discord-Kanals ein.", + discordUrlInGeneric: "Das sieht nach einer Discord-Webhook-URL aus. Wähle das Discord-Format.", + url: "Gib eine gültige URL ein.", + domain: "Gib eine gültige Domain ein, zum Beispiel apply.example.com.", + slug: "Nur Kleinbuchstaben, Ziffern und Bindestriche verwenden.", + handle: + "Nur Kleinbuchstaben, Ziffern und Bindestriche verwenden, ohne Bindestrich am Anfang oder Ende.", + handleReserved: "Dieser Handle ist reserviert.", + statusKey: "1 bis 32 Kleinbuchstaben, Ziffern oder Unterstriche verwenden.", + duplicateKey: "Jeder Schlüssel darf nur einmal vorkommen.", + hexColor: "Gib die Farbe als Hex-Code an, zum Beispiel #00E676.", + oneEvent: "Wähle mindestens ein Ereignis aus.", + oneLogEvent: "Wähle mindestens eine Sache aus, die protokolliert werden soll.", + invalidValue: "Dieser Wert ist ungültig.", + }, kit: { close: "Schließen", shell: { @@ -3883,6 +3917,22 @@ const hu: Dictionary = { dpaNoPerm: "Csak az fogadhatja el ezt a szerződést, aki űrlapokat kezelhet.", }, authError: "A bejelentkezés sikertelen. Kérjük, próbáld újra.", + validation: { + discordId: "Adj meg egy érvényes Discord-azonosítót.", + discordWebhookUrl: "Adj meg egy érvényes Discord-csatorna webhook URL-t.", + discordUrlInGeneric: "Ez Discord webhook URL-nek tűnik. Válaszd a Discord formátumot.", + url: "Adj meg egy érvényes URL-t.", + domain: "Adj meg egy érvényes domaint, például apply.example.com.", + slug: "Csak kisbetűket, számokat és kötőjelet használj.", + handle: "Csak kisbetűket, számokat és kötőjelet használj, az elején és a végén kötőjel nélkül.", + handleReserved: "Ez a handle foglalt.", + statusKey: "1–32 kisbetűt, számjegyet vagy aláhúzásjelet használj.", + duplicateKey: "Minden kulcs csak egyszer szerepelhet.", + hexColor: "Hexa színkódot adj meg, például #00E676.", + oneEvent: "Válassz legalább egy eseményt.", + oneLogEvent: "Válassz legalább egy dolgot, amit naplózni kell.", + invalidValue: "Ez az érték érvénytelen.", + }, kit: { close: "Bezárás", shell: { @@ -5191,6 +5241,24 @@ const fr: Dictionary = { dpaNoPerm: "Seule une personne pouvant gérer les formulaires peut accepter cet accord.", }, authError: "Connexion échouée. Veuillez réessayer.", + validation: { + discordId: "Saisissez un identifiant Discord valide.", + discordWebhookUrl: "Saisissez une URL de webhook de salon Discord valide.", + discordUrlInGeneric: + "Cela ressemble à une URL de webhook Discord. Choisissez le format Discord.", + url: "Saisissez une URL valide.", + domain: "Saisissez un domaine valide, par exemple apply.example.com.", + slug: "Utilisez uniquement des minuscules, des chiffres et des tirets.", + handle: + "Utilisez uniquement des minuscules, des chiffres et des tirets, sans tiret au début ni à la fin.", + handleReserved: "Cet identifiant est réservé.", + statusKey: "Utilisez de 1 à 32 minuscules, chiffres ou tirets bas.", + duplicateKey: "Chaque clé ne peut être utilisée qu’une seule fois.", + hexColor: "Saisissez la couleur en code hexadécimal, par exemple #00E676.", + oneEvent: "Sélectionnez au moins un événement.", + oneLogEvent: "Sélectionnez au moins un élément à journaliser.", + invalidValue: "Cette valeur n’est pas valide.", + }, kit: { close: "Fermer", shell: { @@ -6494,6 +6562,22 @@ const es: Dictionary = { dpaNoPerm: "Solo quien puede gestionar formularios puede aceptar este contrato.", }, authError: "Error al iniciar sesión. Por favor, inténtalo de nuevo.", + validation: { + discordId: "Introduce un ID de Discord válido.", + discordWebhookUrl: "Introduce una URL de webhook de canal de Discord válida.", + discordUrlInGeneric: "Parece una URL de webhook de Discord. Elige el formato de Discord.", + url: "Introduce una URL válida.", + domain: "Introduce un dominio válido, por ejemplo apply.example.com.", + slug: "Usa solo minúsculas, números y guiones.", + handle: "Usa solo minúsculas, números y guiones, sin guion al principio ni al final.", + handleReserved: "Ese identificador está reservado.", + statusKey: "Usa de 1 a 32 minúsculas, dígitos o guiones bajos.", + duplicateKey: "Cada clave solo puede usarse una vez.", + hexColor: "Usa un color hexadecimal, por ejemplo #00E676.", + oneEvent: "Selecciona al menos un evento.", + oneLogEvent: "Selecciona al menos una cosa para registrar.", + invalidValue: "Este valor no es válido.", + }, kit: { close: "Cerrar", shell: { @@ -7795,6 +7879,22 @@ const pt: Dictionary = { dpaNoPerm: "Apenas quem pode gerenciar formulários pode aceitar este contrato.", }, authError: "Falha no login. Por favor, tente novamente.", + validation: { + discordId: "Informe um ID do Discord válido.", + discordWebhookUrl: "Informe uma URL de webhook de canal do Discord válida.", + discordUrlInGeneric: "Parece uma URL de webhook do Discord. Escolha o formato Discord.", + url: "Informe uma URL válida.", + domain: "Informe um domínio válido, por exemplo apply.example.com.", + slug: "Use apenas letras minúsculas, números e hífens.", + handle: "Use apenas letras minúsculas, números e hífens, sem hífen no início ou no fim.", + handleReserved: "Esse identificador está reservado.", + statusKey: "Use de 1 a 32 letras minúsculas, dígitos ou sublinhados.", + duplicateKey: "Cada chave só pode ser usada uma vez.", + hexColor: "Use uma cor hexadecimal, por exemplo #00E676.", + oneEvent: "Selecione pelo menos um evento.", + oneLogEvent: "Selecione pelo menos um item para registrar.", + invalidValue: "Este valor não é válido.", + }, kit: { close: "Fechar", shell: { @@ -9090,6 +9190,22 @@ const pl: Dictionary = { dpaNoPerm: "Tylko osoba zarządzająca formularzami może zaakceptować tę umowę.", }, authError: "Logowanie nie powiodło się. Spróbuj ponownie.", + validation: { + discordId: "Podaj prawidłowy identyfikator Discord.", + discordWebhookUrl: "Podaj prawidłowy adres URL webhooka kanału Discord.", + discordUrlInGeneric: "To wygląda na adres URL webhooka Discord. Wybierz format Discord.", + url: "Podaj prawidłowy adres URL.", + domain: "Podaj prawidłową domenę, na przykład apply.example.com.", + slug: "Używaj tylko małych liter, cyfr i myślników.", + handle: "Używaj tylko małych liter, cyfr i myślników, bez myślnika na początku i na końcu.", + handleReserved: "Ta nazwa jest zarezerwowana.", + statusKey: "Użyj od 1 do 32 małych liter, cyfr lub podkreśleń.", + duplicateKey: "Każdy klucz może wystąpić tylko raz.", + hexColor: "Podaj kolor w zapisie szesnastkowym, na przykład #00E676.", + oneEvent: "Wybierz co najmniej jedno zdarzenie.", + oneLogEvent: "Wybierz co najmniej jedną rzecz do rejestrowania.", + invalidValue: "Ta wartość jest nieprawidłowa.", + }, kit: { close: "Zamknij", shell: { diff --git a/apps/web/src/lib/validation-error.ts b/apps/web/src/lib/validation-error.ts new file mode 100644 index 0000000..7df1de0 --- /dev/null +++ b/apps/web/src/lib/validation-error.ts @@ -0,0 +1,18 @@ +import "server-only"; + +import type { z } from "zod"; + +import { getDict, getLocale } from "@/i18n"; + +import { localizeIssue } from "./validation-message"; + +/** + * The first problem of a rejected request body, in the viewer's language, for + * the `error` of a dashboard route's 422. Undefined when there is none. + */ +export async function validationMessage(error: z.ZodError): Promise { + const issue = error.issues[0]; + if (!issue) return undefined; + const [locale, dict] = await Promise.all([getLocale(), getDict()]); + return localizeIssue(issue, locale, dict.validation); +} diff --git a/apps/web/src/lib/validation-message.test.ts b/apps/web/src/lib/validation-message.test.ts new file mode 100644 index 0000000..eb3be2b --- /dev/null +++ b/apps/web/src/lib/validation-message.test.ts @@ -0,0 +1,51 @@ +import { VALIDATION_MESSAGES, type ValidationMessageKey } from "@msk-forms/shared"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { dictionaries } from "@/i18n/dictionaries"; + +import { localizeIssue } from "./validation-message"; + +const firstIssue = (schema: z.ZodType, value: unknown) => { + const result = schema.safeParse(value); + if (result.success) throw new Error("expected a validation error"); + return result.error.issues[0]!; +}; + +describe("localizeIssue", () => { + const hexIssue = firstIssue(z.string().regex(/^#/, VALIDATION_MESSAGES.hexColor), "red"); + + it("translates our own schema messages from the dictionary", () => { + const de = localizeIssue(hexIssue, "de", dictionaries.de.validation); + expect(de).toBe(dictionaries.de.validation.hexColor); + expect(de).not.toBe(VALIDATION_MESSAGES.hexColor); + }); + + it("keeps English as the API sends it", () => { + expect(localizeIssue(hexIssue, "en", dictionaries.en.validation)).toBe( + VALIDATION_MESSAGES.hexColor, + ); + }); + + it("uses zod's own locale for its built-in messages", () => { + const issue = firstIssue(z.string().max(3), "toolong"); + const de = localizeIssue(issue, "de", dictionaries.de.validation); + expect(de).not.toBe(issue.message); + expect(de).toMatch(/Zu groß/); + }); + + it("falls back to the generic text for a wrong type", () => { + const issue = firstIssue(z.object({ n: z.number() }), { n: "x" }); + expect(localizeIssue(issue, "pl", dictionaries.pl.validation)).toBe( + dictionaries.pl.validation.invalidValue, + ); + }); + + it("has a translation for every message key in every language", () => { + for (const dict of Object.values(dictionaries)) { + for (const key of Object.keys(VALIDATION_MESSAGES) as ValidationMessageKey[]) { + expect(dict.validation[key]).toBeTruthy(); + } + } + }); +}); diff --git a/apps/web/src/lib/validation-message.ts b/apps/web/src/lib/validation-message.ts new file mode 100644 index 0000000..729abad --- /dev/null +++ b/apps/web/src/lib/validation-message.ts @@ -0,0 +1,33 @@ +import { validationMessageKey } from "@msk-forms/shared"; +import { z } from "zod"; + +import type { Dictionary, Locale } from "@/i18n"; + +const ZOD_LOCALES = { + de: z.locales.de, + hu: z.locales.hu, + fr: z.locales.fr, + es: z.locales.es, + pt: z.locales.pt, + pl: z.locales.pl, +} as const; + +/** + * One zod issue in the viewer's language. Our own schema messages come from + * the dictionary, zod's built-in ones from zod's own locale. A wrong type is + * the exception: a finished issue no longer carries the input, so zod would + * name the received type "undefined"; the generic text is used instead. + */ +export function localizeIssue( + issue: z.core.$ZodIssue, + locale: Locale, + t: Dictionary["validation"], +): string { + const key = validationMessageKey(issue.message); + if (key) return t[key]; + if (locale === "en") return issue.message; + if (issue.code === "invalid_type") return t.invalidValue; + const out = ZOD_LOCALES[locale]().localeError(issue as never); + const message = typeof out === "string" ? out : out?.message; + return message ?? t.invalidValue; +} diff --git a/packages/shared/src/audit-log.ts b/packages/shared/src/audit-log.ts index a1a8d7b..5ce0359 100644 --- a/packages/shared/src/audit-log.ts +++ b/packages/shared/src/audit-log.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { LOG_ACTIONS, type LogAction } from "./notifications"; import { DISCORD_WEBHOOK_URL_RE } from "./webhooks"; +import { VALIDATION_MESSAGES } from "./validation-messages"; /** * Audit-log webhooks: a guild points a Discord channel webhook at MSK Forms and @@ -106,10 +107,10 @@ export const auditWebhookInputSchema = z.object({ .string() .trim() .max(2000) - .regex(DISCORD_WEBHOOK_URL_RE, "Enter a valid Discord channel webhook URL."), + .regex(DISCORD_WEBHOOK_URL_RE, VALIDATION_MESSAGES.discordWebhookUrl), actions: z .array(logActionSchema) - .min(1, "Select at least one thing to log.") + .min(1, VALIDATION_MESSAGES.oneLogEvent) .transform((actions) => [...new Set(actions)]), /** Optional form to scope form-related entries to; null = every form. */ formId: z.string().uuid().nullish(), @@ -122,7 +123,7 @@ export const auditWebhookUpdateSchema = z name: z.string().trim().max(80).nullish(), actions: z .array(logActionSchema) - .min(1, "Select at least one thing to log.") + .min(1, VALIDATION_MESSAGES.oneLogEvent) .transform((actions) => [...new Set(actions)]), formId: z.string().uuid().nullish(), active: z.boolean(), diff --git a/packages/shared/src/bot-config.ts b/packages/shared/src/bot-config.ts index 468fab3..4e98364 100644 --- a/packages/shared/src/bot-config.ts +++ b/packages/shared/src/bot-config.ts @@ -1,11 +1,12 @@ import { z } from "zod"; +import { VALIDATION_MESSAGES } from "./validation-messages"; /** * Per-guild bot configuration stored in `Guild.botConfig` (JSON). Drives the * Discord review workflow: where new submissions are announced and which role * is granted on acceptance. */ -const snowflake = z.string().regex(/^\d{17,20}$/, "Enter a valid Discord ID."); +const snowflake = z.string().regex(/^\d{17,20}$/, VALIDATION_MESSAGES.discordId); /** App locales the bot can speak per guild (command replies, embeds, logs). */ export const BOT_LOCALES = ["en", "de", "hu", "fr", "es", "pt", "pl"] as const; diff --git a/packages/shared/src/branding.ts b/packages/shared/src/branding.ts index 82b1379..f21a0d3 100644 --- a/packages/shared/src/branding.ts +++ b/packages/shared/src/branding.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { VALIDATION_MESSAGES } from "./validation-messages"; /** * Per-guild branding stored in `Guild.branding` (JSON). Applied to that guild's @@ -7,7 +8,7 @@ import { z } from "zod"; */ const accentColor = z .string() - .regex(/^#[0-9a-fA-F]{6}$/, "Use a hex color like #00E676.") + .regex(/^#[0-9a-fA-F]{6}$/, VALIDATION_MESSAGES.hexColor) .optional(); /** Hard ceiling for guild custom CSS. */ diff --git a/packages/shared/src/categories.ts b/packages/shared/src/categories.ts index 159a113..b137e65 100644 --- a/packages/shared/src/categories.ts +++ b/packages/shared/src/categories.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { VALIDATION_MESSAGES } from "./validation-messages"; /** Max categories a guild can define. */ export const MAX_CATEGORIES = 40; @@ -13,7 +14,7 @@ export const categoryInputSchema = z.object({ name: z.string().min(1).max(60), color: z .string() - .regex(/^#[0-9a-fA-F]{6}$/, "Use a hex color like #4ea426.") + .regex(/^#[0-9a-fA-F]{6}$/, VALIDATION_MESSAGES.hexColor) .nullable() .optional(), order: z.number().int().min(0).max(1000).default(0), diff --git a/packages/shared/src/domains.ts b/packages/shared/src/domains.ts index e96497a..c42c756 100644 --- a/packages/shared/src/domains.ts +++ b/packages/shared/src/domains.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { VALIDATION_MESSAGES } from "./validation-messages"; /** A DNS hostname (no scheme, no path, no port). Lowercased before validation. */ const HOSTNAME = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/; @@ -8,7 +9,7 @@ export const customDomainSchema = z.object({ .string() .trim() .transform((s) => s.toLowerCase()) - .pipe(z.string().regex(HOSTNAME, "Enter a valid domain like apply.example.com.")), + .pipe(z.string().regex(HOSTNAME, VALIDATION_MESSAGES.domain)), }); export type CustomDomainInput = z.infer; diff --git a/packages/shared/src/form-definition.ts b/packages/shared/src/form-definition.ts index 1de37a8..98116b0 100644 --- a/packages/shared/src/form-definition.ts +++ b/packages/shared/src/form-definition.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { formSettingsSchema } from "./form-settings"; import { formSpecSchema } from "./form-spec"; +import { VALIDATION_MESSAGES } from "./validation-messages"; /** Bumped when the export envelope shape changes incompatibly. */ export const FORM_DEFINITION_VERSION = 1; @@ -27,7 +28,7 @@ export const formDefinitionSchema = z.object({ .string() .min(1) .max(80) - .regex(/^[a-z0-9-]+$/, "Use lowercase letters, numbers and hyphens only."), + .regex(/^[a-z0-9-]+$/, VALIDATION_MESSAGES.slug), status: z.enum(["draft", "live", "closed", "archived"]), visibility: z.enum(["public", "authenticated", "password", "role_required"]), openAt: z.string().datetime().nullish(), diff --git a/packages/shared/src/form-settings.ts b/packages/shared/src/form-settings.ts index caf2c81..b418cdd 100644 --- a/packages/shared/src/form-settings.ts +++ b/packages/shared/src/form-settings.ts @@ -2,13 +2,14 @@ import { z } from "zod"; import { evaluateCondition, type AnswerMap } from "./conditions"; import { conditionOperatorSchema } from "./form-spec"; +import { VALIDATION_MESSAGES } from "./validation-messages"; /** * Per-form settings stored in `Form.settings` (JSON): a per-form accepted role * that overrides the guild-wide `botConfig.acceptedRoleId`, plus "when-then" * automation rules (concept §20). */ -const snowflake = z.string().regex(/^\d{17,20}$/, "Enter a valid Discord ID."); +const snowflake = z.string().regex(/^\d{17,20}$/, VALIDATION_MESSAGES.discordId); /** A single condition: `answers[field] value` (same shape as field conditions). */ export const automationConditionSchema = z.object({ diff --git a/packages/shared/src/handle.ts b/packages/shared/src/handle.ts index a03939c..3be401d 100644 --- a/packages/shared/src/handle.ts +++ b/packages/shared/src/handle.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { VALIDATION_MESSAGES } from "./validation-messages"; /** * Reserved handles that must not be claimed by a guild. Most are also explicit @@ -51,8 +52,5 @@ export const handleSchema = z .string() .min(2) .max(32) - .regex( - /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/, - "Use lowercase letters, numbers and hyphens (no leading or trailing hyphen).", - ) - .refine((h) => !isReservedHandle(h), "That handle is reserved."); + .regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/, VALIDATION_MESSAGES.handle) + .refine((h) => !isReservedHandle(h), VALIDATION_MESSAGES.handleReserved); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7e2a268..aac4f77 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -18,3 +18,4 @@ export * from "./plans"; export * from "./scoring"; export * from "./schedule"; export * from "./formula"; +export * from "./validation-messages"; diff --git a/packages/shared/src/status-defs.ts b/packages/shared/src/status-defs.ts index d36ac08..4cec13b 100644 --- a/packages/shared/src/status-defs.ts +++ b/packages/shared/src/status-defs.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { DEFAULT_STATUSES } from "./constants"; +import { VALIDATION_MESSAGES } from "./validation-messages"; /** * True when a status is terminal — an "ending" state that closes the review @@ -23,11 +24,9 @@ export function isTerminalStatus( */ export const statusDefInputSchema = z.object({ // Stable machine key — lowercase letters, digits, underscores. - key: z - .string() - .regex(/^[a-z0-9_]{1,32}$/, "Use 1–32 lowercase letters, digits or underscores."), + key: z.string().regex(/^[a-z0-9_]{1,32}$/, VALIDATION_MESSAGES.statusKey), label: z.string().min(1).max(60), - color: z.string().regex(/^#[0-9a-fA-F]{6}$/, "Use a hex color like #00E676."), + color: z.string().regex(/^#[0-9a-fA-F]{6}$/, VALIDATION_MESSAGES.hexColor), isTerminal: z.boolean().default(false), visibleToApplicant: z.boolean().default(true), }); @@ -41,7 +40,11 @@ export const statusDefsSchema = z const seen = new Set(); defs.forEach((d, i) => { if (seen.has(d.key)) { - ctx.addIssue({ code: z.ZodIssueCode.custom, path: [i, "key"], message: "Duplicate key." }); + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [i, "key"], + message: VALIDATION_MESSAGES.duplicateKey, + }); } seen.add(d.key); }); diff --git a/packages/shared/src/validation-messages.ts b/packages/shared/src/validation-messages.ts new file mode 100644 index 0000000..694d141 --- /dev/null +++ b/packages/shared/src/validation-messages.ts @@ -0,0 +1,32 @@ +/** + * The custom messages of the dashboard's zod schemas, by key. Schemas use these + * constants instead of inline text: the API speaks English, and the dashboard + * routes map a message back to its key to answer in the viewer's language + * (`validation` in the web app's dictionaries, which must have every key). + */ +export const VALIDATION_MESSAGES = { + discordId: "Enter a valid Discord ID.", + discordWebhookUrl: "Enter a valid Discord channel webhook URL.", + discordUrlInGeneric: "This looks like a Discord webhook URL. Choose the Discord format.", + url: "Enter a valid URL.", + domain: "Enter a valid domain like apply.example.com.", + slug: "Use lowercase letters, numbers and hyphens only.", + handle: "Use lowercase letters, numbers and hyphens (no leading or trailing hyphen).", + handleReserved: "That handle is reserved.", + statusKey: "Use 1–32 lowercase letters, digits or underscores.", + duplicateKey: "Each key can only be used once.", + hexColor: "Use a hex color like #00E676.", + oneEvent: "Select at least one event.", + oneLogEvent: "Select at least one thing to log.", +} as const; + +export type ValidationMessageKey = keyof typeof VALIDATION_MESSAGES; + +const BY_MESSAGE = new Map( + (Object.entries(VALIDATION_MESSAGES) as [ValidationMessageKey, string][]).map(([k, v]) => [v, k]), +); + +/** The key of one of our own validation messages, or undefined for any other text. */ +export function validationMessageKey(message: string): ValidationMessageKey | undefined { + return BY_MESSAGE.get(message); +} diff --git a/packages/shared/src/webhooks.ts b/packages/shared/src/webhooks.ts index 9e6260b..d9f0fe3 100644 --- a/packages/shared/src/webhooks.ts +++ b/packages/shared/src/webhooks.ts @@ -6,6 +6,7 @@ import { formSpecSchema, isLayoutField, } from "./form-spec"; +import { VALIDATION_MESSAGES } from "./validation-messages"; /** * Outgoing webhook events a guild can subscribe to (concept §20). Keep these @@ -38,8 +39,8 @@ export const DISCORD_WEBHOOK_URL_RE = /** A guild's webhook registration as submitted from the dashboard. */ export const webhookInputSchema = z .object({ - url: z.string().url("Enter a valid URL.").max(2000), - events: z.array(webhookEventSchema).min(1, "Select at least one event."), + url: z.string().url(VALIDATION_MESSAGES.url).max(2000), + events: z.array(webhookEventSchema).min(1, VALIDATION_MESSAGES.oneEvent), active: z.boolean().default(true), format: webhookFormatSchema.default("json"), /** Optional form to scope the webhook to; omit/null = every form. */ @@ -51,7 +52,7 @@ export const webhookInputSchema = z ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["url"], - message: "Enter a valid Discord channel webhook URL.", + message: VALIDATION_MESSAGES.discordWebhookUrl, }); } // Common mistake: pasting a Discord webhook URL but leaving the generic JSON @@ -60,7 +61,7 @@ export const webhookInputSchema = z ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["url"], - message: "This looks like a Discord webhook URL. Choose the Discord format.", + message: VALIDATION_MESSAGES.discordUrlInGeneric, }); } }); @@ -90,7 +91,7 @@ const webhookSourceSchema = z.enum(WEBHOOK_SOURCES); * and an optional provider tag so the dashboard can show who manages the hook. */ export const webhookSubscribeSchema = z.object({ - targetUrl: z.string().url("Enter a valid URL.").max(2000), + targetUrl: z.string().url(VALIDATION_MESSAGES.url).max(2000), event: z.enum(WEBHOOK_EVENTS), source: webhookSourceSchema.default("integration"), });