diff --git a/apps/web/src/app/dashboard/[guildId]/forms/[formId]/preview/page.tsx b/apps/web/src/app/dashboard/[guildId]/forms/[formId]/preview/page.tsx index 4ffe525..1d1a156 100644 --- a/apps/web/src/app/dashboard/[guildId]/forms/[formId]/preview/page.tsx +++ b/apps/web/src/app/dashboard/[guildId]/forms/[formId]/preview/page.tsx @@ -104,6 +104,8 @@ export default async function FormPreviewPage({ dateClear: t.dateClear, dateNow: t.dateNow, previewNote: t.previewNote, + yes: t.yes, + no: t.no, }} /> diff --git a/apps/web/src/app/dashboard/[guildId]/forms/page.tsx b/apps/web/src/app/dashboard/[guildId]/forms/page.tsx index 0cbdfe3..f3c8806 100644 --- a/apps/web/src/app/dashboard/[guildId]/forms/page.tsx +++ b/apps/web/src/app/dashboard/[guildId]/forms/page.tsx @@ -13,7 +13,7 @@ import { ShareButton } from "@/components/dashboard/share-button"; import { LocalDateTime } from "@/components/public/local-datetime"; import { requireUser } from "@/lib/auth"; import { appBaseUrl } from "@/lib/url"; -import { canManageForms, getGuildForms } from "@/lib/guild"; +import { canManageForms, getGuildForms, getReviewScope } from "@/lib/guild"; import { getGuildPlan } from "@/lib/plan"; import { enterpriseEnabled, stripeEnabled } from "@/lib/stripe"; import { getDict } from "@/i18n"; @@ -35,8 +35,9 @@ export default async function GuildFormsPage({ }) { const { guildId } = await params; const user = await requireUser(`/dashboard/${guildId}/forms`); + const scope = await getReviewScope(guildId, user.id); const [forms, canManage, plan] = await Promise.all([ - getGuildForms(guildId), + getGuildForms(guildId, scope), canManageForms(guildId, user.id), getGuildPlan(guildId), ]); diff --git a/apps/web/src/app/f/[slug]/page.tsx b/apps/web/src/app/f/[slug]/page.tsx index 89cfd01..a5460df 100644 --- a/apps/web/src/app/f/[slug]/page.tsx +++ b/apps/web/src/app/f/[slug]/page.tsx @@ -194,6 +194,8 @@ export default async function PublicFormPage({ dateClear: t.dateClear, dateNow: t.dateNow, previewNote: t.previewNote, + yes: t.yes, + no: t.no, }} /> diff --git a/apps/web/src/components/form/field-input.tsx b/apps/web/src/components/form/field-input.tsx index e4ee8c7..d3c6977 100644 --- a/apps/web/src/components/form/field-input.tsx +++ b/apps/web/src/components/form/field-input.tsx @@ -26,11 +26,6 @@ export type FieldValue = | Record | undefined; -const YES_NO_OPTIONS = [ - { value: "yes", label: "Yes" }, - { value: "no", label: "No" }, -]; - interface FieldInputProps { field: FormField; value: FieldValue; @@ -41,6 +36,7 @@ interface FieldInputProps { slug: string; fileLabels: FileFieldLabels; dateLabels: DateFieldLabels; + yesNoLabels: { yes: string; no: string }; } /** Renders the interactive control for a single (non-layout) form field. */ @@ -53,6 +49,7 @@ export function FieldInput({ slug, fileLabels, dateLabels, + yesNoLabels, }: FieldInputProps) { const id = field.id; const options = (field.options ?? []).map((o) => ({ value: o.value, label: o.label })); @@ -215,7 +212,10 @@ export function FieldInput({ return ( onChange(v === "yes")} diff --git a/apps/web/src/components/form/form-renderer.tsx b/apps/web/src/components/form/form-renderer.tsx index 7069166..d629c00 100644 --- a/apps/web/src/components/form/form-renderer.tsx +++ b/apps/web/src/components/form/form-renderer.tsx @@ -3,6 +3,7 @@ import { computePagePath, evaluateFormula, + isAnswerMissing, isComputedField, isFieldRequired, isFieldVisible, @@ -26,16 +27,6 @@ function isLayout(field: FormField): boolean { return isLayoutField(field.type); } -/** True when a required field has no meaningful answer. */ -function isEmpty(value: FieldValue): boolean { - if (value === undefined || value === null || value === "") return true; - if (Array.isArray(value)) return value.length === 0; - if (value === false) return true; // unchecked consent/age_check - // Matrix answer ({ rowId: column }) with nothing picked yet. - if (typeof value === "object" && Object.keys(value).length === 0) return true; - return false; -} - export interface FormLabels { submit: string; submitting: string; @@ -53,6 +44,8 @@ export interface FormLabels { dateClear: string; dateNow: string; previewNote: string; + yes: string; + no: string; } export function FormRenderer({ @@ -119,7 +112,7 @@ export function FormRenderer({ for (const field of fields) { // Layout has no value; calculated fields are derived, never entered. if (isLayout(field) || isComputedField(field.type) || !isFieldVisible(field, answers)) continue; - if (isFieldRequired(field, answers) && isEmpty(answers[field.id])) { + if (isFieldRequired(field, answers) && isAnswerMissing(field, answers[field.id])) { out[field.id] = labels.required; } } @@ -254,6 +247,7 @@ export function FormRenderer({ clear: labels.signatureClear, }} dateLabels={{ today: labels.dateToday, clear: labels.dateClear, now: labels.dateNow }} + yesNoLabels={{ yes: labels.yes, no: labels.no }} /> ), diff --git a/apps/web/src/i18n/dictionaries.ts b/apps/web/src/i18n/dictionaries.ts index 755694e..8b39a34 100644 --- a/apps/web/src/i18n/dictionaries.ts +++ b/apps/web/src/i18n/dictionaries.ts @@ -202,6 +202,7 @@ const en = { signatureClear: "Clear", next: "Next", back: "Back", step: "Step", dateToday: "Today", dateClear: "Clear", dateNow: "Now", + yes: "Yes", no: "No", poweredBy: "Powered by MSK Forms", }, status: { @@ -752,6 +753,7 @@ const de: Dictionary = { signatureClear: "Löschen", next: "Weiter", back: "Zurück", step: "Schritt", dateToday: "Heute", dateClear: "Löschen", dateNow: "Jetzt", + yes: "Ja", no: "Nein", poweredBy: "Bereitgestellt von MSK Forms", }, status: { @@ -1298,6 +1300,7 @@ const hu: Dictionary = { signatureClear: "Törlés", next: "Tovább", back: "Vissza", step: "Lépés", dateToday: "Ma", dateClear: "Törlés", dateNow: "Most", + yes: "Igen", no: "Nem", poweredBy: "Üzemelteti: MSK Forms", }, status: { @@ -1844,6 +1847,7 @@ const fr: Dictionary = { signatureClear: "Effacer", next: "Suivant", back: "Retour", step: "Étape", dateToday: "Aujourd'hui", dateClear: "Effacer", dateNow: "Maintenant", + yes: "Oui", no: "Non", poweredBy: "Propulsé par MSK Forms", }, status: { @@ -2391,6 +2395,7 @@ const es: Dictionary = { signatureClear: "Limpiar", next: "Siguiente", back: "Atrás", step: "Paso", dateToday: "Hoy", dateClear: "Limpiar", dateNow: "Ahora", + yes: "Sí", no: "No", poweredBy: "Con tecnología de MSK Forms", }, status: { @@ -2938,6 +2943,7 @@ const pt: Dictionary = { signatureClear: "Limpar", next: "Próximo", back: "Voltar", step: "Etapa", dateToday: "Hoje", dateClear: "Limpar", dateNow: "Agora", + yes: "Sim", no: "Não", poweredBy: "Desenvolvido com MSK Forms", }, status: { @@ -3485,6 +3491,7 @@ const pl: Dictionary = { signatureClear: "Wyczyść", next: "Dalej", back: "Wstecz", step: "Krok", dateToday: "Dzisiaj", dateClear: "Wyczyść", dateNow: "Teraz", + yes: "Tak", no: "Nie", poweredBy: "Powered by MSK Forms", }, status: { diff --git a/apps/web/src/lib/guild.ts b/apps/web/src/lib/guild.ts index 2c210cc..394082d 100644 --- a/apps/web/src/lib/guild.ts +++ b/apps/web/src/lib/guild.ts @@ -130,10 +130,19 @@ export async function requireGuildMembership(guildId: string, userId: string) { return { ...membership.guild, role: membership.role }; } -/** Forms for a guild, with submission counts, for the "Manage Forms" tab. */ -export async function getGuildForms(guildId: string) { +/** + * Forms for a guild, with submission counts, for the "Manage Forms" tab, scoped + * to what the viewer may review. Managers/guild-wide reviewers (`scope.all`) see + * every form; a per-form reviewer sees only their granted forms (empty grants → + * no forms). Keeps the list consistent with the per-form export/review gating. + */ +export async function getGuildForms(guildId: string, scope: ReviewScope) { + if (!scope.all && scope.formIds.length === 0) return []; return prisma.form.findMany({ - where: { guildId }, + where: { + guildId, + ...(scope.all ? {} : { id: { in: scope.formIds } }), + }, orderBy: { updatedAt: "desc" }, select: { id: true, diff --git a/package.json b/package.json index 947dc9c..4cf4f7c 100644 --- a/package.json +++ b/package.json @@ -3,14 +3,14 @@ "version": "0.1.0", "private": true, "description": "MSK Forms — a modern form & application platform with status feedback and a Discord bot", - "author": "Moritz Kohm (https://msk-scripts.de)", + "author": "Moritz Kohm (https://www.msk-scripts.de)", "license": "SEE LICENSE IN LICENSE", "type": "module", "engines": { "node": ">=22", "pnpm": ">=9" }, - "packageManager": "pnpm@11.2.2+sha512.36e6621fad506178936455e70247b8808ef4ec25797a9f437a93281a020484e2607f6a469a22e982987c3dbb8866e3071514ab10a4a1749e06edcd1ec118436f", + "packageManager": "pnpm@11.10.0+sha512.0b7f8b98060031904c017e3a41eb187a16d40eeb829b95c4f8cb03681761fc4ab53dd219115b9b447f4dce1a05a214764461e7d3703392a9f32f9511ce8c86c8", "scripts": { "dev": "turbo run dev", "build": "turbo run build", diff --git a/packages/shared/src/conditions.ts b/packages/shared/src/conditions.ts index 3208070..9174c1e 100644 --- a/packages/shared/src/conditions.ts +++ b/packages/shared/src/conditions.ts @@ -12,6 +12,25 @@ export function isBlankAnswer(value: unknown): boolean { return false; } +/** + * Field-aware "is this required answer missing?" check (shared client+server). + * Most types defer to `isBlankAnswer`, but two need special handling: + * - `yes_no`: an explicit "No" is `false`, which is a valid answer — unlike + * consent/age_check where an unchecked box (`false`) means no agreement. + * - `matrix`: every row must have a picked column. + */ +export function isAnswerMissing(field: FormField, value: unknown): boolean { + if (field.type === "yes_no") return value !== true && value !== false; + if (field.type === "matrix") { + const rows = field.rows ?? []; + return ( + rows.length === 0 || + rows.some((row) => isBlankAnswer((value as Record)?.[row.id])) + ); + } + return isBlankAnswer(value); +} + /** Evaluate a single rule's `when` clause against the current answers. */ export function evaluateCondition(when: ConditionRule["when"], answers: AnswerMap): boolean { const actual = answers[when.field]; diff --git a/packages/shared/src/form-spec.test.ts b/packages/shared/src/form-spec.test.ts index 22d502f..d0c3e33 100644 --- a/packages/shared/src/form-spec.test.ts +++ b/packages/shared/src/form-spec.test.ts @@ -62,6 +62,19 @@ describe("buildAnswerSchema", () => { expect(answerSchema.safeParse({ age: 30 }).success).toBe(false); // name missing }); + it("accepts an explicit No (false) for a required yes_no field", () => { + const s = buildAnswerSchema(specWith({ id: "yn", type: "yes_no", validation: { required: true } })); + expect(s.safeParse({ yn: false }).success).toBe(true); // "No" is a valid answer + expect(s.safeParse({ yn: true }).success).toBe(true); + expect(s.safeParse({}).success).toBe(false); // unanswered + }); + + it("requires a checked box for a required consent field", () => { + const s = buildAnswerSchema(specWith({ id: "c", type: "consent", validation: { required: true } })); + expect(s.safeParse({ c: false }).success).toBe(false); // unchecked = no agreement + expect(s.safeParse({ c: true }).success).toBe(true); + }); + it("rejects an empty string for a required text field", () => { const s = buildAnswerSchema(specWith({ id: "x", type: "short_text", validation: { required: true } })); expect(s.safeParse({ x: "" }).success).toBe(false); diff --git a/packages/shared/src/form-spec.ts b/packages/shared/src/form-spec.ts index 594c98f..ce5154a 100644 --- a/packages/shared/src/form-spec.ts +++ b/packages/shared/src/form-spec.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { isBlankAnswer, isFieldRequired, isFieldVisible } from "./conditions"; +import { isAnswerMissing, isFieldRequired, isFieldVisible } from "./conditions"; /** * Form spec — the JSON definition of a form (concept §6–§8). @@ -300,14 +300,7 @@ export function buildAnswerSchema(spec: FormSpec): z.ZodTypeAny { for (const field of fields) { if (!isFieldVisible(field, answers) || !isFieldRequired(field, answers)) continue; - const value = answers[field.id]; - const missing = - field.type === "matrix" - ? (field.rows ?? []).length === 0 || - (field.rows ?? []).some((row) => isBlankAnswer((value as Record)?.[row.id])) - : isBlankAnswer(value); - - if (missing) { + if (isAnswerMissing(field, answers[field.id])) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: [field.id], message: "Required." }); } }