diff --git a/README.md b/README.md index 6f252de..4aae7e1 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,33 @@ exakt das, was auch heruntergeladen wird. **PNG** für Druck und Office-Dokumente, **SVG** für verlustfreie Skalierung (Plakate, Schilder, Druckvorstufe). -### 5. Meine Codes +### 5. Verwaltung: welche Typen erscheinen + +Unter **Verwaltung → QR-Typen** lässt sich jeder der 15 Typen einzeln +ein- und ausblenden. Ausgeblendete Typen verschwinden vollständig von der +Startseite und lassen sich nicht mehr neu anlegen — praktisch, um die noch +nicht umgesetzten Typen gar nicht erst zu zeigen. + +Zwei Eigenschaften sind dabei zu unterscheiden: + +| | Bedeutung | Woher | +| --- | --- | --- | +| **umgesetzt** | Der Typ ist ausprogrammiert. In Version 1 gilt das nur für „Website“. | Registry im Code | +| **freigegeben** | Die Verwaltung zeigt den Typ an. | Datenbank, jederzeit änderbar | + +Nur umgesetzt **und** freigegeben heißt benutzbar. Freigegeben, aber nicht +umgesetzt, erscheint als Kachel „Bald verfügbar“. + +> **Bereits gespeicherte Codes bleiben unberührt.** Schaltet man einen Typ ab, +> leiten vorhandene dynamische Codes weiter wie bisher und lassen sich weiter +> bearbeiten. Ein gedruckter Code wird durch das Abschalten seines Typs also +> nicht ungültig — gesperrt ist nur das Anlegen neuer Codes. + +Ohne Eintrag in der Datenbank gilt ein Typ als freigegeben. Eine frische +Installation zeigt also alles, und Typen aus späteren Versionen erscheinen +automatisch. + +### 6. Meine Codes Unter „Meine Codes“ stehen alle dynamischen Codes mit Vorschaubild, Kurz-URL, aktuellem Ziel, Scan-Anzahl, Erstellungsdatum und letztem Scan. @@ -339,7 +365,9 @@ deutschen Meldung. | `PATCH` | `/api/codes/{id}` | Ziel, Name oder Gestaltung ändern | | `DELETE` | `/api/codes/{id}` | Code löschen | | `GET` | `/api/codes/{id}/qr` | Bild des Codes (`?format=png\|svg&download=1`) | -| `GET` | `/r/{slug}` | Weiterleitung, erhöht den Scan-Zähler | +| `GET` | `/r/{slug}` | Weiterleitung, erhöht den Scan-Zähler | +| `GET` | `/api/admin/types` | Zustand aller QR-Typen | +| `PATCH` | `/api/admin/types` | Typ freigeben oder abschalten | Beispiel: diff --git a/prisma/migrations/20260814083213_qr_typ_freigaben/migration.sql b/prisma/migrations/20260814083213_qr_typ_freigaben/migration.sql new file mode 100644 index 0000000..da64db1 --- /dev/null +++ b/prisma/migrations/20260814083213_qr_typ_freigaben/migration.sql @@ -0,0 +1,8 @@ +-- CreateTable +CREATE TABLE "QrTypeSetting" ( + "type" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "QrTypeSetting_pkey" PRIMARY KEY ("type") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5dfafea..f7d3dca 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -10,6 +10,19 @@ datasource db { provider = "postgresql" } +/// Freigabe eines QR-Typs durch die Verwaltung. +/// +/// Es gibt nur Zeilen für Typen, die bewusst umgeschaltet wurden — fehlt ein +/// Eintrag, gilt der Typ als freigegeben. So bleibt eine frische Installation +/// ohne Vorbelegung nutzbar, und neue Typen aus späteren Versionen sind +/// automatisch sichtbar. +model QrTypeSetting { + /// Kennung aus der Typ-Registry, z. B. "website". + type String @id + enabled Boolean + updatedAt DateTime @updatedAt +} + /// Ein dynamischer QR-Code. Der QR-Code kodiert `{BASE_URL}/r/{slug}` und /// leitet beim Aufruf auf das Ziel weiter, das aus `data` aufgelöst wird. /// diff --git a/src/app/api/admin/types/route.ts b/src/app/api/admin/types/route.ts new file mode 100644 index 0000000..b640208 --- /dev/null +++ b/src/app/api/admin/types/route.ts @@ -0,0 +1,51 @@ +import type { NextRequest } from "next/server"; +import { z } from "zod"; + +import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; +import { + getQrTypeAvailability, + setQrTypeEnabled, +} from "@/lib/qr/availability"; +import { QR_TYPE_IDS } from "@/lib/qr/type-module"; + +export const dynamic = "force-dynamic"; + +const updateSchema = z.object({ + type: z.enum(QR_TYPE_IDS, { error: "Unbekannter QR-Typ." }), + enabled: z.boolean({ error: "Der Schalter muss wahr oder falsch sein." }), +}); + +/** Zustand aller QR-Typen für die Verwaltung. */ +export async function GET(): Promise { + try { + const types = await getQrTypeAvailability(); + return Response.json({ + types: types.map(({ qrType, implemented, enabled, usable }) => ({ + id: qrType.id, + title: qrType.title, + description: qrType.description, + icon: qrType.icon, + implemented, + enabled, + usable, + })), + }); + } catch (error) { + return errorResponse(error); + } +} + +/** Einen Typ freigeben oder abschalten. */ +export async function PATCH(request: NextRequest): Promise { + try { + const parsed = updateSchema.safeParse(await request.json()); + if (!parsed.success) { + return jsonError(firstZodMessage(parsed.error)); + } + + await setQrTypeEnabled(parsed.data.type, parsed.data.enabled); + return Response.json({ type: parsed.data.type, enabled: parsed.data.enabled }); + } catch (error) { + return errorResponse(error); + } +} diff --git a/src/app/api/codes/[id]/route.ts b/src/app/api/codes/[id]/route.ts index 50ce503..5199d75 100644 --- a/src/app/api/codes/[id]/route.ts +++ b/src/app/api/codes/[id]/route.ts @@ -5,9 +5,9 @@ import type { Prisma } from "@/generated/prisma/client"; import { toJsonInput, toQrCodeDto } from "@/lib/codes"; import { prisma } from "@/lib/db"; import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; +import { requireImplementedQrType } from "@/lib/qr/availability"; import { designSchema } from "@/lib/qr/design"; -import { getActiveQrType } from "@/lib/qr/registry"; -import { QrRequestError } from "@/lib/qr/request"; +import { QrRequestError } from "@/lib/qr/type-module"; export const dynamic = "force-dynamic"; @@ -60,12 +60,10 @@ export async function PATCH( const update: Prisma.QrCodeUpdateInput = {}; if (parsed.data.data !== undefined) { - const qrType = getActiveQrType(existing.type); - if (!qrType) { - throw new QrRequestError( - "Der Typ dieses Codes wird in dieser Version nicht unterstützt.", - ); - } + // Bewusst nur „ausprogrammiert“, nicht „freigegeben“: ein in der + // Verwaltung abgeschalteter Typ soll bestehende Codes nicht + // unbearbeitbar machen — sie sind längst gedruckt. + const qrType = requireImplementedQrType(existing.type); const validated = qrType.validate(parsed.data.data); if (!validated.ok) { return jsonError(validated.message); diff --git a/src/app/api/codes/route.ts b/src/app/api/codes/route.ts index a3d88ce..ed2e64a 100644 --- a/src/app/api/codes/route.ts +++ b/src/app/api/codes/route.ts @@ -4,10 +4,9 @@ import { z } from "zod"; import { toJsonInput, toQrCodeDto } from "@/lib/codes"; import { prisma } from "@/lib/db"; import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; +import { requireAvailableQrType } from "@/lib/qr/availability"; import { designSchema } from "@/lib/qr/design"; -import { getActiveQrType } from "@/lib/qr/registry"; -import { QrRequestError } from "@/lib/qr/request"; -import { QR_TYPE_IDS } from "@/lib/qr/type-module"; +import { QR_TYPE_IDS, QrRequestError } from "@/lib/qr/type-module"; import { generateSlug } from "@/lib/slug"; export const dynamic = "force-dynamic"; @@ -51,13 +50,7 @@ export async function POST(request: NextRequest): Promise { } const { type, design, label } = parsed.data; - const qrType = getActiveQrType(type); - if (!qrType) { - throw new QrRequestError( - "Dieser QR-Typ ist in dieser Version noch nicht verfügbar.", - 404, - ); - } + const qrType = await requireAvailableQrType(type); if (!qrType.supportsDynamic) { throw new QrRequestError( `Der QR-Typ „${qrType.title}“ unterstützt keine dynamischen Codes.`, diff --git a/src/app/api/qr/download/route.ts b/src/app/api/qr/download/route.ts index b3c5bc4..8fb8cb5 100644 --- a/src/app/api/qr/download/route.ts +++ b/src/app/api/qr/download/route.ts @@ -31,7 +31,7 @@ export async function POST(request: NextRequest): Promise { ); } - const { qrType, data, content } = resolveQrRequest(parsed.data); + const { qrType, data, content } = await resolveQrRequest(parsed.data); const body = format === "png" diff --git a/src/app/api/qr/preview/route.ts b/src/app/api/qr/preview/route.ts index cd17702..16f4993 100644 --- a/src/app/api/qr/preview/route.ts +++ b/src/app/api/qr/preview/route.ts @@ -15,7 +15,7 @@ export async function POST(request: NextRequest): Promise { return jsonError(firstZodMessage(parsed.error)); } - const { content } = resolveQrRequest(parsed.data); + const { content } = await resolveQrRequest(parsed.data); const svg = await renderSvg(content, parsed.data.design); return Response.json({ diff --git a/src/app/erstellen/[type]/page.tsx b/src/app/erstellen/[type]/page.tsx index d549b7c..02c0665 100644 --- a/src/app/erstellen/[type]/page.tsx +++ b/src/app/erstellen/[type]/page.tsx @@ -4,14 +4,13 @@ import { notFound } from "next/navigation"; import { Icon } from "@/components/icons"; import { QrGenerator } from "@/components/qr-generator"; -import { QR_TYPE_IDS, getActiveQrType, getQrType } from "@/lib/qr/registry"; +import { getQrTypeState } from "@/lib/qr/availability"; +import { getQrType } from "@/lib/qr/registry"; type Props = { params: Promise<{ type: string }> }; -/** Alle bekannten Typen vorab erzeugen — auch die noch inaktiven. */ -export function generateStaticParams() { - return QR_TYPE_IDS.map((type) => ({ type })); -} +// Die Freigabe der Typen kommt aus der Datenbank und ist jederzeit änderbar. +export const dynamic = "force-dynamic"; export async function generateMetadata({ params }: Props): Promise { const { type } = await params; @@ -19,38 +18,60 @@ export async function generateMetadata({ params }: Props): Promise { return { title: qrType ? `${qrType.title}-QR-Code` : "QR-Code erstellen" }; } +/** Erklärseite statt 404 — der Typ existiert, ist nur (noch) nicht nutzbar. */ +function Hinweis({ + icon, + titel, + text, +}: { + icon: Parameters[0]["name"]; + titel: string; + text: string; +}) { + return ( +
+ + + +

