@remit/calendar-service 0.0.2 → 0.0.4

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.2",
3
+ "version": "0.0.4",
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",
@@ -9,11 +9,15 @@
9
9
  ".": {
10
10
  "types": "./src/index.ts",
11
11
  "default": "./src/index.ts"
12
+ },
13
+ "./memory-store": {
14
+ "types": "./src/memory-store.ts",
15
+ "default": "./src/memory-store.ts"
12
16
  }
13
17
  },
14
18
  "scripts": {
15
19
  "test:typecheck": "tsgo --noEmit",
16
- "test:run": "node $NODE_TEST_FLAGS --import tsx --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-lines=90 --test 'src/**/*.test.ts'"
20
+ "test:run": "node $NODE_TEST_FLAGS --import tsx --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/memory-store.ts' --test-coverage-lines=90 --test 'src/**/*.test.ts'"
17
21
  },
18
22
  "dependencies": {
19
23
  "@remit/data-ports": "*",
@@ -0,0 +1,357 @@
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
+ CalendarSuggestionSource,
6
+ CalendarSuggestionState,
7
+ } from "@remit/domain-enums";
8
+ import { acceptCalendarSuggestion, buildAcceptedCalendar } from "./accept.js";
9
+ import { ical } from "./fixtures.js";
10
+ import { MemoryCalendarStore } from "./memory-store.js";
11
+ import { provisionDefaultCalendar } from "./put.js";
12
+ import { recordCalendarSuggestion } from "./suggest.js";
13
+
14
+ const ACCOUNT_CONFIG_ID = "account-config-1";
15
+ const ATTENDEE = "user@example.test";
16
+ const UID = "invite-4711@example.test";
17
+
18
+ const invitation = ({
19
+ method = "REQUEST",
20
+ sequence = 0,
21
+ attendees = ["ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:user@example.test"],
22
+ extra = [] as string[],
23
+ }): string =>
24
+ ical(
25
+ "BEGIN:VCALENDAR",
26
+ "VERSION:2.0",
27
+ "PRODID:-//Example Corp//Scheduler//EN",
28
+ `METHOD:${method}`,
29
+ "BEGIN:VEVENT",
30
+ `UID:${UID}`,
31
+ "DTSTAMP:20260801T090000Z",
32
+ `SEQUENCE:${sequence}`,
33
+ "DTSTART:20260901T080000Z",
34
+ "DTEND:20260901T090000Z",
35
+ "SUMMARY:Quarterly review",
36
+ "ORGANIZER:mailto:organizer@example.test",
37
+ ...attendees,
38
+ "X-EXAMPLE-TICKET:AB-4711",
39
+ ...extra,
40
+ "END:VEVENT",
41
+ "END:VCALENDAR",
42
+ );
43
+
44
+ const suggestionOf = (
45
+ icalData: string,
46
+ overrides: Partial<CalendarSuggestionItem> = {},
47
+ ): CalendarSuggestionItem => ({
48
+ suggestionId: "suggestion-1",
49
+ accountConfigId: ACCOUNT_CONFIG_ID,
50
+ messageId: "message-1",
51
+ bodyPartId: "body-part-1",
52
+ icalUid: UID,
53
+ sequence: 0,
54
+ method: "Request",
55
+ source: CalendarSuggestionSource.IcalendarPart,
56
+ state: CalendarSuggestionState.Pending,
57
+ summary: "Quarterly review",
58
+ dtStart: "2026-09-01T08:00:00+00:00",
59
+ dtEnd: "2026-09-01T09:00:00+00:00",
60
+ allDay: false,
61
+ location: "",
62
+ organizer: "organizer@example.test",
63
+ zoneCertainty: "Explicit",
64
+ icalData,
65
+ acceptedCalendarObjectId: "",
66
+ createdAt: 0,
67
+ updatedAt: 0,
68
+ ...overrides,
69
+ });
70
+
71
+ const provisioned = async (): Promise<{
72
+ store: MemoryCalendarStore;
73
+ calendarId: string;
74
+ }> => {
75
+ const store = new MemoryCalendarStore();
76
+ const collection = await provisionDefaultCalendar(store, ACCOUNT_CONFIG_ID);
77
+ return { store, calendarId: collection.calendarId };
78
+ };
79
+
80
+ const recorded = async (
81
+ store: MemoryCalendarStore,
82
+ icalData: string,
83
+ messageId: string,
84
+ ): Promise<CalendarSuggestionItem> => {
85
+ const result = await recordCalendarSuggestion(store.calendarSuggestion, {
86
+ accountConfigId: ACCOUNT_CONFIG_ID,
87
+ messageId,
88
+ bodyPartId: "body-part-1",
89
+ source: CalendarSuggestionSource.IcalendarPart,
90
+ icalData,
91
+ timezone: "UTC",
92
+ });
93
+ assert.ok(result.ok);
94
+ return result.value.suggestion;
95
+ };
96
+
97
+ describe("buildAcceptedCalendar", () => {
98
+ it("keeps the UID and the SEQUENCE the organizer sent", async () => {
99
+ const built = await buildAcceptedCalendar(
100
+ suggestionOf(invitation({ sequence: 3 })),
101
+ ATTENDEE,
102
+ );
103
+
104
+ assert.ok(built.ok);
105
+ assert.match(built.value, /UID:invite-4711@example\.test/);
106
+ assert.match(built.value, /SEQUENCE:3/);
107
+ });
108
+
109
+ it("marks the user accepted on the line already naming them", async () => {
110
+ const built = await buildAcceptedCalendar(
111
+ suggestionOf(invitation({})),
112
+ ATTENDEE,
113
+ );
114
+
115
+ assert.ok(built.ok);
116
+ assert.match(built.value, /PARTSTAT=ACCEPTED/);
117
+ assert.doesNotMatch(built.value, /NEEDS-ACTION/);
118
+ assert.equal(built.value.match(/ATTENDEE/g)?.length, 1);
119
+ });
120
+
121
+ it("adds the user as an attendee when the invitation never named them", async () => {
122
+ const built = await buildAcceptedCalendar(
123
+ suggestionOf(invitation({ attendees: [] })),
124
+ ATTENDEE,
125
+ );
126
+
127
+ assert.ok(built.ok);
128
+ assert.match(built.value, /ATTENDEE/);
129
+ assert.match(built.value, /PARTSTAT=ACCEPTED/);
130
+ assert.match(built.value, /mailto:user@example\.test/);
131
+ });
132
+
133
+ it("drops the METHOD, which a stored resource must not carry", async () => {
134
+ // RFC 4791 4.1: a calendar object resource is not a scheduling message.
135
+ // Leaving METHOD in makes every client read the user's own entry as an
136
+ // unanswered invitation.
137
+ const built = await buildAcceptedCalendar(
138
+ suggestionOf(invitation({})),
139
+ ATTENDEE,
140
+ );
141
+
142
+ assert.ok(built.ok);
143
+ assert.doesNotMatch(built.value, /^METHOD:/m);
144
+ });
145
+
146
+ it("carries through a property nobody modelled", async () => {
147
+ const built = await buildAcceptedCalendar(
148
+ suggestionOf(invitation({})),
149
+ ATTENDEE,
150
+ );
151
+
152
+ assert.ok(built.ok);
153
+ assert.match(built.value, /X-EXAMPLE-TICKET:AB-4711/);
154
+ });
155
+
156
+ it("cancels the event when the card is a cancellation", async () => {
157
+ const built = await buildAcceptedCalendar(
158
+ suggestionOf(invitation({ method: "CANCEL" }), { method: "Cancel" }),
159
+ ATTENDEE,
160
+ );
161
+
162
+ assert.ok(built.ok);
163
+ assert.match(built.value, /STATUS:CANCELLED/);
164
+ });
165
+
166
+ it("refuses a suggestion with no iCalendar behind it", async () => {
167
+ const built = await buildAcceptedCalendar(
168
+ suggestionOf("", { source: CalendarSuggestionSource.TextHeuristic }),
169
+ ATTENDEE,
170
+ );
171
+
172
+ assert.equal(built.ok, false);
173
+ });
174
+ });
175
+
176
+ describe("acceptCalendarSuggestion", () => {
177
+ it("writes the resource and settles the card in one unit", async () => {
178
+ const { store, calendarId } = await provisioned();
179
+ const suggestion = await recorded(store, invitation({}), "message-1");
180
+
181
+ const accepted = await acceptCalendarSuggestion(store, {
182
+ accountConfigId: ACCOUNT_CONFIG_ID,
183
+ calendarId,
184
+ suggestion,
185
+ attendee: ATTENDEE,
186
+ });
187
+
188
+ assert.ok(accepted.ok);
189
+ assert.equal(
190
+ accepted.value.suggestion.state,
191
+ CalendarSuggestionState.Accepted,
192
+ );
193
+ assert.equal(
194
+ accepted.value.suggestion.acceptedCalendarObjectId,
195
+ accepted.value.object?.calendarObjectId,
196
+ );
197
+ assert.equal(accepted.value.object?.icalUid, UID);
198
+ assert.equal(store.objects.size, 1);
199
+ });
200
+
201
+ it("expands the accepted event into the occurrence index", async () => {
202
+ const { store, calendarId } = await provisioned();
203
+ const suggestion = await recorded(store, invitation({}), "message-1");
204
+
205
+ const accepted = await acceptCalendarSuggestion(store, {
206
+ accountConfigId: ACCOUNT_CONFIG_ID,
207
+ calendarId,
208
+ suggestion,
209
+ attendee: ATTENDEE,
210
+ });
211
+
212
+ assert.ok(accepted.ok);
213
+ assert.equal(
214
+ store.occurrences.get(accepted.value.object?.calendarObjectId ?? "")
215
+ ?.length,
216
+ 1,
217
+ );
218
+ });
219
+
220
+ it("accepting twice leaves one event in the calendar", async () => {
221
+ const { store, calendarId } = await provisioned();
222
+ const suggestion = await recorded(store, invitation({}), "message-1");
223
+
224
+ const first = await acceptCalendarSuggestion(store, {
225
+ accountConfigId: ACCOUNT_CONFIG_ID,
226
+ calendarId,
227
+ suggestion,
228
+ attendee: ATTENDEE,
229
+ });
230
+ const second = await acceptCalendarSuggestion(store, {
231
+ accountConfigId: ACCOUNT_CONFIG_ID,
232
+ calendarId,
233
+ suggestion,
234
+ attendee: ATTENDEE,
235
+ });
236
+
237
+ assert.ok(first.ok);
238
+ assert.ok(second.ok);
239
+ assert.equal(store.objects.size, 1);
240
+ assert.equal(
241
+ second.value.object?.calendarObjectId,
242
+ first.value.object?.calendarObjectId,
243
+ );
244
+ });
245
+
246
+ it("accepting a later revision rewrites the event, never a second copy", async () => {
247
+ const { store, calendarId } = await provisioned();
248
+ const first = await recorded(
249
+ store,
250
+ invitation({ sequence: 0 }),
251
+ "message-1",
252
+ );
253
+ await acceptCalendarSuggestion(store, {
254
+ accountConfigId: ACCOUNT_CONFIG_ID,
255
+ calendarId,
256
+ suggestion: first,
257
+ attendee: ATTENDEE,
258
+ });
259
+
260
+ const revision = await recorded(
261
+ store,
262
+ invitation({ sequence: 1 }),
263
+ "message-2",
264
+ );
265
+ const accepted = await acceptCalendarSuggestion(store, {
266
+ accountConfigId: ACCOUNT_CONFIG_ID,
267
+ calendarId,
268
+ suggestion: revision,
269
+ attendee: ATTENDEE,
270
+ });
271
+
272
+ assert.ok(accepted.ok);
273
+ assert.equal(store.objects.size, 1);
274
+ assert.equal(accepted.value.object?.sequence, 1);
275
+ });
276
+
277
+ it("never invents an event just to mark it cancelled", async () => {
278
+ // The user left the invitation Pending, so nothing is in their calendar.
279
+ // Accepting the cancellation must clear the card and write nothing —
280
+ // writing a resource here would put a meeting they never had into their
281
+ // calendar for the sole purpose of saying it was called off.
282
+ const { store, calendarId } = await provisioned();
283
+ const request = await recorded(store, invitation({}), "message-1");
284
+ const cancel = await recorded(
285
+ store,
286
+ invitation({ method: "CANCEL", sequence: 1 }),
287
+ "message-2",
288
+ );
289
+
290
+ const accepted = await acceptCalendarSuggestion(store, {
291
+ accountConfigId: ACCOUNT_CONFIG_ID,
292
+ calendarId,
293
+ suggestion: cancel,
294
+ attendee: ATTENDEE,
295
+ });
296
+
297
+ assert.ok(accepted.ok);
298
+ assert.equal(accepted.value.outcome, "NothingToCancel");
299
+ assert.equal(accepted.value.object, null);
300
+ assert.equal(store.objects.size, 0);
301
+ assert.equal(
302
+ accepted.value.suggestion.state,
303
+ CalendarSuggestionState.Dismissed,
304
+ );
305
+ assert.equal(accepted.value.suggestion.acceptedCalendarObjectId, "");
306
+ // The request it superseded is left exactly as the producer left it.
307
+ const superseded = await store.calendarSuggestion.get(
308
+ ACCOUNT_CONFIG_ID,
309
+ request.suggestionId,
310
+ );
311
+ assert.equal(superseded.state, CalendarSuggestionState.Superseded);
312
+ });
313
+
314
+ it("a cancellation touches the calendar only once it is accepted", async () => {
315
+ const { store, calendarId } = await provisioned();
316
+ const request = await recorded(store, invitation({}), "message-1");
317
+ await acceptCalendarSuggestion(store, {
318
+ accountConfigId: ACCOUNT_CONFIG_ID,
319
+ calendarId,
320
+ suggestion: request,
321
+ attendee: ATTENDEE,
322
+ });
323
+
324
+ const cancel = await recorded(
325
+ store,
326
+ invitation({ method: "CANCEL", sequence: 1 }),
327
+ "message-2",
328
+ );
329
+ const before = [...store.objects.values()][0];
330
+ assert.equal(before?.status, "Confirmed");
331
+
332
+ const accepted = await acceptCalendarSuggestion(store, {
333
+ accountConfigId: ACCOUNT_CONFIG_ID,
334
+ calendarId,
335
+ suggestion: cancel,
336
+ attendee: ATTENDEE,
337
+ });
338
+
339
+ assert.ok(accepted.ok);
340
+ assert.equal(accepted.value.object?.status, "Cancelled");
341
+ assert.equal(store.objects.size, 1);
342
+ });
343
+
344
+ it("writes nothing when the invitation's bytes will not parse", async () => {
345
+ const { store, calendarId } = await provisioned();
346
+
347
+ const accepted = await acceptCalendarSuggestion(store, {
348
+ accountConfigId: ACCOUNT_CONFIG_ID,
349
+ calendarId,
350
+ suggestion: suggestionOf("BEGIN:VCALENDAR"),
351
+ attendee: ATTENDEE,
352
+ });
353
+
354
+ assert.equal(accepted.ok, false);
355
+ assert.equal(store.objects.size, 0);
356
+ });
357
+ });
package/src/accept.ts ADDED
@@ -0,0 +1,207 @@
1
+ import type {
2
+ CalendarObjectItem,
3
+ CalendarSuggestionItem,
4
+ ICalendarUnitOfWork,
5
+ } from "@remit/data-ports";
6
+ import {
7
+ CalendarInviteMethod,
8
+ CalendarSuggestionState,
9
+ } from "@remit/domain-enums";
10
+ import ICAL from "ical.js";
11
+ import type { CalendarResult } from "./errors.js";
12
+ import { parseCalendar, serializeCalendar } from "./parse.js";
13
+ import { putCalendarObject } from "./put.js";
14
+ import { mailAddressOf } from "./suggest.js";
15
+
16
+ const eventsOf = (component: ICAL.Component): ICAL.Component[] =>
17
+ component.getAllSubcomponents("vevent");
18
+
19
+ /**
20
+ * Marks one address as having accepted, on every VEVENT of the resource — the
21
+ * master and each override alike, since an attendee's answer is to the series.
22
+ *
23
+ * An ATTENDEE line already naming the user has its PARTSTAT rewritten rather
24
+ * than a second one appended: two ATTENDEE lines for one person is a resource
25
+ * every other client reads as two people.
26
+ */
27
+ const markAccepted = (component: ICAL.Component, attendee: string): void => {
28
+ const wanted = attendee.toLowerCase();
29
+ for (const event of eventsOf(component)) {
30
+ const existing = event
31
+ .getAllProperties("attendee")
32
+ .find(
33
+ (property) =>
34
+ mailAddressOf(String(property.getFirstValue())).toLowerCase() ===
35
+ wanted,
36
+ );
37
+ const property =
38
+ existing ?? new ICAL.Property("attendee", event as ICAL.Component);
39
+ property.setParameter("partstat", "ACCEPTED");
40
+ if (!existing) {
41
+ property.setValue(`mailto:${attendee}`);
42
+ event.addProperty(property);
43
+ }
44
+ }
45
+ };
46
+
47
+ const markCancelled = (component: ICAL.Component): void => {
48
+ for (const event of eventsOf(component)) {
49
+ event.removeAllProperties("status");
50
+ event.addPropertyWithValue("status", "CANCELLED");
51
+ }
52
+ };
53
+
54
+ /**
55
+ * The VCALENDAR a stored resource is made of, built from the invitation's own
56
+ * bytes.
57
+ *
58
+ * Built by editing what arrived rather than by composing a fresh event: the
59
+ * UID, the SEQUENCE, the recurrence rule, the overrides and every X- property
60
+ * the organizer's client wrote survive into the calendar, which is what makes
61
+ * the stored resource answer to the same event a later revision or a
62
+ * cancellation names.
63
+ *
64
+ * The METHOD goes. A scheduling message carries one (RFC 5546); a stored
65
+ * calendar object resource must not (RFC 4791 4.1), and leaving it in makes
66
+ * every CalDAV client treat the user's own calendar entry as an unanswered
67
+ * invitation.
68
+ */
69
+ export const buildAcceptedCalendar = async (
70
+ suggestion: Pick<CalendarSuggestionItem, "icalData" | "method">,
71
+ attendee: string,
72
+ ): Promise<CalendarResult<string>> => {
73
+ const parsed = await parseCalendar(suggestion.icalData);
74
+ if (!parsed.ok) return parsed;
75
+
76
+ const { component } = parsed.value;
77
+ component.removeAllProperties("method");
78
+ markAccepted(component, attendee);
79
+ if (suggestion.method === CalendarInviteMethod.Cancel) {
80
+ markCancelled(component);
81
+ }
82
+
83
+ return { ok: true, value: serializeCalendar(component) };
84
+ };
85
+
86
+ export interface AcceptCalendarSuggestionInput {
87
+ accountConfigId: string;
88
+ calendarId: string;
89
+ suggestion: CalendarSuggestionItem;
90
+ /** Mail address of the person accepting — the account the message arrived on. */
91
+ attendee: string;
92
+ }
93
+
94
+ /**
95
+ * What accepting did.
96
+ *
97
+ * `Written` is the ordinary case: a resource is in the calendar and `object`
98
+ * names it. `NothingToCancel` is a cancellation for a meeting this calendar
99
+ * never held — the user never accepted the invitation, or accepted it
100
+ * somewhere else — where the only honest act is to clear the card. Writing a
101
+ * resource there would put an event in the calendar that exists solely to say
102
+ * it was cancelled, which is worse than the meeting the user never had.
103
+ */
104
+ export type AcceptOutcome = "Written" | "NothingToCancel";
105
+
106
+ export interface AcceptedCalendarSuggestion {
107
+ suggestion: CalendarSuggestionItem;
108
+ outcome: AcceptOutcome;
109
+ /** The resource that was written, `null` for `NothingToCancel`. */
110
+ object: CalendarObjectItem | null;
111
+ }
112
+
113
+ /**
114
+ * Adds a suggested event to a calendar, as one unit.
115
+ *
116
+ * The resource is written through `putCalendarObject`, the same function every
117
+ * other calendar write goes through, so an event added from a mail and one
118
+ * added from the week grid are the same kind of row — and a native client
119
+ * editing it afterwards edits the same bytes. The suggestion's own state is
120
+ * settled inside that transaction: a suggestion marked `Accepted` with no
121
+ * resource behind it, or a resource with the card still asking, is a state
122
+ * nothing later can repair.
123
+ *
124
+ * Accepting a `Cancel` suggestion writes `STATUS:CANCELLED` through that same
125
+ * path, but only onto a resource that is already there. Nothing withdraws an
126
+ * event on its own — a cancellation reaches the calendar only because a person
127
+ * pressed the button, and only when the calendar holds the meeting being
128
+ * withdrawn.
129
+ *
130
+ * No mail is sent. There is no iMIP reply here and none anywhere on this path;
131
+ * the organizer learns nothing from the user accepting.
132
+ *
133
+ * Idempotent. The resource is addressed by the event's UID within the
134
+ * collection, so accepting the same suggestion twice — or accepting a later
135
+ * revision of an event already added — rewrites the one resource rather than
136
+ * leaving the calendar showing the meeting twice.
137
+ */
138
+ export const acceptCalendarSuggestion = async (
139
+ unitOfWork: ICalendarUnitOfWork,
140
+ input: AcceptCalendarSuggestionInput,
141
+ ): Promise<CalendarResult<AcceptedCalendarSuggestion>> => {
142
+ const icalData = await buildAcceptedCalendar(
143
+ input.suggestion,
144
+ input.attendee,
145
+ );
146
+ if (!icalData.ok) return icalData;
147
+
148
+ return unitOfWork.transaction(async (repos) => {
149
+ const existing = await repos.calendarObject.findByUid(
150
+ input.calendarId,
151
+ input.suggestion.icalUid,
152
+ );
153
+
154
+ // A cancellation cancels something. With no resource carrying this UID
155
+ // there is nothing in the calendar to withdraw, and writing one would
156
+ // invent a meeting the user never had purely to mark it cancelled. Clear
157
+ // the card and leave the calendar untouched.
158
+ if (
159
+ input.suggestion.method === CalendarInviteMethod.Cancel &&
160
+ existing === null
161
+ ) {
162
+ const cleared = await repos.calendarSuggestion.settle(
163
+ input.accountConfigId,
164
+ input.suggestion.suggestionId,
165
+ {
166
+ state: CalendarSuggestionState.Dismissed,
167
+ acceptedCalendarObjectId: "",
168
+ },
169
+ );
170
+ return {
171
+ ok: true,
172
+ value: {
173
+ suggestion: cleared,
174
+ outcome: "NothingToCancel",
175
+ object: null,
176
+ },
177
+ };
178
+ }
179
+
180
+ const written = await putCalendarObject(unitOfWork, {
181
+ accountConfigId: input.accountConfigId,
182
+ calendarId: input.calendarId,
183
+ resourceName:
184
+ existing?.resourceName ?? `${input.suggestion.suggestionId}.ics`,
185
+ icalData: icalData.value,
186
+ });
187
+ if (!written.ok) return written;
188
+
189
+ const settled = await repos.calendarSuggestion.settle(
190
+ input.accountConfigId,
191
+ input.suggestion.suggestionId,
192
+ {
193
+ state: CalendarSuggestionState.Accepted,
194
+ acceptedCalendarObjectId: written.value.calendarObjectId,
195
+ },
196
+ );
197
+
198
+ return {
199
+ ok: true,
200
+ value: {
201
+ suggestion: settled,
202
+ outcome: "Written",
203
+ object: written.value,
204
+ },
205
+ };
206
+ });
207
+ };