@remit/calendar-service 0.0.18 → 0.0.19

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.
Files changed (73) hide show
  1. package/dist/accept.d.ts +71 -0
  2. package/dist/accept.js +159 -0
  3. package/dist/build.d.ts +79 -0
  4. package/dist/build.js +196 -0
  5. package/dist/correct.d.ts +4 -0
  6. package/dist/correct.js +22 -0
  7. package/dist/errors.d.ts +56 -0
  8. package/dist/errors.js +1 -0
  9. package/dist/etag.d.ts +9 -0
  10. package/{src/etag.ts → dist/etag.js} +1 -3
  11. package/dist/expand.d.ts +73 -0
  12. package/dist/expand.js +188 -0
  13. package/dist/feed.d.ts +81 -0
  14. package/dist/feed.js +157 -0
  15. package/dist/fixtures.d.ts +19 -0
  16. package/dist/fixtures.js +37 -0
  17. package/dist/index.d.ts +16 -0
  18. package/dist/index.js +15 -0
  19. package/dist/memory-store.d.ts +105 -0
  20. package/dist/memory-store.js +319 -0
  21. package/dist/parse.d.ts +39 -0
  22. package/dist/parse.js +87 -0
  23. package/dist/project.d.ts +30 -0
  24. package/dist/project.js +57 -0
  25. package/dist/put.d.ts +52 -0
  26. package/dist/put.js +78 -0
  27. package/dist/reopen.d.ts +14 -0
  28. package/dist/reopen.js +92 -0
  29. package/dist/scope.d.ts +70 -0
  30. package/dist/scope.js +687 -0
  31. package/dist/subscribe.d.ts +96 -0
  32. package/dist/subscribe.js +298 -0
  33. package/dist/suggest.d.ts +74 -0
  34. package/dist/suggest.js +138 -0
  35. package/dist/time.d.ts +76 -0
  36. package/dist/time.js +164 -0
  37. package/dist/window.d.ts +85 -0
  38. package/dist/window.js +161 -0
  39. package/package.json +40 -35
  40. package/src/accept.test.ts +0 -403
  41. package/src/accept.ts +0 -250
  42. package/src/build.test.ts +0 -113
  43. package/src/build.ts +0 -311
  44. package/src/correct.test.ts +0 -173
  45. package/src/correct.ts +0 -40
  46. package/src/errors.ts +0 -62
  47. package/src/etag.test.ts +0 -54
  48. package/src/expand.test.ts +0 -361
  49. package/src/expand.ts +0 -303
  50. package/src/feed.test.ts +0 -334
  51. package/src/feed.ts +0 -203
  52. package/src/feeds/google-secret-address.ics +0 -99
  53. package/src/fixtures.ts +0 -52
  54. package/src/index.ts +0 -128
  55. package/src/memory-store.ts +0 -485
  56. package/src/parse.test.ts +0 -264
  57. package/src/parse.ts +0 -138
  58. package/src/project.test.ts +0 -247
  59. package/src/project.ts +0 -122
  60. package/src/put.test.ts +0 -318
  61. package/src/put.ts +0 -133
  62. package/src/reopen.test.ts +0 -529
  63. package/src/reopen.ts +0 -199
  64. package/src/scope.test.ts +0 -1421
  65. package/src/scope.ts +0 -1003
  66. package/src/subscribe.test.ts +0 -582
  67. package/src/subscribe.ts +0 -433
  68. package/src/suggest.test.ts +0 -376
  69. package/src/suggest.ts +0 -244
  70. package/src/time.ts +0 -260
  71. package/src/window.test.ts +0 -648
  72. package/src/window.ts +0 -281
  73. package/tsconfig.json +0 -8
