@remit/calendar-service 0.0.1 → 0.0.3
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 +2 -2
- package/src/accept.test.ts +357 -0
- package/src/accept.ts +207 -0
- package/src/build.test.ts +113 -0
- package/src/build.ts +311 -0
- package/src/errors.ts +13 -1
- package/src/expand.test.ts +10 -0
- package/src/expand.ts +221 -111
- package/src/index.ts +53 -1
- package/src/memory-store.ts +303 -0
- package/src/parse.test.ts +11 -0
- package/src/parse.ts +7 -1
- package/src/project.ts +26 -8
- package/src/put.test.ts +1 -193
- package/src/scope.test.ts +472 -0
- package/src/scope.ts +490 -0
- package/src/suggest.test.ts +329 -0
- package/src/suggest.ts +199 -0
- package/src/time.ts +66 -0
- package/src/window.test.ts +524 -0
- package/src/window.ts +232 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/calendar-service",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
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",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"scripts": {
|
|
15
15
|
"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'"
|
|
16
|
+
"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
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"@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
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { CalendarEventStatus, CalendarTransparency } from "@remit/domain-enums";
|
|
4
|
+
import { buildEventCalendar, type CalendarEventFields } from "./build.js";
|
|
5
|
+
import { expandCalendar } from "./expand.js";
|
|
6
|
+
import { parseCalendar } from "./parse.js";
|
|
7
|
+
|
|
8
|
+
const fields = (
|
|
9
|
+
overrides: Partial<CalendarEventFields> = {},
|
|
10
|
+
): CalendarEventFields => ({
|
|
11
|
+
summary: "Stand-up",
|
|
12
|
+
description: "",
|
|
13
|
+
location: "",
|
|
14
|
+
start: "2026-10-15T09:00:00+02:00",
|
|
15
|
+
end: "2026-10-15T10:00:00+02:00",
|
|
16
|
+
allDay: false,
|
|
17
|
+
timeZone: "",
|
|
18
|
+
status: CalendarEventStatus.Confirmed,
|
|
19
|
+
transparency: CalendarTransparency.Opaque,
|
|
20
|
+
recurrenceRule: "",
|
|
21
|
+
...overrides,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const build = async (overrides: Partial<CalendarEventFields> = {}) =>
|
|
25
|
+
buildEventCalendar(
|
|
26
|
+
fields(overrides),
|
|
27
|
+
"new@reader.remit",
|
|
28
|
+
new Date("2026-08-29T00:00:00Z"),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
describe("buildEventCalendar", () => {
|
|
32
|
+
it("writes an event with no zone as the instant it names", async () => {
|
|
33
|
+
const built = await build();
|
|
34
|
+
|
|
35
|
+
assert.ok(built.ok, JSON.stringify(built));
|
|
36
|
+
assert.ok(built.value.includes("DTSTART:20261015T070000Z"));
|
|
37
|
+
assert.ok(built.value.includes("DTEND:20261015T080000Z"));
|
|
38
|
+
assert.ok(built.value.includes("UID:new@reader.remit"));
|
|
39
|
+
assert.ok(built.value.includes("SUMMARY:Stand-up"));
|
|
40
|
+
assert.equal(
|
|
41
|
+
built.value.includes("DESCRIPTION"),
|
|
42
|
+
false,
|
|
43
|
+
"an empty field is left out rather than written blank",
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("anchors an event in the zone it names, not in the offset it arrived with", async () => {
|
|
48
|
+
const built = await build({ timeZone: "Europe/Amsterdam" });
|
|
49
|
+
|
|
50
|
+
assert.ok(built.ok);
|
|
51
|
+
assert.ok(
|
|
52
|
+
built.value.includes("DTSTART;TZID=Europe/Amsterdam:20261015T090000"),
|
|
53
|
+
);
|
|
54
|
+
assert.ok(
|
|
55
|
+
built.value.includes("DTEND;TZID=Europe/Amsterdam:20261015T100000"),
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("keeps a series at its local hour across a DST transition", async () => {
|
|
60
|
+
const built = await build({
|
|
61
|
+
timeZone: "Europe/Amsterdam",
|
|
62
|
+
recurrenceRule: "FREQ=WEEKLY;COUNT=4",
|
|
63
|
+
});
|
|
64
|
+
assert.ok(built.ok);
|
|
65
|
+
|
|
66
|
+
const parsed = await parseCalendar(built.value);
|
|
67
|
+
assert.ok(parsed.ok);
|
|
68
|
+
const expansion = expandCalendar(parsed.value, "Europe/Amsterdam");
|
|
69
|
+
|
|
70
|
+
assert.deepEqual(
|
|
71
|
+
expansion.occurrences.map((occurrence) => occurrence.startAt),
|
|
72
|
+
[
|
|
73
|
+
"2026-10-15T07:00:00Z",
|
|
74
|
+
"2026-10-22T07:00:00Z",
|
|
75
|
+
"2026-10-29T08:00:00Z",
|
|
76
|
+
"2026-11-05T08:00:00Z",
|
|
77
|
+
],
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("writes an all-day event as civil dates with an exclusive end", async () => {
|
|
82
|
+
const built = await build({
|
|
83
|
+
allDay: true,
|
|
84
|
+
start: "2026-10-15",
|
|
85
|
+
end: "2026-10-17",
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
assert.ok(built.ok);
|
|
89
|
+
assert.ok(built.value.includes("DTSTART;VALUE=DATE:20261015"));
|
|
90
|
+
assert.ok(built.value.includes("DTEND;VALUE=DATE:20261017"));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("refuses a date-time with no zone offset", async () => {
|
|
94
|
+
const built = await build({ start: "2026-10-15T09:00:00" });
|
|
95
|
+
|
|
96
|
+
assert.ok(!built.ok);
|
|
97
|
+
assert.equal(built.error.code, "InvalidDateTime");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("refuses a zone this server cannot resolve", async () => {
|
|
101
|
+
const built = await build({ timeZone: "W. Europe Standard Time" });
|
|
102
|
+
|
|
103
|
+
assert.ok(!built.ok);
|
|
104
|
+
assert.equal(built.error.code, "UnknownTimeZone");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("refuses a recurrence rule it cannot read", async () => {
|
|
108
|
+
const built = await build({ recurrenceRule: "EVERY OTHER TUESDAY" });
|
|
109
|
+
|
|
110
|
+
assert.ok(!built.ok);
|
|
111
|
+
assert.equal(built.error.code, "InvalidRecurrenceRule");
|
|
112
|
+
});
|
|
113
|
+
});
|