feedback API endpoints (port from flexsiebels, fdb() schema rename)

Public (slug-gated, auto-allowlisted):
- GET  /api/public/feedback/[slug]              — instance config
- POST /api/public/feedback/[slug]/submit       — form submission (honeypot, rate-limit, required-validation, 423 if closed)
- GET  /api/public/feedback/[slug]/posts        — chat polling (?since=, hides body of moderated posts)
- POST /api/public/feedback/[slug]/posts        — new chat post (honeypot, rate-limit, 423 if closed)

Admin (requireAuth, owner-scoped):
- GET/POST   /api/admin/feedback                — list/create
- GET/PATCH/DELETE /api/admin/feedback/[id]    — detail/update/delete (PATCH closes/reopens, sets closed_at)
- POST       /api/admin/feedback/[id]/posts/[post_id]/hide — toggle hidden flag
- GET        /api/admin/feedback/[id]/export?format=csv|json — single-file dump

Auth:
- POST /api/auth/sign-in   — Supabase email+password, sets access+refresh cookies
- POST /api/auth/sign-out  — clears cookies

bun run check: 0 errors, 0 warnings.
This commit is contained in:
mAi
2026-05-05 11:34:54 +02:00
parent f5992ebc5b
commit 946c755f17
9 changed files with 601 additions and 0 deletions

View File

@@ -0,0 +1,86 @@
/**
* GET /api/admin/feedback — list m's feedback instances + counts.
* POST /api/admin/feedback — create a new instance with random slug.
*/
import type { RequestHandler } from './$types';
import { json, requireAuth } from '$lib/server/response';
import { parseBody, handleApiError } from '$lib/server/errors';
import { FeedbackInstanceCreateSchema } from '$lib/server/schemas';
import { fdb } from '$lib/server/fdb';
import { generateSlug } from '$lib/server/feedback';
interface InstanceRow {
id: string;
slug: string;
title: string;
description: string | null;
form_definition: unknown | null;
chat_enabled: boolean;
status: 'open' | 'closed';
closed_at: string | null;
created_at: string;
updated_at: string;
}
export const GET: RequestHandler = async ({ locals }) => {
const err = requireAuth(locals.userId);
if (err) return err;
try {
const { data: instances, error } = await fdb()
.from('feedback_instances')
.select('id, slug, title, description, form_definition, chat_enabled, status, closed_at, created_at, updated_at')
.eq('owner_user_id', locals.userId!)
.order('created_at', { ascending: false })
.limit(500);
if (error) throw error;
const ids = (instances || []).map((i: InstanceRow) => i.id);
const counts: Record<string, { submissions: number; posts: number }> = {};
for (const id of ids) counts[id] = { submissions: 0, posts: 0 };
if (ids.length > 0) {
const [s, p] = await Promise.all([
fdb().from('feedback_submissions').select('instance_id').in('instance_id', ids),
fdb().from('feedback_posts').select('instance_id').in('instance_id', ids),
]);
if (s.error) throw s.error;
if (p.error) throw p.error;
for (const row of s.data || []) counts[(row as { instance_id: string }).instance_id].submissions += 1;
for (const row of p.data || []) counts[(row as { instance_id: string }).instance_id].posts += 1;
}
return json({
instances: (instances || []).map((i: InstanceRow) => ({
...i,
counts: counts[i.id],
})),
});
} catch (e) {
return handleApiError(e, 'admin feedback GET');
}
};
export const POST: RequestHandler = async ({ request, locals }) => {
const err = requireAuth(locals.userId);
if (err) return err;
try {
const body = await parseBody(request, FeedbackInstanceCreateSchema);
const slug = generateSlug();
const { data, error } = await fdb().from('feedback_instances').insert({
slug,
title: body.title,
description: body.description ?? null,
owner_user_id: locals.userId!,
form_definition: body.form_definition ?? null,
chat_enabled: body.chat_enabled === true,
}).select().single();
if (error) throw error;
return json({ instance: data });
} catch (e) {
return handleApiError(e, 'admin feedback POST');
}
};

View File

