@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 +6 -2
- package/src/accept.test.ts +357 -0
- package/src/accept.ts +207 -0
- package/src/feed.test.ts +286 -0
- package/src/feed.ts +182 -0
- package/src/index.ts +31 -0
- package/src/memory-store.ts +350 -0
- package/src/parse.test.ts +11 -0
- package/src/parse.ts +7 -1
- package/src/put.test.ts +1 -210
- package/src/suggest.test.ts +329 -0
- package/src/suggest.ts +199 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { CalendarSuggestionItem } from "@remit/data-ports";
|
|
4
|
+
import { deriveCalendarSuggestionId } from "@remit/data-ports/id";
|
|
5
|
+
import {
|
|
6
|
+
CalendarInviteMethod,
|
|
7
|
+
CalendarSuggestionSource,
|
|
8
|
+
CalendarSuggestionState,
|
|
9
|
+
} from "@remit/domain-enums";
|
|
10
|
+
import { AMSTERDAM_VTIMEZONE, ical } from "./fixtures.js";
|
|
11
|
+
import { MemoryCalendarStore } from "./memory-store.js";
|
|
12
|
+
import { projectSuggestion, recordCalendarSuggestion } from "./suggest.js";
|
|
13
|
+
|
|
14
|
+
const ACCOUNT_CONFIG_ID = "account-config-1";
|
|
15
|
+
const UID = "invite-4711@example.test";
|
|
16
|
+
|
|
17
|
+
const invitation = ({
|
|
18
|
+
method = "REQUEST",
|
|
19
|
+
sequence = 0,
|
|
20
|
+
summary = "Quarterly review",
|
|
21
|
+
uid = UID,
|
|
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
|
+
...AMSTERDAM_VTIMEZONE,
|
|
30
|
+
"BEGIN:VEVENT",
|
|
31
|
+
`UID:${uid}`,
|
|
32
|
+
"DTSTAMP:20260801T090000Z",
|
|
33
|
+
`SEQUENCE:${sequence}`,
|
|
34
|
+
"DTSTART;TZID=Europe/Amsterdam:20260901T100000",
|
|
35
|
+
"DTEND;TZID=Europe/Amsterdam:20260901T110000",
|
|
36
|
+
`SUMMARY:${summary}`,
|
|
37
|
+
"LOCATION:Room 4",
|
|
38
|
+
"ORGANIZER;CN=Ada:mailto:organizer@example.test",
|
|
39
|
+
"ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:user@example.test",
|
|
40
|
+
...extra,
|
|
41
|
+
"END:VEVENT",
|
|
42
|
+
"END:VCALENDAR",
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const record = (
|
|
46
|
+
store: MemoryCalendarStore,
|
|
47
|
+
icalData: string,
|
|
48
|
+
messageId: string,
|
|
49
|
+
) =>
|
|
50
|
+
recordCalendarSuggestion(store.calendarSuggestion, {
|
|
51
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
52
|
+
messageId,
|
|
53
|
+
bodyPartId: "body-part-1",
|
|
54
|
+
source: CalendarSuggestionSource.IcalendarPart,
|
|
55
|
+
icalData,
|
|
56
|
+
timezone: "Europe/Amsterdam",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("projectSuggestion", () => {
|
|
60
|
+
it("reads the facts a card is drawn from", async () => {
|
|
61
|
+
const projected = await projectSuggestion(
|
|
62
|
+
invitation({}),
|
|
63
|
+
"Europe/Amsterdam",
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
assert.ok(projected.ok);
|
|
67
|
+
assert.equal(projected.value.icalUid, UID);
|
|
68
|
+
assert.equal(projected.value.method, CalendarInviteMethod.Request);
|
|
69
|
+
assert.equal(projected.value.summary, "Quarterly review");
|
|
70
|
+
assert.equal(projected.value.location, "Room 4");
|
|
71
|
+
assert.equal(projected.value.organizer, "organizer@example.test");
|
|
72
|
+
assert.equal(projected.value.dtStart, "2026-09-01T10:00:00+02:00");
|
|
73
|
+
assert.equal(projected.value.dtEnd, "2026-09-01T11:00:00+02:00");
|
|
74
|
+
assert.equal(projected.value.allDay, false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("reads a cancellation as a cancellation", async () => {
|
|
78
|
+
const projected = await projectSuggestion(
|
|
79
|
+
invitation({ method: "CANCEL", sequence: 2 }),
|
|
80
|
+
"Europe/Amsterdam",
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
assert.ok(projected.ok);
|
|
84
|
+
assert.equal(projected.value.method, CalendarInviteMethod.Cancel);
|
|
85
|
+
assert.equal(projected.value.sequence, 2);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("reads a bare .ics as carrying no method", async () => {
|
|
89
|
+
const bare = ical(
|
|
90
|
+
"BEGIN:VCALENDAR",
|
|
91
|
+
"VERSION:2.0",
|
|
92
|
+
"PRODID:-//Example//EN",
|
|
93
|
+
"BEGIN:VEVENT",
|
|
94
|
+
`UID:${UID}`,
|
|
95
|
+
"DTSTAMP:20260801T090000Z",
|
|
96
|
+
"DTSTART:20260901T080000Z",
|
|
97
|
+
"DTEND:20260901T090000Z",
|
|
98
|
+
"SUMMARY:Lunch",
|
|
99
|
+
"END:VEVENT",
|
|
100
|
+
"END:VCALENDAR",
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const projected = await projectSuggestion(bare, "Europe/Amsterdam");
|
|
104
|
+
|
|
105
|
+
assert.ok(projected.ok);
|
|
106
|
+
assert.equal(projected.value.method, CalendarInviteMethod.None);
|
|
107
|
+
assert.equal(projected.value.organizer, "");
|
|
108
|
+
assert.equal(projected.value.location, "");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("refuses unreadable iCalendar as a value, not a throw", async () => {
|
|
112
|
+
const projected = await projectSuggestion("not a calendar", "UTC");
|
|
113
|
+
|
|
114
|
+
assert.equal(projected.ok, false);
|
|
115
|
+
assert.ok(!projected.ok && projected.error.code);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("recordCalendarSuggestion", () => {
|
|
120
|
+
it("writes a fixture invitation as one Pending suggestion", async () => {
|
|
121
|
+
const store = new MemoryCalendarStore();
|
|
122
|
+
|
|
123
|
+
const recorded = await record(store, invitation({}), "message-1");
|
|
124
|
+
|
|
125
|
+
assert.ok(recorded.ok);
|
|
126
|
+
assert.equal(
|
|
127
|
+
recorded.value.suggestion.state,
|
|
128
|
+
CalendarSuggestionState.Pending,
|
|
129
|
+
);
|
|
130
|
+
assert.equal(recorded.value.suggestion.icalUid, UID);
|
|
131
|
+
assert.equal(
|
|
132
|
+
recorded.value.suggestion.suggestionId,
|
|
133
|
+
deriveCalendarSuggestionId("message-1", "body-part-1", UID),
|
|
134
|
+
);
|
|
135
|
+
assert.equal(recorded.value.suggestion.acceptedCalendarObjectId, "");
|
|
136
|
+
assert.deepEqual(recorded.value.superseded, []);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("stores the invitation's bytes unchanged", async () => {
|
|
140
|
+
const store = new MemoryCalendarStore();
|
|
141
|
+
const bytes = invitation({});
|
|
142
|
+
|
|
143
|
+
const recorded = await record(store, bytes, "message-1");
|
|
144
|
+
|
|
145
|
+
assert.ok(recorded.ok);
|
|
146
|
+
assert.equal(recorded.value.suggestion.icalData, bytes);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("re-reading the same message rewrites the one row", async () => {
|
|
150
|
+
const store = new MemoryCalendarStore();
|
|
151
|
+
|
|
152
|
+
await record(store, invitation({}), "message-1");
|
|
153
|
+
await record(store, invitation({}), "message-1");
|
|
154
|
+
|
|
155
|
+
assert.equal(store.suggestions.size, 1);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("supersedes the earlier revision and offers the new one", async () => {
|
|
159
|
+
const store = new MemoryCalendarStore();
|
|
160
|
+
|
|
161
|
+
const first = await record(store, invitation({ sequence: 0 }), "message-1");
|
|
162
|
+
const second = await record(
|
|
163
|
+
store,
|
|
164
|
+
invitation({ sequence: 1, summary: "Quarterly review, moved" }),
|
|
165
|
+
"message-2",
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
assert.ok(first.ok);
|
|
169
|
+
assert.ok(second.ok);
|
|
170
|
+
assert.equal(
|
|
171
|
+
second.value.suggestion.state,
|
|
172
|
+
CalendarSuggestionState.Pending,
|
|
173
|
+
);
|
|
174
|
+
assert.deepEqual(
|
|
175
|
+
second.value.superseded.map((row) => row.suggestionId),
|
|
176
|
+
[first.value.suggestion.suggestionId],
|
|
177
|
+
);
|
|
178
|
+
const superseded = await store.calendarSuggestion.get(
|
|
179
|
+
ACCOUNT_CONFIG_ID,
|
|
180
|
+
first.value.suggestion.suggestionId,
|
|
181
|
+
);
|
|
182
|
+
assert.equal(superseded.state, CalendarSuggestionState.Superseded);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("leaves the earlier revision alone when the sequence did not move", async () => {
|
|
186
|
+
// SEQUENCE is the organizer's revision counter. A resend of the same
|
|
187
|
+
// revision — a redelivery, a second copy filed elsewhere — is not a new
|
|
188
|
+
// revision, and retiring the card the user is looking at would be a lie.
|
|
189
|
+
const store = new MemoryCalendarStore();
|
|
190
|
+
|
|
191
|
+
const first = await record(store, invitation({ sequence: 3 }), "message-1");
|
|
192
|
+
const second = await record(
|
|
193
|
+
store,
|
|
194
|
+
invitation({ sequence: 3 }),
|
|
195
|
+
"message-2",
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
assert.ok(first.ok);
|
|
199
|
+
assert.ok(second.ok);
|
|
200
|
+
assert.deepEqual(second.value.superseded, []);
|
|
201
|
+
const earlier = await store.calendarSuggestion.get(
|
|
202
|
+
ACCOUNT_CONFIG_ID,
|
|
203
|
+
first.value.suggestion.suggestionId,
|
|
204
|
+
);
|
|
205
|
+
assert.equal(earlier.state, CalendarSuggestionState.Pending);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("leaves another event's card alone", async () => {
|
|
209
|
+
const store = new MemoryCalendarStore();
|
|
210
|
+
|
|
211
|
+
const other = await record(
|
|
212
|
+
store,
|
|
213
|
+
invitation({ uid: "other@example.test" }),
|
|
214
|
+
"message-1",
|
|
215
|
+
);
|
|
216
|
+
const mine = await record(store, invitation({ sequence: 4 }), "message-2");
|
|
217
|
+
|
|
218
|
+
assert.ok(other.ok);
|
|
219
|
+
assert.ok(mine.ok);
|
|
220
|
+
assert.deepEqual(mine.value.superseded, []);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("never retires a suggestion the user already answered", async () => {
|
|
224
|
+
// A decision is the user's. A later revision arriving does not erase the
|
|
225
|
+
// event they added; a cancellation reaches the calendar only when they
|
|
226
|
+
// accept the cancellation card.
|
|
227
|
+
const store = new MemoryCalendarStore();
|
|
228
|
+
|
|
229
|
+
const first = await record(store, invitation({ sequence: 0 }), "message-1");
|
|
230
|
+
assert.ok(first.ok);
|
|
231
|
+
await store.calendarSuggestion.settle(
|
|
232
|
+
ACCOUNT_CONFIG_ID,
|
|
233
|
+
first.value.suggestion.suggestionId,
|
|
234
|
+
{
|
|
235
|
+
state: CalendarSuggestionState.Accepted,
|
|
236
|
+
acceptedCalendarObjectId: "cal-object-1",
|
|
237
|
+
},
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const second = await record(
|
|
241
|
+
store,
|
|
242
|
+
invitation({ sequence: 1 }),
|
|
243
|
+
"message-2",
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
assert.ok(second.ok);
|
|
247
|
+
assert.deepEqual(second.value.superseded, []);
|
|
248
|
+
const answered = await store.calendarSuggestion.get(
|
|
249
|
+
ACCOUNT_CONFIG_ID,
|
|
250
|
+
first.value.suggestion.suggestionId,
|
|
251
|
+
);
|
|
252
|
+
assert.equal(answered.state, CalendarSuggestionState.Accepted);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("keeps an acceptance that lands between the read and the retire", async () => {
|
|
256
|
+
// The producer reads the pending set, then retires what it found. A
|
|
257
|
+
// person pressing Add to calendar in that gap must keep their event: an
|
|
258
|
+
// unconditional write here would mark the card Superseded and blank the
|
|
259
|
+
// id of the resource it just put in their calendar, orphaning it.
|
|
260
|
+
const store = new MemoryCalendarStore();
|
|
261
|
+
const first = await record(store, invitation({ sequence: 0 }), "message-1");
|
|
262
|
+
assert.ok(first.ok);
|
|
263
|
+
const target = first.value.suggestion.suggestionId;
|
|
264
|
+
|
|
265
|
+
const racing = {
|
|
266
|
+
...store.calendarSuggestion,
|
|
267
|
+
listByState: async (
|
|
268
|
+
accountConfigId: string,
|
|
269
|
+
state: CalendarSuggestionItem["state"],
|
|
270
|
+
) => {
|
|
271
|
+
const page = await store.calendarSuggestion.listByState(
|
|
272
|
+
accountConfigId,
|
|
273
|
+
state,
|
|
274
|
+
);
|
|
275
|
+
// The user accepts, right here.
|
|
276
|
+
await store.calendarSuggestion.settle(ACCOUNT_CONFIG_ID, target, {
|
|
277
|
+
state: CalendarSuggestionState.Accepted,
|
|
278
|
+
acceptedCalendarObjectId: "cal-object-3",
|
|
279
|
+
});
|
|
280
|
+
return page;
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const second = await recordCalendarSuggestion(racing, {
|
|
285
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
286
|
+
messageId: "message-2",
|
|
287
|
+
bodyPartId: "body-part-1",
|
|
288
|
+
source: CalendarSuggestionSource.IcalendarPart,
|
|
289
|
+
icalData: invitation({ sequence: 1 }),
|
|
290
|
+
timezone: "Europe/Amsterdam",
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
assert.ok(second.ok);
|
|
294
|
+
assert.deepEqual(second.value.superseded, []);
|
|
295
|
+
const answered = await store.calendarSuggestion.get(
|
|
296
|
+
ACCOUNT_CONFIG_ID,
|
|
297
|
+
target,
|
|
298
|
+
);
|
|
299
|
+
assert.equal(answered.state, CalendarSuggestionState.Accepted);
|
|
300
|
+
assert.equal(answered.acceptedCalendarObjectId, "cal-object-3");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("records a cancellation as a card of its own, touching nothing", async () => {
|
|
304
|
+
const store = new MemoryCalendarStore();
|
|
305
|
+
|
|
306
|
+
const cancel = await record(
|
|
307
|
+
store,
|
|
308
|
+
invitation({ method: "CANCEL", sequence: 5 }),
|
|
309
|
+
"message-3",
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
assert.ok(cancel.ok);
|
|
313
|
+
assert.equal(cancel.value.suggestion.method, CalendarInviteMethod.Cancel);
|
|
314
|
+
assert.equal(
|
|
315
|
+
cancel.value.suggestion.state,
|
|
316
|
+
CalendarSuggestionState.Pending,
|
|
317
|
+
);
|
|
318
|
+
assert.equal(store.objects.size, 0);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("writes nothing when the iCalendar will not parse", async () => {
|
|
322
|
+
const store = new MemoryCalendarStore();
|
|
323
|
+
|
|
324
|
+
const recorded = await record(store, "BEGIN:VCALENDAR", "message-1");
|
|
325
|
+
|
|
326
|
+
assert.equal(recorded.ok, false);
|
|
327
|
+
assert.equal(store.suggestions.size, 0);
|
|
328
|
+
});
|
|
329
|
+
});
|
package/src/suggest.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CalendarSuggestionItem,
|
|
3
|
+
ICalendarSuggestionRepository,
|
|
4
|
+
PutCalendarSuggestionInput,
|
|
5
|
+
} from "@remit/data-ports";
|
|
6
|
+
import {
|
|
7
|
+
CalendarInviteMethod,
|
|
8
|
+
CalendarSuggestionState,
|
|
9
|
+
} from "@remit/domain-enums";
|
|
10
|
+
import type ICAL from "ical.js";
|
|
11
|
+
import type { CalendarResult } from "./errors.js";
|
|
12
|
+
import { parseCalendar } from "./parse.js";
|
|
13
|
+
import { projectCalendar } from "./project.js";
|
|
14
|
+
|
|
15
|
+
const METHOD_BY_ICAL: Record<string, CalendarSuggestionItem["method"]> = {
|
|
16
|
+
REQUEST: CalendarInviteMethod.Request,
|
|
17
|
+
REPLY: CalendarInviteMethod.Reply,
|
|
18
|
+
CANCEL: CalendarInviteMethod.Cancel,
|
|
19
|
+
COUNTER: CalendarInviteMethod.Counter,
|
|
20
|
+
PUBLISH: CalendarInviteMethod.Publish,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const readString = (component: ICAL.Component, name: string): string => {
|
|
24
|
+
const value = component.getFirstPropertyValue(name);
|
|
25
|
+
return typeof value === "string" ? value : "";
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A `CAL-ADDRESS` as a plain mail address. RFC 5545 3.3.3 writes one as a URI,
|
|
30
|
+
* which in practice is always `mailto:`; a card shows a person, not a URI.
|
|
31
|
+
*/
|
|
32
|
+
export const mailAddressOf = (calAddress: string): string =>
|
|
33
|
+
calAddress.replace(/^mailto:/i, "");
|
|
34
|
+
|
|
35
|
+
/** The iTIP method a VCALENDAR declared, `None` when it declared no METHOD. */
|
|
36
|
+
export const inviteMethodOf = (
|
|
37
|
+
component: ICAL.Component,
|
|
38
|
+
): CalendarSuggestionItem["method"] =>
|
|
39
|
+
METHOD_BY_ICAL[readString(component, "method").toUpperCase()] ??
|
|
40
|
+
CalendarInviteMethod.None;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The facts a suggestion carries beside its bytes, read out of the message's
|
|
44
|
+
* iCalendar and nowhere else. Shares `projectCalendar` with the stored
|
|
45
|
+
* resource, so a card and the event it becomes cannot disagree about when the
|
|
46
|
+
* meeting is.
|
|
47
|
+
*/
|
|
48
|
+
export type CalendarSuggestionProjection = Pick<
|
|
49
|
+
PutCalendarSuggestionInput,
|
|
50
|
+
| "icalUid"
|
|
51
|
+
| "sequence"
|
|
52
|
+
| "method"
|
|
53
|
+
| "summary"
|
|
54
|
+
| "dtStart"
|
|
55
|
+
| "dtEnd"
|
|
56
|
+
| "allDay"
|
|
57
|
+
| "location"
|
|
58
|
+
| "organizer"
|
|
59
|
+
| "zoneCertainty"
|
|
60
|
+
>;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Reads a message's `text/calendar` bytes into the facts a card is drawn from.
|
|
64
|
+
*
|
|
65
|
+
* Refusal is a returned value, as everywhere else in this package: the bytes
|
|
66
|
+
* come from whatever client the organizer uses, and a mail carrying broken
|
|
67
|
+
* iCalendar is an ordinary thing that must not fail the message's body sync.
|
|
68
|
+
*/
|
|
69
|
+
export const projectSuggestion = async (
|
|
70
|
+
icalData: string,
|
|
71
|
+
timezone: string,
|
|
72
|
+
): Promise<CalendarResult<CalendarSuggestionProjection>> => {
|
|
73
|
+
const parsed = await parseCalendar(icalData);
|
|
74
|
+
if (!parsed.ok) return parsed;
|
|
75
|
+
|
|
76
|
+
const projection = projectCalendar(parsed.value, timezone);
|
|
77
|
+
if (!projection.ok) return projection;
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
ok: true,
|
|
81
|
+
value: {
|
|
82
|
+
icalUid: projection.value.icalUid,
|
|
83
|
+
sequence: projection.value.sequence,
|
|
84
|
+
method: inviteMethodOf(parsed.value.component),
|
|
85
|
+
summary: projection.value.summary,
|
|
86
|
+
dtStart: projection.value.dtStart,
|
|
87
|
+
dtEnd: projection.value.dtEnd,
|
|
88
|
+
allDay: projection.value.allDay,
|
|
89
|
+
location: readString(parsed.value.master, "location"),
|
|
90
|
+
organizer: mailAddressOf(readString(parsed.value.master, "organizer")),
|
|
91
|
+
zoneCertainty: projection.value.zoneCertainty,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export interface RecordCalendarSuggestionInput {
|
|
97
|
+
accountConfigId: string;
|
|
98
|
+
messageId: string;
|
|
99
|
+
bodyPartId: string;
|
|
100
|
+
source: CalendarSuggestionItem["source"];
|
|
101
|
+
/** The VCALENDAR text as it arrived in the message. */
|
|
102
|
+
icalData: string;
|
|
103
|
+
/**
|
|
104
|
+
* IANA zone a floating time in the invitation is read in — the zone of the
|
|
105
|
+
* collection the event would land in. An invitation whose DTSTART names no
|
|
106
|
+
* zone is RFC 5545 floating time, and reading it anywhere but where the
|
|
107
|
+
* user's calendar lives puts the meeting at the wrong hour.
|
|
108
|
+
*/
|
|
109
|
+
timezone: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface RecordedCalendarSuggestion {
|
|
113
|
+
suggestion: CalendarSuggestionItem;
|
|
114
|
+
/** The revisions this one replaced, already marked `Superseded`. */
|
|
115
|
+
superseded: CalendarSuggestionItem[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Every pending suggestion the account holds. Drained rather than read as one
|
|
120
|
+
* page: the page size is a repository detail, and a supersession that missed
|
|
121
|
+
* the older revision because it sat on page two would leave two live cards for
|
|
122
|
+
* one event.
|
|
123
|
+
*/
|
|
124
|
+
const listPending = async (
|
|
125
|
+
repo: ICalendarSuggestionRepository,
|
|
126
|
+
accountConfigId: string,
|
|
127
|
+
): Promise<CalendarSuggestionItem[]> => {
|
|
128
|
+
const items: CalendarSuggestionItem[] = [];
|
|
129
|
+
let continuationToken: string | undefined;
|
|
130
|
+
do {
|
|
131
|
+
const page = await repo.listByState(
|
|
132
|
+
accountConfigId,
|
|
133
|
+
CalendarSuggestionState.Pending,
|
|
134
|
+
{ continuationToken },
|
|
135
|
+
);
|
|
136
|
+
items.push(...page.items);
|
|
137
|
+
continuationToken = page.continuationToken;
|
|
138
|
+
} while (continuationToken);
|
|
139
|
+
return items;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Records what one message offers, and retires the revision it replaces.
|
|
144
|
+
*
|
|
145
|
+
* A revision is a new row, never an edit. The organizer's `SEQUENCE` is what
|
|
146
|
+
* orders them (RFC 5545 3.8.7.4): a later message carrying the same `UID` with
|
|
147
|
+
* a higher value leaves the earlier card in place as `Superseded` and writes a
|
|
148
|
+
* new `Pending` one, so the message the old card came from still has its card
|
|
149
|
+
* and the user can see which revision they are being asked about.
|
|
150
|
+
*
|
|
151
|
+
* Only a `Pending` suggestion is superseded. An answered one is a decision the
|
|
152
|
+
* user already took, and a later message does not get to erase it — a
|
|
153
|
+
* cancellation of an accepted event arrives as its own `Cancel` suggestion and
|
|
154
|
+
* waits for the user like everything else.
|
|
155
|
+
*
|
|
156
|
+
* The new row is written before the old ones are retired. A failure between
|
|
157
|
+
* the two leaves two live cards, which a person can see and act on; the other
|
|
158
|
+
* order would leave the event with none.
|
|
159
|
+
*
|
|
160
|
+
* Retiring is a compare-and-set for the same reason the read and the write are
|
|
161
|
+
* two steps: a person can accept a card between them, and an unconditional
|
|
162
|
+
* write would replace their acceptance with `Superseded` and blank the id of
|
|
163
|
+
* the event it put in their calendar. A card answered in the gap keeps its
|
|
164
|
+
* answer and is simply not reported as superseded.
|
|
165
|
+
*/
|
|
166
|
+
export const recordCalendarSuggestion = async (
|
|
167
|
+
repo: ICalendarSuggestionRepository,
|
|
168
|
+
input: RecordCalendarSuggestionInput,
|
|
169
|
+
): Promise<CalendarResult<RecordedCalendarSuggestion>> => {
|
|
170
|
+
const projection = await projectSuggestion(input.icalData, input.timezone);
|
|
171
|
+
if (!projection.ok) return projection;
|
|
172
|
+
|
|
173
|
+
const suggestion = await repo.put({
|
|
174
|
+
accountConfigId: input.accountConfigId,
|
|
175
|
+
messageId: input.messageId,
|
|
176
|
+
bodyPartId: input.bodyPartId,
|
|
177
|
+
source: input.source,
|
|
178
|
+
icalData: input.icalData,
|
|
179
|
+
...projection.value,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const stale = (await listPending(repo, input.accountConfigId)).filter(
|
|
183
|
+
(candidate) =>
|
|
184
|
+
candidate.icalUid === suggestion.icalUid &&
|
|
185
|
+
candidate.suggestionId !== suggestion.suggestionId &&
|
|
186
|
+
candidate.sequence < suggestion.sequence,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const superseded: CalendarSuggestionItem[] = [];
|
|
190
|
+
for (const candidate of stale) {
|
|
191
|
+
const retired = await repo.supersedeIfPending(
|
|
192
|
+
input.accountConfigId,
|
|
193
|
+
candidate.suggestionId,
|
|
194
|
+
);
|
|
195
|
+
if (retired) superseded.push(retired);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { ok: true, value: { suggestion, superseded } };
|
|
199
|
+
};
|