Merge mai/cronus/arch-phase1: withOwnedInstance + findExistingSubmission + tests

This commit is contained in:
mAi
2026-05-07 19:50:22 +02:00
16 changed files with 782 additions and 445 deletions

View File

@@ -9,7 +9,7 @@
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"start": "node build/index.js",
"test": "bun test ./src/lib/server/rate-limit.test.ts ./src/lib/server/public-scope.test.ts ./src/lib/server/results.test.ts"
"test": "bun test ./src/lib/server/rate-limit.test.ts ./src/lib/server/public-scope.test.ts ./src/lib/server/results.test.ts ./src/lib/server/admin-route.test.ts ./src/lib/server/feedback-pure.test.ts"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.5.4",

View File

@@ -0,0 +1,30 @@
/**
* Pure decision branch for the admin-route ownership check. Lives in its own
* module (no `$env/dynamic/private` transitive imports) so bun:test can
* exercise the table directly.
*
* The runtime wiring lives in ./admin-route.ts — that's where requireAuth,
* getInstanceById, and the SvelteKit Response helpers come together.
*/
export type OwnershipDecision =
| { ok: true; inst: { owner_user_id: string } }
| { ok: false; status: number; message: string };
/**
* Given the authenticated userId and a loaded instance, decide whether the
* caller may proceed. Structural typing on the instance — only the
* `owner_user_id` field matters for the decision; the wrapper supplies the
* full `FeedbackInstance` to the handler.
*/
export function ownershipDecision<I extends { owner_user_id: string }>(
userId: string | null,
instance: I | null,
): { ok: true; inst: I } | { ok: false; status: number; message: string } {
if (!userId) return { ok: false, status: 401, message: 'Unauthorized' };
if (!instance) return { ok: false, status: 404, message: 'Instance not found' };
if (instance.owner_user_id !== userId) {
return { ok: false, status: 401, message: 'Not your instance' };
}
return { ok: true, inst: instance };
}

View File

@@ -0,0 +1,52 @@
import { describe, test, expect } from 'bun:test';
import { ownershipDecision } from './admin-route-decision';
// Pure decision-table test for the admin-route ownership check. The wrapper
// itself (withOwnedInstance) wires getInstanceById + handleApiError + Response
// helpers and lives behind SvelteKit's RequestEvent shape, which bun:test can't
// construct without pulling in $env/dynamic/private. The decision branch is the
// load-bearing logic, and it's pure — exercise it directly here.
//
// Backstop: if withOwnedInstance regresses past these branches (e.g. forgets to
// call requireAuth), the public-scope policy gate (lib/server/public-scope.ts)
// catches anonymous DB access at the response edge.
const fake = (owner_user_id: string) => ({ owner_user_id });
describe('ownershipDecision', () => {
test('null userId → 401 Unauthorized', () => {
const d = ownershipDecision(null, fake('u-owner'));
expect(d.ok).toBe(false);
if (!d.ok) {
expect(d.status).toBe(401);
expect(d.message).toBe('Unauthorized');
}
});
test('null instance → 404 Instance not found', () => {
const d = ownershipDecision('u-owner', null);
expect(d.ok).toBe(false);
if (!d.ok) {
expect(d.status).toBe(404);
expect(d.message).toBe('Instance not found');
}
});
test('different owner → 401 Not your instance', () => {
const d = ownershipDecision('u-stranger', fake('u-owner'));
expect(d.ok).toBe(false);
if (!d.ok) {
expect(d.status).toBe(401);
expect(d.message).toBe('Not your instance');
}
});
test('matching owner → ok with the instance', () => {
const inst = fake('u-owner');
const d = ownershipDecision('u-owner', inst);
expect(d.ok).toBe(true);
if (d.ok) {
expect(d.inst).toBe(inst);
}
});
});

View File

@@ -0,0 +1,62 @@
/**
* Admin-route helpers — auth + ownership envelope shared by every endpoint
* under /api/admin/feedback/[id]/*.
*
* Today every such endpoint opens with the same 6-line preamble:
* const err = requireAuth(locals.userId);
* if (err) return err;
* try {
* const inst = await getInstanceById(params.id);
* if (!inst) return notFound('Instance not found');
* if (inst.owner_user_id !== locals.userId) return unauthorized('Not your instance');
* // ...real work...
* } catch (e) {
* return handleApiError(e, '<context>');
* }
*
* `withOwnedInstance` lifts that preamble into one place. Each endpoint becomes:
* export const POST = withOwnedInstance(async ({ inst, event }) => {
* // inst is guaranteed valid + owned, errors caught + tagged
* }, 'admin feedback X');
*
* The pure decision branch lives in `./admin-route-decision.ts` so it stays
* unit-testable without pulling in SvelteKit's $env/dynamic/private module
* (which bun:test can't resolve — same constraint as feedback.ts's helpers).
*/
import type { RequestEvent, RequestHandler } from '@sveltejs/kit';
import { requireAuth } from './response';
import { handleApiError, notFound, unauthorized } from './errors';
import { getInstanceById, type FeedbackInstance } from './feedback';
import { ownershipDecision } from './admin-route-decision';
export interface OwnedInstanceContext<P extends Partial<Record<string, string>> = Partial<Record<string, string>>> {
inst: FeedbackInstance;
event: RequestEvent<P>;
}
export function withOwnedInstance<P extends Partial<Record<string, string>> = Partial<Record<string, string>>>(
handler: (ctx: OwnedInstanceContext<P>) => Promise<Response>,
context: string,
): RequestHandler<P> {
return async (event) => {
const authErr = requireAuth(event.locals.userId);
if (authErr) return authErr;
try {
const id = event.params.id;
if (!id) return notFound('Instance not found');
const inst = await getInstanceById(id);
const decision = ownershipDecision(event.locals.userId, inst);
if (!decision.ok) {
return decision.status === 404
? notFound(decision.message)
: unauthorized(decision.message);
}
return await handler({ inst: decision.inst as FeedbackInstance, event });
} catch (e) {
return handleApiError(e, context);
}
};
}

View File