@@ -0,0 +1,104 @@
/**
* GET /api/admin/feedback/<id>
* 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 { FeedbackInstanceUpdateSchema } from '$lib/server/schemas';
import { fdb } from '$lib/server/fdb';
import { getInstanceById } from '$lib/server/feedback';
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 };
}
export const GET: 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 [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', 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;
return json({ instance: own.inst, submissions: s.data || [], posts: p.data || [] });
} catch (e) {
return handleApiError(e, 'admin feedback detail GET');
}
};
export const PATCH: RequestHandler = async ({ params, request, 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 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.form_definition !== undefined) update.form_definition = body.form_definition;
if (body.chat_enabled !== undefined) update.chat_enabled = body.chat_enabled;
if (body.status !== undefined) {
update.status = body.status;
update.closed_at = body.status === 'closed' ? new Date().toISOString() : null;
}
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');
}
};
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');
}
};

View File

@@ -0,0 +1,139 @@
/**
* 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 { fdb } from '$lib/server/fdb';
import { getInstanceById } from '$lib/server/feedback';
import type { FeedbackFormDefinition } from '$lib/server/schemas';
interface SubmissionRow {
id: string;
display_name: string | null;
client_session_id: string;
answers: Record<string, unknown>;
client_ip: string | null;
user_agent: string | null;
created_at: string;
}
interface PostRow {
id: string;
display_name: string | null;
client_session_id: string;
body: string;
hidden: boolean;
client_ip: string | null;
user_agent: string | null;
created_at: string;
}
function csvEscape(v: unknown): string {
if (v === null || v === undefined) return '';
const s = typeof v === 'string' ? v : Array.isArray(v) ? v.join('|') : JSON.stringify(v);
if (s.includes('"') || s.includes(',') || s.includes('\n') || s.includes('\r')) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
}
function rowsToCsv(headers: string[], rows: (string | unknown)[][]): string {
const lines = [headers.map(csvEscape).join(',')];
for (const r of rows) lines.push(r.map(csvEscape).join(','));
return lines.join('\n');
}
export const GET: RequestHandler = async ({ params, url, locals }) => {
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');
const format = url.searchParams.get('format') ?? 'json';
if (format !== 'csv' && format !== 'json') return badRequest('format must be csv or json');
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 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 ?? [];
const subHeaders = ['id', 'created_at', 'display_name', 'client_session_id', ...questions.map((q) => q.id)];
const subRows = submissions.map((row) => [
row.id, row.created_at, row.display_name, row.client_session_id,
...questions.map((q) => row.answers?.[q.id] ?? ''),
]);
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"`,
},
});
} catch (e) {
return handleApiError(e, 'admin feedback export');
}
};

View File

@@ -0,0 +1,36 @@
/**
* 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 { FeedbackPostHideSchema } from '$lib/server/schemas';
import { fdb } from '$lib/server/fdb';
import { getInstanceById } from '$lib/server/feedback';
export const POST: RequestHandler = async ({ params, request, locals }) => {
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');
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');
}
};

View File

@@ -0,0 +1,40 @@
/**
* POST /api/auth/sign-in
* Body: { email, password }
*
* Validates with Supabase, sets access + refresh cookies on success.
*/
import type { RequestHandler } from './$types';
import { json } from '$lib/server/response';
import { parseBody, handleApiError, unauthorized } from '$lib/server/errors';
import { SignInSchema } from '$lib/server/schemas';
import { getSupabaseAnon } from '$lib/server/supabase';
import { ACCESS_TOKEN_COOKIE, REFRESH_TOKEN_COOKIE, COOKIE_OPTS, ACCESS_MAX_AGE, REFRESH_MAX_AGE } from '$lib/server/auth';
export const POST: RequestHandler = async ({ request, cookies }) => {
try {
const body = await parseBody(request, SignInSchema);
const { data, error } = await getSupabaseAnon().auth.signInWithPassword({
email: body.email,
password: body.password,
});
if (error || !data.session) {
return unauthorized(error?.message ?? 'Invalid credentials');
}
cookies.set(ACCESS_TOKEN_COOKIE, data.session.access_token, {
...COOKIE_OPTS,
maxAge: ACCESS_MAX_AGE,
});
cookies.set(REFRESH_TOKEN_COOKIE, data.session.refresh_token, {
...COOKIE_OPTS,
maxAge: REFRESH_MAX_AGE,
});
return json({ ok: true, user: { id: data.user.id, email: data.user.email } });
} catch (e) {
return handleApiError(e, 'auth sign-in');
}
};

View File

@@ -0,0 +1,9 @@
import type { RequestHandler } from './$types';
import { json } from '$lib/server/response';
import { ACCESS_TOKEN_COOKIE, REFRESH_TOKEN_COOKIE } from '$lib/server/auth';
export const POST: RequestHandler = async ({ cookies }) => {
cookies.delete(ACCESS_TOKEN_COOKIE, { path: '/' });
cookies.delete(REFRESH_TOKEN_COOKIE, { path: '/' });
return json({ ok: true });
};

View File

@@ -0,0 +1,27 @@
/**
* GET /api/public/feedback/<slug>
*
* Returns the public-facing instance config. No auth — slug is the access token.
*/
import type { RequestHandler } from './$types';
import { json } from '$lib/server/response';
import { handleApiError, notFound } from '$lib/server/errors';
import { getInstanceBySlug } from '$lib/server/feedback';
export const GET: RequestHandler = async ({ params }) => {
try {
const inst = await getInstanceBySlug(params.slug);
if (!inst) return notFound('Feedback instance not found');
return json({
title: inst.title,
description: inst.description,
form_definition: inst.form_definition,
chat_enabled: inst.chat_enabled,
status: inst.status,
closed_at: inst.closed_at,
});
} catch (e) {
return handleApiError(e, 'feedback GET');
}
};

View File

