import { z } from "zod"; import { isHttpsUrl, ITEM_KINDS, parseYouTube } from "@/lib/screen"; import { idSchema } from "@/lib/tokens"; // Limits mirror the CHECK constraints in supabase/migrations/0001_init.sql so that // the admin sees a friendly message instead of a database error. const text = (max: number, what: string) => z .string() .trim() .min(1, `${what} can't be empty.`) .max(max, `${what} can be at most ${max} characters.`); const optionalText = (max: number, what: string) => z.string().trim().max(max, `${what} can be at most ${max} characters.`); const number = (what: string) => z.number(`${what} must be a number.`); export const eventSchema = z.object({ name: text(120, "Event name"), eventDate: z.union([z.iso.date("Pick a valid date."), z.literal("")]), }); /** A co-host's email, normalised the way event_cohosts stores it (trimmed, lower case). */ export const cohostEmailSchema = z .string() .trim() .toLowerCase() .pipe(z.email("Enter a valid email address.").max(254, "That email is too long.")); export const teamSchema = z.object({ name: text(60, "Team name"), color: z.string().regex(/^#[0-9A-Fa-f]{6}$/, "Pick a colour."), }); /** Is (max − min) a whole number of steps? Tolerates float error for steps like 0.5. */ const onStep = (min: number, max: number, step: number) => { const steps = (max - min) / step; return Math.abs(steps - Math.round(steps)) < 1e-9; }; export const criterionSchema = z .object({ id: idSchema.optional(), label: text(60, "Criterion label"), description: optionalText(300, "Criterion description"), inputType: z.enum(["blocks", "number"]), min: number("Min").min(-999999).max(999999), max: number("Max").min(-999999).max(999999), step: number("Step").positive("Step must be more than 0.").max(999999), weight: number("Weight").min(0, "Weight can't be negative.").max(9999), }) .superRefine((criterion, context) => { const name = criterion.label || "A criterion"; if (criterion.max <= criterion.min) { context.addIssue({ code: "custom", message: `${name}: max must be more than min.` }); return; } if (!onStep(criterion.min, criterion.max, criterion.step)) { context.addIssue({ code: "custom", message: `${name}: the step must divide evenly into the range from min to max.`, }); return; } if ( criterion.inputType === "blocks" && (criterion.max - criterion.min) / criterion.step > 10 ) { context.addIssue({ code: "custom", message: `${name}: blocks can show at most 11 buttons. Use a number input or a bigger step.`, }); } }); export const gameSchema = z.object({ title: text(120, "Game title"), description: optionalText(2000, "Description"), scoringMode: z.enum(["judged", "direct"]), pointsMode: z.enum(["raw", "placement"]), multiplier: number("Multiplier").min(0, "Multiplier can't be negative.").max(9999), placementPoints: z .array(z.number().int("Placement points must be whole numbers.").min(0).max(100000)) .min(1, "Add at least one placement value, like 10, 7, 5, 3.") .max(30), buzzerEnabled: z.boolean(), adminNotes: optionalText(4000, "Admin notes"), judgeNotes: optionalText(4000, "Judge notes"), criteria: z.array(criterionSchema).min(1, "A game needs at least one criterion.").max(20), }); export const MAX_ITEMS_PER_GAME = 200; /** A question, sound or video for a game. Media is a link or an uploaded file's path, never both. */ export const itemSchema = z .object({ kind: z.enum(ITEM_KINDS), title: optionalText(500, "The text"), answer: optionalText(500, "The answer"), mediaUrl: z.string().trim().max(2000, "That link is too long."), storagePath: z.string().nullable(), }) .superRefine((item, context) => { const issue = (message: string) => context.addIssue({ code: "custom", message }); if (!item.title) { return issue(item.kind === "question" ? "Type the question." : "Give it a name, so you can find it in the list."); } if (item.mediaUrl && item.storagePath) return issue("Use a link or a file, not both."); if (item.mediaUrl && !isHttpsUrl(item.mediaUrl)) return issue("The link must start with https://"); if (item.kind === "question") { if (item.mediaUrl && parseYouTube(item.mediaUrl)) { issue("A question's picture must be an image, not a YouTube link. Add the video as its own item."); } } else if (!item.mediaUrl && !item.storagePath) { issue(`Paste a link or choose a file for this ${item.kind}.`); } }); export type ItemDraft = z.input; export type CriterionDraft = z.input; export type GameDraft = z.input; /** Parses "10, 7, 5, 3" into numbers; invalid parts become NaN so validation reports them. */ export function parsePlacementPoints(value: string): number[] { return value .split(/[,\s]+/) .filter(Boolean) .map((part) => Number(part)); } export function firstError(error: z.ZodError): string { return error.issues[0]?.message ?? "Something in the form isn't valid."; }