diff --git a/README.md b/README.md index ed87ee5..a347fda 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,24 @@ python3 mailer.py aus MariaDB entfernt, ohne Archiv. - Nur iCloud als Quelle, Google/Microsoft sind nicht Teil dieses Repos. +## Kontaktruppen + +iCloud-Länder speichern Gruppen als eigene vCards mit +`X-ADDRESSBOOKSERVER-KIND:group`. Diese werden beim Sync automatisch +erkannt und separat in den Tabellen `groups` und `group_members` +gespeichert (nicht als Kontakte). + +Gruppen sind über die API abrufbar: + +- `GET /api/groups` — Alle Gruppen mit Member-Anzahl +- `GET /api/groups/{id}` — Gruppe mit aufgelösten Members +- `GET /api/groups/{id}/members` — Nur Members einer Gruppe +- `GET /api/contacts/{id}` — Enthält `groups`-Feld mit Gruppennamen + +Wird ein Kontakt gelöscht, wird die Mitgliedschaft in Gruppen +automatisch entfernt (`ON DELETE CASCADE`). Die Gruppe selbst bleibt +erhalten. + ## 11. Web-Ansicht und API (interner Zugriff über Authelia) Läuft als zweiter Service aus demselben Image, aber mit anderem @@ -206,13 +224,16 @@ Zugriff ohne den Reverse-Proxy ist damit nicht möglich. |---------|------|--------------| | `GET` | `/` | Dashboard mit Kontaktdaten-Übersicht, letzten Sync-Status und Geburtstagen der nächsten 7 Tage (HTML) | | `GET` | `/search` | Web-UI -- Suchfunktion, zeigt Kontakte des eingeloggten Users | -| `GET` | `/contacts/{id}` | Web-UI -- Detailseite eines einzelnen Kontakts | +| `GET` | `/contacts/{id}` | Web-UI -- Detailseite eines einzelnen Kontakts (inkl. Gruppen) | | `GET` | `/api/health` | Health Check (`{"status": "ok"}`), kein Login nötig | | `GET` | `/api/contacts` | Kontaktsuche mit Pagination (`?q=...&limit=...&offset=...`) | -| `GET` | `/api/contacts/{id}` | Einzelnen Kontakt per ID abrufen | +| `GET` | `/api/contacts/{id}` | Einzelnen Kontakt per ID abrufen (inkl. `groups`-Feld) | | `GET` | `/api/contacts/count` | Anzahl der Kontakte des eingeloggten Users | | `GET` | `/api/contacts/birthdays/today` | Heutige Geburtstage | | `GET` | `/api/contacts/birthdays/upcoming` | Geburtstage der nächsten N Tage (`?days=7`, Default 7) | +| `GET` | `/api/groups` | Gruppenliste mit Member-Anzahl | +| `GET` | `/api/groups/{id}` | Einzelne Gruppe mit aufgelösten Members | +| `GET` | `/api/groups/{id}/members` | Members einer Gruppe (Kontaktdaten) | | `GET` | `/api/sync-runs` | Letzte 50 Sync-Runs (Status, Zeitstempel, Fehler) | Alle Endpunkte (außer `/api/health`) erfordern eine Authentifizierung diff --git a/SPEC.md b/SPEC.md index a89a4c8..d211fbf 100644 --- a/SPEC.md +++ b/SPEC.md @@ -92,6 +92,11 @@ Siehe `sql/schema.sql`. Wichtigste Änderungen gegenüber v1: - Neue Tabelle `birthday_mail_log`: ein Datensatz pro Tag, an dem erfolgreich eine Geburtstagsmail versendet wurde, verhindert Doppelversand bei mehrfachem Container-Neustart am selben Tag. +- Neue Tabellen `groups` und `group_members`: Speichert + iCloud-Kontaktgruppen (vCards mit `X-ADDRESSBOOKSERVER-KIND:group`) + und deren Mitgliedschaften. Gruppen werden beim Sync erkannt und + nicht als Kontakte in die `contacts`-Tabelle geschrieben. + `group_members` referenziert `groups(id)` mit `ON DELETE CASCADE`. ## 6. Geburtstags-Mailer @@ -247,10 +252,13 @@ geteilt wird. Getrennt ist nur die **Rolle**, in der der Container läuft. | `GET /contacts/{id}` | HTML-Detailseite eines einzelnen Kontakts (Jinja2-Template) | | `GET /api/health` | Health-Check ohne Auth-Anforderung | | `GET /api/contacts` | Kontaktliste, Filter `q` (Freitext), Pagination `limit`/`offset` | -| `GET /api/contacts/{id}` | Einzelner Kontakt (JSON) | +| `GET /api/contacts/{id}` | Einzelner Kontakt (JSON), inklusive `groups`-Feld mit zugehörigen Gruppennamen | | `GET /api/contacts/count` | Anzahl der Kontakte des zugeordneten Accounts | | `GET /api/contacts/birthdays/today` | Heutige Geburtstage (kontospezifisch bzw. global für Admins) | | `GET /api/contacts/birthdays/upcoming` | Geburtstage der nächsten N Tage (Parameter `days`, Default 7) | +| `GET /api/groups` | Gruppenliste mit `member_count`, Pagination `limit`/`offset` | +| `GET /api/groups/{id}` | Einzelne Gruppe mit aufgelösten Members (Name + UID) | +| `GET /api/groups/{id}/members` | Nur Members einer Gruppe (Kontaktdaten aufgelöst) | | `GET /api/sync-runs` | Sync-Historie (kontospezifisch bzw. global für Admins) | ### 12.5 Netzwerkkontext diff --git a/sql/schema.sql b/sql/schema.sql index a82249f..b92e134 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -57,6 +57,29 @@ CREATE TABLE IF NOT EXISTS sync_runs ( error_message TEXT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- iCloud-Kontaktgruppen (vCards mit X-ADDRESSBOOKSERVER-KIND:group) +CREATE TABLE IF NOT EXISTS `groups` ( + id INT AUTO_INCREMENT PRIMARY KEY, + account VARCHAR(100) NOT NULL, + uid VARCHAR(255) NOT NULL, + etag VARCHAR(255) NULL, + name VARCHAR(512) NULL, + raw_vcard LONGTEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + last_synced_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + sync_run_id VARCHAR(64) NULL, + UNIQUE KEY uq_groups_account_uid (account, uid) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS group_members ( + id INT AUTO_INCREMENT PRIMARY KEY, + group_id INT NOT NULL, + member_uid VARCHAR(255) NOT NULL, + FOREIGN KEY (group_id) REFERENCES `groups`(id) ON DELETE CASCADE, + UNIQUE KEY uq_group_member (group_id, member_uid) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- Protokoll der Geburtstags-Mails, verhindert Doppelversand am selben Tag. CREATE TABLE IF NOT EXISTS birthday_mail_log ( id INT AUTO_INCREMENT PRIMARY KEY, diff --git a/src/api/main.py b/src/api/main.py index 16b1fee..a0c6d25 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -21,7 +21,13 @@ from starlette.middleware.sessions import SessionMiddleware import db from api.auth import get_current_user, resolve_account_for_user -from api.schemas import ContactListResponse, ContactOut, SyncRunOut +from api.schemas import ( + ContactListResponse, + ContactOut, + GroupDetailOut, + GroupListResponse, + SyncRunOut, +) from config import Config from mailer import build_message, fetch_birthdays_for_date, send_message from utils import fmt_birthday_age, fmt_birthday_short, is_unknown_year @@ -48,7 +54,7 @@ def _fmt_ts(dt) -> str | None: return dt.astimezone(Config.TIMEZONE).strftime("%d.%m.%Y %H:%M:%S") -def _row_to_contact_out(row: dict) -> dict: +def _row_to_contact_out(row: dict, group_names: list[str] | None = None) -> dict: row = dict(row) for field in ["emails", "phones", "addresses", "urls", "social_profiles", "categories"]: raw = row.get(field) @@ -56,6 +62,7 @@ def _row_to_contact_out(row: dict) -> dict: if not row.get("full_name"): row["full_name"] = db._build_full_name(row) row["updated_at"] = _fmt_ts(row["updated_at"]) + row["groups"] = group_names if group_names is not None else [] return row @@ -133,9 +140,13 @@ def get_contact(contact_id: int, current_user: str = Depends(get_current_user)): ) row = cur.fetchone() - if not row: - return {} - return _row_to_contact_out(row) + if not row: + return {} + + groups = db.get_groups_for_contact(conn, row["account"], row["uid"]) + group_names = [g["name"] for g in groups if g.get("name")] + + return _row_to_contact_out(row, group_names=group_names) @app.get("/api/contacts/birthdays/today", response_model=list[ContactOut]) @@ -202,6 +213,108 @@ def list_sync_runs(current_user: str = Depends(get_current_user)): return rows +@app.get("/api/groups", response_model=GroupListResponse) +def list_groups( + request: Request, + limit: int = Query(default=50, le=200), + offset: int = Query(default=0, ge=0), + current_user: str = Depends(get_current_user), +): + account_name, is_admin = resolve_account_for_user(current_user) + + with db.get_connection() as conn: + where_clause, params = _account_filter_clause(account_name) + with conn.cursor() as cur: + cur.execute(f"SELECT COUNT(*) AS total FROM `groups` {where_clause}", params) + total = cur.fetchone()["total"] + + cur.execute( + f"""SELECT g.id, g.account, g.uid, g.name, g.updated_at, + (SELECT COUNT(*) FROM group_members gm WHERE gm.group_id = g.id) AS member_count + FROM `groups` g {where_clause} + ORDER BY g.name + LIMIT %s OFFSET %s""", + params + [limit, offset], + ) + rows = cur.fetchall() + + for r in rows: + r["updated_at"] = _fmt_ts(r["updated_at"]) + return {"total": total, "items": rows} + + +@app.get("/api/groups/{group_id}", response_model=GroupDetailOut) +def get_group(group_id: int, current_user: str = Depends(get_current_user)): + account_name, is_admin = resolve_account_for_user(current_user) + + with db.get_connection() as conn: + where_clause, params = _account_filter_clause(account_name) + id_clause = "AND g.id = %s" if where_clause else "WHERE g.id = %s" + with conn.cursor() as cur: + cur.execute( + f"""SELECT g.id, g.account, g.uid, g.name, g.updated_at, + (SELECT COUNT(*) FROM group_members gm WHERE gm.group_id = g.id) AS member_count + FROM `groups` g {where_clause} {id_clause}""", + params + [group_id], + ) + group_row = cur.fetchone() + + if not group_row: + return {} + + cur.execute( + """SELECT gm.member_uid, c.id, c.full_name, c.given_name, c.family_name + FROM group_members gm + LEFT JOIN contacts c ON c.account = g.account AND c.uid = gm.member_uid + CROSS JOIN `groups` g + WHERE g.id = %s AND gm.group_id = g.id""", + (group_id,), + ) + members = cur.fetchall() + + for m in members: + if not m.get("full_name"): + m["full_name"] = db._build_full_name(m) if any(m.get(k) for k in ("given_name", "family_name")) else None + + group_row["updated_at"] = _fmt_ts(group_row["updated_at"]) + group_row["members"] = [ + {"member_uid": m["member_uid"], "full_name": m["full_name"], "id": m["id"]} + for m in members + ] + return group_row + + +@app.get("/api/groups/{group_id}/members") +def get_group_members(group_id: int, current_user: str = Depends(get_current_user)): + account_name, is_admin = resolve_account_for_user(current_user) + + with db.get_connection() as conn: + where_clause, params = _account_filter_clause(account_name) + with conn.cursor() as cur: + cur.execute( + f"""SELECT g.id FROM `groups` g {where_clause} + {"AND" if where_clause else "WHERE"} g.id = %s""", + params + [group_id], + ) + if not cur.fetchone(): + return {} + + cur.execute( + """SELECT gm.member_uid, c.id, c.full_name, c.given_name, c.family_name, + c.organization, c.birthday, c.photo_url + FROM group_members gm + LEFT JOIN contacts c ON c.uid = gm.member_uid + WHERE gm.group_id = %s""", + (group_id,), + ) + rows = cur.fetchall() + + for r in rows: + if not r.get("full_name"): + r["full_name"] = db._build_full_name(r) if any(r.get(k) for k in ("given_name", "family_name")) else None + return {"group_id": group_id, "members": rows} + + @app.get("/", response_class=HTMLResponse) def web_dashboard( request: Request, @@ -462,11 +575,14 @@ def web_contact( ) row = cur.fetchone() - if not row: - from fastapi.responses import RedirectResponse - return RedirectResponse(url="/search", status_code=303) + if not row: + from fastapi.responses import RedirectResponse + return RedirectResponse(url="/search", status_code=303) - contact = _row_to_contact_out(row) + groups = db.get_groups_for_contact(conn, row["account"], row["uid"]) + group_names = [g["name"] for g in groups if g.get("name")] + + contact = _row_to_contact_out(row, group_names=group_names) homecity = "" workcity = "" diff --git a/src/api/schemas.py b/src/api/schemas.py index b739658..2bf036c 100644 --- a/src/api/schemas.py +++ b/src/api/schemas.py @@ -23,6 +23,7 @@ class ContactOut(BaseModel): urls: list social_profiles: list categories: list + groups: list[str] = [] updated_at: str class Config: @@ -44,3 +45,30 @@ class SyncRunOut(BaseModel): contacts_upserted: int | None contacts_deleted: int | None error_message: str | None + + +class GroupMemberOut(BaseModel): + member_uid: str + full_name: str | None + id: int | None = None + + +class GroupOut(BaseModel): + id: int + account: str + uid: str + name: str | None + member_count: int + updated_at: str + + class Config: + from_attributes = True + + +class GroupDetailOut(GroupOut): + members: list[GroupMemberOut] + + +class GroupListResponse(BaseModel): + total: int + items: list[GroupOut] diff --git a/src/api/templates/contact.html b/src/api/templates/contact.html index da6646f..6856f57 100644 --- a/src/api/templates/contact.html +++ b/src/api/templates/contact.html @@ -422,6 +422,17 @@ {% endif %} + {% if contact.groups %} +