QR-Typen je Gruppe freigeben

Bisher galt die Freigabe eines Typs fuer die ganze Installation. Bei
mehreren Gruppen ist das zu grob: Die Marketingabteilung braucht Social
Media, die Werkstatt nur Website und PDF.

Die Freigabe bekommt eine zweite Ebene. Die Grundeinstellung bleibt die
Grundlage; je Gruppe laesst sich davon abweichen. Fehlt eine Abweichung,
folgt die Gruppe der Grundeinstellung.

Zur offenen Frage im Issue: Die globale Abschaltung gewinnt immer. Ein
global abgeschalteter Typ laesst sich in keiner Gruppe einschalten — der
Schalter steht dort sichtbar still und nennt den Grund. Andersherum waere
"hier abgeschaltet" keine verlaessliche Aussage mehr.

Eine eigene Tabelle statt eines zusammengesetzten Schluessels in
QrTypeSetting: Der globale Fall braeuchte dort eine leere Gruppe, und
PostgreSQL laesst in einem eindeutigen Index beliebig viele NULL
nebeneinander zu — die Eindeutigkeit der globalen Zeile waere also gerade
nicht gesichert.

Die Startseite zeigt die Vereinigung ueber die eigenen Gruppen; beim
Anlegen entscheidet die gewaehlte Gruppe, und die Route prueft das noch
einmal.

