Files
motia/bitbylaw/steps/advoware_cal_sync

Advoware Calendar Sync - Hub-Based Design

Advoware Calendar Sync - Hub-Based Design

Dieser Abschnitt implementiert die bidirektionale Synchronisation zwischen Advoware-Terminen und Google Calendar unter Verwendung von PostgreSQL als zentralem Hub (Single Source of Truth). Das System stellt sicher, dass Termine konsistent gehalten werden, mit konfigurierbaren Konfliktauflösungsstrategien, Schreibberechtigungen und Datenschutzfeatures wie Anonymisierung. Der Sync läuft in vier strikten Phasen, um maximale Robustheit und Atomarität zu gewährleisten.

Übersicht

Das System synchronisiert Termine zwischen:

  • Advoware: Zentrale Terminverwaltung mit detaillierten Informationen (aber vielen API-Bugs).
  • Google Calendar: Benutzerfreundliche Kalenderansicht für jeden Mitarbeiter.
  • PostgreSQL Hub: Zentraler Datenspeicher für State, Policies und Audit-Logs.

Architektur

Hub-Design

  • Single Source of Truth: Alle Sync-Informationen werden in PostgreSQL gespeichert.
  • Policies: Enums für Sync-Strategien (source_system_wins, last_change_wins) und Flags für Schreibberechtigung (advoware_write_allowed).
  • Status-Tracking: sync_status ('pending', 'synced', 'failed') für Monitoring und Retries.
  • Transaktionen: Jede DB-Operation läuft in separaten Transaktionen; Fehler beeinflussen nur den aktuellen Eintrag.
  • Soft Deletes: Gelöschte Termine werden markiert, nicht entfernt.
  • Phasen-basierte Verarbeitung: Sync in 4 Phasen, um Neue, Deletes und Updates zu trennen.
  • Timestamp-basierte Updates: Updates werden ausschließlich auf Basis von last_sync (gesetzt auf den API-Timestamp der Quelle) getriggert, nicht auf Datenvergleichen, um Race-Conditions zu vermeiden.
  • Anonymisierung: Optionale Anonymisierung sensibler Daten (Text, Notiz, Ort) bei Advoware → Google Sync, um Datenschutz zu wahren.

Sync-Phasen

  1. Phase 1: Neue Einträge Advoware → Google - Erstelle Google-Events für neue Advoware-Termine, dann DB-Insert.
  2. Phase 2: Neue Einträge Google → Advoware - Erstelle Advoware-Termine für neue Google-Events, dann DB-Insert.
  3. Phase 3: Gelöschte Einträge identifizieren - Handle Deletes/Recreates basierend auf Strategie.
  4. Phase 4: Bestehende Einträge updaten - Update bei Änderungen, basierend auf Timestamps (API-Timestamp > last_sync).

Datenbank-Schema

