Verwaltung: QR-Typen einzeln freigeben oder ausblenden

Neue Seite Verwaltung -> QR-Typen mit einem Schalter je Typ. Abgeschaltete
Typen verschwinden vollstaendig von der Startseite und lassen sich nicht
mehr neu anlegen.

- Neue Tabelle QrTypeSetting; fehlender Eintrag heisst freigegeben, damit
  eine frische Installation ohne Vorbelegung laeuft und spaetere Typen
  automatisch erscheinen.
- Registry-Feld 'active' heisst jetzt 'implemented' und beschreibt nur noch,
  ob der Typ ausprogrammiert ist. Ob er erscheint, entscheidet zusaetzlich
  die Freigabe aus der Datenbank (src/lib/qr/availability.ts).
- Bestehende dynamische Codes bleiben von einer Abschaltung unberuehrt: die
  Weiterleitung funktioniert weiter und Bearbeiten bleibt moeglich. Ein
  gedruckter Code darf durch einen Schalter nicht ungueltig werden.
- resolveQrRequest ist dadurch asynchron geworden; QrRequestError liegt jetzt
  in type-module.ts, sonst gaebe es einen Ringschluss mit availability.ts.
- Startseite meldet jetzt auch den Fall, dass nur noch Platzhalter
  freigegeben sind - vorher standen dort 14 tote Kacheln ohne Erklaerung.
- Kopfzeile: Schriftzug auf schmalen Geraeten ausgeblendet, sonst passte die
  dritte Navigationsschaltflaeche nicht mehr in die Zeile (7px Ueberlauf).
This commit is contained in:
Florian Wilde
2026-08-14 10:48:21 +02:00
parent 1e9e37f08d
commit c6da20f419
18 changed files with 684 additions and 148 deletions
+141
View File
@@ -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<ReadonlySet<string>> {
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<QrTypeAvailability[]> {
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<QrTypeAvailability[]> {
return (await getQrTypeAvailability()).filter((entry) => entry.enabled);
}
export async function getQrTypeState(
id: string,
): Promise<QrTypeAvailability | undefined> {
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<QrTypeModule> {
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<void> {
await prisma.qrTypeSetting.upsert({
where: { type: id },
create: { type: id, enabled },
update: { enabled },
});
}
+9 -3
View File
@@ -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 };
+15 -19
View File
@@ -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<typeof qrRequestSchema>;
/** 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<ResolvedQrRequest> {
const qrType = await requireAvailableQrType(request.type);
const validated = qrType.validate(request.data);
if (!validated.ok) {
+24 -5
View File
@@ -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<TSchema extends z.ZodType> = {
title: string;
description: string;
icon: IconName;
active: boolean;
implemented: boolean;
supportsDynamic: boolean;
schema: TSchema;
defaultData: QrFormData;
@@ -120,7 +139,7 @@ export function defineQrType<TSchema extends z.ZodType>(
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: () => ({
+1 -1
View File
@@ -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: "" },