From 16ff434bef29c488bd2cd17a5e730100ee61441c Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 13 Jul 2026 20:53:11 +0200 Subject: [PATCH] Make imported-event dedup bulletproof (server-side, id-based) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repeated Contacts syncs must never create duplicate birthdays. Enforce it on the server, independent of the client's reconcile: - POST /local/events is now idempotent on external_uid: if an event with the same (calendar_id, external_uid) exists, update it in place instead of inserting a new row - startup cleanup removes existing duplicates sharing the same (calendar_id, external_uid), keeping the earliest — auto-heals old data - unique DB index on (calendar_id, external_uid) as a hard guarantee external_uid is the stable "contact::", so the same contact from the same device always maps to exactly one event. Co-Authored-By: Claude Opus 4.8 --- backend/main.py | 27 +++++++++++++ backend/routers/local_router.py | 67 ++++++++++++++++++++++++--------- 2 files changed, 76 insertions(+), 18 deletions(-) diff --git a/backend/main.py b/backend/main.py index 59b2d49..48fa0f1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -282,6 +282,33 @@ def _migrate(): except Exception: pass + # One-time cleanup of duplicate imported events sharing the same + # (calendar_id, external_uid) — e.g. birthdays created repeatedly by an + # older build without idempotent upsert. Keep the earliest row. + # Idempotent: after cleanup there is nothing left to delete. + try: + conn.execute(text( + "DELETE FROM local_events WHERE external_uid IS NOT NULL AND id NOT IN " + "(SELECT MIN(id) FROM local_events WHERE external_uid IS NOT NULL " + "GROUP BY calendar_id, external_uid)" + )) + conn.commit() + logging.info("Migration: de-duplicated local_events by external_uid") + except Exception: + pass + + # Hard guarantee: the DB itself forbids two events with the same + # external_uid in one calendar (imported entries only; NULLs unconstrained). + try: + conn.execute(text( + "CREATE UNIQUE INDEX IF NOT EXISTS ux_local_events_calendar_external " + "ON local_events(calendar_id, external_uid) WHERE external_uid IS NOT NULL" + )) + conn.commit() + logging.info("Migration: unique index on (calendar_id, external_uid)") + except Exception: + pass + _migrate() app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None) diff --git a/backend/routers/local_router.py b/backend/routers/local_router.py index cde8e65..b169d18 100644 --- a/backend/routers/local_router.py +++ b/backend/routers/local_router.py @@ -375,24 +375,55 @@ def create_event( db, current_user, data.calendar_id, require_write=True ) - ev = models.LocalEvent( - calendar_id=cal.id, - uid=str(uuid.uuid4()), - title=data.title, - start=data.start, - end=data.end, - all_day=data.allDay, - location=data.location, - description=data.description, - color=data.color, - rrule=data.rrule, - is_private=data.private, - reminders=(",".join(str(m) for m in data.reminders) if data.reminders else None), - external_uid=data.external_uid, - birth_year=data.birth_year, - creator_id=current_user.id, # server-side, never from the client - ) - db.add(ev) + # Idempotent on external_uid: repeated syncs of the same contact (stable + # "contact::") must NEVER create duplicates. If a row + # with this external_uid already exists in this calendar, update it in place + # instead of inserting a new one. Enforced server-side, so the client can + # never duplicate — even without the reconcile read. + existing = None + if data.external_uid: + existing = ( + db.query(models.LocalEvent) + .filter( + models.LocalEvent.calendar_id == cal.id, + models.LocalEvent.external_uid == data.external_uid, + ) + .first() + ) + + reminders = ",".join(str(m) for m in data.reminders) if data.reminders else None + if existing is not None: + ev = existing + ev.title = data.title + ev.start = data.start + ev.end = data.end + ev.all_day = data.allDay + ev.location = data.location + ev.description = data.description + ev.color = data.color + ev.rrule = data.rrule + ev.is_private = data.private + ev.reminders = reminders + ev.birth_year = data.birth_year + else: + ev = models.LocalEvent( + calendar_id=cal.id, + uid=str(uuid.uuid4()), + title=data.title, + start=data.start, + end=data.end, + all_day=data.allDay, + location=data.location, + description=data.description, + color=data.color, + rrule=data.rrule, + is_private=data.private, + reminders=reminders, + external_uid=data.external_uid, + birth_year=data.birth_year, + creator_id=current_user.id, # server-side, never from the client + ) + db.add(ev) dav_util.bump_dav(cal, ev) db.commit() db.refresh(ev)