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:
@@ -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)
|
||||
|
||||
@@ -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:<deviceId>:<contactId>") 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)
|
||||
|
||||
Reference in New Issue
Block a user