Skip to content

13 – Integrationen (Google / Microsoft) + Storage + Geo

  • Reifegrad🟡 funktional, Teile Beta
  • Schicht`src/lib/google/*`, `src/lib/microsoft/*`, `src/lib/calendar/*`, `src/lib/geo/*`, `@work7/storage`, `src/app/api/{google,microsoft,calendar,geo,places}/*`

Gilt ab Work7 0.3.0 · Stand 16.09.2026

Zweck

Anbindung externer Plattformen: Kalender- und Mail-Sync mit Google Workspace und Microsoft 365, Adress-/Geo-Daten über Google Places sowie mandantenspezifischer Dokumentenspeicher.

Google Workspace

  • Auth: src/lib/google/auth.ts, request-auth.ts, config.ts, clients.ts, company-connection.ts. Unterstützt Domain-Wide Delegation (DWD) (Service-Account impersoniert Tenant-User) – Setup-Doku in src/dwd_setup1.png/dwd_setup2.png, Test-Skripte scripts/test-google-dwd.ts, scripts/send-google-dwd-test-mails.ts, scripts/list-google-tenant-users.ts.
  • Kalender (/api/google/calendar/*): calendars, events (+ [eventId]), Watch-Channels watch/{start,status,stop,webhook} (Push-Notifications).
  • Gmail (/api/google/gmail/*): messages (+ [id]), send.
  • Verwaltung: /api/google/setup, connection-test, health, users, users/suggestions.
  • Bootstrap eingeladener Nutzer: src/lib/google/invite-user-bootstrap.ts.

Microsoft 365 (Graph)

  • Spiegelbildlich: src/lib/microsoft/* (auth, clients, company-connection, config, errors).
  • Kalender (/api/microsoft/calendar/*): calendars, events (+ [eventId]), watch/{start,status,stop,webhook}.
  • Mail (/api/microsoft/mail/*): messages (+ [id]), send.
  • Verwaltung: /api/microsoft/setup, connection-test, users, users/suggestions.
  • Einrichtung (Einstellungen › Integrationen › Kalender & E-Mail): ein Knopf „Bei Microsoft anmelden“ leitet zum Admin-Consent (login.microsoftonline.com/organizations/adminconsent). Der Rückruf auf /settings/integrations?admin_consent=True&tenant=… startet connection-test automatisch; die Route legt die Verbindung an, prüft Organisation, Verzeichnis und ein Postfach und setzt sie auf active. Eine Tenant-ID wird nicht mehr von Hand eingegeben.

Kalender-Sync-Engine (src/lib/calendar/*)

Provider-neutrale Sync-Schicht über den Termin-Tabellen (Migration 016):

  • sync-service.tsenqueueOutboundAppointmentSync, Outbound-Queue.
  • mirror-sync.ts / provider-inbound.ts – Inbound: Provider-Eventzeiten auf verknüpfte appointments (inkl. Abwesenheit/leave_requests/user_absences); Löschungen canceln.
  • google-watch.ts / microsoft-watch.ts – Push-Channel-Lifecycle.
  • outbound-push.ts – interne Termine → Provider (pushAppointmentToActiveProviders).
  • unified-events.ts – gemeinsames Event-Modell beider Provider.
  • ics.ts – ICS/iCalendar-Generierung. browser-cache.ts – clientseitiger Cache.
  • Verbindungs-/Sync-State: sync_connections, external_event_links, sync_events, sync_runs; status/conflict_strategy-Lifecycle (Migration 018). API: /api/calendar/connect/[provider]/{start,callback}, /api/calendar/connections[/id], /api/calendar/sync/[provider]/{delta,webhook}.

Geo / Places (src/lib/geo/*)

  • google-places.ts + company-bias.ts – Adress-Autocomplete mit Bias auf den Firmenstandort.
  • API: /api/geo/address-autocomplete, /api/geo/place-details, /api/places/*.
  • Nutzung in Kontakt-/Projekt-/Firmen-Adressfeldern (siehe 03-kontakte-crm).

Mandanten-Storage (@work7/storage)

Zusammenfassung (Detail in 02-mandanten-company-settings): Default Azure Blob, optional SharePoint/Google Drive/Dropbox/Nextcloud. Google Drive, Dropbox und Nextcloud laufen über delegiertes PKCE-OAuth (connect/[provider]/{start,callback}, verschlüsselte Access-/Refresh- Tokens in tenant_storage_oauth_credentials). SharePoint läuft seit Sept. 2026 über Application-Permissions (siehe unten) und speichert gar keine Tokens mehr. Tabellen tenant_storage_settings, tenant_storage_oauth_credentials, document_storage_mappings. APIs unter /api/settings/storage/*. WhatsApp-Medien des Agents landen im selben Tenant-Storage (work7-media).

SharePoint über App-Berechtigungen (Sites.Selected)

SharePoint nutzt kein delegiertes OAuth mehr: Work7 spricht Microsoft Graph mit Application-Permissions an (Client Credentials) und bekommt Zugriff auf genau eine vom Kunden freigegebene Site — nicht auf den gesamten Tenant (Sites.ReadWrite.All wird bewusst nicht verwendet). Code: packages/storage/src/sharepoint-app.ts (Tenant-ID, Token-Cache, Site-Auflösung, Fehlerklassifizierung, Freigabe-Befehl) und sharepoint-connect.ts (Verbinden).

  • Tenant-ID kommt aus der bestehenden Microsoft-365-Verbindung des Mandanten (sync_connections.token_state->>'microsoftTenantId', provider='microsoft', aktive zuerst). Es gibt kein Eingabefeld. Fehlt die Verbindung, verweist die Oberfläche auf Einstellungen › Integrationen › Kalender & E-Mail.
  • Token: Client Credentials gegen {MICROSOFT_LOGIN_BASE_URL}/{tenantId}/oauth2/v2.0/token, scope=https://graph.microsoft.com/.default, mit MICROSOFT_CLIENT_ID/MICROSOFT_CLIENT_SECRET (dieselben wie Mail/Kalender). Das Token wird je Tenant im Speicher gehalten (Ablauf minus 120 s) und nie in der Datenbank abgelegt. Zentrale Abzweigung: getValidOAuthAccessToken(db, companyId, 'sharepoint').
  • Kein Redirect: POST /api/settings/storage/sharepoint/connect (site_url, optional site_id) löst die Site auf, prüft den Schreibzugriff (Testordner anlegen/löschen) und speichert sharepointSiteUrl + sharepointSiteId; danach bootstrapTenantWorkspace. connect/sharepoint/{start,callback} lehnt mit klarem Fehler ab statt still durchzulaufen.
  • Kein OneDrive-Modus mehr: Mit Sites.Selected gibt es kein /me/drive (kein angemeldeter Benutzer). Eine Team-Site ist Pflicht; graphDriveBaseUrl(siteId) verlangt die Site-ID.
  • Fehlerbilder (SharePointConnectError.code, HTTP-Status in der Route): no_microsoft_connection (409), site_not_shared (403, Graph 401/403 — Site noch nicht freigegeben), site_not_writable (403, Freigabe nur mit read), site_not_found (404), invalid_site_url / site_not_configured (400), token_failed / graph_error (502).
  • Trennen: Microsoft kennt kein App-seitiges Revoke. „Trennen“ löscht nur die lokalen Angaben (clearSharePointSiteSettings); die Site-Freigabe hebt der Microsoft-Admin selbst auf. Schlägt die Azure-Fallback-Kopie fehl, wird trotzdem getrennt (migrationError in der Antwort).

Site-Freigabe durch den Kunden-Admin. Die Freigabe einer einzelnen Site für eine App läuft über POST /sites/{siteId}/permissions mit {"roles":["write"],"grantedToIdentities":[{"application":{"id":"<client-id>","displayName":"Work7"}}]} und ist im SharePoint Admin Center nicht klickbar. Die Oberfläche zeigt deshalb einen fertigen PowerShell-Befehl (Connect-MgGraph -Scopes "Sites.FullControl.All" + New-MgSitePermission) mit eingesetzter Client-ID und Site zum Kopieren. Die Client-ID liefert GET /api/settings/storage/setup (kein Geheimnis); das Client-Secret verlässt den Server nie. Kundenanleitung: microsoft-365-einrichten.

Offener Punkt: Ob GET /sites/{hostname}:{pfad} (Auflösung der Site-URL zur Site-ID) mit reinem Sites.Selected funktioniert oder zusätzlich Sites.Read.All benötigt, ist in der Microsoft-Doku widersprüchlich und von uns noch nicht am echten Tenant verifiziert. Der Code ist deshalb so gebaut, dass ein Scheitern der URL-Auflösung das Feature nicht blockiert: Der Kunde kann die Site-ID direkt eintragen (optionales Feld), dann wird die URL-Auflösung übersprungen.

Ordnerstruktur (SharePoint, Google Drive, Dropbox, Nextcloud)

Seit dem Ordner-Register (Ablage-Sync Phase 1, Plan docs/plans/plan-ablage-sync.md) werden Ordner über storage_folders gefunden, nicht über den Namen. Neue Ordner tragen lesbare Namen:

text
{company_id}/work7-app/
├── customers/
│   ├── {Kundenname}/                    # Namensgleichheit: "Müller GmbH (2)"
│   │   └── {PR-Nummer} {Projektname}/   # D1, z. B. "PR-4711-0003 Neubau Schule"
│   │       ├── Dateien | Verträge | Bilder | Notizen
│   │       └── Bautagebuch | Aufmaß | Abnahme
│   │   └── _Archiv/{PR-Nummer} {Projektname}/   # archivierte Projekte (D3)
│   ├── _Ohne Kunde/{PR-Nummer} {Projektname}/…  # auch Projekte gelöschter Kunden
│   └── _Archiv/{Kundenname}/ · _Archiv/Gelöscht/{Kundenname}/
├── anfragen/{ANF-Nummer}/               # Fallback "Anfrage {id}"
│   ├── Dateien | Bilder | E-Mails
│   └── _Archiv/{ANF-Nummer}/            # gelöschte Anfragen mit fremden Dateien
├── system/agent-media | leave           # technisch, Upload-Staging nur ohne Azure-Konfiguration
└── import-migration/inbox/material

Kategorie-Ordner: interne Schlüssel bleiben (files, contracts, images, notes, bautagebuch, aufmass, acceptance, mail), die Anzeigenamen stehen in CATEGORY_FOLDER_LABELS (packages/storage/src/folder-names.ts, D5).

Bestehende Ordner nach bisherigem Schema (customers/{kontakt-slug}/projects/{projekt-slug}/{schlüssel}, anfragen/{nummer-slug}) werden beim ersten Zugriff bzw. per Backfill übernommen, damit alte und neue Dateien zusammen bleiben. Beim ersten Sync-Lauf der Entität (Phase 2) werden sie auf das neue Schema gebracht: Name nach D1, Kategorien auf Deutsch (D5), Projekt aus der Zwischenebene projects/ direkt in den Kundenordner. Die leere Zwischenebene bleibt stehen (Aufräumen: Phase 4).

Azure Blob hat keine echten Ordner: kein Register, neue Uploads nutzen stabile ID-Pfade {company}/work7-app/projects/{project_id}/{kategorie}, …/anfragen/{anfrage_id}/{kategorie}, …/customers/{kontakt_id}; bestehende Blobs bleiben liegen.

DomäneZiel (putTenantObject target)
Anfrage (Upload){ entity: 'anfrage', id, category: 'files' | 'images' }
Mail-Anhang nach Sync (app + landing){ entity: 'anfrage', id, category: 'mail' }
Mail vor Anfragekein Tenant-Pfad (Provider)
WhatsApp-Bild/-Video zum Projekt{ entity: 'project', id, category: 'images' }, Name WhatsApp {Datum Uhrzeit} {Absender}.jpg
Abnahme-Foto{ entity: 'project', id, category: 'acceptance' }, ohne Projekt { entity: 'system', key: 'agent-media' }
Materialimport{ entity: 'system', key: 'import-material' }
WhatsApp-/Assistent-ZwischenablageAzure Blob system/agent-media (D8), Fallback Firmenspeicher ohne Azure-Konfiguration
Logo / E-RechnungDB, nicht Tenant-Storage

Ordner-Register (storage_folders, Migration 115)

SpalteInhalt
entity_type / entity_idroot (tenant, app, customers, anfragen, no_customer), customer (kontakt_id), project (project_id), project_category ({project_id}:{schlüssel}), anfrage, anfrage_category, system (system/agent-media, import-migration/inbox/material …)
parent_id, folder_name, pathBaum, aktueller Name und Pfad beim Anbieter
external_iddriveItem-ID (SharePoint), File-ID (Drive), id:… (Dropbox), NULL (Nextcloud)
stateactive, archived (liegt im Archiv), orphaned (Projektzeile gelöscht, Ordner bleibt), missing (Ordner beim Upload nicht mehr gefunden), error (Sync nach allen Versuchen fehlgeschlagen)

Eindeutig je (company_id, provider, entity_type, entity_id); registrierte Geschwister dürfen nicht denselben Namen haben (Groß/Klein egal), und ein Anbieter-Ordner (external_id) gehört höchstens einer Entität. storage_sync_log protokolliert create, adopt, missing, rename, move, move_file, remove, state:* und fehlgeschlagene Läufe mit Auslöser (upload, event, manual). document_storage_mappings.folder_id merkt sich den Ordner jeder Datei (für das Umschreiben bei pfadbasierten Anbietern ab Phase 2).

Ablauf beim Upload (resolveUploadTargetFolderResolver, folder-registry.ts):

  1. Registrierter Ordner → sofort nutzen, kein Anbieter-Aufruf.
  2. Sonst Elternordner auflösen, Altordner nach bisherigem Schema suchen und übernehmen (nicht, wenn er schon einer anderen Entität gehört).
  3. Sonst folderNameFor(entity, geschwister) und ensureFolder (übernimmt gleichnamigen Ordner, bei gleichnamiger Datei (2)), dann registrieren.
  4. Meldet der Anbieter beim Upload „Ordner fehlt“, wird der Eintrag missing gesetzt, neu aufgelöst und einmal wiederholt.
  5. Ist die Tabelle noch nicht migriert (42P01), fällt der Upload auf die bisherigen Pfade zurück.

Anbieter-Operationen (FolderOps, folder-ops.ts): ensureFolder, findChild, getFolder, renameFolder, moveFolder, listChildren — SharePoint (Graph, per ID), Google Drive (files.update mit addParents/removeParents), Dropbox (create_folder_v2, move_v2) und Nextcloud (WebDAV MKCOL/MOVE/PROPFIND). Alle Anbieter-Aufrufe laufen über fetchWithRetry (429/503 mit Retry-After, sonst exponentiell, max. 3 Wiederholungen).

Ereignisgetriebener Sync (Phase 2, Queue storage-sync)

  • Einreihen: publishDomainEvent ruft nach enqueueWebhookEvent den Hook enqueueStorageSync (packages/domain/src/storage-sync-hook.ts). Er stößt das Einreihen an, ohne zu warten, und zwar über afterCommit (packages/domain/src/after-commit.ts): innerhalb von withTransaction erst nach dem COMMIT, bei ROLLBACK gar nicht — der Worker liest nie einen Stand vor dem Commit. App (instrumentation.tslib/storage/folder-sync-adapter.ts, Producer lib/storage/storage-sync-queue.ts) und Agent-Worker registrieren über setStorageSyncEnqueuer einen Enqueuer (createStorageSyncEnqueuer): nur für customers.*, projects.*, anfragen.* und nur für Mandanten mit OAuth-Anbieter (storageSyncEnabled, 60 s Cache).
  • Job: ID {company}:{entity}:{id}, 3 s Verzögerung (Events fallen vor dem Commit; Mehrfach- änderungen werden zusammengefasst), läuft der Job gerade, folgt genau ein Nachlauf …-next (laufen beide, kommt ein weiterer Nachlauf …-t{Zeitstempel} dazu — kein Event wird verworfen). Jeder Lauf vergleicht am Ende den Soll-Fingerabdruck (Lebenszyklus, Name, Soll-Elternordner, Gewinnprojekt) mit dem Stand beim Planen; weicht er ab, reiht sich der Job erneut ein. 5 Versuche, exponentieller Backoff ab 5 s (STORAGE_SYNC_JOB_OPTIONS); Prefix und Redis-DB wie die übrigen Queues (BULLMQ_PREFIX, REDIS_*). Consumer: Agent-Worker (handleStorageSyncJob, Nebenläufigkeit 2). Nach dem letzten Versuch state = 'error' (markEntityFolderError).
  • Rückfall: Kann nicht eingereiht werden (kein Redis, SKIP_REDIS=1, Timeout 3 s), läuft der Sync ersatzweise im Prozess (runStorageSyncInProcess); ein hängendes Redis blockiert keinen Request.
  • Ablauf je Job (syncEntityFolder): Mandanten-Sperre als Session-Advisory-Lock pg_advisory_lock(hashtext('storage-sync:{company}')) auf einer eigenen Verbindung (keine Transaktion bleibt über Anbieter-Aufrufe offen), Soll-Zustand aus der DB (sqlSyncStateLoader), Ist aus dem Register, reine Planung planFolderOps(soll, ist) (folder-plan.ts), dann jede Operation einzeln. Beim Kunden werden zuerst seine Projekte synchronisiert (Archiv-/Lösch- Kaskaden ändern Projekte ohne eigenes Event).
  • Atomarität je Operation: Ordner umbenennen/verschieben = erst Anbieter, dann Register und Referenzen in einer kurzen Transaktion. Scheitert die DB danach, bleibt das Register auf dem alten Stand; der Retry findet die Quelle nicht mehr, aber das Ziel (findChild) und zieht nur Register und Referenzen nach. Einzeldateien (D4) umgekehrt: erst Referenzen, dann Anbieter in einer Transaktion (scheitert der Anbieter, wird zurückgerollt). Meldet der Anbieter einen Fehler, obwohl er schon verschoben hat, erkennt der Executor die Datei am Ziel und zieht nur die Referenz nach; Referenzen, die nach einem abgebrochenen Lauf noch auf den Anfrageordner zeigen, werden vor dem Entfernen am Ziel gesucht und repariert (repair_ref). Entfernen ist idempotent: erst Anbieter (404 = schon weg), dann Register.
  • Zeitlimits: Gesamtbudget 10 min je Lauf (runWithDeadline, bricht Anbieter-Aufrufe ab), 60 s je Anbieter-Aufruf, auf der dedizierten Verbindung lock_timeout = 120s (Warten auf die Sperre) und statement_timeout = 60s; ein hängender Anbieter hält die Mandanten-Sperre nicht unbegrenzt.
Ereignis (Soll)Operationen
Kunde angelegtkeine (D2)
Kunde umbenanntrename; Groß/Klein-Änderung und stabiler Zusatz (n) sind No-Ops
Kunde archiviert / reaktiviertmove nach customers/_Archiv bzw. zurück, state
Kunde gelöschtProjekte nach _Ohne Kunde, Kunde nach _Archiv/Gelöscht
Projekt angelegtensure + alle Kategorien (Kundenordner entsteht als Elternordner)
Projekt umbenannt / anderem Kunden zugeordnetrename bzw. move (Namenskonflikt → (2))
Projekt archiviert / reaktiviertmove nach {Kunde}/_Archiv bzw. _Ohne Kunde/_Archiv und zurück; liegt der Kunde selbst im Archiv, bleibt das Projekt in seinem Ordner
Projektzeile gelöschtstate = 'orphaned', Ordner bleibt
Anfrage angelegt / bestätigtensure + Kategorien (nicht für Entwürfe eingegangen sowie verloren/abgelehnt; vorhandene Ordner bleiben)
Anfrage gelöschtleeren Ordner entfernen, sonst nach anfragen/_Archiv
Anfrage gewonnen (D4)Inhalte von Dateien/Bilder/E-Mails in Projekt Dateien/Bilder/Dateien verschieben (Namenskonflikt (2)), Anfrageordner entfernen
Altordner übernommenUmbenennen auf D1/D5, aus projects/ in den Kundenordner
  • Referenzen: Nach rename/move schreibt der Executor Register-Pfade des ganzen Teilbaums um und ruft sqlReferenceRewriter: document_storage_mappings immer (Anzeige-Pfad, bei Nextcloud auch die ID; Auswahl über folder_id und Pfad-Präfix), bei Dropbox/Nextcloud zusätzlich die Pfadspalten von documents.file_path (nur Dokumente mit Mapping dieses Anbieters), acceptance_media, agent_media, catalog_import_sessions, supplier_catalog_files. Verschobene Einzeldateien (D4) bekommen external_path und folder_id des Projektordners. Jede Abfrage ist auf company_id beschränkt. SharePoint/Drive-Links bleiben per ID gültig.
  • Anbieter-Operationen ergänzt: moveFolder(ref, parent, newName?) (auch für Dateien), deleteFolder (nur leere Ordner; Graph: Papierkorb, Drive: trashed, Dropbox delete_v2, Nextcloud DELETE).

Dateinamen (D7): Originalname bereinigt (sanitizeFileName, Umlaute bleiben); belegt → (2), (3) … (SharePoint/Dropbox per Konflikt-Antwort, Drive/Nextcloud per Namensprüfung). Azure Blob behält Zeitstempel-Präfix.

Backfill: scripts/storage-folders-backfill.ts --company <id> [--dry-run] registriert bestehende Ordner (nichts anlegen, nichts verschieben) und listet Ordner ohne Zuordnung; siehe docs/runbooks/runbook-ablage-backfill.md.

resolveUploadFolder legt relative Pfade immer unter den Tenant-Root. Parent-Ordner werden lazy beim Upload (putTenantObject) sichergestellt. Bestehende Blob-Pfade bleiben gültig (keine Pflicht-Migration).

Rückfall ohne Register (legacyProjectCategoryPath): gleichnamige Kunden bekommen wie im Bootstrap eindeutige Slugs (muller-gmbh, muller-gmbh-2, vergeben in id-Reihenfolge der aktiven Kunden), Projekte ohne Kunden liegen unter customers/_Ohne Kunde/{projekt-slug}/….

Datei-Referenzen (StorageRef)

Jede Referenzspalte beschreibt ein Objekt als StorageRef { provider, externalId, path } (packages/storage/src/storage-ref.ts). Geladen wird ausschließlich über downloadTenantBlob(db, { companyKey, ref }) bzw. downloadStorageRef; der Anbieter entscheidet, ob per ID (SharePoint driveItem, Drive-File-ID, Dropbox id:…) oder per Pfad (Azure, Nextcloud) geladen wird. resolveStorageRef ergänzt bei Altzeilen ohne Anbieter-Spalte den Anbieter aus document_storage_mappings (jeder Upload schreibt dort Anbieter + ID), sonst gilt: Schlüssel mit / = Azure-Blob-Pfad, reine ID = aktueller Anbieter des Mandanten.

TabelleReferenzspalten
document_storage_mappingsprovider, external_id, external_path
acceptance_mediastorage_provider, storage_external_id, storage_key (Migration 115)
agent_mediastorage_provider, storage_external_id, blob_path
catalog_import_sessions, supplier_catalog_filesstorage_provider, storage_external_id, storage_path

Abgleich (Phase 3, reconcile.ts, reconcile-plan.ts)

Sicherheitsnetz für verlorene Events und Änderungen direkt im Speicher (Plan §7.3). Cron POST /api/cron/storage-reconcile?mode=light|deep (Bearer CRON_SECRET, Kubernetes-CronJobs aus deploy/chart/templates/cronjobs.yaml: light stündlich :07, deep 02:30 Europe/Vienna).

  • light: Soll aus der DB gegen das Register, ohne Anbieter-Aufrufe. Jeder erfolgreiche Sync speichert den Soll-Fingerabdruck in storage_folders.synced_fingerprint (Migration 116); planReconcile reiht Sync-Jobs ein für: fehlende Ordner aktiver Projekte/bestätigter Anfragen, abweichenden Fingerabdruck (verlorenes Event, übernommener Altordner), gelöschte Datensätze (z. B. Anfragen gelöschter Kunden, die ohne Event verschwinden), Zustand error/missing.
  • deep: zusätzlich Ist beim Anbieter. SharePoint root/delta (Grundlinie token=latest) und Google Drive changes.list (startPageToken) mit Cursor in tenant_storage_settings.metadata.storageReconcile; beim ersten Lauf bzw. abgelaufenem Cursor (Graph 410, Drive 400/404/410) eine vollständige Prüfung aller registrierten Ordner in Tranchen (300 je Lauf). Höchstens 20 Seiten Änderungen je Lauf, der Rest folgt ab dem gespeicherten Cursor. Graph-Delta ist bei OneDrive for Business/SharePoint nur an der Laufwerkswurzel möglich, liefert also Änderungen des ganzen Laufwerks; ausgewertet werden nur registrierte Ordner-IDs. Dropbox und Nextcloud per getFolder je registriertem Ordner in Tranchen (Fortschritt in metadata.storageReconcile.offset).
  • Externe Änderungen (D6): Ordner von Hand umbenannt/verschoben → Register auf den beobachteten Stand, der Sync stellt Name und Ort wieder her; feste Ordner (customers, system …) werden direkt zurückbenannt. Von Hand gelöscht → Teilbaum missing, betroffene Entitäten neu anlegen, Datei-Referenzen darin document_storage_mappings.missing_since („nicht auffindbar“). Entfernte Dateien aus dem Änderungsprotokoll ebenso; tauchen sie wieder auf, wird die Markierung gelöscht. Nextcloud meldet Umbenennungen als „fehlt“ (Pfad-Anbieter, keine IDs) — der Ordner wird neu angelegt, der umbenannte bleibt als fremder Ordner stehen. Fremde Dateien und Ordner sind nicht registriert und werden nie angefasst.
  • Einrichtung: bootstrapTenantWorkspace (OAuth-Callback, Einstellungen speichern, „Verbindung testen“, Skript scripts/bootstrap-tenant-storage.ts) legt nur noch die Grundordner über das Register an und startet den tiefen Abgleich im Hintergrund; metadata.workspaceBootstrap wird nicht mehr gelesen oder geschrieben.
  • Status-Ansicht: GET /api/settings/storage/status (loadStorageStatus: letzter Abgleich, Ordner je Zustand, Fehler/fehlende Ordner, nicht auffindbare Dateien, letzte 50 Einträge aus storage_sync_log) und POST /api/settings/storage/reconcile („Jetzt abgleichen“, tiefer Abgleich im Hintergrund, 409 wenn er schon läuft) — beide Rolle 1/2 wie die übrigen Speicher-Routen; UI StorageSyncStatusPanel in Einstellungen › Integrationen › Dateispeicher.
  • Benachrichtigung: Besteht ein Fehlerzustand (Ordner error, Speicher nicht erreichbar, Verbindung error) länger als 24 h, erhalten die Admins (Rolle 1) eine Notification (storage_sync_error), höchstens einmal pro Tag (errorSince/errorNotifiedAt in den Metadaten).

Bestandsbereinigung (Phase 4, cleanup.ts, Skript scripts/storage-cleanup.ts)

Je Mandant, Standard Probelauf, Änderungen nur mit --apply, unter der Mandanten-Sperre des Syncs. runStorageCleanup scannt nicht registrierte Ordner in Slug-Schreibweise unter customers/ und anfragen/ sowie projects/-Zwischenebenen in registrierten Kundenordnern. planCleanup ordnet jeden Alt-Projekt-/Anfrageordner über die Datei-Referenzen (sqlFileOwnerIndex: document_storage_mappingsdocuments.project_id/anfrage_id, acceptance_mediaacceptance_protocols.project_id) genau einer Entität zu; sonst nur Bericht. Zusammenführen mit mergeItemInto (Dateien und Unterordner, Referenzen zuerst, Wiederanlauf-Erkennung am Ziel, Namenskonflikt „ (2)“ vorab aus der Ziel-Liste), alte Kategorien auf die registrierten deutschen Kategorie-Ordner (Anfrage → Projekt über WON_CATEGORY_TARGET). Danach werden leere Altbäume von unten entfernt (jeder Ordner vor dem Löschen neu gelistet). Bericht: Altbestand vorher/nachher (Ordner/Dateien), Verschiebungen mit Konfliktmarke, entfernte Ordner, übersprungene Fälle, Fehler; Log-Einträge merge_file, merge_folder, remove_empty, cleanup (Auslöser manual).

Aufbewahrung der Zwischenablage (D9, tiefer Abgleich)

applyStagingRetention: nur agent_media aus WhatsApp (whatsapp_message_id gesetzt), Bilder und Videos, Objekte unter system/agent-media (Projektdateien sind eigene Kopien und werden nie angefasst).

FallAktion
Einem Dokument zugeordnet und dort gespeichert (documents.extracted_data.agent_media_id, kein pending://)nach 7 Tagen löschen
Nicht zugeordnet, nie gefragtnach 23 Tagen einmal beim Absender nachfragen (retention_notified_at)
Nicht zugeordnet, gefragtfrühestens nach 30 Tagen und 7 Tage nach der Rückfrage löschen

Als „gefragt“ gilt ein Medium erst, wenn die Rückfrage zugestellt wurde: Markierung atomar (UPDATE … WHERE retention_notified_at IS NULL RETURNING), dann Versand; scheitert er, wird die Markierung zurückgenommen und der nächste Lauf fragt erneut. Ohne zugestellte Rückfrage wird nie gelöscht. Ist der Absender keinem Nutzer zugeordnet (Session ohne user_id), gehen Rückfrage und Hinweis auf die unbekannte Nummer an die Admins des Mandanten. Die Aufbewahrung läuft unter der Laufsperre des Abgleichs (acquireReconcileLease, pg_try_advisory_lock), die auch „Jetzt abgleichen“ nutzt — läuft der nächtliche Abgleich, antwortet die Route 409.

Die Rückfrage ist eine Notification (storage_media_retention) an den WhatsApp-Absender; der bestehende WhatsApp-Fanout sendet innerhalb des 24-h-Fensters den Text, außerhalb die freigegebene Inbox-Vorlage (WHATSAPP_TEMPLATE_INBOX_PROMPT), der Text steht dann in der Glocke. Gelöschte Objekte: agent_media.purged_at, Datensatz bleibt; jede Löschung und Rückfrage steht in storage_sync_log (retention_delete, retention_notice). Web-Assistent-Uploads und Sprachnachrichten sind ausgenommen.

Realtime-Kopplung

Eingehende Kalender-Webhooks emittieren calendar:google / calendar:microsoft über die Realtime-Bridge (siehe 15-notifications-realtime), sodass UI-Plantafeln live aktualisieren.

Bekannte Befunde / offene Punkte

  • Sync ist Beta: Webhook-Erneuerung, Delta-Token-Recovery und Konfliktstrategien (internal_wins/provider_wins/manual_review) sind angelegt, brauchen aber Monitoring (sync_runs) im Dauerbetrieb.
  • DWD setzt korrekte Google-Workspace-Admin-Konfiguration voraus (Scopes, Service-Account).
  • Callback-/Webhook-URLs sind an die App-Subdomain gebunden (app.work7.net) – bei Domain-Wechsel nachziehen (siehe adr-0001-landing-app-domain-split).

Verwandte Notes

Work7 · Software für Handwerksbetriebe · Doku-Stand Version 0.2.1 (Build fb6311f-dirty)