@remit/calendar-service 0.0.15 → 0.0.16
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 +1 -1
- package/src/errors.ts +3 -1
- package/src/index.ts +7 -0
- package/src/memory-store.ts +14 -0
- package/src/reopen.test.ts +531 -0
- package/src/reopen.ts +190 -0
- package/src/scope.ts +8 -0
package/package.json
CHANGED
package/src/errors.ts
CHANGED
|
@@ -38,7 +38,9 @@ export type CalendarValidationCode =
|
|
|
38
38
|
| "UnmovableRecurrenceRule"
|
|
39
39
|
| "UneditableCancellation"
|
|
40
40
|
/** A subscription address that is not an http, https or webcal URL. */
|
|
41
|
-
| "InvalidSubscriptionUrl"
|
|
41
|
+
| "InvalidSubscriptionUrl"
|
|
42
|
+
| "NotReopenable"
|
|
43
|
+
| "AnswerOvertaken";
|
|
42
44
|
|
|
43
45
|
export interface CalendarValidationError {
|
|
44
46
|
code: CalendarValidationCode;
|
package/src/index.ts
CHANGED
|
@@ -60,10 +60,17 @@ export {
|
|
|
60
60
|
provisionDefaultCalendar,
|
|
61
61
|
putCalendarObject,
|
|
62
62
|
} from "./put.js";
|
|
63
|
+
export {
|
|
64
|
+
answersOvertakenBy,
|
|
65
|
+
type ReopenCalendarSuggestionInput,
|
|
66
|
+
reopenCalendarSuggestion,
|
|
67
|
+
} from "./reopen.js";
|
|
63
68
|
export {
|
|
64
69
|
applyScopedDelete,
|
|
65
70
|
applyScopedUpdate,
|
|
66
71
|
findOccurrence,
|
|
72
|
+
followingUidOf,
|
|
73
|
+
followingUidPrefix,
|
|
67
74
|
type RecurrenceScopeValue,
|
|
68
75
|
type ScopedWrite,
|
|
69
76
|
type ScopedWriteInput,
|
package/src/memory-store.ts
CHANGED
|
@@ -169,6 +169,11 @@ export class MemoryCalendarStore implements ICalendarUnitOfWork {
|
|
|
169
169
|
(object) =>
|
|
170
170
|
object.calendarId === calendarId && object.icalUid === icalUid,
|
|
171
171
|
) ?? null,
|
|
172
|
+
listByUidPrefix: async (calendarId: string, prefix: string) =>
|
|
173
|
+
[...this.objects.values()].filter(
|
|
174
|
+
(object) =>
|
|
175
|
+
object.calendarId === calendarId && object.icalUid.startsWith(prefix),
|
|
176
|
+
),
|
|
172
177
|
listByCalendar: async (calendarId: string) =>
|
|
173
178
|
[...this.objects.values()].filter(
|
|
174
179
|
(object) => object.calendarId === calendarId,
|
|
@@ -250,6 +255,15 @@ export class MemoryCalendarStore implements ICalendarUnitOfWork {
|
|
|
250
255
|
}
|
|
251
256
|
return suggestion;
|
|
252
257
|
},
|
|
258
|
+
listByAcceptedCalendarObjects: async (
|
|
259
|
+
accountConfigId: string,
|
|
260
|
+
calendarObjectIds: string[],
|
|
261
|
+
) =>
|
|
262
|
+
[...this.suggestions.values()].filter(
|
|
263
|
+
(suggestion) =>
|
|
264
|
+
suggestion.accountConfigId === accountConfigId &&
|
|
265
|
+
calendarObjectIds.includes(suggestion.acceptedCalendarObjectId),
|
|
266
|
+
),
|
|
253
267
|
listByMessage: async (accountConfigId: string, messageId: string) =>
|
|
254
268
|
[...this.suggestions.values()].filter(
|
|
255
269
|
(suggestion) =>
|
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { CalendarInviteAnswer as CalendarInviteAnswerValue } from "@remit/api-openapi-types";
|
|
4
|
+
import type { CalendarSuggestionItem } from "@remit/data-ports";
|
|
5
|
+
import {
|
|
6
|
+
CalendarAnswerOvertaken,
|
|
7
|
+
CalendarInviteAnswer,
|
|
8
|
+
CalendarSuggestionSource,
|
|
9
|
+
CalendarSuggestionState,
|
|
10
|
+
} from "@remit/domain-enums";
|
|
11
|
+
import { acceptCalendarSuggestion } from "./accept.js";
|
|
12
|
+
import { ical } from "./fixtures.js";
|
|
13
|
+
import { MemoryCalendarStore } from "./memory-store.js";
|
|
14
|
+
import {
|
|
15
|
+
deleteCalendarObject,
|
|
16
|
+
provisionDefaultCalendar,
|
|
17
|
+
putCalendarObject,
|
|
18
|
+
} from "./put.js";
|
|
19
|
+
import { answersOvertakenBy, reopenCalendarSuggestion } from "./reopen.js";
|
|
20
|
+
import { followingUidOf, followingUidPrefix } from "./scope.js";
|
|
21
|
+
import { recordCalendarSuggestion } from "./suggest.js";
|
|
22
|
+
|
|
23
|
+
const ACCOUNT_CONFIG_ID = "account-config-1";
|
|
24
|
+
const ATTENDEE = "user@example.test";
|
|
25
|
+
|
|
26
|
+
const invitation = (
|
|
27
|
+
uid: string,
|
|
28
|
+
{
|
|
29
|
+
method = "REQUEST",
|
|
30
|
+
sequence = 0,
|
|
31
|
+
}: { method?: "REQUEST" | "CANCEL"; sequence?: number } = {},
|
|
32
|
+
): string =>
|
|
33
|
+
ical(
|
|
34
|
+
"BEGIN:VCALENDAR",
|
|
35
|
+
"VERSION:2.0",
|
|
36
|
+
"PRODID:-//Example Corp//Scheduler//EN",
|
|
37
|
+
`METHOD:${method}`,
|
|
38
|
+
"BEGIN:VEVENT",
|
|
39
|
+
`UID:${uid}`,
|
|
40
|
+
"DTSTAMP:20260801T090000Z",
|
|
41
|
+
`SEQUENCE:${sequence}`,
|
|
42
|
+
"DTSTART:20260901T080000Z",
|
|
43
|
+
"DTEND:20260901T090000Z",
|
|
44
|
+
"RRULE:FREQ=WEEKLY;COUNT=4",
|
|
45
|
+
"SUMMARY:Quarterly review",
|
|
46
|
+
"ORGANIZER:mailto:organizer@example.test",
|
|
47
|
+
"ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:user@example.test",
|
|
48
|
+
"END:VEVENT",
|
|
49
|
+
"END:VCALENDAR",
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const provisioned = async (): Promise<{
|
|
53
|
+
store: MemoryCalendarStore;
|
|
54
|
+
calendarId: string;
|
|
55
|
+
}> => {
|
|
56
|
+
const store = new MemoryCalendarStore();
|
|
57
|
+
const collection = await provisionDefaultCalendar(store, ACCOUNT_CONFIG_ID);
|
|
58
|
+
return { store, calendarId: collection.calendarId };
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const recorded = async (
|
|
62
|
+
store: MemoryCalendarStore,
|
|
63
|
+
uid: string,
|
|
64
|
+
messageId: string,
|
|
65
|
+
options: { method?: "REQUEST" | "CANCEL"; sequence?: number } = {},
|
|
66
|
+
): Promise<CalendarSuggestionItem> => {
|
|
67
|
+
const result = await recordCalendarSuggestion(store.calendarSuggestion, {
|
|
68
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
69
|
+
messageId,
|
|
70
|
+
bodyPartId: "body-part-1",
|
|
71
|
+
source: CalendarSuggestionSource.IcalendarPart,
|
|
72
|
+
icalData: invitation(uid, options),
|
|
73
|
+
timezone: "UTC",
|
|
74
|
+
});
|
|
75
|
+
assert.ok(result.ok);
|
|
76
|
+
return result.value.suggestion;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const answered = async (
|
|
80
|
+
store: MemoryCalendarStore,
|
|
81
|
+
calendarId: string,
|
|
82
|
+
suggestion: CalendarSuggestionItem,
|
|
83
|
+
answer: CalendarInviteAnswerValue,
|
|
84
|
+
): Promise<CalendarSuggestionItem> => {
|
|
85
|
+
const result = await acceptCalendarSuggestion(store, {
|
|
86
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
87
|
+
calendarId,
|
|
88
|
+
suggestion,
|
|
89
|
+
attendee: ATTENDEE,
|
|
90
|
+
answer,
|
|
91
|
+
});
|
|
92
|
+
assert.ok(result.ok);
|
|
93
|
+
return result.value.suggestion;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const reopen = (
|
|
97
|
+
store: MemoryCalendarStore,
|
|
98
|
+
suggestion: CalendarSuggestionItem,
|
|
99
|
+
) =>
|
|
100
|
+
reopenCalendarSuggestion(store, {
|
|
101
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
102
|
+
suggestion,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const reopened = async (
|
|
106
|
+
store: MemoryCalendarStore,
|
|
107
|
+
suggestion: CalendarSuggestionItem,
|
|
108
|
+
): Promise<CalendarSuggestionItem> => {
|
|
109
|
+
const result = await reopen(store, suggestion);
|
|
110
|
+
assert.ok(result.ok, JSON.stringify(result));
|
|
111
|
+
return result.value;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const answerOf = async (
|
|
115
|
+
store: MemoryCalendarStore,
|
|
116
|
+
suggestion: CalendarSuggestionItem,
|
|
117
|
+
) => {
|
|
118
|
+
const [answered] = await answersOvertakenBy(
|
|
119
|
+
store.calendarSuggestion,
|
|
120
|
+
ACCOUNT_CONFIG_ID,
|
|
121
|
+
[suggestion],
|
|
122
|
+
);
|
|
123
|
+
return answered?.answerOvertakenBy;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const secondCalendar = (store: MemoryCalendarStore) =>
|
|
127
|
+
store.transaction((repos) =>
|
|
128
|
+
repos.calendarCollection.create({
|
|
129
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
130
|
+
urlSegment: "personal",
|
|
131
|
+
displayName: "Personal",
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
describe("reopenCalendarSuggestion", () => {
|
|
136
|
+
for (const answer of [
|
|
137
|
+
CalendarInviteAnswer.Accepted,
|
|
138
|
+
CalendarInviteAnswer.Tentative,
|
|
139
|
+
]) {
|
|
140
|
+
it(`takes a ${answer} answer back: the event and its occurrences leave, the card is Pending`, async () => {
|
|
141
|
+
const { store, calendarId } = await provisioned();
|
|
142
|
+
const card = await answered(
|
|
143
|
+
store,
|
|
144
|
+
calendarId,
|
|
145
|
+
await recorded(store, "invite-1@example.test", "message-1"),
|
|
146
|
+
answer,
|
|
147
|
+
);
|
|
148
|
+
const written = card.acceptedCalendarObjectId;
|
|
149
|
+
const sequenceBefore = store.collections.get(calendarId)?.syncSequence;
|
|
150
|
+
|
|
151
|
+
const back = await reopened(store, card);
|
|
152
|
+
|
|
153
|
+
assert.equal(back.state, CalendarSuggestionState.Pending);
|
|
154
|
+
assert.equal(back.acceptedCalendarObjectId, "");
|
|
155
|
+
assert.equal(store.objects.has(written), false);
|
|
156
|
+
assert.equal(store.occurrences.get(written)?.length ?? 0, 0);
|
|
157
|
+
assert.ok(
|
|
158
|
+
(store.collections.get(calendarId)?.syncSequence ?? 0) >
|
|
159
|
+
(sequenceBefore ?? 0),
|
|
160
|
+
);
|
|
161
|
+
assert.equal(
|
|
162
|
+
store.suggestions.get(card.suggestionId)?.state,
|
|
163
|
+
CalendarSuggestionState.Pending,
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
it("a reopened card can be answered again", async () => {
|
|
169
|
+
const { store, calendarId } = await provisioned();
|
|
170
|
+
const card = await answered(
|
|
171
|
+
store,
|
|
172
|
+
calendarId,
|
|
173
|
+
await recorded(store, "invite-1@example.test", "message-1"),
|
|
174
|
+
CalendarInviteAnswer.Accepted,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const again = await answered(
|
|
178
|
+
store,
|
|
179
|
+
calendarId,
|
|
180
|
+
await reopened(store, card),
|
|
181
|
+
CalendarInviteAnswer.Tentative,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
assert.equal(again.state, CalendarSuggestionState.Tentative);
|
|
185
|
+
assert.equal(store.objects.size, 1);
|
|
186
|
+
assert.ok(store.objects.has(again.acceptedCalendarObjectId));
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("removes the remainder a this-and-following edit split off, so a re-add does not duplicate", async () => {
|
|
190
|
+
const { store, calendarId } = await provisioned();
|
|
191
|
+
const uid = "invite-1@example.test";
|
|
192
|
+
const card = await answered(
|
|
193
|
+
store,
|
|
194
|
+
calendarId,
|
|
195
|
+
await recorded(store, uid, "message-1"),
|
|
196
|
+
CalendarInviteAnswer.Accepted,
|
|
197
|
+
);
|
|
198
|
+
const head = store.objects.get(card.acceptedCalendarObjectId);
|
|
199
|
+
assert.ok(head);
|
|
200
|
+
const tailUid = followingUidOf(uid, "split-1@reader.remit");
|
|
201
|
+
const tail = await putCalendarObject(store, {
|
|
202
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
203
|
+
calendarId,
|
|
204
|
+
resourceName: "split-1.ics",
|
|
205
|
+
icalData: head.icalData.replace(`UID:${uid}`, `UID:${tailUid}`),
|
|
206
|
+
});
|
|
207
|
+
assert.ok(tail.ok);
|
|
208
|
+
const tailOfTail = await putCalendarObject(store, {
|
|
209
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
210
|
+
calendarId,
|
|
211
|
+
resourceName: "split-2.ics",
|
|
212
|
+
icalData: head.icalData.replace(
|
|
213
|
+
`UID:${uid}`,
|
|
214
|
+
`UID:${followingUidOf(tailUid, "split-2@reader.remit")}`,
|
|
215
|
+
),
|
|
216
|
+
});
|
|
217
|
+
assert.ok(tailOfTail.ok);
|
|
218
|
+
assert.equal(store.objects.size, 3);
|
|
219
|
+
|
|
220
|
+
const back = await reopened(store, card);
|
|
221
|
+
assert.equal(store.objects.size, 0);
|
|
222
|
+
assert.equal(
|
|
223
|
+
store.occurrences.get(tail.value.calendarObjectId)?.length ?? 0,
|
|
224
|
+
0,
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
await answered(store, calendarId, back, CalendarInviteAnswer.Accepted);
|
|
228
|
+
assert.equal(store.objects.size, 1);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
for (const second of [
|
|
232
|
+
{ name: "a resend at the same SEQUENCE", sequence: 0 },
|
|
233
|
+
{ name: "a later revision", sequence: 1 },
|
|
234
|
+
]) {
|
|
235
|
+
it(`keeps the event while ${second.name} still holds it, and removes it with the last answer`, async () => {
|
|
236
|
+
const { store, calendarId } = await provisioned();
|
|
237
|
+
const uid = "invite-1@example.test";
|
|
238
|
+
const first = await answered(
|
|
239
|
+
store,
|
|
240
|
+
calendarId,
|
|
241
|
+
await recorded(store, uid, "message-1"),
|
|
242
|
+
CalendarInviteAnswer.Accepted,
|
|
243
|
+
);
|
|
244
|
+
const other = await answered(
|
|
245
|
+
store,
|
|
246
|
+
calendarId,
|
|
247
|
+
await recorded(store, uid, "message-2", { sequence: second.sequence }),
|
|
248
|
+
CalendarInviteAnswer.Tentative,
|
|
249
|
+
);
|
|
250
|
+
const event = first.acceptedCalendarObjectId;
|
|
251
|
+
assert.equal(other.acceptedCalendarObjectId, event);
|
|
252
|
+
assert.equal(await answerOf(store, first), CalendarAnswerOvertaken.None);
|
|
253
|
+
|
|
254
|
+
const firstBack = await reopened(store, first);
|
|
255
|
+
|
|
256
|
+
assert.equal(firstBack.state, CalendarSuggestionState.Pending);
|
|
257
|
+
assert.equal(firstBack.acceptedCalendarObjectId, "");
|
|
258
|
+
assert.ok(store.objects.has(event), "the other answer still holds it");
|
|
259
|
+
assert.equal(
|
|
260
|
+
store.suggestions.get(other.suggestionId)?.acceptedCalendarObjectId,
|
|
261
|
+
event,
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const otherBack = await reopened(store, other);
|
|
265
|
+
|
|
266
|
+
assert.equal(otherBack.state, CalendarSuggestionState.Pending);
|
|
267
|
+
assert.equal(store.objects.has(event), false);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
it("takes back only the parts in the calendar that holds the answered event", async () => {
|
|
272
|
+
const { store, calendarId: work } = await provisioned();
|
|
273
|
+
const personal = (await secondCalendar(store)).calendarId;
|
|
274
|
+
const uid = "invite-1@example.test";
|
|
275
|
+
const first = await answered(
|
|
276
|
+
store,
|
|
277
|
+
work,
|
|
278
|
+
await recorded(store, uid, "message-1"),
|
|
279
|
+
CalendarInviteAnswer.Accepted,
|
|
280
|
+
);
|
|
281
|
+
const second = await answered(
|
|
282
|
+
store,
|
|
283
|
+
personal,
|
|
284
|
+
await recorded(store, uid, "message-2", { sequence: 1 }),
|
|
285
|
+
CalendarInviteAnswer.Accepted,
|
|
286
|
+
);
|
|
287
|
+
assert.notEqual(
|
|
288
|
+
first.acceptedCalendarObjectId,
|
|
289
|
+
second.acceptedCalendarObjectId,
|
|
290
|
+
);
|
|
291
|
+
const personalHead = store.objects.get(second.acceptedCalendarObjectId);
|
|
292
|
+
assert.ok(personalHead);
|
|
293
|
+
const personalTail = await putCalendarObject(store, {
|
|
294
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
295
|
+
calendarId: personal,
|
|
296
|
+
resourceName: "split-1.ics",
|
|
297
|
+
icalData: personalHead.icalData.replace(
|
|
298
|
+
`UID:${uid}`,
|
|
299
|
+
`UID:${followingUidOf(uid, "split-1@reader.remit")}`,
|
|
300
|
+
),
|
|
301
|
+
});
|
|
302
|
+
assert.ok(personalTail.ok);
|
|
303
|
+
|
|
304
|
+
await reopened(store, first);
|
|
305
|
+
|
|
306
|
+
assert.equal(store.objects.has(first.acceptedCalendarObjectId), false);
|
|
307
|
+
assert.ok(store.objects.has(second.acceptedCalendarObjectId));
|
|
308
|
+
assert.ok(store.objects.has(personalTail.value.calendarObjectId));
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("leaves a remainder split off before split UIDs were derived", async () => {
|
|
312
|
+
const { store, calendarId } = await provisioned();
|
|
313
|
+
const uid = "invite-1@example.test";
|
|
314
|
+
const card = await answered(
|
|
315
|
+
store,
|
|
316
|
+
calendarId,
|
|
317
|
+
await recorded(store, uid, "message-1"),
|
|
318
|
+
CalendarInviteAnswer.Accepted,
|
|
319
|
+
);
|
|
320
|
+
const head = store.objects.get(card.acceptedCalendarObjectId);
|
|
321
|
+
assert.ok(head);
|
|
322
|
+
const legacy = await putCalendarObject(store, {
|
|
323
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
324
|
+
calendarId,
|
|
325
|
+
resourceName: "legacy.ics",
|
|
326
|
+
icalData: head.icalData
|
|
327
|
+
.replace(`UID:${uid}`, "UID:legacy-1@reader.remit")
|
|
328
|
+
.replace("DTSTART:20260901T080000Z", "DTSTART:20260915T080000Z")
|
|
329
|
+
.replace("DTEND:20260901T090000Z", "DTEND:20260915T090000Z"),
|
|
330
|
+
});
|
|
331
|
+
assert.ok(legacy.ok, JSON.stringify(legacy));
|
|
332
|
+
|
|
333
|
+
await reopened(store, card);
|
|
334
|
+
|
|
335
|
+
assert.equal(store.objects.has(card.acceptedCalendarObjectId), false);
|
|
336
|
+
assert.ok(store.objects.has(legacy.value.calendarObjectId));
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("refuses to take back a request's answer once its cancellation was answered into the same event", async () => {
|
|
340
|
+
const { store, calendarId } = await provisioned();
|
|
341
|
+
const uid = "invite-1@example.test";
|
|
342
|
+
const request = await answered(
|
|
343
|
+
store,
|
|
344
|
+
calendarId,
|
|
345
|
+
await recorded(store, uid, "message-1"),
|
|
346
|
+
CalendarInviteAnswer.Accepted,
|
|
347
|
+
);
|
|
348
|
+
const cancel = await answered(
|
|
349
|
+
store,
|
|
350
|
+
calendarId,
|
|
351
|
+
await recorded(store, uid, "message-2", { method: "CANCEL" }),
|
|
352
|
+
CalendarInviteAnswer.Accepted,
|
|
353
|
+
);
|
|
354
|
+
assert.equal(
|
|
355
|
+
request.acceptedCalendarObjectId,
|
|
356
|
+
cancel.acceptedCalendarObjectId,
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
const refused = await reopen(store, request);
|
|
360
|
+
|
|
361
|
+
assert.equal(refused.ok, false);
|
|
362
|
+
assert.equal(!refused.ok && refused.error.code, "AnswerOvertaken");
|
|
363
|
+
assert.ok(store.objects.has(cancel.acceptedCalendarObjectId));
|
|
364
|
+
assert.equal(
|
|
365
|
+
await answerOf(store, request),
|
|
366
|
+
CalendarAnswerOvertaken.Cancellation,
|
|
367
|
+
);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("refuses a superseded card and a cancellation card", async () => {
|
|
371
|
+
const { store } = await provisioned();
|
|
372
|
+
const pending = await recorded(store, "invite-1@example.test", "message-1");
|
|
373
|
+
const cancel = await recorded(store, "invite-2@example.test", "message-2", {
|
|
374
|
+
method: "CANCEL",
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
for (const card of [
|
|
378
|
+
{ ...pending, state: CalendarSuggestionState.Superseded },
|
|
379
|
+
{ ...cancel, state: CalendarSuggestionState.Dismissed },
|
|
380
|
+
]) {
|
|
381
|
+
const refused = await reopen(store, card);
|
|
382
|
+
assert.equal(!refused.ok && refused.error.code, "NotReopenable");
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("leaves every other event in the calendar alone", async () => {
|
|
387
|
+
const { store, calendarId } = await provisioned();
|
|
388
|
+
const other = await answered(
|
|
389
|
+
store,
|
|
390
|
+
calendarId,
|
|
391
|
+
await recorded(store, "invite-2@example.test", "message-2"),
|
|
392
|
+
CalendarInviteAnswer.Accepted,
|
|
393
|
+
);
|
|
394
|
+
const card = await answered(
|
|
395
|
+
store,
|
|
396
|
+
calendarId,
|
|
397
|
+
await recorded(store, "invite-1@example.test", "message-1"),
|
|
398
|
+
CalendarInviteAnswer.Accepted,
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
await reopened(store, card);
|
|
402
|
+
|
|
403
|
+
assert.deepEqual(
|
|
404
|
+
[...store.objects.keys()],
|
|
405
|
+
[other.acceptedCalendarObjectId],
|
|
406
|
+
);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("reopens a declined card without touching the calendar", async () => {
|
|
410
|
+
const { store, calendarId } = await provisioned();
|
|
411
|
+
const kept = await answered(
|
|
412
|
+
store,
|
|
413
|
+
calendarId,
|
|
414
|
+
await recorded(store, "invite-2@example.test", "message-2"),
|
|
415
|
+
CalendarInviteAnswer.Accepted,
|
|
416
|
+
);
|
|
417
|
+
const pending = await recorded(store, "invite-1@example.test", "message-1");
|
|
418
|
+
const declined = await store.calendarSuggestion.settle(
|
|
419
|
+
ACCOUNT_CONFIG_ID,
|
|
420
|
+
pending.suggestionId,
|
|
421
|
+
{ state: CalendarSuggestionState.Declined, acceptedCalendarObjectId: "" },
|
|
422
|
+
);
|
|
423
|
+
const sequenceBefore = store.collections.get(calendarId)?.syncSequence;
|
|
424
|
+
|
|
425
|
+
const back = await reopened(store, declined);
|
|
426
|
+
|
|
427
|
+
assert.equal(back.state, CalendarSuggestionState.Pending);
|
|
428
|
+
assert.ok(store.objects.has(kept.acceptedCalendarObjectId));
|
|
429
|
+
assert.equal(
|
|
430
|
+
store.collections.get(calendarId)?.syncSequence,
|
|
431
|
+
sequenceBefore,
|
|
432
|
+
);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("still reopens when the event was already deleted from the calendar", async () => {
|
|
436
|
+
const { store, calendarId } = await provisioned();
|
|
437
|
+
const card = await answered(
|
|
438
|
+
store,
|
|
439
|
+
calendarId,
|
|
440
|
+
await recorded(store, "invite-1@example.test", "message-1"),
|
|
441
|
+
CalendarInviteAnswer.Accepted,
|
|
442
|
+
);
|
|
443
|
+
await deleteCalendarObject(store, {
|
|
444
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
445
|
+
calendarId,
|
|
446
|
+
calendarObjectId: card.acceptedCalendarObjectId,
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
const back = await reopened(store, card);
|
|
450
|
+
|
|
451
|
+
assert.equal(back.state, CalendarSuggestionState.Pending);
|
|
452
|
+
assert.equal(back.acceptedCalendarObjectId, "");
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("returns a Pending card as it is", async () => {
|
|
456
|
+
const { store } = await provisioned();
|
|
457
|
+
const pending = await recorded(store, "invite-1@example.test", "message-1");
|
|
458
|
+
|
|
459
|
+
assert.deepEqual(await reopened(store, pending), pending);
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
describe("followingUidOf", () => {
|
|
464
|
+
it("keeps every split of a series under the first event's UID", () => {
|
|
465
|
+
const once = followingUidOf("root@example.test", "a");
|
|
466
|
+
const twice = followingUidOf(once, "b");
|
|
467
|
+
|
|
468
|
+
assert.ok(once.startsWith(followingUidPrefix("root@example.test")));
|
|
469
|
+
assert.ok(twice.startsWith(followingUidPrefix("root@example.test")));
|
|
470
|
+
assert.equal(followingUidPrefix(twice), followingUidPrefix(once));
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
describe("answersOvertakenBy", () => {
|
|
475
|
+
it("reads the holders of a whole page in one lookup", async () => {
|
|
476
|
+
const { store, calendarId } = await provisioned();
|
|
477
|
+
const request = await answered(
|
|
478
|
+
store,
|
|
479
|
+
calendarId,
|
|
480
|
+
await recorded(store, "invite-1@example.test", "message-1"),
|
|
481
|
+
CalendarInviteAnswer.Accepted,
|
|
482
|
+
);
|
|
483
|
+
await answered(
|
|
484
|
+
store,
|
|
485
|
+
calendarId,
|
|
486
|
+
await recorded(store, "invite-1@example.test", "message-2", {
|
|
487
|
+
method: "CANCEL",
|
|
488
|
+
}),
|
|
489
|
+
CalendarInviteAnswer.Accepted,
|
|
490
|
+
);
|
|
491
|
+
const other = await answered(
|
|
492
|
+
store,
|
|
493
|
+
calendarId,
|
|
494
|
+
await recorded(store, "invite-2@example.test", "message-3"),
|
|
495
|
+
CalendarInviteAnswer.Accepted,
|
|
496
|
+
);
|
|
497
|
+
const pending = await recorded(store, "invite-3@example.test", "message-4");
|
|
498
|
+
const reads: string[][] = [];
|
|
499
|
+
const counting = {
|
|
500
|
+
listByAcceptedCalendarObjects: (
|
|
501
|
+
accountConfigId: string,
|
|
502
|
+
calendarObjectIds: string[],
|
|
503
|
+
) => {
|
|
504
|
+
reads.push(calendarObjectIds);
|
|
505
|
+
return store.calendarSuggestion.listByAcceptedCalendarObjects(
|
|
506
|
+
accountConfigId,
|
|
507
|
+
calendarObjectIds,
|
|
508
|
+
);
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
const page = await answersOvertakenBy(counting, ACCOUNT_CONFIG_ID, [
|
|
513
|
+
request,
|
|
514
|
+
other,
|
|
515
|
+
pending,
|
|
516
|
+
]);
|
|
517
|
+
|
|
518
|
+
assert.equal(reads.length, 1);
|
|
519
|
+
assert.deepEqual(
|
|
520
|
+
page.map((entry) => [
|
|
521
|
+
entry.suggestion.suggestionId,
|
|
522
|
+
entry.answerOvertakenBy,
|
|
523
|
+
]),
|
|
524
|
+
[
|
|
525
|
+
[request.suggestionId, CalendarAnswerOvertaken.Cancellation],
|
|
526
|
+
[other.suggestionId, CalendarAnswerOvertaken.None],
|
|
527
|
+
[pending.suggestionId, CalendarAnswerOvertaken.None],
|
|
528
|
+
],
|
|
529
|
+
);
|
|
530
|
+
});
|
|
531
|
+
});
|
package/src/reopen.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { CalendarAnswerOvertaken as CalendarAnswerOvertakenValue } from "@remit/api-openapi-types";
|
|
2
|
+
import type {
|
|
3
|
+
CalendarCollectionItem,
|
|
4
|
+
CalendarObjectItem,
|
|
5
|
+
CalendarSuggestionItem,
|
|
6
|
+
CalendarUnitOfWorkRepositories,
|
|
7
|
+
ICalendarSuggestionRepository,
|
|
8
|
+
ICalendarUnitOfWork,
|
|
9
|
+
} from "@remit/data-ports";
|
|
10
|
+
import {
|
|
11
|
+
CalendarAnswerOvertaken,
|
|
12
|
+
CalendarInviteMethod,
|
|
13
|
+
CalendarSuggestionState,
|
|
14
|
+
} from "@remit/domain-enums";
|
|
15
|
+
import { type CalendarResult, calendarFailure } from "./errors.js";
|
|
16
|
+
import { deleteCalendarObject } from "./put.js";
|
|
17
|
+
import { followingUidPrefix } from "./scope.js";
|
|
18
|
+
|
|
19
|
+
export interface ReopenCalendarSuggestionInput {
|
|
20
|
+
accountConfigId: string;
|
|
21
|
+
suggestion: CalendarSuggestionItem;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const answeredStates: ReadonlySet<CalendarSuggestionItem["state"]> = new Set([
|
|
25
|
+
CalendarSuggestionState.Accepted,
|
|
26
|
+
CalendarSuggestionState.Tentative,
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
type SuggestionHolders = Pick<
|
|
30
|
+
ICalendarSuggestionRepository,
|
|
31
|
+
"listByAcceptedCalendarObjects"
|
|
32
|
+
>;
|
|
33
|
+
|
|
34
|
+
const holdersByObject = async (
|
|
35
|
+
calendarSuggestion: SuggestionHolders,
|
|
36
|
+
accountConfigId: string,
|
|
37
|
+
suggestions: CalendarSuggestionItem[],
|
|
38
|
+
): Promise<Map<string, CalendarSuggestionItem[]>> => {
|
|
39
|
+
const objectIds = [
|
|
40
|
+
...new Set(
|
|
41
|
+
suggestions
|
|
42
|
+
.map((suggestion) => suggestion.acceptedCalendarObjectId)
|
|
43
|
+
.filter((objectId) => objectId !== ""),
|
|
44
|
+
),
|
|
45
|
+
];
|
|
46
|
+
const byObject = new Map<string, CalendarSuggestionItem[]>();
|
|
47
|
+
for (const holder of await calendarSuggestion.listByAcceptedCalendarObjects(
|
|
48
|
+
accountConfigId,
|
|
49
|
+
objectIds,
|
|
50
|
+
)) {
|
|
51
|
+
if (!answeredStates.has(holder.state)) continue;
|
|
52
|
+
const held = byObject.get(holder.acceptedCalendarObjectId) ?? [];
|
|
53
|
+
held.push(holder);
|
|
54
|
+
byObject.set(holder.acceptedCalendarObjectId, held);
|
|
55
|
+
}
|
|
56
|
+
return byObject;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const othersIn = (
|
|
60
|
+
byObject: Map<string, CalendarSuggestionItem[]>,
|
|
61
|
+
suggestion: CalendarSuggestionItem,
|
|
62
|
+
): CalendarSuggestionItem[] =>
|
|
63
|
+
(byObject.get(suggestion.acceptedCalendarObjectId) ?? []).filter(
|
|
64
|
+
(other) => other.suggestionId !== suggestion.suggestionId,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const overtakenOf = (
|
|
68
|
+
holders: CalendarSuggestionItem[],
|
|
69
|
+
): CalendarAnswerOvertakenValue =>
|
|
70
|
+
holders.some((other) => other.method === CalendarInviteMethod.Cancel)
|
|
71
|
+
? CalendarAnswerOvertaken.Cancellation
|
|
72
|
+
: CalendarAnswerOvertaken.None;
|
|
73
|
+
|
|
74
|
+
export const answersOvertakenBy = async (
|
|
75
|
+
calendarSuggestion: SuggestionHolders,
|
|
76
|
+
accountConfigId: string,
|
|
77
|
+
suggestions: CalendarSuggestionItem[],
|
|
78
|
+
): Promise<
|
|
79
|
+
{
|
|
80
|
+
suggestion: CalendarSuggestionItem;
|
|
81
|
+
answerOvertakenBy: CalendarAnswerOvertakenValue;
|
|
82
|
+
}[]
|
|
83
|
+
> => {
|
|
84
|
+
const byObject = await holdersByObject(
|
|
85
|
+
calendarSuggestion,
|
|
86
|
+
accountConfigId,
|
|
87
|
+
suggestions,
|
|
88
|
+
);
|
|
89
|
+
return suggestions.map((suggestion) => ({
|
|
90
|
+
suggestion,
|
|
91
|
+
answerOvertakenBy: overtakenOf(othersIn(byObject, suggestion)),
|
|
92
|
+
}));
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const locateAnswered = async (
|
|
96
|
+
repos: CalendarUnitOfWorkRepositories,
|
|
97
|
+
collections: CalendarCollectionItem[],
|
|
98
|
+
calendarObjectId: string,
|
|
99
|
+
): Promise<
|
|
100
|
+
{ collection: CalendarCollectionItem; object: CalendarObjectItem } | undefined
|
|
101
|
+
> => {
|
|
102
|
+
for (const collection of collections) {
|
|
103
|
+
const object = await repos.calendarObject.find(
|
|
104
|
+
collection.calendarId,
|
|
105
|
+
calendarObjectId,
|
|
106
|
+
);
|
|
107
|
+
if (object !== null) return { collection, object };
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const partsOf = async (
|
|
113
|
+
repos: CalendarUnitOfWorkRepositories,
|
|
114
|
+
suggestion: CalendarSuggestionItem,
|
|
115
|
+
collection: CalendarCollectionItem,
|
|
116
|
+
answered: CalendarObjectItem,
|
|
117
|
+
): Promise<CalendarObjectItem[]> => [
|
|
118
|
+
answered,
|
|
119
|
+
...(await repos.calendarObject.listByUidPrefix(
|
|
120
|
+
collection.calendarId,
|
|
121
|
+
followingUidPrefix(suggestion.icalUid),
|
|
122
|
+
)),
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
export const reopenCalendarSuggestion = async (
|
|
126
|
+
unitOfWork: ICalendarUnitOfWork,
|
|
127
|
+
input: ReopenCalendarSuggestionInput,
|
|
128
|
+
): Promise<CalendarResult<CalendarSuggestionItem>> => {
|
|
129
|
+
const { accountConfigId, suggestion } = input;
|
|
130
|
+
if (suggestion.state === CalendarSuggestionState.Pending) {
|
|
131
|
+
return { ok: true, value: suggestion };
|
|
132
|
+
}
|
|
133
|
+
if (suggestion.state === CalendarSuggestionState.Superseded) {
|
|
134
|
+
return calendarFailure(
|
|
135
|
+
"NotReopenable",
|
|
136
|
+
"A later message replaced this invitation, so answer that one instead.",
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (suggestion.method === CalendarInviteMethod.Cancel) {
|
|
140
|
+
return calendarFailure(
|
|
141
|
+
"NotReopenable",
|
|
142
|
+
"This is a cancellation, so there is no answer to take back.",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return unitOfWork.transaction(async (repos) => {
|
|
147
|
+
const holders = othersIn(
|
|
148
|
+
await holdersByObject(repos.calendarSuggestion, accountConfigId, [
|
|
149
|
+
suggestion,
|
|
150
|
+
]),
|
|
151
|
+
suggestion,
|
|
152
|
+
);
|
|
153
|
+
if (overtakenOf(holders) === CalendarAnswerOvertaken.Cancellation) {
|
|
154
|
+
return calendarFailure<CalendarSuggestionItem>(
|
|
155
|
+
"AnswerOvertaken",
|
|
156
|
+
"The cancellation of this event was answered into the same event, so this answer can no longer be taken back.",
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const located =
|
|
161
|
+
suggestion.acceptedCalendarObjectId === "" || holders.length > 0
|
|
162
|
+
? undefined
|
|
163
|
+
: await locateAnswered(
|
|
164
|
+
repos,
|
|
165
|
+
await repos.calendarCollection.listByAccountConfig(accountConfigId),
|
|
166
|
+
suggestion.acceptedCalendarObjectId,
|
|
167
|
+
);
|
|
168
|
+
if (located) {
|
|
169
|
+
for (const object of await partsOf(
|
|
170
|
+
repos,
|
|
171
|
+
suggestion,
|
|
172
|
+
located.collection,
|
|
173
|
+
located.object,
|
|
174
|
+
)) {
|
|
175
|
+
await deleteCalendarObject(unitOfWork, {
|
|
176
|
+
accountConfigId,
|
|
177
|
+
calendarId: located.collection.calendarId,
|
|
178
|
+
calendarObjectId: object.calendarObjectId,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const settled = await repos.calendarSuggestion.settle(
|
|
184
|
+
accountConfigId,
|
|
185
|
+
suggestion.suggestionId,
|
|
186
|
+
{ state: CalendarSuggestionState.Pending, acceptedCalendarObjectId: "" },
|
|
187
|
+
);
|
|
188
|
+
return { ok: true, value: settled };
|
|
189
|
+
});
|
|
190
|
+
};
|
package/src/scope.ts
CHANGED
|
@@ -35,6 +35,14 @@ export interface ScopedWriteInput {
|
|
|
35
35
|
followingUid: string;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
const FOLLOWING_UID_SEPARATOR = "/following/";
|
|
39
|
+
|
|
40
|
+
export const followingUidPrefix = (icalUid: string): string =>
|
|
41
|
+
`${icalUid.split(FOLLOWING_UID_SEPARATOR)[0]}${FOLLOWING_UID_SEPARATOR}`;
|
|
42
|
+
|
|
43
|
+
export const followingUidOf = (icalUid: string, id: string): string =>
|
|
44
|
+
`${followingUidPrefix(icalUid)}${id}`;
|
|
45
|
+
|
|
38
46
|
interface FoundOccurrence {
|
|
39
47
|
/** The rule slot, in whatever form the master's DTSTART is written in. */
|
|
40
48
|
slot: ICAL.Time;
|