From c5d0b2ae60c5469236a9dcf76b382b4882f1d4e5 Mon Sep 17 00:00:00 2001 From: mAi Date: Tue, 5 May 2026 23:13:05 +0200 Subject: [PATCH] mAi: #2 - shlink server: helper + share endpoint + schema Port flexsiebels' shlink REST v3 wrapper for short-link sharing of feedback forms. New helper `src/lib/server/shlink.ts` reads SHLINK_URL (default https://msbls.de) + SHLINK_API_KEY from env. New auth-gated `POST /api/admin/feedback//share` builds the long URL from PUBLIC_SITE_URL + instance.slug, calls shlink, persists shortUrl/shortCode on feedback_instances, and returns the updated row. Adds ShareCreateSchema (zod) for the request body and extends FeedbackInstance with the new columns. DB columns short_url + short_code added via Supabase migration fdbck_feedback_instances_add_short_url (both TEXT NULL). Refs m/fdbck#2. --- src/lib/schemas.ts | 8 +++ src/lib/server/feedback.ts | 2 + src/lib/server/shlink.ts | 49 +++++++++++++++++ .../api/admin/feedback/[id]/share/+server.ts | 54 +++++++++++++++++++ 4 files changed, 113 insertions(+) create mode 100644 src/lib/server/shlink.ts create mode 100644 src/routes/api/admin/feedback/[id]/share/+server.ts diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 22088ca..c93ef8d 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -111,5 +111,13 @@ export const SignInSchema = z.object({ password: z.string().min(6).max(200), }); +/** Body schema for the admin "create short URL" endpoint. */ +export const ShareCreateSchema = z.object({ + customSlug: z.string().min(1).max(64).regex(/^[a-zA-Z0-9_-]+$/, { + message: 'customSlug may only contain letters, digits, "-" and "_"', + }).optional(), + maxVisits: z.number().int().positive().max(1_000_000).optional(), +}); + export type FeedbackQuestion = z.infer; export type FeedbackFormDefinition = z.infer; diff --git a/src/lib/server/feedback.ts b/src/lib/server/feedback.ts index 88e2181..f110cf2 100644 --- a/src/lib/server/feedback.ts +++ b/src/lib/server/feedback.ts @@ -35,6 +35,8 @@ export interface FeedbackInstance { live_results_enabled: boolean; status: 'open' | 'closed'; closed_at: string | null; + short_url: string | null; + short_code: string | null; created_at: string; updated_at: string; } diff --git a/src/lib/server/shlink.ts b/src/lib/server/shlink.ts new file mode 100644 index 0000000..74cd57d --- /dev/null +++ b/src/lib/server/shlink.ts @@ -0,0 +1,49 @@ +/** + * Shlink REST v3 client — wraps `POST /rest/v3/short-urls`. + * + * Env: + * SHLINK_URL base URL of the shlink instance (default: https://msbls.de) + * SHLINK_API_KEY REST API key (required — throws if unset) + * + * Ported from flexsiebels/website/src/routes/api/share/+server.ts. + */ +import { env } from '$env/dynamic/private'; + +export interface ShlinkShortUrl { + shortUrl: string; + shortCode: string; + longUrl: string; + domain: string | null; + dateCreated: string; + visitsCount?: number; + tags?: string[]; + customSlug?: string | null; + [key: string]: unknown; +} + +export interface CreateShortUrlOpts { + longUrl: string; + customSlug?: string; + maxVisits?: number; + domain?: string; +} + +export async function createShortUrl(opts: CreateShortUrlOpts): Promise { + const shlinkUrl = env.SHLINK_URL || 'https://msbls.de'; + const shlinkKey = env.SHLINK_API_KEY; + if (!shlinkKey) throw new Error('Shlink API key is not configured'); + + const body: Record = { longUrl: opts.longUrl }; + if (opts.customSlug) body.customSlug = opts.customSlug; + if (opts.maxVisits) body.maxVisits = opts.maxVisits; + if (opts.domain) body.domain = opts.domain; + + const res = await fetch(`${shlinkUrl}/rest/v3/short-urls`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Api-Key': shlinkKey }, + body: JSON.stringify(body), + }); + + if (!res.ok) throw new Error(`Shlink returned ${res.status}: ${await res.text()}`); + return res.json() as Promise; +} diff --git a/src/routes/api/admin/feedback/[id]/share/+server.ts b/src/routes/api/admin/feedback/[id]/share/+server.ts new file mode 100644 index 0000000..56004ae --- /dev/null +++ b/src/routes/api/admin/feedback/[id]/share/+server.ts @@ -0,0 +1,54 @@ +/** + * POST /api/admin/feedback//share — create or refresh the shlink short URL + * that points to /f/. + * + * 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 { ShareCreateSchema } from '$lib/schemas'; +import { fdb } from '$lib/server/fdb'; +import { getInstanceById } from '$lib/server/feedback'; +import { createShortUrl } from '$lib/server/shlink'; + +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, ShareCreateSchema); + + const siteUrl = (env.PUBLIC_SITE_URL || 'https://fdbck.msbls.de').replace(/\/+$/, ''); + const longUrl = `${siteUrl}/f/${inst.slug}`; + + 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'); + } +};