Plattform — Auth · Rollen/RLS · i18n · Audit · Storage · Notifications

Modul 8 von 8. Dies ist das Fundament-Modul: es liefert die Querschnitts-Dienste, die alle anderen sieben Module konsumieren (Identität, Berechtigung, Übersetzung, Revisionssicherheit, Belegablage, Benachrichtigung). Wo andere Module „eine Rolle darf X" oder „dies wird ins Audit-Log geschrieben" sagen, ist hier die normative Definition.


1. Zweck & Scope

Die Plattform-Schicht stellt sicher, dass Caja eine sichere, mehrsprachige, revisionssichere Anwendung ist statt acht lose gekoppelter Module. Konkret leistet sie: (a) Authentifizierung des kleinen Festteams (Marcel, Mariela, Markus, Arkys) wahlweise über Azure Entra ID (SSO/OIDC, M365-Tenant vorhanden) und/oder Supabase Auth, sowie externer Affiliates über Supabase Auth (Magic-Link / E-Mail-OTP, keine Passwörter); 2FA ist aktiv (über Entra bzw. TOTP für ADMIN/BUCHHALTUNG) — Entscheid 2026-06-28 Teil 3; (b) ein Rollen- und RLS-Modell, das jede der geteilten Kern-Entitäten modulübergreifend konsistent schützt — insbesondere garantiert, dass ein Affiliate ausschliesslich seine eigenen Daten sieht; (c) die i18n-Architektur (stabile interne Codes + mehrsprachige Label-Lookups DE/ES/EN, Quelle GLOSSAR-ERP_DE-ES-EN.csv), die das auf der Website bereits gelebte Muster pref_lang-Cookie / data-i18n in die App überträgt; (d) ein append-only Audit-Journal als technische Umsetzung der Aufbewahrungs- und Unveränderbarkeitspflichten nach OR 957/957a und GeBüV, inkl. Abschluss-Sperren im Zusammenspiel mit monthly_closings; (e) Dokument-Storage (Supabase Storage) für Ausweis-Scans (KYC), Offerten-/Rechnungs-PDFs und Belege mit Bucket-getrennter RLS; (f) Notifications über E-Mail (Resend, bereits produktiv mit verifizierter Domain dominicanoexpress.com) und WhatsApp Cloud Business API (Meta) ab Start (automatischer Versand inkl. provider_message_id, genehmigte Templates, Opt-in, Webhooks für ein-/ausgehende Nachrichten) für die Schlüsselereignisse Offerte gesendet, Zahlung erhalten, Sendungs-Status. Status-Nachrichten gehen an beide Parteien (CH-Absender und DR-Empfänger) und ohne Zahlungs-Gate (kein Unterdrücken bei offener/unbezahlter Rechnung).


2. Domänenmodell

Identität & Berechtigung. Supabase verwaltet die kanonischen Login-Identitäten in auth.users (von uns nicht direkt beschrieben). Wir spiegeln jeden Nutzer in ein app_users-Profil (1:1 zu auth.users.id) mit Anzeigename, bevorzugter Sprache und Aktiv/Inaktiv-Flag. Die Rolle hängt nicht am Profil als Freitext, sondern an user_roles (n:m; Mehrfachrollen je Person sind ausdrücklich erlaubt — Entscheid 2026-06-28 Teil 3 / E1 — z. B. Markus = operations + buchhaltung; Berechtigungen additiv). Affiliates sind ein Sonderfall: ihr app_users-Profil ist über affiliate_users mit genau einem affiliates.id (Modul 7) verknüpft — das ist der Anker, an dem die gesamte „nur Eigenes"-RLS hängt.

i18n. locale_strings ist die eine Übersetzungstabelle für UI-Texte und für die Labels der Fach-Lookups. Jede Zeile = (namespace, key, locale) → value. Die Fach-Lookups selbst (movement_types, payment_methods, Status-Enums) tragen stabile Codes als Primärschlüssel und referenzieren ihre Labels über locale_strings (oder, pragmatischer, halten drei Label-Spalten — siehe §3-Entscheid). app_users.preferred_locale und der pref_lang-Cookie steuern die Auflösung.

Audit & Abschluss. audit_log ist ein append-only Journal: jede schreibende Operation auf revisionsrelevanten Tabellen erzeugt genau eine Zeile (wer, wann, welche Tabelle/Zeile, Aktion, alt→neu als JSONB-Diff). monthly_closings (gehört primär Modul 6) liefert die Periodensperre: ist eine Periode closed, verweigern Trigger jede Mutation an Bewegungen/Belegen dieser Periode.

Storage. storage_objects (logische Metadaten-Tabelle über den physischen Supabase-Storage-Objekten) verknüpft eine hochgeladene Datei mit ihrer Quell-Entität (owner_type + owner_id, z. B. contact/invoice/box), ihrem Bucket, ihrer Dokument-Klasse (KYC-Ausweis, Offerte-PDF, Beleg …) und Aufbewahrungs-Metadaten. Die physischen Buckets selbst sind in Supabase Storage RLS-geschützt.

Notifications. notifications ist das Outbox-/Protokoll-Pattern: jede ausgehende Nachricht (Kanal E-Mail/WhatsApp, Empfänger, Template-Code, Status queued→sent→failed, Provider-ID, Bezug zur Quell-Entität) wird als Zeile angelegt und asynchron versendet — das gibt Idempotenz, Retry und ein nachvollziehbares „was wurde wann an wen geschickt".

ER-Skizze (Plattform-Kern):

Diagramm wird geladen …

Die polymorphen Kanten (storage_objects, notifications → beliebige Owner-Entität) sind absichtlich nicht als harte FKs modelliert, sondern als (owner_type, owner_id)-Paar mit CHECK-Constraint auf erlaubte Typen — das vermeidet eine FK-Explosion über alle 8 Module und bleibt mit der „geteilte Kern-Entitäten"-Liste konsistent.


3. Supabase-Schema

Konventionen: alle Tabellen public.*, PK id uuid default gen_random_uuid(), Zeitstempel created_at timestamptz not null default now() / updated_at per Trigger. Stabile Fachcodes sind text-PKs in GROSSBUCHSTABEN (nie übersetzt, nie in Logik hartkodiert — Glossar-Prinzip). Geld als numeric(12,2), Währung default CHF.

3.1 Rollen-Lookup roles

Stabile Rollen-Codes; Labels mehrsprachig.

SpalteTypNotiz
codetext PKstabiler Code, siehe Enum unten
label_de / label_es / label_entext not nullAnzeige-Labels
rankint not nullfür UI-Sortierung / „höchste Rolle gewinnt"
is_internalbool not null default truefalse nur für affiliate

Rollen-Codes (stabil):

