Darstellung
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}/*`
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 insrc/dwd_setup1.png/dwd_setup2.png, Test-Skriptescripts/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-Channelswatch/{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=…startetconnection-testautomatisch; die Route legt die Verbindung an, prüft Organisation, Verzeichnis und ein Postfach und setzt sie aufactive. 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.ts–enqueueOutboundAppointmentSync, Outbound-Queue.mirror-sync.ts/provider-inbound.ts– Inbound: Provider-Eventzeiten auf verknüpfteappointments(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, mitMICROSOFT_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, optionalsite_id) löst die Site auf, prüft den Schreibzugriff (Testordner anlegen/löschen) und speichertsharepointSiteUrl+sharepointSiteId; danachbootstrapTenantWorkspace.connect/sharepoint/{start,callback}lehnt mit klarem Fehler ab statt still durchzulaufen. - Kein OneDrive-Modus mehr: Mit
Sites.Selectedgibt 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 mitread),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 (migrationErrorin 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 reinemSites.Selectedfunktioniert oder zusätzlichSites.Read.Allbenö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/materialKategorie-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äne | Ziel (putTenantObject target) |
|---|---|
| Anfrage (Upload) | { entity: 'anfrage', id, category: 'files' | 'images' } |
| Mail-Anhang nach Sync (app + landing) | { entity: 'anfrage', id, category: 'mail' } |
| Mail vor Anfrage | kein 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-Zwischenablage | Azure Blob system/agent-media (D8), Fallback Firmenspeicher ohne Azure-Konfiguration |
| Logo / E-Rechnung | DB, nicht Tenant-Storage |
Ordner-Register (storage_folders, Migration 115)
| Spalte | Inhalt |
|---|---|
entity_type / entity_id | root (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, path | Baum, aktueller Name und Pfad beim Anbieter |
external_id | driveItem-ID (SharePoint), File-ID (Drive), id:… (Dropbox), NULL (Nextcloud) |
state | active, 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 (resolveUploadTarget → FolderResolver, folder-registry.ts):
- Registrierter Ordner → sofort nutzen, kein Anbieter-Aufruf.
- Sonst Elternordner auflösen, Altordner nach bisherigem Schema suchen und übernehmen (nicht, wenn er schon einer anderen Entität gehört).
- Sonst
folderNameFor(entity, geschwister)undensureFolder(übernimmt gleichnamigen Ordner, bei gleichnamiger Datei(2)), dann registrieren. - Meldet der Anbieter beim Upload „Ordner fehlt“, wird der Eintrag
missinggesetzt, neu aufgelöst und einmal wiederholt. - 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:
publishDomainEventruft nachenqueueWebhookEventden HookenqueueStorageSync(packages/domain/src/storage-sync-hook.ts). Er stößt das Einreihen an, ohne zu warten, und zwar überafterCommit(packages/domain/src/after-commit.ts): innerhalb vonwithTransactionerst nach dem COMMIT, bei ROLLBACK gar nicht — der Worker liest nie einen Stand vor dem Commit. App (instrumentation.ts→lib/storage/folder-sync-adapter.ts, Producerlib/storage/storage-sync-queue.ts) und Agent-Worker registrieren übersetStorageSyncEnqueuereinen Enqueuer (createStorageSyncEnqueuer): nur fürcustomers.*,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 Versuchstate = '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-Lockpg_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 PlanungplanFolderOps(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 Verbindunglock_timeout = 120s(Warten auf die Sperre) undstatement_timeout = 60s; ein hängender Anbieter hält die Mandanten-Sperre nicht unbegrenzt.
| Ereignis (Soll) | Operationen |
|---|---|
| Kunde angelegt | keine (D2) |
| Kunde umbenannt | rename; Groß/Klein-Änderung und stabiler Zusatz (n) sind No-Ops |
| Kunde archiviert / reaktiviert | move nach customers/_Archiv bzw. zurück, state |
| Kunde gelöscht | Projekte nach _Ohne Kunde, Kunde nach _Archiv/Gelöscht |
| Projekt angelegt | ensure + alle Kategorien (Kundenordner entsteht als Elternordner) |
| Projekt umbenannt / anderem Kunden zugeordnet | rename bzw. move (Namenskonflikt → (2)) |
| Projekt archiviert / reaktiviert | move nach {Kunde}/_Archiv bzw. _Ohne Kunde/_Archiv und zurück; liegt der Kunde selbst im Archiv, bleibt das Projekt in seinem Ordner |
| Projektzeile gelöscht | state = 'orphaned', Ordner bleibt |
| Anfrage angelegt / bestätigt | ensure + Kategorien (nicht für Entwürfe eingegangen sowie verloren/abgelehnt; vorhandene Ordner bleiben) |
| Anfrage gelöscht | leeren 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 übernommen | Umbenennen auf D1/D5, aus projects/ in den Kundenordner |
- Referenzen: Nach
rename/moveschreibt der Executor Register-Pfade des ganzen Teilbaums um und ruftsqlReferenceRewriter:document_storage_mappingsimmer (Anzeige-Pfad, bei Nextcloud auch die ID; Auswahl überfolder_idund Pfad-Präfix), bei Dropbox/Nextcloud zusätzlich die Pfadspalten vondocuments.file_path(nur Dokumente mit Mapping dieses Anbieters),acceptance_media,agent_media,catalog_import_sessions,supplier_catalog_files. Verschobene Einzeldateien (D4) bekommenexternal_pathundfolder_iddes Projektordners. Jede Abfrage ist aufcompany_idbeschrä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, Dropboxdelete_v2, NextcloudDELETE).
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.
| Tabelle | Referenzspalten |
|---|---|
document_storage_mappings | provider, external_id, external_path |
acceptance_media | storage_provider, storage_external_id, storage_key (Migration 115) |
agent_media | storage_provider, storage_external_id, blob_path |
catalog_import_sessions, supplier_catalog_files | storage_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);planReconcilereiht 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), Zustanderror/missing. - deep: zusätzlich Ist beim Anbieter. SharePoint
root/delta(Grundlinietoken=latest) und Google Drivechanges.list(startPageToken) mit Cursor intenant_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 pergetFolderje registriertem Ordner in Tranchen (Fortschritt inmetadata.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 → Teilbaummissing, betroffene Entitäten neu anlegen, Datei-Referenzen darindocument_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“, Skriptscripts/bootstrap-tenant-storage.ts) legt nur noch die Grundordner über das Register an und startet den tiefen Abgleich im Hintergrund;metadata.workspaceBootstrapwird 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 ausstorage_sync_log) undPOST /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; UIStorageSyncStatusPanelin Einstellungen › Integrationen › Dateispeicher. - Benachrichtigung: Besteht ein Fehlerzustand (Ordner
error, Speicher nicht erreichbar, Verbindungerror) länger als 24 h, erhalten die Admins (Rolle 1) eine Notification (storage_sync_error), höchstens einmal pro Tag (errorSince/errorNotifiedAtin 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_mappings → documents.project_id/anfrage_id, acceptance_media → acceptance_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).
| Fall | Aktion |
|---|---|
Einem Dokument zugeordnet und dort gespeichert (documents.extracted_data.agent_media_id, kein pending://) | nach 7 Tagen löschen |
| Nicht zugeordnet, nie gefragt | nach 23 Tagen einmal beim Absender nachfragen (retention_notified_at) |
| Nicht zugeordnet, gefragt | frü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).