@@ -0,0 +1,154 @@
import { describe, test, expect } from 'bun:test';
import {
generateSlug,
parseFormDefinition,
clampUserAgent,
lookupPlan,
isHoneypotTrap,
} from './feedback-pure';
describe('generateSlug', () => {
test('returns 32 characters', () => {
const s = generateSlug();
expect(s).toHaveLength(32);
});
test('only contains base62 characters', () => {
for (let i = 0; i < 50; i++) {
expect(generateSlug()).toMatch(/^[A-Za-z0-9]{32}$/);
}
});
test('returns distinct values across many calls (collision smoke)', () => {
const seen = new Set<string>();
for (let i = 0; i < 5000; i++) seen.add(generateSlug());
// 5000 random 32-char base62 strings should always be unique.
expect(seen.size).toBe(5000);
});
});
describe('clampUserAgent', () => {
test('null input → null', () => {
expect(clampUserAgent(null)).toBe(null);
});
test('short input passes through', () => {
expect(clampUserAgent('Mozilla/5.0 …')).toBe('Mozilla/5.0 …');
});
test('long input truncated to 500 chars', () => {
const ua = 'X'.repeat(800);
const out = clampUserAgent(ua);
expect(out).not.toBeNull();
expect(out).toHaveLength(500);
});
});
describe('parseFormDefinition', () => {
// Contract guard for the supabase-js .schema('fdbck') JSONB-as-encoded-string
// quirk. If a future fdb adapter starts returning decoded objects (or vice
// versa), these tests fail loud — the participant page renders a string
// instead of {questions: [...]} otherwise.
test('encoded JSON string → decoded object', () => {
const row = {
form_definition: JSON.stringify({ questions: [{ id: 'q1', label: 'Hi', type: 'short_text' }] }),
};
const out = parseFormDefinition(row);
expect(out).toBe(row); // mutates in place
expect(typeof out.form_definition).toBe('object');
expect((out.form_definition as unknown as { questions: unknown[] }).questions).toHaveLength(1);
});
test('already-decoded object → unchanged', () => {
const obj = { questions: [{ id: 'q1', label: 'Hi', type: 'short_text' }] };
const row = { form_definition: obj };
const out = parseFormDefinition(row);
expect(out.form_definition).toBe(obj);
});
test('null form_definition → unchanged', () => {
const row = { form_definition: null };
const out = parseFormDefinition(row);
expect(out.form_definition).toBeNull();
});
test('preserves other row fields', () => {
const row = { form_definition: '{"a":1}', id: 'x', title: 't' };
const out = parseFormDefinition(row);
expect(out.id).toBe('x');
expect(out.title).toBe('t');
});
});
describe('lookupPlan', () => {
test('all keys empty → empty plan', () => {
expect(lookupPlan({})).toEqual([]);
expect(lookupPlan({ sessionId: null, ip: null, ua: null })).toEqual([]);
});
test('valid sessionId only → session-only plan', () => {
expect(lookupPlan({ sessionId: 'abc-123' })).toEqual([
{ kind: 'session', sessionId: 'abc-123' },
]);
});
test('ip+ua only → ip-ua-only plan', () => {
expect(lookupPlan({ ip: '1.2.3.4', ua: 'Mozilla' })).toEqual([
{ kind: 'ip-ua', ip: '1.2.3.4', ua: 'Mozilla' },
]);
});
test('all three → session first, ip-ua fallback', () => {
expect(lookupPlan({ sessionId: 'sid', ip: 'i', ua: 'u' })).toEqual([
{ kind: 'session', sessionId: 'sid' },
{ kind: 'ip-ua', ip: 'i', ua: 'u' },
]);
});
test('ip without ua → not enough for ip-ua fallback', () => {
expect(lookupPlan({ ip: '1.2.3.4' })).toEqual([]);
});
test('ua without ip → not enough for ip-ua fallback', () => {
expect(lookupPlan({ ua: 'Mozilla' })).toEqual([]);
});
test('empty-string sessionId → skipped', () => {
expect(lookupPlan({ sessionId: '', ip: 'i', ua: 'u' })).toEqual([
{ kind: 'ip-ua', ip: 'i', ua: 'u' },
]);
});
test('overlong sessionId (>100 chars) → skipped (defends the PostgREST filter)', () => {
const longId = 'x'.repeat(101);
expect(lookupPlan({ sessionId: longId, ip: 'i', ua: 'u' })).toEqual([
{ kind: 'ip-ua', ip: 'i', ua: 'u' },
]);
});
});
describe('isHoneypotTrap', () => {
// The honeypot field `company` is hidden from real users via .fb-honeypot
// (off-screen + tabindex="-1" + aria-hidden). Spam bots fill every field
// they find. Both /submit and /posts call this and reply with a fake-ok
// response when triggered, so the bot doesn't learn the trap is rigged.
test('missing company → not a trap', () => {
expect(isHoneypotTrap({})).toBe(false);
});
test('empty-string company → not a trap', () => {
expect(isHoneypotTrap({ company: '' })).toBe(false);
});
test('null company → not a trap', () => {
expect(isHoneypotTrap({ company: null })).toBe(false);
});
test('any non-empty company → trap', () => {
expect(isHoneypotTrap({ company: 'Acme Corp' })).toBe(true);
expect(isHoneypotTrap({ company: ' ' })).toBe(true);
expect(isHoneypotTrap({ company: 'a' })).toBe(true);
});
});

View File

