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:
mAi
2026-05-07 19:44:44 +02:00
parent bc278a87d6
commit 8b0f453022
8 changed files with 345 additions and 277 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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