From b6995bb63884bbcb3b54a906c9ee320d3ee5c346 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:29:28 +0100 Subject: [PATCH] fix: move a task into or within a shared calendar (bulwarkmail/webmail#1204) --- .../__tests__/live-stalwart-calendar.test.ts | 32 ++++++++++++++ src/stores/__tests__/calendar-store.test.ts | 43 +++++++++++++++++++ src/stores/calendar-store.ts | 41 +++++++++++++++++- 3 files changed, 115 insertions(+), 1 deletion(-) diff --git a/src/api/__tests__/live-stalwart-calendar.test.ts b/src/api/__tests__/live-stalwart-calendar.test.ts index da230dcd..6cda02e3 100644 --- a/src/api/__tests__/live-stalwart-calendar.test.ts +++ b/src/api/__tests__/live-stalwart-calendar.test.ts @@ -783,6 +783,38 @@ live('live Stalwart calendar', () => { expect(list).toHaveLength(1); expect(list[0].recurrenceOverrides?.['2027-10-11T10:00:00']?.title).toBe(`${RUN} owner second`); }, 60_000); + + it('moves a task into the shared calendar and back out of it (#1204)', async () => { + expect(sharedCalendarId, 'the calendar shared above').toBeTruthy(); + await connectSingleton(); + const [[, made]] = await raw(client, [['CalendarEvent/set', { + accountId: client.accountId, + create: { t: { '@type': 'Task', title: `${RUN} moving task`, calendarIds: { [calendarId]: true }, due: '2027-11-03T10:00:00', timeZone: 'Europe/Berlin' } }, + }, '0']]); + expect(made.created?.t?.id, JSON.stringify(made.notCreated)).toBeTruthy(); + const store = useCalendarStore.getState(); + await store.fetchCalendars(); + await useCalendarStore.getState().fetchTasks(); + const taskOf = () => useCalendarStore.getState().tasks.find((t) => t.title === `${RUN} moving task`); + const shared = useCalendarStore.getState().calendars.find((c) => c.name === `${RUN} team`)!; + const task = taskOf()!; + expect(task.accountId).toBeFalsy(); + + await useCalendarStore.getState().updateTask(task.id, { calendarIds: { [shared.id]: true } }); + const inShared = (await raw(other!, [['CalendarEvent/query', { accountId: other!.accountId, filter: { inCalendar: sharedCalendarId } }, 'q'], + ['CalendarEvent/get', { accountId: other!.accountId, '#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' }, properties: ['id', 'uid', 'title', '@type'] }, 'g']])) + .find((r) => r[2] === 'g')?.[1].list as Record[]; + const moved = inShared.find((e) => e.title === `${RUN} moving task`); + expect(moved?.uid).toBe(task.uid); + expect((await raw(client, [['CalendarEvent/get', { accountId: client.accountId, ids: [task.id], properties: ['id'] }, '0']]))[0][1].notFound) + .toEqual([task.id]); + expect(taskOf()?.accountId).toBe(other!.accountId); + + // And back into the user's own calendar. + await useCalendarStore.getState().updateTask(taskOf()!.id, { calendarIds: { [calendarId]: true } }); + expect(taskOf()?.accountId).toBeFalsy(); + await removeRunEvents(client); + }, 60_000); }); describe('a new calendar', () => { diff --git a/src/stores/__tests__/calendar-store.test.ts b/src/stores/__tests__/calendar-store.test.ts index 9f3bbd3d..fbc32912 100644 --- a/src/stores/__tests__/calendar-store.test.ts +++ b/src/stores/__tests__/calendar-store.test.ts @@ -656,6 +656,49 @@ describe('calendar-store', () => { }); }); + describe('updateTask in shared calendars (#1204)', () => { + const calendars = [ + { id: 'own', name: 'Own' }, + { id: 'acc-2:shared', originalId: 'shared', accountId: 'acc-2', isShared: true, name: 'Team' }, + { id: 'acc-2:other', originalId: 'other', accountId: 'acc-2', isShared: true, name: 'Team 2' }, + ] as any[]; + + it('moves a task within a shared account under the raw calendar id', async () => { + useCalendarStore.setState({ + calendars, + tasks: [{ id: 'acc-2:t1', originalId: 't1', accountId: 'acc-2', calendarIds: { 'acc-2:shared': true } } as any], + }); + mockUpdateEvent.mockResolvedValue(undefined); + await useCalendarStore.getState().updateTask('acc-2:t1', { calendarIds: { 'acc-2:other': true } }); + expect(mockUpdateEvent).toHaveBeenCalledWith('t1', { calendarIds: { other: true } }, undefined, 'acc-2'); + expect(mockCreateEvent).not.toHaveBeenCalled(); + }); + + it('moves an own task into a shared calendar by making it there and removing the original', async () => { + useCalendarStore.setState({ + calendars, + tasks: [{ id: 't1', calendarIds: { own: true }, title: 'Pay' } as any], + }); + mockGetEvents.mockResolvedValue([{ + id: 't1', '@type': 'Task', uid: 'u-1', title: 'Pay', calendarIds: { own: true }, sequence: 2, created: 'x', + }]); + mockCreateEvent.mockResolvedValue({ id: 'n1' }); + mockDeleteEvents.mockResolvedValue(undefined); + await useCalendarStore.getState().updateTask('t1', { calendarIds: { 'acc-2:shared': true }, title: 'Pay rent' }); + + expect(mockUpdateEvent).not.toHaveBeenCalled(); + const [created, calendarId, , account] = mockCreateEvent.mock.calls[0]; + expect(calendarId).toBe('shared'); + expect(account).toBe('acc-2'); + expect(created).toMatchObject({ '@type': 'Task', uid: 'u-1', title: 'Pay rent' }); + expect(created).not.toHaveProperty('id'); + expect(created).not.toHaveProperty('sequence'); + expect(mockDeleteEvents).toHaveBeenCalledWith(['t1'], undefined, 'acc-1'); + // Created first: a failed create leaves the task where it was. + expect(mockCreateEvent.mock.invocationCallOrder[0]).toBeLessThan(mockDeleteEvents.mock.invocationCallOrder[0]); + }); + }); + describe('deleteEvent', () => { it('should remove event from list', async () => { useCalendarStore.setState({ diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index df9e4a15..46fca24a 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -1540,7 +1540,46 @@ export const useCalendarStore = create()( const scope = writeScope(); const task = get().tasks.find((t) => t.id === id); const realId = task?.originalId || id; - await apiUpdateEvent(realId, changes, undefined, writeAccount(scope, task?.accountId)); + const sourceAccount = task?.accountId ?? undefined; + // Store calendar ids (namespaced for a shared account) back to the raw + // ids of the account that owns the calendar, which may be another one + // than the task's (webmail #1204). + const patch: Partial = { ...changes }; + let destAccount = sourceAccount; + if (changes.calendarIds) { + const remapped: Record = {}; + for (const [calId, on] of Object.entries(changes.calendarIds)) { + const cal = get().calendars.find((c) => c.id === calId); + remapped[cal?.originalId || calId] = on; + if (on && cal) destAccount = cal.accountId ?? undefined; + } + patch.calendarIds = remapped; + } + if (destAccount !== sourceAccount) { + // JMAP has no move across accounts: create the task in the calendar's + // account, then destroy the original. Creating first means a failed + // create leaves the task where it was. + const [original] = await fetchEvents([realId], writeAccount(scope, sourceAccount)); + if (!original) throw new Error('Task not found'); + assertInScope(scope); + const { + id: _id, created: _created, updated: _updated, sequence: _sequence, isOrigin: _isOrigin, + utcStart: _utcStart, utcEnd: _utcEnd, calendarIds: _calendarIds, ...rest + } = original as CalendarEvent & Record; + const destCalendarId = Object.keys(patch.calendarIds ?? {})[0]; + const { calendarIds: _patchCalendars, ...fields } = patch; + await apiCreateEvent( + { ...rest, ...fields, '@type': 'Task' } as Partial, + destCalendarId, + undefined, + writeAccount(scope, destAccount), + ); + await apiDeleteEvents([realId], undefined, writeAccount(scope, sourceAccount)); + // Under a new id in another account: reload rather than patch. + if (inScope(scope)) await get().fetchTasks(); + return; + } + await apiUpdateEvent(realId, patch, undefined, writeAccount(scope, sourceAccount)); // Saved; the next account's tasks can carry the same ids. if (!inScope(scope)) return; set({ tasks: get().tasks.map((t) => (t.id === id ? { ...t, ...changes } : t)) });