@remit/calendar-service 0.0.16 → 0.0.18
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/accept.ts +78 -57
- package/src/index.ts +3 -0
- package/src/memory-store.ts +105 -18
- package/src/put.test.ts +6 -8
- package/src/put.ts +73 -63
- package/src/reopen.test.ts +5 -7
- package/src/reopen.ts +50 -41
- package/src/subscribe.test.ts +43 -39
- package/src/subscribe.ts +84 -71
- package/src/suggest.test.ts +25 -0
- package/src/suggest.ts +21 -0
package/package.json
CHANGED
package/src/accept.test.ts
CHANGED
package/src/accept.ts
CHANGED
|
@@ -2,8 +2,9 @@ import type { CalendarInviteAnswer as CalendarInviteAnswerValue } from "@remit/a
|
|
|
2
2
|
import type {
|
|
3
3
|
CalendarObjectItem,
|
|
4
4
|
CalendarSuggestionItem,
|
|
5
|
-
|
|
5
|
+
ICalendarStore,
|
|
6
6
|
} from "@remit/data-ports";
|
|
7
|
+
import { deriveCalendarObjectId } from "@remit/data-ports/id";
|
|
7
8
|
import {
|
|
8
9
|
CalendarInviteAnswer,
|
|
9
10
|
CalendarInviteMethod,
|
|
@@ -12,7 +13,7 @@ import {
|
|
|
12
13
|
import ICAL from "ical.js";
|
|
13
14
|
import type { CalendarResult } from "./errors.js";
|
|
14
15
|
import { parseCalendar, serializeCalendar } from "./parse.js";
|
|
15
|
-
import {
|
|
16
|
+
import { prepareCalendarObject, readWrittenObject } from "./put.js";
|
|
16
17
|
import { mailAddressOf } from "./suggest.js";
|
|
17
18
|
|
|
18
19
|
const eventsOf = (component: ICAL.Component): ICAL.Component[] =>
|
|
@@ -132,15 +133,16 @@ export interface AcceptedCalendarSuggestion {
|
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
/**
|
|
135
|
-
* Adds a suggested event to a calendar,
|
|
136
|
+
* Adds a suggested event to a calendar, then settles the suggestion.
|
|
136
137
|
*
|
|
137
138
|
* The resource is written through `putCalendarObject`, the same function every
|
|
138
139
|
* other calendar write goes through, so an event added from a mail and one
|
|
139
140
|
* added from the week grid are the same kind of row — and a native client
|
|
140
|
-
* editing it afterwards edits the same bytes. The
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
141
|
+
* editing it afterwards edits the same bytes. The event and the suggestion
|
|
142
|
+
* live in different databases, so the commit writes the event first and
|
|
143
|
+
* settles the card second. A failure between the two leaves the card still
|
|
144
|
+
* asking with the event in the calendar, and accepting again repairs it: the
|
|
145
|
+
* event write lands on the same resource and the settle follows.
|
|
144
146
|
*
|
|
145
147
|
* Accepting a `Cancel` suggestion writes `STATUS:CANCELLED` through that same
|
|
146
148
|
* path, but only onto a resource that is already there. Nothing withdraws an
|
|
@@ -157,7 +159,7 @@ export interface AcceptedCalendarSuggestion {
|
|
|
157
159
|
* leaving the calendar showing the meeting twice.
|
|
158
160
|
*/
|
|
159
161
|
export const acceptCalendarSuggestion = async (
|
|
160
|
-
|
|
162
|
+
store: ICalendarStore,
|
|
161
163
|
input: AcceptCalendarSuggestionInput,
|
|
162
164
|
): Promise<CalendarResult<AcceptedCalendarSuggestion>> => {
|
|
163
165
|
const icalData = await buildAcceptedCalendar(
|
|
@@ -167,63 +169,82 @@ export const acceptCalendarSuggestion = async (
|
|
|
167
169
|
);
|
|
168
170
|
if (!icalData.ok) return icalData;
|
|
169
171
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
input.
|
|
187
|
-
|
|
172
|
+
const existing = await store.repos.calendarObject.findByUid(
|
|
173
|
+
input.calendarId,
|
|
174
|
+
input.suggestion.icalUid,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// A cancellation cancels something. With no resource carrying this UID
|
|
178
|
+
// there is nothing in the calendar to withdraw, and writing one would
|
|
179
|
+
// invent a meeting the user never had purely to mark it cancelled. Clear
|
|
180
|
+
// the card and leave the calendar untouched.
|
|
181
|
+
if (
|
|
182
|
+
input.suggestion.method === CalendarInviteMethod.Cancel &&
|
|
183
|
+
existing === null
|
|
184
|
+
) {
|
|
185
|
+
await store.commit([
|
|
186
|
+
{
|
|
187
|
+
kind: "SettleSuggestion",
|
|
188
|
+
accountConfigId: input.accountConfigId,
|
|
189
|
+
suggestionId: input.suggestion.suggestionId,
|
|
190
|
+
settle: {
|
|
188
191
|
state: CalendarSuggestionState.Dismissed,
|
|
189
192
|
acceptedCalendarObjectId: "",
|
|
190
193
|
},
|
|
191
|
-
);
|
|
192
|
-
return {
|
|
193
|
-
ok: true,
|
|
194
|
-
value: {
|
|
195
|
-
suggestion: cleared,
|
|
196
|
-
outcome: "NothingToCancel",
|
|
197
|
-
object: null,
|
|
198
|
-
},
|
|
199
|
-
};
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const written = await putCalendarObject(unitOfWork, {
|
|
203
|
-
accountConfigId: input.accountConfigId,
|
|
204
|
-
calendarId: input.calendarId,
|
|
205
|
-
resourceName:
|
|
206
|
-
existing?.resourceName ?? `${input.suggestion.suggestionId}.ics`,
|
|
207
|
-
icalData: icalData.value,
|
|
208
|
-
});
|
|
209
|
-
if (!written.ok) return written;
|
|
210
|
-
|
|
211
|
-
const settled = await repos.calendarSuggestion.settle(
|
|
212
|
-
input.accountConfigId,
|
|
213
|
-
input.suggestion.suggestionId,
|
|
214
|
-
{
|
|
215
|
-
state: stateOf[input.answer],
|
|
216
|
-
acceptedCalendarObjectId: written.value.calendarObjectId,
|
|
217
194
|
},
|
|
218
|
-
);
|
|
219
|
-
|
|
195
|
+
]);
|
|
220
196
|
return {
|
|
221
197
|
ok: true,
|
|
222
198
|
value: {
|
|
223
|
-
suggestion:
|
|
224
|
-
|
|
225
|
-
|
|
199
|
+
suggestion: await store.repos.calendarSuggestion.get(
|
|
200
|
+
input.accountConfigId,
|
|
201
|
+
input.suggestion.suggestionId,
|
|
202
|
+
),
|
|
203
|
+
outcome: "NothingToCancel",
|
|
204
|
+
object: null,
|
|
226
205
|
},
|
|
227
206
|
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const collection = await store.repos.calendarCollection.get(
|
|
210
|
+
input.accountConfigId,
|
|
211
|
+
input.calendarId,
|
|
212
|
+
);
|
|
213
|
+
const write = await prepareCalendarObject(collection, {
|
|
214
|
+
accountConfigId: input.accountConfigId,
|
|
215
|
+
calendarId: input.calendarId,
|
|
216
|
+
resourceName:
|
|
217
|
+
existing?.resourceName ?? `${input.suggestion.suggestionId}.ics`,
|
|
218
|
+
icalData: icalData.value,
|
|
228
219
|
});
|
|
220
|
+
if (!write.ok) return write;
|
|
221
|
+
|
|
222
|
+
const calendarObjectId = deriveCalendarObjectId(
|
|
223
|
+
write.value.object.calendarId,
|
|
224
|
+
write.value.object.resourceName,
|
|
225
|
+
);
|
|
226
|
+
await store.commit([
|
|
227
|
+
write.value,
|
|
228
|
+
{
|
|
229
|
+
kind: "SettleSuggestion",
|
|
230
|
+
accountConfigId: input.accountConfigId,
|
|
231
|
+
suggestionId: input.suggestion.suggestionId,
|
|
232
|
+
settle: {
|
|
233
|
+
state: stateOf[input.answer],
|
|
234
|
+
acceptedCalendarObjectId: calendarObjectId,
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
]);
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
ok: true,
|
|
241
|
+
value: {
|
|
242
|
+
suggestion: await store.repos.calendarSuggestion.get(
|
|
243
|
+
input.accountConfigId,
|
|
244
|
+
input.suggestion.suggestionId,
|
|
245
|
+
),
|
|
246
|
+
outcome: "Written",
|
|
247
|
+
object: await readWrittenObject(store, write.value),
|
|
248
|
+
},
|
|
249
|
+
};
|
|
229
250
|
};
|
package/src/index.ts
CHANGED
|
@@ -56,9 +56,12 @@ export {
|
|
|
56
56
|
export {
|
|
57
57
|
DEFAULT_CALENDAR_URL_SEGMENT,
|
|
58
58
|
deleteCalendarObject,
|
|
59
|
+
deleteCalendarObjectWrite,
|
|
59
60
|
type PutCalendarObjectInput,
|
|
61
|
+
prepareCalendarObject,
|
|
60
62
|
provisionDefaultCalendar,
|
|
61
63
|
putCalendarObject,
|
|
64
|
+
readWrittenObject,
|
|
62
65
|
} from "./put.js";
|
|
63
66
|
export {
|
|
64
67
|
answersOvertakenBy,
|
package/src/memory-store.ts
CHANGED
|
@@ -4,14 +4,16 @@ import type {
|
|
|
4
4
|
CalendarFeedTokenItem,
|
|
5
5
|
CalendarObjectItem,
|
|
6
6
|
CalendarOccurrenceInput,
|
|
7
|
+
CalendarRepositories,
|
|
7
8
|
CalendarSuggestionItem,
|
|
9
|
+
CalendarWrite,
|
|
8
10
|
CreateCalendarCollectionInput,
|
|
9
11
|
ICalendarCollectionRepository,
|
|
10
12
|
ICalendarEventIndexRepository,
|
|
11
13
|
ICalendarFeedTokenRepository,
|
|
12
14
|
ICalendarObjectRepository,
|
|
15
|
+
ICalendarStore,
|
|
13
16
|
ICalendarSuggestionRepository,
|
|
14
|
-
ICalendarUnitOfWork,
|
|
15
17
|
PutCalendarFeedTokenInput,
|
|
16
18
|
PutCalendarObjectInput as PutCalendarObjectRow,
|
|
17
19
|
PutCalendarSuggestionInput,
|
|
@@ -35,13 +37,11 @@ import { CalendarSuggestionState } from "@remit/domain-enums";
|
|
|
35
37
|
export class MissingRow extends NotFoundError {}
|
|
36
38
|
|
|
37
39
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* calls what it should, in the order it should; that the three writes stand or
|
|
41
|
-
* fall together is proven against a real transaction, in
|
|
40
|
+
* The calendar store over plain maps. A commit applies its writes in order;
|
|
41
|
+
* that they stand or fall together on a real database is proven in
|
|
42
42
|
* drizzle-service's calendar-put.sqlite.test.ts.
|
|
43
43
|
*/
|
|
44
|
-
export class MemoryCalendarStore implements
|
|
44
|
+
export class MemoryCalendarStore implements ICalendarStore {
|
|
45
45
|
readonly collections = new Map<string, CalendarCollectionItem>();
|
|
46
46
|
readonly objects = new Map<string, CalendarObjectItem>();
|
|
47
47
|
readonly occurrences = new Map<string, CalendarEventIndexItem[]>();
|
|
@@ -273,11 +273,17 @@ export class MemoryCalendarStore implements ICalendarUnitOfWork {
|
|
|
273
273
|
listByState: async (
|
|
274
274
|
accountConfigId: string,
|
|
275
275
|
state: CalendarSuggestionItem["state"],
|
|
276
|
+
options: {
|
|
277
|
+
endsAfter: string;
|
|
278
|
+
limit?: number;
|
|
279
|
+
continuationToken?: string;
|
|
280
|
+
},
|
|
276
281
|
) => ({
|
|
277
282
|
items: [...this.suggestions.values()].filter(
|
|
278
283
|
(suggestion) =>
|
|
279
284
|
suggestion.accountConfigId === accountConfigId &&
|
|
280
|
-
suggestion.state === state
|
|
285
|
+
suggestion.state === state &&
|
|
286
|
+
suggestion.endsAtUtc >= options.endsAfter,
|
|
281
287
|
),
|
|
282
288
|
continuationToken: undefined,
|
|
283
289
|
}),
|
|
@@ -378,21 +384,102 @@ export class MemoryCalendarStore implements ICalendarUnitOfWork {
|
|
|
378
384
|
return this.suggestionRepo;
|
|
379
385
|
}
|
|
380
386
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
calendarCollection: ICalendarCollectionRepository;
|
|
384
|
-
calendarObject: ICalendarObjectRepository;
|
|
385
|
-
calendarEventIndex: ICalendarEventIndexRepository;
|
|
386
|
-
calendarSuggestion: ICalendarSuggestionRepository;
|
|
387
|
-
calendarFeedToken: ICalendarFeedTokenRepository;
|
|
388
|
-
}) => Promise<T>,
|
|
389
|
-
): Promise<T> {
|
|
390
|
-
return fn({
|
|
387
|
+
get repos(): CalendarRepositories {
|
|
388
|
+
return {
|
|
391
389
|
calendarCollection: this.collectionRepo,
|
|
392
390
|
calendarObject: this.objectRepo,
|
|
393
391
|
calendarEventIndex: this.eventIndexRepo,
|
|
394
392
|
calendarSuggestion: this.suggestionRepo,
|
|
395
393
|
calendarFeedToken: this.feedTokenRepo,
|
|
396
|
-
}
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async commit(writes: readonly CalendarWrite[]): Promise<void> {
|
|
398
|
+
for (const write of writes) await this.apply(write);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private async apply(write: CalendarWrite): Promise<void> {
|
|
402
|
+
switch (write.kind) {
|
|
403
|
+
case "PutObject": {
|
|
404
|
+
const { calendarId } = write.object;
|
|
405
|
+
const syncSequence = await this.collectionRepo.bumpSyncSequence(
|
|
406
|
+
write.accountConfigId,
|
|
407
|
+
calendarId,
|
|
408
|
+
);
|
|
409
|
+
const object = await this.objectRepo.put({
|
|
410
|
+
...write.object,
|
|
411
|
+
syncSequence,
|
|
412
|
+
});
|
|
413
|
+
await this.eventIndexRepo.replaceForObject(
|
|
414
|
+
calendarId,
|
|
415
|
+
object.calendarObjectId,
|
|
416
|
+
write.occurrences,
|
|
417
|
+
);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
case "ReprojectObject": {
|
|
421
|
+
const { calendarId, resourceName } = write.object;
|
|
422
|
+
const stored = await this.objectRepo.findByResourceName(
|
|
423
|
+
calendarId,
|
|
424
|
+
resourceName,
|
|
425
|
+
);
|
|
426
|
+
if (stored === null || stored.etag !== write.readEtag) return;
|
|
427
|
+
await this.apply({
|
|
428
|
+
kind: "PutObject",
|
|
429
|
+
accountConfigId: write.accountConfigId,
|
|
430
|
+
object: write.object,
|
|
431
|
+
occurrences: write.occurrences,
|
|
432
|
+
});
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
case "DeleteObject":
|
|
436
|
+
await this.eventIndexRepo.deleteForObject(
|
|
437
|
+
write.calendarId,
|
|
438
|
+
write.calendarObjectId,
|
|
439
|
+
);
|
|
440
|
+
await this.objectRepo.delete(write.calendarId, write.calendarObjectId);
|
|
441
|
+
await this.collectionRepo.bumpSyncSequence(
|
|
442
|
+
write.accountConfigId,
|
|
443
|
+
write.calendarId,
|
|
444
|
+
);
|
|
445
|
+
return;
|
|
446
|
+
case "SettleSuggestion":
|
|
447
|
+
await this.suggestionRepo.settle(
|
|
448
|
+
write.accountConfigId,
|
|
449
|
+
write.suggestionId,
|
|
450
|
+
write.settle,
|
|
451
|
+
);
|
|
452
|
+
return;
|
|
453
|
+
case "UpdateCollection":
|
|
454
|
+
await this.collectionRepo.update(
|
|
455
|
+
write.accountConfigId,
|
|
456
|
+
write.calendarId,
|
|
457
|
+
write.patch,
|
|
458
|
+
);
|
|
459
|
+
return;
|
|
460
|
+
case "DeleteCollection": {
|
|
461
|
+
for (const object of await this.objectRepo.listByCalendar(
|
|
462
|
+
write.calendarId,
|
|
463
|
+
)) {
|
|
464
|
+
await this.eventIndexRepo.deleteForObject(
|
|
465
|
+
write.calendarId,
|
|
466
|
+
object.calendarObjectId,
|
|
467
|
+
);
|
|
468
|
+
await this.objectRepo.delete(
|
|
469
|
+
write.calendarId,
|
|
470
|
+
object.calendarObjectId,
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
await this.feedTokenRepo.delete(
|
|
474
|
+
write.accountConfigId,
|
|
475
|
+
write.calendarId,
|
|
476
|
+
);
|
|
477
|
+
await this.collectionRepo.delete(
|
|
478
|
+
write.accountConfigId,
|
|
479
|
+
write.calendarId,
|
|
480
|
+
);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
397
484
|
}
|
|
398
485
|
}
|
package/src/put.test.ts
CHANGED
|
@@ -275,14 +275,12 @@ describe("putCalendarObject", () => {
|
|
|
275
275
|
|
|
276
276
|
it("projects the event in the collection's timezone", async () => {
|
|
277
277
|
const store = new MemoryCalendarStore();
|
|
278
|
-
const collection = await store.
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
}),
|
|
285
|
-
);
|
|
278
|
+
const collection = await store.repos.calendarCollection.create({
|
|
279
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
280
|
+
urlSegment: "berlin",
|
|
281
|
+
displayName: "Berlin",
|
|
282
|
+
timezone: "Europe/Berlin",
|
|
283
|
+
});
|
|
286
284
|
|
|
287
285
|
const result = await putCalendarObject(store, {
|
|
288
286
|
accountConfigId: ACCOUNT_CONFIG_ID,
|
package/src/put.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
CalendarCollectionItem,
|
|
3
3
|
CalendarObjectItem,
|
|
4
|
-
|
|
4
|
+
DeleteCalendarObjectWrite,
|
|
5
|
+
ICalendarStore,
|
|
6
|
+
PutCalendarObjectWrite,
|
|
5
7
|
} from "@remit/data-ports";
|
|
8
|
+
import { deriveCalendarObjectId } from "@remit/data-ports/id";
|
|
6
9
|
import { CalendarSource } from "@remit/domain-enums";
|
|
7
10
|
import type { CalendarResult } from "./errors.js";
|
|
8
11
|
import { computeEtag } from "./etag.js";
|
|
@@ -23,83 +26,92 @@ export interface PutCalendarObjectInput {
|
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* that
|
|
29
|
+
* Turns one calendar resource into the write that stores it, read in the
|
|
30
|
+
* collection's timezone. Every caller that stores an event goes through here —
|
|
31
|
+
* a REST handler, a DAV PUT, an accepted suggestion, a feed refresh — because
|
|
32
|
+
* the object row, its occurrence rows and the collection's sequence bump are
|
|
33
|
+
* one fact, and a caller that writes any of them alone leaves the store
|
|
34
|
+
* describing a calendar that does not exist.
|
|
31
35
|
*
|
|
32
36
|
* The bytes are stored exactly as given: parsing is for validating and
|
|
33
37
|
* projecting them, never for rewriting them. Refusing the resource is a
|
|
34
38
|
* returned value — the input is client-supplied, so malformed iCalendar is an
|
|
35
39
|
* outcome the caller renders, not a fault.
|
|
36
40
|
*/
|
|
37
|
-
export const
|
|
38
|
-
|
|
41
|
+
export const prepareCalendarObject = async (
|
|
42
|
+
collection: Pick<CalendarCollectionItem, "timezone">,
|
|
39
43
|
input: PutCalendarObjectInput,
|
|
40
|
-
): Promise<CalendarResult<
|
|
44
|
+
): Promise<CalendarResult<PutCalendarObjectWrite>> => {
|
|
41
45
|
const parsed = await parseCalendar(input.icalData);
|
|
42
46
|
if (!parsed.ok) return parsed;
|
|
43
47
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
input.accountConfigId,
|
|
47
|
-
input.calendarId,
|
|
48
|
-
);
|
|
49
|
-
|
|
50
|
-
const projection = projectCalendar(parsed.value, collection.timezone);
|
|
51
|
-
if (!projection.ok) return projection;
|
|
48
|
+
const projection = projectCalendar(parsed.value, collection.timezone);
|
|
49
|
+
if (!projection.ok) return projection;
|
|
52
50
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
51
|
+
const expansion = expandCalendar(parsed.value, collection.timezone);
|
|
52
|
+
return {
|
|
53
|
+
ok: true,
|
|
54
|
+
value: {
|
|
55
|
+
kind: "PutObject",
|
|
56
|
+
accountConfigId: input.accountConfigId,
|
|
57
|
+
object: {
|
|
58
|
+
...projection.value,
|
|
59
|
+
calendarId: input.calendarId,
|
|
60
|
+
resourceName: input.resourceName,
|
|
61
|
+
icalData: input.icalData,
|
|
62
|
+
etag: computeEtag(input.icalData),
|
|
63
|
+
expandedThrough: expansion.expandedThrough,
|
|
64
|
+
},
|
|
65
|
+
occurrences: expansion.occurrences,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
};
|
|
68
69
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
/** The resource a committed put wrote, under the id its name derives to. */
|
|
71
|
+
export const readWrittenObject = (
|
|
72
|
+
store: ICalendarStore,
|
|
73
|
+
write: PutCalendarObjectWrite,
|
|
74
|
+
): Promise<CalendarObjectItem> =>
|
|
75
|
+
store.repos.calendarObject.get(
|
|
76
|
+
write.object.calendarId,
|
|
77
|
+
deriveCalendarObjectId(write.object.calendarId, write.object.resourceName),
|
|
78
|
+
);
|
|
74
79
|
|
|
75
|
-
|
|
76
|
-
|
|
80
|
+
/** Writes one calendar resource into its collection. */
|
|
81
|
+
export const putCalendarObject = async (
|
|
82
|
+
store: ICalendarStore,
|
|
83
|
+
input: PutCalendarObjectInput,
|
|
84
|
+
): Promise<CalendarResult<CalendarObjectItem>> => {
|
|
85
|
+
const collection = await store.repos.calendarCollection.get(
|
|
86
|
+
input.accountConfigId,
|
|
87
|
+
input.calendarId,
|
|
88
|
+
);
|
|
89
|
+
const write = await prepareCalendarObject(collection, input);
|
|
90
|
+
if (!write.ok) return write;
|
|
91
|
+
await store.commit([write.value]);
|
|
92
|
+
return { ok: true, value: await readWrittenObject(store, write.value) };
|
|
77
93
|
};
|
|
78
94
|
|
|
79
95
|
/**
|
|
80
|
-
* Removes a resource and the occurrences it produced
|
|
81
|
-
*
|
|
82
|
-
*
|
|
96
|
+
* Removes a resource and the occurrences it produced — an object dropped
|
|
97
|
+
* without its occurrence rows leaves a calendar showing events no resource
|
|
98
|
+
* backs, which nothing later can attribute or clean up.
|
|
83
99
|
*/
|
|
100
|
+
export const deleteCalendarObjectWrite = (input: {
|
|
101
|
+
accountConfigId: string;
|
|
102
|
+
calendarId: string;
|
|
103
|
+
calendarObjectId: string;
|
|
104
|
+
}): DeleteCalendarObjectWrite => ({ kind: "DeleteObject", ...input });
|
|
105
|
+
|
|
84
106
|
export const deleteCalendarObject = async (
|
|
85
|
-
|
|
107
|
+
store: ICalendarStore,
|
|
86
108
|
input: {
|
|
87
109
|
accountConfigId: string;
|
|
88
110
|
calendarId: string;
|
|
89
111
|
calendarObjectId: string;
|
|
90
112
|
},
|
|
91
113
|
): Promise<void> => {
|
|
92
|
-
await
|
|
93
|
-
await repos.calendarEventIndex.deleteForObject(
|
|
94
|
-
input.calendarId,
|
|
95
|
-
input.calendarObjectId,
|
|
96
|
-
);
|
|
97
|
-
await repos.calendarObject.delete(input.calendarId, input.calendarObjectId);
|
|
98
|
-
await repos.calendarCollection.bumpSyncSequence(
|
|
99
|
-
input.accountConfigId,
|
|
100
|
-
input.calendarId,
|
|
101
|
-
);
|
|
102
|
-
});
|
|
114
|
+
await store.commit([deleteCalendarObjectWrite(input)]);
|
|
103
115
|
};
|
|
104
116
|
|
|
105
117
|
/**
|
|
@@ -109,15 +121,13 @@ export const deleteCalendarObject = async (
|
|
|
109
121
|
* the collection the first one made rather than a rival copy of it.
|
|
110
122
|
*/
|
|
111
123
|
export const provisionDefaultCalendar = (
|
|
112
|
-
|
|
124
|
+
store: ICalendarStore,
|
|
113
125
|
accountConfigId: string,
|
|
114
126
|
displayName = "Calendar",
|
|
115
127
|
): Promise<CalendarCollectionItem> =>
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}),
|
|
123
|
-
);
|
|
128
|
+
store.repos.calendarCollection.create({
|
|
129
|
+
accountConfigId,
|
|
130
|
+
urlSegment: DEFAULT_CALENDAR_URL_SEGMENT,
|
|
131
|
+
displayName,
|
|
132
|
+
source: CalendarSource.Default,
|
|
133
|
+
});
|
package/src/reopen.test.ts
CHANGED
|
@@ -124,13 +124,11 @@ const answerOf = async (
|
|
|
124
124
|
};
|
|
125
125
|
|
|
126
126
|
const secondCalendar = (store: MemoryCalendarStore) =>
|
|
127
|
-
store.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}),
|
|
133
|
-
);
|
|
127
|
+
store.repos.calendarCollection.create({
|
|
128
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
129
|
+
urlSegment: "personal",
|
|
130
|
+
displayName: "Personal",
|
|
131
|
+
});
|
|
134
132
|
|
|
135
133
|
describe("reopenCalendarSuggestion", () => {
|
|
136
134
|
for (const answer of [
|
package/src/reopen.ts
CHANGED
|
@@ -2,10 +2,10 @@ import type { CalendarAnswerOvertaken as CalendarAnswerOvertakenValue } from "@r
|
|
|
2
2
|
import type {
|
|
3
3
|
CalendarCollectionItem,
|
|
4
4
|
CalendarObjectItem,
|
|
5
|
+
CalendarRepositories,
|
|
5
6
|
CalendarSuggestionItem,
|
|
6
|
-
|
|
7
|
+
ICalendarStore,
|
|
7
8
|
ICalendarSuggestionRepository,
|
|
8
|
-
ICalendarUnitOfWork,
|
|
9
9
|
} from "@remit/data-ports";
|
|
10
10
|
import {
|
|
11
11
|
CalendarAnswerOvertaken,
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
CalendarSuggestionState,
|
|
14
14
|
} from "@remit/domain-enums";
|
|
15
15
|
import { type CalendarResult, calendarFailure } from "./errors.js";
|
|
16
|
-
import {
|
|
16
|
+
import { deleteCalendarObjectWrite } from "./put.js";
|
|
17
17
|
import { followingUidPrefix } from "./scope.js";
|
|
18
18
|
|
|
19
19
|
export interface ReopenCalendarSuggestionInput {
|
|
@@ -93,7 +93,7 @@ export const answersOvertakenBy = async (
|
|
|
93
93
|
};
|
|
94
94
|
|
|
95
95
|
const locateAnswered = async (
|
|
96
|
-
repos:
|
|
96
|
+
repos: CalendarRepositories,
|
|
97
97
|
collections: CalendarCollectionItem[],
|
|
98
98
|
calendarObjectId: string,
|
|
99
99
|
): Promise<
|
|
@@ -110,7 +110,7 @@ const locateAnswered = async (
|
|
|
110
110
|
};
|
|
111
111
|
|
|
112
112
|
const partsOf = async (
|
|
113
|
-
repos:
|
|
113
|
+
repos: CalendarRepositories,
|
|
114
114
|
suggestion: CalendarSuggestionItem,
|
|
115
115
|
collection: CalendarCollectionItem,
|
|
116
116
|
answered: CalendarObjectItem,
|
|
@@ -123,7 +123,7 @@ const partsOf = async (
|
|
|
123
123
|
];
|
|
124
124
|
|
|
125
125
|
export const reopenCalendarSuggestion = async (
|
|
126
|
-
|
|
126
|
+
store: ICalendarStore,
|
|
127
127
|
input: ReopenCalendarSuggestionInput,
|
|
128
128
|
): Promise<CalendarResult<CalendarSuggestionItem>> => {
|
|
129
129
|
const { accountConfigId, suggestion } = input;
|
|
@@ -143,48 +143,57 @@ export const reopenCalendarSuggestion = async (
|
|
|
143
143
|
);
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
suggestion,
|
|
150
|
-
]),
|
|
146
|
+
const { repos } = store;
|
|
147
|
+
const holders = othersIn(
|
|
148
|
+
await holdersByObject(repos.calendarSuggestion, accountConfigId, [
|
|
151
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.",
|
|
152
157
|
);
|
|
153
|
-
|
|
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
|
-
}
|
|
158
|
+
}
|
|
159
159
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
repos,
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
located.object,
|
|
174
|
-
)) {
|
|
175
|
-
await deleteCalendarObject(unitOfWork, {
|
|
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
|
+
const removals = located
|
|
169
|
+
? (
|
|
170
|
+
await partsOf(repos, suggestion, located.collection, located.object)
|
|
171
|
+
).map((object) =>
|
|
172
|
+
deleteCalendarObjectWrite({
|
|
176
173
|
accountConfigId,
|
|
177
174
|
calendarId: located.collection.calendarId,
|
|
178
175
|
calendarObjectId: object.calendarObjectId,
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
|
|
176
|
+
}),
|
|
177
|
+
)
|
|
178
|
+
: [];
|
|
182
179
|
|
|
183
|
-
|
|
180
|
+
await store.commit([
|
|
181
|
+
...removals,
|
|
182
|
+
{
|
|
183
|
+
kind: "SettleSuggestion",
|
|
184
|
+
accountConfigId,
|
|
185
|
+
suggestionId: suggestion.suggestionId,
|
|
186
|
+
settle: {
|
|
187
|
+
state: CalendarSuggestionState.Pending,
|
|
188
|
+
acceptedCalendarObjectId: "",
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
]);
|
|
192
|
+
return {
|
|
193
|
+
ok: true,
|
|
194
|
+
value: await repos.calendarSuggestion.get(
|
|
184
195
|
accountConfigId,
|
|
185
196
|
suggestion.suggestionId,
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
return { ok: true, value: settled };
|
|
189
|
-
});
|
|
197
|
+
),
|
|
198
|
+
};
|
|
190
199
|
};
|
package/src/subscribe.test.ts
CHANGED
|
@@ -39,16 +39,14 @@ const subscribed = async (): Promise<{
|
|
|
39
39
|
collection: CalendarCollectionItem;
|
|
40
40
|
}> => {
|
|
41
41
|
const store = new MemoryCalendarStore();
|
|
42
|
-
const collection = await store.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}),
|
|
51
|
-
);
|
|
42
|
+
const collection = await store.repos.calendarCollection.create({
|
|
43
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
44
|
+
urlSegment: "harbour-rota",
|
|
45
|
+
displayName: "Harbour rota",
|
|
46
|
+
source: CalendarSource.Subscribed,
|
|
47
|
+
subscriptionUrl: SECRET_URL,
|
|
48
|
+
subscriptionEnabled: true,
|
|
49
|
+
});
|
|
52
50
|
return { store, collection };
|
|
53
51
|
};
|
|
54
52
|
|
|
@@ -56,12 +54,11 @@ const drawn = async (
|
|
|
56
54
|
store: MemoryCalendarStore,
|
|
57
55
|
collection: CalendarCollectionItem,
|
|
58
56
|
) => {
|
|
59
|
-
const current = await store.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const instances = await store.transaction((repos) =>
|
|
63
|
-
listCalendarInstances(repos, [current], WINDOW),
|
|
57
|
+
const current = await store.repos.calendarCollection.get(
|
|
58
|
+
ACCOUNT_CONFIG_ID,
|
|
59
|
+
collection.calendarId,
|
|
64
60
|
);
|
|
61
|
+
const instances = await listCalendarInstances(store.repos, [current], WINDOW);
|
|
65
62
|
return instances.map((instance) => ({
|
|
66
63
|
summary: instance.summary,
|
|
67
64
|
startAt: instance.startAt,
|
|
@@ -352,8 +349,9 @@ describe("writeCalendarFeed", () => {
|
|
|
352
349
|
},
|
|
353
350
|
]);
|
|
354
351
|
|
|
355
|
-
const stored = await store.
|
|
356
|
-
|
|
352
|
+
const stored = await store.repos.calendarCollection.get(
|
|
353
|
+
ACCOUNT_CONFIG_ID,
|
|
354
|
+
collection.calendarId,
|
|
357
355
|
);
|
|
358
356
|
assert.equal(stored.subscriptionCheckedAt, 5_000);
|
|
359
357
|
assert.equal(stored.subscriptionFetchedAt, 5_000);
|
|
@@ -364,8 +362,9 @@ describe("writeCalendarFeed", () => {
|
|
|
364
362
|
const { store, collection } = await subscribed();
|
|
365
363
|
await writeFeed(store, collection, GOOGLE_FEED, 5_000);
|
|
366
364
|
const sequenceBefore = (
|
|
367
|
-
await store.
|
|
368
|
-
|
|
365
|
+
await store.repos.calendarCollection.get(
|
|
366
|
+
ACCOUNT_CONFIG_ID,
|
|
367
|
+
collection.calendarId,
|
|
369
368
|
)
|
|
370
369
|
).syncSequence;
|
|
371
370
|
|
|
@@ -387,8 +386,9 @@ describe("writeCalendarFeed", () => {
|
|
|
387
386
|
);
|
|
388
387
|
assert.equal(summaries.includes("Tide table review"), false);
|
|
389
388
|
assert.equal(summaries.length, 6);
|
|
390
|
-
const after = await store.
|
|
391
|
-
|
|
389
|
+
const after = await store.repos.calendarCollection.get(
|
|
390
|
+
ACCOUNT_CONFIG_ID,
|
|
391
|
+
collection.calendarId,
|
|
392
392
|
);
|
|
393
393
|
assert.equal(after.syncSequence, sequenceBefore + 1);
|
|
394
394
|
});
|
|
@@ -396,8 +396,9 @@ describe("writeCalendarFeed", () => {
|
|
|
396
396
|
it("writes nothing when a second fetch differs only in its export stamps", async () => {
|
|
397
397
|
const { store, collection } = await subscribed();
|
|
398
398
|
await writeFeed(store, collection, GOOGLE_FEED, 5_000);
|
|
399
|
-
const before = await store.
|
|
400
|
-
|
|
399
|
+
const before = await store.repos.calendarCollection.get(
|
|
400
|
+
ACCOUNT_CONFIG_ID,
|
|
401
|
+
collection.calendarId,
|
|
401
402
|
);
|
|
402
403
|
|
|
403
404
|
const restamped = GOOGLE_FEED.replaceAll(
|
|
@@ -413,8 +414,9 @@ describe("writeCalendarFeed", () => {
|
|
|
413
414
|
removed: 0,
|
|
414
415
|
skipped: 0,
|
|
415
416
|
});
|
|
416
|
-
const after = await store.
|
|
417
|
-
|
|
417
|
+
const after = await store.repos.calendarCollection.get(
|
|
418
|
+
ACCOUNT_CONFIG_ID,
|
|
419
|
+
collection.calendarId,
|
|
418
420
|
);
|
|
419
421
|
assert.equal(after.syncSequence, before.syncSequence);
|
|
420
422
|
assert.equal(after.subscriptionFetchedAt, 9_000);
|
|
@@ -422,12 +424,10 @@ describe("writeCalendarFeed", () => {
|
|
|
422
424
|
|
|
423
425
|
it("writes nothing for a subscription paused while its feed was read", async () => {
|
|
424
426
|
const { store, collection } = await subscribed();
|
|
425
|
-
await store.
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
{ subscriptionEnabled: false },
|
|
430
|
-
),
|
|
427
|
+
await store.repos.calendarCollection.update(
|
|
428
|
+
ACCOUNT_CONFIG_ID,
|
|
429
|
+
collection.calendarId,
|
|
430
|
+
{ subscriptionEnabled: false },
|
|
431
431
|
);
|
|
432
432
|
|
|
433
433
|
const write = await writeFeed(store, collection, GOOGLE_FEED, 5_000);
|
|
@@ -440,8 +440,9 @@ describe("writeCalendarFeed", () => {
|
|
|
440
440
|
skipped: 0,
|
|
441
441
|
});
|
|
442
442
|
assert.deepEqual(await drawn(store, collection), []);
|
|
443
|
-
const stored = await store.
|
|
444
|
-
|
|
443
|
+
const stored = await store.repos.calendarCollection.get(
|
|
444
|
+
ACCOUNT_CONFIG_ID,
|
|
445
|
+
collection.calendarId,
|
|
445
446
|
);
|
|
446
447
|
assert.equal(stored.subscriptionFetchedAt, 0);
|
|
447
448
|
});
|
|
@@ -479,8 +480,9 @@ describe("writeCalendarFeed", () => {
|
|
|
479
480
|
|
|
480
481
|
assert.equal(write.written, 1);
|
|
481
482
|
assert.equal(write.skipped, 2);
|
|
482
|
-
const stored = await store.
|
|
483
|
-
|
|
483
|
+
const stored = await store.repos.calendarCollection.get(
|
|
484
|
+
ACCOUNT_CONFIG_ID,
|
|
485
|
+
collection.calendarId,
|
|
484
486
|
);
|
|
485
487
|
assert.equal(
|
|
486
488
|
stored.subscriptionError,
|
|
@@ -509,8 +511,9 @@ describe("refreshCalendarSubscription", () => {
|
|
|
509
511
|
ok: false,
|
|
510
512
|
reason: "the feed answered HTTP 503",
|
|
511
513
|
});
|
|
512
|
-
const stored = await store.
|
|
513
|
-
|
|
514
|
+
const stored = await store.repos.calendarCollection.get(
|
|
515
|
+
ACCOUNT_CONFIG_ID,
|
|
516
|
+
collection.calendarId,
|
|
514
517
|
);
|
|
515
518
|
assert.equal(stored.subscriptionCheckedAt, 9_000);
|
|
516
519
|
assert.equal(stored.subscriptionFetchedAt, 5_000);
|
|
@@ -557,8 +560,9 @@ describe("refreshCalendarSubscription", () => {
|
|
|
557
560
|
|
|
558
561
|
assert.deepEqual(asked, [SECRET_URL]);
|
|
559
562
|
assert.ok(refreshed.ok);
|
|
560
|
-
const stored = await store.
|
|
561
|
-
|
|
563
|
+
const stored = await store.repos.calendarCollection.get(
|
|
564
|
+
ACCOUNT_CONFIG_ID,
|
|
565
|
+
collection.calendarId,
|
|
562
566
|
);
|
|
563
567
|
assert.equal(stored.subscriptionError, "");
|
|
564
568
|
assert.equal(stored.subscriptionFetchedAt, 6_000);
|
package/src/subscribe.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import type {
|
|
3
3
|
CalendarCollectionItem,
|
|
4
|
-
|
|
4
|
+
CalendarWrite,
|
|
5
|
+
ICalendarStore,
|
|
5
6
|
} from "@remit/data-ports";
|
|
6
7
|
import ICAL from "ical.js";
|
|
7
8
|
import { type CalendarResult, calendarFailure } from "./errors.js";
|
|
8
9
|
import { readCalendarComponent, serializeCalendar } from "./parse.js";
|
|
9
|
-
import {
|
|
10
|
+
import { deleteCalendarObjectWrite, prepareCalendarObject } from "./put.js";
|
|
10
11
|
|
|
11
12
|
export const CALENDAR_SUBSCRIPTION_TIMEOUT_MS = 30_000;
|
|
12
13
|
|
|
@@ -271,99 +272,111 @@ const feedErrorOf = (write: CalendarFeedWrite): string =>
|
|
|
271
272
|
: `${write.skipped} ${write.skipped === 1 ? "event" : "events"} in the feed could not be stored`;
|
|
272
273
|
|
|
273
274
|
/**
|
|
274
|
-
* Makes a subscribed collection hold what the feed holds, in one
|
|
275
|
+
* Makes a subscribed collection hold what the feed holds, in one commit.
|
|
275
276
|
*
|
|
276
|
-
* Every resource goes through `
|
|
277
|
-
* subscribed event is stored, projected and expanded the way any other event
|
|
277
|
+
* Every resource goes through `prepareCalendarObject`, the one write path, so
|
|
278
|
+
* a subscribed event is stored, projected and expanded the way any other event
|
|
278
279
|
* is. A resource whose bytes have not changed is left alone, and one the feed
|
|
279
280
|
* no longer carries is removed. A resource the write path refuses is counted
|
|
280
281
|
* and recorded on the collection rather than failing the whole refresh.
|
|
282
|
+
*
|
|
283
|
+
* Repeating a refresh converges: a resource is keyed by its name, so a second
|
|
284
|
+
* run over the same feed finds every resource unchanged.
|
|
281
285
|
*/
|
|
282
286
|
export const writeCalendarFeed = async (
|
|
283
|
-
|
|
287
|
+
store: ICalendarStore,
|
|
284
288
|
target: CalendarSubscriptionTarget,
|
|
285
289
|
split: CalendarFeedSplit,
|
|
286
290
|
now: number,
|
|
287
|
-
): Promise<CalendarFeedWrite> =>
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
)
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
291
|
+
): Promise<CalendarFeedWrite> => {
|
|
292
|
+
const current = await store.repos.calendarCollection.get(
|
|
293
|
+
target.accountConfigId,
|
|
294
|
+
target.calendarId,
|
|
295
|
+
);
|
|
296
|
+
if (!current.subscriptionEnabled) {
|
|
297
|
+
return { paused: true, written: 0, unchanged: 0, removed: 0, skipped: 0 };
|
|
298
|
+
}
|
|
299
|
+
const stored = await store.repos.calendarObject.listByCalendar(
|
|
300
|
+
target.calendarId,
|
|
301
|
+
);
|
|
302
|
+
const storedByName = new Map(
|
|
303
|
+
stored.map((object) => [object.resourceName, object]),
|
|
304
|
+
);
|
|
305
|
+
const kept = new Set<string>();
|
|
306
|
+
const writes: CalendarWrite[] = [];
|
|
307
|
+
const write: CalendarFeedWrite = {
|
|
308
|
+
paused: false,
|
|
309
|
+
written: 0,
|
|
310
|
+
unchanged: 0,
|
|
311
|
+
removed: 0,
|
|
312
|
+
skipped: split.unreadable,
|
|
313
|
+
};
|
|
308
314
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
kept.add(resource.resourceName);
|
|
316
|
-
write.unchanged += 1;
|
|
317
|
-
continue;
|
|
318
|
-
}
|
|
319
|
-
const put = await putCalendarObject(unitOfWork, {
|
|
320
|
-
accountConfigId: target.accountConfigId,
|
|
321
|
-
calendarId: target.calendarId,
|
|
322
|
-
resourceName: resource.resourceName,
|
|
323
|
-
icalData: resource.icalData,
|
|
324
|
-
});
|
|
325
|
-
if (!put.ok) {
|
|
326
|
-
write.skipped += 1;
|
|
327
|
-
continue;
|
|
328
|
-
}
|
|
315
|
+
for (const resource of split.resources) {
|
|
316
|
+
const existing = storedByName.get(resource.resourceName);
|
|
317
|
+
if (
|
|
318
|
+
existing &&
|
|
319
|
+
comparable(existing.icalData) === comparable(resource.icalData)
|
|
320
|
+
) {
|
|
329
321
|
kept.add(resource.resourceName);
|
|
330
|
-
write.
|
|
322
|
+
write.unchanged += 1;
|
|
323
|
+
continue;
|
|
331
324
|
}
|
|
325
|
+
const put = await prepareCalendarObject(current, {
|
|
326
|
+
accountConfigId: target.accountConfigId,
|
|
327
|
+
calendarId: target.calendarId,
|
|
328
|
+
resourceName: resource.resourceName,
|
|
329
|
+
icalData: resource.icalData,
|
|
330
|
+
});
|
|
331
|
+
if (!put.ok) {
|
|
332
|
+
write.skipped += 1;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
writes.push(put.value);
|
|
336
|
+
kept.add(resource.resourceName);
|
|
337
|
+
write.written += 1;
|
|
338
|
+
}
|
|
332
339
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
340
|
+
for (const object of stored) {
|
|
341
|
+
if (kept.has(object.resourceName)) continue;
|
|
342
|
+
writes.push(
|
|
343
|
+
deleteCalendarObjectWrite({
|
|
336
344
|
accountConfigId: target.accountConfigId,
|
|
337
345
|
calendarId: target.calendarId,
|
|
338
346
|
calendarObjectId: object.calendarObjectId,
|
|
339
|
-
})
|
|
340
|
-
write.removed += 1;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
await repos.calendarCollection.update(
|
|
344
|
-
target.accountConfigId,
|
|
345
|
-
target.calendarId,
|
|
346
|
-
{
|
|
347
|
-
subscriptionCheckedAt: now,
|
|
348
|
-
subscriptionFetchedAt: now,
|
|
349
|
-
subscriptionError: truncate(feedErrorOf(write)),
|
|
350
|
-
},
|
|
347
|
+
}),
|
|
351
348
|
);
|
|
352
|
-
|
|
349
|
+
write.removed += 1;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
writes.push({
|
|
353
|
+
kind: "UpdateCollection",
|
|
354
|
+
accountConfigId: target.accountConfigId,
|
|
355
|
+
calendarId: target.calendarId,
|
|
356
|
+
patch: {
|
|
357
|
+
subscriptionCheckedAt: now,
|
|
358
|
+
subscriptionFetchedAt: now,
|
|
359
|
+
subscriptionError: truncate(feedErrorOf(write)),
|
|
360
|
+
},
|
|
353
361
|
});
|
|
362
|
+
await store.commit(writes);
|
|
363
|
+
return write;
|
|
364
|
+
};
|
|
354
365
|
|
|
355
366
|
/** Records a refresh that stored nothing, and why. Every stored event stays. */
|
|
356
367
|
export const recordCalendarFeedFailure = async (
|
|
357
|
-
|
|
368
|
+
store: ICalendarStore,
|
|
358
369
|
target: CalendarSubscriptionTarget,
|
|
359
370
|
reason: string,
|
|
360
371
|
now: number,
|
|
361
372
|
): Promise<void> => {
|
|
362
|
-
await
|
|
363
|
-
|
|
373
|
+
await store.repos.calendarCollection.update(
|
|
374
|
+
target.accountConfigId,
|
|
375
|
+
target.calendarId,
|
|
376
|
+
{
|
|
364
377
|
subscriptionCheckedAt: now,
|
|
365
378
|
subscriptionError: truncate(reason),
|
|
366
|
-
}
|
|
379
|
+
},
|
|
367
380
|
);
|
|
368
381
|
};
|
|
369
382
|
|
|
@@ -391,7 +404,7 @@ export const readCalendarFeed = async (
|
|
|
391
404
|
* records its reason on the collection and removes nothing.
|
|
392
405
|
*/
|
|
393
406
|
export const refreshCalendarSubscription = async (
|
|
394
|
-
|
|
407
|
+
store: ICalendarStore,
|
|
395
408
|
collection: CalendarCollectionItem,
|
|
396
409
|
now: number,
|
|
397
410
|
fetcher: CalendarFeedFetcher = fetch,
|
|
@@ -402,10 +415,10 @@ export const refreshCalendarSubscription = async (
|
|
|
402
415
|
};
|
|
403
416
|
const feed = await readCalendarFeed(collection.subscriptionUrl, fetcher);
|
|
404
417
|
if (!feed.ok) {
|
|
405
|
-
await recordCalendarFeedFailure(
|
|
418
|
+
await recordCalendarFeedFailure(store, target, feed.reason, now);
|
|
406
419
|
return feed;
|
|
407
420
|
}
|
|
408
|
-
const write = await writeCalendarFeed(
|
|
421
|
+
const write = await writeCalendarFeed(store, target, feed.value, now);
|
|
409
422
|
return { ok: true, value: write };
|
|
410
423
|
};
|
|
411
424
|
|
package/src/suggest.test.ts
CHANGED
|
@@ -71,9 +71,32 @@ describe("projectSuggestion", () => {
|
|
|
71
71
|
assert.equal(projected.value.organizer, "organizer@example.test");
|
|
72
72
|
assert.equal(projected.value.dtStart, "2026-09-01T10:00:00+02:00");
|
|
73
73
|
assert.equal(projected.value.dtEnd, "2026-09-01T11:00:00+02:00");
|
|
74
|
+
assert.equal(projected.value.endsAtUtc, "2026-09-01T09:00:00Z");
|
|
74
75
|
assert.equal(projected.value.allDay, false);
|
|
75
76
|
});
|
|
76
77
|
|
|
78
|
+
const withRule = (rule: string): string => invitation({ extra: [rule] });
|
|
79
|
+
|
|
80
|
+
it("ends a bounded series with its last occurrence", async () => {
|
|
81
|
+
const projected = await projectSuggestion(
|
|
82
|
+
withRule("RRULE:FREQ=WEEKLY;COUNT=3"),
|
|
83
|
+
"Europe/Amsterdam",
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
assert.ok(projected.ok);
|
|
87
|
+
assert.equal(projected.value.endsAtUtc, "2026-09-15T09:00:00Z");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("never ends an open-ended series", async () => {
|
|
91
|
+
const projected = await projectSuggestion(
|
|
92
|
+
withRule("RRULE:FREQ=WEEKLY"),
|
|
93
|
+
"Europe/Amsterdam",
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
assert.ok(projected.ok);
|
|
97
|
+
assert.equal(projected.value.endsAtUtc, "9999-12-31T23:59:59Z");
|
|
98
|
+
});
|
|
99
|
+
|
|
77
100
|
it("reads a cancellation as a cancellation", async () => {
|
|
78
101
|
const projected = await projectSuggestion(
|
|
79
102
|
invitation({ method: "CANCEL", sequence: 2 }),
|
|
@@ -289,10 +312,12 @@ describe("recordCalendarSuggestion", () => {
|
|
|
289
312
|
listByState: async (
|
|
290
313
|
accountConfigId: string,
|
|
291
314
|
state: CalendarSuggestionItem["state"],
|
|
315
|
+
options: { endsAfter: string },
|
|
292
316
|
) => {
|
|
293
317
|
const page = await store.calendarSuggestion.listByState(
|
|
294
318
|
accountConfigId,
|
|
295
319
|
state,
|
|
320
|
+
options,
|
|
296
321
|
);
|
|
297
322
|
// The user accepts, right here.
|
|
298
323
|
await store.calendarSuggestion.settle(ACCOUNT_CONFIG_ID, target, {
|
package/src/suggest.ts
CHANGED
|
@@ -9,8 +9,14 @@ import {
|
|
|
9
9
|
} from "@remit/domain-enums";
|
|
10
10
|
import type ICAL from "ical.js";
|
|
11
11
|
import type { CalendarResult } from "./errors.js";
|
|
12
|
+
import { expandCalendar } from "./expand.js";
|
|
13
|
+
import type { ParsedCalendar } from "./parse.js";
|
|
12
14
|
import { parseCalendar } from "./parse.js";
|
|
13
15
|
import { projectCalendar, readString } from "./project.js";
|
|
16
|
+
import { toUtcIso } from "./time.js";
|
|
17
|
+
|
|
18
|
+
const OPEN_ENDED_SERIES = "9999-12-31T23:59:59Z";
|
|
19
|
+
const EARLIEST_INSTANT = new Date(0).toISOString();
|
|
14
20
|
|
|
15
21
|
const METHOD_BY_ICAL: Record<string, CalendarSuggestionItem["method"]> = {
|
|
16
22
|
REQUEST: CalendarInviteMethod.Request,
|
|
@@ -48,12 +54,25 @@ export type CalendarSuggestionProjection = Pick<
|
|
|
48
54
|
| "summary"
|
|
49
55
|
| "dtStart"
|
|
50
56
|
| "dtEnd"
|
|
57
|
+
| "endsAtUtc"
|
|
51
58
|
| "allDay"
|
|
52
59
|
| "location"
|
|
53
60
|
| "organizer"
|
|
54
61
|
| "zoneCertainty"
|
|
55
62
|
>;
|
|
56
63
|
|
|
64
|
+
const endsAtUtcOf = (
|
|
65
|
+
calendar: ParsedCalendar,
|
|
66
|
+
timezone: string,
|
|
67
|
+
dtEnd: string,
|
|
68
|
+
): string => {
|
|
69
|
+
const expansion = expandCalendar(calendar, timezone);
|
|
70
|
+
if (expansion.expandedThrough !== "") return OPEN_ENDED_SERIES;
|
|
71
|
+
const ends = expansion.occurrences.map((occurrence) => occurrence.endAt);
|
|
72
|
+
if (ends.length === 0) return toUtcIso(Date.parse(dtEnd));
|
|
73
|
+
return ends.reduce((latest, end) => (end > latest ? end : latest));
|
|
74
|
+
};
|
|
75
|
+
|
|
57
76
|
/**
|
|
58
77
|
* Reads a message's `text/calendar` bytes into the facts a card is drawn from.
|
|
59
78
|
*
|
|
@@ -80,6 +99,7 @@ export const projectSuggestion = async (
|
|
|
80
99
|
summary: projection.value.summary,
|
|
81
100
|
dtStart: projection.value.dtStart,
|
|
82
101
|
dtEnd: projection.value.dtEnd,
|
|
102
|
+
endsAtUtc: endsAtUtcOf(parsed.value, timezone, projection.value.dtEnd),
|
|
83
103
|
allDay: projection.value.allDay,
|
|
84
104
|
location: readString(parsed.value.master, "location"),
|
|
85
105
|
organizer: mailAddressOf(readString(parsed.value.master, "organizer")),
|
|
@@ -125,6 +145,7 @@ const listInState = async (
|
|
|
125
145
|
let continuationToken: string | undefined;
|
|
126
146
|
do {
|
|
127
147
|
const page = await repo.listByState(accountConfigId, state, {
|
|
148
|
+
endsAfter: EARLIEST_INSTANT,
|
|
128
149
|
continuationToken,
|
|
129
150
|
});
|
|
130
151
|
items.push(...page.items);
|