@remit/calendar-service 0.0.14 → 0.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/calendar-service",
3
- "version": "0.0.14",
3
+ "version": "0.0.16",
4
4
  "description": "iCalendar (RFC 5545) parsing, projection and recurrence expansion over the calendar store",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -64,6 +64,7 @@ const suggestionOf = (
64
64
  zoneCertainty: "Explicit",
65
65
  icalData,
66
66
  acceptedCalendarObjectId: "",
67
+ supersededByMessageId: "",
67
68
  createdAt: 0,
68
69
  updatedAt: 0,
69
70
  ...overrides,
package/src/errors.ts CHANGED
@@ -38,7 +38,9 @@ export type CalendarValidationCode =
38
38
  | "UnmovableRecurrenceRule"
39
39
  | "UneditableCancellation"
40
40
  /** A subscription address that is not an http, https or webcal URL. */
41
- | "InvalidSubscriptionUrl";
41
+ | "InvalidSubscriptionUrl"
42
+ | "NotReopenable"
43
+ | "AnswerOvertaken";
42
44
 
43
45
  export interface CalendarValidationError {
44
46
  code: CalendarValidationCode;
package/src/index.ts CHANGED
@@ -60,10 +60,17 @@ export {
60
60
  provisionDefaultCalendar,
61
61
  putCalendarObject,
62
62
  } from "./put.js";
63
+ export {
64
+ answersOvertakenBy,
65
+ type ReopenCalendarSuggestionInput,
66
+ reopenCalendarSuggestion,
67
+ } from "./reopen.js";
63
68
  export {
64
69
  applyScopedDelete,
65
70
  applyScopedUpdate,
66
71
  findOccurrence,
72
+ followingUidOf,
73
+ followingUidPrefix,
67
74
  type RecurrenceScopeValue,
68
75
  type ScopedWrite,
69
76
  type ScopedWriteInput,
@@ -169,6 +169,11 @@ export class MemoryCalendarStore implements ICalendarUnitOfWork {
169
169
  (object) =>
170
170
  object.calendarId === calendarId && object.icalUid === icalUid,
171
171
  ) ?? null,
172
+ listByUidPrefix: async (calendarId: string, prefix: string) =>
173
+ [...this.objects.values()].filter(
174
+ (object) =>
175
+ object.calendarId === calendarId && object.icalUid.startsWith(prefix),
176
+ ),
172
177
  listByCalendar: async (calendarId: string) =>
173
178
  [...this.objects.values()].filter(
174
179
  (object) => object.calendarId === calendarId,
@@ -236,6 +241,7 @@ export class MemoryCalendarStore implements ICalendarUnitOfWork {
236
241
  suggestionId,
237
242
  state: existing?.state ?? CalendarSuggestionState.Pending,
238
243
  acceptedCalendarObjectId: existing?.acceptedCalendarObjectId ?? "",
244
+ supersededByMessageId: existing?.supersededByMessageId ?? "",
239
245
  createdAt: existing?.createdAt ?? now,
240
246
  updatedAt: now,
241
247
  };
@@ -249,6 +255,15 @@ export class MemoryCalendarStore implements ICalendarUnitOfWork {
249
255
  }
250
256
  return suggestion;
251
257
  },
258
+ listByAcceptedCalendarObjects: async (
259
+ accountConfigId: string,
260
+ calendarObjectIds: string[],
261
+ ) =>
262
+ [...this.suggestions.values()].filter(
263
+ (suggestion) =>
264
+ suggestion.accountConfigId === accountConfigId &&
265
+ calendarObjectIds.includes(suggestion.acceptedCalendarObjectId),
266
+ ),
252
267
  listByMessage: async (accountConfigId: string, messageId: string) =>
253
268
  [...this.suggestions.values()].filter(
254
269
  (suggestion) =>
@@ -282,6 +297,7 @@ export class MemoryCalendarStore implements ICalendarUnitOfWork {
282
297
  supersedeIfPending: async (
283
298
  accountConfigId: string,
284
299
  suggestionId: string,
300
+ supersededByMessageId: string,
285
301
  ) => {
286
302
  const suggestion = this.suggestions.get(suggestionId);
287
303
  if (
@@ -295,11 +311,33 @@ export class MemoryCalendarStore implements ICalendarUnitOfWork {
295
311
  ...suggestion,
296
312
  state: CalendarSuggestionState.Superseded,
297
313
  acceptedCalendarObjectId: "",
314
+ supersededByMessageId,
298
315
  updatedAt: Date.now(),
299
316
  };
300
317
  this.suggestions.set(suggestionId, retired);
301
318
  return retired;
302
319
  },
320
+ repointSuperseded: async (
321
+ accountConfigId: string,
322
+ suggestionId: string,
323
+ supersededByMessageId: string,
324
+ ) => {
325
+ const suggestion = this.suggestions.get(suggestionId);
326
+ if (
327
+ !suggestion ||
328
+ suggestion.accountConfigId !== accountConfigId ||
329
+ suggestion.state !== CalendarSuggestionState.Superseded
330
+ ) {
331
+ return null;
332
+ }
333
+ const repointed = {
334
+ ...suggestion,
335
+ supersededByMessageId,
336
+ updatedAt: Date.now(),
337
+ };
338
+ this.suggestions.set(suggestionId, repointed);
339
+ return repointed;
340
+ },
303
341
  };
304
342
 
305
343
  private readonly feedTokenRepo: ICalendarFeedTokenRepository = {
@@ -0,0 +1,531 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { CalendarInviteAnswer as CalendarInviteAnswerValue } from "@remit/api-openapi-types";
4
+ import type { CalendarSuggestionItem } from "@remit/data-ports";
5
+ import {
6
+ CalendarAnswerOvertaken,
7
+ CalendarInviteAnswer,
8
+ CalendarSuggestionSource,
9
+ CalendarSuggestionState,
10
+ } from "@remit/domain-enums";
11
+ import { acceptCalendarSuggestion } from "./accept.js";
12
+ import { ical } from "./fixtures.js";
13
+ import { MemoryCalendarStore } from "./memory-store.js";
14
+ import {
15
+ deleteCalendarObject,
16
+ provisionDefaultCalendar,
17
+ putCalendarObject,
18
+ } from "./put.js";
19
+ import { answersOvertakenBy, reopenCalendarSuggestion } from "./reopen.js";
20
+ import { followingUidOf, followingUidPrefix } from "./scope.js";
21
+ import { recordCalendarSuggestion } from "./suggest.js";
22
+
23
+ const ACCOUNT_CONFIG_ID = "account-config-1";
24
+ const ATTENDEE = "user@example.test";
25
+
26
+ const invitation = (
27
+ uid: string,
28
+ {
29
+ method = "REQUEST",
30
+ sequence = 0,
31
+ }: { method?: "REQUEST" | "CANCEL"; sequence?: number } = {},
32
+ ): string =>
33
+ ical(
34
+ "BEGIN:VCALENDAR",
35
+ "VERSION:2.0",
36
+ "PRODID:-//Example Corp//Scheduler//EN",
37
+ `METHOD:${method}`,
38
+ "BEGIN:VEVENT",
39
+ `UID:${uid}`,
40
+ "DTSTAMP:20260801T090000Z",
41
+ `SEQUENCE:${sequence}`,
42
+ "DTSTART:20260901T080000Z",
43
+ "DTEND:20260901T090000Z",
44
+ "RRULE:FREQ=WEEKLY;COUNT=4",
45
+ "SUMMARY:Quarterly review",
46
+ "ORGANIZER:mailto:organizer@example.test",
47
+ "ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:user@example.test",
48
+ "END:VEVENT",
49
+ "END:VCALENDAR",
50
+ );
51
+
52
+ const provisioned = async (): Promise<{
53
+ store: MemoryCalendarStore;
54
+ calendarId: string;
55
+ }> => {
56
+ const store = new MemoryCalendarStore();
57
+ const collection = await provisionDefaultCalendar(store, ACCOUNT_CONFIG_ID);
58
+ return { store, calendarId: collection.calendarId };
59
+ };
60
+
61
+ const recorded = async (
62
+ store: MemoryCalendarStore,
63
+ uid: string,
64
+ messageId: string,
65
+ options: { method?: "REQUEST" | "CANCEL"; sequence?: number } = {},
66
+ ): Promise<CalendarSuggestionItem> => {
67
+ const result = await recordCalendarSuggestion(store.calendarSuggestion, {
68
+ accountConfigId: ACCOUNT_CONFIG_ID,
69
+ messageId,
70
+ bodyPartId: "body-part-1",
71
+ source: CalendarSuggestionSource.IcalendarPart,
72
+ icalData: invitation(uid, options),
73
+ timezone: "UTC",
74
+ });
75
+ assert.ok(result.ok);
76
+ return result.value.suggestion;
77
+ };
78
+
79
+ const answered = async (
80
+ store: MemoryCalendarStore,
81
+ calendarId: string,
82
+ suggestion: CalendarSuggestionItem,
83
+ answer: CalendarInviteAnswerValue,
84
+ ): Promise<CalendarSuggestionItem> => {
85
+ const result = await acceptCalendarSuggestion(store, {
86
+ accountConfigId: ACCOUNT_CONFIG_ID,
87
+ calendarId,
88
+ suggestion,
89
+ attendee: ATTENDEE,
90
+ answer,
91
+ });
92
+ assert.ok(result.ok);
93
+ return result.value.suggestion;
94
+ };
95
+
96
+ const reopen = (
97
+ store: MemoryCalendarStore,
98
+ suggestion: CalendarSuggestionItem,
99
+ ) =>
100
+ reopenCalendarSuggestion(store, {
101
+ accountConfigId: ACCOUNT_CONFIG_ID,
102
+ suggestion,
103
+ });
104
+
105
+ const reopened = async (
106
+ store: MemoryCalendarStore,
107
+ suggestion: CalendarSuggestionItem,
108
+ ): Promise<CalendarSuggestionItem> => {
109
+ const result = await reopen(store, suggestion);
110
+ assert.ok(result.ok, JSON.stringify(result));
111
+ return result.value;
112
+ };
113
+
114
+ const answerOf = async (
115
+ store: MemoryCalendarStore,
116
+ suggestion: CalendarSuggestionItem,
117
+ ) => {
118
+ const [answered] = await answersOvertakenBy(
119
+ store.calendarSuggestion,
120
+ ACCOUNT_CONFIG_ID,
121
+ [suggestion],
122
+ );
123
+ return answered?.answerOvertakenBy;
124
+ };
125
+
126
+ const secondCalendar = (store: MemoryCalendarStore) =>
127
+ store.transaction((repos) =>
128
+ repos.calendarCollection.create({
129
+ accountConfigId: ACCOUNT_CONFIG_ID,
130
+ urlSegment: "personal",
131
+ displayName: "Personal",
132
+ }),
133
+ );
134
+
135
+ describe("reopenCalendarSuggestion", () => {
136
+ for (const answer of [
137
+ CalendarInviteAnswer.Accepted,
138
+ CalendarInviteAnswer.Tentative,
139
+ ]) {
140
+ it(`takes a ${answer} answer back: the event and its occurrences leave, the card is Pending`, async () => {
141
+ const { store, calendarId } = await provisioned();
142
+ const card = await answered(
143
+ store,
144
+ calendarId,
145
+ await recorded(store, "invite-1@example.test", "message-1"),
146
+ answer,
147
+ );
148
+ const written = card.acceptedCalendarObjectId;
149
+ const sequenceBefore = store.collections.get(calendarId)?.syncSequence;
150
+
151
+ const back = await reopened(store, card);
152
+
153
+ assert.equal(back.state, CalendarSuggestionState.Pending);
154
+ assert.equal(back.acceptedCalendarObjectId, "");
155
+ assert.equal(store.objects.has(written), false);
156
+ assert.equal(store.occurrences.get(written)?.length ?? 0, 0);
157
+ assert.ok(
158
+ (store.collections.get(calendarId)?.syncSequence ?? 0) >
159
+ (sequenceBefore ?? 0),
160
+ );
161
+ assert.equal(
162
+ store.suggestions.get(card.suggestionId)?.state,
163
+ CalendarSuggestionState.Pending,
164
+ );
165
+ });
166
+ }
167
+
168
+ it("a reopened card can be answered again", async () => {
169
+ const { store, calendarId } = await provisioned();
170
+ const card = await answered(
171
+ store,
172
+ calendarId,
173
+ await recorded(store, "invite-1@example.test", "message-1"),
174
+ CalendarInviteAnswer.Accepted,
175
+ );
176
+
177
+ const again = await answered(
178
+ store,
179
+ calendarId,
180
+ await reopened(store, card),
181
+ CalendarInviteAnswer.Tentative,
182
+ );
183
+
184
+ assert.equal(again.state, CalendarSuggestionState.Tentative);
185
+ assert.equal(store.objects.size, 1);
186
+ assert.ok(store.objects.has(again.acceptedCalendarObjectId));
187
+ });
188
+
189
+ it("removes the remainder a this-and-following edit split off, so a re-add does not duplicate", async () => {
190
+ const { store, calendarId } = await provisioned();
191
+ const uid = "invite-1@example.test";
192
+ const card = await answered(
193
+ store,
194
+ calendarId,
195
+ await recorded(store, uid, "message-1"),
196
+ CalendarInviteAnswer.Accepted,
197
+ );
198
+ const head = store.objects.get(card.acceptedCalendarObjectId);
199
+ assert.ok(head);
200
+ const tailUid = followingUidOf(uid, "split-1@reader.remit");
201
+ const tail = await putCalendarObject(store, {
202
+ accountConfigId: ACCOUNT_CONFIG_ID,
203
+ calendarId,
204
+ resourceName: "split-1.ics",
205
+ icalData: head.icalData.replace(`UID:${uid}`, `UID:${tailUid}`),
206
+ });
207
+ assert.ok(tail.ok);
208
+ const tailOfTail = await putCalendarObject(store, {
209
+ accountConfigId: ACCOUNT_CONFIG_ID,
210
+ calendarId,
211
+ resourceName: "split-2.ics",
212
+ icalData: head.icalData.replace(
213
+ `UID:${uid}`,
214
+ `UID:${followingUidOf(tailUid, "split-2@reader.remit")}`,
215
+ ),
216
+ });
217
+ assert.ok(tailOfTail.ok);
218
+ assert.equal(store.objects.size, 3);
219
+
220
+ const back = await reopened(store, card);
221
+ assert.equal(store.objects.size, 0);
222
+ assert.equal(
223
+ store.occurrences.get(tail.value.calendarObjectId)?.length ?? 0,
224
+ 0,
225
+ );
226
+
227
+ await answered(store, calendarId, back, CalendarInviteAnswer.Accepted);
228
+ assert.equal(store.objects.size, 1);
229
+ });
230
+
231
+ for (const second of [
232
+ { name: "a resend at the same SEQUENCE", sequence: 0 },
233
+ { name: "a later revision", sequence: 1 },
234
+ ]) {
235
+ it(`keeps the event while ${second.name} still holds it, and removes it with the last answer`, async () => {
236
+ const { store, calendarId } = await provisioned();
237
+ const uid = "invite-1@example.test";
238
+ const first = await answered(
239
+ store,
240
+ calendarId,
241
+ await recorded(store, uid, "message-1"),
242
+ CalendarInviteAnswer.Accepted,
243
+ );
244
+ const other = await answered(
245
+ store,
246
+ calendarId,
247
+ await recorded(store, uid, "message-2", { sequence: second.sequence }),
248
+ CalendarInviteAnswer.Tentative,
249
+ );
250
+ const event = first.acceptedCalendarObjectId;
251
+ assert.equal(other.acceptedCalendarObjectId, event);
252
+ assert.equal(await answerOf(store, first), CalendarAnswerOvertaken.None);
253
+
254
+ const firstBack = await reopened(store, first);
255
+
256
+ assert.equal(firstBack.state, CalendarSuggestionState.Pending);
257
+ assert.equal(firstBack.acceptedCalendarObjectId, "");
258
+ assert.ok(store.objects.has(event), "the other answer still holds it");
259
+ assert.equal(
260
+ store.suggestions.get(other.suggestionId)?.acceptedCalendarObjectId,
261
+ event,
262
+ );
263
+
264
+ const otherBack = await reopened(store, other);
265
+
266
+ assert.equal(otherBack.state, CalendarSuggestionState.Pending);
267
+ assert.equal(store.objects.has(event), false);
268
+ });
269
+ }
270
+
271
+ it("takes back only the parts in the calendar that holds the answered event", async () => {
272
+ const { store, calendarId: work } = await provisioned();
273
+ const personal = (await secondCalendar(store)).calendarId;
274
+ const uid = "invite-1@example.test";
275
+ const first = await answered(
276
+ store,
277
+ work,
278
+ await recorded(store, uid, "message-1"),
279
+ CalendarInviteAnswer.Accepted,
280
+ );
281
+ const second = await answered(
282
+ store,
283
+ personal,
284
+ await recorded(store, uid, "message-2", { sequence: 1 }),
285
+ CalendarInviteAnswer.Accepted,
286
+ );
287
+ assert.notEqual(
288
+ first.acceptedCalendarObjectId,
289
+ second.acceptedCalendarObjectId,
290
+ );
291
+ const personalHead = store.objects.get(second.acceptedCalendarObjectId);
292
+ assert.ok(personalHead);
293
+ const personalTail = await putCalendarObject(store, {
294
+ accountConfigId: ACCOUNT_CONFIG_ID,
295
+ calendarId: personal,
296
+ resourceName: "split-1.ics",
297
+ icalData: personalHead.icalData.replace(
298
+ `UID:${uid}`,
299
+ `UID:${followingUidOf(uid, "split-1@reader.remit")}`,
300
+ ),
301
+ });
302
+ assert.ok(personalTail.ok);
303
+
304
+ await reopened(store, first);
305
+
306
+ assert.equal(store.objects.has(first.acceptedCalendarObjectId), false);
307
+ assert.ok(store.objects.has(second.acceptedCalendarObjectId));
308
+ assert.ok(store.objects.has(personalTail.value.calendarObjectId));
309
+ });
310
+
311
+ it("leaves a remainder split off before split UIDs were derived", async () => {
312
+ const { store, calendarId } = await provisioned();
313
+ const uid = "invite-1@example.test";
314
+ const card = await answered(
315
+ store,
316
+ calendarId,
317
+ await recorded(store, uid, "message-1"),
318
+ CalendarInviteAnswer.Accepted,
319
+ );
320
+ const head = store.objects.get(card.acceptedCalendarObjectId);
321
+ assert.ok(head);
322
+ const legacy = await putCalendarObject(store, {
323
+ accountConfigId: ACCOUNT_CONFIG_ID,
324
+ calendarId,
325
+ resourceName: "legacy.ics",
326
+ icalData: head.icalData
327
+ .replace(`UID:${uid}`, "UID:legacy-1@reader.remit")
328
+ .replace("DTSTART:20260901T080000Z", "DTSTART:20260915T080000Z")
329
+ .replace("DTEND:20260901T090000Z", "DTEND:20260915T090000Z"),
330
+ });
331
+ assert.ok(legacy.ok, JSON.stringify(legacy));
332
+
333
+ await reopened(store, card);
334
+
335
+ assert.equal(store.objects.has(card.acceptedCalendarObjectId), false);
336
+ assert.ok(store.objects.has(legacy.value.calendarObjectId));
337
+ });
338
+
339
+ it("refuses to take back a request's answer once its cancellation was answered into the same event", async () => {
340
+ const { store, calendarId } = await provisioned();
341
+ const uid = "invite-1@example.test";
342
+ const request = await answered(
343
+ store,
344
+ calendarId,
345
+ await recorded(store, uid, "message-1"),
346
+ CalendarInviteAnswer.Accepted,
347
+ );
348
+ const cancel = await answered(
349
+ store,
350
+ calendarId,
351
+ await recorded(store, uid, "message-2", { method: "CANCEL" }),
352
+ CalendarInviteAnswer.Accepted,
353
+ );
354
+ assert.equal(
355
+ request.acceptedCalendarObjectId,
356
+ cancel.acceptedCalendarObjectId,
357
+ );
358
+
359
+ const refused = await reopen(store, request);
360
+
361
+ assert.equal(refused.ok, false);
362
+ assert.equal(!refused.ok && refused.error.code, "AnswerOvertaken");
363
+ assert.ok(store.objects.has(cancel.acceptedCalendarObjectId));
364
+ assert.equal(
365
+ await answerOf(store, request),
366
+ CalendarAnswerOvertaken.Cancellation,
367
+ );
368
+ });
369
+
370
+ it("refuses a superseded card and a cancellation card", async () => {
371
+ const { store } = await provisioned();
372
+ const pending = await recorded(store, "invite-1@example.test", "message-1");
373
+ const cancel = await recorded(store, "invite-2@example.test", "message-2", {
374
+ method: "CANCEL",
375
+ });
376
+
377
+ for (const card of [
378
+ { ...pending, state: CalendarSuggestionState.Superseded },
379
+ { ...cancel, state: CalendarSuggestionState.Dismissed },
380
+ ]) {
381
+ const refused = await reopen(store, card);
382
+ assert.equal(!refused.ok && refused.error.code, "NotReopenable");
383
+ }
384
+ });
385
+
386
+ it("leaves every other event in the calendar alone", async () => {
387
+ const { store, calendarId } = await provisioned();
388
+ const other = await answered(
389
+ store,
390
+ calendarId,
391
+ await recorded(store, "invite-2@example.test", "message-2"),
392
+ CalendarInviteAnswer.Accepted,
393
+ );
394
+ const card = await answered(
395
+ store,
396
+ calendarId,
397
+ await recorded(store, "invite-1@example.test", "message-1"),
398
+ CalendarInviteAnswer.Accepted,
399
+ );
400
+
401
+ await reopened(store, card);
402
+
403
+ assert.deepEqual(
404
+ [...store.objects.keys()],
405
+ [other.acceptedCalendarObjectId],
406
+ );
407
+ });
408
+
409
+ it("reopens a declined card without touching the calendar", async () => {
410
+ const { store, calendarId } = await provisioned();
411
+ const kept = await answered(
412
+ store,
413
+ calendarId,
414
+ await recorded(store, "invite-2@example.test", "message-2"),
415
+ CalendarInviteAnswer.Accepted,
416
+ );
417
+ const pending = await recorded(store, "invite-1@example.test", "message-1");
418
+ const declined = await store.calendarSuggestion.settle(
419
+ ACCOUNT_CONFIG_ID,
420
+ pending.suggestionId,
421
+ { state: CalendarSuggestionState.Declined, acceptedCalendarObjectId: "" },
422
+ );
423
+ const sequenceBefore = store.collections.get(calendarId)?.syncSequence;
424
+
425
+ const back = await reopened(store, declined);
426
+
427
+ assert.equal(back.state, CalendarSuggestionState.Pending);
428
+ assert.ok(store.objects.has(kept.acceptedCalendarObjectId));
429
+ assert.equal(
430
+ store.collections.get(calendarId)?.syncSequence,
431
+ sequenceBefore,
432
+ );
433
+ });
434
+
435
+ it("still reopens when the event was already deleted from the calendar", async () => {
436
+ const { store, calendarId } = await provisioned();
437
+ const card = await answered(
438
+ store,
439
+ calendarId,
440
+ await recorded(store, "invite-1@example.test", "message-1"),
441
+ CalendarInviteAnswer.Accepted,
442
+ );
443
+ await deleteCalendarObject(store, {
444
+ accountConfigId: ACCOUNT_CONFIG_ID,
445
+ calendarId,
446
+ calendarObjectId: card.acceptedCalendarObjectId,
447
+ });
448
+
449
+ const back = await reopened(store, card);
450
+
451
+ assert.equal(back.state, CalendarSuggestionState.Pending);
452
+ assert.equal(back.acceptedCalendarObjectId, "");
453
+ });
454
+
455
+ it("returns a Pending card as it is", async () => {
456
+ const { store } = await provisioned();
457
+ const pending = await recorded(store, "invite-1@example.test", "message-1");
458
+
459
+ assert.deepEqual(await reopened(store, pending), pending);
460
+ });
461
+ });
462
+
463
+ describe("followingUidOf", () => {
464
+ it("keeps every split of a series under the first event's UID", () => {
465
+ const once = followingUidOf("root@example.test", "a");
466
+ const twice = followingUidOf(once, "b");
467
+
468
+ assert.ok(once.startsWith(followingUidPrefix("root@example.test")));
469
+ assert.ok(twice.startsWith(followingUidPrefix("root@example.test")));
470
+ assert.equal(followingUidPrefix(twice), followingUidPrefix(once));
471
+ });
472
+ });
473
+
474
+ describe("answersOvertakenBy", () => {
475
+ it("reads the holders of a whole page in one lookup", async () => {
476
+ const { store, calendarId } = await provisioned();
477
+ const request = await answered(
478
+ store,
479
+ calendarId,
480
+ await recorded(store, "invite-1@example.test", "message-1"),
481
+ CalendarInviteAnswer.Accepted,
482
+ );
483
+ await answered(
484
+ store,
485
+ calendarId,
486
+ await recorded(store, "invite-1@example.test", "message-2", {
487
+ method: "CANCEL",
488
+ }),
489
+ CalendarInviteAnswer.Accepted,
490
+ );
491
+ const other = await answered(
492
+ store,
493
+ calendarId,
494
+ await recorded(store, "invite-2@example.test", "message-3"),
495
+ CalendarInviteAnswer.Accepted,
496
+ );
497
+ const pending = await recorded(store, "invite-3@example.test", "message-4");
498
+ const reads: string[][] = [];
499
+ const counting = {
500
+ listByAcceptedCalendarObjects: (
501
+ accountConfigId: string,
502
+ calendarObjectIds: string[],
503
+ ) => {
504
+ reads.push(calendarObjectIds);
505
+ return store.calendarSuggestion.listByAcceptedCalendarObjects(
506
+ accountConfigId,
507
+ calendarObjectIds,
508
+ );
509
+ },
510
+ };
511
+
512
+ const page = await answersOvertakenBy(counting, ACCOUNT_CONFIG_ID, [
513
+ request,
514
+ other,
515
+ pending,
516
+ ]);
517
+
518
+ assert.equal(reads.length, 1);
519
+ assert.deepEqual(
520
+ page.map((entry) => [
521
+ entry.suggestion.suggestionId,
522
+ entry.answerOvertakenBy,
523
+ ]),
524
+ [
525
+ [request.suggestionId, CalendarAnswerOvertaken.Cancellation],
526
+ [other.suggestionId, CalendarAnswerOvertaken.None],
527
+ [pending.suggestionId, CalendarAnswerOvertaken.None],
528
+ ],
529
+ );
530
+ });
531
+ });
package/src/reopen.ts ADDED
@@ -0,0 +1,190 @@
1
+ import type { CalendarAnswerOvertaken as CalendarAnswerOvertakenValue } from "@remit/api-openapi-types";
2
+ import type {
3
+ CalendarCollectionItem,
4
+ CalendarObjectItem,
5
+ CalendarSuggestionItem,
6
+ CalendarUnitOfWorkRepositories,
7
+ ICalendarSuggestionRepository,
8
+ ICalendarUnitOfWork,
9
+ } from "@remit/data-ports";
10
+ import {
11
+ CalendarAnswerOvertaken,
12
+ CalendarInviteMethod,
13
+ CalendarSuggestionState,
14
+ } from "@remit/domain-enums";
15
+ import { type CalendarResult, calendarFailure } from "./errors.js";
16
+ import { deleteCalendarObject } from "./put.js";
17
+ import { followingUidPrefix } from "./scope.js";
18
+
19
+ export interface ReopenCalendarSuggestionInput {
20
+ accountConfigId: string;
21
+ suggestion: CalendarSuggestionItem;
22
+ }
23
+
24
+ const answeredStates: ReadonlySet<CalendarSuggestionItem["state"]> = new Set([
25
+ CalendarSuggestionState.Accepted,
26
+ CalendarSuggestionState.Tentative,
27
+ ]);
28
+
29
+ type SuggestionHolders = Pick<
30
+ ICalendarSuggestionRepository,
31
+ "listByAcceptedCalendarObjects"
32
+ >;
33
+
34
+ const holdersByObject = async (
35
+ calendarSuggestion: SuggestionHolders,
36
+ accountConfigId: string,
37
+ suggestions: CalendarSuggestionItem[],
38
+ ): Promise<Map<string, CalendarSuggestionItem[]>> => {
39
+ const objectIds = [
40
+ ...new Set(
41
+ suggestions
42
+ .map((suggestion) => suggestion.acceptedCalendarObjectId)
43
+ .filter((objectId) => objectId !== ""),
44
+ ),
45
+ ];
46
+ const byObject = new Map<string, CalendarSuggestionItem[]>();
47
+ for (const holder of await calendarSuggestion.listByAcceptedCalendarObjects(
48
+ accountConfigId,
49
+ objectIds,
50
+ )) {
51
+ if (!answeredStates.has(holder.state)) continue;
52
+ const held = byObject.get(holder.acceptedCalendarObjectId) ?? [];
53
+ held.push(holder);
54
+ byObject.set(holder.acceptedCalendarObjectId, held);
55
+ }
56
+ return byObject;
57
+ };
58
+
59
+ const othersIn = (
60
+ byObject: Map<string, CalendarSuggestionItem[]>,
61
+ suggestion: CalendarSuggestionItem,
62
+ ): CalendarSuggestionItem[] =>
63
+ (byObject.get(suggestion.acceptedCalendarObjectId) ?? []).filter(
64
+ (other) => other.suggestionId !== suggestion.suggestionId,
65
+ );
66
+
67
+ const overtakenOf = (
68
+ holders: CalendarSuggestionItem[],
69
+ ): CalendarAnswerOvertakenValue =>
70
+ holders.some((other) => other.method === CalendarInviteMethod.Cancel)
71
+ ? CalendarAnswerOvertaken.Cancellation
72
+ : CalendarAnswerOvertaken.None;
73
+
74
+ export const answersOvertakenBy = async (
75
+ calendarSuggestion: SuggestionHolders,
76
+ accountConfigId: string,
77
+ suggestions: CalendarSuggestionItem[],
78
+ ): Promise<
79
+ {
80
+ suggestion: CalendarSuggestionItem;
81
+ answerOvertakenBy: CalendarAnswerOvertakenValue;
82
+ }[]
83
+ > => {
84
+ const byObject = await holdersByObject(
85
+ calendarSuggestion,
86
+ accountConfigId,
87
+ suggestions,
88
+ );
89
+ return suggestions.map((suggestion) => ({
90
+ suggestion,
91
+ answerOvertakenBy: overtakenOf(othersIn(byObject, suggestion)),
92
+ }));
93
+ };
94
+
95
+ const locateAnswered = async (
96
+ repos: CalendarUnitOfWorkRepositories,
97
+ collections: CalendarCollectionItem[],
98
+ calendarObjectId: string,
99
+ ): Promise<
100
+ { collection: CalendarCollectionItem; object: CalendarObjectItem } | undefined
101
+ > => {
102
+ for (const collection of collections) {
103
+ const object = await repos.calendarObject.find(
104
+ collection.calendarId,
105
+ calendarObjectId,
106
+ );
107
+ if (object !== null) return { collection, object };
108
+ }
109
+ return undefined;
110
+ };
111
+
112
+ const partsOf = async (
113
+ repos: CalendarUnitOfWorkRepositories,
114
+ suggestion: CalendarSuggestionItem,
115
+ collection: CalendarCollectionItem,
116
+ answered: CalendarObjectItem,
117
+ ): Promise<CalendarObjectItem[]> => [
118
+ answered,
119
+ ...(await repos.calendarObject.listByUidPrefix(
120
+ collection.calendarId,
121
+ followingUidPrefix(suggestion.icalUid),
122
+ )),
123
+ ];
124
+
125
+ export const reopenCalendarSuggestion = async (
126
+ unitOfWork: ICalendarUnitOfWork,
127
+ input: ReopenCalendarSuggestionInput,
128
+ ): Promise<CalendarResult<CalendarSuggestionItem>> => {
129
+ const { accountConfigId, suggestion } = input;
130
+ if (suggestion.state === CalendarSuggestionState.Pending) {
131
+ return { ok: true, value: suggestion };
132
+ }
133
+ if (suggestion.state === CalendarSuggestionState.Superseded) {
134
+ return calendarFailure(
135
+ "NotReopenable",
136
+ "A later message replaced this invitation, so answer that one instead.",
137
+ );
138
+ }
139
+ if (suggestion.method === CalendarInviteMethod.Cancel) {
140
+ return calendarFailure(
141
+ "NotReopenable",
142
+ "This is a cancellation, so there is no answer to take back.",
143
+ );
144
+ }
145
+
146
+ return unitOfWork.transaction(async (repos) => {
147
+ const holders = othersIn(
148
+ await holdersByObject(repos.calendarSuggestion, accountConfigId, [
149
+ suggestion,
150
+ ]),
151
+ suggestion,
152
+ );
153
+ if (overtakenOf(holders) === CalendarAnswerOvertaken.Cancellation) {
154
+ return calendarFailure<CalendarSuggestionItem>(
155
+ "AnswerOvertaken",
156
+ "The cancellation of this event was answered into the same event, so this answer can no longer be taken back.",
157
+ );
158
+ }
159
+
160
+ const located =
161
+ suggestion.acceptedCalendarObjectId === "" || holders.length > 0
162
+ ? undefined
163
+ : await locateAnswered(
164
+ repos,
165
+ await repos.calendarCollection.listByAccountConfig(accountConfigId),
166
+ suggestion.acceptedCalendarObjectId,
167
+ );
168
+ if (located) {
169
+ for (const object of await partsOf(
170
+ repos,
171
+ suggestion,
172
+ located.collection,
173
+ located.object,
174
+ )) {
175
+ await deleteCalendarObject(unitOfWork, {
176
+ accountConfigId,
177
+ calendarId: located.collection.calendarId,
178
+ calendarObjectId: object.calendarObjectId,
179
+ });
180
+ }
181
+ }
182
+
183
+ const settled = await repos.calendarSuggestion.settle(
184
+ accountConfigId,
185
+ suggestion.suggestionId,
186
+ { state: CalendarSuggestionState.Pending, acceptedCalendarObjectId: "" },
187
+ );
188
+ return { ok: true, value: settled };
189
+ });
190
+ };
package/src/scope.ts CHANGED
@@ -35,6 +35,14 @@ export interface ScopedWriteInput {
35
35
  followingUid: string;
36
36
  }
37
37
 
38
+ const FOLLOWING_UID_SEPARATOR = "/following/";
39
+
40
+ export const followingUidPrefix = (icalUid: string): string =>
41
+ `${icalUid.split(FOLLOWING_UID_SEPARATOR)[0]}${FOLLOWING_UID_SEPARATOR}`;
42
+
43
+ export const followingUidOf = (icalUid: string, id: string): string =>
44
+ `${followingUidPrefix(icalUid)}${id}`;
45
+
38
46
  interface FoundOccurrence {
39
47
  /** The rule slot, in whatever form the master's DTSTART is written in. */
40
48
  slot: ICAL.Time;
@@ -180,6 +180,28 @@ describe("recordCalendarSuggestion", () => {
180
180
  first.value.suggestion.suggestionId,
181
181
  );
182
182
  assert.equal(superseded.state, CalendarSuggestionState.Superseded);
183
+ assert.equal(
184
+ superseded.supersededByMessageId,
185
+ second.value.suggestion.messageId,
186
+ );
187
+ });
188
+
189
+ it("points every retired revision at the newest one", async () => {
190
+ const store = new MemoryCalendarStore();
191
+
192
+ const first = await record(store, invitation({ sequence: 0 }), "message-1");
193
+ await record(store, invitation({ sequence: 1 }), "message-2");
194
+ await record(store, invitation({ sequence: 2 }), "message-3");
195
+
196
+ assert.ok(first.ok);
197
+ const retired = [...store.suggestions.values()]
198
+ .filter((row) => row.state === CalendarSuggestionState.Superseded)
199
+ .map((row) => [row.messageId, row.supersededByMessageId])
200
+ .sort();
201
+ assert.deepEqual(retired, [
202
+ ["message-1", "message-3"],
203
+ ["message-2", "message-3"],
204
+ ]);
183
205
  });
184
206
 
185
207
  it("leaves the earlier revision alone when the sequence did not move", async () => {
package/src/suggest.ts CHANGED
@@ -111,23 +111,22 @@ export interface RecordedCalendarSuggestion {
111
111
  }
112
112
 
113
113
  /**
114
- * Every pending suggestion the account holds. Drained rather than read as one
115
- * page: the page size is a repository detail, and a supersession that missed
116
- * the older revision because it sat on page two would leave two live cards for
117
- * one event.
114
+ * Every suggestion the account holds in one state. Drained rather than read as
115
+ * one page: the page size is a repository detail, and a supersession that
116
+ * missed the older revision because it sat on page two would leave two live
117
+ * cards for one event.
118
118
  */
119
- const listPending = async (
119
+ const listInState = async (
120
120
  repo: ICalendarSuggestionRepository,
121
121
  accountConfigId: string,
122
+ state: CalendarSuggestionItem["state"],
122
123
  ): Promise<CalendarSuggestionItem[]> => {
123
124
  const items: CalendarSuggestionItem[] = [];
124
125
  let continuationToken: string | undefined;
125
126
  do {
126
- const page = await repo.listByState(
127
- accountConfigId,
128
- CalendarSuggestionState.Pending,
129
- { continuationToken },
130
- );
127
+ const page = await repo.listByState(accountConfigId, state, {
128
+ continuationToken,
129
+ });
131
130
  items.push(...page.items);
132
131
  continuationToken = page.continuationToken;
133
132
  } while (continuationToken);
@@ -157,6 +156,10 @@ const listPending = async (
157
156
  * write would replace their acceptance with `Superseded` and blank the id of
158
157
  * the event it put in their calendar. A card answered in the gap keeps its
159
158
  * answer and is simply not reported as superseded.
159
+ *
160
+ * Every earlier revision already retired is pointed at this one too, so a
161
+ * card three revisions back opens the newest in one step rather than walking
162
+ * the chain a revision at a time.
160
163
  */
161
164
  export const recordCalendarSuggestion = async (
162
165
  repo: ICalendarSuggestionRepository,
@@ -174,21 +177,47 @@ export const recordCalendarSuggestion = async (
174
177
  ...projection.value,
175
178
  });
176
179
 
177
- const stale = (await listPending(repo, input.accountConfigId)).filter(
178
- (candidate) =>
179
- candidate.icalUid === suggestion.icalUid &&
180
- candidate.suggestionId !== suggestion.suggestionId &&
181
- candidate.sequence < suggestion.sequence,
182
- );
180
+ const olderRevision = (candidate: CalendarSuggestionItem): boolean =>
181
+ candidate.icalUid === suggestion.icalUid &&
182
+ candidate.suggestionId !== suggestion.suggestionId &&
183
+ candidate.sequence < suggestion.sequence;
184
+
185
+ const stale = (
186
+ await listInState(
187
+ repo,
188
+ input.accountConfigId,
189
+ CalendarSuggestionState.Pending,
190
+ )
191
+ ).filter(olderRevision);
183
192
 
184
193
  const superseded: CalendarSuggestionItem[] = [];
185
194
  for (const candidate of stale) {
186
195
  const retired = await repo.supersedeIfPending(
187
196
  input.accountConfigId,
188
197
  candidate.suggestionId,
198
+ suggestion.messageId,
189
199
  );
190
200
  if (retired) superseded.push(retired);
191
201
  }
192
202
 
203
+ const retiredEarlier = (
204
+ await listInState(
205
+ repo,
206
+ input.accountConfigId,
207
+ CalendarSuggestionState.Superseded,
208
+ )
209
+ ).filter(
210
+ (candidate) =>
211
+ olderRevision(candidate) &&
212
+ candidate.supersededByMessageId !== suggestion.messageId,
213
+ );
214
+ for (const candidate of retiredEarlier) {
215
+ await repo.repointSuperseded(
216
+ input.accountConfigId,
217
+ candidate.suggestionId,
218
+ suggestion.messageId,
219
+ );
220
+ }
221
+
193
222
  return { ok: true, value: { suggestion, superseded } };
194
223
  };