{titel}

+

{text}

+ + Zur Typ-Auswahl + +
+ ); +} + export default async function CreatePage({ params }: Props) { const { type } = await params; - const qrType = getQrType(type); + const state = await getQrTypeState(type); - if (!qrType) { + if (!state) { notFound(); } - // Registrierte, aber noch nicht implementierte Typen bekommen eine - // erklärende Seite statt einer 404. - if (!getActiveQrType(type)) { + if (!state.enabled) { return ( -
- - - -

- „{qrType.title}“ ist bald verfügbar -

-

- Dieser QR-Typ folgt in Version 2. In Version 1 steht der Typ „Website“ - zur Verfügung. -

- - Website-QR-Code erstellen - -
+ ); } - return ; + if (!state.implemented) { + return ( + + ); + } + + return ; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8e2c8ad..3ef8824 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -18,7 +18,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
-
+
) { - QR-Code-Generator + {/* Auf schmalen Geräten reicht das Zeichen — sonst passt die + Navigation nicht mehr in eine Zeile. */} + QR-Code-Generator -
diff --git a/src/app/page.tsx b/src/app/page.tsx index fef005c..987ae43 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,10 +1,17 @@ import Link from "next/link"; import { Icon } from "@/components/icons"; -import { Badge } from "@/components/ui"; -import { qrTypeList } from "@/lib/qr/registry"; +import { Badge, Notice } from "@/components/ui"; +import { getVisibleQrTypes } from "@/lib/qr/availability"; + +// Welche Typen sichtbar sind, steht in der Datenbank und ist jederzeit +// änderbar — die Startseite darf deshalb nicht vorgerendert werden. +export const dynamic = "force-dynamic"; + +export default async function HomePage() { + const types = await getVisibleQrTypes(); + const usableCount = types.filter((entry) => entry.usable).length; -export default function HomePage() { return (
@@ -18,76 +25,102 @@ export default function HomePage() {

-
    - {qrTypeList.map((type) => { - const tile = ( - <> - - - - - - - {type.title} - - {type.active ? ( - Beliebt - ) : ( - Bald verfügbar - )} - + {usableCount === 0 ? ( +
    + + {types.length === 0 + ? "Zurzeit ist kein QR-Typ freigegeben." + : "Zurzeit ist kein QR-Typ zum Erstellen freigegeben — die unten gezeigten Typen folgen erst in einer späteren Version."}{" "} + Unter{" "} + + Verwaltung → QR-Typen + {" "} + lassen sich Typen wieder freigeben. + +
    + ) : null} + + {types.length === 0 ? null : ( +
      + {types.map(({ qrType, usable }) => { + const tile = ( + <> - {type.description} + - - - ); + + + + {qrType.title} + + {usable ? ( + Beliebt + ) : ( + Bald verfügbar + )} + + + {qrType.description} + + + + ); - return ( -
    • - {type.active ? ( - - {tile} - - ) : ( -
      - {tile} -
      - )} -
    • - ); - })} -
    + return ( +
  • + {usable ? ( + + {tile} + + ) : ( +
    + {tile} +
    + )} +
  • + ); + })} +
+ )} -

- In Version 1 ist der Typ „Website“ verfügbar. Die übrigen Typen folgen in - Version 2. -

+ {usableCount > 0 && usableCount < types.length ? ( +

+ Die übrigen Typen folgen in einer späteren Version. Welche Kacheln + hier erscheinen, legst du unter{" "} + + Verwaltung → QR-Typen + {" "} + fest. +

+ ) : null}
); } diff --git a/src/app/verwaltung/typen/page.tsx b/src/app/verwaltung/typen/page.tsx new file mode 100644 index 0000000..0038ac8 --- /dev/null +++ b/src/app/verwaltung/typen/page.tsx @@ -0,0 +1,55 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +import { Icon } from "@/components/icons"; +import { TypeSettings, type TypeSetting } from "@/components/type-settings"; +import { getQrTypeAvailability } from "@/lib/qr/availability"; + +export const metadata: Metadata = { title: "QR-Typen" }; + +export const dynamic = "force-dynamic"; + +export default async function TypeSettingsPage() { + const availability = await getQrTypeAvailability(); + + const initial: TypeSetting[] = availability.map( + ({ qrType, implemented, enabled }) => ({ + id: qrType.id, + title: qrType.title, + description: qrType.description, + icon: qrType.icon, + implemented, + enabled, + }), + ); + + return ( +
+ + + Zur Typ-Auswahl + + +
+

+ QR-Typen +

+

+ Lege fest, welche Typen auf der Startseite erscheinen. Abgeschaltete + Typen sind komplett ausgeblendet und lassen sich nicht mehr neu + anlegen. +

+

+ Bereits gespeicherte dynamische Codes bleiben davon unberührt: sie + leiten weiter und lassen sich weiter bearbeiten. Ein gedruckter Code + wird durch das Abschalten seines Typs also nicht ungültig. +

+
+ + +
+ ); +} diff --git a/src/components/type-settings.tsx b/src/components/type-settings.tsx new file mode 100644 index 0000000..86f1272 --- /dev/null +++ b/src/components/type-settings.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useState } from "react"; + +import { Icon, type IconName } from "@/components/icons"; +import { Badge, Card, Notice, cx } from "@/components/ui"; +import { ApiError, apiJson } from "@/lib/api-client"; +import type { QrTypeId } from "@/lib/qr/type-module"; + +export type TypeSetting = { + id: QrTypeId; + title: string; + description: string; + icon: IconName; + implemented: boolean; + enabled: boolean; +}; + +function Toggle({ + checked, + disabled, + label, + onChange, +}: { + checked: boolean; + disabled: boolean; + label: string; + onChange: (next: boolean) => void; +}) { + return ( + + ); +} + +export function TypeSettings({ initial }: { initial: TypeSetting[] }) { + const [types, setTypes] = useState(initial); + const [pending, setPending] = useState(null); + const [error, setError] = useState(null); + + const visibleCount = types.filter((entry) => entry.enabled).length; + const usableCount = types.filter( + (entry) => entry.enabled && entry.implemented, + ).length; + + const toggle = async (id: QrTypeId, enabled: boolean) => { + const before = types; + // Sofort umschalten und bei einem Fehler zurückdrehen — der Schalter + // soll sich nicht zäh anfühlen. + setTypes((current) => + current.map((entry) => (entry.id === id ? { ...entry, enabled } : entry)), + ); + setPending(id); + setError(null); + try { + await apiJson("/api/admin/types", { + method: "PATCH", + json: { type: id, enabled }, + }); + } catch (caught) { + setTypes(before); + setError( + caught instanceof ApiError + ? caught.message + : "Die Änderung konnte nicht gespeichert werden.", + ); + } finally { + setPending(null); + } + }; + + return ( +
+ {error ? {error} : null} + + {visibleCount === 0 ? ( + + Zurzeit ist kein Typ freigegeben — die Startseite bleibt leer und es + lassen sich keine neuen Codes anlegen. Bereits gespeicherte + dynamische Codes leiten weiterhin ganz normal weiter. + + ) : usableCount === 0 ? ( + + Es ist kein nutzbarer Typ freigegeben. Die freigegebenen Typen sind in + dieser Version noch nicht ausprogrammiert. + + ) : null} + + +
    + {types.map((entry) => ( +
  • + + + + +
    +
    + + {entry.title} + + {entry.implemented ? ( + verfügbar + ) : ( + noch nicht umgesetzt + )} +
    +

    + {entry.enabled + ? entry.implemented + ? "Erscheint auf der Startseite und kann verwendet werden." + : "Erscheint auf der Startseite als „Bald verfügbar“." + : "Ausgeblendet — erscheint nicht auf der Startseite."} +

    +
    + + void toggle(entry.id, next)} + /> +
  • + ))} +
+
+ +

+ {usableCount} von {types.length} Typen nutzbar, {visibleCount} auf der + Startseite sichtbar. +

+
+ ); +} diff --git a/src/lib/qr/availability.ts b/src/lib/qr/availability.ts new file mode 100644 index 0000000..030d167 --- /dev/null +++ b/src/lib/qr/availability.ts @@ -0,0 +1,141 @@ +import "server-only"; + +import { prisma } from "@/lib/db"; +import { getQrType, qrTypeList } from "@/lib/qr/registry"; +import { + isQrTypeId, + QrRequestError, + type QrTypeId, + type QrTypeModule, +} from "@/lib/qr/type-module"; + +/** + * Freigabe der QR-Typen durch die Verwaltung. + * + * Zwei voneinander unabhängige Eigenschaften bestimmen, was ein Typ kann: + * + * - `implemented` — steht in der Registry und sagt, ob der Typ überhaupt + * ausprogrammiert ist. In V1 gilt das nur für „Website“. + * - `enabled` — kommt aus der Datenbank und sagt, ob die Verwaltung den Typ + * freigegeben hat. Fehlt der Eintrag, ist der Typ freigegeben. + * + * Ein abgeschalteter Typ verschwindet aus der Oberfläche und lässt sich nicht + * mehr neu anlegen. **Bereits bestehende Codes dieses Typs funktionieren + * weiter** — sie sind gedruckt und verteilt, und ein Schalter in der + * Verwaltung darf sie nicht ins Leere laufen lassen. + */ + +export type QrTypeAvailability = { + qrType: QrTypeModule; + /** Der Typ ist in dieser Version ausprogrammiert. */ + implemented: boolean; + /** Die Verwaltung hat den Typ freigegeben. */ + enabled: boolean; + /** Freigegeben und ausprogrammiert — nur dann ist er benutzbar. */ + usable: boolean; + /** Freigegeben, aber noch nicht ausprogrammiert — „Bald verfügbar“. */ + announced: boolean; +}; + +/** Abschaltungen aus der Datenbank; alles nicht Genannte ist freigegeben. */ +async function loadDisabledTypes(): Promise> { + try { + const rows = await prisma.qrTypeSetting.findMany({ + where: { enabled: false }, + select: { type: true }, + }); + return new Set(rows.map((row) => row.type)); + } catch (error) { + // Ohne Datenbank ist die Anwendung für statische Codes weiterhin + // brauchbar. Lieber alles anzeigen als eine leere Startseite. + console.error( + "Freigaben der QR-Typen konnten nicht geladen werden — es gilt die Voreinstellung (alle freigegeben):", + error, + ); + return new Set(); + } +} + +/** Verfügbarkeit aller Typen in Anzeigereihenfolge. */ +export async function getQrTypeAvailability(): Promise { + const disabled = await loadDisabledTypes(); + + return qrTypeList.map((qrType) => { + const enabled = !disabled.has(qrType.id); + return { + qrType, + implemented: qrType.implemented, + enabled, + usable: enabled && qrType.implemented, + announced: enabled && !qrType.implemented, + }; + }); +} + +/** Nur die Typen, die in der Oberfläche erscheinen sollen. */ +export async function getVisibleQrTypes(): Promise { + return (await getQrTypeAvailability()).filter((entry) => entry.enabled); +} + +export async function getQrTypeState( + id: string, +): Promise { + if (!isQrTypeId(id)) return undefined; + return (await getQrTypeAvailability()).find((entry) => entry.qrType.id === id); +} + +/** + * Liefert den Typ für das Anlegen neuer Codes — oder wirft mit einer + * Meldung, die unverändert in die Oberfläche gehen kann. + */ +export async function requireAvailableQrType( + id: string, +): Promise { + const state = await getQrTypeState(id); + + if (!state) { + throw new QrRequestError("Unbekannter QR-Typ.", 404); + } + if (!state.implemented) { + throw new QrRequestError( + `Der QR-Typ „${state.qrType.title}“ ist in dieser Version noch nicht verfügbar.`, + 404, + ); + } + if (!state.enabled) { + throw new QrRequestError( + `Der QR-Typ „${state.qrType.title}“ ist in der Verwaltung abgeschaltet.`, + 403, + ); + } + + return state.qrType; +} + +/** + * Typ eines bestehenden Codes. Hier zählt nur, ob der Typ ausprogrammiert + * ist — eine Abschaltung in der Verwaltung darf bestehende Codes nicht + * unbearbeitbar machen. + */ +export function requireImplementedQrType(id: string): QrTypeModule { + const qrType = getQrType(id); + if (!qrType?.implemented) { + throw new QrRequestError( + "Der Typ dieses Codes wird in dieser Version nicht unterstützt.", + 404, + ); + } + return qrType; +} + +/** Freigabe eines Typs setzen. */ +export async function setQrTypeEnabled( + id: QrTypeId, + enabled: boolean, +): Promise { + await prisma.qrTypeSetting.upsert({ + where: { type: id }, + create: { type: id, enabled }, + update: { enabled }, + }); +} diff --git a/src/lib/qr/registry.ts b/src/lib/qr/registry.ts index 1ed785e..a42283f 100644 --- a/src/lib/qr/registry.ts +++ b/src/lib/qr/registry.ts @@ -120,10 +120,16 @@ export function getQrType(id: string): QrTypeModule | undefined { return isQrTypeId(id) ? qrTypes[id] : undefined; } -/** Liefert den Typ nur, wenn er in dieser Version nutzbar ist. */ -export function getActiveQrType(id: string): QrTypeModule | undefined { +/** + * Liefert den Typ nur, wenn er in dieser Version ausprogrammiert ist. + * + * Achtung: das ist noch keine Freigabe. Ob ein Typ neu angelegt werden darf, + * entscheidet zusätzlich die Verwaltung — dafür `requireAvailableQrType` aus + * `src/lib/qr/availability.ts` verwenden. + */ +export function getImplementedQrType(id: string): QrTypeModule | undefined { const qrType = getQrType(id); - return qrType?.active ? qrType : undefined; + return qrType?.implemented ? qrType : undefined; } export { QR_TYPE_IDS, isQrTypeId }; diff --git a/src/lib/qr/request.ts b/src/lib/qr/request.ts index 9ed1b83..07f0936 100644 --- a/src/lib/qr/request.ts +++ b/src/lib/qr/request.ts @@ -1,8 +1,14 @@ +import "server-only"; + import { z } from "zod"; +import { requireAvailableQrType } from "@/lib/qr/availability"; import { designSchema } from "@/lib/qr/design"; -import { getActiveQrType } from "@/lib/qr/registry"; -import { QR_TYPE_IDS, type QrTypeModule } from "@/lib/qr/type-module"; +import { + QR_TYPE_IDS, + QrRequestError, + type QrTypeModule, +} from "@/lib/qr/type-module"; import { shortUrl } from "@/lib/env"; import { isValidSlug } from "@/lib/slug"; @@ -30,15 +36,9 @@ export const qrRequestSchema = z.object({ export type QrRequest = z.infer; -/** Fehler mit einer Meldung, die unverändert an die Oberfläche geht. */ -export class QrRequestError extends Error { - constructor( - message: string, - readonly status: number = 400, - ) { - super(message); - } -} +// Liegt in type-module.ts, damit auch availability.ts ihn werfen kann, ohne +// dass ein Ringschluss zwischen den beiden Modulen entsteht. +export { QrRequestError }; export type ResolvedQrRequest = { qrType: QrTypeModule; @@ -51,14 +51,10 @@ export type ResolvedQrRequest = { * Prüft eine Anfrage vollständig und liefert den zu kodierenden Inhalt: * bei statischen Codes die Nutzdaten selbst, bei dynamischen die Kurz-URL. */ -export function resolveQrRequest(request: QrRequest): ResolvedQrRequest { - const qrType = getActiveQrType(request.type); - if (!qrType) { - throw new QrRequestError( - "Dieser QR-Typ ist in dieser Version noch nicht verfügbar.", - 404, - ); - } +export async function resolveQrRequest( + request: QrRequest, +): Promise { + const qrType = await requireAvailableQrType(request.type); const validated = qrType.validate(request.data); if (!validated.ok) { diff --git a/src/lib/qr/type-module.ts b/src/lib/qr/type-module.ts index a76c7e1..ae7e5db 100644 --- a/src/lib/qr/type-module.ts +++ b/src/lib/qr/type-module.ts @@ -46,6 +46,19 @@ export type ValidationResult = */ export class QrTypeError extends Error {} +/** + * Fehler mit einer Meldung, die unverändert an die Oberfläche geht, plus + * dem passenden HTTP-Status. + */ +export class QrRequestError extends Error { + constructor( + message: string, + readonly status: number = 400, + ) { + super(message); + } +} + /** * Die Schnittstelle, über die der Rest der Anwendung mit einem QR-Typ * arbeitet. Die konkreten Datentypen sind hier bewusst gelöscht: die @@ -57,8 +70,14 @@ export interface QrTypeModule { readonly title: string; readonly description: string; readonly icon: IconName; - /** Ist der Typ in dieser Version nutzbar? */ - readonly active: boolean; + /** + * Ist der Typ in dieser Version ausprogrammiert? + * + * Nicht zu verwechseln mit der Freigabe: ob ein Typ in der Oberfläche + * erscheint, entscheidet zusätzlich die Verwaltung — siehe + * `src/lib/qr/availability.ts`. + */ + readonly implemented: boolean; /** Unterstützt der Typ dynamische Codes (Kurz-URL + Weiterleitung)? */ readonly supportsDynamic: boolean; /** Startwerte für ein leeres Formular. */ @@ -78,7 +97,7 @@ type QrTypeSpec = { title: string; description: string; icon: IconName; - active: boolean; + implemented: boolean; supportsDynamic: boolean; schema: TSchema; defaultData: QrFormData; @@ -120,7 +139,7 @@ export function defineQrType( title: spec.title, description: spec.description, icon: spec.icon, - active: spec.active, + implemented: spec.implemented, supportsDynamic: spec.supportsDynamic, defaultData: spec.defaultData, validate(input) { @@ -157,7 +176,7 @@ export function defineComingSoonType(spec: { title: spec.title, description: spec.description, icon: spec.icon, - active: false, + implemented: false, supportsDynamic: false, defaultData: {}, validate: () => ({ diff --git a/src/lib/qr/types/website.ts b/src/lib/qr/types/website.ts index 1ee26dd..28eeea5 100644 --- a/src/lib/qr/types/website.ts +++ b/src/lib/qr/types/website.ts @@ -47,7 +47,7 @@ export const websiteType = defineQrType({ title: "Website", description: "Verlinke eine beliebige Webadresse.", icon: "globe", - active: true, + implemented: true, supportsDynamic: true, schema: websiteDataSchema, defaultData: { url: "" },