// 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"); } }