@@ -0,0 +1,104 @@
/**
* Pure helpers for the feedback feature — no $env/dynamic/private imports,
* so bun:test can exercise them directly without pulling SvelteKit's virtual
* modules through the supabase.ts → fdb.ts chain.
*
* The DB-aware functions (getInstanceBy*, findExistingSubmission) live in
* ./feedback.ts and import from this module.
*/
import { randomBytes } from 'node:crypto';
const BASE62_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const SLUG_LENGTH = 32;
export const RATE_LIMIT = {
post: { max: 30, windowMs: 5 * 60_000 },
submit: { max: 10, windowMs: 5 * 60_000 },
};
/** 32-char base62 slug — ~190 bits of entropy. */
export function generateSlug(): string {
const buf = randomBytes(SLUG_LENGTH * 2);
let out = '';
for (let i = 0; i < SLUG_LENGTH; i++) {
out += BASE62_ALPHABET[buf[i] % BASE62_ALPHABET.length];
}
return out;
}
export interface FeedbackInstance {
id: string;
slug: string;
title: string;
description: string | null;
owner_user_id: string;
form_definition: unknown | null;
chat_enabled: boolean;
live_results_enabled: boolean;
single_submission: boolean;
status: 'open' | 'closed';
closed_at: string | null;
short_url: string | null;
short_code: string | null;
created_at: string;
updated_at: string;
}
/**
* supabase-js with `.schema('fdbck')` returns JSONB columns as JSON-encoded
* strings instead of decoded objects. Normalise here so callers always see the
* decoded shape. Idempotent: already-decoded objects pass through unchanged.
*/
export function parseFormDefinition<T extends { form_definition: unknown | null }>(row: T): T {
if (typeof row.form_definition === 'string') {
row.form_definition = JSON.parse(row.form_definition);
}
return row;
}
export function clampUserAgent(ua: string | null): string | null {
if (!ua) return null;
return ua.slice(0, 500);
}
/**
* Plan the existing-submission lookup. The same query shape was inlined in
* three places (server load, /api/public/feedback GET, /api/public/feedback
* submit gate) — extracted here so the strategy table is unit-testable.
*
* Strategy order: session-id first (more specific, user-set), then IP+UA as a
* fallback for the cleared-LocalStorage / new-IP case.
*/
export type LookupStrategy =
| { kind: 'session'; sessionId: string }
| { kind: 'ip-ua'; ip: string; ua: string };
export interface LookupKeys {
sessionId?: string | null;
ip?: string | null;
ua?: string | null;
}
export function lookupPlan(by: LookupKeys): LookupStrategy[] {
const plan: LookupStrategy[] = [];
const sid = by.sessionId;
if (typeof sid === 'string' && sid.length > 0 && sid.length <= 100) {
plan.push({ kind: 'session', sessionId: sid });
}
if (by.ip && by.ua) {
plan.push({ kind: 'ip-ua', ip: by.ip, ua: by.ua });
}
return plan;
}
/**
* Honeypot trap test — the participant form + chat-compose include a hidden
* `company` field that real users never see. Anonymous spam bots fill every
* field they find; if `company` is non-empty, drop the request silently with
* a fake-success response.
*
* Used by both /api/public/feedback/[slug]/submit and /posts.
*/
export function isHoneypotTrap(body: { company?: string | null }): boolean {
return typeof body.company === 'string' && body.company.length > 0;
}

View File

