@remit/calendar-service 0.0.18 → 0.0.19

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