codeDEESENKurzbeschrieb
ADMINAdministratorAdministradorAdminVollzugriff inkl. Nutzer-/Rollenverwaltung, Periodensperre setzen/lösen (Marcel)
BUCHHALTUNGBuchhaltungContabilidadAccountingFinanzen/Movements/Abschluss voll; operative Module lesend (Mariela)
OPERATIONSOperationsOperacionesOperationsCRM/Produkte/Offerten/Logistik voll; Finanzen lesend (Markus)
FAHRERFahrerConductorDriverSendungen/Boxen scannen & Status setzen, Empfänger-Daten; sonst minimal (Arkys)
AFFILIATEAffiliateAfiliadoAffiliatenur Eigenes: eigene vermittelte Sendungen + eigene Abrechnung
READONLYNur-LesenSolo lecturaRead-onlylesend über operative + Finanz-Dashboards, kein Schreiben (Treuhänder/Gast)

Hinweis zu den Brief-Vorgaben: Der Auftrag nennt die Rollen admin, buchhaltung, operations, fahrer, affiliate, readonly. Intern sind das die obigen GROSSBUCHSTABEN-Codes (Fachcode-Konvention); die kleingeschriebenen Namen sind die umgangssprachliche Referenz.

3.2 app_users

SpalteTypConstraints / Notiz
iduuid PK= auth.users.id (kein eigenes default; per Trigger/Insert gesetzt)
emailtext not null uniquegespiegelt aus auth.users.email
display_nametext„Marcel R."
preferred_localetext not null default 'es'CHECK in ('de','es','en')
is_activebool not null default trueDeaktivierung statt Löschung (Audit!)
phone_e164textfür WhatsApp-Notifications an interne Nutzer (optional)
last_seen_attimestamptz
created_at / updated_attimestamptz

Index: unique(email). Trigger handle_new_user() (AFTER INSERT ON auth.users) legt automatisch das Profil an (Standard preferred_locale='es', keine Rolle → siehe §4.1).

3.3 user_roles

SpalteTypConstraints
user_iduuid not nullFK → app_users.id ON DELETE CASCADE
role_codetext not nullFK → roles.code
granted_byuuidFK → app_users.id (wer hat zugewiesen)
granted_attimestamptz not null default now()
PK = (user_id, role_code)

3.4 affiliate_users

SpalteTypConstraints
user_iduuid PKFK → app_users.id ON DELETE CASCADE
affiliate_iduuid not null uniqueFK → affiliates.id (Modul 7)

1:1 in beide Richtungen: ein Login = höchstens ein Affiliate-Konto; ein Affiliate-Konto = höchstens ein Login. Das unique auf beiden Spalten erzwingt das.

3.5 Berechtigungs-Helfer (SQL-Funktionen für RLS)

Damit RLS-Policies lesbar bleiben, kapseln wir die Rollen-Prüfung in SECURITY DEFINER-Funktionen mit STABLE-Volatilität:

-- aktueller Nutzer hat Rolle?
create or replace function public.has_role(p_role text)
returns boolean language sql stable security definer set search_path = public as $$
  select exists (
    select 1 from public.user_roles
    where user_id = auth.uid() and role_code = p_role
  );
$$;

-- aktueller Nutzer ist eine der internen Rollen (alles ausser AFFILIATE)?
create or replace function public.is_internal()
returns boolean language sql stable security definer set search_path = public as $$
  select exists (
    select 1 from public.user_roles ur
    join public.roles r on r.code = ur.role_code
    where ur.user_id = auth.uid() and r.is_internal
  );
$$;

-- affiliate_id des aktuellen Nutzers (NULL, wenn kein Affiliate)
create or replace function public.current_affiliate_id()
returns uuid language sql stable security definer set search_path = public as $$
  select affiliate_id from public.affiliate_users where user_id = auth.uid();
$$;

Diese drei Funktionen sind die modulübergreifende RLS-Grammatik: jede Tabelle in jedem Modul formuliert ihre Policies in has_role(...), is_internal(), current_affiliate_id(). Das hält die Berechtigungslogik an einer Stelle wartbar.

Normativ (Quelle der Wahrheit für Rollen): Rollen werden ausschliesslich über has_role()/user_roles aufgelöst; die Rolle wird nie im JWT user_metadata gespeichert — falls ein Claim nötig ist, nur über einen server-seitigen Supabase Custom Access Token Hook aus user_roles befüllt.

3.6 i18n: locale_strings

SpalteTypConstraints
namespacetext not nullz. B. ui.common, movement_type, payment_method, status, email_template
keytext not nullstabiler Schlüssel (oft = Fachcode, z. B. EXPENSE)
localetext not nullCHECK in ('de','es','en')
valuetext not nullübersetzter Text
updated_attimestamptz
PK = (namespace, key, locale)

Index: (namespace, locale) für Bulk-Load eines Namespaces pro Sprache.

Entscheid Label-Strategie (pragmatischer Hybrid): Fach-Lookups, die selten ändern und in Listen/Filtern häufig gejoint werden (roles, movement_types, payment_methods, Status-Enums), tragen ihre drei Labels direkt als Spalten (label_de/es/en) — spart Joins, ist schemastabil. Freie UI-Texte (Buttons, Empty-States, Toasts, Formfehler, E-Mail-Templates) leben in locale_strings. Beide werden aus derselben Quelle GLOSSAR-ERP_DE-ES-EN.csv befüllt (Seed-Skript), damit es genau eine Wahrheitsquelle gibt. 🔲 zu bestätigen, falls stattdessen ALLES über locale_strings laufen soll (mehr Konsistenz, mehr Joins).

3.7 Fach-Lookups mit stabilen Codes (Plattform-relevanter Auszug)

Diese gehören fachlich zu Modul 6, sind aber hier als Schema-Heimat der stabilen Codes + i18n-Labels verankert, weil sie modulübergreifend genutzt werden.

create table public.movement_types (
  code        text primary key,           -- 'DEPOSIT_CASH','INVOICE','INVOICE_PAYMENT','RECEIVABLE',
                                           -- 'INCOME','EXPENSE','SUPPLIER_PAYMENT','SUPPLIER_DEBT',
                                           -- 'CASH_TO_BANK','BANK_TO_CASH','BANK_TO_TWINT','TWINT_TO_BANK'
  label_de    text not null,
  label_es    text not null,
  label_en    text not null,
  is_internal_transfer boolean not null default false,  -- true für die 4 Transfer-Codes
  affects_debt boolean not null default false,          -- treibt open_debt (DEPOSIT_CASH/INVOICE/RECEIVABLE)
  sort         int not null default 0
);

create table public.payment_methods (
  code     text primary key,              -- 'CASH','BANK','TWINT','CARD','OPEN'
  label_de text not null, label_es text not null, label_en text not null,
  channel  text not null check (channel in ('cash','bank','open')),  -- harte Kanal-Regel (§ Brief b)
  sort     int not null default 0
);

Kanal-Klassifikation: payment_methods.channel (nicht Textmatch) entscheidet cash/bank — das ist die normative Spec-Regel aus dem Kontext-Brief (b), die das Excel-classifyPaymentChannel ersetzt. OPENchannel='open' (kein zahlungswirksamer Eintrag).

