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:
86
src/routes/api/admin/feedback/+server.ts
Normal file
86
src/routes/api/admin/feedback/+server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
104
src/routes/api/admin/feedback/[id]/+server.ts
Normal file
104
src/routes/api/admin/feedback/[id]/+server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
139
src/routes/api/admin/feedback/[id]/export/+server.ts
Normal file
139
src/routes/api/admin/feedback/[id]/export/+server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
40
src/routes/api/auth/sign-in/+server.ts
Normal file
40
src/routes/api/auth/sign-in/+server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
9
src/routes/api/auth/sign-out/+server.ts
Normal file
9
src/routes/api/auth/sign-out/+server.ts
Normal 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 });
|
||||
};
|
||||
27
src/routes/api/public/feedback/[slug]/+server.ts
Normal file
27
src/routes/api/public/feedback/[slug]/+server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
97
src/routes/api/public/feedback/[slug]/posts/+server.ts
Normal file
97
src/routes/api/public/feedback/[slug]/posts/+server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
63
src/routes/api/public/feedback/[slug]/submit/+server.ts
Normal file
63
src/routes/api/public/feedback/[slug]/submit/+server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user