Make imported-event dedup bulletproof (server-side, id-based)

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:<deviceId>:<contactId>", so the same contact
from the same device always maps to exactly one event.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-13 20:53:11 +02:00
parent 1a30b4066a
commit 16ff434bef
2 changed files with 76 additions and 18 deletions

View File

@@ -282,6 +282,33 @@ def _migrate():
except Exception: except Exception:
pass 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() _migrate()
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None) app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None)

View File

@@ -375,24 +375,55 @@ def create_event(
db, current_user, data.calendar_id, require_write=True db, current_user, data.calendar_id, require_write=True
) )
ev = models.LocalEvent( # Idempotent on external_uid: repeated syncs of the same contact (stable
calendar_id=cal.id, # "contact:<deviceId>:<contactId>") must NEVER create duplicates. If a row
uid=str(uuid.uuid4()), # with this external_uid already exists in this calendar, update it in place
title=data.title, # instead of inserting a new one. Enforced server-side, so the client can
start=data.start, # never duplicate — even without the reconcile read.
end=data.end, existing = None
all_day=data.allDay, if data.external_uid:
location=data.location, existing = (
description=data.description, db.query(models.LocalEvent)
color=data.color, .filter(
rrule=data.rrule, models.LocalEvent.calendar_id == cal.id,
is_private=data.private, models.LocalEvent.external_uid == data.external_uid,
reminders=(",".join(str(m) for m in data.reminders) if data.reminders else None), )
external_uid=data.external_uid, .first()
birth_year=data.birth_year, )
creator_id=current_user.id, # server-side, never from the client
) reminders = ",".join(str(m) for m in data.reminders) if data.reminders else None
db.add(ev) 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) dav_util.bump_dav(cal, ev)
db.commit() db.commit()
db.refresh(ev) db.refresh(ev)