3.8 audit_log (append-only, revisionssicher)

SpalteTypConstraints / Notiz
idbigint PKgenerated always as identity (monoton, lückenlos-ish, sortierstabil)
attimestamptz not null default now()Zeitpunkt
actor_user_iduuidFK → app_users.id; NULL = Systemjob
actor_emailtextdenormalisiert mitgeschrieben (bleibt lesbar, auch wenn Nutzer später deaktiviert/umbenannt)
actiontext not nullCHECK in ('insert','update','delete','login','export','close_period','reopen_period')
table_nametext not nullz. B. movements, invoices, contacts
row_pktextPK der betroffenen Zeile (als Text, weil heterogen)
old_datajsonbZustand vor Änderung (bei update/delete)
new_datajsonbZustand nach Änderung (bei insert/update)
diffjsonbnur geänderte Felder {feld:{old,new}} (für UI)
period_keytextMM/YYYY der betroffenen Zeile, falls finanzrelevant (für Sperr-Bezug)
contextjsonboptional: IP, User-Agent, request-id, Notification-Anlass

Unveränderbarkeit (technisch erzwungen):

-- 1) Keine UPDATE/DELETE auf audit_log – auch nicht für ADMIN.
revoke update, delete on public.audit_log from authenticated, anon, service_role;
create rule audit_log_no_update as on update to public.audit_log do instead nothing;
create rule audit_log_no_delete as on delete to public.audit_log do instead nothing;
-- 2) RLS: alle internen Rollen dürfen LESEN; niemand darf via API schreiben (nur Trigger/SECURITY DEFINER).

Schreibend befüllt wird audit_log ausschliesslich durch eine generische Trigger-Funktion fn_audit(), die per AFTER INSERT/UPDATE/DELETE an jede revisionsrelevante Tabelle gehängt wird (movements, invoices, quotes, orders, deposit_*, contacts, boxes, shipments, affiliates, referral_codes, user_roles). Index: (table_name, row_pk), (actor_user_id, at), (period_key).

3.9 Abschluss-Sperre — Brücke zu monthly_closings

monthly_closings (Definition in Modul 6) trägt period_key text PK, status text check (status in ('open','review','closed')), closed_by uuid, closed_at timestamptz. Plattform-Beitrag: eine Guard-Funktion, die finanzrelevante Tabellen vor Mutation in geschlossenen Perioden schützt.

create or replace function public.fn_guard_closed_period()
returns trigger language plpgsql security definer set search_path = public as $$
declare v_pk text;
begin
  v_pk := coalesce(new.period_key, old.period_key);
  if exists (select 1 from public.monthly_closings
             where period_key = v_pk and status = 'closed')
     and not public.has_role('ADMIN')           -- nur ADMIN kann nach Wiedereröffnung mutieren
  then
     raise exception 'Periode % ist abgeschlossen (OR957/GeBüV): keine Änderung.', v_pk
       using errcode = 'P0001';
  end if;
  return coalesce(new, old);
end; $$;

Wiedereröffnen einer Periode (closed → review) ist nur ADMIN, schreibt action='reopen_period' ins audit_log und ist damit nachvollziehbar — entspricht dem GeBüV-Grundsatz, dass Korrekturen ersichtlich bleiben müssen.

3.10 storage_objects

SpalteTypConstraints / Notiz
iduuid PK
buckettext not nullCHECK in ('kyc','documents','receipts','public-assets')
storage_pathtext not null uniquePfad im Supabase-Bucket
doc_classtext not nullCHECK in ('id_scan','quote_pdf','invoice_pdf','receipt','delivery_proof','other')
owner_typetext not nullCHECK in ('contact','recipient','quote','order','invoice','deposit','box','shipment','movement') (F-20: recipient ergänzt)
owner_iduuid not nullZeile der Quell-Entität (polymorph, kein harter FK)
mime_typetext
byte_sizebigint
uploaded_byuuidFK → app_users.id
retain_untildateAufbewahrungsende (Default = Ablage + 10 J für Belege/Rechnungen)
is_sensitivebool not null default falsetrue für KYC/Ausweis → strengste RLS
created_attimestamptz

Index: (owner_type, owner_id), (bucket, doc_class), (retain_until).

Buckets (Supabase Storage), alle private (kein Public-Read ausser public-assets):

BucketInhaltWer darf lesenWer darf schreibenAufbewahrung
kycAusweis-Scans (Cédula/Pass, Modul 2)ADMIN, OPERATIONS, BUCHHALTUNGOPERATIONS (+ OCR-Job)so kurz wie möglich; löschbar nach KYC-Zweckerfüllung (DSG)
documentsOfferte-/Rechnungs-PDFs, Liefernachweiseinterne Rollen; AFFILIATE nur eigene (über owner-Join); READONLY lesendSystem (PDF-Gen), OPERATIONS, BUCHHALTUNG10 Jahre (OR 958f)
receiptsBelege zu movements/AusgabenADMIN, BUCHHALTUNGBUCHHALTUNG, OPERATIONS10 Jahre
public-assetsi18n-Flags, generische Bilderalle (auch anon)ADMIN

3.11 notifications (Outbox)

SpalteTypConstraints / Notiz
iduuid PK
channeltext not nullCHECK in ('email','whatsapp')
template_codetext not nullsiehe Template-Tabelle §6.3
localetext not nullCHECK in ('de','es','en'); = Empfänger-Sprache
to_emailtextbei channel='email'
to_phone_e164textbei channel='whatsapp'
subjecttextgerenderter Betreff (E-Mail)
bodytextgerenderter Text/HTML (oder Referenz)
owner_type / owner_idtext / uuidBezug (z. B. quote/<uuid>); CHECK in ('contact','recipient','quote','order','invoice','shipment','movement','commission_payout') (F-20: alle real referenzierten Owner inkl. recipient/shipment für SHIPMENT_STATUS)
statustext not null default 'queued'CHECK in ('queued','sent','failed','skipped')
provider_message_idtextResend-data.id bzw. WhatsApp-ID
errortextletzter Fehler (intern; nie an Client-UI roh)
attemptsint not null default 0
dedupe_keytext uniqueIdempotenz, z. B. payment_received:<movement_id>
created_at / sent_attimestamptz

Index: unique(dedupe_key), (status, created_at) (Worker-Queue), (owner_type, owner_id).

3.12 RLS-Skizze (pro Plattform-Tabelle)

Annahme: enable row level security auf allen Tabellen; ohne passende Policy ⇒ kein Zugriff (deny-by-default). service_role (Server-/Edge-Functions) umgeht RLS und macht die Auto-Posting-/Notification-Writes.

