@remit/calendar-service 0.0.13 → 0.0.15

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.13",
3
+ "version": "0.0.15",
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,
@@ -0,0 +1,173 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { CalendarSuggestionItem } from "@remit/data-ports";
4
+ import {
5
+ CalendarInviteAnswer,
6
+ CalendarSuggestionSource,
7
+ CalendarSuggestionState,
8
+ ZoneCertainty,
9
+ } from "@remit/domain-enums";
10
+ import { acceptCalendarSuggestion } from "./accept.js";
11
+ import type { CalendarEventFields } from "./build.js";
12
+ import { correctCalendarSuggestion } from "./correct.js";
13
+ import { ical } from "./fixtures.js";
14
+ import { MemoryCalendarStore } from "./memory-store.js";
15
+ import { provisionDefaultCalendar } from "./put.js";
16
+ import { recordCalendarSuggestion } from "./suggest.js";
17
+
18
+ const ACCOUNT_CONFIG_ID = "account-config-1";
19
+ const ATTENDEE = "user@example.test";
20
+ const UID = "invite-5120@example.test";
21
+
22
+ const invitation = ({
23
+ method = "REQUEST",
24
+ start = "DTSTART:20260901T080000Z",
25
+ end = "DTEND:20260901T090000Z",
26
+ }: {
27
+ method?: string;
28
+ start?: string;
29
+ end?: string;
30
+ }): string =>
31
+ ical(
32
+ "BEGIN:VCALENDAR",
33
+ "VERSION:2.0",
34
+ "PRODID:-//Example Corp//Scheduler//EN",
35
+ `METHOD:${method}`,
36
+ "BEGIN:VEVENT",
37
+ `UID:${UID}`,
38
+ "DTSTAMP:20260801T090000Z",
39
+ "SEQUENCE:2",
40
+ start,
41
+ end,
42
+ "SUMMARY:Quarterly review",
43
+ "LOCATION:Room 4",
44
+ "ORGANIZER:mailto:organizer@example.test",
45
+ "ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:user@example.test",
46
+ "X-EXAMPLE-TICKET:AB-5120",
47
+ "END:VEVENT",
48
+ "END:VCALENDAR",
49
+ );
50
+
51
+ const pendingIn = async (icalData: string) => {
52
+ const store = new MemoryCalendarStore();
53
+ const collection = await provisionDefaultCalendar(store, ACCOUNT_CONFIG_ID);
54
+ const recorded = await recordCalendarSuggestion(store.calendarSuggestion, {
55
+ accountConfigId: ACCOUNT_CONFIG_ID,
56
+ messageId: "message-1",
57
+ bodyPartId: "body-part-1",
58
+ source: CalendarSuggestionSource.IcalendarPart,
59
+ icalData,
60
+ timezone: "UTC",
61
+ });
62
+ assert.ok(recorded.ok);
63
+ return {
64
+ store,
65
+ calendarId: collection.calendarId,
66
+ suggestion: recorded.value.suggestion,
67
+ };
68
+ };
69
+
70
+ const acceptCorrected = async (
71
+ store: MemoryCalendarStore,
72
+ calendarId: string,
73
+ suggestion: CalendarSuggestionItem,
74
+ patch: Partial<CalendarEventFields>,
75
+ ) => {
76
+ const corrected = await correctCalendarSuggestion(suggestion, patch, "UTC");
77
+ if (!corrected.ok) return corrected;
78
+ return acceptCalendarSuggestion(store, {
79
+ accountConfigId: ACCOUNT_CONFIG_ID,
80
+ calendarId,
81
+ suggestion: corrected.value,
82
+ attendee: ATTENDEE,
83
+ answer: CalendarInviteAnswer.Accepted,
84
+ });
85
+ };
86
+
87
+ describe("correctCalendarSuggestion", () => {
88
+ it("writes the corrected event and settles the card as accepted", async () => {
89
+ const { store, calendarId, suggestion } = await pendingIn(invitation({}));
90
+
91
+ const accepted = await acceptCorrected(store, calendarId, suggestion, {
92
+ summary: "Quarterly review, moved",
93
+ start: "2026-09-01T13:00:00+00:00",
94
+ end: "2026-09-01T14:30:00+00:00",
95
+ allDay: false,
96
+ timeZone: "",
97
+ });
98
+
99
+ assert.ok(accepted.ok);
100
+ const object = accepted.value.object;
101
+ assert.ok(object);
102
+ assert.equal(object.summary, "Quarterly review, moved");
103
+ assert.equal(Date.parse(object.dtStart), Date.parse("2026-09-01T13:00Z"));
104
+ assert.equal(Date.parse(object.dtEnd), Date.parse("2026-09-01T14:30Z"));
105
+ assert.equal(object.icalUid, UID);
106
+ assert.match(object.icalData, /LOCATION:Room 4/);
107
+ assert.match(object.icalData, /X-EXAMPLE-TICKET:AB-5120/);
108
+ assert.match(object.icalData, /PARTSTAT=ACCEPTED/);
109
+
110
+ const settled = await store.calendarSuggestion.get(
111
+ ACCOUNT_CONFIG_ID,
112
+ suggestion.suggestionId,
113
+ );
114
+ assert.equal(settled.state, CalendarSuggestionState.Accepted);
115
+ assert.equal(settled.acceptedCalendarObjectId, object.calendarObjectId);
116
+ });
117
+
118
+ it("places an unresolvable zone on the clock the reader picked", async () => {
119
+ const { store, calendarId, suggestion } = await pendingIn(
120
+ invitation({
121
+ start: "DTSTART;TZID=Nowhere Standard Time:20260901T090000",
122
+ end: "DTEND;TZID=Nowhere Standard Time:20260901T100000",
123
+ }),
124
+ );
125
+ assert.equal(suggestion.zoneCertainty, ZoneCertainty.Ambiguous);
126
+
127
+ const accepted = await acceptCorrected(store, calendarId, suggestion, {
128
+ start: "2026-09-01T09:00:00+02:00",
129
+ end: "2026-09-01T10:00:00+02:00",
130
+ allDay: false,
131
+ timeZone: "Europe/Amsterdam",
132
+ });
133
+
134
+ assert.ok(accepted.ok);
135
+ const object = accepted.value.object;
136
+ assert.ok(object);
137
+ assert.equal(object.zoneCertainty, ZoneCertainty.Explicit);
138
+ assert.equal(Date.parse(object.dtStart), Date.parse("2026-09-01T07:00Z"));
139
+ assert.match(
140
+ object.icalData,
141
+ /DTSTART;TZID=Europe\/Amsterdam:20260901T090000/,
142
+ );
143
+ });
144
+
145
+ it("refuses a cancellation and leaves the card waiting", async () => {
146
+ const { store, calendarId, suggestion } = await pendingIn(
147
+ invitation({ method: "CANCEL" }),
148
+ );
149
+
150
+ const refused = await acceptCorrected(store, calendarId, suggestion, {
151
+ summary: "Anything",
152
+ });
153
+
154
+ assert.equal(refused.ok, false);
155
+ assert.equal(store.objects.size, 0);
156
+ const untouched = await store.calendarSuggestion.get(
157
+ ACCOUNT_CONFIG_ID,
158
+ suggestion.suggestionId,
159
+ );
160
+ assert.equal(untouched.state, CalendarSuggestionState.Pending);
161
+ });
162
+
163
+ it("refuses a correction the event cannot take and writes nothing", async () => {
164
+ const { store, calendarId, suggestion } = await pendingIn(invitation({}));
165
+
166
+ const refused = await acceptCorrected(store, calendarId, suggestion, {
167
+ start: "2026-09-01T13:00:00",
168
+ });
169
+
170
+ assert.equal(refused.ok, false);
171
+ assert.equal(store.objects.size, 0);
172
+ });
173
+ });
package/src/correct.ts ADDED
@@ -0,0 +1,40 @@
1
+ import type { CalendarSuggestionItem } from "@remit/data-ports";
2
+ import { CalendarInviteMethod, RecurrenceScope } from "@remit/domain-enums";
3
+ import type { CalendarEventFields } from "./build.js";
4
+ import { type CalendarResult, calendarFailure } from "./errors.js";
5
+ import { parseCalendar } from "./parse.js";
6
+ import { applyScopedUpdate } from "./scope.js";
7
+
8
+ export const correctCalendarSuggestion = async (
9
+ suggestion: CalendarSuggestionItem,
10
+ patch: Partial<CalendarEventFields>,
11
+ collectionTimezone: string,
12
+ ): Promise<CalendarResult<CalendarSuggestionItem>> => {
13
+ if (suggestion.method === CalendarInviteMethod.Cancel) {
14
+ return calendarFailure(
15
+ "UneditableCancellation",
16
+ "a cancellation names no event to correct — add it or dismiss it as it is",
17
+ );
18
+ }
19
+
20
+ const parsed = await parseCalendar(suggestion.icalData);
21
+ if (!parsed.ok) return parsed;
22
+
23
+ const edited = await applyScopedUpdate(
24
+ parsed.value,
25
+ collectionTimezone,
26
+ { scope: RecurrenceScope.All, recurrenceId: "", followingUid: "" },
27
+ patch,
28
+ );
29
+ if (!edited.ok) return edited;
30
+ if (edited.value.kind !== "Replace") {
31
+ throw new Error(
32
+ `an edit to the whole invitation resolved to a ${edited.value.kind}`,
33
+ );
34
+ }
35
+
36
+ return {
37
+ ok: true,
38
+ value: { ...suggestion, icalData: edited.value.icalData },
39
+ };
40
+ };
package/src/errors.ts CHANGED
@@ -36,6 +36,7 @@ export type CalendarValidationCode =
36
36
  /** A RECURRENCE-ID naming no occurrence this series produces. */
