@remit/calendar-service 0.0.13 → 0.0.15
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/correct.test.ts +173 -0
- package/src/correct.ts +40 -0
- package/src/errors.ts +1 -0
- package/src/index.ts +1 -0
- package/src/memory-store.ts +24 -0
- package/src/suggest.test.ts +22 -0
- package/src/suggest.ts +45 -16
package/package.json
CHANGED
package/src/accept.test.ts
CHANGED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { CalendarSuggestionItem } from "@remit/data-ports";
|
|
4
|
+
import {
|
|
5
|
+
CalendarInviteAnswer,
|
|
6
|
+
CalendarSuggestionSource,
|
|
7
|
+
CalendarSuggestionState,
|
|
8
|
+
ZoneCertainty,
|
|
9
|
+
} from "@remit/domain-enums";
|
|
10
|
+
import { acceptCalendarSuggestion } from "./accept.js";
|
|
11
|
+
import type { CalendarEventFields } from "./build.js";
|
|
12
|
+
import { correctCalendarSuggestion } from "./correct.js";
|
|
13
|
+
import { ical } from "./fixtures.js";
|
|
14
|
+
import { MemoryCalendarStore } from "./memory-store.js";
|
|
15
|
+
import { provisionDefaultCalendar } from "./put.js";
|
|
16
|
+
import { recordCalendarSuggestion } from "./suggest.js";
|
|
17
|
+
|
|
18
|
+
const ACCOUNT_CONFIG_ID = "account-config-1";
|
|
19
|
+
const ATTENDEE = "user@example.test";
|
|
20
|
+
const UID = "invite-5120@example.test";
|
|
21
|
+
|
|
22
|
+
const invitation = ({
|
|
23
|
+
method = "REQUEST",
|
|
24
|
+
start = "DTSTART:20260901T080000Z",
|
|
25
|
+
end = "DTEND:20260901T090000Z",
|
|
26
|
+
}: {
|
|
27
|
+
method?: string;
|
|
28
|
+
start?: string;
|
|
29
|
+
end?: string;
|
|
30
|
+
}): string =>
|
|
31
|
+
ical(
|
|
32
|
+
"BEGIN:VCALENDAR",
|
|
33
|
+
"VERSION:2.0",
|
|
34
|
+
"PRODID:-//Example Corp//Scheduler//EN",
|
|
35
|
+
`METHOD:${method}`,
|
|
36
|
+
"BEGIN:VEVENT",
|
|
37
|
+
`UID:${UID}`,
|
|
38
|
+
"DTSTAMP:20260801T090000Z",
|
|
39
|
+
"SEQUENCE:2",
|
|
40
|
+
start,
|
|
41
|
+
end,
|
|
42
|
+
"SUMMARY:Quarterly review",
|
|
43
|
+
"LOCATION:Room 4",
|
|
44
|
+
"ORGANIZER:mailto:organizer@example.test",
|
|
45
|
+
"ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:user@example.test",
|
|
46
|
+
"X-EXAMPLE-TICKET:AB-5120",
|
|
47
|
+
"END:VEVENT",
|
|
48
|
+
"END:VCALENDAR",
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const pendingIn = async (icalData: string) => {
|
|
52
|
+
const store = new MemoryCalendarStore();
|
|
53
|
+
const collection = await provisionDefaultCalendar(store, ACCOUNT_CONFIG_ID);
|
|
54
|
+
const recorded = await recordCalendarSuggestion(store.calendarSuggestion, {
|
|
55
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
56
|
+
messageId: "message-1",
|
|
57
|
+
bodyPartId: "body-part-1",
|
|
58
|
+
source: CalendarSuggestionSource.IcalendarPart,
|
|
59
|
+
icalData,
|
|
60
|
+
timezone: "UTC",
|
|
61
|
+
});
|
|
62
|
+
assert.ok(recorded.ok);
|
|
63
|
+
return {
|
|
64
|
+
store,
|
|
65
|
+
calendarId: collection.calendarId,
|
|
66
|
+
suggestion: recorded.value.suggestion,
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const acceptCorrected = async (
|
|
71
|
+
store: MemoryCalendarStore,
|
|
72
|
+
calendarId: string,
|
|
73
|
+
suggestion: CalendarSuggestionItem,
|
|
74
|
+
patch: Partial<CalendarEventFields>,
|
|
75
|
+
) => {
|
|
76
|
+
const corrected = await correctCalendarSuggestion(suggestion, patch, "UTC");
|
|
77
|
+
if (!corrected.ok) return corrected;
|
|
78
|
+
return acceptCalendarSuggestion(store, {
|
|
79
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
80
|
+
calendarId,
|
|
81
|
+
suggestion: corrected.value,
|
|
82
|
+
attendee: ATTENDEE,
|
|
83
|
+
answer: CalendarInviteAnswer.Accepted,
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
describe("correctCalendarSuggestion", () => {
|
|
88
|
+
it("writes the corrected event and settles the card as accepted", async () => {
|
|
89
|
+
const { store, calendarId, suggestion } = await pendingIn(invitation({}));
|
|
90
|
+
|
|
91
|
+
const accepted = await acceptCorrected(store, calendarId, suggestion, {
|
|
92
|
+
summary: "Quarterly review, moved",
|
|
93
|
+
start: "2026-09-01T13:00:00+00:00",
|
|
94
|
+
end: "2026-09-01T14:30:00+00:00",
|
|
95
|
+
allDay: false,
|
|
96
|
+
timeZone: "",
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
assert.ok(accepted.ok);
|
|
100
|
+
const object = accepted.value.object;
|
|
101
|
+
assert.ok(object);
|
|
102
|
+
assert.equal(object.summary, "Quarterly review, moved");
|
|
103
|
+
assert.equal(Date.parse(object.dtStart), Date.parse("2026-09-01T13:00Z"));
|
|
104
|
+
assert.equal(Date.parse(object.dtEnd), Date.parse("2026-09-01T14:30Z"));
|
|
105
|
+
assert.equal(object.icalUid, UID);
|
|
106
|
+
assert.match(object.icalData, /LOCATION:Room 4/);
|
|
107
|
+
assert.match(object.icalData, /X-EXAMPLE-TICKET:AB-5120/);
|
|
108
|
+
assert.match(object.icalData, /PARTSTAT=ACCEPTED/);
|
|
109
|
+
|
|
110
|
+
const settled = await store.calendarSuggestion.get(
|
|
111
|
+
ACCOUNT_CONFIG_ID,
|
|
112
|
+
suggestion.suggestionId,
|
|
113
|
+
);
|
|
114
|
+
assert.equal(settled.state, CalendarSuggestionState.Accepted);
|
|
115
|
+
assert.equal(settled.acceptedCalendarObjectId, object.calendarObjectId);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("places an unresolvable zone on the clock the reader picked", async () => {
|
|
119
|
+
const { store, calendarId, suggestion } = await pendingIn(
|
|
120
|
+
invitation({
|
|
121
|
+
start: "DTSTART;TZID=Nowhere Standard Time:20260901T090000",
|
|
122
|
+
end: "DTEND;TZID=Nowhere Standard Time:20260901T100000",
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
assert.equal(suggestion.zoneCertainty, ZoneCertainty.Ambiguous);
|
|
126
|
+
|
|
127
|
+
const accepted = await acceptCorrected(store, calendarId, suggestion, {
|
|
128
|
+
start: "2026-09-01T09:00:00+02:00",
|
|
129
|
+
end: "2026-09-01T10:00:00+02:00",
|
|
130
|
+
allDay: false,
|
|
131
|
+
timeZone: "Europe/Amsterdam",
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
assert.ok(accepted.ok);
|
|
135
|
+
const object = accepted.value.object;
|
|
136
|
+
assert.ok(object);
|
|
137
|
+
assert.equal(object.zoneCertainty, ZoneCertainty.Explicit);
|
|
138
|
+
assert.equal(Date.parse(object.dtStart), Date.parse("2026-09-01T07:00Z"));
|
|
139
|
+
assert.match(
|
|
140
|
+
object.icalData,
|
|
141
|
+
/DTSTART;TZID=Europe\/Amsterdam:20260901T090000/,
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("refuses a cancellation and leaves the card waiting", async () => {
|
|
146
|
+
const { store, calendarId, suggestion } = await pendingIn(
|
|
147
|
+
invitation({ method: "CANCEL" }),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const refused = await acceptCorrected(store, calendarId, suggestion, {
|
|
151
|
+
summary: "Anything",
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
assert.equal(refused.ok, false);
|
|
155
|
+
assert.equal(store.objects.size, 0);
|
|
156
|
+
const untouched = await store.calendarSuggestion.get(
|
|
157
|
+
ACCOUNT_CONFIG_ID,
|
|
158
|
+
suggestion.suggestionId,
|
|
159
|
+
);
|
|
160
|
+
assert.equal(untouched.state, CalendarSuggestionState.Pending);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("refuses a correction the event cannot take and writes nothing", async () => {
|
|
164
|
+
const { store, calendarId, suggestion } = await pendingIn(invitation({}));
|
|
165
|
+
|
|
166
|
+
const refused = await acceptCorrected(store, calendarId, suggestion, {
|
|
167
|
+
start: "2026-09-01T13:00:00",
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
assert.equal(refused.ok, false);
|
|
171
|
+
assert.equal(store.objects.size, 0);
|
|
172
|
+
});
|
|
173
|
+
});
|
package/src/correct.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { CalendarSuggestionItem } from "@remit/data-ports";
|
|
2
|
+
import { CalendarInviteMethod, RecurrenceScope } from "@remit/domain-enums";
|
|
3
|
+
import type { CalendarEventFields } from "./build.js";
|
|
4
|
+
import { type CalendarResult, calendarFailure } from "./errors.js";
|
|
5
|
+
import { parseCalendar } from "./parse.js";
|
|
6
|
+
import { applyScopedUpdate } from "./scope.js";
|
|
7
|
+
|
|
8
|
+
export const correctCalendarSuggestion = async (
|
|
9
|
+
suggestion: CalendarSuggestionItem,
|
|
10
|
+
patch: Partial<CalendarEventFields>,
|
|
11
|
+
collectionTimezone: string,
|
|
12
|
+
): Promise<CalendarResult<CalendarSuggestionItem>> => {
|
|
13
|
+
if (suggestion.method === CalendarInviteMethod.Cancel) {
|
|
14
|
+
return calendarFailure(
|
|
15
|
+
"UneditableCancellation",
|
|
16
|
+
"a cancellation names no event to correct — add it or dismiss it as it is",
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const parsed = await parseCalendar(suggestion.icalData);
|
|
21
|
+
if (!parsed.ok) return parsed;
|
|
22
|
+
|
|
23
|
+
const edited = await applyScopedUpdate(
|
|
24
|
+
parsed.value,
|
|
25
|
+
collectionTimezone,
|
|
26
|
+
{ scope: RecurrenceScope.All, recurrenceId: "", followingUid: "" },
|
|
27
|
+
patch,
|
|
28
|
+
);
|
|
29
|
+
if (!edited.ok) return edited;
|
|
30
|
+
if (edited.value.kind !== "Replace") {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`an edit to the whole invitation resolved to a ${edited.value.kind}`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
ok: true,
|
|
38
|
+
value: { ...suggestion, icalData: edited.value.icalData },
|
|
39
|
+
};
|
|
40
|
+
};
|
package/src/errors.ts
CHANGED
|
@@ -36,6 +36,7 @@ export type CalendarValidationCode =
|
|
|
36
36
|
/** A RECURRENCE-ID naming no occurrence this series produces. */
|
|
37
37
|
| "UnknownOccurrence"
|
|
38
38
|
| "UnmovableRecurrenceRule"
|
|
39
|
+
| "UneditableCancellation"
|
|
39
40
|
/** A subscription address that is not an http, https or webcal URL. */
|
|
40
41
|
| "InvalidSubscriptionUrl";
|
|
41
42
|
|
package/src/index.ts
CHANGED
package/src/memory-store.ts
CHANGED
|
@@ -236,6 +236,7 @@ export class MemoryCalendarStore implements ICalendarUnitOfWork {
|
|
|
236
236
|
suggestionId,
|
|
237
237
|
state: existing?.state ?? CalendarSuggestionState.Pending,
|
|
238
238
|
acceptedCalendarObjectId: existing?.acceptedCalendarObjectId ?? "",
|
|
239
|
+
supersededByMessageId: existing?.supersededByMessageId ?? "",
|
|
239
240
|
createdAt: existing?.createdAt ?? now,
|
|
240
241
|
updatedAt: now,
|
|
241
242
|
};
|
|
@@ -282,6 +283,7 @@ export class MemoryCalendarStore implements ICalendarUnitOfWork {
|
|
|
282
283
|
supersedeIfPending: async (
|
|
283
284
|
accountConfigId: string,
|
|
284
285
|
suggestionId: string,
|
|
286
|
+
supersededByMessageId: string,
|
|
285
287
|
) => {
|
|
286
288
|
const suggestion = this.suggestions.get(suggestionId);
|
|
287
289
|
if (
|
|
@@ -295,11 +297,33 @@ export class MemoryCalendarStore implements ICalendarUnitOfWork {
|
|
|
295
297
|
...suggestion,
|
|
296
298
|
state: CalendarSuggestionState.Superseded,
|
|
297
299
|
acceptedCalendarObjectId: "",
|
|
300
|
+
supersededByMessageId,
|
|
298
301
|
updatedAt: Date.now(),
|
|
299
302
|
};
|
|
300
303
|
this.suggestions.set(suggestionId, retired);
|
|
301
304
|
return retired;
|
|
302
305
|
},
|
|
306
|
+
repointSuperseded: async (
|
|
307
|
+
accountConfigId: string,
|
|
308
|
+
suggestionId: string,
|
|
309
|
+
supersededByMessageId: string,
|
|
310
|
+
) => {
|
|
311
|
+
const suggestion = this.suggestions.get(suggestionId);
|
|
312
|
+
if (
|
|
313
|
+
!suggestion ||
|
|
314
|
+
suggestion.accountConfigId !== accountConfigId ||
|
|
315
|
+
suggestion.state !== CalendarSuggestionState.Superseded
|
|
316
|
+
) {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
const repointed = {
|
|
320
|
+
...suggestion,
|
|
321
|
+
supersededByMessageId,
|
|
322
|
+
updatedAt: Date.now(),
|
|
323
|
+
};
|
|
324
|
+
this.suggestions.set(suggestionId, repointed);
|
|
325
|
+
return repointed;
|
|
326
|
+
},
|
|
303
327
|
};
|
|
304
328
|
|
|
305
329
|
private readonly feedTokenRepo: ICalendarFeedTokenRepository = {
|
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
|
};
|