TabelleSELECTINSERTUPDATEDELETE
rolesalle authentifiziertenADMINADMIN
app_usersis_internal() (alle internen sehen Team); jeder seine eigene ZeileTrigger/ADMINeigene Zeile (nur display_name,preferred_locale); ADMIN alles– (nur is_active=false)
user_rolesis_internal()ADMINADMINADMIN
affiliate_usersADMIN; betroffener Affiliate seine ZeileADMINADMINADMIN
locale_stringsalle (auch anon, für Login-Screen)ADMINADMINADMIN
movement_types/payment_methodsalle authentifiziertenADMINADMIN
audit_logis_internal() (lesen); AFFILIATE: kein Zugriffnur via Trigger (SECURITY DEFINER)nie (Rule)nie (Rule)
monthly_closingsis_internal()BUCHHALTUNG,ADMINBUCHHALTUNG (open↔review), ADMIN (→closed / reopen)
storage_objectsinterne Rollen je Bucket (§3.10); AFFILIATE nur wo owner ihm gehörthochladende Rolle je BucketUploader/ADMINADMIN (+ Storage-Lifecycle-Job für KYC)
notificationsis_internal(); AFFILIATE: nur an ihn adressierteservice_role/interne Triggerservice_role (Status)

Affiliate-Isolation — das Kernbeispiel (gilt analog in Modul 4/5/7 für quotes, shipments, referral_codes, Provisionen):

-- Beispiel: ein Affiliate sieht nur storage_objects, deren Owner-Offerte ihm zugeordnet ist.
create policy aff_read_own_docs on public.storage_objects
for select to authenticated using (
     public.is_internal()                                  -- Team sieht alles (gem. Bucket)
  or ( public.current_affiliate_id() is not null
       and owner_type = 'quote'
       and owner_id in (select q.id from public.quotes q
                        where q.affiliate_id = public.current_affiliate_id()) )
);

4. Kern-Workflows

4.1 Login & Erst-Provisionierung (Entra ID SSO und/oder Magic-Link/OTP)

Auth-Provider-Umfang ENTSCHIEDEN (2026-06-28, Teil 3): Das interne Team meldet sich via Azure Entra ID (SSO/OIDC) an (M365-Tenant der GmbH ist vorhanden) — als OIDC-Provider in Supabase Auth hinterlegt; Affiliates/externe weiterhin via Supabase Auth (Magic-Link/E-Mail-OTP). 2FA ist aktiv: für interne Nutzer über die Entra-ID-MFA-Policy (Microsoft Authenticator/TOTP), für ADMIN/BUCHHALTUNG mindestens TOTP erzwungen (auch falls ein Konto ausnahmsweise nicht über Entra läuft). In beiden Fällen bleibt auth.users die kanonische Identität und user_roles die einzige Wahrheitsquelle der Rolle.

  1. Intern (Entra ID): Nutzer öffnet caja.dominicanoexpress, wählt „Mit Microsoft anmelden" → OIDC-Redirect zu Entra ID (inkl. MFA gemäss Tenant-Policy) → Rückkehr mit Token; Supabase legt/aktualisiert auth.users (E-Mail aus dem Entra-Claim). Extern (Affiliate): E-Mail eingeben → Supabase Auth sendet Magic-Link + 6-stelligen OTP (Supabase-SMTP; Templates DE/ES/EN, siehe §6).
  2. Klick/Code/SSO-Rückkehr → Supabase erstellt/aktualisiert auth.users; Trigger handle_new_user() legt app_users an (Default preferred_locale='es').
  3. Edge-Case neue Person ohne Rolle: Profil existiert, aber user_roles ist leer ⇒ App zeigt „Konto wartet auf Freischaltung", kein Datenzugriff (RLS greift ohnehin). ADMIN weist Rolle zu.
  4. Edge-Case unbekannte E-Mail: Supabase legt grundsätzlich an; ohne Rollen-Zuweisung passiert nichts → kein Sicherheitsleck, aber ADMIN sieht „Pending"-Nutzer.
  5. Edge-Case deaktiviert: is_active=false ⇒ Login technisch möglich, aber ein globaler Guard (Layout-Loader prüft is_active) wirft sofort raus; zusätzlich keine Rolle = kein RLS-Zugriff.
  6. Sprache: nach Login wird app_users.preferred_locale gezogen und in pref_lang-Cookie gespiegelt (Konsistenz mit Website-Muster).
  7. Fehler-Case Magic-Link abgelaufen (Default 1 h, nur externer/Affiliate-Pfad): klare Meldung + „neuen Link senden". OTP-Eingabe als Fallback auf demselben Screen.
  8. 2FA (aktiv): Interne Anmeldung erzwingt MFA über die Entra-ID-Tenant-Policy (Microsoft Authenticator/TOTP). Für ADMIN/BUCHHALTUNG ist mindestens TOTP Pflicht; ein interner Login ohne erfüllten zweiten Faktor wird abgewiesen. Edge-Case Entra-Konto ohne Caja-Rolle: auth.users wird angelegt, aber ohne user_roles-Eintrag kein Datenzugriff (RLS) — ADMIN schaltet frei (analog Schritt 3/4).

4.2 Rollen/Nutzer verwalten (nur ADMIN)

  1. ADMIN öffnet „Team & Zugriff", sieht app_users + Rollen.
  2. Rolle hinzufügen/entfernen → schreibt user_roles (mit granted_by=auth.uid()), erzeugt audit_log-Zeile (action='update', table_name='user_roles').
  3. Affiliate verknüpfen: wählt Affiliate-Konto (Modul 7) → affiliate_users-Insert; ab jetzt greift „nur Eigenes".
  4. Edge-Case Selbst-Entzug: ADMIN darf sich nicht selbst die letzte ADMIN-Rolle entziehen → CHECK/Guard „mindestens 1 aktiver ADMIN".
  5. Deaktivieren statt Löschen (Audit-Pflicht): is_active=false; Historie/Bewegungen bleiben dem Nutzer zugeordnet.

4.3 i18n auflösen & umschalten

  1. Server (RSC/Layout): liest pref_lang-Cookie → lädt benötigte locale_strings-Namespaces für die Route (Bulk-Query (namespace, locale)), rendert serverseitig in der Zielsprache (kein Flash).
  2. Fach-Labels: Listen/Tabellen lesen label_<locale> direkt aus den Lookups (kein zusätzlicher i18n-Call).
  3. Umschalten: Sprachwähler (⌘K-Palette + Settings) setzt pref_lang-Cookie und persistiert app_users.preferred_locale; UI re-rendert.
  4. Fallback-Kette: Cookie → app_users.preferred_locale → Browser (navigator.languages, ES-priorisiert) → 'es' — exakt das auf der Website implementierte Verhalten.
  5. Edge-Case fehlender Key: Auflösung gibt key selbst + Telemetrie-Warnung zurück (nie leer/Crash); fehlende Übersetzungen erscheinen im „i18n-Lücken"-Report für ADMIN.

