Files
RAPPORT/src/storage/supabase-adapter.js
T
karim 27b1057cd4 Release 0.8.0: Cloud-Variante (Supabase, Multi-Studio, Realtime, Web-Deploy)
Rapport ist jetzt dual: lokal (wie bisher) ODER Cloud auf eigenem Supabase-Server.
Beide Modi haben dieselben Funktionen, Cloud zusätzlich Multi-User + Live-Sync.

Storage-Architektur
- src/storage/adapter.js: einheitliche Promise-API, LocalStorage- und SupabaseAdapter
- src/storage/migrations.js: applyMigrations als reine Funktion, für beide Backends
- Konfig-driven: VITE_SUPABASE_URL im Production-Build → automatisch Cloud-Modus

Postgres-Schema (supabase/migrations/0001–0010)
- 29 Tabellen, multi-tenant via studio_id + Row-Level-Security
- Audit-Spalten (created_by/updated_by/at) + Trigger
- Seed-Trigger pro neuem Studio (Rollen, Templates, Absenz-Typen)
- Realtime-Publication für Live-Sync
- RPCs: ensure_profile, create_studio_with_admin (mit Personen-Sharing),
  list_studios, load_persons_for_studio, attach_user_to_studio

Cloud-Features (App)
- BackendChoice.jsx als Erst-Screen «Lokal oder Cloud»
- CloudSetup.jsx: 3-Schritt-Wizard für Erst-Einrichtung
- Login.jsx: Modus-Switcher + Server-URL + Studio-Dropdown + Passwort-Vergessen
- ResetPassword.jsx: empfängt Mail-Link-Klick via PASSWORD_RECOVERY-Event
- Realtime: Änderungen zwischen Browsern ohne Reload sichtbar
- Settings → System: Cloud-Verbindung, Studio-Switcher, weiteres Studio anlegen
- Settings → Team: Mitarbeiter via Email einladen (Admin-Aktion)
- Personen-Sharing: bei neuem Studio Personen aus anderen Studios übernehmen
- Reload-Resume: studio_id in sessionStorage, kein erneuter Login nötig

Web-Deploy
- deploy/docker-compose.yml + nginx.conf: dist/ via nginx-Container, Port 8080
- .env.production.example: Build-time Cloud-URL
- DEPLOY.md: Anleitung für LAN-only und extern via Nginx Proxy Manager

Doku
- README.md: Cloud-Variante prominent erklärt
- ARCHITECTURE.md: Storage-Adapter, Migrations, neue Views in Risiko-Tabelle
- DEPLOY.md: Schritt-für-Schritt für Mac Mini + NPM

Version-Bump auf 0.8.0 in package.json, src-tauri/tauri.conf.json, Cargo.toml.
Changelog-Entry im App.jsx-Modal (Karim sieht ihn beim ersten Start).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 19:08:00 +02:00

485 lines
21 KiB
JavaScript

