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/<id>/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.
This commit is contained in:
@@ -111,5 +111,13 @@ export const SignInSchema = z.object({
|
|||||||
password: z.string().min(6).max(200),
|
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<typeof FeedbackQuestionSchema>;
|
export type FeedbackQuestion = z.infer<typeof FeedbackQuestionSchema>;
|
||||||
export type FeedbackFormDefinition = z.infer<typeof FeedbackFormDefinitionSchema>;
|
export type FeedbackFormDefinition = z.infer<typeof FeedbackFormDefinitionSchema>;
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export interface FeedbackInstance {
|
|||||||
live_results_enabled: boolean;
|
live_results_enabled: boolean;
|
||||||
status: 'open' | 'closed';
|
status: 'open' | 'closed';
|
||||||
closed_at: string | null;
|
closed_at: string | null;
|
||||||
|
short_url: string | null;
|
||||||
|
short_code: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
49
src/lib/server/shlink.ts
Normal file
49
src/lib/server/shlink.ts
Normal file
@@ -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<ShlinkShortUrl> {
|
||||||
|
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<string, unknown> = { 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<ShlinkShortUrl>;
|
||||||
|
}
|
||||||
54
src/routes/api/admin/feedback/[id]/share/+server.ts
Normal file
54
src/routes/api/admin/feedback/[id]/share/+server.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/admin/feedback/<id>/share — create or refresh the shlink short URL
|
||||||
|
* that points to /f/<instance.slug>.
|
||||||
|
*
|
||||||
|
* 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user