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>
This commit is contained in:
@@ -0,0 +1,484 @@
|
||||
// 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"); }
|
||||
}
|
||||
Reference in New Issue
Block a user