-- Haupt-Tabelle
CREATE TABLE calendar_sync (
    sync_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    employee_kuerzel VARCHAR(10) NOT NULL,
    advoware_frnr INTEGER,
    google_event_id VARCHAR(255),
    source_system source_system_enum NOT NULL,
    sync_strategy sync_strategy_enum NOT NULL DEFAULT 'source_system_wins',
    sync_status sync_status_enum NOT NULL DEFAULT 'synced',
    advoware_write_allowed BOOLEAN NOT NULL DEFAULT FALSE,
    deleted BOOLEAN NOT NULL DEFAULT FALSE,
    last_sync TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- Enums
CREATE TYPE source_system_enum AS ENUM ('advoware', 'google');
CREATE TYPE sync_strategy_enum AS ENUM ('source_system_wins', 'last_change_wins');
CREATE TYPE sync_status_enum AS ENUM ('pending', 'synced', 'failed');

-- Audit-Tabelle
CREATE TABLE calendar_sync_audit (
    id SERIAL PRIMARY KEY,
    sync_id UUID NOT NULL,
    action VARCHAR(10) NOT NULL, -- INSERT, UPDATE, DELETE
    timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- Indizes (angepasst für Soft Deletes)
CREATE UNIQUE INDEX idx_calendar_sync_advoware ON calendar_sync (employee_kuerzel, advoware_frnr) WHERE advoware_frnr IS NOT NULL AND deleted = FALSE;
CREATE UNIQUE INDEX idx_calendar_sync_google ON calendar_sync (employee_kuerzel, google_event_id) WHERE google_event_id IS NOT NULL AND deleted = FALSE;

Funktionalität

Automatische Kalender-Erstellung

  • Für jeden Advoware-Mitarbeiter wird ein Google Calendar mit dem Namen AW-{Kuerzel} erstellt.
  • Beispiel: Mitarbeiter mit Kürzel "SB" → Calendar "AW-SB".
  • Kalender wird mit dem Haupt-Google-Account (lehmannundpartner@gmail.com) als Owner geteilt.

Phasen-Details

Phase 1: Neue Einträge Advoware → Google

  • Fetch Advoware-Termine.
  • Für jede frNr, die nicht in DB (deleted=FALSE) existiert: Standardisiere Daten (mit Anonymisierung falls aktiviert), erstelle Google-Event, dann INSERT in DB mit sync_status = 'synced', last_sync auf Advoware-Timestamp.
  • Bei Fehlern: Warnung loggen, weitermachen (nicht abbrechen).

Phase 2: Neue Einträge Google → Advoware

  • Fetch Google-Events.
  • Für jeden event_id, der nicht in DB existiert: Standardisiere Daten, erstelle Advoware-Termin, dann INSERT in DB mit sync_status = 'synced', last_sync auf Google-Timestamp.
  • Bei frNr None (API-Bug): Skippen mit Warnung.
  • Bei Fehlern: Warnung loggen, weitermachen.

Phase 3: Gelöschte Einträge identifizieren

  • Für jeden DB-Eintrag: Prüfe, ob Termin in API fehlt.
  • Bei beiden fehlend: Soft Delete.
  • Bei einem fehlend: Recreate oder propagate Delete basierend auf Strategie.
  • Bei Fehlern: sync_status = 'failed', Warnung.

Phase 4: Bestehende Einträge updaten

  • Für bestehende Einträge: Prüfe API-Timestamp > last_sync.
  • Bei source_system_wins: Update basierend auf source_system, setze last_sync auf den API-Timestamp der Quelle.
  • Bei last_change_wins: Vergleiche Timestamps, update das System mit dem neueren, setze last_sync auf den neueren Timestamp.
  • Anonymisierung: Bei Advoware → Google wird Text/Notiz/Ort anonymisiert, wenn CALENDAR_SYNC_ANONYMIZE_GOOGLE_EVENTS = True.
  • Bei Fehlern: sync_status = 'failed', Warnung.

Datenmapping und Standardisierung

Beide Systeme werden auf gemeinsames Format normalisiert (Berlin TZ):

{
    'start': datetime,  # Berlin TZ
    'end': datetime,
    'text': str,
    'notiz': str,
    'ort': str,
    'dauertermin': int,  # 0/1
    'turnus': int,       # 0/1
    'turnusArt': int,
    'recurrence': str    # RRULE oder None
}

Advoware → Standard

  • Start: datum + uhrzeitVon (Fallback 09:00), oder datum als datetime.
  • End: datumBis + uhrzeitBis (Fallback 10:00), oder datum + 1h.
  • All-Day: dauertermin=1 oder Dauer >1 Tag.
  • Recurring: turnus/turnusArt (vereinfacht, keine RRULE).
  • Anonymisierung: Wenn CALENDAR_SYNC_ANONYMIZE_GOOGLE_EVENTS, setze text='Advoware blocked', notiz='', ort=''.

Google → Standard

  • Start/End: dateTime oder date (All-Day).
  • All-Day: dauertermin=1 wenn All-Day oder Dauer >1 Tag.
  • Recurring: RRULE aus recurrence.

Standard → Advoware

  • POST/PUT: datum/uhrzeitBis/datumBis aus start/end.
  • Defaults: vorbereitungsDauer='00:00:00', sb/anwalt=employee_kuerzel.

Standard → Google

  • All-Day: date statt dateTime, end +1 Tag.
  • Recurring: RRULE aus recurrence.

API-Schwächen und Fuckups

Advoware API (Buggy und Inkonsistent)

  • Case Sensitivity in Responses: Feldnamen variieren manchmal 'frNr', manchmal 'frnr' (z.B. POST-Response: {'frnr': 123}). Code prüft beide (result.get('frNr') or result.get('frnr')), um None zu vermeiden.
  • Zeitformate: datum/datumBis als 'YYYY-MM-DD' oder 'YYYY-MM-DDTHH:MM:SS'. uhrzeitVon/uhrzeitBis separat (z.B. '09:00:00'). Fehlt uhrzeitVon, Fallback 09:00; fehlt uhrzeitBis, 10:00. Parsing muss beide Formate handhaben.
  • Defaults und Fehlende Felder: Viele Felder optional; Code setzt Fallbacks (z.B. uhrzeitVon='09:00:00').
  • Recurring-Unterstützung: Keine RRULE; nur turnus (0/1) und turnusArt (0-?). Mapping zu Google RRULE ist vereinfacht und unvollständig.
  • API-Zuverlässigkeit: Manchmal erfolgreicher POST, aber frNr: None (trotz gültiger Response). 500-Fehler bei Bad Requests. Keine Timestamp-Details in Responses.
  • Zeitzonen: Alles implizit Berlin; Code konvertiert explizit.
  • Andere Bugs: zuletztGeaendertAm für Timestamps, aber Format unzuverlässig.
  • DELETE Responses: DELETE-Anfragen geben manchmal einen leeren Body zurück, was zu JSONDecodeError führt. Code fängt dies mit try/except ab und gibt None zurück, um den Sync nicht zu brechen.
  • frNr Wiederverwendung: frNr sind sequentiell und werden nicht wiederverwendet. Getestet durch Erstellen/Löschen/Erstellen: z.B. 85861, 85862, delete 85861, nächstes Create 85863. Kein Risiko für DB-Konflikte durch ID-Reuse.
  • Timestamp-basierte Updates: Um Race-Conditions und redundante Syncs zu vermeiden, werden Updates in Phase 4 nur durchgeführt, wenn der API-Timestamp der Quelle > last_sync (gesetzt auf den API-Timestamp nach erfolgreichem Write).
  • Soft Deletes und Partielle Unique Indexes: Gelöschte Termine werden mit deleted = TRUE markiert, nicht entfernt. Partielle Unique Indexes (z.B. WHERE deleted = FALSE) verhindern Duplikate für aktive Einträge.
  • Anonymisierung: Optionale Anonymisierung sensibler Daten (Text, Notiz, Ort) bei Advoware → Google Sync, um Datenschutz zu wahren (z.B. text='Advoware blocked').

Google Calendar API (Zuverlässig)

  • Zeitformate: dateTime als ISO mit TZ (z.B. '2025-01-01T10:00:00+01:00'), date für All-Day. Code parst mit fromisoformat und .rstrip('Z').
  • Zeitzonen: Explizit (z.B. 'Europe/Berlin'); Code konvertiert zu Berlin TZ.
  • Recurring: RRULE in recurrence; vollständig unterstützt.
  • Updates: updated Timestamp für last-change.
  • Keine bekannten Bugs: Zuverlässig, aber Rate-Limits möglich.

Step-Konfiguration

calendar_sync_event_step.py

  • Type: event
  • Subscribes: calendar.sync.triggered
  • Flows: advoware

Event Data:

{
  "data": {
    "body": {
      "employee_kuerzel": "SB"  // Erforderlich, kein Default
    }
  }
}

Setup

PostgreSQL

  1. PostgreSQL 17 installieren und starten (localhost-only).
  2. Datenbank erstellen: sudo -u postgres psql -f /tmp/create_db.sql
  3. User und Berechtigungen setzen.

Google API Credentials

  1. Google Cloud Console Projekt erstellen.
  2. Google Calendar API aktivieren.
  3. Service Account erstellen.
  4. service-account.json im Projekt bereitstellen.

Advoware API Credentials

OAuth-ähnliche Authentifizierung.

Umgebungsvariablen

# PostgreSQL
POSTGRES_HOST=localhost
POSTGRES_USER=calendar_sync_user
POSTGRES_PASSWORD=your_password
POSTGRES_DB_NAME=calendar_sync_db

# Google Calendar
GOOGLE_CALENDAR_SERVICE_ACCOUNT_PATH=service-account.json

# Advoware API
ADVOWARE_API_BASE_URL=https://www2.advo-net.net:90/
ADVOWARE_PRODUCT_ID=64
ADVOWARE_APP_ID=your_app_id
ADVOWARE_API_KEY=your_api_key
ADVOWARE_KANZLEI=your_kanzlei
ADVOWARE_DATABASE=your_database
ADVOWARE_USER=your_user
ADVOWARE_ROLE=2
ADVOWARE_PASSWORD=your_password
ADVOWARE_TOKEN_LIFETIME_MINUTES=55
ADVOWARE_API_TIMEOUT_SECONDS=30

# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_DB_CALENDAR_SYNC=1
REDIS_TIMEOUT_SECONDS=5

# Anonymisierung
CALENDAR_SYNC_ANONYMIZE_GOOGLE_EVENTS=true  # Optional, default false

Verwendung

Manueller Sync

curl -X POST "http://localhost:3000/advoware/calendar/sync" \
  -H "Content-Type: application/json" \
  -d '{"full_content": true}'

Automatischer Sync

Cron-Step für regelmäßige Ausführung.

Fehlerbehandlung und Logging

  • Transaktionen: Pro Operation separat; Rollback nur für diese.
  • Logging: Detailliert (Info/Debug für API, Warnung für Fehler).
  • API-Fehler: Retry mit Backoff für Google; robust gegen Advoware-Bugs.
  • Datenfehler: Fallbacks bei Parsing-Fehlern.

Sicherheit und Datenschutz

  • DB-User mit minimalen Berechtigungen.
  • Schreibberechtigung-Flags verhindern unbefugte Änderungen.
  • Anonymisierung: Verhindert Leakage sensibler Daten in Google Calendar.
  • Audit-Logs für Compliance.

Bekannte Probleme

  • Recurring-Events: Begrenzte Unterstützung; Advoware hat keine RRULE.
  • Timestamps: Fehlende in Google können zu Fallback führen.
  • Performance: Bei vielen Terminen könnte Paginierung helfen.

Korrekter Umgang mit Advoware-Timestamps

Problemstellung

Advoware-Timestamps (z.B. 'zuletztGeaendertAm') werden in Berlin-Zeit geliefert, aber das Parsing mit datetime.datetime.fromisoformat(...).replace(tzinfo=BERLIN_TZ) führte zu falschen Offsets (z.B. 53 Minuten Unterschied), da replace(tzinfo=...) auf naive datetime nicht korrekt mit pytz-TZ-Objekten funktioniert. Dies verursachte Endlosschleifen in Phase 4, da adv_ts falsch hochgesetzt wurde.

Lösung

Verwende BERLIN_TZ.localize(naive_datetime) statt .replace(tzinfo=BERLIN_TZ):

  • localize() setzt die TZ korrekt auf pytz-TZ-Objekte.
  • Beispiel:
    naive = datetime.datetime.fromisoformat('2025-10-23T14:18:36.245')
    adv_ts = BERLIN_TZ.localize(naive)  # Ergebnis: 2025-10-23 14:18:36.245+02:00
    
  • Dies stellt sicher, dass Timestamps korrekt in UTC konvertiert werden (z.B. 12:18 UTC) und Vergleiche in Phase 4 funktionieren.

Implementierung

  • In calendar_sync_event_step.py, Phase 4:
    adv_ts = BERLIN_TZ.localize(datetime.datetime.fromisoformat(adv_data['zuletztGeaendertAm']))
    
  • Für Google-Timestamps: .astimezone(BERLIN_TZ) bleibt korrekt.
  • Alle Timestamps werden zu UTC normalisiert für DB-Speicherung und Vergleiche.

Vermeidung von Fehlern

  • Niemals .replace(tzinfo=pytz_tz) verwenden immer tz.localize(naive).
  • Teste Parsing: BERLIN_TZ.localize(datetime.datetime.fromisoformat(ts)).astimezone(pytz.utc) sollte korrekte UTC ergeben.
  • Bei anderen TZ: Gleiche Regel anwenden.

Erweiterungen

Der Sync funktioniert jetzt perfekt für alle Mitarbeiter ohne Limit auf 'AI'. Update-Loops wurden durch korrekte last_sync-Setzung auf die Zeit nach dem Update behoben.