@@ -1,58 +1,30 @@
/**
* Feedback feature — DB helpers + slug generator + rate-limit constants.
* Feedback feature — DB-aware helpers. Pure helpers (slug generator,
* rate-limit constants, parseFormDefinition, lookupPlan) live in
* ./feedback-pure.ts and are re-exported here so existing callers don't
* have to know the split.
*
* Public participant routes and m's admin routes share the same primitives.
*/
import { randomBytes } from 'node:crypto';
import { fdb } from './fdb';
import { lookupPlan, parseFormDefinition, type LookupKeys } from './feedback-pure';
const BASE62_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const SLUG_LENGTH = 32;
export {
RATE_LIMIT,
generateSlug,
parseFormDefinition,
clampUserAgent,
lookupPlan,
isHoneypotTrap,
} from './feedback-pure';
export const RATE_LIMIT = {
post: { max: 30, windowMs: 5 * 60_000 },
submit: { max: 10, windowMs: 5 * 60_000 },
};
export type {
FeedbackInstance,
LookupKeys,
LookupStrategy,
} from './feedback-pure';
/** 32-char base62 slug — ~190 bits of entropy. */
export function generateSlug(): string {
const buf = randomBytes(SLUG_LENGTH * 2);
let out = '';
for (let i = 0; i < SLUG_LENGTH; i++) {
out += BASE62_ALPHABET[buf[i] % BASE62_ALPHABET.length];
}
return out;
}
export interface FeedbackInstance {
id: string;
slug: string;
title: string;
description: string | null;
owner_user_id: string;
form_definition: unknown | null;
chat_enabled: boolean;
live_results_enabled: boolean;
single_submission: boolean;
status: 'open' | 'closed';
closed_at: string | null;
short_url: string | null;
short_code: string | null;
created_at: string;
updated_at: string;
}
/**
* supabase-js with `.schema('fdbck')` returns JSONB columns as JSON-encoded
* strings instead of decoded objects. Normalise here so callers always see the
* decoded shape.
*/
export function parseFormDefinition<T extends { form_definition: unknown | null }>(row: T): T {
if (typeof row.form_definition === 'string') {
row.form_definition = JSON.parse(row.form_definition);
}
return row;
}
import type { FeedbackInstance } from './feedback-pure';
export async function getInstanceBySlug(slug: string): Promise<FeedbackInstance | null> {
const { data, error } = await fdb()
@@ -76,7 +48,59 @@ export async function getInstanceById(id: string): Promise<FeedbackInstance | nu
return parseFormDefinition(data as FeedbackInstance);
}
export function clampUserAgent(ua: string | null): string | null {
if (!ua) return null;
return ua.slice(0, 500);
export interface SubmissionLookup {
id: string;
submitted_at: string;
display_name: string | null;
answers: Record<string, unknown>;
}
interface SubmissionRow {
id: string;
answers: Record<string, unknown>;
display_name: string | null;
created_at: string;
}
function rowToLookup(row: SubmissionRow): SubmissionLookup {
return {
id: row.id,
submitted_at: row.created_at,
display_name: row.display_name,
answers: row.answers,
};
}
/**
* Look up an existing submission for a participant. Tries the strategies
* returned by `lookupPlan` in order — session_id first (set by user), then
* IP+UA (catches the cleared-LocalStorage / changed-network case).
*
* Used by:
* - /f/[slug]/+page.server.ts — IP+UA only on first paint
* - /api/public/feedback/[slug] GET — both, for client retry
* - /api/public/feedback/[slug]/submit POST — both, single-submission gate
*/
export async function findExistingSubmission(
instanceId: string,
by: LookupKeys,
): Promise<SubmissionLookup | null> {
const cols = 'id, answers, display_name, created_at';
for (const step of lookupPlan(by)) {
const q = fdb()
.from('feedback_submissions')
.select(cols)
.eq('instance_id', instanceId);
const filtered =
step.kind === 'session'
? q.eq('client_session_id', step.sessionId)
: q.eq('client_ip', step.ip).eq('user_agent', step.ua);
const r = await filtered
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (r.error) throw r.error;
if (r.data) return rowToLookup(r.data as SubmissionRow);
}
return null;
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { aggregateResults, type SubmissionRow } from './results';
import { aggregateResults, publicResults, type SubmissionRow } from './results';
import type { FeedbackFormDefinition } from '../schemas';
const baseForm: FeedbackFormDefinition = {
@@ -104,3 +104,69 @@ describe('aggregateResults — date_ranked_choice', () => {
}
});
});
describe('publicResults', () => {
// Free-text answers may contain PII or contributor identity. The participant
// page can show "live results" after submitting, but text answers must never
// leak through that endpoint. Lock the contract here.
const formWithText: FeedbackFormDefinition = {
questions: [
{ id: 'short', label: 'Short', type: 'short_text' },
{ id: 'long', label: 'Long', type: 'long_text' },
{ id: 'rate', label: 'Rate', type: 'scale', min: 1, max: 5 },
],
};
function textSub(answers: Record<string, unknown>): SubmissionRow {
return { answers, form_snapshot: formWithText, created_at: '2026-05-06T10:00:00Z' };
}
test('strips short_text and long_text answers, keeps counts', () => {
const full = aggregateResults(formWithText, [
textSub({ short: 'secret name', long: 'long secret', rate: 4 }),
textSub({ short: 'another', long: 'more text', rate: 5 }),
]);
// Sanity: full results contain the text values
const fullShort = full.questions.find((q) => q.id === 'short')!.stats;
if (fullShort.type !== 'short_text') throw new Error('narrow');
expect(fullShort.count).toBe(2);
expect(fullShort.answers).toHaveLength(2);
const pub = publicResults(full);
const pubShort = pub.questions.find((q) => q.id === 'short')!.stats;
if (pubShort.type !== 'short_text') throw new Error('narrow');
expect(pubShort.count).toBe(2); // count preserved
expect(pubShort.answers).toEqual([]); // text stripped
const pubLong = pub.questions.find((q) => q.id === 'long')!.stats;
if (pubLong.type !== 'long_text') throw new Error('narrow');
expect(pubLong.count).toBe(2);
expect(pubLong.answers).toEqual([]);
// Non-text question untouched
const pubRate = pub.questions.find((q) => q.id === 'rate')!.stats;
if (pubRate.type !== 'scale') throw new Error('narrow');
expect(pubRate.count).toBe(2);
expect(pubRate.mean).toBe(4.5);
});
test('preserves total_submissions', () => {
const full = aggregateResults(formWithText, [
textSub({ short: 'a' }),
textSub({ short: 'b' }),
textSub({ short: 'c' }),
]);
const pub = publicResults(full);
expect(pub.total_submissions).toBe(3);
});
test('does not mutate the input', () => {
const full = aggregateResults(formWithText, [textSub({ short: 'kept' })]);
const before = JSON.stringify(full);
publicResults(full);
expect(JSON.stringify(full)).toBe(before);
});
});

View File