4.4 Audit schreiben & Periode sperren

  1. Jede Mutation an revisionsrelevanter Tabelle feuert fn_audit() → eine audit_log-Zeile mit old/new/diff + denormalisiertem actor_email.
  2. Monatsabschluss (Modul 6): BUCHHALTUNG setzt Periode open→review; wenn offene Kreditoren = offene Depots = offene Debitoren = 0 ⇒ ADMIN/BUCHHALTUNG setzt closed (sonst bleibt review).
  3. Ab closed: fn_guard_closed_period() blockt jede Mutation an Bewegungen/Belegen dieser Periode.
  4. Korrektur nach Abschluss: nur ADMIN reopenet (closed→review, Audit reopen_period), korrigiert, schliesst neu — Spur bleibt sichtbar.
  5. Edge-Case Bulk-Import (source_type='import'): läuft über service_role, schreibt trotzdem Audit (action='insert', context.import_batch=<id>), ist aber von der Periodensperre ausgenommen, solange Periode open ist.

4.5 Dokument hochladen (z. B. KYC-Ausweis)

  1. Feldteam scannt Ausweis (Modul 2, Kamera) → Upload in Bucket kyc (signierter Upload-URL via Edge-Function, damit der Client nie den Service-Key sieht).
  2. Edge-Function legt storage_objects-Zeile an (doc_class='id_scan', is_sensitive=true, owner_type='contact', retain_until = minimal).
  3. OCR-Job liest die Datei (Bucket-Lesezugriff via service_role), füllt Kundensatz vor; das Scan-Original bleibt zugriffsbeschränkt (kyc-Bucket-RLS).
  4. Edge-Case zu grosse/falsche Datei: Edge-Function validiert mime_type/byte_size vor Persistenz, lehnt sonst ab (kein verwaister Storage-Eintrag).
  5. Löschpflicht (DSG): Ablauf der KYC-Notwendigkeit → Storage-Lifecycle-Job entfernt Datei + markiert storage_objects als gelöscht (Audit-Eintrag bleibt).