@@ -0,0 +1,71 @@
1
+ import type { CalendarInviteAnswer as CalendarInviteAnswerValue } from "@remit/api-openapi-types";
2
+ import type { CalendarObjectItem, CalendarSuggestionItem, ICalendarStore } from "@remit/data-ports";
3
+ import type { CalendarResult } from "./errors.js";
4
+ /**
5
+ * The VCALENDAR a stored resource is made of, built from the invitation's own
6
+ * bytes.
7
+ *
8
+ * Built by editing what arrived rather than by composing a fresh event: the
9
+ * UID, the SEQUENCE, the recurrence rule, the overrides and every X- property
10
+ * the organizer's client wrote survive into the calendar, which is what makes
11
+ * the stored resource answer to the same event a later revision or a
12
+ * cancellation names.
13
+ *
14
+ * The METHOD goes. A scheduling message carries one (RFC 5546); a stored
15
+ * calendar object resource must not (RFC 4791 4.1), and leaving it in makes
16
+ * every CalDAV client treat the user's own calendar entry as an unanswered
17
+ * invitation.
18
+ */
19
+ export declare const buildAcceptedCalendar: (suggestion: Pick<CalendarSuggestionItem, "icalData" | "method">, attendee: string, answer: CalendarInviteAnswerValue) => Promise<CalendarResult<string>>;
20
+ export interface AcceptCalendarSuggestionInput {
21
+ accountConfigId: string;
22
+ calendarId: string;
23
+ suggestion: CalendarSuggestionItem;
24
+ /** Mail address of the person accepting — the account the message arrived on. */
25
+ attendee: string;
26
+ answer: CalendarInviteAnswerValue;
27
+ }
28
+ /**
29
+ * What accepting did.
30
+ *
31
+ * `Written` is the ordinary case: a resource is in the calendar and `object`
32
+ * names it. `NothingToCancel` is a cancellation for a meeting this calendar
33
+ * never held — the user never accepted the invitation, or accepted it
34
+ * somewhere else — where the only honest act is to clear the card. Writing a
35
+ * resource there would put an event in the calendar that exists solely to say
36
+ * it was cancelled, which is worse than the meeting the user never had.
37
+ */
38
+ export type AcceptOutcome = "Written" | "NothingToCancel";
39
+ export interface AcceptedCalendarSuggestion {
40
+ suggestion: CalendarSuggestionItem;
41
+ outcome: AcceptOutcome;
42
+ /** The resource that was written, `null` for `NothingToCancel`. */
43
+ object: CalendarObjectItem | null;
44
+ }
45
+ /**
46
+ * Adds a suggested event to a calendar, then settles the suggestion.
47
+ *
48
+ * The resource is written through `putCalendarObject`, the same function every
49
+ * other calendar write goes through, so an event added from a mail and one
50
+ * added from the week grid are the same kind of row — and a native client
51
+ * editing it afterwards edits the same bytes. The event and the suggestion
52
+ * live in different databases, so the commit writes the event first and
53
+ * settles the card second. A failure between the two leaves the card still
54
+ * asking with the event in the calendar, and accepting again repairs it: the
55
+ * event write lands on the same resource and the settle follows.
56
+ *
57
+ * Accepting a `Cancel` suggestion writes `STATUS:CANCELLED` through that same
58
+ * path, but only onto a resource that is already there. Nothing withdraws an
59
+ * event on its own — a cancellation reaches the calendar only because a person
60
+ * pressed the button, and only when the calendar holds the meeting being
61
+ * withdrawn.
62
+ *
63
+ * No mail is sent. There is no iMIP reply here and none anywhere on this path;
64
+ * the organizer learns nothing from the user accepting.
65
+ *
66
+ * Idempotent. The resource is addressed by the event's UID within the
67
+ * collection, so accepting the same suggestion twice — or accepting a later
68
+ * revision of an event already added — rewrites the one resource rather than
69
+ * leaving the calendar showing the meeting twice.
70
+ */
71
+ export declare const acceptCalendarSuggestion: (store: ICalendarStore, input: AcceptCalendarSuggestionInput) => Promise<CalendarResult<AcceptedCalendarSuggestion>>;
package/dist/accept.js ADDED
@@ -0,0 +1,159 @@
1
+ import { deriveCalendarObjectId } from "@remit/data-ports/id";
2
+ import { CalendarInviteAnswer, CalendarInviteMethod, CalendarSuggestionState, } from "@remit/domain-enums";
3
+ import ICAL from "ical.js";
4
+ import { parseCalendar, serializeCalendar } from "./parse.js";
5
+ import { prepareCalendarObject, readWrittenObject } from "./put.js";
6
+ import { mailAddressOf } from "./suggest.js";
7
+ const eventsOf = (component) => component.getAllSubcomponents("vevent");
8
+ const partstatOf = {
9
+ [CalendarInviteAnswer.Accepted]: "ACCEPTED",
10
+ [CalendarInviteAnswer.Tentative]: "TENTATIVE",
11
+ };
12
+ const stateOf = {
13
+ [CalendarInviteAnswer.Accepted]: CalendarSuggestionState.Accepted,
14
+ [CalendarInviteAnswer.Tentative]: CalendarSuggestionState.Tentative,
15
+ };
16
+ /**
17
+ * Marks one address with its answer, on every VEVENT of the resource — the
18
+ * master and each override alike, since an attendee's answer is to the series.
19
+ *
20
+ * An ATTENDEE line already naming the user has its PARTSTAT rewritten rather
21
+ * than a second one appended: two ATTENDEE lines for one person is a resource
22
+ * every other client reads as two people.
23
+ */
24
+ const markAnswered = (component, attendee, answer) => {
25
+ const wanted = attendee.toLowerCase();
26
+ for (const event of eventsOf(component)) {
27
+ const existing = event
28
+ .getAllProperties("attendee")
29
+ .find((property) => mailAddressOf(String(property.getFirstValue())).toLowerCase() ===
30
+ wanted);
31
+ const property = existing ?? new ICAL.Property("attendee", event);
32
+ property.setParameter("partstat", partstatOf[answer]);
33
+ if (!existing) {
34
+ property.setValue(`mailto:${attendee}`);
35
+ event.addProperty(property);
36
+ }
37
+ }
38
+ };
39
+ const markCancelled = (component) => {
40
+ for (const event of eventsOf(component)) {
41
+ event.removeAllProperties("status");
42
+ event.addPropertyWithValue("status", "CANCELLED");
43
+ }
44
+ };
45
+ /**
46
+ * The VCALENDAR a stored resource is made of, built from the invitation's own
47
+ * bytes.
48
+ *
49
+ * Built by editing what arrived rather than by composing a fresh event: the
50
+ * UID, the SEQUENCE, the recurrence rule, the overrides and every X- property
51
+ * the organizer's client wrote survive into the calendar, which is what makes
52
+ * the stored resource answer to the same event a later revision or a
53
+ * cancellation names.
54
+ *
55
+ * The METHOD goes. A scheduling message carries one (RFC 5546); a stored
56
+ * calendar object resource must not (RFC 4791 4.1), and leaving it in makes
57
+ * every CalDAV client treat the user's own calendar entry as an unanswered
58
+ * invitation.
59
+ */
60
+ export const buildAcceptedCalendar = async (suggestion, attendee, answer) => {
61
+ const parsed = await parseCalendar(suggestion.icalData);
62
+ if (!parsed.ok)
63
+ return parsed;
64
+ const { component } = parsed.value;
65
+ component.removeAllProperties("method");
66
+ markAnswered(component, attendee, answer);
67
+ if (suggestion.method === CalendarInviteMethod.Cancel) {
68
+ markCancelled(component);
69
+ }
70
+ return { ok: true, value: serializeCalendar(component) };
71
+ };
72
+ /**
73
+ * Adds a suggested event to a calendar, then settles the suggestion.
74
+ *
75
+ * The resource is written through `putCalendarObject`, the same function every
76
+ * other calendar write goes through, so an event added from a mail and one
77
+ * added from the week grid are the same kind of row — and a native client
78
+ * editing it afterwards edits the same bytes. The event and the suggestion
79
+ * live in different databases, so the commit writes the event first and
80
+ * settles the card second. A failure between the two leaves the card still
81
+ * asking with the event in the calendar, and accepting again repairs it: the
82
+ * event write lands on the same resource and the settle follows.
83
+ *
84
+ * Accepting a `Cancel` suggestion writes `STATUS:CANCELLED` through that same
85
+ * path, but only onto a resource that is already there. Nothing withdraws an
86
+ * event on its own — a cancellation reaches the calendar only because a person
87
+ * pressed the button, and only when the calendar holds the meeting being
88
+ * withdrawn.
89
+ *
90
+ * No mail is sent. There is no iMIP reply here and none anywhere on this path;
91
+ * the organizer learns nothing from the user accepting.
92
+ *
93
+ * Idempotent. The resource is addressed by the event's UID within the
94
+ * collection, so accepting the same suggestion twice — or accepting a later
95
+ * revision of an event already added — rewrites the one resource rather than
96
+ * leaving the calendar showing the meeting twice.
97
+ */
98
+ export const acceptCalendarSuggestion = async (store, input) => {
99
+ const icalData = await buildAcceptedCalendar(input.suggestion, input.attendee, input.answer);
100
+ if (!icalData.ok)
101
+ return icalData;
102
+ const existing = await store.repos.calendarObject.findByUid(input.calendarId, input.suggestion.icalUid);
103
+ // A cancellation cancels something. With no resource carrying this UID
104
+ // there is nothing in the calendar to withdraw, and writing one would
105
+ // invent a meeting the user never had purely to mark it cancelled. Clear
106
+ // the card and leave the calendar untouched.
107
+ if (input.suggestion.method === CalendarInviteMethod.Cancel &&
108
+ existing === null) {
109
+ await store.commit([
110
+ {
111
+ kind: "SettleSuggestion",
112
+ accountConfigId: input.accountConfigId,
113
+ suggestionId: input.suggestion.suggestionId,
114
+ settle: {
115
+ state: CalendarSuggestionState.Dismissed,
116
+ acceptedCalendarObjectId: "",
117
+ },
118
+ },
119
+ ]);
120
+ return {
121
+ ok: true,
122
+ value: {
123
+ suggestion: await store.repos.calendarSuggestion.get(input.accountConfigId, input.suggestion.suggestionId),
124
+ outcome: "NothingToCancel",
125
+ object: null,
126
+ },
127
+ };
128
+ }
129
+ const collection = await store.repos.calendarCollection.get(input.accountConfigId, input.calendarId);
130
+ const write = await prepareCalendarObject(collection, {
131
+ accountConfigId: input.accountConfigId,
132
+ calendarId: input.calendarId,
133
+ resourceName: existing?.resourceName ?? `${input.suggestion.suggestionId}.ics`,
134
+ icalData: icalData.value,
135
+ });
136
+ if (!write.ok)
137
+ return write;
138
+ const calendarObjectId = deriveCalendarObjectId(write.value.object.calendarId, write.value.object.resourceName);
139
+ await store.commit([
140
+ write.value,
141
+ {
142
+ kind: "SettleSuggestion",
143
+ accountConfigId: input.accountConfigId,
144
+ suggestionId: input.suggestion.suggestionId,
145
+ settle: {
146
+ state: stateOf[input.answer],
147
+ acceptedCalendarObjectId: calendarObjectId,
148
+ },
149
+ },
150
+ ]);
151
+ return {
152
+ ok: true,
153
+ value: {
154
+ suggestion: await store.repos.calendarSuggestion.get(input.accountConfigId, input.suggestion.suggestionId),
155
+ outcome: "Written",
156
+ object: await readWrittenObject(store, write.value),
157
+ },
158
+ };
159
+ };
@@ -0,0 +1,79 @@
1
+ import type { CalendarObjectItem } from "@remit/data-ports";
2
+ import ICAL from "ical.js";
3
+ import { type CalendarResult } from "./errors.js";
4
+ /** Identifies this server in the PRODID of every VCALENDAR it writes. */
5
+ export declare const CALENDAR_PRODID = "-//Remit//Reader Calendar//EN";
6
+ /**
7
+ * An event as a person describes it, before it is iCalendar.
8
+ *
9
+ * Deliberately not a subset of a VEVENT: a client should not have to know that
10
+ * an all-day end is exclusive, that a duration and an end are the same fact
11
+ * written twice, or that a zone lives in a parameter rather than in the value.
12
+ * The server owns iCalendar; this is the shape the API takes and the shape a
13
+ * patch is expressed in.
14
+ */
15
+ export interface CalendarEventFields {
16
+ summary: string;
17
+ description: string;
18
+ location: string;
19
+ /** ISO 8601 with a zone offset, or `YYYY-MM-DD` when `allDay`. */
20
+ start: string;
21
+ /** Same form as `start`. Exclusive for an all-day event. */
22
+ end: string;
23
+ allDay: boolean;
24
+ /** IANA zone the event is anchored in. `""` anchors it in UTC. */
25
+ timeZone: string;
26
+ status: CalendarObjectItem["status"];
27
+ transparency: CalendarObjectItem["transparency"];
28
+ /** RRULE value without the property name. `""` for a single event. */
29
+ recurrenceRule: string;
30
+ }
31
+ /**
32
+ * An offset is required on a date-time. A wall time with no offset names no
33
+ * instant — a client sending `2026-03-29T02:30:00` on the morning the clocks
34
+ * move is asking for a time that happens twice, or not at all — and guessing
35
+ * one is how a calendar quietly puts an event an hour out.
36
+ */
37
+ export declare const OFFSET_BEARING: RegExp;
38
+ /**
39
+ * Reads one API date-time into the iCalendar time the resource will carry.
40
+ *
41
+ * An all-day value becomes a DATE, which is a civil date and has no zone at
42
+ * all. A timed value becomes the wall time it reads as in `timeZone`, so the
43
+ * resource records the zone the event is anchored in rather than the offset
44
+ * that zone happened to be at — which is the whole difference between a weekly
45
+ * 09:00 meeting and a meeting that moves to 08:00 every autumn.
46
+ */
47
+ export declare const readEventTime: (value: string, allDay: boolean, timeZone: string) => CalendarResult<ICAL.Time>;
48
+ /** The time fields a stored VEVENT already carries, in the API's own form. */
49
+ export declare const eventTimeFields: (component: ICAL.Component, collectionTimezone: string) => Pick<CalendarEventFields, "start" | "end" | "allDay" | "timeZone">;
50
+ /**
51
+ * ical.js's recurrence parser in a promise, so an unreadable rule arrives as a
52
+ * value to branch on rather than a synchronous throw.
53
+ *
54
+ * A rule with no FREQ is refused rather than stored. ical.js reads text it
55
+ * finds no parts in as a rule with a null frequency instead of failing, and
56
+ * storing that would give somebody an event marked as recurring that produces
57
+ * nothing — the failure a client cannot see and cannot fix.
58
+ */
59
+ export declare const readRecurrenceRule: (recurrenceRule: string) => Promise<CalendarResult<ICAL.Recur>>;
60
+ /**
61
+ * Writes a patch onto a VEVENT.
62
+ *
63
+ * A field the patch does not carry is untouched, so renaming an event moves
64
+ * nothing. The time fields are the exception: zone, all-day-ness, start and end
65
+ * are one fact between them, so touching any of them rewrites DTSTART and DTEND
66
+ * together from the values the event would then have. Rewriting only the one
67
+ * the caller named is what produces an event that starts in one zone and ends
68
+ * in another.
69
+ */
70
+ export declare const applyEventFields: (component: ICAL.Component, patch: Partial<CalendarEventFields>, collectionTimezone: string) => Promise<CalendarResult<null>>;
71
+ /**
72
+ * Builds the VCALENDAR text for a new event.
73
+ *
74
+ * The output goes straight to the single write path, which parses it again to
75
+ * validate and project it. That second read is not waste: it is the same gate
76
+ * every other writer passes, so an event this function got wrong is refused
77
+ * here rather than stored.
78
+ */
79
+ export declare const buildEventCalendar: (fields: CalendarEventFields, uid: string, now?: Date) => Promise<CalendarResult<string>>;
package/dist/build.js ADDED
@@ -0,0 +1,196 @@
1
+ import { CalendarEventStatus, CalendarTransparency } from "@remit/domain-enums";
2
+ import ICAL from "ical.js";
3
+ import { calendarFailure } from "./errors.js";
4
+ import { serializeCalendar } from "./parse.js";
5
+ import { civilInZone, dtEndTzid, dtStartTzid, isResolvableZone, resolveTime, } from "./time.js";
6
+ /** Identifies this server in the PRODID of every VCALENDAR it writes. */
7
+ export const CALENDAR_PRODID = "-//Remit//Reader Calendar//EN";
8
+ const STATUS_TO_ICAL = {
9
+ [CalendarEventStatus.Confirmed]: "CONFIRMED",
10
+ [CalendarEventStatus.Tentative]: "TENTATIVE",
11
+ [CalendarEventStatus.Cancelled]: "CANCELLED",
12
+ };
13
+ const TRANSPARENCY_TO_ICAL = {
14
+ [CalendarTransparency.Opaque]: "OPAQUE",
15
+ [CalendarTransparency.Transparent]: "TRANSPARENT",
16
+ };
17
+ const DATE_ONLY = /^\d{4}-\d{2}-\d{2}/;
18
+ /**
19
+ * An offset is required on a date-time. A wall time with no offset names no
20
+ * instant — a client sending `2026-03-29T02:30:00` on the morning the clocks
21
+ * move is asking for a time that happens twice, or not at all — and guessing
22
+ * one is how a calendar quietly puts an event an hour out.
23
+ */
24
+ export const OFFSET_BEARING = /(Z|[+-]\d{2}:\d{2})$/;
25
+ /**
26
+ * Reads one API date-time into the iCalendar time the resource will carry.
27
+ *
28
+ * An all-day value becomes a DATE, which is a civil date and has no zone at
29
+ * all. A timed value becomes the wall time it reads as in `timeZone`, so the
30
+ * resource records the zone the event is anchored in rather than the offset
31
+ * that zone happened to be at — which is the whole difference between a weekly
32
+ * 09:00 meeting and a meeting that moves to 08:00 every autumn.
33
+ */
34
+ export const readEventTime = (value, allDay, timeZone) => {
35
+ if (allDay) {
36
+ const date = DATE_ONLY.exec(value)?.[0];
37
+ if (!date) {
38
+ return calendarFailure("InvalidDateTime", `an all-day event needs a YYYY-MM-DD date, and this one carries "${value}"`);
39
+ }
40
+ const [year, month, day] = date.split("-").map(Number);
41
+ return {
42
+ ok: true,
43
+ value: ICAL.Time.fromData({ year, month, day, isDate: true }),
44
+ };
45
+ }
46
+ if (!OFFSET_BEARING.test(value)) {
47
+ return calendarFailure("InvalidDateTime", `a date-time needs an explicit zone offset, and this one carries "${value}"`);
48
+ }
49
+ const instantMs = Date.parse(value);
50
+ if (Number.isNaN(instantMs)) {
51
+ return calendarFailure("InvalidDateTime", `"${value}" is not a date-time this server can read`);
52
+ }
53
+ if (timeZone !== "" && !isResolvableZone(timeZone)) {
54
+ return calendarFailure("UnknownTimeZone", `"${timeZone}" is not a time zone this server can resolve`);
55
+ }
56
+ const civil = civilInZone(instantMs, timeZone === "" ? "UTC" : timeZone);
57
+ return {
58
+ ok: true,
59
+ value: ICAL.Time.fromData({
60
+ year: civil.year,
61
+ month: civil.month,
62
+ day: civil.day,
63
+ hour: civil.hour,
64
+ minute: civil.minute,
65
+ second: civil.second,
66
+ isDate: false,
67
+ }, timeZone === "" ? ICAL.Timezone.utcTimezone : ICAL.Timezone.localTimezone),
68
+ };
69
+ };
70
+ const setTimeProperty = (event, name, time, timeZone) => {
71
+ event.removeAllProperties(name);
72
+ const property = new ICAL.Property(name);
73
+ event.addProperty(property);
74
+ property.setValue(time);
75
+ if (!time.isDate && timeZone !== "") {
76
+ property.setParameter("tzid", timeZone);
77
+ }
78
+ };
79
+ const setTextProperty = (event, name, value) => {
80
+ event.removeAllProperties(name);
81
+ if (value === "")
82
+ return;
83
+ event.addPropertyWithValue(name, value);
84
+ };
85
+ /** The time fields a stored VEVENT already carries, in the API's own form. */
86
+ export const eventTimeFields = (component, collectionTimezone) => {
87
+ const event = new ICAL.Event(component);
88
+ const startTzid = dtStartTzid(component);
89
+ const start = resolveTime(event.startDate, startTzid, collectionTimezone);
90
+ const end = resolveTime(event.endDate, dtEndTzid(component), collectionTimezone);
91
+ return {
92
+ start: start.isoOffset,
93
+ end: end.isoOffset,
94
+ allDay: start.isDate,
95
+ timeZone: startTzid,
96
+ };
97
+ };
98
+ /**
99
+ * ical.js's recurrence parser in a promise, so an unreadable rule arrives as a
100
+ * value to branch on rather than a synchronous throw.
101
+ *
102
+ * A rule with no FREQ is refused rather than stored. ical.js reads text it
103
+ * finds no parts in as a rule with a null frequency instead of failing, and
104
+ * storing that would give somebody an event marked as recurring that produces
105
+ * nothing — the failure a client cannot see and cannot fix.
106
+ */
107
+ export const readRecurrenceRule = (recurrenceRule) => new Promise((resolve) => {
108
+ resolve(ICAL.Recur.fromString(recurrenceRule));
109
+ }).then((value) => value.freq
110
+ ? { ok: true, value }
111
+ : calendarFailure("InvalidRecurrenceRule", `"${recurrenceRule}" names no FREQ, so it is not a recurrence rule`), (error) => calendarFailure("InvalidRecurrenceRule", error instanceof Error
112
+ ? error.message
113
+ : `"${recurrenceRule}" is not a recurrence rule this server can read`));
114
+ /**
115
+ * Writes a patch onto a VEVENT.
116
+ *
117
+ * A field the patch does not carry is untouched, so renaming an event moves
118
+ * nothing. The time fields are the exception: zone, all-day-ness, start and end
119
+ * are one fact between them, so touching any of them rewrites DTSTART and DTEND
120
+ * together from the values the event would then have. Rewriting only the one
121
+ * the caller named is what produces an event that starts in one zone and ends
122
+ * in another.
123
+ */
124
+ export const applyEventFields = async (component, patch, collectionTimezone) => {
125
+ if (patch.summary !== undefined) {
126
+ setTextProperty(component, "summary", patch.summary);
127
+ }
128
+ if (patch.description !== undefined) {
129
+ setTextProperty(component, "description", patch.description);
130
+ }
131
+ if (patch.location !== undefined) {
132
+ setTextProperty(component, "location", patch.location);
133
+ }
134
+ if (patch.status !== undefined) {
135
+ setTextProperty(component, "status", STATUS_TO_ICAL[patch.status]);
136
+ }
137
+ if (patch.transparency !== undefined) {
138
+ setTextProperty(component, "transp", TRANSPARENCY_TO_ICAL[patch.transparency]);
139
+ }
140
+ if (patch.recurrenceRule !== undefined) {
141
+ component.removeAllProperties("rrule");
142
+ if (patch.recurrenceRule !== "") {
143
+ const rule = await readRecurrenceRule(patch.recurrenceRule);
144
+ if (!rule.ok)
145
+ return rule;
146
+ component.addPropertyWithValue("rrule", rule.value);
147
+ }
148
+ }
149
+ const touchesTime = patch.start !== undefined ||
150
+ patch.end !== undefined ||
151
+ patch.allDay !== undefined ||
152
+ patch.timeZone !== undefined;
153
+ if (!touchesTime)
154
+ return { ok: true, value: null };
155
+ // A VEVENT being built from nothing has no times to read back, and every one
156
+ // of them is in the patch. The empty start then refuses a create that left
157
+ // one out, which is the same answer reading it back would have given.
158
+ const current = component.hasProperty("dtstart")
159
+ ? eventTimeFields(component, collectionTimezone)
160
+ : { start: "", end: "", allDay: false, timeZone: "" };
161
+ const allDay = patch.allDay ?? current.allDay;
162
+ const timeZone = patch.timeZone ?? current.timeZone;
163
+ const start = readEventTime(patch.start ?? current.start, allDay, timeZone);
164
+ if (!start.ok)
165
+ return start;
166
+ const end = readEventTime(patch.end ?? current.end, allDay, timeZone);
167
+ if (!end.ok)
168
+ return end;
169
+ // A duration says the same thing DTEND does, and leaving one behind beside a
170
+ // rewritten DTEND leaves two answers to how long the event is.
171
+ component.removeAllProperties("duration");
172
+ setTimeProperty(component, "dtstart", start.value, timeZone);
173
+ setTimeProperty(component, "dtend", end.value, timeZone);
174
+ return { ok: true, value: null };
175
+ };
176
+ /**
177
+ * Builds the VCALENDAR text for a new event.
178
+ *
179
+ * The output goes straight to the single write path, which parses it again to
180
+ * validate and project it. That second read is not waste: it is the same gate
181
+ * every other writer passes, so an event this function got wrong is refused
182
+ * here rather than stored.
183
+ */
184
+ export const buildEventCalendar = async (fields, uid, now = new Date()) => {
185
+ const calendar = new ICAL.Component("vcalendar");
186
+ calendar.addPropertyWithValue("version", "2.0");
187
+ calendar.addPropertyWithValue("prodid", CALENDAR_PRODID);
188
+ const event = new ICAL.Component("vevent");
189
+ calendar.addSubcomponent(event);
190
+ event.addPropertyWithValue("uid", uid);
191
+ event.addPropertyWithValue("dtstamp", ICAL.Time.fromJSDate(now, true));
192
+ const applied = await applyEventFields(event, fields, fields.timeZone);
193
+ if (!applied.ok)
194
+ return applied;
195
+ return { ok: true, value: serializeCalendar(calendar) };
196
+ };
@@ -0,0 +1,4 @@
1
+ import type { CalendarSuggestionItem } from "@remit/data-ports";
2
+ import type { CalendarEventFields } from "./build.js";
3
+ import { type CalendarResult } from "./errors.js";
4
+ export declare const correctCalendarSuggestion: (suggestion: CalendarSuggestionItem, patch: Partial<CalendarEventFields>, collectionTimezone: string) => Promise<CalendarResult<CalendarSuggestionItem>>;
@@ -0,0 +1,22 @@
1
+ import { CalendarInviteMethod, RecurrenceScope } from "@remit/domain-enums";
2
+ import { calendarFailure } from "./errors.js";
3
+ import { parseCalendar } from "./parse.js";
4
+ import { applyScopedUpdate } from "./scope.js";
5
+ export const correctCalendarSuggestion = async (suggestion, patch, collectionTimezone) => {
6
+ if (suggestion.method === CalendarInviteMethod.Cancel) {
7
+ return calendarFailure("UneditableCancellation", "a cancellation names no event to correct — add it or dismiss it as it is");
8
+ }
9
+ const parsed = await parseCalendar(suggestion.icalData);
10
+ if (!parsed.ok)
11
+ return parsed;
12
+ const edited = await applyScopedUpdate(parsed.value, collectionTimezone, { scope: RecurrenceScope.All, recurrenceId: "", followingUid: "" }, patch);
13
+ if (!edited.ok)
14
+ return edited;
15
+ if (edited.value.kind !== "Replace") {
16
+ throw new Error(`an edit to the whole invitation resolved to a ${edited.value.kind}`);
17
+ }
18
+ return {
19
+ ok: true,
20
+ value: { ...suggestion, icalData: edited.value.icalData },
21
+ };
22
+ };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Why a VCALENDAR was refused. Every value names something the writer can act
3
+ * on — a caller maps these to a 4xx and repeats the message.
4
+ */
5
+ export type CalendarValidationCode =
6
+ /** The bytes are not parseable iCalendar at all. */
7
+ "MalformedIcalendar"
8
+ /** The document's root is not a VCALENDAR. */
9
+ | "NotACalendar"
10
+ /** A component this collection does not store, e.g. VTODO or VJOURNAL. */
11
+ | "UnsupportedComponent"
12
+ /** A VCALENDAR with no VEVENT in it. */
13
+ | "NoEvent"
14
+ /** No VEVENT without a RECURRENCE-ID: overrides with nothing to override. */
15
+ | "NoMasterEvent"
16
+ /** More than one VEVENT without a RECURRENCE-ID. */
17
+ | "MultipleMasterEvents"
18
+ /** A VEVENT with no UID, or an empty one. */
19
+ | "MissingUid"
20
+ /** VEVENTs in one resource declaring different UIDs. */
21
+ | "MismatchedUid"
22
+ /** A VEVENT with no DTSTART. */
23
+ | "MissingDtStart"
24
+ /** A VEVENT whose end precedes its start. */
25
+ | "BackwardsEnd"
26
+ /** A start or end that is not a date-time this server can read. */
27
+ | "InvalidDateTime"
28
+ /** A time zone name nothing on this platform resolves. */
29
+ | "UnknownTimeZone"
30
+ /** An RRULE value ical.js could not read. */
31
+ | "InvalidRecurrenceRule"
32
+ /** A scoped write that names one occurrence without saying which. */
33
+ | "MissingRecurrenceId"
34
+ /** A per-occurrence scope against an event that has one occurrence. */
35
+ | "NotRecurring"
36
+ /** A RECURRENCE-ID naming no occurrence this series produces. */
37
+ | "UnknownOccurrence" | "UnmovableRecurrenceRule" | "UneditableCancellation"
38
+ /** A subscription address that is not an http, https or webcal URL. */
39
+ | "InvalidSubscriptionUrl" | "NotReopenable" | "AnswerOvertaken";
40
+ export interface CalendarValidationError {
41
+ code: CalendarValidationCode;
42
+ message: string;
43
+ }
44
+ /**
45
+ * The outcome of reading a VCALENDAR. A refusal is a value, not a throw:
46
+ * malformed input from a client is an expected outcome of this boundary, and
47
+ * every caller has to render it rather than crash on it.
48
+ */
49
+ export type CalendarResult<T> = {
50
+ ok: true;
51
+ value: T;
52
+ } | {
53
+ ok: false;
54
+ error: CalendarValidationError;
55
+ };
56
+ export declare const calendarFailure: <T>(code: CalendarValidationCode, message: string) => CalendarResult<T>;
package/dist/errors.js ADDED
@@ -0,0 +1 @@
1
+ export const calendarFailure = (code, message) => ({ ok: false, error: { code, message } });
package/dist/etag.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Strong entity tag over the resource's stored bytes.
3
+ *
4
+ * Computed over the bytes exactly as they are stored — CRLF and all — rather
5
+ * than over a reparse of them, so the tag of an untouched resource never moves.
6
+ * Unquoted: HTTP's quoting is the transport's business, and a stored tag that
7
+ * carried the quotes would have to be stripped by everything that compares it.
8
+ */
9
+ export declare const computeEtag: (icalData: string) => string;
@@ -1,5 +1,4 @@
1
1
  import { createHash } from "node:crypto";
2
-
3
2
  /**
4
3
  * Strong entity tag over the resource's stored bytes.
5
4
  *
@@ -8,5 +7,4 @@ import { createHash } from "node:crypto";
8
7
  * Unquoted: HTTP's quoting is the transport's business, and a stored tag that
9
8
  * carried the quotes would have to be stripped by everything that compares it.
10
9
  */
11
- export const computeEtag = (icalData: string): string =>
12
- createHash("sha256").update(icalData, "utf8").digest("hex");
10
+ export const computeEtag = (icalData) => createHash("sha256").update(icalData, "utf8").digest("hex");