Skip to content

fix(calendar): default-assignee action heals events moved before #1306 - #1418

Draft
ulsklyc wants to merge 2 commits into
mainfrom
fix/calendar-heal-moved-event-assignee
Draft

ulsklyc wants to merge 2 commits into
mainfrom
fix/calendar-heal-moved-event-assignee

Conversation

@ulsklyc

@ulsklyc ulsklyc commented Sep 22, 2026

Copy link
Copy Markdown
Owner

Problem

Events moved between two calendars of one account before v2.68.0 still carry the default assignee (and so the colour) of the calendar they came from. The runtime fix from #1306 only acts when calendar_ref_id changes during a sync, so it never reaches them.

Fix

Extends the existing global admin action "Apply to existing appointments" (#1154, GET/POST /api/v1/calendar/external-calendars/default-assignee-backfill) instead of adding a second mechanism. No migration, no new button.

  • New candidate kind (MOVED_DEFAULT_EVENTS in server/services/sync-assignment.js): an imported event in calendar B whose only assignee is the default assignee of another calendar A of the same provider (for CalDAV the same account, via caldav_calendar_selection), assigned_to empty or that person, B has a different default assignee. It gets B's default assignee.
  • The "untouched" rule is now one SQL predicate, UNTOUCHED_DEFAULT_ASSIGNMENT(who), used by both reassignDefaultOnCalendarMove() (fix(calendar): a moved event gets the new calendar's default assignee #1306) and the new query, so the two cannot drift. It includes NOT_PUSHED_OUTBOUND.
  • Additionally user_modified = 0 for the heal only: the runtime path saw the move, the heal cannot. So "exactly A's person" could also be a hand assignment in B. Every edit of an external event in Yuvomi, including assignment, sets user_modified, and the inbound never clears it.
  • Moved candidates join the same confirmed list, count and token (Calendar backfill: bind the confirmation to the candidate set, fix a stale doc comment #1171). For a moved event the token also names the person it would lose, so a switch from "fill" to "replace" at the same size and target is a 409.
  • The write goes through the same helper as the runtime move (setEventAssignments, past inherited reminders count as dismissed).
  • UI text: description, question and "none" toast updated in all 24 locales (existing keys, values changed line by line).

Tests

test/test-calendar-routes.js, run through the real routes (count, token, apply):

  • healed: CalDAV (primary + rows, rows only), Google, Apple, a weekly RRULE series with a linked override (checked via GET / in the window, all three occurrences show the new person)
  • left alone: assigned by hand to someone else, assigned by hand to exactly A's person (user_modified), two people, a different primary, another CalDAV account's default, target without default, own calendar, another provider, pushed out
  • token binding and re-check during apply

Failing before the fix: gezaehlt werden die fuenf umgezogenen Termine - 0 !== 5 and die Zahl allein sieht den Wechsel nicht - 0 !== 1. Passing after. Counterproofs (file copy): dropping user_modified, the account clause, the token kind, or a clause of the shared predicate each fails (the last one also fails test:sync-calendar-move-assignment).

Suites: calendar-routes, sync-calendar-move-assignment, sync-default-assignee, i18n, i18n-plural, i18n-translated, frontend-audit, openapi-structure, openapi-coverage, api, api-offset-conversion, calendar-structure, settings-copy, changelog - all exit 0.

Closes #1307
Refs #1270

Events moved between two calendars of one account before v2.68.0 kept
the old calendar's default assignee, and the runtime fix never sees the
move again. The existing admin action "Apply to existing appointments"
(#1154) now also collects them: the only assignment is the untouched
default assignee of another calendar of the same provider/account, the
event was never edited in Yuvomi and not pushed out. The untouched rule
is one SQL predicate shared with reassignDefaultOnCalendarMove(); moved
candidates join the confirmed set and its token (#1171).

Closes #1307
Refs #1270
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Comment on lines +170 to +190
const MOVED_DEFAULT_EVENTS = `
FROM calendar_events e
JOIN external_calendars ec ON ec.id = e.calendar_ref_id
JOIN users u ON u.id = ec.default_assignee_user_id
JOIN event_assignments cur ON cur.event_id = e.id
WHERE e.external_source = ec.source
AND e.user_modified = 0
AND cur.user_id <> ec.default_assignee_user_id
AND ${UNTOUCHED_DEFAULT_ASSIGNMENT('cur.user_id')}
AND EXISTS (
SELECT 1 FROM external_calendars eca
WHERE eca.source = ec.source
AND eca.id <> ec.id
AND eca.default_assignee_user_id = cur.user_id
AND (ec.source <> 'caldav' OR EXISTS (
SELECT 1 FROM caldav_calendar_selection sa
JOIN caldav_calendar_selection sb ON sb.account_id = sa.account_id
WHERE sa.calendar_url = eca.external_id AND sb.calendar_url = ec.external_id
))
)
`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MOVED_DEFAULT_EVENTS has no record of an event ever actually changing calendar_ref_id - it infers "moved from calendar A" purely from the current shape of the data: an untouched single assignment that no longer matches its own calendar's default, plus some other same-account calendar whose default happens to match that assignee.

That heuristic also fires when nothing ever moved: if an admin changes calendar B's own default_assignee_user_id from X to Y via PATCH /external-calendars (server/routes/calendar/google.js:184-229 - an ordinary, repeatable admin action, not setup-only), every untouched event in B that was correctly auto-assigned to B's old default X now satisfies cur.user_id <> ec.default_assignee_user_id (line 177). If any other same-provider (same-account for CalDAV) calendar still defaults to X, the EXISTS at line 183 matches too, and the backfill silently reassigns these never-moved events from X to Y.

user_modified = 0 doesn't guard against this case (comment at lines 157-163 only anticipates a hand assignment mimicking A's default) - these events were never touched by a human, they were just correctly imported under B's own prior default. "Each member's calendar defaults to that member" is exactly the normal household shape this app is built around, so the other-calendar-with-matching-default precondition is common, not contrived.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed by measurement: the schema has no origin, timestamp or history that separates a move from a changed default, so this heuristic cannot be made safe. The PR is back to draft and will be rebuilt so the admin picks each candidate in the preview (details in the PR comment).

The moved-event heal infers the move from the row's shape. A calendar
whose default assignee was changed later, while the previous person is
another calendar's default, looks identical; the confirmation, OpenAPI
text and CHANGELOG now say so.
@ulsklyc
ulsklyc marked this pull request as draft September 22, 2026 07:16
@ulsklyc

ulsklyc commented Sep 22, 2026

Copy link
Copy Markdown
Owner Author

Holding this back from the release: the review finding holds, and it cannot be fixed inside the current shape.

Measured against the schema: nothing in the data tells "this event moved from calendar A" apart from "calendar B's default assignee was changed". event_assignments has no origin or timestamp, external_calendars does not record when a default changed, there is no event or calendar history table, CalDAV/iCloud overwrite external_object_url on every inbound, and Google keeps the same event id across a move. So any automatic repair infers the move from today's shape of the data, and silently reassigns events that never moved.

Next round, after the release: the preview of the #1154 action lists each candidate ("title, date, person X -> Y"), the admin unticks what should stay, and the confirmation binds the chosen subset - the same binding #1171 introduced for the whole set. What carries over from this branch: the shared "untouched" rule used by #1306 and the repair, the single write path, and the series tests.

@claude

claude Bot commented Sep 22, 2026

Copy link
Copy Markdown

Code review

No issues found. Checked for bugs and CONTRIBUTING.md compliance (this repo has no CLAUDE.md; CONTRIBUTING.md on main is the rules doc).

Reviewed at commit 6094fe0.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Calendar mapping: events moved between calendars before the fix keep the old person

1 participant