Refs #11
This commit is contained in:
Florian Wilde
2026-08-21 03:48:22 +02:00
parent b96e9ddbe5
commit 3cb5421a20
11 changed files with 423 additions and 50 deletions
+15 -4
View File
@@ -52,7 +52,12 @@ function Hinweis({
export default async function CreatePage({ params }: Props) {
const actor = await requirePageActor();
const { type } = await params;
const state = await getQrTypeState(type);
// Welche Gruppe gilt, entscheidet mit darueber, ob der Typ hier offensteht.
const zielgruppe = await resolveTargetGroup(actor, undefined).catch(
() => undefined,
);
const state = await getQrTypeState(type, zielgruppe);
if (!state) {
notFound();
@@ -63,7 +68,11 @@ export default async function CreatePage({ params }: Props) {
<Hinweis
icon="lock"
titel={`„${state.qrType.title}“ ist abgeschaltet`}
text="Dieser QR-Typ wurde in der Verwaltung abgeschaltet. Ein Administrator kann ihn unter Verwaltung → QR-Typen wieder freigeben."
text={
state.enabledGlobally
? "Dieser QR-Typ ist für deine Gruppe abgeschaltet. Ein Administrator kann ihn unter Verwaltung → QR-Typen für die Gruppe wieder freigeben."
: "Dieser QR-Typ wurde in der Verwaltung abgeschaltet. Ein Administrator kann ihn unter Verwaltung → QR-Typen wieder freigeben."
}
/>
);
}
@@ -82,8 +91,10 @@ export default async function CreatePage({ params }: Props) {
// Einstellung. Das Feld erscheint nur dann — die Route prueft es ohnehin
// noch einmal.
const { editorsMaySetSlug } = await getSettings();
const groupId = await resolveTargetGroup(actor, undefined).catch(() => undefined);
const maySetSlug = can(actor, "codes:slug", { groupId, editorsMaySetSlug });
const maySetSlug = can(actor, "codes:slug", {
groupId: zielgruppe,
editorsMaySetSlug,
});
return <QrGenerator typeId={state.qrType.id} maySetSlug={maySetSlug} />;
}
+4 -3
View File
@@ -3,15 +3,16 @@ import Link from "next/link";
import { Icon } from "@/components/icons";
import { Badge, Notice } from "@/components/ui";
import { requirePageActor } from "@/lib/auth/guard";
import { getVisibleQrTypes } from "@/lib/qr/availability";
import { getVisibleQrTypesForActor } 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() {
await requirePageActor();
const types = await getVisibleQrTypes();
const actor = await requirePageActor();
// Wer in mehreren Gruppen ist, sieht die Vereinigung ihrer Freigaben.
const types = await getVisibleQrTypesForActor(actor);
const usableCount = types.filter((entry) => entry.usable).length;
return (
+15 -2
View File
@@ -4,6 +4,7 @@ import Link from "next/link";
import { Icon } from "@/components/icons";
import { TypeSettings, type TypeSetting } from "@/components/type-settings";
import { requirePagePermission } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { getQrTypeAvailability } from "@/lib/qr/availability";
export const metadata: Metadata = { title: "QR-Typen" };
@@ -15,16 +16,23 @@ export default async function TypeSettingsPage() {
const availability = await getQrTypeAvailability();
const initial: TypeSetting[] = availability.map(
({ qrType, implemented, enabled }) => ({
({ qrType, implemented, enabled, enabledGlobally, disabledInGroup }) => ({
id: qrType.id,
title: qrType.title,
description: qrType.description,
icon: qrType.icon,
implemented,
enabled,
enabledGlobally,
disabledInGroup,
}),
);
const groups = await prisma.group.findMany({
orderBy: { name: "asc" },
select: { id: true, name: true },
});
return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
<Link
@@ -44,6 +52,11 @@ export default async function TypeSettingsPage() {
Typen sind komplett ausgeblendet und lassen sich nicht mehr neu
anlegen.
</p>
<p className="mt-2 text-slate-600">
Die Grundeinstellung gilt für die ganze Installation. Je Gruppe lässt
sich davon abweichen — was hier abgeschaltet ist, bleibt aber überall
abgeschaltet.
</p>
<p className="mt-2 text-sm text-slate-500">
Bereits gespeicherte dynamische Codes bleiben davon unberührt: sie
leiten weiter und lassen sich weiter bearbeiten. Ein gedruckter Code
@@ -51,7 +64,7 @@ export default async function TypeSettingsPage() {
</p>
</div>
<TypeSettings initial={initial} />
<TypeSettings initial={initial} groups={groups} />
</div>
);
}
+47 -14
View File
@@ -4,9 +4,11 @@ import { z } from "zod";
import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { prisma } from "@/lib/db";
import {
getQrTypeAvailability,
setQrTypeEnabled,
setQrTypeEnabledForGroup,
} from "@/lib/qr/availability";
import { QR_TYPE_IDS } from "@/lib/qr/type-module";
@@ -15,23 +17,45 @@ 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." }),
/**
* Für welche Gruppe der Schalter gilt. Ohne Angabe die Grundeinstellung
* für die ganze Installation.
*/
groupId: z.string().trim().min(1).optional(),
});
/** Zustand aller QR-Typen für die Verwaltung. */
export async function GET(): Promise<Response> {
/**
* Zustand aller QR-Typen für die Verwaltung.
*
* Mit `?group=<id>` die Sicht einer einzelnen Gruppe, sonst die
* Grundeinstellung.
*/
export async function GET(request: NextRequest): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
const types = await getQrTypeAvailability();
const groupId = request.nextUrl.searchParams.get("group") ?? undefined;
if (groupId !== undefined) {
const gruppe = await prisma.group.findUnique({ where: { id: groupId } });
if (!gruppe) return jsonError("Diese Gruppe gibt es nicht.", 404);
}
const types = await getQrTypeAvailability(groupId);
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,
})),
groupId: groupId ?? null,
types: types.map(
({ qrType, implemented, enabled, enabledGlobally, disabledInGroup, usable }) => ({
id: qrType.id,
title: qrType.title,
description: qrType.description,
icon: qrType.icon,
implemented,
enabled,
enabledGlobally,
disabledInGroup,
usable,
}),
),
});
} catch (error) {
return errorResponse(error);
@@ -48,8 +72,17 @@ export async function PATCH(request: NextRequest): Promise<Response> {
return jsonError(firstZodMessage(parsed.error));
}
await setQrTypeEnabled(parsed.data.type, parsed.data.enabled);
return Response.json({ type: parsed.data.type, enabled: parsed.data.enabled });
const { type, enabled, groupId } = parsed.data;
if (groupId === undefined) {
await setQrTypeEnabled(type, enabled);
} else {
const gruppe = await prisma.group.findUnique({ where: { id: groupId } });
if (!gruppe) return jsonError("Diese Gruppe gibt es nicht.", 404);
await setQrTypeEnabledForGroup(type, groupId, enabled);
}
return Response.json({ type, enabled, groupId: groupId ?? null });
} catch (error) {
return errorResponse(error);
}
+2 -1
View File
@@ -100,7 +100,8 @@ export async function POST(request: NextRequest): Promise<Response> {
// ist — und der Slug ist nur je Domain eindeutig.
const domainId = await resolveTargetDomain(actor, groupId, parsed.data.domainId);
const qrType = await requireAvailableQrType(type);
// Die Freigabe haengt an der Zielgruppe — global abgeschaltet gewinnt.
const qrType = await requireAvailableQrType(type, groupId);
const validated = qrType.validate(parsed.data.data);
if (!validated.ok) {
+111 -13
View File
@@ -1,9 +1,9 @@
"use client";
import { useState } from "react";
import { useCallback, useState } from "react";
import { Icon, type IconName } from "@/components/icons";
import { Badge, Card, Notice, cx } from "@/components/ui";
import { Badge, Card, Notice, Select, cx } from "@/components/ui";
import { ApiError, apiJson } from "@/lib/api-client";
import type { QrTypeId } from "@/lib/qr/type-module";
@@ -13,9 +13,19 @@ export type TypeSetting = {
description: string;
icon: IconName;
implemented: boolean;
/** Freigegeben in der gerade gezeigten Sicht (global oder Gruppe). */
enabled: boolean;
/** Die globale Freigabe für sich genommen. */
enabledGlobally: boolean;
/** In der gezeigten Gruppe ausdrücklich abgewählt. */
disabledInGroup: boolean;
};
export type GroupOption = { id: string; name: string };
/** Die Grundeinstellung, wenn keine Gruppe gewählt ist. */
const GLOBAL = "global";
function Toggle({
checked,
disabled,
@@ -50,29 +60,71 @@ function Toggle({
);
}
export function TypeSettings({ initial }: { initial: TypeSetting[] }) {
export function TypeSettings({
initial,
groups = [],
}: {
initial: TypeSetting[];
groups?: GroupOption[];
}) {
const [types, setTypes] = useState(initial);
const [scope, setScope] = useState<string>(GLOBAL);
const [pending, setPending] = useState<QrTypeId | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inGruppe = scope !== GLOBAL;
const gruppenname = groups.find((g) => g.id === scope)?.name ?? "";
const visibleCount = types.filter((entry) => entry.enabled).length;
const usableCount = types.filter(
(entry) => entry.enabled && entry.implemented,
).length;
/** Sicht wechseln — die Zustände unterscheiden sich je Gruppe. */
const wechsle = useCallback(async (next: string) => {
setScope(next);
setLoading(true);
setError(null);
try {
const result = await apiJson<{ types: TypeSetting[] }>(
next === GLOBAL ? "/api/admin/types" : `/api/admin/types?group=${next}`,
);
setTypes(result.types);
} catch (caught) {
setError(
caught instanceof ApiError
? caught.message
: "Die Freigaben konnten nicht geladen werden.",
);
} finally {
setLoading(false);
}
}, []);
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)),
current.map((entry) =>
entry.id === id
? {
...entry,
enabled,
...(inGruppe
? { disabledInGroup: !enabled }
: { enabledGlobally: enabled }),
}
: entry,
),
);
setPending(id);
setError(null);
try {
await apiJson("/api/admin/types", {
method: "PATCH",
json: { type: id, enabled },
json: { type: id, enabled, ...(inGruppe ? { groupId: scope } : {}) },
});
} catch (caught) {
setTypes(before);
@@ -90,6 +142,40 @@ export function TypeSettings({ initial }: { initial: TypeSetting[] }) {
<div className="space-y-4">
{error ? <Notice tone="error">{error}</Notice> : null}
{groups.length > 0 ? (
<div className="flex flex-wrap items-center gap-3">
<label
htmlFor="typen-gruppe"
className="text-sm font-medium text-slate-700"
>
Freigabe für
</label>
<Select
id="typen-gruppe"
className="!w-auto"
value={scope}
disabled={loading}
onChange={(event) => void wechsle(event.target.value)}
>
<option value={GLOBAL}>Alle Gruppen (Grundeinstellung)</option>
{groups.map((group) => (
<option key={group.id} value={group.id}>
Nur: {group.name}
</option>
))}
</Select>
</div>
) : null}
{inGruppe ? (
<Notice tone="info">
Du siehst die Freigabe für <strong>{gruppenname}</strong>. Was hier
abgeschaltet ist, gilt nur für diese Gruppe. Ein Typ, der in der
Grundeinstellung aus ist, lässt sich hier nicht einschalten — die
globale Abschaltung gewinnt immer.
</Notice>
) : null}
{visibleCount === 0 ? (
<Notice tone="warning">
Zurzeit ist kein Typ freigegeben — die Startseite bleibt leer und es
@@ -138,17 +224,28 @@ export function TypeSettings({ initial }: { initial: TypeSetting[] }) {
)}
</div>
<p className="mt-0.5 text-sm text-slate-500">
{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."}
{inGruppe && !entry.enabledGlobally
? "In der Grundeinstellung abgeschaltet — für diese Gruppe nicht einschaltbar."
: entry.enabled
? entry.implemented
? inGruppe
? `Steht ${gruppenname} zur Verfügung.`
: "Erscheint auf der Startseite und kann verwendet werden."
: "Erscheint auf der Startseite als „Bald verfügbar“."
: inGruppe
? `Für ${gruppenname} abgeschaltet — andere Gruppen bleiben unberührt.`
: "Ausgeblendet — erscheint nicht auf der Startseite."}
</p>
</div>
<Toggle
checked={entry.enabled}
disabled={pending === entry.id}
disabled={
pending === entry.id ||
loading ||
// Global aus gewinnt immer — der Schalter steht dann still.
(inGruppe && !entry.enabledGlobally)
}
label={`${entry.title} ${entry.enabled ? "ausblenden" : "freigeben"}`}
onChange={(next) => void toggle(entry.id, next)}
/>
@@ -158,8 +255,9 @@ export function TypeSettings({ initial }: { initial: TypeSetting[] }) {
</Card>
<p className="text-sm text-slate-500">
{usableCount} von {types.length} Typen nutzbar, {visibleCount} auf der
Startseite sichtbar.
{inGruppe
? `${usableCount} von ${types.length} Typen stehen ${gruppenname} zur Verfügung.`
: `${usableCount} von ${types.length} Typen nutzbar, ${visibleCount} auf der Startseite sichtbar.`}
</p>
</div>
);
+141 -10
View File
@@ -1,5 +1,7 @@
import "server-only";
import type { Actor } from "@/lib/auth/actor";
import { visibleGroupIds } from "@/lib/auth/policy";
import { prisma } from "@/lib/db";
import { getQrType, qrTypeList } from "@/lib/qr/registry";
import {
@@ -19,6 +21,16 @@ import {
* - `enabled` — kommt aus der Datenbank und sagt, ob die Verwaltung den Typ
* freigegeben hat. Fehlt der Eintrag, ist der Typ freigegeben.
*
* Die Freigabe hat zwei Ebenen. **Global** gilt für die ganze Installation
* und ist die Grundlage; **je Gruppe** lässt sich davon abweichen, damit die
* Marketingabteilung Social Media bekommt und die Werkstatt nur Website und
* PDF. Fehlt eine Abweichung, folgt die Gruppe der globalen Einstellung.
*
* **Die globale Abschaltung gewinnt immer.** Ein global abgeschalteter Typ
* lässt sich in keiner Gruppe wieder einschalten. Andersherum wäre „hier
* abgeschaltet“ keine verlässliche Aussage mehr, und wer den Schalter umlegt,
* müsste alle Gruppen durchsehen, um zu wissen, was er getan hat.
*
* 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
@@ -29,8 +41,15 @@ export type QrTypeAvailability = {
qrType: QrTypeModule;
/** Der Typ ist in dieser Version ausprogrammiert. */
implemented: boolean;
/** Die Verwaltung hat den Typ freigegeben. */
/** Die Verwaltung hat den Typ freigegeben — global und in der Gruppe. */
enabled: boolean;
/** Die globale Freigabe für sich genommen, ohne die Gruppe. */
enabledGlobally: boolean;
/**
* Nur bei einer Abfrage für eine bestimmte Gruppe gesetzt: Ist der Typ dort
* ausdrücklich abgewählt? Ohne Abweichung `false`.
*/
disabledInGroup: boolean;
/** Freigegeben und ausprogrammiert — nur dann ist er benutzbar. */
usable: boolean;
/** Freigegeben, aber noch nicht ausprogrammiert — „Bald verfügbar“. */
@@ -56,16 +75,52 @@ async function loadDisabledTypes(): Promise<ReadonlySet<string>> {
}
}
/** Verfügbarkeit aller Typen in Anzeigereihenfolge. */
export async function getQrTypeAvailability(): Promise<QrTypeAvailability[]> {
/** Abweichungen einer Gruppe; leer, wenn sie der globalen Einstellung folgt. */
async function loadGroupDisabledTypes(
groupId: string,
): Promise<ReadonlySet<string>> {
try {
const rows = await prisma.qrTypeGroupSetting.findMany({
where: { groupId, enabled: false },
select: { type: true },
});
return new Set(rows.map((row) => row.type));
} catch (error) {
// Wie oben: im Zweifel lieber alles zeigen als eine leere Startseite.
console.error(
"Freigaben der QR-Typen je Gruppe konnten nicht geladen werden — es gilt die globale Einstellung:",
error,
);
return new Set();
}
}
/**
* Verfügbarkeit aller Typen in Anzeigereihenfolge.
*
* Ohne `groupId` gilt allein die globale Freigabe — das ist die Sicht der
* Verwaltung auf die Grundeinstellung.
*/
export async function getQrTypeAvailability(
groupId?: string,
): Promise<QrTypeAvailability[]> {
const disabled = await loadDisabledTypes();
const inGruppeAbgewaehlt =
groupId === undefined
? new Set<string>()
: await loadGroupDisabledTypes(groupId);
return qrTypeList.map((qrType) => {
const enabled = !disabled.has(qrType.id);
const global = !disabled.has(qrType.id);
const inGruppe = inGruppeAbgewaehlt.has(qrType.id);
// Global aus gewinnt immer — eine Gruppe kann das nicht aufheben.
const enabled = global && !inGruppe;
return {
qrType,
implemented: qrType.implemented,
enabled,
enabledGlobally: global,
disabledInGroup: inGruppe,
usable: enabled && qrType.implemented,
announced: enabled && !qrType.implemented,
};
@@ -73,15 +128,61 @@ export async function getQrTypeAvailability(): Promise<QrTypeAvailability[]> {
}
/** 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 getVisibleQrTypes(
groupId?: string,
): Promise<QrTypeAvailability[]> {
return (await getQrTypeAvailability(groupId)).filter((entry) => entry.enabled);
}
/**
* Was jemand auf der Startseite sehen soll: die **Vereinigung** über seine
* Gruppen.
*
* Wer in der Werkstatt und im Marketing ist, sieht beides. Welche Gruppe
* beim Anlegen gilt, entscheidet sich erst dort — und wird dort auch geprüft.
*/
export async function getVisibleQrTypesForActor(
actor: Actor,
): Promise<QrTypeAvailability[]> {
const eigene = visibleGroupIds(actor);
// `null` heißt: alle Gruppen. Ohne Gruppen (oder ohne Anmeldung) bleibt es
// bei der globalen Einstellung.
const gruppen =
eigene === null
? (await prisma.group.findMany({ select: { id: true } })).map((g) => g.id)
: eigene;
if (gruppen.length === 0) return getVisibleQrTypes();
const je = await Promise.all(gruppen.map((id) => getVisibleQrTypes(id)));
const gesehen = new Set<string>();
const vereinigt: QrTypeAvailability[] = [];
// Über qrTypeList laufen statt über die Ergebnisse: so bleibt die
// Anzeigereihenfolge der Registry erhalten.
for (const qrType of qrTypeList) {
for (const liste of je) {
if (gesehen.has(qrType.id)) break;
const treffer = liste.find((entry) => entry.qrType.id === qrType.id);
if (treffer) {
gesehen.add(qrType.id);
vereinigt.push(treffer);
}
}
}
return vereinigt;
}
export async function getQrTypeState(
id: string,
groupId?: string,
): Promise<QrTypeAvailability | undefined> {
if (!isQrTypeId(id)) return undefined;
return (await getQrTypeAvailability()).find((entry) => entry.qrType.id === id);
return (await getQrTypeAvailability(groupId)).find(
(entry) => entry.qrType.id === id,
);
}
/**
@@ -90,8 +191,9 @@ export async function getQrTypeState(
*/
export async function requireAvailableQrType(
id: string,
groupId?: string,
): Promise<QrTypeModule> {
const state = await getQrTypeState(id);
const state = await getQrTypeState(id, groupId);
if (!state) {
throw new QrRequestError("Unbekannter QR-Typ.", 404);
@@ -104,7 +206,9 @@ export async function requireAvailableQrType(
}
if (!state.enabled) {
throw new QrRequestError(
`Der QR-Typ „${state.qrType.title}“ ist in der Verwaltung abgeschaltet.`,
state.enabledGlobally
? `Der QR-Typ „${state.qrType.title}“ ist für diese Gruppe abgeschaltet.`
: `Der QR-Typ „${state.qrType.title}“ ist in der Verwaltung abgeschaltet.`,
403,
);
}
@@ -128,7 +232,7 @@ export function requireImplementedQrType(id: string): QrTypeModule {
return qrType;
}
/** Freigabe eines Typs setzen. */
/** Globale Freigabe eines Typs setzen. */
export async function setQrTypeEnabled(
id: QrTypeId,
enabled: boolean,
@@ -139,3 +243,30 @@ export async function setQrTypeEnabled(
update: { enabled },
});
}
/**
* Freigabe eines Typs für eine Gruppe setzen.
*
* `true` löscht die Abweichung, statt sie auf „an“ zu setzen: Die Gruppe
* folgt dann wieder der globalen Einstellung. Eine Zeile „an“ zu hinterlassen
* würde vortäuschen, sie könnte eine globale Abschaltung überstimmen — das
* kann sie nicht.
*/
export async function setQrTypeEnabledForGroup(
id: QrTypeId,
groupId: string,
enabled: boolean,
): Promise<void> {
if (enabled) {
await prisma.qrTypeGroupSetting.deleteMany({
where: { type: id, groupId },
});
return;
}
await prisma.qrTypeGroupSetting.upsert({
where: { type_groupId: { type: id, groupId } },
create: { type: id, groupId, enabled: false },
update: { enabled: false },
});
}