Merge mai/cronus/arch-phase1: withOwnedInstance + findExistingSubmission + tests
This commit is contained in:
@@ -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",
|
||||
|
||||
30
src/lib/server/admin-route-decision.ts
Normal file
30
src/lib/server/admin-route-decision.ts
Normal 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 };
|
||||
}
|
||||
52
src/lib/server/admin-route.test.ts
Normal file
52
src/lib/server/admin-route.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
62
src/lib/server/admin-route.ts
Normal file
62
src/lib/server/admin-route.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
154
src/lib/server/feedback-pure.test.ts
Normal file
154
src/lib/server/feedback-pure.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
104
src/lib/server/feedback-pure.ts
Normal file
104
src/lib/server/feedback-pure.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user