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.*, PKid uuid default gen_random_uuid(), Zeitstempelcreated_at timestamptz not null default now()/updated_atper Trigger. Stabile Fachcodes sindtext-PKs in GROSSBUCHSTABEN (nie übersetzt, nie in Logik hartkodiert — Glossar-Prinzip). Geld alsnumeric(12,2), Währung defaultCHF.
3.1 Rollen-Lookup roles
Stabile Rollen-Codes; Labels mehrsprachig.
| Spalte | Typ | Notiz |
|---|---|---|
code | text PK | stabiler Code, siehe Enum unten |
label_de / label_es / label_en | text not null | Anzeige-Labels |
rank | int not null | für UI-Sortierung / „höchste Rolle gewinnt" |
is_internal | bool not null default true | false nur für affiliate |
Rollen-Codes (stabil):
code | DE | ES | EN | Kurzbeschrieb |
|---|---|---|---|---|
ADMIN | Administrator | Administrador | Admin | Vollzugriff inkl. Nutzer-/Rollenverwaltung, Periodensperre setzen/lösen (Marcel) |
BUCHHALTUNG | Buchhaltung | Contabilidad | Accounting | Finanzen/Movements/Abschluss voll; operative Module lesend (Mariela) |
OPERATIONS | Operations | Operaciones | Operations | CRM/Produkte/Offerten/Logistik voll; Finanzen lesend (Markus) |
FAHRER | Fahrer | Conductor | Driver | Sendungen/Boxen scannen & Status setzen, Empfänger-Daten; sonst minimal (Arkys) |
AFFILIATE | Affiliate | Afiliado | Affiliate | nur Eigenes: eigene vermittelte Sendungen + eigene Abrechnung |
READONLY | Nur-Lesen | Solo lectura | Read-only | lesend ü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
| Spalte | Typ | Constraints / Notiz |
|---|---|---|
id | uuid PK | = auth.users.id (kein eigenes default; per Trigger/Insert gesetzt) |
email | text not null unique | gespiegelt aus auth.users.email |
display_name | text | „Marcel R." |
preferred_locale | text not null default 'es' | CHECK in ('de','es','en') |
is_active | bool not null default true | Deaktivierung statt Löschung (Audit!) |
phone_e164 | text | für WhatsApp-Notifications an interne Nutzer (optional) |
last_seen_at | timestamptz | |
created_at / updated_at | timestamptz |
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
| Spalte | Typ | Constraints |
|---|---|---|
user_id | uuid not null | FK → app_users.id ON DELETE CASCADE |
role_code | text not null | FK → roles.code |
granted_by | uuid | FK → app_users.id (wer hat zugewiesen) |
granted_at | timestamptz not null default now() | |
PK = (user_id, role_code) |
3.4 affiliate_users
| Spalte | Typ | Constraints |
|---|---|---|
user_id | uuid PK | FK → app_users.id ON DELETE CASCADE |
affiliate_id | uuid not null unique | FK → affiliates.id (Modul 7) |
1:1 in beide Richtungen: ein Login = höchstens ein Affiliate-Konto; ein Affiliate-Konto = höchstens ein Login. Das
uniqueauf 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
| Spalte | Typ | Constraints |
|---|---|---|
namespace | text not null | z. B. ui.common, movement_type, payment_method, status, email_template |
key | text not null | stabiler Schlüssel (oft = Fachcode, z. B. EXPENSE) |
locale | text not null | CHECK in ('de','es','en') |
value | text not null | übersetzter Text |
updated_at | timestamptz | |
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) entscheidetcash/bank— das ist die normative Spec-Regel aus dem Kontext-Brief (b), die das Excel-classifyPaymentChannelersetzt.OPEN→channel='open'(kein zahlungswirksamer Eintrag).
3.8 audit_log (append-only, revisionssicher)
| Spalte | Typ | Constraints / Notiz |
|---|---|---|
id | bigint PK | generated always as identity (monoton, lückenlos-ish, sortierstabil) |
at | timestamptz not null default now() | Zeitpunkt |
actor_user_id | uuid | FK → app_users.id; NULL = Systemjob |
actor_email | text | denormalisiert mitgeschrieben (bleibt lesbar, auch wenn Nutzer später deaktiviert/umbenannt) |
action | text not null | CHECK in ('insert','update','delete','login','export','close_period','reopen_period') |
table_name | text not null | z. B. movements, invoices, contacts |
row_pk | text | PK der betroffenen Zeile (als Text, weil heterogen) |
old_data | jsonb | Zustand vor Änderung (bei update/delete) |
new_data | jsonb | Zustand nach Änderung (bei insert/update) |
diff | jsonb | nur geänderte Felder {feld:{old,new}} (für UI) |
period_key | text | MM/YYYY der betroffenen Zeile, falls finanzrelevant (für Sperr-Bezug) |
context | jsonb | optional: 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 nurADMIN, schreibtaction='reopen_period'insaudit_logund ist damit nachvollziehbar — entspricht dem GeBüV-Grundsatz, dass Korrekturen ersichtlich bleiben müssen.
3.10 storage_objects
| Spalte | Typ | Constraints / Notiz |
|---|---|---|
id | uuid PK | |
bucket | text not null | CHECK in ('kyc','documents','receipts','public-assets') |
storage_path | text not null unique | Pfad im Supabase-Bucket |
doc_class | text not null | CHECK in ('id_scan','quote_pdf','invoice_pdf','receipt','delivery_proof','other') |
owner_type | text not null | CHECK in ('contact','recipient','quote','order','invoice','deposit','box','shipment','movement') (F-20: recipient ergänzt) |
owner_id | uuid not null | Zeile der Quell-Entität (polymorph, kein harter FK) |
mime_type | text | |
byte_size | bigint | |
uploaded_by | uuid | FK → app_users.id |
retain_until | date | Aufbewahrungsende (Default = Ablage + 10 J für Belege/Rechnungen) |
is_sensitive | bool not null default false | true für KYC/Ausweis → strengste RLS |
created_at | timestamptz |
Index: (owner_type, owner_id), (bucket, doc_class), (retain_until).
Buckets (Supabase Storage), alle private (kein Public-Read ausser public-assets):
| Bucket | Inhalt | Wer darf lesen | Wer darf schreiben | Aufbewahrung |
|---|---|---|---|---|
kyc | Ausweis-Scans (Cédula/Pass, Modul 2) | ADMIN, OPERATIONS, BUCHHALTUNG | OPERATIONS (+ OCR-Job) | so kurz wie möglich; löschbar nach KYC-Zweckerfüllung (DSG) |
documents | Offerte-/Rechnungs-PDFs, Liefernachweise | interne Rollen; AFFILIATE nur eigene (über owner-Join); READONLY lesend | System (PDF-Gen), OPERATIONS, BUCHHALTUNG | 10 Jahre (OR 958f) |
receipts | Belege zu movements/Ausgaben | ADMIN, BUCHHALTUNG | BUCHHALTUNG, OPERATIONS | 10 Jahre |
public-assets | i18n-Flags, generische Bilder | alle (auch anon) | ADMIN | — |
3.11 notifications (Outbox)
| Spalte | Typ | Constraints / Notiz |
|---|---|---|
id | uuid PK | |
channel | text not null | CHECK in ('email','whatsapp') |
template_code | text not null | siehe Template-Tabelle §6.3 |
locale | text not null | CHECK in ('de','es','en'); = Empfänger-Sprache |
to_email | text | bei channel='email' |
to_phone_e164 | text | bei channel='whatsapp' |
subject | text | gerenderter Betreff (E-Mail) |
body | text | gerenderter Text/HTML (oder Referenz) |
owner_type / owner_id | text / uuid | Bezug (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) |
status | text not null default 'queued' | CHECK in ('queued','sent','failed','skipped') |
provider_message_id | text | Resend-data.id bzw. WhatsApp-ID |
error | text | letzter Fehler (intern; nie an Client-UI roh) |
attempts | int not null default 0 | |
dedupe_key | text unique | Idempotenz, z. B. payment_received:<movement_id> |
created_at / sent_at | timestamptz |
Index: unique(dedupe_key), (status, created_at) (Worker-Queue), (owner_type, owner_id).
3.12 RLS-Skizze (pro Plattform-Tabelle)
Annahme:
enable row level securityauf allen Tabellen; ohne passende Policy ⇒ kein Zugriff (deny-by-default).service_role(Server-/Edge-Functions) umgeht RLS und macht die Auto-Posting-/Notification-Writes.
| Tabelle | SELECT | INSERT | UPDATE | DELETE |
|---|---|---|---|---|
roles | alle authentifizierten | ADMIN | ADMIN | – |
app_users | is_internal() (alle internen sehen Team); jeder seine eigene Zeile | Trigger/ADMIN | eigene Zeile (nur display_name,preferred_locale); ADMIN alles | – (nur is_active=false) |
user_roles | is_internal() | ADMIN | ADMIN | ADMIN |
affiliate_users | ADMIN; betroffener Affiliate seine Zeile | ADMIN | ADMIN | ADMIN |
locale_strings | alle (auch anon, für Login-Screen) | ADMIN | ADMIN | ADMIN |
movement_types/payment_methods | alle authentifizierten | ADMIN | ADMIN | – |
audit_log | is_internal() (lesen); AFFILIATE: kein Zugriff | nur via Trigger (SECURITY DEFINER) | nie (Rule) | nie (Rule) |
monthly_closings | is_internal() | BUCHHALTUNG,ADMIN | BUCHHALTUNG (open↔review), ADMIN (→closed / reopen) | – |
storage_objects | interne Rollen je Bucket (§3.10); AFFILIATE nur wo owner ihm gehört | hochladende Rolle je Bucket | Uploader/ADMIN | ADMIN (+ Storage-Lifecycle-Job für KYC) |
notifications | is_internal(); AFFILIATE: nur an ihn adressierte | service_role/interne Trigger | service_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/BUCHHALTUNGmindestens TOTP erzwungen (auch falls ein Konto ausnahmsweise nicht über Entra läuft). In beiden Fällen bleibtauth.usersdie kanonische Identität unduser_rolesdie einzige Wahrheitsquelle der Rolle.
- 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/aktualisiertauth.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). - Klick/Code/SSO-Rückkehr → Supabase erstellt/aktualisiert
auth.users; Triggerhandle_new_user()legtapp_usersan (Defaultpreferred_locale='es'). - Edge-Case neue Person ohne Rolle: Profil existiert, aber
user_rolesist leer ⇒ App zeigt „Konto wartet auf Freischaltung", kein Datenzugriff (RLS greift ohnehin).ADMINweist Rolle zu. - Edge-Case unbekannte E-Mail: Supabase legt grundsätzlich an; ohne Rollen-Zuweisung passiert nichts → kein Sicherheitsleck, aber
ADMINsieht „Pending"-Nutzer. - Edge-Case deaktiviert:
is_active=false⇒ Login technisch möglich, aber ein globaler Guard (Layout-Loader prüftis_active) wirft sofort raus; zusätzlich keine Rolle = kein RLS-Zugriff. - Sprache: nach Login wird
app_users.preferred_localegezogen und inpref_lang-Cookie gespiegelt (Konsistenz mit Website-Muster). - Fehler-Case Magic-Link abgelaufen (Default 1 h, nur externer/Affiliate-Pfad): klare Meldung + „neuen Link senden". OTP-Eingabe als Fallback auf demselben Screen.
- 2FA (aktiv): Interne Anmeldung erzwingt MFA über die Entra-ID-Tenant-Policy (Microsoft Authenticator/TOTP). Für
ADMIN/BUCHHALTUNGist mindestens TOTP Pflicht; ein interner Login ohne erfüllten zweiten Faktor wird abgewiesen. Edge-Case Entra-Konto ohne Caja-Rolle:auth.userswird angelegt, aber ohneuser_roles-Eintrag kein Datenzugriff (RLS) —ADMINschaltet frei (analog Schritt 3/4).
4.2 Rollen/Nutzer verwalten (nur ADMIN)
ADMINöffnet „Team & Zugriff", siehtapp_users+ Rollen.- Rolle hinzufügen/entfernen → schreibt
user_roles(mitgranted_by=auth.uid()), erzeugtaudit_log-Zeile (action='update',table_name='user_roles'). - Affiliate verknüpfen: wählt Affiliate-Konto (Modul 7) →
affiliate_users-Insert; ab jetzt greift „nur Eigenes". - Edge-Case Selbst-Entzug:
ADMINdarf sich nicht selbst die letzteADMIN-Rolle entziehen → CHECK/Guard „mindestens 1 aktiver ADMIN". - Deaktivieren statt Löschen (Audit-Pflicht):
is_active=false; Historie/Bewegungen bleiben dem Nutzer zugeordnet.
4.3 i18n auflösen & umschalten
- Server (RSC/Layout): liest
pref_lang-Cookie → lädt benötigtelocale_strings-Namespaces für die Route (Bulk-Query(namespace, locale)), rendert serverseitig in der Zielsprache (kein Flash). - Fach-Labels: Listen/Tabellen lesen
label_<locale>direkt aus den Lookups (kein zusätzlicher i18n-Call). - Umschalten: Sprachwähler (⌘K-Palette + Settings) setzt
pref_lang-Cookie und persistiertapp_users.preferred_locale; UI re-rendert. - Fallback-Kette: Cookie →
app_users.preferred_locale→ Browser (navigator.languages, ES-priorisiert) →'es'— exakt das auf der Website implementierte Verhalten. - Edge-Case fehlender Key: Auflösung gibt
keyselbst + Telemetrie-Warnung zurück (nie leer/Crash); fehlende Übersetzungen erscheinen im „i18n-Lücken"-Report fürADMIN.
4.4 Audit schreiben & Periode sperren
- Jede Mutation an revisionsrelevanter Tabelle feuert
fn_audit()→ eineaudit_log-Zeile mitold/new/diff+ denormalisiertemactor_email. - Monatsabschluss (Modul 6):
BUCHHALTUNGsetzt Periodeopen→review; wenn offene Kreditoren = offene Depots = offene Debitoren = 0 ⇒ADMIN/BUCHHALTUNGsetztclosed(sonst bleibtreview). - Ab
closed:fn_guard_closed_period()blockt jede Mutation an Bewegungen/Belegen dieser Periode. - Korrektur nach Abschluss: nur
ADMINreopenet (closed→review, Auditreopen_period), korrigiert, schliesst neu — Spur bleibt sichtbar. - Edge-Case Bulk-Import (
source_type='import'): läuft überservice_role, schreibt trotzdem Audit (action='insert',context.import_batch=<id>), ist aber von der Periodensperre ausgenommen, solange Periodeopenist.
4.5 Dokument hochladen (z. B. KYC-Ausweis)
- Feldteam scannt Ausweis (Modul 2, Kamera) → Upload in Bucket
kyc(signierter Upload-URL via Edge-Function, damit der Client nie den Service-Key sieht). - Edge-Function legt
storage_objects-Zeile an (doc_class='id_scan',is_sensitive=true,owner_type='contact',retain_until= minimal). - OCR-Job liest die Datei (Bucket-Lesezugriff via
service_role), füllt Kundensatz vor; das Scan-Original bleibt zugriffsbeschränkt (kyc-Bucket-RLS). - Edge-Case zu grosse/falsche Datei: Edge-Function validiert
mime_type/byte_sizevor Persistenz, lehnt sonst ab (kein verwaister Storage-Eintrag). - Löschpflicht (DSG): Ablauf der KYC-Notwendigkeit → Storage-Lifecycle-Job entfernt Datei + markiert
storage_objectsals gelöscht (Audit-Eintrag bleibt).
4.6 Notification senden (Outbox)
- Operatives Event (z. B. Offerte versendet, Zahlung erfasst) ruft
enqueue_notification(...)→notifications-Insert mitdedupe_key+ Empfänger-locale. - 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 GraphPOST /{phone-number-id}/messagesmit genehmigtem Template). - Erfolg →
status='sent',provider_message_id= Resend-data.idbzw. 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 heutesend-contact). Spätere Delivery-/Read-Status der WhatsApp Cloud API treffen per Webhook ein und aktualisieren dienotifications-Zeile (matched überprovider_message_id). - 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.) - Idempotenz:
dedupe_key-Unique verhindert Doppelversand bei Retry/Race (eine Zahlung = eine „Zahlung erhalten"-Mail). - Edge-Case fehlende Kontaktdaten: kein
to_email/to_phone⇒status='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-Helferhas_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-Modul | Event | Plattform-Reaktion |
|---|---|---|
| 4 Offerten | quote.sent | enqueue_notification(template=QUOTE_SENT, channel=email/whatsapp, owner=quote) + Audit |
| 6 Finanzen | payment.recorded (movement upsert, source_type ∈ debtor_payment/deposit_payment) | enqueue_notification(template=PAYMENT_RECEIVED, dedupe=payment_received:<movement_id>) + Audit |
| 5 Logistik | shipment.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/OCR | id_scanned | storage_objects-Insert (kyc) + Audit |
| alle | jede revisionsrelevante Mutation | audit_log-Zeile |
Auto-Posting-Bezug: Die Plattform schreibt keine Buchungen selbst — sie stellt nur Audit + Notification + Sperre. Das eigentliche Auto-Posting (Event →
movementsperupsertmitsource_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_code | Anlass | Kanäle | Empfänger |
|---|---|---|---|
QUOTE_SENT | Offerte versendet | email, whatsapp | Kunde |
PAYMENT_RECEIVED | Zahlung/Anzahlung verbucht | Kunde | |
SHIPMENT_STATUS | Sendungs-Status geändert (9 Stufen) | email, whatsapp | beide (#16): CH-Absender (contacts.preferred_lang) und DR-Empfänger (recipients.preferred_lang, F-07; Default ES) — Versand immer, kein Zahlungs-Gate (#17) |
AFFILIATE_STATEMENT | Affiliate-Abrechnung bereit | Affiliate | |
AUTH_MAGIC_LINK | Login-Link/OTP | interner 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_idFK erzwingt existierendesapp_users. - Affiliate-Doppelbindung:
uniqueaufaffiliate_users.user_idund.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 Fallbackkey-anzeigen + Lücken-Report. - Audit unmanipulierbar: UPDATE/DELETE auf
audit_logdurch Rules + entzogene Grants blockiert — auch fürADMINundservice_role. - Audit referenziert toten Nutzer:
actor_emailist 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_classserverseitig 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_rolenur 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_logist 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, Bucketsdocuments/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, nurADMIN/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_rolestrikt 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_stringsfür freie Texte) vs. alles inlocale_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_untildavon 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-workerversendet 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 ausrecipients.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 Schemauser_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
BUCHHALTUNGdas 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 überuser_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()analogcurrent_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 nutzteLoginController.Indexals 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_expiryeinstellbar). 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; RLSapp_userserlaubt Self-Update nur fürdisplay_name/preferred_locale/theme, nichtemail. Die Geschäfts-contacts.emailist davon entkoppelt.