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