37
37
  | "UnknownOccurrence"
38
38
  | "UnmovableRecurrenceRule"
39
+ | "UneditableCancellation"
39
40
  /** A subscription address that is not an http, https or webcal URL. */
40
41
  | "InvalidSubscriptionUrl";
41
42
 
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export {
14
14
  readEventTime,
15
15
  readRecurrenceRule,
16
16
  } from "./build.js";
17
+ export { correctCalendarSuggestion } from "./correct.js";
17
18
  export type {
18
19
  CalendarResult,
19
20
  CalendarValidationCode,
@@ -236,6 +236,7 @@ export class MemoryCalendarStore implements ICalendarUnitOfWork {
236
236
  suggestionId,
237
237
  state: existing?.state ?? CalendarSuggestionState.Pending,
238
238
  acceptedCalendarObjectId: existing?.acceptedCalendarObjectId ?? "",
239
+ supersededByMessageId: existing?.supersededByMessageId ?? "",
239
240
  createdAt: existing?.createdAt ?? now,
240
241
  updatedAt: now,
241
242
  };
@@ -282,6 +283,7 @@ export class MemoryCalendarStore implements ICalendarUnitOfWork {
282
283
  supersedeIfPending: async (
283
284
  accountConfigId: string,
284
285
  suggestionId: string,
286
+ supersededByMessageId: string,
285
287
  ) => {
286
288
  const suggestion = this.suggestions.get(suggestionId);
287
289
  if (
@@ -295,11 +297,33 @@ export class MemoryCalendarStore implements ICalendarUnitOfWork {
295
297
  ...suggestion,
296
298
  state: CalendarSuggestionState.Superseded,
297
299
  acceptedCalendarObjectId: "",
300
+ supersededByMessageId,
298
301
  updatedAt: Date.now(),
299
302
  };
300
303
  this.suggestions.set(suggestionId, retired);
301
304
  return retired;
302
305
  },
306
+ repointSuperseded: async (
307
+ accountConfigId: string,
308
+ suggestionId: string,
309
+ supersededByMessageId: string,
310
+ ) => {
311
+ const suggestion = this.suggestions.get(suggestionId);
312
+ if (
313
+ !suggestion ||
314
+ suggestion.accountConfigId !== accountConfigId ||
315
+ suggestion.state !== CalendarSuggestionState.Superseded
316
+ ) {
317
+ return null;
318
+ }
319
+ const repointed = {
320
+ ...suggestion,
321
+ supersededByMessageId,
322
+ updatedAt: Date.now(),
323
+ };
324
+ this.suggestions.set(suggestionId, repointed);
325
+ return repointed;
326
+ },
303
327
  };
304
328
 
305
329
  private readonly feedTokenRepo: ICalendarFeedTokenRepository = {
@@ -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
  };