Files
fdbck/src/lib/schemas.ts
mAi 120f0798cd mAi: #4 - single-submission enforcement (default on)
Block repeat submissions per participant by default. No new fingerprint
column — dedup against the existing client_session_id and (client_ip,
user_agent) we already store on feedback_submissions.

Schema migration `fdbck_feedback_instances_add_single_submission` adds
single_submission BOOLEAN NOT NULL DEFAULT true (applied via Supabase MCP).
Existing instances default to true. Author opts out per-instance via the
new toggle on /admin/feedback/new and the detail Edit tab.

Server (POST /api/public/feedback/<slug>/submit): when
inst.single_submission is true, look up the most recent existing submission
matching instance_id AND (client_session_id = body.client_session_id) OR
(client_ip = req.ip AND user_agent = req.user_agent). Two separate
parameterised queries instead of a single PostgREST `.or()` filter — the
user-controlled session id has no character restriction in the schema, so
splicing it into a filter string would risk PostgREST filter injection.
Returns 409 with { error: 'already_submitted', submitted_at, display_name,
answers } so the client can render the previous answers without an extra
round-trip.

Client (/f/<slug>): on 409, replace the form with a read-only "already
submitted on <date>" card listing the previous answers per question. Reuses
question shape via a small summariseSubmittedAnswer() helper covering all
six question types including date_ranked_choice rating maps. No submit
button on the read-only view; live results polling still kicks off.

Admin schemas (InstanceCreate + InstanceUpdate) accept the new
single_submission boolean. POST/PATCH endpoints persist it.

bun check 0 errors, bun test 25 pass, bun build OK.
2026-05-06 15:32:20 +02:00

161 lines
5.5 KiB
TypeScript

/**
* Zod schemas for fdbck request body validation.
*/
import { z } from 'zod';
const FeedbackQuestionBaseSchema = z.object({
id: z.string().min(1).max(64),
label: z.string().min(1).max(200),
required: z.boolean().optional(),
help: z.string().max(500).optional(),
});
/** One date/time option in a `date_ranked_choice` question. Times are stored as UTC ISO 8601 strings. */
export const DateRankedOptionSchema = z.object({
id: z.string().min(1).max(64).regex(/^[a-zA-Z0-9_-]+$/, {
message: 'option id may only contain letters, digits, "-" and "_"',
}),
start: z.string().datetime({ offset: true }),
end: z.string().datetime({ offset: true }).optional(),
label: z.string().max(200).optional(),
});
export const FeedbackQuestionSchema = z.discriminatedUnion('type', [
FeedbackQuestionBaseSchema.extend({
type: z.literal('short_text'),
placeholder: z.string().max(100).optional(),
}),
FeedbackQuestionBaseSchema.extend({
type: z.literal('long_text'),
placeholder: z.string().max(100).optional(),
}),
FeedbackQuestionBaseSchema.extend({
type: z.literal('single_choice'),
options: z.array(z.string().min(1).max(200)).min(2).max(20),
}),
FeedbackQuestionBaseSchema.extend({
type: z.literal('multi_choice'),
options: z.array(z.string().min(1).max(200)).min(2).max(20),
}),
FeedbackQuestionBaseSchema.extend({
type: z.literal('scale'),
min: z.number().int().min(0).max(100),
max: z.number().int().min(1).max(100),
min_label: z.string().max(50).optional(),
max_label: z.string().max(50).optional(),
}),
FeedbackQuestionBaseSchema.extend({
type: z.literal('boolean'),
}),
FeedbackQuestionBaseSchema.extend({
type: z.literal('date_ranked_choice'),
options: z.array(DateRankedOptionSchema).min(2).max(50)
.refine(
(opts) => new Set(opts.map((o) => o.id)).size === opts.length,
{ message: 'date_ranked_choice option ids must be unique' },
),
// Scale is locked at 1-5 (5-point Likert) per design — only the labels are author-configurable.
scale: z.object({
min_label: z.string().max(50).optional(),
max_label: z.string().max(50).optional(),
}).optional(),
allow_partial: z.boolean().optional(),
}),
]);
/** Version stamp like `0.260505` (YYMMDD) or `0.260505.b` for same-day re-edits. */
export const FormVersionSchema = z.string().regex(/^0\.\d{6}(\.[a-z])?$/);
export const FeedbackFormDefinitionSchema = z.object({
version: FormVersionSchema.optional(),
intro: z.string().max(2000).optional(),
outro: z.string().max(2000).optional(),
questions: z.array(FeedbackQuestionSchema).min(1).max(50),
}).refine(
(def) => {
const ids = def.questions.map((q) => q.id);
return new Set(ids).size === ids.length;
},
{ message: 'Question ids must be unique' },
);
export const FeedbackInstanceCreateSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
form_definition: FeedbackFormDefinitionSchema.nullable().optional(),
chat_enabled: z.boolean().optional(),
live_results_enabled: z.boolean().optional(),
single_submission: z.boolean().optional(),
}).refine(
(v) => v.form_definition != null || v.chat_enabled === true,
{ message: 'Either form_definition or chat_enabled must be set' },
);
export const FeedbackInstanceUpdateSchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().max(2000).nullable().optional(),
form_definition: FeedbackFormDefinitionSchema.nullable().optional(),
chat_enabled: z.boolean().optional(),
live_results_enabled: z.boolean().optional(),
single_submission: z.boolean().optional(),
status: z.enum(['open', 'closed']).optional(),
});
const ANSWER_MAX = 5000;
/** Per-option rating map for `date_ranked_choice` answers, e.g. `{ opt1: 5, opt2: null }`. */
const DateRankedAnswerSchema = z.record(
z.string().min(1).max(64),
z.number().int().min(1).max(5).nullable(),
).refine((m) => Object.keys(m).length <= 50, {
message: 'too many rated options',
});
export const FeedbackSubmissionSchema = z.object({
display_name: z.string().max(80).nullable().optional()
.transform((v) => (v && v.trim() ? v.trim().replace(/[\r\n]+/g, ' ') : null)),
client_session_id: z.string().min(1).max(100),
answers: z.record(
z.string().min(1).max(64),
z.union([
z.string().max(ANSWER_MAX),
z.number(),
z.boolean(),
z.array(z.string().max(200)).max(20),
DateRankedAnswerSchema,
z.null(),
]),
),
company: z.string().max(200).optional(), // honeypot
});
export const FeedbackPostSchema = z.object({
display_name: z.string().max(80).nullable().optional()
.transform((v) => (v && v.trim() ? v.trim().replace(/[\r\n]+/g, ' ') : null)),
client_session_id: z.string().min(1).max(100),
body: z.string().min(1).max(2000),
company: z.string().max(200).optional(), // honeypot
});
export const FeedbackPostHideSchema = z.object({
hidden: z.boolean(),
});
export const SignInSchema = z.object({
email: z.string().email(),
password: z.string().min(6).max(200),
});
/** Body schema for the admin "create short URL" endpoint. */
export const ShareCreateSchema = z.object({
customSlug: z.string().min(1).max(64).regex(/^[a-zA-Z0-9_-]+$/, {
message: 'customSlug may only contain letters, digits, "-" and "_"',
}).optional(),
maxVisits: z.number().int().positive().max(1_000_000).optional(),
});
export type FeedbackQuestion = z.infer<typeof FeedbackQuestionSchema>;
export type FeedbackFormDefinition = z.infer<typeof FeedbackFormDefinitionSchema>;
export type DateRankedOption = z.infer<typeof DateRankedOptionSchema>;
export type DateRankedAnswer = z.infer<typeof DateRankedAnswerSchema>;