@@ -0,0 +1,97 @@
/**
* GET /api/public/feedback/<slug>/posts?since=<iso>
* POST /api/public/feedback/<slug>/posts
*/
import type { RequestHandler } from './$types';
import { json } from '$lib/server/response';
import { parseBody, handleApiError, badRequest, notFound } from '$lib/server/errors';
import { FeedbackPostSchema } from '$lib/server/schemas';
import { getInstanceBySlug, RATE_LIMIT, clampUserAgent } from '$lib/server/feedback';
import { checkRate } from '$lib/server/rate-limit';
import { fdb } from '$lib/server/fdb';
interface PostRow {
id: string;
display_name: string | null;
client_session_id: string;
body: string | null;
hidden: boolean;
created_at: string;
}
const MAX_POSTS_PER_REQUEST = 500;
export const GET: RequestHandler = async ({ params, url }) => {
try {
const inst = await getInstanceBySlug(params.slug);
if (!inst) return notFound('Feedback instance not found');
if (!inst.chat_enabled) return badRequest('This feedback instance has no chat');
const since = url.searchParams.get('since');
let q = fdb()
.from('feedback_posts')
.select('id, display_name, client_session_id, body, hidden, created_at')
.eq('instance_id', inst.id)
.order('created_at', { ascending: true })
.limit(MAX_POSTS_PER_REQUEST);
if (since) {
const sinceDate = new Date(since);
if (Number.isNaN(sinceDate.getTime())) return badRequest('Invalid `since` parameter');
q = q.gt('created_at', sinceDate.toISOString());
}
const { data, error } = await q;
if (error) throw error;
const posts = (data || []).map((row: PostRow) => ({
id: row.id,
display_name: row.display_name,
client_session_id: row.client_session_id,
body: row.hidden ? null : row.body,
hidden: row.hidden,
created_at: row.created_at,
}));
return json({ posts, status: inst.status });
} catch (e) {
return handleApiError(e, 'feedback posts GET');
}
};
export const POST: RequestHandler = async ({ params, request, getClientAddress }) => {
try {
const inst = await getInstanceBySlug(params.slug);
if (!inst) return notFound('Feedback instance not found');
if (!inst.chat_enabled) return badRequest('This feedback instance has no chat');
if (inst.status === 'closed') return new Response('Locked', { status: 423 });
const body = await parseBody(request, FeedbackPostSchema);
if (body.company && body.company.length > 0) return json({ ok: true });
const ip = getClientAddress();
const key = `fb:post:${ip}:${params.slug}`;
if (!checkRate(key, RATE_LIMIT.post)) {
return new Response(JSON.stringify({ error: 'Too many posts' }), {
status: 429,
headers: { 'Content-Type': 'application/json' },
});
}
const { data, error } = await fdb().from('feedback_posts').insert({
instance_id: inst.id,
display_name: body.display_name,
client_session_id: body.client_session_id,
body: body.body,
client_ip: ip,
user_agent: clampUserAgent(request.headers.get('user-agent')),
}).select('id, display_name, client_session_id, body, hidden, created_at').single();
if (error) throw error;
return json({ post: data });
} catch (e) {
return handleApiError(e, 'feedback posts POST');
}
};

View File

@@ -0,0 +1,63 @@
/**
* POST /api/public/feedback/<slug>/submit
*/
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/server/schemas';
import { getInstanceBySlug, RATE_LIMIT, clampUserAgent } from '$lib/server/feedback';
import { checkRate } from '$lib/server/rate-limit';
import { fdb } from '$lib/server/fdb';
export const POST: RequestHandler = async ({ params, request, getClientAddress }) => {
try {
const inst = await getInstanceBySlug(params.slug);
if (!inst) return notFound('Feedback instance not found');
if (!inst.form_definition) return badRequest('This feedback instance has no form');
if (inst.status === 'closed') return new Response('Locked', { status: 423 });
const body = await parseBody(request, FeedbackSubmissionSchema);
if (body.company && body.company.length > 0) return json({ ok: true });
const ip = getClientAddress();
const key = `fb:submit:${ip}:${params.slug}`;
if (!checkRate(key, RATE_LIMIT.submit)) {
return new Response(JSON.stringify({ error: 'Too many submissions' }), {
status: 429,
headers: { 'Content-Type': 'application/json' },
});
}
const formDef = FeedbackFormDefinitionSchema.parse(inst.form_definition);
const knownIds = new Set(formDef.questions.map((q) => q.id));
for (const id of Object.keys(body.answers)) {
if (!knownIds.has(id)) return badRequest(`Unknown question id: ${id}`);
}
for (const q of formDef.questions) {
if (q.required) {
const v = body.answers[q.id];
const empty =
v === undefined ||
v === null ||
(typeof v === 'string' && v.trim() === '') ||
(Array.isArray(v) && v.length === 0);
if (empty) return badRequest(`Missing answer for required question: ${q.id}`);
}
}
const { error } = await fdb().from('feedback_submissions').insert({
instance_id: inst.id,
display_name: body.display_name,
client_session_id: body.client_session_id,
answers: body.answers,
client_ip: ip,
user_agent: clampUserAgent(request.headers.get('user-agent')),
});
if (error) throw error;
return json({ ok: true });
} catch (e) {
return handleApiError(e, 'feedback submit');
}
};