@remit/calendar-service 0.0.14 → 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/accept.test.ts +1 -0
- package/src/errors.ts +3 -1
- package/src/index.ts +7 -0
- package/src/memory-store.ts +38 -0
- package/src/reopen.test.ts +531 -0
- package/src/reopen.ts +190 -0
- package/src/scope.ts +8 -0
- package/src/suggest.test.ts +22 -0
- package/src/suggest.ts +45 -16
package/package.json
CHANGED
package/src/accept.test.ts
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,
|
|
@@ -236,6 +241,7 @@ export class MemoryCalendarStore implements ICalendarUnitOfWork {
|
|
|
236
241
|
suggestionId,
|
|
237
242
|
state: existing?.state ?? CalendarSuggestionState.Pending,
|
|
238
243
|
acceptedCalendarObjectId: existing?.acceptedCalendarObjectId ?? "",
|
|
244
|
+
supersededByMessageId: existing?.supersededByMessageId ?? "",
|
|
239
245
|
createdAt: existing?.createdAt ?? now,
|
|
240
246
|
updatedAt: now,
|
|
241
247
|
};
|
|
@@ -249,6 +255,15 @@ export class MemoryCalendarStore implements ICalendarUnitOfWork {
|
|
|
249
255
|
}
|
|
250
256
|
return suggestion;
|
|
251
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
|
+
),
|
|
252
267
|
listByMessage: async (accountConfigId: string, messageId: string) =>
|
|
253
268
|
[...this.suggestions.values()].filter(
|
|
254
269
|
(suggestion) =>
|
|
@@ -282,6 +297,7 @@ export class MemoryCalendarStore implements ICalendarUnitOfWork {
|
|
|
282
297
|
supersedeIfPending: async (
|
|
283
298
|
accountConfigId: string,
|
|
284
299
|
suggestionId: string,
|
|
300
|
+
supersededByMessageId: string,
|
|
285
301
|
) => {
|
|
286
302
|
const suggestion = this.suggestions.get(suggestionId);
|
|
287
303
|
if (
|
|
@@ -295,11 +311,33 @@ export class MemoryCalendarStore implements ICalendarUnitOfWork {
|
|
|
295
311
|
...suggestion,
|
|
296
312
|
state: CalendarSuggestionState.Superseded,
|
|
297
313
|
acceptedCalendarObjectId: "",
|
|
314
|
+
supersededByMessageId,
|
|
298
315
|
updatedAt: Date.now(),
|
|
299
316
|
};
|
|
300
317
|
this.suggestions.set(suggestionId, retired);
|
|
301
318
|
return retired;
|
|
302
319
|
},
|
|
320
|
+
repointSuperseded: async (
|
|
321
|
+
accountConfigId: string,
|
|
322
|
+
suggestionId: string,
|
|
323
|
+
supersededByMessageId: string,
|
|
324
|
+
) => {
|
|
325
|
+
const suggestion = this.suggestions.get(suggestionId);
|
|
326
|
+
if (
|
|
327
|
+
!suggestion ||
|
|
328
|
+
suggestion.accountConfigId !== accountConfigId ||
|
|
329
|
+
suggestion.state !== CalendarSuggestionState.Superseded
|
|
330
|
+
) {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
const repointed = {
|
|
334
|
+
...suggestion,
|
|
335
|
+
supersededByMessageId,
|
|
336
|
+
updatedAt: Date.now(),
|
|
337
|
+
};
|
|
338
|
+
this.suggestions.set(suggestionId, repointed);
|
|
339
|
+
return repointed;
|
|
340
|
+
},
|
|
303
341
|
};
|
|
304
342
|
|
|
305
343
|
private readonly feedTokenRepo: ICalendarFeedTokenRepository = {
|
|
@@ -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;
|
package/src/suggest.test.ts
CHANGED
|
@@ -180,6 +180,28 @@ describe("recordCalendarSuggestion", () => {
|
|
|
180
180
|
first.value.suggestion.suggestionId,
|
|
181
181
|
);
|
|
182
182
|
assert.equal(superseded.state, CalendarSuggestionState.Superseded);
|
|
183
|
+
assert.equal(
|
|
184
|
+
superseded.supersededByMessageId,
|
|
185
|
+
second.value.suggestion.messageId,
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("points every retired revision at the newest one", async () => {
|
|
190
|
+
const store = new MemoryCalendarStore();
|
|
191
|
+
|
|
192
|
+
const first = await record(store, invitation({ sequence: 0 }), "message-1");
|
|
193
|
+
await record(store, invitation({ sequence: 1 }), "message-2");
|
|
194
|
+
await record(store, invitation({ sequence: 2 }), "message-3");
|
|
195
|
+
|
|
196
|
+
assert.ok(first.ok);
|
|
197
|
+
const retired = [...store.suggestions.values()]
|
|
198
|
+
.filter((row) => row.state === CalendarSuggestionState.Superseded)
|
|
199
|
+
.map((row) => [row.messageId, row.supersededByMessageId])
|
|
200
|
+
.sort();
|
|
201
|
+
assert.deepEqual(retired, [
|
|
202
|
+
["message-1", "message-3"],
|
|
203
|
+
["message-2", "message-3"],
|
|
204
|
+
]);
|
|
183
205
|
});
|
|
184
206
|
|
|
185
207
|
it("leaves the earlier revision alone when the sequence did not move", async () => {
|
package/src/suggest.ts
CHANGED
|
@@ -111,23 +111,22 @@ export interface RecordedCalendarSuggestion {
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
/**
|
|
114
|
-
* Every
|
|
115
|
-
* page: the page size is a repository detail, and a supersession that
|
|
116
|
-
* the older revision because it sat on page two would leave two live
|
|
117
|
-
* one event.
|
|
114
|
+
* Every suggestion the account holds in one state. Drained rather than read as
|
|
115
|
+
* one page: the page size is a repository detail, and a supersession that
|
|
116
|
+
* missed the older revision because it sat on page two would leave two live
|
|
117
|
+
* cards for one event.
|
|
118
118
|
*/
|
|
119
|
-
const
|
|
119
|
+
const listInState = async (
|
|
120
120
|
repo: ICalendarSuggestionRepository,
|
|
121
121
|
accountConfigId: string,
|
|
122
|
+
state: CalendarSuggestionItem["state"],
|
|
122
123
|
): Promise<CalendarSuggestionItem[]> => {
|
|
123
124
|
const items: CalendarSuggestionItem[] = [];
|
|
124
125
|
let continuationToken: string | undefined;
|
|
125
126
|
do {
|
|
126
|
-
const page = await repo.listByState(
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
{ continuationToken },
|
|
130
|
-
);
|
|
127
|
+
const page = await repo.listByState(accountConfigId, state, {
|
|
128
|
+
continuationToken,
|
|
129
|
+
});
|
|
131
130
|
items.push(...page.items);
|
|
132
131
|
continuationToken = page.continuationToken;
|
|
133
132
|
} while (continuationToken);
|
|
@@ -157,6 +156,10 @@ const listPending = async (
|
|
|
157
156
|
* write would replace their acceptance with `Superseded` and blank the id of
|
|
158
157
|
* the event it put in their calendar. A card answered in the gap keeps its
|
|
159
158
|
* answer and is simply not reported as superseded.
|
|
159
|
+
*
|
|
160
|
+
* Every earlier revision already retired is pointed at this one too, so a
|
|
161
|
+
* card three revisions back opens the newest in one step rather than walking
|
|
162
|
+
* the chain a revision at a time.
|
|
160
163
|
*/
|
|
161
164
|
export const recordCalendarSuggestion = async (
|
|
162
165
|
repo: ICalendarSuggestionRepository,
|
|
@@ -174,21 +177,47 @@ export const recordCalendarSuggestion = async (
|
|
|
174
177
|
...projection.value,
|
|
175
178
|
});
|
|
176
179
|
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
180
|
+
const olderRevision = (candidate: CalendarSuggestionItem): boolean =>
|
|
181
|
+
candidate.icalUid === suggestion.icalUid &&
|
|
182
|
+
candidate.suggestionId !== suggestion.suggestionId &&
|
|
183
|
+
candidate.sequence < suggestion.sequence;
|
|
184
|
+
|
|
185
|
+
const stale = (
|
|
186
|
+
await listInState(
|
|
187
|
+
repo,
|
|
188
|
+
input.accountConfigId,
|
|
189
|
+
CalendarSuggestionState.Pending,
|
|
190
|
+
)
|
|
191
|
+
).filter(olderRevision);
|
|
183
192
|
|
|
184
193
|
const superseded: CalendarSuggestionItem[] = [];
|
|
185
194
|
for (const candidate of stale) {
|
|
186
195
|
const retired = await repo.supersedeIfPending(
|
|
187
196
|
input.accountConfigId,
|
|
188
197
|
candidate.suggestionId,
|
|
198
|
+
suggestion.messageId,
|
|
189
199
|
);
|
|
190
200
|
if (retired) superseded.push(retired);
|
|
191
201
|
}
|
|
192
202
|
|
|
203
|
+
const retiredEarlier = (
|
|
204
|
+
await listInState(
|
|
205
|
+
repo,
|
|
206
|
+
input.accountConfigId,
|
|
207
|
+
CalendarSuggestionState.Superseded,
|
|
208
|
+
)
|
|
209
|
+
).filter(
|
|
210
|
+
(candidate) =>
|
|
211
|
+
olderRevision(candidate) &&
|
|
212
|
+
candidate.supersededByMessageId !== suggestion.messageId,
|
|
213
|
+
);
|
|
214
|
+
for (const candidate of retiredEarlier) {
|
|
215
|
+
await repo.repointSuperseded(
|
|
216
|
+
input.accountConfigId,
|
|
217
|
+
candidate.suggestionId,
|
|
218
|
+
suggestion.messageId,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
193
222
|
return { ok: true, value: { suggestion, superseded } };
|
|
194
223
|
};
|