@@ -3,25 +3,17 @@
* PATCH /api/admin/feedback/<id>
* DELETE /api/admin/feedback/<id>
*/
import type { RequestHandler } from './$types';
import { json, requireAuth } from '$lib/server/response';
import { parseBody, handleApiError, badRequest, notFound, unauthorized } from '$lib/server/errors';
import { json } from '$lib/server/response';
import { parseBody, badRequest } from '$lib/server/errors';
import {
FeedbackInstanceUpdateSchema,
FeedbackFormDefinitionSchema,
type FeedbackFormDefinition,
} from '$lib/schemas';
import { fdb } from '$lib/server/fdb';
import { getInstanceById } from '$lib/server/feedback';
import { withOwnedInstance } from '$lib/server/admin-route';
import { nextVersion, aggregateResults, type SubmissionRow } from '$lib/server/results';
async function ownerOf(id: string, userId: string) {
const inst = await getInstanceById(id);
if (!inst) return { ok: false as const, response: notFound('Instance not found') };
if (inst.owner_user_id !== userId) return { ok: false as const, response: unauthorized('Not your instance') };
return { ok: true as const, inst };
}
/**
* Stamp the incoming form_definition with the right version. Keeps the current
* version while no submissions exist for it (drafts are free); otherwise picks
@@ -64,101 +56,70 @@ async function stampVersion(
return { ...incoming, version };
}
export const GET: RequestHandler = async ({ params, locals }) => {
const err = requireAuth(locals.userId);
if (err) return err;
export const GET = withOwnedInstance(async ({ inst }) => {
const [s, p] = await Promise.all([
fdb().from('feedback_submissions')
.select('id, display_name, client_session_id, answers, form_snapshot, client_ip, user_agent, created_at')
.eq('instance_id', inst.id)
.order('created_at', { ascending: false })
.limit(2000),
fdb().from('feedback_posts')
.select('id, display_name, client_session_id, body, hidden, client_ip, user_agent, created_at')
.eq('instance_id', inst.id)
.order('created_at', { ascending: true })
.limit(2000),
]);
if (s.error) throw s.error;
if (p.error) throw p.error;
try {
const own = await ownerOf(params.id, locals.userId!);
if (!own.ok) return own.response;
const [s, p] = await Promise.all([
fdb().from('feedback_submissions')
.select('id, display_name, client_session_id, answers, form_snapshot, client_ip, user_agent, created_at')
.eq('instance_id', own.inst.id)
.order('created_at', { ascending: false })
.limit(2000),
fdb().from('feedback_posts')
.select('id, display_name, client_session_id, body, hidden, client_ip, user_agent, created_at')
.eq('instance_id', own.inst.id)
.order('created_at', { ascending: true })
.limit(2000),
]);
if (s.error) throw s.error;
if (p.error) throw p.error;
let results = null;
if (own.inst.form_definition) {
const formDef = FeedbackFormDefinitionSchema.parse(own.inst.form_definition);
results = aggregateResults(formDef, (s.data || []) as SubmissionRow[]);
}
return json({ instance: own.inst, submissions: s.data || [], posts: p.data || [], results });
} catch (e) {
return handleApiError(e, 'admin feedback detail GET');
let results = null;
if (inst.form_definition) {
const formDef = FeedbackFormDefinitionSchema.parse(inst.form_definition);
results = aggregateResults(formDef, (s.data || []) as SubmissionRow[]);
}
};
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
const err = requireAuth(locals.userId);
if (err) return err;
return json({ instance: inst, submissions: s.data || [], posts: p.data || [], results });
}, 'admin feedback detail GET');
try {
const own = await ownerOf(params.id, locals.userId!);
if (!own.ok) return own.response;
export const PATCH = withOwnedInstance(async ({ inst, event }) => {
const body = await parseBody(event.request, FeedbackInstanceUpdateSchema);
const body = await parseBody(request, FeedbackInstanceUpdateSchema);
const update: Record<string, unknown> = {};
if (body.title !== undefined) update.title = body.title;
if (body.description !== undefined) update.description = body.description;
if (body.chat_enabled !== undefined) update.chat_enabled = body.chat_enabled;
if (body.live_results_enabled !== undefined) update.live_results_enabled = body.live_results_enabled;
if (body.single_submission !== undefined) update.single_submission = body.single_submission;
if (body.status !== undefined) {
update.status = body.status;
update.closed_at = body.status === 'closed' ? new Date().toISOString() : null;
}
if (body.form_definition !== undefined) {
update.form_definition = body.form_definition === null
? null
: await stampVersion(own.inst.id, own.inst.form_definition, body.form_definition);
}
const merged = {
form_definition: 'form_definition' in update ? update.form_definition : own.inst.form_definition,
chat_enabled: 'chat_enabled' in update ? update.chat_enabled : own.inst.chat_enabled,
};
if (merged.form_definition == null && merged.chat_enabled !== true) {
return badRequest('Either form_definition or chat_enabled must be set');
}
if (Object.keys(update).length === 0) return json({ instance: own.inst });
const { data, error } = await fdb().from('feedback_instances')
.update(update).eq('id', own.inst.id).select().single();
if (error) throw error;
return json({ instance: data });
} catch (e) {
return handleApiError(e, 'admin feedback PATCH');
const update: Record<string, unknown> = {};
if (body.title !== undefined) update.title = body.title;
if (body.description !== undefined) update.description = body.description;
if (body.chat_enabled !== undefined) update.chat_enabled = body.chat_enabled;
if (body.live_results_enabled !== undefined) update.live_results_enabled = body.live_results_enabled;
if (body.single_submission !== undefined) update.single_submission = body.single_submission;
if (body.status !== undefined) {
update.status = body.status;
update.closed_at = body.status === 'closed' ? new Date().toISOString() : null;
}
};
export const DELETE: RequestHandler = async ({ params, locals }) => {
const err = requireAuth(locals.userId);
if (err) return err;
try {
const own = await ownerOf(params.id, locals.userId!);
if (!own.ok) return own.response;
const { error } = await fdb().from('feedback_instances').delete().eq('id', own.inst.id);
if (error) throw error;
return json({ ok: true });
} catch (e) {
return handleApiError(e, 'admin feedback DELETE');
if (body.form_definition !== undefined) {
update.form_definition = body.form_definition === null
? null
: await stampVersion(inst.id, inst.form_definition, body.form_definition);
}
};
const merged = {
form_definition: 'form_definition' in update ? update.form_definition : inst.form_definition,
chat_enabled: 'chat_enabled' in update ? update.chat_enabled : inst.chat_enabled,
};
if (merged.form_definition == null && merged.chat_enabled !== true) {
return badRequest('Either form_definition or chat_enabled must be set');
}
if (Object.keys(update).length === 0) return json({ instance: inst });
const { data, error } = await fdb().from('feedback_instances')
.update(update).eq('id', inst.id).select().single();
if (error) throw error;
return json({ instance: data });
}, 'admin feedback PATCH');
export const DELETE = withOwnedInstance(async ({ inst }) => {
const { error } = await fdb().from('feedback_instances').delete().eq('id', inst.id);
if (error) throw error;
return json({ ok: true });
}, 'admin feedback DELETE');

View File

@@ -1,11 +1,9 @@
/**
* GET /api/admin/feedback/<id>/export?format=csv|json
*/
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/response';
import { handleApiError, badRequest, notFound, unauthorized } from '$lib/server/errors';
import { badRequest } from '$lib/server/errors';
import { fdb } from '$lib/server/fdb';
import { getInstanceById } from '$lib/server/feedback';
import { withOwnedInstance } from '$lib/server/admin-route';
import type { FeedbackFormDefinition } from '$lib/schemas';
interface SubmissionRow {
@@ -44,117 +42,106 @@ function rowsToCsv(headers: string[], rows: (string | unknown)[][]): string {
return lines.join('\n');
}
export const GET: RequestHandler = async ({ params, url, locals }) => {
const err = requireAuth(locals.userId);
if (err) return err;
export const GET = withOwnedInstance(async ({ inst, event }) => {
const format = event.url.searchParams.get('format') ?? 'json';
if (format !== 'csv' && format !== 'json') return badRequest('format must be csv or json');
try {
const inst = await getInstanceById(params.id);
if (!inst) return notFound('Instance not found');
if (inst.owner_user_id !== locals.userId) return unauthorized('Not your instance');
const [s, p] = await Promise.all([
fdb().from('feedback_submissions')
.select('id, display_name, client_session_id, answers, client_ip, user_agent, created_at')
.eq('instance_id', inst.id)
.order('created_at', { ascending: true }),
fdb().from('feedback_posts')
.select('id, display_name, client_session_id, body, hidden, client_ip, user_agent, created_at')
.eq('instance_id', inst.id)
.order('created_at', { ascending: true }),
]);
if (s.error) throw s.error;
if (p.error) throw p.error;
const format = url.searchParams.get('format') ?? 'json';
if (format !== 'csv' && format !== 'json') return badRequest('format must be csv or json');
const submissions = (s.data ?? []) as SubmissionRow[];
const posts = (p.data ?? []) as PostRow[];
const [s, p] = await Promise.all([
fdb().from('feedback_submissions')
.select('id, display_name, client_session_id, answers, client_ip, user_agent, created_at')
.eq('instance_id', inst.id)
.order('created_at', { ascending: true }),
fdb().from('feedback_posts')
.select('id, display_name, client_session_id, body, hidden, client_ip, user_agent, created_at')
.eq('instance_id', inst.id)
.order('created_at', { ascending: true }),
]);
if (s.error) throw s.error;
if (p.error) throw p.error;
const baseName = `feedback-${inst.slug.slice(0, 8)}-${new Date().toISOString().slice(0, 10)}`;
const submissions = (s.data ?? []) as SubmissionRow[];
const posts = (p.data ?? []) as PostRow[];
const baseName = `feedback-${inst.slug.slice(0, 8)}-${new Date().toISOString().slice(0, 10)}`;
if (format === 'json') {
const payload = {
instance: {
id: inst.id,
slug: inst.slug,
title: inst.title,
description: inst.description,
form_definition: inst.form_definition,
chat_enabled: inst.chat_enabled,
status: inst.status,
closed_at: inst.closed_at,
created_at: inst.created_at,
},
submissions,
posts,
exported_at: new Date().toISOString(),
};
return new Response(JSON.stringify(payload, null, 2), {
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Content-Disposition': `attachment; filename="${baseName}.json"`,
},
});
}
const formDef = inst.form_definition as FeedbackFormDefinition | null;
const questions = formDef?.questions ?? [];
// Expand `date_ranked_choice` questions into one column per option (e.g. `kickoff_when[opt1]`).
// Other types stay as a single column keyed by question id.
const colSpecs: { qid: string; optId?: string; header: string }[] = [];
for (const q of questions) {
if (q.type === 'date_ranked_choice') {
for (const opt of q.options) {
colSpecs.push({ qid: q.id, optId: opt.id, header: `${q.id}[${opt.id}]` });
}
} else {
colSpecs.push({ qid: q.id, header: q.id });
}
}
const subHeaders = ['id', 'created_at', 'display_name', 'client_session_id', ...colSpecs.map((c) => c.header)];
const subRows = submissions.map((row) => [
row.id, row.created_at, row.display_name, row.client_session_id,
...colSpecs.map((c) => {
const v = row.answers?.[c.qid];
if (c.optId) {
if (!v || typeof v !== 'object' || Array.isArray(v)) return '';
const r = (v as Record<string, unknown>)[c.optId];
return r === null || r === undefined ? '' : r;
}
return v ?? '';
}),
]);
const submissionsCsv = rowsToCsv(subHeaders, subRows);
const postHeaders = ['id', 'created_at', 'display_name', 'client_session_id', 'hidden', 'body'];
const postRows = posts.map((row) => [
row.id, row.created_at, row.display_name, row.client_session_id, row.hidden, row.body,
]);
const postsCsv = rowsToCsv(postHeaders, postRows);
const body = [
`# instance: ${inst.title} (${inst.slug})`,
`# exported_at: ${new Date().toISOString()}`,
'',
'# === SUBMISSIONS ===',
submissionsCsv,
'',
'# === POSTS ===',
postsCsv,
'',
].join('\n');
return new Response(body, {
if (format === 'json') {
const payload = {
instance: {
id: inst.id,
slug: inst.slug,
title: inst.title,
description: inst.description,
form_definition: inst.form_definition,
chat_enabled: inst.chat_enabled,
status: inst.status,
closed_at: inst.closed_at,
created_at: inst.created_at,
},
submissions,
posts,
exported_at: new Date().toISOString(),
};
return new Response(JSON.stringify(payload, null, 2), {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="${baseName}.csv"`,
'Content-Type': 'application/json; charset=utf-8',
'Content-Disposition': `attachment; filename="${baseName}.json"`,
},
});
} catch (e) {
return handleApiError(e, 'admin feedback export');
}
};
const formDef = inst.form_definition as FeedbackFormDefinition | null;
const questions = formDef?.questions ?? [];
// Expand `date_ranked_choice` questions into one column per option (e.g. `kickoff_when[opt1]`).
// Other types stay as a single column keyed by question id.
const colSpecs: { qid: string; optId?: string; header: string }[] = [];
for (const q of questions) {
if (q.type === 'date_ranked_choice') {
for (const opt of q.options) {
colSpecs.push({ qid: q.id, optId: opt.id, header: `${q.id}[${opt.id}]` });
}
} else {
colSpecs.push({ qid: q.id, header: q.id });
}
}
const subHeaders = ['id', 'created_at', 'display_name', 'client_session_id', ...colSpecs.map((c) => c.header)];
const subRows = submissions.map((row) => [
row.id, row.created_at, row.display_name, row.client_session_id,
...colSpecs.map((c) => {
const v = row.answers?.[c.qid];
if (c.optId) {
if (!v || typeof v !== 'object' || Array.isArray(v)) return '';
const r = (v as Record<string, unknown>)[c.optId];
return r === null || r === undefined ? '' : r;
}
return v ?? '';
}),
]);
const submissionsCsv = rowsToCsv(subHeaders, subRows);
const postHeaders = ['id', 'created_at', 'display_name', 'client_session_id', 'hidden', 'body'];
const postRows = posts.map((row) => [
row.id, row.created_at, row.display_name, row.client_session_id, row.hidden, row.body,
]);
const postsCsv = rowsToCsv(postHeaders, postRows);
const body = [
`# instance: ${inst.title} (${inst.slug})`,
`# exported_at: ${new Date().toISOString()}`,
'',
'# === SUBMISSIONS ===',
submissionsCsv,
'',
'# === POSTS ===',
postsCsv,
'',
].join('\n');
return new Response(body, {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="${baseName}.csv"`,
},
});
}, 'admin feedback export');

View File

@@ -2,35 +2,23 @@
* POST /api/admin/feedback/<id>/posts/<post_id>/hide
* Body: { hidden: boolean }
*/
import type { RequestHandler } from './$types';
import { json, requireAuth } from '$lib/server/response';
import { parseBody, handleApiError, notFound, unauthorized } from '$lib/server/errors';
import { json } from '$lib/server/response';
import { parseBody, notFound } from '$lib/server/errors';
import { FeedbackPostHideSchema } from '$lib/schemas';
import { fdb } from '$lib/server/fdb';
import { getInstanceById } from '$lib/server/feedback';
import { withOwnedInstance } from '$lib/server/admin-route';
export const POST: RequestHandler = async ({ params, request, locals }) => {
const err = requireAuth(locals.userId);
if (err) return err;
export const POST = withOwnedInstance(async ({ inst, event }) => {
const body = await parseBody(event.request, FeedbackPostHideSchema);
try {
const inst = await getInstanceById(params.id);
if (!inst) return notFound('Instance not found');
if (inst.owner_user_id !== locals.userId) return unauthorized('Not your instance');
const { data, error } = await fdb().from('feedback_posts')
.update({ hidden: body.hidden })
.eq('id', event.params.post_id)
.eq('instance_id', inst.id)
.select()
.single();
if (error) throw error;
if (!data) return notFound('Post not found');
const body = await parseBody(request, FeedbackPostHideSchema);
const { data, error } = await fdb().from('feedback_posts')
.update({ hidden: body.hidden })
.eq('id', params.post_id)
.eq('instance_id', inst.id)
.select()
.single();
if (error) throw error;
if (!data) return notFound('Post not found');
return json({ post: data });
} catch (e) {
return handleApiError(e, 'admin feedback post hide');
}
};
return json({ post: data });
}, 'admin feedback post hide');

View File

@@ -5,50 +5,38 @@
* Body: { customSlug?: string, maxVisits?: number }
* Returns: { shortUrl, shortCode, customSlug, instance }
*/
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/private';
import { json, requireAuth } from '$lib/server/response';
import { parseBody, handleApiError, notFound, unauthorized } from '$lib/server/errors';
import { json } from '$lib/server/response';
import { parseBody } from '$lib/server/errors';
import { ShareCreateSchema } from '$lib/schemas';
import { fdb } from '$lib/server/fdb';
import { getInstanceById } from '$lib/server/feedback';
import { withOwnedInstance } from '$lib/server/admin-route';
import { createShortUrl } from '$lib/server/shlink';
export const POST: RequestHandler = async ({ params, request, locals }) => {
const err = requireAuth(locals.userId);
if (err) return err;
export const POST = withOwnedInstance(async ({ inst, event }) => {
const body = await parseBody(event.request, ShareCreateSchema);
try {
const inst = await getInstanceById(params.id);
if (!inst) return notFound('Instance not found');
if (inst.owner_user_id !== locals.userId) return unauthorized('Not your instance');
const siteUrl = (env.PUBLIC_SITE_URL || 'https://fdbck.msbls.de').replace(/\/+$/, '');
const longUrl = `${siteUrl}/f/${inst.slug}`;
const body = await parseBody(request, ShareCreateSchema);
const result = await createShortUrl({
longUrl,
customSlug: body.customSlug,
maxVisits: body.maxVisits,
});
const siteUrl = (env.PUBLIC_SITE_URL || 'https://fdbck.msbls.de').replace(/\/+$/, '');
const longUrl = `${siteUrl}/f/${inst.slug}`;
const { data: updated, error } = await fdb()
.from('feedback_instances')
.update({ short_url: result.shortUrl, short_code: result.shortCode })
.eq('id', inst.id)
.select()
.single();
if (error) throw error;
const result = await createShortUrl({
longUrl,
customSlug: body.customSlug,
maxVisits: body.maxVisits,
});
const { data: updated, error } = await fdb()
.from('feedback_instances')
.update({ short_url: result.shortUrl, short_code: result.shortCode })
.eq('id', inst.id)
.select()
.single();
if (error) throw error;
return json({
shortUrl: result.shortUrl,
shortCode: result.shortCode,
customSlug: result.customSlug ?? body.customSlug ?? null,
instance: updated,
});
} catch (e) {
return handleApiError(e, 'admin feedback share POST');
}
};
return json({
shortUrl: result.shortUrl,
shortCode: result.shortCode,
customSlug: result.customSlug ?? body.customSlug ?? null,
instance: updated,
});
}, 'admin feedback share POST');

View File

@@ -9,66 +9,24 @@
import type { RequestHandler } from './$types';
import { json } from '$lib/server/response';
import { handleApiError, notFound } from '$lib/server/errors';
import { getInstanceBySlug, clampUserAgent } from '$lib/server/feedback';
import { fdb } from '$lib/server/fdb';
import {
getInstanceBySlug,
clampUserAgent,
findExistingSubmission,
} from '$lib/server/feedback';
export const GET: RequestHandler = async ({ params, url, request, getClientAddress }) => {
try {
const inst = await getInstanceBySlug(params.slug);
if (!inst) return notFound('Feedback instance not found');
let previousSubmission:
| { submitted_at: string; display_name: string | null; answers: unknown }
| null = null;
if (inst.single_submission) {
const cols = 'answers, display_name, created_at';
const sessionId = url.searchParams.get('session_id');
if (sessionId && sessionId.length > 0 && sessionId.length <= 100) {
const r = await fdb()
.from('feedback_submissions')
.select(cols)
.eq('instance_id', inst.id)
.eq('client_session_id', sessionId)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (r.error) throw r.error;
if (r.data) {
previousSubmission = {
submitted_at: r.data.created_at,
display_name: r.data.display_name,
answers: r.data.answers,
};
}
}
// Back-stop: same IP + UA. Catches the "cleared LocalStorage but same browser" case.
if (!previousSubmission) {
const ip = getClientAddress();
const ua = clampUserAgent(request.headers.get('user-agent'));
if (ua) {
const r = await fdb()
.from('feedback_submissions')
.select(cols)
.eq('instance_id', inst.id)
.eq('client_ip', ip)
.eq('user_agent', ua)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (r.error) throw r.error;
if (r.data) {
previousSubmission = {
submitted_at: r.data.created_at,
display_name: r.data.display_name,
answers: r.data.answers,
};
}
}
}
}
const previousSubmission = inst.single_submission
? await findExistingSubmission(inst.id, {
sessionId: url.searchParams.get('session_id'),
ip: getClientAddress(),
ua: clampUserAgent(request.headers.get('user-agent')),
})
: null;
return json({
title: inst.title,

View File

@@ -6,7 +6,7 @@ import type { RequestHandler } from './$types';
import { json } from '$lib/server/response';
import { parseBody, handleApiError, badRequest, notFound } from '$lib/server/errors';
import { FeedbackPostSchema } from '$lib/schemas';
import { getInstanceBySlug, RATE_LIMIT, clampUserAgent } from '$lib/server/feedback';
import { getInstanceBySlug, RATE_LIMIT, clampUserAgent, isHoneypotTrap } from '$lib/server/feedback';
import { checkRate } from '$lib/server/rate-limit';
import { fdb } from '$lib/server/fdb';
@@ -69,7 +69,7 @@ export const POST: RequestHandler = async ({ params, request, getClientAddress }
const body = await parseBody(request, FeedbackPostSchema);
if (body.company && body.company.length > 0) return json({ ok: true });
if (isHoneypotTrap(body)) return json({ ok: true });
const ip = getClientAddress();
const key = `fb:post:${ip}:${params.slug}`;

View File

@@ -5,7 +5,13 @@ import type { RequestHandler } from './$types';
import { json } from '$lib/server/response';
import { parseBody, handleApiError, badRequest, notFound } from '$lib/server/errors';
import { FeedbackSubmissionSchema, FeedbackFormDefinitionSchema } from '$lib/schemas';
import { getInstanceBySlug, RATE_LIMIT, clampUserAgent } from '$lib/server/feedback';
import {
getInstanceBySlug,
RATE_LIMIT,
clampUserAgent,
findExistingSubmission,
isHoneypotTrap,
} from '$lib/server/feedback';
import { checkRate } from '$lib/server/rate-limit';
import { fdb } from '$lib/server/fdb';
@@ -18,7 +24,7 @@ export const POST: RequestHandler = async ({ params, request, getClientAddress }
const body = await parseBody(request, FeedbackSubmissionSchema);
if (body.company && body.company.length > 0) return json({ ok: true });
if (isHoneypotTrap(body)) return json({ ok: true });
const ip = getClientAddress();
const key = `fb:submit:${ip}:${params.slug}`;
@@ -48,42 +54,20 @@ export const POST: RequestHandler = async ({ params, request, getClientAddress }
const ua = clampUserAgent(request.headers.get('user-agent'));
// Single-submission gate. Default-on per migration. Match either the LocalStorage-backed
// session id or (client_ip + user_agent) — clearing storage alone shouldn't bypass it.
// Two parameterised queries (vs. a single `.or()`) so user-controlled session_id can't
// inject into the PostgREST filter string.
// Single-submission gate. Default-on per migration. findExistingSubmission tries the
// LocalStorage-backed session id first (more specific) and falls back to client_ip +
// user_agent so clearing storage alone shouldn't bypass the gate.
if (inst.single_submission) {
const cols = 'id, answers, display_name, created_at';
const bySession = await fdb()
.from('feedback_submissions')
.select(cols)
.eq('instance_id', inst.id)
.eq('client_session_id', body.client_session_id)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (bySession.error) throw bySession.error;
let existing = bySession.data;
if (!existing && ua) {
const byIpUa = await fdb()
.from('feedback_submissions')
.select(cols)
.eq('instance_id', inst.id)
.eq('client_ip', ip)
.eq('user_agent', ua)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (byIpUa.error) throw byIpUa.error;
existing = byIpUa.data;
}
const existing = await findExistingSubmission(inst.id, {
sessionId: body.client_session_id,
ip,
ua,
});
if (existing) {
return new Response(
JSON.stringify({
error: 'already_submitted',
submitted_at: existing.created_at,
submitted_at: existing.submitted_at,
display_name: existing.display_name,
answers: existing.answers,
}),

View File

@@ -1,7 +1,6 @@
import type { PageServerLoad } from './$types';
import { error } from '@sveltejs/kit';
import { getInstanceBySlug, clampUserAgent } from '$lib/server/feedback';
import { fdb } from '$lib/server/fdb';
import { getInstanceBySlug, clampUserAgent, findExistingSubmission } from '$lib/server/feedback';
export const load: PageServerLoad = async ({ params, request, getClientAddress }) => {
const inst = await getInstanceBySlug(params.slug);
@@ -10,32 +9,12 @@ export const load: PageServerLoad = async ({ params, request, getClientAddress }
// Server-side IP + UA fallback so first paint after a reload shows the read-only summary
// without an extra client round-trip. The session_id-based lookup still happens on the
// client (it lives in LocalStorage and isn't sent as a cookie).
let previousSubmission:
| { submitted_at: string; display_name: string | null; answers: unknown }
| null = null;
if (inst.single_submission) {
const ua = clampUserAgent(request.headers.get('user-agent'));
if (ua) {
const ip = getClientAddress();
const r = await fdb()
.from('feedback_submissions')
.select('answers, display_name, created_at')
.eq('instance_id', inst.id)
.eq('client_ip', ip)
.eq('user_agent', ua)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (!r.error && r.data) {
previousSubmission = {
submitted_at: r.data.created_at,
display_name: r.data.display_name,
answers: r.data.answers,
};
}
}
}
const previousSubmission = inst.single_submission
? await findExistingSubmission(inst.id, {
ip: getClientAddress(),
ua: clampUserAgent(request.headers.get('user-agent')),
})
: null;
return {
slug: inst.slug,