From 3cb5421a20fd5889dd93bbb689ae94197f01db8e Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Fri, 21 Aug 2026 03:48:22 +0200 Subject: [PATCH] QR-Typen je Gruppe freigeben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 24 ++- docs/funktionen.md | 16 +- .../migration.sql | 25 +++ prisma/schema.prisma | 26 +++ src/app/(app)/erstellen/[type]/page.tsx | 19 ++- src/app/(app)/page.tsx | 7 +- src/app/(app)/verwaltung/typen/page.tsx | 17 +- src/app/api/admin/types/route.ts | 61 +++++-- src/app/api/codes/route.ts | 3 +- src/components/type-settings.tsx | 124 ++++++++++++-- src/lib/qr/availability.ts | 151 ++++++++++++++++-- 11 files changed, 423 insertions(+), 50 deletions(-) create mode 100644 prisma/migrations/20260821030000_typen_je_gruppe/migration.sql diff --git a/README.md b/README.md index 86826ca..4f3c1d5 100644 --- a/README.md +++ b/README.md @@ -589,6 +589,26 @@ Zwei Eigenschaften sind dabei zu unterscheiden: Nur umgesetzt **und** freigegeben heißt benutzbar. Freigegeben, aber nicht 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: \** — 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, > leiten vorhandene dynamische Codes weiter wie bisher und lassen sich weiter > bearbeiten. Ein gedruckter Code wird durch das Abschalten seines Typs also @@ -964,8 +984,8 @@ deutschen Meldung. | `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` | `/api/admin/types` | Zustand aller QR-Typen | -| `PATCH` | `/api/admin/types` | Typ freigeben oder abschalten | +| `GET` | `/api/admin/types` | Zustand aller QR-Typen (`?group={id}` je Gruppe) | +| `PATCH` | `/api/admin/types` | Typ freigeben oder abschalten (`groupId` optional) | Beispiel: diff --git a/docs/funktionen.md b/docs/funktionen.md index 1662174..b20af91 100644 --- a/docs/funktionen.md +++ b/docs/funktionen.md @@ -466,7 +466,7 @@ auch hier ausgeschlossen, aus demselben Grund wie bei den übrigen Uploads. | --- | --- | | **Einstellungen** | was Bearbeiter dürfen — etwa eigene Kurz-Links | | **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 | | **Gruppen** | anlegen, umbenennen, Mitglieder und deren Rollen pflegen | | **Domains** | anlegen, Gruppen zuteilen, abschalten | @@ -478,6 +478,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 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 diff --git a/prisma/migrations/20260821030000_typen_je_gruppe/migration.sql b/prisma/migrations/20260821030000_typen_je_gruppe/migration.sql new file mode 100644 index 0000000..9e18e4d --- /dev/null +++ b/prisma/migrations/20260821030000_typen_je_gruppe/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 898c2c5..4c5ef39 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -87,6 +87,7 @@ model Group { memberships Membership[] codes QrCode[] domainGrants DomainGrant[] + typeSettings QrTypeGroupSetting[] } /// Mitgliedschaft eines Benutzers in einer Gruppe, mit Rolle. Ein Benutzer @@ -155,6 +156,31 @@ model QrTypeSetting { 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 /// leitet beim Aufruf auf das Ziel weiter, das aus `data` aufgelöst wird. /// diff --git a/src/app/(app)/erstellen/[type]/page.tsx b/src/app/(app)/erstellen/[type]/page.tsx index 41df18a..2710a0e 100644 --- a/src/app/(app)/erstellen/[type]/page.tsx +++ b/src/app/(app)/erstellen/[type]/page.tsx @@ -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) { ); } @@ -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 ; } diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx index 17d55e4..915ab1e 100644 --- a/src/app/(app)/page.tsx +++ b/src/app/(app)/page.tsx @@ -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 ( diff --git a/src/app/(app)/verwaltung/typen/page.tsx b/src/app/(app)/verwaltung/typen/page.tsx index b2ee4d0..88d4ec7 100644 --- a/src/app/(app)/verwaltung/typen/page.tsx +++ b/src/app/(app)/verwaltung/typen/page.tsx @@ -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 (
+

+ Die Grundeinstellung gilt für die ganze Installation. Je Gruppe lässt + sich davon abweichen — was hier abgeschaltet ist, bleibt aber überall + abgeschaltet. +

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() {

- + ); } diff --git a/src/app/api/admin/types/route.ts b/src/app/api/admin/types/route.ts index ed54ec7..b8d2311 100644 --- a/src/app/api/admin/types/route.ts +++ b/src/app/api/admin/types/route.ts @@ -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 { +/** + * Zustand aller QR-Typen für die Verwaltung. + * + * Mit `?group=` die Sicht einer einzelnen Gruppe, sonst die + * Grundeinstellung. + */ +export async function GET(request: NextRequest): Promise { 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 { 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); } diff --git a/src/app/api/codes/route.ts b/src/app/api/codes/route.ts index 239279e..926f53c 100644 --- a/src/app/api/codes/route.ts +++ b/src/app/api/codes/route.ts @@ -100,7 +100,8 @@ export async function POST(request: NextRequest): Promise { // 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) { diff --git a/src/components/type-settings.tsx b/src/components/type-settings.tsx index 6ae043c..e1cbe96 100644 --- a/src/components/type-settings.tsx +++ b/src/components/type-settings.tsx @@ -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(GLOBAL); const [pending, setPending] = useState(null); + const [loading, setLoading] = useState(false); const [error, setError] = useState(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[] }) {
{error ? {error} : null} + {groups.length > 0 ? ( +
+ + +
+ ) : null} + + {inGruppe ? ( + + Du siehst die Freigabe für {gruppenname}. 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. + + ) : null} + {visibleCount === 0 ? ( Zurzeit ist kein Typ freigegeben — die Startseite bleibt leer und es @@ -138,17 +224,28 @@ export function TypeSettings({ initial }: { initial: TypeSetting[] }) { )}

- {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."}

void toggle(entry.id, next)} /> @@ -158,8 +255,9 @@ export function TypeSettings({ initial }: { initial: TypeSetting[] }) {

- {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.`}

); diff --git a/src/lib/qr/availability.ts b/src/lib/qr/availability.ts index 030d167..f0e40a3 100644 --- a/src/lib/qr/availability.ts +++ b/src/lib/qr/availability.ts @@ -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> { } } -/** Verfügbarkeit aller Typen in Anzeigereihenfolge. */ -export async function getQrTypeAvailability(): Promise { +/** Abweichungen einer Gruppe; leer, wenn sie der globalen Einstellung folgt. */ +async function loadGroupDisabledTypes( + groupId: string, +): Promise> { + 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 { const disabled = await loadDisabledTypes(); + const inGruppeAbgewaehlt = + groupId === undefined + ? new Set() + : 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 { } /** 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 getVisibleQrTypes( + groupId?: string, +): Promise { + 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 { + 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(); + 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 { 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 { - 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 { + 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 }, + }); +} -- 2.54.0