Merge #11: QR-Typen je Gruppe freigeben

This commit was merged in pull request #17.
This commit is contained in:
2026-08-21 09:00:09 +00:00
11 changed files with 423 additions and 50 deletions
+22 -2
View File
@@ -615,6 +615,26 @@ Zwei Eigenschaften sind dabei zu unterscheiden:
Nur umgesetzt **und** freigegeben heißt benutzbar. Freigegeben, aber nicht Nur umgesetzt **und** freigegeben heißt benutzbar. Freigegeben, aber nicht
umgesetzt, erscheint als Kachel „Bald verfügbar“. umgesetzt, erscheint als Kachel „Bald verfügbar“.
#### Freigabe je Gruppe
Die Auswahl **Freigabe für** über der Liste hat zwei Stufen:
- **Alle Gruppen (Grundeinstellung)** — gilt für die ganze Installation.
- **Nur: \<Gruppe\>** — weicht für diese eine Gruppe davon ab. Die
Marketingabteilung bekommt Social Media, die Werkstatt nur Website und PDF.
Fehlt eine Abweichung, folgt die Gruppe der Grundeinstellung. **Die globale
Abschaltung gewinnt immer:** Ein Typ, der in der Grundeinstellung aus ist,
lässt sich in keiner Gruppe einschalten — der Schalter steht dort sichtbar
still und sagt warum. 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.
Auf der Startseite sieht man die **Vereinigung** über die eigenen Gruppen. Wer
in Werkstatt und Marketing ist, sieht beides. Welche Freigabe gilt, entscheidet
sich erst beim Anlegen — dort zählt die gewählte Gruppe, und die Route prüft
das noch einmal.
> **Bereits gespeicherte Codes bleiben unberührt.** Schaltet man einen Typ ab, > **Bereits gespeicherte Codes bleiben unberührt.** Schaltet man einen Typ ab,
> leiten vorhandene dynamische Codes weiter wie bisher und lassen sich weiter > leiten vorhandene dynamische Codes weiter wie bisher und lassen sich weiter
> bearbeiten. Ein gedruckter Code wird durch das Abschalten seines Typs also > bearbeiten. Ein gedruckter Code wird durch das Abschalten seines Typs also
@@ -995,8 +1015,8 @@ deutschen Meldung.
| `DELETE` | `/api/codes/{id}` | Code löschen | | `DELETE` | `/api/codes/{id}` | Code löschen |
| `GET` | `/api/codes/{id}/qr` | Bild des Codes (`?format=png\|svg&download=1`) | | `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 | | `GET` | `/api/admin/types` | Zustand aller QR-Typen (`?group={id}` je Gruppe) |
| `PATCH` | `/api/admin/types` | Typ freigeben oder abschalten | | `PATCH` | `/api/admin/types` | Typ freigeben oder abschalten (`groupId` optional) |
Beispiel: Beispiel:
+15 -1
View File
@@ -500,7 +500,7 @@ auch hier ausgeschlossen, aus demselben Grund wie bei den übrigen Uploads.
| --- | --- | | --- | --- |
| **Einstellungen** | was Bearbeiter dürfen — etwa eigene Kurz-Links | | **Einstellungen** | was Bearbeiter dürfen — etwa eigene Kurz-Links |
| **Erscheinungsbild** | Name, Logo, Favicon, Akzentfarbe | | **Erscheinungsbild** | Name, Logo, Favicon, Akzentfarbe |
| **QR-Typen** | einzelne Typen ausblenden | | **QR-Typen** | einzelne Typen ausblenden — für alle oder je Gruppe |
| **Benutzer** | anlegen, deaktivieren, Passwort setzen, Instanz-Admin vergeben | | **Benutzer** | anlegen, deaktivieren, Passwort setzen, Instanz-Admin vergeben |
| **Gruppen** | anlegen, umbenennen, Mitglieder und deren Rollen pflegen | | **Gruppen** | anlegen, umbenennen, Mitglieder und deren Rollen pflegen |
| **Domains** | anlegen, Gruppen zuteilen, abschalten | | **Domains** | anlegen, Gruppen zuteilen, abschalten |
@@ -512,6 +512,20 @@ lassen sich bearbeiten. Nur neue lassen sich nicht mehr anlegen. Ohne Eintrag
in der Datenbank gilt ein Typ als freigegeben, damit eine frische Installation in der Datenbank gilt ein Typ als freigegeben, damit eine frische Installation
alles zeigt und Typen aus späteren Versionen automatisch erscheinen. alles zeigt und Typen aus späteren Versionen automatisch erscheinen.
Die Freigabe hat **zwei Ebenen**. Die Grundeinstellung gilt für die ganze
Installation; je Gruppe lässt sich davon abweichen — die Marketingabteilung
bekommt Social Media, die Werkstatt nur Website und PDF. Fehlt eine Abweichung,
folgt die Gruppe der Grundeinstellung.
**Die globale Abschaltung gewinnt immer.** Ein Typ, der in der
Grundeinstellung aus ist, lässt sich in keiner Gruppe einschalten; der
Schalter steht dort sichtbar still und nennt den Grund. Andersherum wäre „hier
abgeschaltet“ keine verlässliche Aussage mehr.
Auf der Startseite sieht man die **Vereinigung** über die eigenen Gruppen. Wer
in beiden Gruppen ist, sieht beides; welche Freigabe zählt, entscheidet sich
beim Anlegen anhand der gewählten Gruppe.
--- ---
## Betrieb ## Betrieb
@@ -0,0 +1,25 @@
-- QR-Typen lassen sich je Gruppe abschalten.
--
-- Die globale Freigabe in "QrTypeSetting" bleibt die Grundlage. Diese Tabelle
-- traegt nur Abweichungen davon: Fehlt eine Zeile, folgt die Gruppe der
-- globalen Einstellung. Eine globale Abschaltung laesst sich hierueber nicht
-- aufheben — global aus gewinnt immer.
--
-- 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.
CREATE TABLE "QrTypeGroupSetting" (
"type" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "QrTypeGroupSetting_pkey" PRIMARY KEY ("type","groupId")
);
CREATE INDEX "QrTypeGroupSetting_groupId_idx" ON "QrTypeGroupSetting"("groupId");
-- Mit der Gruppe verschwindet auch ihre Abweichung.
ALTER TABLE "QrTypeGroupSetting" ADD CONSTRAINT "QrTypeGroupSetting_groupId_fkey"
FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+26
View File
@@ -87,6 +87,7 @@ model Group {
memberships Membership[] memberships Membership[]
codes QrCode[] codes QrCode[]
domainGrants DomainGrant[] domainGrants DomainGrant[]
typeSettings QrTypeGroupSetting[]
} }
/// Mitgliedschaft eines Benutzers in einer Gruppe, mit Rolle. Ein Benutzer /// Mitgliedschaft eines Benutzers in einer Gruppe, mit Rolle. Ein Benutzer
@@ -155,6 +156,31 @@ model QrTypeSetting {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
/// Abweichung von der globalen Freigabe fuer eine einzelne Gruppe.
///
/// Bewusst 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.
///
/// Fehlt eine Zeile, folgt die Gruppe der globalen Einstellung. Eine Zeile
/// mit `enabled = false` schaltet den Typ **nur** in dieser Gruppe ab. Eine
/// Zeile mit `enabled = true` kann eine globale Abschaltung **nicht**
/// aufheben — global aus gewinnt immer.
model QrTypeGroupSetting {
/// Kennung aus der Typ-Registry, z. B. "website".
type String
groupId String
enabled Boolean
updatedAt DateTime @updatedAt
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
@@id([type, groupId])
@@index([groupId])
}
/// Ein dynamischer QR-Code. Der QR-Code kodiert `{BASE_URL}/r/{slug}` und /// 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. /// leitet beim Aufruf auf das Ziel weiter, das aus `data` aufgelöst wird.
/// ///
+15 -4
View File
@@ -53,7 +53,12 @@ function Hinweis({
export default async function CreatePage({ params }: Props) { export default async function CreatePage({ params }: Props) {
const actor = await requirePageActor(); const actor = await requirePageActor();
const { type } = await params; 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) { if (!state) {
notFound(); notFound();
@@ -64,7 +69,11 @@ export default async function CreatePage({ params }: Props) {
<Hinweis <Hinweis
icon="lock" icon="lock"
titel={`„${state.qrType.title}“ ist abgeschaltet`} 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."
}
/> />
); );
} }
@@ -83,8 +92,10 @@ export default async function CreatePage({ params }: Props) {
// Einstellung. Das Feld erscheint nur dann — die Route prueft es ohnehin // Einstellung. Das Feld erscheint nur dann — die Route prueft es ohnehin
// noch einmal. // noch einmal.
const { editorsMaySetSlug } = await getSettings(); const { editorsMaySetSlug } = await getSettings();
const groupId = await resolveTargetGroup(actor, undefined).catch(() => undefined); const maySetSlug = can(actor, "codes:slug", {
const maySetSlug = can(actor, "codes:slug", { groupId, editorsMaySetSlug }); groupId: zielgruppe,
editorsMaySetSlug,
});
// Ohne hinterlegtes Logo entfaellt die Wahl der Herkunft — dann bleibt nur // Ohne hinterlegtes Logo entfaellt die Wahl der Herkunft — dann bleibt nur
// der eigene Upload. // der eigene Upload.
+4 -3
View File
@@ -3,15 +3,16 @@ import Link from "next/link";
import { Icon } from "@/components/icons"; import { Icon } from "@/components/icons";
import { Badge, Notice } from "@/components/ui"; import { Badge, Notice } from "@/components/ui";
import { requirePageActor } from "@/lib/auth/guard"; 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 // Welche Typen sichtbar sind, steht in der Datenbank und ist jederzeit
// änderbar — die Startseite darf deshalb nicht vorgerendert werden. // änderbar — die Startseite darf deshalb nicht vorgerendert werden.
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function HomePage() { export default async function HomePage() {
await requirePageActor(); const actor = await requirePageActor();
const types = await getVisibleQrTypes(); // Wer in mehreren Gruppen ist, sieht die Vereinigung ihrer Freigaben.
const types = await getVisibleQrTypesForActor(actor);
const usableCount = types.filter((entry) => entry.usable).length; const usableCount = types.filter((entry) => entry.usable).length;
return ( return (
+15 -2
View File
@@ -4,6 +4,7 @@ import Link from "next/link";
import { Icon } from "@/components/icons"; import { Icon } from "@/components/icons";
import { TypeSettings, type TypeSetting } from "@/components/type-settings"; import { TypeSettings, type TypeSetting } from "@/components/type-settings";
import { requirePagePermission } from "@/lib/auth/guard"; import { requirePagePermission } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { getQrTypeAvailability } from "@/lib/qr/availability"; import { getQrTypeAvailability } from "@/lib/qr/availability";
export const metadata: Metadata = { title: "QR-Typen" }; export const metadata: Metadata = { title: "QR-Typen" };
@@ -15,16 +16,23 @@ export default async function TypeSettingsPage() {
const availability = await getQrTypeAvailability(); const availability = await getQrTypeAvailability();
const initial: TypeSetting[] = availability.map( const initial: TypeSetting[] = availability.map(
({ qrType, implemented, enabled }) => ({ ({ qrType, implemented, enabled, enabledGlobally, disabledInGroup }) => ({
id: qrType.id, id: qrType.id,
title: qrType.title, title: qrType.title,
description: qrType.description, description: qrType.description,
icon: qrType.icon, icon: qrType.icon,
implemented, implemented,
enabled, enabled,
enabledGlobally,
disabledInGroup,
}), }),
); );
const groups = await prisma.group.findMany({
orderBy: { name: "asc" },
select: { id: true, name: true },
});
return ( return (
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10"> <div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
<Link <Link
@@ -44,6 +52,11 @@ export default async function TypeSettingsPage() {
Typen sind komplett ausgeblendet und lassen sich nicht mehr neu Typen sind komplett ausgeblendet und lassen sich nicht mehr neu
anlegen. anlegen.
</p> </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"> <p className="mt-2 text-sm text-slate-500">
Bereits gespeicherte dynamische Codes bleiben davon unberührt: sie Bereits gespeicherte dynamische Codes bleiben davon unberührt: sie
leiten weiter und lassen sich weiter bearbeiten. Ein gedruckter Code leiten weiter und lassen sich weiter bearbeiten. Ein gedruckter Code
@@ -51,7 +64,7 @@ export default async function TypeSettingsPage() {
</p> </p>
</div> </div>
<TypeSettings initial={initial} /> <TypeSettings initial={initial} groups={groups} />
</div> </div>
); );
} }
+47 -14
View File
@@ -4,9 +4,11 @@ import { z } from "zod";
import { getActor } from "@/lib/auth/actor"; import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy"; import { requirePermission } from "@/lib/auth/policy";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { prisma } from "@/lib/db";
import { import {
getQrTypeAvailability, getQrTypeAvailability,
setQrTypeEnabled, setQrTypeEnabled,
setQrTypeEnabledForGroup,
} from "@/lib/qr/availability"; } from "@/lib/qr/availability";
import { QR_TYPE_IDS } from "@/lib/qr/type-module"; import { QR_TYPE_IDS } from "@/lib/qr/type-module";
@@ -15,23 +17,45 @@ export const dynamic = "force-dynamic";
const updateSchema = z.object({ const updateSchema = z.object({
type: z.enum(QR_TYPE_IDS, { error: "Unbekannter QR-Typ." }), type: z.enum(QR_TYPE_IDS, { error: "Unbekannter QR-Typ." }),
enabled: z.boolean({ error: "Der Schalter muss wahr oder falsch sein." }), 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 { try {
requirePermission(await getActor(), "instance:manage"); 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({ return Response.json({
types: types.map(({ qrType, implemented, enabled, usable }) => ({ groupId: groupId ?? null,
id: qrType.id, types: types.map(
title: qrType.title, ({ qrType, implemented, enabled, enabledGlobally, disabledInGroup, usable }) => ({
description: qrType.description, id: qrType.id,
icon: qrType.icon, title: qrType.title,
implemented, description: qrType.description,
enabled, icon: qrType.icon,
usable, implemented,
})), enabled,
enabledGlobally,
disabledInGroup,
usable,
}),
),
}); });
} catch (error) { } catch (error) {
return errorResponse(error); return errorResponse(error);
@@ -48,8 +72,17 @@ export async function PATCH(request: NextRequest): Promise<Response> {
return jsonError(firstZodMessage(parsed.error)); return jsonError(firstZodMessage(parsed.error));
} }
await setQrTypeEnabled(parsed.data.type, parsed.data.enabled); const { type, enabled, groupId } = parsed.data;
return Response.json({ type: parsed.data.type, enabled: parsed.data.enabled });
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) { } catch (error) {
return errorResponse(error); return errorResponse(error);
} }
+2 -1
View File
@@ -125,7 +125,8 @@ export async function POST(request: NextRequest): Promise<Response> {
// ist — und der Slug ist nur je Domain eindeutig. // ist — und der Slug ist nur je Domain eindeutig.
const domainId = await resolveTargetDomain(actor, groupId, parsed.data.domainId); 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); const validated = qrType.validate(parsed.data.data);
if (!validated.ok) { if (!validated.ok) {
+111 -13
View File
@@ -1,9 +1,9 @@
"use client"; "use client";
import { useState } from "react"; import { useCallback, useState } from "react";
import { Icon, type IconName } from "@/components/icons"; 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 { ApiError, apiJson } from "@/lib/api-client";
import type { QrTypeId } from "@/lib/qr/type-module"; import type { QrTypeId } from "@/lib/qr/type-module";
@@ -13,9 +13,19 @@ export type TypeSetting = {
description: string; description: string;
icon: IconName; icon: IconName;
implemented: boolean; implemented: boolean;
/** Freigegeben in der gerade gezeigten Sicht (global oder Gruppe). */
enabled: boolean; 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({ function Toggle({
checked, checked,
disabled, 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 [types, setTypes] = useState(initial);
const [scope, setScope] = useState<string>(GLOBAL);
const [pending, setPending] = useState<QrTypeId | null>(null); const [pending, setPending] = useState<QrTypeId | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); 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 visibleCount = types.filter((entry) => entry.enabled).length;
const usableCount = types.filter( const usableCount = types.filter(
(entry) => entry.enabled && entry.implemented, (entry) => entry.enabled && entry.implemented,
).length; ).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 toggle = async (id: QrTypeId, enabled: boolean) => {
const before = types; const before = types;
// Sofort umschalten und bei einem Fehler zurückdrehen — der Schalter // Sofort umschalten und bei einem Fehler zurückdrehen — der Schalter
// soll sich nicht zäh anfühlen. // soll sich nicht zäh anfühlen.
setTypes((current) => 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); setPending(id);
setError(null); setError(null);
try { try {
await apiJson("/api/admin/types", { await apiJson("/api/admin/types", {
method: "PATCH", method: "PATCH",
json: { type: id, enabled }, json: { type: id, enabled, ...(inGruppe ? { groupId: scope } : {}) },
}); });
} catch (caught) { } catch (caught) {
setTypes(before); setTypes(before);
@@ -90,6 +142,40 @@ export function TypeSettings({ initial }: { initial: TypeSetting[] }) {
<div className="space-y-4"> <div className="space-y-4">
{error ? <Notice tone="error">{error}</Notice> : null} {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 ? ( {visibleCount === 0 ? (
<Notice tone="warning"> <Notice tone="warning">
Zurzeit ist kein Typ freigegeben — die Startseite bleibt leer und es Zurzeit ist kein Typ freigegeben — die Startseite bleibt leer und es
@@ -138,17 +224,28 @@ export function TypeSettings({ initial }: { initial: TypeSetting[] }) {
)} )}
</div> </div>
<p className="mt-0.5 text-sm text-slate-500"> <p className="mt-0.5 text-sm text-slate-500">
{entry.enabled {inGruppe && !entry.enabledGlobally
? entry.implemented ? "In der Grundeinstellung abgeschaltet — für diese Gruppe nicht einschaltbar."
? "Erscheint auf der Startseite und kann verwendet werden." : entry.enabled
: "Erscheint auf der Startseite als „Bald verfügbar“." ? entry.implemented
: "Ausgeblendet — erscheint nicht auf der Startseite."} ? 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> </p>
</div> </div>
<Toggle <Toggle
checked={entry.enabled} 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"}`} label={`${entry.title} ${entry.enabled ? "ausblenden" : "freigeben"}`}
onChange={(next) => void toggle(entry.id, next)} onChange={(next) => void toggle(entry.id, next)}
/> />
@@ -158,8 +255,9 @@ export function TypeSettings({ initial }: { initial: TypeSetting[] }) {
</Card> </Card>
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500">
{usableCount} von {types.length} Typen nutzbar, {visibleCount} auf der {inGruppe
Startseite sichtbar. ? `${usableCount} von ${types.length} Typen stehen ${gruppenname} zur Verfügung.`
: `${usableCount} von ${types.length} Typen nutzbar, ${visibleCount} auf der Startseite sichtbar.`}
</p> </p>
</div> </div>
); );
+141 -10
View File
@@ -1,5 +1,7 @@
import "server-only"; import "server-only";
import type { Actor } from "@/lib/auth/actor";
import { visibleGroupIds } from "@/lib/auth/policy";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { getQrType, qrTypeList } from "@/lib/qr/registry"; import { getQrType, qrTypeList } from "@/lib/qr/registry";
import { import {
@@ -19,6 +21,16 @@ import {
* - `enabled` — kommt aus der Datenbank und sagt, ob die Verwaltung den Typ * - `enabled` — kommt aus der Datenbank und sagt, ob die Verwaltung den Typ
* freigegeben hat. Fehlt der Eintrag, ist der Typ freigegeben. * 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 * Ein abgeschalteter Typ verschwindet aus der Oberfläche und lässt sich nicht
* mehr neu anlegen. **Bereits bestehende Codes dieses Typs funktionieren * mehr neu anlegen. **Bereits bestehende Codes dieses Typs funktionieren
* weiter** — sie sind gedruckt und verteilt, und ein Schalter in der * weiter** — sie sind gedruckt und verteilt, und ein Schalter in der
@@ -29,8 +41,15 @@ export type QrTypeAvailability = {
qrType: QrTypeModule; qrType: QrTypeModule;
/** Der Typ ist in dieser Version ausprogrammiert. */ /** Der Typ ist in dieser Version ausprogrammiert. */
implemented: boolean; implemented: boolean;
/** Die Verwaltung hat den Typ freigegeben. */ /** Die Verwaltung hat den Typ freigegeben — global und in der Gruppe. */
enabled: boolean; 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. */ /** Freigegeben und ausprogrammiert — nur dann ist er benutzbar. */
usable: boolean; usable: boolean;
/** Freigegeben, aber noch nicht ausprogrammiert — „Bald verfügbar“. */ /** Freigegeben, aber noch nicht ausprogrammiert — „Bald verfügbar“. */
@@ -56,16 +75,52 @@ async function loadDisabledTypes(): Promise<ReadonlySet<string>> {
} }
} }
/** Verfügbarkeit aller Typen in Anzeigereihenfolge. */ /** Abweichungen einer Gruppe; leer, wenn sie der globalen Einstellung folgt. */
export async function getQrTypeAvailability(): Promise<QrTypeAvailability[]> { 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 disabled = await loadDisabledTypes();
const inGruppeAbgewaehlt =
groupId === undefined
? new Set<string>()
: await loadGroupDisabledTypes(groupId);
return qrTypeList.map((qrType) => { 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 { return {
qrType, qrType,
implemented: qrType.implemented, implemented: qrType.implemented,
enabled, enabled,
enabledGlobally: global,
disabledInGroup: inGruppe,
usable: enabled && qrType.implemented, usable: enabled && qrType.implemented,
announced: 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. */ /** Nur die Typen, die in der Oberfläche erscheinen sollen. */
export async function getVisibleQrTypes(): Promise<QrTypeAvailability[]> { export async function getVisibleQrTypes(
return (await getQrTypeAvailability()).filter((entry) => entry.enabled); 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( export async function getQrTypeState(
id: string, id: string,
groupId?: string,
): Promise<QrTypeAvailability | undefined> { ): Promise<QrTypeAvailability | undefined> {
if (!isQrTypeId(id)) return 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( export async function requireAvailableQrType(
id: string, id: string,
groupId?: string,
): Promise<QrTypeModule> { ): Promise<QrTypeModule> {
const state = await getQrTypeState(id); const state = await getQrTypeState(id, groupId);
if (!state) { if (!state) {
throw new QrRequestError("Unbekannter QR-Typ.", 404); throw new QrRequestError("Unbekannter QR-Typ.", 404);
@@ -104,7 +206,9 @@ export async function requireAvailableQrType(
} }
if (!state.enabled) { if (!state.enabled) {
throw new QrRequestError( 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, 403,
); );
} }
@@ -128,7 +232,7 @@ export function requireImplementedQrType(id: string): QrTypeModule {
return qrType; return qrType;
} }
/** Freigabe eines Typs setzen. */ /** Globale Freigabe eines Typs setzen. */
export async function setQrTypeEnabled( export async function setQrTypeEnabled(
id: QrTypeId, id: QrTypeId,
enabled: boolean, enabled: boolean,
@@ -139,3 +243,30 @@ export async function setQrTypeEnabled(
update: { enabled }, 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 },
});
}