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.
161 lines
5.5 KiB
TypeScript
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>;
|