Merge mai/hermes/fdbck-shlink-short-link: shlink integration + admin Share section

This commit is contained in:
mAi
2026-05-05 23:15:26 +02:00
6 changed files with 248 additions and 0 deletions

View File

@@ -10,3 +10,8 @@ PUBLIC_SITE_URL=https://fdbck.msbls.de
# Optional cookie scope (leave unset for host-only)
# COOKIE_DOMAIN=.msbls.de
# Shlink short-URL service (admin "Share" feature)
# SHLINK_URL defaults to https://msbls.de when unset.
SHLINK_URL=https://msbls.de
SHLINK_API_KEY=

View File

@@ -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<typeof FeedbackQuestionSchema>;
export type FeedbackFormDefinition = z.infer<typeof FeedbackFormDefinitionSchema>;

View File

@@ -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;
}

49
src/lib/server/shlink.ts Normal file
View 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>;
}

View File

@@ -25,6 +25,11 @@
let editChatEnabled = $state(inst.chat_enabled);
let editLiveResults = $state(inst.live_results_enabled);
let shareSlugInput = $state('');
let shareInFlight = $state(false);
let shareError = $state<string | null>(null);
let shareCopied = $state(false);
let editMode = $state<'visual' | 'json'>('visual');
let editForm = $state<FeedbackFormDefinition | null>(
inst.form_definition ? (inst.form_definition as FeedbackFormDefinition) : null,
@@ -214,6 +219,46 @@
}
}
async function copyShortUrl(): Promise<void> {
if (!inst.short_url) return;
try {
await navigator.clipboard.writeText(inst.short_url);
shareCopied = true;
setTimeout(() => (shareCopied = false), 1500);
} catch {
// silent
}
}
async function createShareLink(): Promise<void> {
shareError = null;
shareInFlight = true;
try {
const slug = shareSlugInput.trim();
const body: { customSlug?: string } = {};
if (slug) body.customSlug = slug;
const res = await fetch(`/api/admin/feedback/${inst.id}/share`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { error?: string; details?: unknown };
shareError = j.error ?? `Error ${res.status}`;
if (j.details) shareError += ' — ' + JSON.stringify(j.details);
return;
}
const j = (await res.json()) as { instance: typeof inst };
inst = j.instance;
shareSlugInput = '';
} catch (e) {
shareError = e instanceof Error ? e.message : 'Network error';
} finally {
shareInFlight = false;
}
}
function fmtDateTime(iso: string): string {
return new Date(iso).toLocaleString();
}
@@ -289,6 +334,91 @@
<div class="fb-banner fb-banner--error">{actionError}</div>
{/if}
<section class="fb-section" data-fb-share>
<h2>Share</h2>
{#if inst.short_url}
<p style="margin: 0 0 0.5rem 0; color: var(--color-text-muted); font-size: 0.85rem;">
Memorable short link — resolves to <code>/f/{inst.slug}</code>.
</p>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
<a
href={inst.short_url}
target="_blank"
rel="noopener"
style="font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 0.95rem; word-break: break-all;"
>
{inst.short_url}
</a>
<button type="button" class="fb-btn fb-btn--ghost" onclick={copyShortUrl}>
{shareCopied ? 'Copied' : 'Copy'}
</button>
<a class="fb-btn fb-btn--ghost" href={inst.short_url} target="_blank" rel="noopener">
Open ↗
</a>
</div>
<details style="margin-top: 0.75rem;">
<summary style="cursor: pointer; color: var(--color-text-muted); font-size: 0.85rem;">
Replace with a different short link
</summary>
<div style="margin-top: 0.5rem;">
<label class="fb-question__label" for="fb-share-slug-replace">Custom slug (optional)</label>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
<input
id="fb-share-slug-replace"
class="fb-input"
maxlength="64"
placeholder="e.g. vote, session-feedback"
bind:value={shareSlugInput}
style="max-width: 320px;"
/>
<button
type="button"
class="fb-btn"
disabled={shareInFlight}
onclick={createShareLink}
>
{shareInFlight ? 'Creating…' : 'Create new'}
</button>
</div>
<p style="margin: 0.4rem 0 0 0; color: var(--color-text-muted); font-size: 0.8rem;">
Leave blank for a random short code, or pick a memorable slug like <code>vote</code> or <code>session-feedback</code>.
</p>
</div>
</details>
{:else}
<p style="margin: 0 0 0.5rem 0; color: var(--color-text-muted); font-size: 0.85rem;">
Create a memorable short link (e.g. <code>https://msbls.de/vote</code>) that redirects to this form.
</p>
<div style="margin-top: 0.5rem;">
<label class="fb-question__label" for="fb-share-slug">Custom slug (optional)</label>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
<input
id="fb-share-slug"
class="fb-input"
maxlength="64"
placeholder="e.g. vote, session-feedback"
bind:value={shareSlugInput}
style="max-width: 320px;"
/>
<button
type="button"
class="fb-btn"
disabled={shareInFlight}
onclick={createShareLink}
>
{shareInFlight ? 'Creating…' : 'Create short link'}
</button>
</div>
<p style="margin: 0.4rem 0 0 0; color: var(--color-text-muted); font-size: 0.8rem;">
Leave blank for a random short code, or pick a memorable slug like <code>vote</code> or <code>session-feedback</code>.
</p>
</div>
{/if}
{#if shareError}
<div class="fb-banner fb-banner--error" style="margin-top: 0.5rem;">{shareError}</div>
{/if}
</section>
<div class="fb-tabs">
{#each [
{ key: 'chat', label: `Chat (${posts.length})`, show: inst.chat_enabled },

View 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');
}
};