4.6 Notification senden (Outbox)

  1. Operatives Event (z. B. Offerte versendet, Zahlung erfasst) ruft enqueue_notification(...)notifications-Insert mit dedupe_key + Empfänger-locale.
  2. Worker/Edge-Function (Cron) zieht status='queued', rendert Template (email_template-Namespace in der Empfänger-Sprache), versendet via Resend (E-Mail) bzw. die WhatsApp Cloud API (Meta Graph POST /{phone-number-id}/messages mit genehmigtem Template).
  3. Erfolg → status='sent', provider_message_id = Resend-data.id bzw. WhatsApp-messages[0].id; Fehler → status='failed', error=..., attempts++ (max. Retries, danach manuell sichtbar). Der echte Fehler steht im Server-Log/error-Feld, nie roh im Client (gleiches Prinzip wie heute send-contact). Spätere Delivery-/Read-Status der WhatsApp Cloud API treffen per Webhook ein und aktualisieren die notifications-Zeile (matched über provider_message_id).
  4. Kein Zahlungs-Gate (Geschäftsentscheid 2026-06-28, #17): Status-Nachrichten werden immer versendet — eine offene/unbezahlte Rechnung unterdrückt keine Benachrichtigung. (Einziges legitimes skipped = fehlende Empfängeradresse, s. u.)
  5. Idempotenz: dedupe_key-Unique verhindert Doppelversand bei Retry/Race (eine Zahlung = eine „Zahlung erhalten"-Mail).
  6. Edge-Case fehlende Kontaktdaten: kein to_email/to_phonestatus='skipped' mit Begründung (kein Hard-Fail des auslösenden Workflows).

5. UI-Screens (Mobile/Tablet-First)

Globale Plattform-Chrome: Sidebar ab Desktop, Bottom-Tab-Bar + Drawer am Handy, Command-Palette (⌘K) überall. ≥44px Touch-Targets, Light/Dark via Tokens, Skeletons/Empty-States. Diese Screens sind die Querschnitts-Screens; Fach-Screens liefern die Module 1–7.

5.1 Login / Magic-Link. Zweck: passwortloser Zugang. Eine zentrierte Karte (E-Mail + „Link senden"), darunter Sprachumschalter ES/DE/EN. Handy: vollflächige Karte, grosser inputmode=email, Tastatur-sicherer Abstand zum Bottom (env(safe-area-inset-bottom)); nach „Link senden" inline OTP-6-Felder (inputmode=numeric, Auto-Advance) als Fallback. Tablet/Desktop: gleiche Karte mittig, mehr Whitespace. Kein adaptives Tabellenproblem (formularzentriert).

5.2 Team & Zugriff (ADMIN). Zweck: Nutzer + Rollen + Affiliate-Verknüpfung. Adaptives Muster Tabelle↔Karte: Desktop = TanStack-Table (Name, E-Mail, Rollen-Badges, Aktiv, letzte Aktivität, Aktionen); Handy = Karten-Liste (kein Horizontal-Scroll), je Karte Name + Rollen-Chips + Aktiv-Toggle. Rolle ändern via Bottom-Sheet-Select (Rollen mit label_<locale>); destruktive Aktion (Deaktivieren) via Swipe-Aktion auf der Karte mit Bestätigung. Empty-State „noch keine weiteren Nutzer".

5.3 Sprach- & Profileinstellungen. Zweck: preferred_locale, Anzeigename, eigene Telefonnummer. Segmented-Control ES/DE/EN (sofortiges Re-Render), thumb-optimiert. Identisch über alle Formfaktoren; persistiert Cookie + app_users.

5.4 Audit-Journal (intern, read-only). Zweck: Revisions-Nachweis. Desktop: gefilterte Tabelle (Zeit, Akteur, Tabelle, Aktion, Diff-Vorschau) mit Detail-Drawer (alt→neu JSON-Diff farbcodiert). Handy: chronologische Timeline-Karten (Akteur · Aktion · Tabelle · Zeit), Tap öffnet Diff im Bottom-Sheet. Filter (Tabelle, Akteur, Zeitraum, Periode) als Bottom-Sheet-Filterpanel. Keine Edit-Controls (append-only).

5.5 Dokumente / Belege. Zweck: Storage-Browsing je Owner-Entität. Camera-First: prominenter „Scannen/Foto"-FAB öffnet Kamera (Beleg/Ausweis), Direkt-Upload mit Fortschritt + Offline-Queue. Handy: Karten mit Thumbnail + doc_class-Badge + Owner-Link; Swipe = Ansehen/Löschen (Löschen rollen-/sensibilitätsabhängig). Tablet/Desktop: Galerie-Grid + Vorschau-Panel. KYC-Objekte tragen sichtbares „Sensibel"-Schild.

5.6 Benachrichtigungs-Protokoll (intern). Zweck: „was wurde an wen geschickt", Retry. Desktop: Tabelle (Zeit, Kanal-Icon, Template, Empfänger, Status-Ampel, Provider-ID). Handy: Status-gruppierte Karten (Fehlgeschlagen oben), je Karte Kanal + Template + Empfänger + Status; Swipe „erneut senden" bei failed (setzt status='queued'). Empty/Skeleton-States. Roh-Fehlertext nur im Detail-Drawer für interne Rollen.


6. Integrationen & Verbindungen zu anderen Modulen

6.1 Was die Plattform allen anderen bereitstellt (geteilte Entitäten/Funktionen):

  • Identität/Rollen: app_users, user_roles, roles, affiliate_users + die RLS-Helfer has_role(), is_internal(), current_affiliate_id()jedes Modul formuliert seine Policies damit. Das ist der modulübergreifende Konsistenz-Anker.
  • i18n: locale_strings + label_<locale>-Konvention für alle Fach-Lookups (movement_types, payment_methods, Status, Produkt-/Box-Labels, Tracking-Schritt-Labels). Quelle GLOSSAR.
  • Audit: fn_audit()-Trigger wird an die revisionsrelevanten Tabellen aller Module gehängt (Finanzen, Offerten, Aufträge, Logistik, CRM, Affiliate).
  • Periodensperre: fn_guard_closed_period() schützt Modul-6-Bewegungen + alle finanzwirksamen Quell-Events (Depots, Rechnungen).
  • Storage: storage_objects + Buckets für Modul 2 (KYC-Scans), Modul 4 (Offerte-PDF), Modul 6 (Rechnung-PDF/Belege), Modul 5 (Liefernachweis).
  • Notifications: enqueue_notification() als einziger Ausgang für Modul 4 (Offerte gesendet), Modul 6 (Zahlung erhalten), Modul 5 (Sendungs-Status), Modul 7 (Affiliate-Abrechnung).

6.2 Eingehende Events (wer löst Plattform-Aktionen aus):

Quell-ModulEventPlattform-Reaktion
4 Offertenquote.sentenqueue_notification(template=QUOTE_SENT, channel=email/whatsapp, owner=quote) + Audit
6 Finanzenpayment.recorded (movement upsert, source_typedebtor_payment/deposit_payment)enqueue_notification(template=PAYMENT_RECEIVED, dedupe=payment_received:<movement_id>) + Audit
5 Logistikshipment.status_changed (eine der 9 Stufen)enqueue_notification(template=SHIPMENT_STATUS) an beide — CH-Absender (contacts) und DR-Empfänger (recipients) (#16); kein Zahlungs-Gate (#17) + Audit
2 WhatsApp/OCRid_scannedstorage_objects-Insert (kyc) + Audit
allejede revisionsrelevante Mutationaudit_log-Zeile

Auto-Posting-Bezug: Die Plattform schreibt keine Buchungen selbst — sie stellt nur Audit + Notification + Sperre. Das eigentliche Auto-Posting (Event → movements per upsert mit source_type+source_id) ist Modul 6. Plattform garantiert aber, dass diese Buchungen revisionssicher (Audit) und periodensicher (Sperre) sind.

6.3 Notification-Templates (Code-stabil, Texte in locale_strings-Namespace email_template):

template_codeAnlassKanäleEmpfänger
QUOTE_SENTOfferte versendetemail, whatsappKunde
PAYMENT_RECEIVEDZahlung/Anzahlung verbuchtemailKunde
SHIPMENT_STATUSSendungs-Status geändert (9 Stufen)email, whatsappbeide (#16): CH-Absender (contacts.preferred_lang) und DR-Empfänger (recipients.preferred_lang, F-07; Default ES) — Versand immer, kein Zahlungs-Gate (#17)
AFFILIATE_STATEMENTAffiliate-Abrechnung bereitemailAffiliate
AUTH_MAGIC_LINKLogin-Link/OTPemailinterner Nutzer/Affiliate

E-Mail-Versand = Resend, bereits produktiv konfiguriert. Wiederverwendung des bestehenden Setups (api/send-contact.js): Absender RESEND_FROM muss …@dominicanoexpress.com sein (Domain in Resend verifiziert), { data, error }-Auswertung, replyTo optional, dreisprachige Subject/Body-Maps. Key-/Env-Änderung erfordert Redeploy (Vercel snapshottet Env pro Deployment). WhatsApp = WhatsApp Cloud Business API (Meta) ab Start (Geschäftsentscheid 2026-06-28, #21): automatischer Versand über die Meta Graph API (POST /{phone-number-id}/messages, vorab genehmigte Templates, Opt-in des Empfängers) — kein manueller Deep-Link mehr. Der notify-worker füllt provider_message_id (WhatsApp-messages[0].id); eingehende Nachrichten/Medien (OCR-Intake, Modul 2) und Delivery-/Read-Status kommen über den Cloud-API-Webhook (whatsapp-inbound, HMAC X-Hub-Signature-256). Secrets WHATSAPP_* nur server-seitig (Modul 20 §10.2). Business-Nummer +41 79 199 93 93 (WABA-verknüpft).


7. Validierungen & Edge-Cases

  • Mindestens 1 aktiver ADMIN: Guard verhindert Entzug der letzten ADMIN-Rolle / Deaktivierung des letzten Admins.
  • app_users.id = auth.users.id: Insert ohne korrespondierende Auth-Identität wird abgelehnt (FK/Trigger); keine „verwaisten" Profile.
  • Rolle ohne Profil unmöglich: user_roles.user_id FK erzwingt existierendes app_users.
  • Affiliate-Doppelbindung: unique auf affiliate_users.user_id und .affiliate_id (1:1 beidseitig).
  • i18n-Vollständigkeit: jede (namespace,key) sollte alle drei Locales haben; CI-Check/Seed-Validierung gegen GLOSSAR; fehlt eine, greift Fallback key-anzeigen + Lücken-Report.
  • Audit unmanipulierbar: UPDATE/DELETE auf audit_log durch Rules + entzogene Grants blockiert — auch für ADMIN und service_role.
  • Audit referenziert toten Nutzer: actor_email ist denormalisiert mitgeschrieben → Journal bleibt lesbar trotz Deaktivierung/Umbenennung.
  • Periodensperre vs. Nachzügler: Buchung mit Datum in geschlossener Periode wird von fn_guard_closed_period() abgewiesen; korrekter Weg = reopen (nur ADMIN) oder Buchung in offener Folgeperiode (Geschäftsentscheid Modul 6).
  • Notification-Idempotenz: dedupe_key-Unique fängt Retries/Races; fehlende Empfängeradresse ⇒ skipped, kein Hard-Fail.
  • Storage-Validierung: mime_type/byte_size/bucket/doc_class serverseitig geprüft vor Persistenz; signierte Upload-URLs (Client sieht nie den Service-Key).
  • RLS deny-by-default: neue Tabelle ohne Policy = niemand sieht etwas (Sicherheit vor Bequemlichkeit); Reviews stellen sicher, dass jede neue Tabelle ihre Policies mitbringt.
  • service_role nur serverseitig: niemals an den Client; alle privilegierten Writes (Auto-Posting, Notifications, OCR) laufen in Edge-/Server-Functions.

8. Compliance-/Sicherheits-Hinweise

  • OR 957/957a + GeBüV (Aufbewahrung & Revisionssicherheit): audit_log ist ein append-only, unveränderbares Journal (DB-Rules + Grant-Entzug) mit lückenlosem Identity-PK, Zeitstempel, Akteur und alt→neu-Diff. Geschäftsunterlagen (Rechnungen, Belege) werden 10 Jahre aufbewahrt (storage_objects.retain_until, Buckets documents/receipts). Abschlüsse werden gesperrt; Korrekturen nach Abschluss sind nur via dokumentiertes Reopen (ADMIN) möglich und bleiben im Journal sichtbar — entspricht dem Grundsatz der Nachvollziehbarkeit von Änderungen.
  • revDSG/DSG (CH-Datenschutz): KYC-Ausweisdaten (Cédula/Pass, Modul 2) sind besonders schützenswert → eigener kyc-Bucket, strengste RLS (is_sensitive=true, nur ADMIN/OPERATIONS/BUCHHALTUNG), Datenminimierung (nur das fürs Zoll-/Empfänger-Matching Nötige) und Löschung nach Zweckerfüllung (Lifecycle-Job; Audit-Eintrag der Löschung bleibt). Empfänger-DR-Daten ebenso zweckgebunden. Datenschutzerklärung der Website (/datenschutz) bildet die Rechtsgrundlage ab.
  • KYC-Zweckbindung: Ausweis-Scan dient ausschliesslich Identitäts-/Zoll-/Empfänger-Matching; kein Weiterverkauf, kein Tracking. Zugriff streng rollenbasiert + auditiert.
  • Auth-Härtung: internes Team via Azure Entra ID (SSO/OIDC) mit MFA (Tenant-Policy), Affiliates/extern passwortlos (Magic-Link/OTP) — eliminiert Passwort-Leaks; abgelaufene Links (1 h). 2FA aktiv (Entra-MFA bzw. TOTP für ADMIN/BUCHHALTUNG, Entscheid 2026-06-28 Teil 3). RLS deny-by-default als zweite Verteidigungslinie unabhängig von der UI; service_role strikt serverseitig.
  • Geheimnisse: RESEND_API_KEY/RESEND_FROM/Supabase-Service-Key nur in Vercel-/Supabase-Env, nie im Repo; Key-Rotation erfordert Redeploy. Echte Fehlertexte (Resend/Storage) bleiben in Server-Logs, nie roh im Client.
  • Mandantentrennung Affiliate: ein Affiliate ist datenschutzrechtlich ein Externer → RLS stellt hart sicher, dass er ausschliesslich seine vermittelten Sendungen/Abrechnungen sieht (kein Kunden- oder Finanz-Gesamtzugriff).

9. Offene Punkte

  • 🔲 Label-Strategie final: Hybrid (drei label_<locale>-Spalten an Lookups + locale_strings für freie Texte) vs. alles in locale_strings. Empfehlung Hybrid (weniger Joins in Listen). — zu bestätigen
  • 🔲 KYC-Aufbewahrungsfrist konkret: Wie lange müssen/dürfen Ausweis-Scans gehalten werden (Zoll-/Geldwäsche-Anforderungen DR/CH vs. DSG-Minimierung)? Default-retain_until davon abhängig. — zu bestätigen
  • WhatsApp-Kanal ENTSCHIEDEN (2026-06-28, #21): WhatsApp Cloud Business API (Meta) ab Start — automatischer Versand inkl. provider_message_id, genehmigte Templates, Opt-in, Webhooks für ein-/ausgehende Nachrichten. Kein manueller Deep-Link-MVP. notify-worker versendet via Cloud API, whatsapp-inbound-Webhook nimmt eingehende Medien (OCR) + Status-Callbacks entgegen (§4.6, §6.3; Modul 20 §9.4/§10.2). — erledigt
  • 🔲 Notification-Opt-in/Einwilligung: Die Sprache ist geklärt (F-07: Kunde aus contacts.preferred_lang, DR-Empfänger aus recipients.preferred_lang, Default ES). Offen bleibt nur das Abmelde-/Einwilligungsmodell für Status-Mails/WhatsApp (Geschäftsentscheid). — zu bestätigen
  • Mehrfachrollen je Person ENTSCHIEDEN (2026-06-28, Teil 3 / E1): JA. Eine Person kann mehrere Rollen gleichzeitig tragen (z. B. Markus = OPERATIONS + BUCHHALTUNG). Das Schema user_roles (n:m, PK (user_id, role_code), §3.3) unterstützt das bereits; has_role() prüft je Rolle, Berechtigungen sind additiv. — erledigt
  • 🔲 Reopen-Kompetenz: Ist „nur ADMIN darf abgeschlossene Periode wiedereröffnen" korrekt, oder soll BUCHHALTUNG das auch dürfen (mit Audit)? — zu bestätigen
  • Auth-Provider-Umfang & 2FA ENTSCHIEDEN (2026-06-28, Teil 3): Internes Team via Azure Entra ID (SSO/OIDC, M365-Tenant), Affiliates/extern via Supabase Auth (Magic-Link/OTP). 2FA aktiv: Entra-MFA für interne Nutzer, mindestens TOTP für ADMIN/BUCHHALTUNG. Umsetzung: Entra als OIDC-Provider in Supabase Auth; Rollen weiterhin nur über user_roles (§3.5). — erledigt

10. IST-Gap-Nachträge (P0 aus Alt-ERP-Analyse)

Aus der verifizierten Gap-Analyse (docs/legacy-ist/95-caja-gap-analyse.md). Schließt Plattform-Lücken, die das Alt-ERP-Audit aufdeckte.

10.1 CSRF-/Origin-Schutz (G8 — BR-S12)

Die einzige IST-Security-Schwäche ohne bisheriges explizites Gegenstück. Normativ festhalten:

  • Mutierende Calls laufen über Next.js Server Actions / Route Handlers mit Supabase Bearer-Token (kein Ambient-Cookie-Auth) → klassisches Cookie-CSRF strukturell entschärft.
  • Zusätzlich Origin-/Referer-Validierung für Server Actions und Webhooks (HMAC-signiert, vgl. Compliance §8.8); Session-Cookies Secure/HttpOnly/SameSite=lax.
  • TLS 1.2+ erzwungen (Vercel/Supabase Default) — IST-Schwächen „Cookie ohne SSL / SMTP ohne TLS" damit abgedeckt.

10.2 app_settings — globale Konfig-Flags (G-Settings — BR-S08)

Das Alt-ERP hatte nur ein DB-Setting (PricePerM3). Caja braucht eine Heimat für globale Flags, die heute verstreut referenziert werden. Tabelle app_settings (key, value jsonb, …) in Dok 30 §6.1. Schlüssel u. a.: vat_enabled, vat_method (brutto/netto), default_currency, volume_tariff_unit (G7-Tarifeinheit, IST=100), opening_balances_done. RLS: ADMIN write, interne Rollen read (löst zugleich die fehlende ManageSettings-Granularität).

10.3 Application-Error-Logging (G9 — BR-S07)

audit_log ist ein Änderungs-Journal (insert/update/delete/login/export/close), kein Exception-Log. Das Alt-ERP hatte mit AdminErrors ein durchsuchbares Fehler-Journal (Subject/Message/URL/User/IP). Festlegen: technische Exceptions werden über Vercel-Observability bzw. ein Error-Tracking (Sentry-Äquivalent) zentral erfasst — nicht im audit_log vermischen. Mindestens als Betriebsanforderung dokumentieren.

10.4 Storage-Belegklassen erweitert (G2/G6)

storage_objects.doc_class um signature (G6), customs_report und shipment_list (G2) ergänzt (Dok 30 §6.3) — für Liefernachweis-Unterschrift und Zoll-/Listen-Belege (Modul 14 §10).

10.5 RBAC-Granularität & Kundenportal — bewusste Entscheide

  • E2 (Action-Ebene): Caja verzichtet bewusst auf eine pflegbare actions/permissions-Tabelle (IST hatte ~19 Einzel-Actions); Feingranularität lebt in den RLS-Policies (Tabelle×Rolle×CRUD, teils spalten-/zeilengenau). Konsequenz: rollenfremde Einzelrechte erfordern eine Migration, kein Datenpflege-Insert. — als Architektur-Entscheid festgehalten.
  • E8 (Kundenportal): Endkunden haben zum Start kein Login (nur Team + Affiliates). Die IST-Mandanten-Isolation „Kunde sieht nur Eigenes" (BR-04/BR-P09) ist damit vorerst gegenstandslos. Für Phase 2+ vorgesehen: RLS-Helfer current_contact_id() analog current_affiliate_id() von Anfang an mitdenken, damit ein späteres Kundenportal ohne RLS-Umbau aktivierbar ist.

11. IST-Paritäts-Nachträge (Welle 2 — fehlende Screens & Auth-Doku)

Die Alt-ERP-Plattform-Substanz (Auth, RBAC, Settings, Audit, i18n) ist abgedeckt; es fehlten drei UI-Screens (Daten-Fundament je vorhanden) + zwei Auth-Klarstellungen. Aus der Paritäts-Analyse (docs/legacy-ist/95-caja-gap-analyse.md).

11.1 Systemeinstellungen-Screen (ADMIN) — Pendant zu IST Settings.html

Pflege der globalen app_settings-Flags (Schema §10.2 / Dok 30 §6.1) — das Caja-Pendant zum IST-Settings.html (das nur „Precio por M3" kannte). Adaptive gestapelte Karten-Liste (eine Karte je Gruppe): Finanzen (vat_enabled-Toggle, vat_method-Select, default_currency); Tarif (volume_tariff_unit — IST-Divisor 100, der G7-Volumen-Tarif-Parameter; numerisch); Anfangssalden (opening_balances_done + Eröffnungssaldo Kasse/Bank, Dok 30 §14); GS1/SSCC (gs1_sscc_enabled-Toggle, gs1_company_prefix/GCP, gs1_extension_digit — schaltet die standardisierten Transport-Etiketten scharf, Voraussetzung GS1-Schweiz-Mitgliedschaft; s. Modul 14 §12). Handy: Karten-Liste, kritische Schalter (vat_enabled) via Bottom-Sheet-Confirm; Desktop: zweispaltiges Formular. Jede Änderung schreibt app_settings.updated_by/updated_at + audit_log. RLS: nur ADMIN schreibt, interne Rollen lesen. Auto-Save (debounced) optional je Feld (wie IST).

11.2 Rollen & Rechte — Berechtigungs-Matrix (read-only) — Pendant zu IST Roles.html

Da Caja die Feingranularität in RLS-Policies hält (Entscheid E2 — keine pflegbare actions-Tabelle), ist diese Matrix anschauend, nicht editierend: sie rendert die in Dok 30 §13 normierte Rolle×Tabelle×CRUD-Matrix als durchsuchbare Tabelle (Zeilen = Entitäten, Spalten = 6 Rollen, Zellen = C/R/U/D/R*-Badges). Dient ADMIN/Treuhänder als Audit-/Onboarding-Übersicht („wer darf was"). Hinweis-Banner: „Rollenrechte sind als Policy/Code definiert; Änderungen via Migration (E2), nicht hier." Handy: je Rolle eine Karte; Desktop: Voll-Matrix mit Sticky-Header. Editierbar bleibt nur die Rollen-Zuweisung (§5.2 „Team & Zugriff"). Erfüllt BR-S01 (Rollen/Rechte einsehbar) als Screen, ohne E2 zu unterlaufen.

11.3 Objekt-Verlauf („Historial de cambios", pro Entität) — Pendant zum IST-History-Tab

Zusätzlich zum globalen Audit-Journal (§5.4) eine objekt-gefilterte Verlaufs-Ansicht als wiederverwendbare Komponente (Design-Pattern, auch in Modul 18 zu verankern): liest audit_log gefiltert auf (table_name, row_pk) der Entität und rendert die Änderungen als chronologische Timeline (Akteur · Aktion · Zeit · Feld-Diff farbcodiert). Eingebunden als Tab „Verlauf" in: Kontaktdetail (Modul 1 §5.2 — schließt den dort genannten „Aktivitäten"-Tab), Auftrag-Detail (Modul 5 §5.6), Rechnungs-Detail (Modul 6), Sendungs-/Box-Detail (Modul 5 §5.3, neben der physischen Tracking-Timeline). Read-only (append-only Audit); Detail-Diff im Bottom-Sheet (Handy) / Drawer (Desktop). Erfüllt BR-13/BR-14 (Änderungs-Historie pro Objekt) als Screen, nicht nur als Datenmodell.

11.4 Auth-Klarstellungen (Logout / Passwort-Reset / eigene E-Mail)

  • Logout: über das Profil-Menü (Sidebar-Footer / Bottom-Tab „Konto") → „Abmelden" (Supabase signOut, Session gelöscht), danach Redirect auf die Login-Karte (§5.1). (IST nutzte LoginController.Index als Logout.)
  • Passwort-Reset (BR-S06) → obsolet durch passwortlose Auth / SSO: Das IST-Verfahren „6-stelliger Code, 20 min, per E-Mail" hat kein PW-Reset-Pendant (keine Passwörter). Intern erfolgt die Anmeldung über Entra ID SSO (Passwort/MFA verwaltet der M365-Tenant, nicht Caja); extern/Affiliate ist der funktionale Ersatz der E-Mail-OTP des Magic-Link-Logins (§4.1, 6-stellig; Ablauf Supabase-Default 1 h, via otp_expiry einstellbar). Bewusste Divergenz.
  • Eigene E-Mail nicht selbst änderbar (BR-P04): strukturell erfüllt — die Login-E-Mail (app_users.email/auth.users) wird ausschließlich über den Supabase-Auth-Change-Flow (Doppel-Opt-in) geändert, nicht über ein freies Feld; RLS app_users erlaubt Self-Update nur für display_name/preferred_locale/theme, nicht email. Die Geschäfts-contacts.email ist davon entkoppelt.