refactor(server): withOwnedInstance wrapper for admin /feedback/[id] endpoints
§3.C of docs/plans/architecture-improvements.md.
Lifts the auth + ownership + try/catch preamble that was inlined across
four admin endpoints into a single wrapper. Each endpoint now:
export const POST = withOwnedInstance(async ({ inst, event }) => {
// inst is guaranteed valid + owned, errors caught + tagged
}, 'admin feedback X');
Files:
- New lib/server/admin-route.ts — runtime wiring (requireAuth, getInstanceById,
handleApiError, Response helpers).
- New lib/server/admin-route-decision.ts — pure ownership decision branch.
Lives in its own module so bun:test can exercise it without pulling in
$env/dynamic/private through the feedback.ts → supabase.ts chain (same
constraint as the existing rate-limit.test.ts comment).
- New lib/server/admin-route.test.ts — 4-row decision-table test
(anonymous → 401, missing instance → 404, foreign owner → 401, owner → ok).
Endpoints rewired (auth+ownership boilerplate removed):
- /api/admin/feedback/[id]/+server.ts (GET / PATCH / DELETE — local `ownerOf`
helper deleted, was only used here)
- /api/admin/feedback/[id]/posts/[post_id]/hide/+server.ts
- /api/admin/feedback/[id]/share/+server.ts
- /api/admin/feedback/[id]/export/+server.ts
The list endpoint /api/admin/feedback/+server.ts has the auth half but no
ownership half (it lists by owner_user_id = userId), so it stays unchanged.
Behaviour unchanged. 29 tests pass. svelte-check + bun run build clean.
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"
|
||||
},
|
||||
"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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user