// SupabaseAdapter — Cloud-Variante des Storage-Adapters.
//
// Phase 3b.1 (jetzt): Skelett mit Connection-Setup und Auth-Check.
// load/save/clear werfen `NotImplementedError`.
// Phase 3b.2 (next): load() — alle Tabellen lesen, zu `data`-Shape zusammensetzen.
// Phase 3b.3: save() — `data` zerlegen, in Tabellen schreiben.
// Phase 3b.4: Auth-Flow (signIn/signUp), Studio-Wahl bei Multi-Studio-User.
//
// Multi-Tenant: jede Query filtert nach `studio_id` (kommt nach Login aus
// studio_members). Vor erfolgreichem Login kann nichts geladen werden.
import { createClient } from "@supabase/supabase-js";
import { fromDB, toDB } from "./supabase-mappers.js";
class NotImplementedError extends Error {
constructor(method) {
super(`SupabaseAdapter.${method}() — wird in einer späteren Phase implementiert.`);
this.name = "NotImplementedError";
}
}
export class SupabaseAdapter {
constructor(url, anonKey) {
if (!url || !anonKey) {
throw new Error("SupabaseAdapter: URL und Anon-Key sind erforderlich.");
}
this.url = url;
this.anonKey = anonKey;
this.client = createClient(url, anonKey, {
auth: {
// Session in localStorage persistieren (wie LocalStorage Adapter),
// damit der User nach Reload nicht erneut einloggen muss.
persistSession: true,
autoRefreshToken: true,
storageKey: "rapport_supabase_session",
},
});
this._studioId = null;
}
// Wird nach erfolgreichem Login gesetzt — bei Multi-Studio-Usern wählt
// der User explizit, in welchem Studio er gerade arbeitet.
setStudioId(studioId) {
this._studioId = studioId;
}
// Diagnose: prüft, ob die Cloud erreichbar ist (auth-Endpoint antwortet).
// Funktioniert auch ohne eingeloggten User.
async testConnection() {
try {
const { error } = await this.client.auth.getSession();
if (error) return { ok: false, error: error.message };
return { ok: true };
} catch (e) {
return { ok: false, error: e.message || String(e) };
}
}
// Login per Email + Passwort. Liefert { user, profile, studios } oder null
// bei falschen Credentials. Studios ist die Liste aller studio_members des
// Users — Caller wählt das aktive aus (in Phase 3b.5 mit UI-Dropdown).
async signIn(email, password) {
const { data: authData, error: authErr } = await this.client.auth.signInWithPassword({ email, password });
if (authErr || !authData?.user) return null;
const userId = authData.user.id;
const [{ data: profile }, { data: memberships }] = await Promise.all([
this.client.from("profiles").select("*").eq("id", userId).maybeSingle(),
this.client.from("studio_members")
.select("studio_id, app_role_id, studios(name, slug)")
.eq("user_id", userId)
.eq("active", true),
]);
return {
user: authData.user,
profile: profile || null,
studios: memberships || [],
};
}
async signOut() {
this.unsubscribeFromChanges();
await this.client.auth.signOut();
this._studioId = null;
}
// Passwort-Reset anfordern. Supabase Auth verschickt eine Mail mit einem
// Reset-Link, der auf `redirectTo` zurückführt. Wichtig: KEIN eigenes Hash-
// Fragment in redirectTo — Supabase appended sein eigenes (`#access_token=
// ...&type=recovery`), und zwei `#` brechen die URL.
async requestPasswordReset(email) {
const redirectTo = (typeof window !== "undefined")
? `${window.location.origin}${window.location.pathname}`
: undefined;
const { error } = await this.client.auth.resetPasswordForEmail(email, { redirectTo });
if (error) return { ok: false, error: error.message };
return { ok: true };
}
// Sign-Up für brandneue Cloud-Accounts. Selfhosted-Supabase hat per default
// `enable_confirmations = false`, also gibt's nach signUp direkt eine Session.
// Frontend muss anschließend `createStudio()` aufrufen, damit der User ein
// Studio hat (sonst hängt er im "0 Studios"-Limbo).
async signUp(email, password) {
const { data, error } = await this.client.auth.signUp({ email, password });
if (error || !data?.user) {
return { ok: false, error: error?.message || "signUp failed" };
}
return { ok: true, user: data.user };
}
// Anlegen / Aktualisieren des eigenen Profils. Pflicht vor createStudio,
// sonst zeigt data.users[] keinen displayName.
async ensureProfile(username, displayName) {
const { error } = await this.client.rpc("ensure_profile", {
p_username: username,
p_display_name: displayName,
});
if (error) throw new Error("ensureProfile: " + error.message);
}
// Öffentliche Liste aller Studios auf dieser Supabase-Instanz — wird vom
// Login-Screen genutzt, um den Studio-Dropdown vor Email+Passwort zu füllen.
// Kein Auth nötig (RPC läuft als SECURITY DEFINER).
async listStudios() {
const { data, error } = await this.client.rpc("list_studios");
if (error) {
console.error("listStudios:", error.message);
return [];
}
return data || [];
}
// Legt ein neues Studio an und macht den aktuellen User zum Admin.
// Optional: `sharePersonsFrom` ist eine Liste von Quell-Studio-IDs, deren
// Personen ins neue Studio mit übernommen werden (siehe RPC-Doc in 0007).
// Liefert die neue studio_id zurück.
async createStudio(name, slug, sharePersonsFrom = []) {
const { data, error } = await this.client.rpc("create_studio_with_admin", {
p_name: name,
p_slug: slug,
p_share_persons_from: sharePersonsFrom,
});
if (error) throw new Error("createStudio: " + error.message);
return data; // uuid
}
// Mitarbeiter ins aktuelle Studio einladen — Admin-Aktion.
// 1) signUp mit temporärem Client (kein Session-Persist, damit der Admin
// nicht selbst "ausgeloggt" und auf den Neuen umgeschaltet wird).
// 2) attach_user_to_studio RPC: legt Profile + Membership an. Prüft
// serverseitig, dass der Caller Admin im Ziel-Studio ist.
// Liefert das temp-Passwort zurück, damit der Admin es dem Mitarbeiter
// weitergeben kann (mündlich, separater Kanal etc.).
async inviteMember(email, tempPassword, displayName, appRoleId = "r-mitarbeiter") {
if (!this._studioId) return { ok: false, error: "Kein aktives Studio." };
// Temporärer Client für den signUp — eigene Session bleibt intakt
const tempClient = createClient(this.url, this.anonKey, {
auth: { persistSession: false, autoRefreshToken: false },
});
const { data: signUpRes, error: signErr } = await tempClient.auth.signUp({
email, password: tempPassword,
});
if (signErr || !signUpRes?.user) {
return { ok: false, error: signErr?.message || "signUp failed" };
}
const username = (email.split("@")[0] || "user").replace(/[^a-zA-Z0-9._-]/g, "");
const { error: attachErr } = await this.client.rpc("attach_user_to_studio", {
p_user_id: signUpRes.user.id,
p_studio_id: this._studioId,
p_app_role_id: appRoleId,
p_username: username,
p_display_name: displayName,
});
if (attachErr) return { ok: false, error: attachErr.message };
return { ok: true, userId: signUpRes.user.id };
}
// Liefert die Studios, in denen der aktuelle User Mitglied ist —
// gebraucht im Settings-Cloud-Tab (Studio-Switcher + Sharing-Auswahl).
async myStudios() {
const { data: sess } = await this.client.auth.getSession();
const userId = sess?.session?.user?.id;
if (!userId) return [];
const { data, error } = await this.client.from("studio_members")
.select("studio_id, app_role_id, studios(name, slug)")
.eq("user_id", userId)
.eq("active", true);
if (error) {
console.error("myStudios:", error.message);
return [];
}
return (data || []).map(m => ({
id: m.studio_id,
name: m.studios?.name,
slug: m.studios?.slug,
appRoleId: m.app_role_id,
}));
}
// Realtime: lauscht auf alle DB-Änderungen im aktuellen Studio und ruft
// `onChange()` (debounced vom Caller). Eine Subscription deckt alle Tabellen
// im public-Schema ab — wir filtern nicht weiter, weil die postgres_changes-
// API kein einfaches Tenant-Filter über Joins erlaubt. Stattdessen vertraut
// der Caller darauf, dass load() nur die studio_eigenen Daten zurückgibt
// (was via RLS garantiert ist).
subscribeToChanges(onChange) {
if (this._channel) return;
this._channel = this.client
.channel(`rapport-studio-${this._studioId}`)
.on("postgres_changes", { event: "*", schema: "public" }, () => {
try { onChange(); } catch (e) { console.error("onChange handler:", e); }
})
.subscribe();
}
unsubscribeFromChanges() {
if (this._channel) {
this.client.removeChannel(this._channel);
this._channel = null;
}
}
async hasExistingData() {
if (!this._studioId) return false;
const { count, error } = await this.client
.from("studios")
.select("id", { count: "exact", head: true })
.eq("id", this._studioId);
if (error) {
console.error("hasExistingData failed:", error);
return false;
}
return (count || 0) > 0;
}
// Lädt den vollständigen `data`-Snapshot eines Studios aus der Cloud.
// Sub-Tabellen (invoice_reminders, project_quote_links, delivery_note_items)
// werden via Inner-Join nach studio_id gefiltert, damit RLS-Konsistenz wahrt.
async load() {
if (!this._studioId) {
throw new Error("SupabaseAdapter.load: studio_id nicht gesetzt — setStudioId() nach Login.");
}
const sid = this._studioId;
const c = this.client;
const responses = await Promise.all([
c.from("studio_settings").select("*").eq("studio_id", sid).maybeSingle(),
c.from("studio_roles").select("*").eq("studio_id", sid).order("sort"),
// Personen kommen via RPC, weil geteilte (global, studio_id=NULL) nur über
// person_studio_links sichtbar werden — direkter studio_id-Filter würde sie verlieren.
c.rpc("load_persons_for_studio", { p_studio_id: sid }),
c.from("projects").select("*").eq("studio_id", sid).order("number"),
c.from("project_quote_links").select("*, projects!inner(studio_id)").eq("projects.studio_id", sid),
c.from("quotes").select("*").eq("studio_id", sid).order("number"),
c.from("invoices").select("*").eq("studio_id", sid).order("number"),
c.from("invoice_reminders").select("*, invoices!inner(studio_id)").eq("invoices.studio_id", sid),
c.from("time_entries").select("*").eq("studio_id", sid).order("date"),
c.from("expenses").select("*").eq("studio_id", sid).order("date"),
c.from("internal_expenses").select("*").eq("studio_id", sid).order("date"),
c.from("employees").select("*").eq("studio_id", sid).order("name"),
c.from("absences").select("*").eq("studio_id", sid),
c.from("vacation_entries").select("*").eq("studio_id", sid),
c.from("payroll_entries").select("*").eq("studio_id", sid),
c.from("overtime_closings").select("*").eq("studio_id", sid),
c.from("holidays").select("*").eq("studio_id", sid),
c.from("absence_types").select("*").eq("studio_id", sid),
c.from("letter_templates").select("*").eq("studio_id", sid),
c.from("app_roles").select("*").eq("studio_id", sid),
c.from("dashboard_templates").select("*").eq("studio_id", sid),
c.from("protocols").select("*").eq("studio_id", sid),
c.from("delivery_notes").select("*").eq("studio_id", sid).order("number"),
c.from("delivery_note_items").select("*, delivery_notes!inner(studio_id)").eq("delivery_notes.studio_id", sid),
c.from("blog_posts").select("*").eq("studio_id", sid).order("created_at", { ascending: false }),
// studio_members → wird zu data.users[] (zusammen mit profiles unten)
c.from("studio_members")
.select("user_id, app_role_id")
.eq("studio_id", sid)
.eq("active", true),
]);
// Profile-Lookup separat: PostgREST kann den Join über auth.users nicht inferren.
const memberIds = (responses[responses.length - 1].data || []).map(m => m.user_id);
let profilesById = {};
if (memberIds.length) {
const { data: profileRows, error: profErr } = await c.from("profiles")
.select("id, username, display_name")
.in("id", memberIds);
if (profErr) throw new Error("SupabaseAdapter.load profiles: " + profErr.message);
profilesById = Object.fromEntries((profileRows || []).map(p => [p.id, p]));
}
for (const r of responses) {
if (r.error) {
throw new Error("SupabaseAdapter.load: " + r.error.message);
}
}
const [
settingsR, rolesR, personsR, projectsR, quoteLinksR, quotesR,
invoicesR, remindersR, timeEntriesR, expensesR, internalExpensesR,
employeesR, absencesR, vacationR, payrollR, overtimeR, holidaysR,
absenceTypesR, letterTemplatesR, appRolesR, dashboardTemplatesR,
protocolsR, deliveryNotesR, deliveryNoteItemsR, blogPostsR,
membersR,
] = responses;
return {
settings: settingsR.data
? fromDB.studioSettings(settingsR.data, rolesR.data || [])
: undefined,
persons: (personsR.data || []).map(fromDB.person),
projects: (projectsR.data || []).map(p => fromDB.project(p, quoteLinksR.data || [])),
quotes: (quotesR.data || []).map(fromDB.quote),
invoices: (invoicesR.data || []).map(i => fromDB.invoice(i, remindersR.data || [])),
timeEntries: (timeEntriesR.data || []).map(fromDB.timeEntry),
expenses: (expensesR.data || []).map(fromDB.expense),
internalExpenses: (internalExpensesR.data || []).map(fromDB.internalExpense),
employees: (employeesR.data || []).map(fromDB.employee),
absences: (absencesR.data || []).map(fromDB.absence),
ferienEntries: (vacationR.data || []).map(fromDB.vacationEntry),
lohnEntries: (payrollR.data || []).map(fromDB.payrollEntry),
uberstundenAbschluss: (overtimeR.data || []).map(fromDB.overtimeClosing),
feiertage: (holidaysR.data || []).map(fromDB.holiday),
absenzTypes: (absenceTypesR.data || []).map(fromDB.absenceType),
letterTemplates: (letterTemplatesR.data || []).map(fromDB.letterTemplate),
appRoles: (appRolesR.data || []).map(fromDB.appRole),
dashboardTemplates: (dashboardTemplatesR.data || []).map(fromDB.dashboardTemplate),
protocols: (protocolsR.data || []).map(fromDB.protocol),
deliveryNotes: (deliveryNotesR.data || []).map(dn => fromDB.deliveryNote(dn, deliveryNoteItemsR.data || [])),
blogPosts: (blogPostsR.data || []).map(fromDB.blogPost),
users: (membersR.data || []).map(m => {
const p = profilesById[m.user_id] || {};
return {
id: m.user_id,
username: p.username || "",
displayName: p.display_name || p.username || "",
appRoleId: m.app_role_id,
role: m.app_role_id === "r-admin" ? "admin" : "user",
};
}),
};
}
// Schreibt den Snapshot in die Cloud. Queue-Pattern: pro Zeitpunkt läuft
// höchstens ein Write — neue save()-Calls werden in `_pendingData` gesammelt
// und nach dem aktuellen Write zu einem einzigen weiteren Write zusammen-
// geführt (coalescing). Damit gibt es keine Race-Conditions und kein
// verzögertes Schreiben, das bei einem Page-Reload verloren gehen könnte.
//
// Strategie: "Full Replace per studio_id" — UPSERT für Konfig, UPSERT +
// DELETE-not-in-snapshot für Daten, DELETE+INSERT für Sub-Tables (reminders/
// items/quote-links). Kein echter Diff, last-write-wins. Reicht für Single-
// User-Studios.
async save(data) {
if (!this._studioId) {
throw new Error("SupabaseAdapter.save: studio_id nicht gesetzt.");
}
this._pendingData = data;
if (this._currentWrite) return this._currentWrite;
this._currentWrite = (async () => {
try {
while (this._pendingData) {
const next = this._pendingData;
this._pendingData = null;
await this._writeSnapshot(next);
}
} finally {
this._currentWrite = null;
}
})();
return this._currentWrite;
}
async _writeSnapshot(data) {
const sid = this._studioId;
const c = this.client;
// ── 1. Studio-Settings (Singleton) und Konfig-Tabellen (UPSERT-only,
// kein Cleanup wegen referentieller Bindungen wie studio_members.app_role_id)
const configOps = [
c.from("studio_settings").upsert(toDB.studioSettings(data.settings || {}, sid), { onConflict: "studio_id" }),
];
if (data.settings?.roles?.length)
configOps.push(c.from("studio_roles").upsert(toDB.studioRoles(data.settings.roles, sid), { onConflict: "studio_id,id" }));
if (data.appRoles?.length)
configOps.push(c.from("app_roles").upsert((data.appRoles || []).map(r => toDB.appRole(r, sid)), { onConflict: "studio_id,id" }));
if (data.dashboardTemplates?.length)
configOps.push(c.from("dashboard_templates").upsert((data.dashboardTemplates || []).map(d => toDB.dashboardTemplate(d, sid)), { onConflict: "studio_id,id" }));
if (data.absenzTypes?.length)
configOps.push(c.from("absence_types").upsert((data.absenzTypes || []).map(t => toDB.absenceType(t, sid)), { onConflict: "studio_id,id" }));
if (data.letterTemplates?.length)
configOps.push(c.from("letter_templates").upsert((data.letterTemplates || []).map(t => toDB.letterTemplate(t, sid)), { onConflict: "studio_id,id" }));
if (data.feiertage?.length)
configOps.push(c.from("holidays").upsert((data.feiertage || []).map(h => toDB.holiday(h, sid)), { onConflict: "studio_id,date" }));
await this._allOk(configOps, "config");
// ── 2. Daten-Parents (UPSERT + DELETE-not-in-snapshot)
await Promise.all([
this._syncTable("persons", sid, (data.persons || []).map(p => toDB.person(p, sid))),
this._syncTable("employees", sid, (data.employees || []).map(e => toDB.employee(e, sid))),
]);
// ── 3. Daten-Mid-Level (referenzieren persons/employees)
await Promise.all([
this._syncTable("projects", sid, (data.projects || []).map(p => toDB.project(p, sid))),
this._syncTable("quotes", sid, (data.quotes || []).map(q => toDB.quote(q, sid))),
this._syncTable("absences", sid, (data.absences || []).map(a => toDB.absence(a, sid))),
this._syncTable("vacation_entries", sid, (data.ferienEntries || []).map(v => toDB.vacationEntry(v, sid))),
this._syncTable("payroll_entries", sid, (data.lohnEntries || []).map(p => toDB.payrollEntry(p, sid))),
this._syncTable("overtime_closings", sid, (data.uberstundenAbschluss || []).map(o => toDB.overtimeClosing(o, sid))),
]);
// ── 4. Daten-Children (referenzieren projects/quotes)
await Promise.all([
this._syncTable("invoices", sid, (data.invoices || []).map(i => toDB.invoice(i, sid))),
this._syncTable("time_entries", sid, (data.timeEntries || []).map(t => toDB.timeEntry(t, sid))),
this._syncTable("expenses", sid, (data.expenses || []).map(e => toDB.expense(e, sid))),
this._syncTable("internal_expenses", sid, (data.internalExpenses || []).map(e => toDB.internalExpense(e, sid))),
this._syncTable("protocols", sid, (data.protocols || []).map(p => toDB.protocol(p, sid))),
this._syncTable("delivery_notes", sid, (data.deliveryNotes || []).map(d => toDB.deliveryNote(d, sid))),
this._syncTable("blog_posts", sid, (data.blogPosts || []).map(b => toDB.blogPost(b, sid))),
]);
// ── 5. Sub-Tables (replace per parent — Inhalt kommt direkt aus den Parent-Rows)
await Promise.all([
this._replaceSubTable("project_quote_links", "project_id",
(data.projects || []).map(p => p.id),
toDB.projectQuoteLinks(data.projects || [])),
this._replaceSubTable("invoice_reminders", "invoice_id",
(data.invoices || []).map(i => i.id),
toDB.invoiceReminders(data.invoices || [])),
this._replaceSubTable("delivery_note_items", "delivery_note_id",
(data.deliveryNotes || []).map(d => d.id),
toDB.deliveryNoteItems(data.deliveryNotes || [])),
]);
}
// UPSERT alle rows + DELETE was nicht mehr im snapshot ist (gleicher studio_id-Scope)
async _syncTable(table, sid, rows) {
const ids = rows.map(r => r.id).filter(Boolean);
const ops = [];
if (rows.length) {
ops.push(this.client.from(table).upsert(rows));
}
let delQ = this.client.from(table).delete().eq("studio_id", sid);
if (ids.length) {
delQ = delQ.not("id", "in", `(${ids.join(",")})`);
}
ops.push(delQ);
await this._allOk(ops, table);
}
// Sub-Tables: keine eigene studio_id, Filter über Parent-ID-Liste.
// Strategy: DELETE alle für (parent_id ∈ parentIds), dann INSERT die neuen rows.
async _replaceSubTable(table, parentField, parentIds, rows) {
if (parentIds.length === 0 && rows.length === 0) return;
const ops = [];
if (parentIds.length) {
ops.push(this.client.from(table).delete().in(parentField, parentIds));
}
if (rows.length) {
// Insert kommt nach Delete (Reihenfolge wichtig) — daher sequentiell
await this._allOk(ops, table);
const { error } = await this.client.from(table).insert(rows);
if (error) throw new Error(`INSERT ${table}: ${error.message}`);
return;
}
await this._allOk(ops, table);
}
async _allOk(ops, label) {
const results = await Promise.all(ops);
for (const r of results) {
if (r.error) throw new Error(`${label}: ${r.error.message}`);
}
}
async clear() { throw new NotImplementedError("clear"); }
}