@remit/calendar-service 0.0.21 → 1.0.1

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/dist/accept.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { CalendarInviteAnswer as CalendarInviteAnswerValue } from "@remit/api-openapi-types";
2
2
  import type { CalendarObjectItem, CalendarSuggestionItem, ICalendarStore } from "@remit/data-ports";
3
3
  import type { CalendarResult } from "./errors.js";
4
+ /** The state a suggestion settles in for each answer. */
5
+ export declare const SUGGESTION_STATE_OF_ANSWER: Record<CalendarInviteAnswerValue, CalendarSuggestionItem["state"]>;
4
6
  /**
5
7
  * The VCALENDAR a stored resource is made of, built from the invitation's own
6
8
  * bytes.
package/dist/accept.js CHANGED
@@ -2,16 +2,15 @@ import { deriveCalendarObjectId } from "@remit/data-ports/id";
2
2
  import { CalendarInviteAnswer, CalendarInviteMethod, CalendarSuggestionState, } from "@remit/domain-enums";
3
3
  import ICAL from "ical.js";
4
4
  import { parseCalendar, serializeCalendar } from "./parse.js";
5
+ import { PARTSTAT_OF_ANSWER } from "./participation.js";
5
6
  import { prepareCalendarObject, readWrittenObject } from "./put.js";
6
7
  import { mailAddressOf } from "./suggest.js";
7
8
  const eventsOf = (component) => component.getAllSubcomponents("vevent");
8
- const partstatOf = {
9
- [CalendarInviteAnswer.Accepted]: "ACCEPTED",
10
- [CalendarInviteAnswer.Tentative]: "TENTATIVE",
11
- };
12
- const stateOf = {
9
+ /** The state a suggestion settles in for each answer. */
10
+ export const SUGGESTION_STATE_OF_ANSWER = {
13
11
  [CalendarInviteAnswer.Accepted]: CalendarSuggestionState.Accepted,
14
12
  [CalendarInviteAnswer.Tentative]: CalendarSuggestionState.Tentative,
13
+ [CalendarInviteAnswer.Declined]: CalendarSuggestionState.Declined,
15
14
  };
16
15
  /**
17
16
  * Marks one address with its answer, on every VEVENT of the resource — the
@@ -29,7 +28,7 @@ const markAnswered = (component, attendee, answer) => {
29
28
  .find((property) => mailAddressOf(String(property.getFirstValue())).toLowerCase() ===
30
29
  wanted);
31
30
  const property = existing ?? new ICAL.Property("attendee", event);
32
- property.setParameter("partstat", partstatOf[answer]);
31
+ property.setParameter("partstat", PARTSTAT_OF_ANSWER[answer]);
33
32
  if (!existing) {
34
33
  property.setValue(`mailto:${attendee}`);
35
34
  event.addProperty(property);
@@ -143,7 +142,7 @@ export const acceptCalendarSuggestion = async (store, input) => {
143
142
  accountConfigId: input.accountConfigId,
144
143
  suggestionId: input.suggestion.suggestionId,
145
144
  settle: {
146
- state: stateOf[input.answer],
145
+ state: SUGGESTION_STATE_OF_ANSWER[input.answer],
147
146
  acceptedCalendarObjectId: calendarObjectId,
148
147
  },
149
148
  },
package/dist/errors.d.ts CHANGED
@@ -36,7 +36,19 @@ export type CalendarValidationCode =
36
36
  /** A RECURRENCE-ID naming no occurrence this series produces. */
37
37
  | "UnknownOccurrence" | "UnmovableRecurrenceRule" | "UneditableCancellation"
38
38
  /** A subscription address that is not an http, https or webcal URL. */
39
- | "InvalidSubscriptionUrl" | "NotReopenable" | "AnswerOvertaken";
39
+ | "InvalidSubscriptionUrl" | "NotReopenable" | "AnswerOvertaken"
40
+ /** An answer to the rest of a series, which is not a thing an answer can be to. */
41
+ | "InvalidScope"
42
+ /** A guest list change on an event somebody else organizes. */
43
+ | "NotOrganizer"
44
+ /** An answer from somebody who is not on the guest list. */
45
+ | "NotAGuest"
46
+ /** A guest that is not a mail address. */
47
+ | "InvalidGuest"
48
+ /** A sending account that is not the caller's. */
49
+ | "UnknownOrganizer"
50
+ /** A sending account with Mail turned off, or no account to send from. */
51
+ | "OrganizerCannotSend";
40
52
  export interface CalendarValidationError {
41
53
  code: CalendarValidationCode;
42
54
  message: string;
package/dist/index.d.ts CHANGED
@@ -1,16 +1,21 @@
1
- export { type AcceptCalendarSuggestionInput, type AcceptedCalendarSuggestion, acceptCalendarSuggestion, buildAcceptedCalendar, } from "./accept.js";
1
+ export { type AcceptCalendarSuggestionInput, type AcceptedCalendarSuggestion, acceptCalendarSuggestion, buildAcceptedCalendar, SUGGESTION_STATE_OF_ANSWER, } from "./accept.js";
2
2
  export { applyEventFields, buildEventCalendar, CALENDAR_PRODID, type CalendarEventFields, eventTimeFields, OFFSET_BEARING, readEventTime, readRecurrenceRule, } from "./build.js";
3
3
  export { correctCalendarSuggestion } from "./correct.js";
4
4
  export type { CalendarResult, CalendarValidationCode, CalendarValidationError, } from "./errors.js";
5
5
  export { computeEtag } from "./etag.js";
6
6
  export { CALENDAR_EXPANSION_HORIZON_DAYS, CALENDAR_EXPANSION_MAX_OCCURRENCES, CALENDAR_WINDOW_MAX_STEPS, type CalendarExpansion, expandCalendar, expandCalendarWindow, } from "./expand.js";
7
7
  export { buildCalendarFeed, CALENDAR_FEED_PATH_PREFIX, CALENDAR_FEED_PATH_SUFFIX, CALENDAR_FEED_TOKEN_BYTES, type CalendarFeed, type CalendarFeedSecret, calendarFeedIsUnchanged, calendarFeedIsUnmodifiedSince, hashCalendarFeedToken, isCalendarFeedToken, mintCalendarFeedToken, readCalendarFeedToken, redactCalendarFeedPath, } from "./feed.js";
8
+ export { type EventSchedulingUpdate, guestListOf, invitationSenderInEffect, type PlannedEventUpdate, planCreateSends, planDeleteFanOut, planDeleteSends, planEventCreate, planEventUpdate, planGuestFanOut, planUpdateSends, type SchedulingSendPlan, type SchedulingSends, schedulingBody, sendingAccountFor, sendsMail, shapeScheduledUpdate, } from "./invitations.js";
8
9
  export { type ParsedCalendar, parseCalendar, serializeCalendar, } from "./parse.js";
10
+ export { attendeesOf, calendarAddressOf, countAttendees, organizerOf, PARTSTAT_OF_ANSWER, participationAtSlot, participationOf, selfAddressSet, } from "./participation.js";
9
11
  export { type CalendarObjectProjection, projectCalendar, } from "./project.js";
10
- export { DEFAULT_CALENDAR_URL_SEGMENT, deleteCalendarObject, deleteCalendarObjectWrite, type PutCalendarObjectInput, prepareCalendarObject, provisionDefaultCalendar, putCalendarObject, readWrittenObject, } from "./put.js";
12
+ export { DEFAULT_CALENDAR_URL_SEGMENT, deleteCalendarObject, deleteCalendarObjectWrite, type PutCalendarObjectInput, prepareCalendarObject, provisionDefaultCalendar, provisionTaskCalendar, putCalendarObject, readWrittenObject, TASK_CALENDAR_URL_SEGMENT, } from "./put.js";
11
13
  export { answersOvertakenBy, type ReopenCalendarSuggestionInput, reopenCalendarSuggestion, } from "./reopen.js";
14
+ export { type RecordCalendarReplyInput, type RecordCalendarReplyOutcome, type ReplyRevision, recordCalendarReply, replyOvertakes, } from "./reply.js";
15
+ export { applyAnswer, applyGuestList, buildSchedulingMessage, bumpSequence, type CalendarPerson, distinctGuests, guestAddressesOf, invalidGuestOf, type SchedulingMessage, type SchedulingMessageInput, type SchedulingMethod, schedulingSubject, } from "./scheduling.js";
12
16
  export { applyScopedDelete, applyScopedUpdate, findOccurrence, followingUidOf, followingUidPrefix, type RecurrenceScopeValue, type ScopedWrite, type ScopedWriteInput, } from "./scope.js";
13
17
  export { CALENDAR_SUBSCRIPTION_MAX_BYTES, CALENDAR_SUBSCRIPTION_TIMEOUT_MS, type CalendarFeedFetch, type CalendarFeedFetcher, type CalendarFeedResource, type CalendarFeedSplit, type CalendarFeedWrite, type CalendarSubscriptionRefresh, type CalendarSubscriptionTarget, fetchCalendarFeed, isSubscriptionDue, readCalendarFeed, readSubscriptionUrl, recordCalendarFeedFailure, refreshCalendarSubscription, splitCalendarFeed, writeCalendarFeed, } from "./subscribe.js";
14
18
  export { type CalendarSuggestionProjection, inviteMethodOf, mailAddressOf, projectSuggestion, type RecordCalendarSuggestionInput, type RecordedCalendarSuggestion, recordCalendarSuggestion, } from "./suggest.js";
19
+ export { buildTaskCalendar, type CalendarTaskFields, listTasksInWindow, parseTaskCalendar, prepareCalendarTask, updateTaskCalendar, } from "./task.js";
15
20
  export { civilInZone, isResolvableZone, type ResolvedTime, resolveTime, toOffsetIso, toUtcIso, } from "./time.js";
16
21
  export { type BusySpan, CALENDAR_WINDOW_LOOKBACK_DAYS, type CalendarInstance, type CalendarWindow, type CalendarWindowRepositories, isBusy, listBusySpans, listCalendarInstances, listObjectInstances, mergeBusySpans, } from "./window.js";
package/dist/index.js CHANGED
@@ -1,15 +1,20 @@
1
- export { acceptCalendarSuggestion, buildAcceptedCalendar, } from "./accept.js";
1
+ export { acceptCalendarSuggestion, buildAcceptedCalendar, SUGGESTION_STATE_OF_ANSWER, } from "./accept.js";
2
2
  export { applyEventFields, buildEventCalendar, CALENDAR_PRODID, eventTimeFields, OFFSET_BEARING, readEventTime, readRecurrenceRule, } from "./build.js";
3
3
  export { correctCalendarSuggestion } from "./correct.js";
4
4
  export { computeEtag } from "./etag.js";
5
5
  export { CALENDAR_EXPANSION_HORIZON_DAYS, CALENDAR_EXPANSION_MAX_OCCURRENCES, CALENDAR_WINDOW_MAX_STEPS, expandCalendar, expandCalendarWindow, } from "./expand.js";
6
6
  export { buildCalendarFeed, CALENDAR_FEED_PATH_PREFIX, CALENDAR_FEED_PATH_SUFFIX, CALENDAR_FEED_TOKEN_BYTES, calendarFeedIsUnchanged, calendarFeedIsUnmodifiedSince, hashCalendarFeedToken, isCalendarFeedToken, mintCalendarFeedToken, readCalendarFeedToken, redactCalendarFeedPath, } from "./feed.js";
7
+ export { guestListOf, invitationSenderInEffect, planCreateSends, planDeleteFanOut, planDeleteSends, planEventCreate, planEventUpdate, planGuestFanOut, planUpdateSends, schedulingBody, sendingAccountFor, sendsMail, shapeScheduledUpdate, } from "./invitations.js";
7
8
  export { parseCalendar, serializeCalendar, } from "./parse.js";
9
+ export { attendeesOf, calendarAddressOf, countAttendees, organizerOf, PARTSTAT_OF_ANSWER, participationAtSlot, participationOf, selfAddressSet, } from "./participation.js";
8
10
  export { projectCalendar, } from "./project.js";
9
- export { DEFAULT_CALENDAR_URL_SEGMENT, deleteCalendarObject, deleteCalendarObjectWrite, prepareCalendarObject, provisionDefaultCalendar, putCalendarObject, readWrittenObject, } from "./put.js";
11
+ export { DEFAULT_CALENDAR_URL_SEGMENT, deleteCalendarObject, deleteCalendarObjectWrite, prepareCalendarObject, provisionDefaultCalendar, provisionTaskCalendar, putCalendarObject, readWrittenObject, TASK_CALENDAR_URL_SEGMENT, } from "./put.js";
10
12
  export { answersOvertakenBy, reopenCalendarSuggestion, } from "./reopen.js";
13
+ export { recordCalendarReply, replyOvertakes, } from "./reply.js";
14
+ export { applyAnswer, applyGuestList, buildSchedulingMessage, bumpSequence, distinctGuests, guestAddressesOf, invalidGuestOf, schedulingSubject, } from "./scheduling.js";
11
15
  export { applyScopedDelete, applyScopedUpdate, findOccurrence, followingUidOf, followingUidPrefix, } from "./scope.js";
12
16
  export { CALENDAR_SUBSCRIPTION_MAX_BYTES, CALENDAR_SUBSCRIPTION_TIMEOUT_MS, fetchCalendarFeed, isSubscriptionDue, readCalendarFeed, readSubscriptionUrl, recordCalendarFeedFailure, refreshCalendarSubscription, splitCalendarFeed, writeCalendarFeed, } from "./subscribe.js";
13
17
  export { inviteMethodOf, mailAddressOf, projectSuggestion, recordCalendarSuggestion, } from "./suggest.js";
18
+ export { buildTaskCalendar, listTasksInWindow, parseTaskCalendar, prepareCalendarTask, updateTaskCalendar, } from "./task.js";
14
19
  export { civilInZone, isResolvableZone, resolveTime, toOffsetIso, toUtcIso, } from "./time.js";
15
20
  export { CALENDAR_WINDOW_LOOKBACK_DAYS, isBusy, listBusySpans, listCalendarInstances, listObjectInstances, mergeBusySpans, } from "./window.js";
@@ -0,0 +1,120 @@
1
+ import type { CalendarInviteAnswer as CalendarInviteAnswerValue } from "@remit/api-openapi-types";
2
+ import type { AccountItem } from "@remit/data-ports";
3
+ import type { CalendarEventFields } from "./build.js";
4
+ import { type CalendarResult } from "./errors.js";
5
+ import { type ParsedCalendar } from "./parse.js";
6
+ import { type CalendarPerson, type SchedulingMethod } from "./scheduling.js";
7
+ import type { RecurrenceScopeValue, ScopedWrite } from "./scope.js";
8
+ type Account = Pick<AccountItem, "accountId" | "email" | "syncedServices">;
9
+ /** Whether an account can send: a scheduling message goes out as mail. */
10
+ export declare const sendsMail: (account: Pick<AccountItem, "syncedServices">) => boolean;
11
+ /**
12
+ * The account invitations go out from by default: the one the reader set, while
13
+ * it still sends mail, and otherwise the account an event is on. A calendar
14
+ * belongs to the account config rather than to one account, so a new event is
15
+ * on the config's first account that sends mail.
16
+ */
17
+ export declare const invitationSenderInEffect: <A extends Account>(accounts: readonly A[], settingAccountId: string | undefined) => A | undefined;
18
+ /** The account a request names to send from, refused when it cannot send. */
19
+ export declare const sendingAccountFor: <A extends Account>(accounts: readonly A[], accountId: string) => CalendarResult<A>;
20
+ /** A guest list as the editor sent it, refused when an entry is not an address. */
21
+ export declare const guestListOf: (organizer: string, attendees: readonly CalendarPerson[]) => CalendarResult<CalendarPerson[]>;
22
+ /**
23
+ * Who gets which scheduling message after an organizer saves an event.
24
+ *
25
+ * Every guest still on the list gets the event as it now stands: a save is a
26
+ * revision, and a guest who keeps the old one keeps the old time. A guest the
27
+ * save took off the list gets a cancellation, and only them.
28
+ */
29
+ export declare const planGuestFanOut: (previousGuests: readonly string[], currentGuests: readonly string[]) => {
30
+ request: string[];
31
+ cancel: string[];
32
+ };
33
+ /**
34
+ * What a delete sends. Removing the whole event cancels it for every guest.
35
+ * Removing one occurrence or the rest of a series leaves an event behind, and
36
+ * the guests get that event — with the EXDATE or the shortened rule — rather
37
+ * than a cancellation of a series that still exists.
38
+ */
39
+ export declare const planDeleteFanOut: (removedWhole: boolean, guests: readonly string[]) => {
40
+ method: SchedulingMethod;
41
+ recipients: string[];
42
+ };
43
+ /** One message to queue: about which stored resource, to whom, saying what. */
44
+ export interface SchedulingSendPlan {
45
+ method: SchedulingMethod;
46
+ calendarObjectId: string;
47
+ /** The stored resource the message is built from. */
48
+ icalData: string;
49
+ collectionTimezone: string;
50
+ recipient: CalendarPerson;
51
+ /** For a REPLY: the guest answering. For the rest: the recipient. */
52
+ attendee: CalendarPerson;
53
+ recurrenceId: string;
54
+ answer?: CalendarInviteAnswerValue;
55
+ }
56
+ /** The messages a write queues, and the account they go out from. */
57
+ export interface SchedulingSends<A> {
58
+ from: A;
59
+ plans: SchedulingSendPlan[];
60
+ }
61
+ /** The plain-text body a scheduling message carries beside its calendar part. */
62
+ export declare const schedulingBody: (subject: string, icalData: string) => string;
63
+ /** What a create must know before it writes: who sends, and to whom. */
64
+ export declare const planEventCreate: <A extends Account>(accounts: readonly A[], settingAccountId: string | undefined, attendees: readonly CalendarPerson[], organizerAccountId: string | undefined) => CalendarResult<{
65
+ organizer: A;
66
+ guests: CalendarPerson[];
67
+ } | null>;
68
+ export declare const planCreateSends: <A extends Account>(planned: {
69
+ organizer: A;
70
+ guests: CalendarPerson[];
71
+ }, stored: {
72
+ calendarObjectId: string;
73
+ icalData: string;
74
+ }, collectionTimezone: string) => SchedulingSends<A>;
75
+ /** The scheduling half of an event update. */
76
+ export interface EventSchedulingUpdate {
77
+ attendees?: CalendarPerson[];
78
+ organizerAccountId?: string;
79
+ answer?: CalendarInviteAnswerValue;
80
+ }
81
+ /** What an update decided about scheduling before anything is written. */
82
+ export interface PlannedEventUpdate<A> {
83
+ storedOrganizer: string;
84
+ previousGuests: CalendarPerson[];
85
+ guests: CalendarPerson[];
86
+ /** The account the organizer's messages go out from; absent when none are owed. */
87
+ organizer: A | undefined;
88
+ /** The reader's own address on the guest list, when they answer. */
89
+ answeringAs: string;
90
+ /** Whether the organizer's guests are owed the revision. */
91
+ organized: boolean;
92
+ }
93
+ /**
94
+ * Decides who an update writes as and owes messages to, refused before
95
+ * anything is written: a guest list only its organizer may change, an answer
96
+ * only a guest may give, and nobody to send from refuses the save rather than
97
+ * leaving guests uninvited.
98
+ */
99
+ export declare const planEventUpdate: <A extends Account>(calendar: ParsedCalendar, accounts: readonly A[], settingAccountId: string | undefined, scope: RecurrenceScopeValue, update: EventSchedulingUpdate) => CalendarResult<PlannedEventUpdate<A>>;
100
+ /**
101
+ * The bytes an update stores: the guest list on the resource the patch applied
102
+ * to, SEQUENCE moved past the last revision when the organizer reschedules, and
103
+ * the reader's own answer on the occurrence or the series they answered.
104
+ */
105
+ export declare const shapeScheduledUpdate: <A extends Account>(write: Exclude<ScopedWrite, {
106
+ kind: "Delete";
107
+ }>, planned: PlannedEventUpdate<A>, update: EventSchedulingUpdate, patch: Partial<CalendarEventFields>, collectionTimezone: string, scope: RecurrenceScopeValue, recurrenceId: string) => Promise<CalendarResult<ScopedWrite>>;
108
+ interface StoredResource {
109
+ calendarObjectId: string;
110
+ icalData: string;
111
+ }
112
+ /**
113
+ * The messages an update owes: the revision to every guest of each resource
114
+ * it wrote, a cancellation to each guest it removed, and the reader's reply to
115
+ * the organizer when they answered as a guest.
116
+ */
117
+ export declare const planUpdateSends: <A extends Account>(planned: PlannedEventUpdate<A>, accounts: readonly A[], update: EventSchedulingUpdate, original: StoredResource, written: readonly StoredResource[], collectionTimezone: string, scope: RecurrenceScopeValue, recurrenceId: string) => Promise<SchedulingSends<A> | null>;
118
+ /** The messages a delete owes, only on an event the reader organizes. */
119
+ export declare const planDeleteSends: <A extends Account>(calendar: ParsedCalendar, accounts: readonly A[], original: StoredResource, remaining: StoredResource | null, collectionTimezone: string) => SchedulingSends<A> | null;
120
+ export {};
@@ -0,0 +1,321 @@
1
+ import { AccountService, CalendarInviteMethod, CalendarParticipation, RecurrenceScope, } from "@remit/domain-enums";
2
+ import { calendarFailure } from "./errors.js";
3
+ import { parseCalendar } from "./parse.js";
4
+ import { attendeesOf, calendarAddressOf, organizerOf, participationOf, selfAddressSet, } from "./participation.js";
5
+ import { applyAnswer, applyGuestList, bumpSequence, distinctGuests, guestAddressesOf, invalidGuestOf, } from "./scheduling.js";
6
+ /** Whether an account can send: a scheduling message goes out as mail. */
7
+ export const sendsMail = (account) => account.syncedServices.includes(AccountService.Mail);
8
+ /**
9
+ * The account invitations go out from by default: the one the reader set, while
10
+ * it still sends mail, and otherwise the account an event is on. A calendar
11
+ * belongs to the account config rather than to one account, so a new event is
12
+ * on the config's first account that sends mail.
13
+ */
14
+ export const invitationSenderInEffect = (accounts, settingAccountId) => accounts.find((account) => account.accountId === settingAccountId && sendsMail(account)) ?? accounts.find(sendsMail);
15
+ /** The account a request names to send from, refused when it cannot send. */
16
+ export const sendingAccountFor = (accounts, accountId) => {
17
+ const account = accounts.find((candidate) => candidate.accountId === accountId);
18
+ if (!account) {
19
+ return calendarFailure("UnknownOrganizer", `no account ${accountId} on this account config to send the invitations from`);
20
+ }
21
+ if (!sendsMail(account)) {
22
+ return calendarFailure("OrganizerCannotSend", `${account.email} has Mail turned off, so it cannot send invitations. Turn Mail on for it in Settings, or send from another account.`);
23
+ }
24
+ return { ok: true, value: account };
25
+ };
26
+ const accountByAddress = (accounts, address) => accounts.find((account) => account.email.toLowerCase() === address);
27
+ /** A guest list as the editor sent it, refused when an entry is not an address. */
28
+ export const guestListOf = (organizer, attendees) => {
29
+ const invalid = invalidGuestOf(attendees);
30
+ if (invalid) {
31
+ return calendarFailure("InvalidGuest", `"${invalid.email}" is not a mail address, so no invitation can go to it`);
32
+ }
33
+ return { ok: true, value: distinctGuests(organizer, attendees) };
34
+ };
35
+ const guestPeopleOf = (calendar) => attendeesOf(calendar.master)
36
+ .filter((attendee) => attendee.role === "Guest")
37
+ .map((attendee) => ({
38
+ email: attendee.email,
39
+ displayName: attendee.displayName,
40
+ }));
41
+ const personOf = (people, email) => people.find((person) => person.email === email) ?? { email, displayName: "" };
42
+ /**
43
+ * Who gets which scheduling message after an organizer saves an event.
44
+ *
45
+ * Every guest still on the list gets the event as it now stands: a save is a
46
+ * revision, and a guest who keeps the old one keeps the old time. A guest the
47
+ * save took off the list gets a cancellation, and only them.
48
+ */
49
+ export const planGuestFanOut = (previousGuests, currentGuests) => {
50
+ const current = new Set(currentGuests);
51
+ return {
52
+ request: [...current],
53
+ cancel: [...new Set(previousGuests)].filter((guest) => !current.has(guest)),
54
+ };
55
+ };
56
+ /**
57
+ * What a delete sends. Removing the whole event cancels it for every guest.
58
+ * Removing one occurrence or the rest of a series leaves an event behind, and
59
+ * the guests get that event — with the EXDATE or the shortened rule — rather
60
+ * than a cancellation of a series that still exists.
61
+ */
62
+ export const planDeleteFanOut = (removedWhole, guests) => ({
63
+ method: removedWhole
64
+ ? CalendarInviteMethod.Cancel
65
+ : CalendarInviteMethod.Request,
66
+ recipients: [...new Set(guests)],
67
+ });
68
+ /** The plain-text body a scheduling message carries beside its calendar part. */
69
+ export const schedulingBody = (subject, icalData) => {
70
+ const start = /\r?\nDTSTART[^:]*:([^\r\n]+)/.exec(icalData)?.[1] ?? "";
71
+ return start === "" ? `${subject}\n` : `${subject}\nWhen: ${start}\n`;
72
+ };
73
+ const requests = (guests, resource, collectionTimezone) => guests.map((guest) => ({
74
+ method: CalendarInviteMethod.Request,
75
+ calendarObjectId: resource.calendarObjectId,
76
+ icalData: resource.icalData,
77
+ collectionTimezone,
78
+ recipient: guest,
79
+ attendee: guest,
80
+ recurrenceId: "",
81
+ }));
82
+ /** What a create must know before it writes: who sends, and to whom. */
83
+ export const planEventCreate = (accounts, settingAccountId, attendees, organizerAccountId) => {
84
+ if (organizerAccountId !== undefined) {
85
+ const named = sendingAccountFor(accounts, organizerAccountId);
86
+ if (!named.ok)
87
+ return named;
88
+ }
89
+ if (attendees.length === 0)
90
+ return { ok: true, value: null };
91
+ const organizer = organizerAccountId !== undefined
92
+ ? sendingAccountFor(accounts, organizerAccountId)
93
+ : (() => {
94
+ const inEffect = invitationSenderInEffect(accounts, settingAccountId);
95
+ return inEffect
96
+ ? { ok: true, value: inEffect }
97
+ : calendarFailure("OrganizerCannotSend", "none of your accounts sends mail, so no invitation can go out");
98
+ })();
99
+ if (!organizer.ok)
100
+ return organizer;
101
+ const guests = guestListOf(organizer.value.email, attendees);
102
+ if (!guests.ok)
103
+ return guests;
104
+ return {
105
+ ok: true,
106
+ value: { organizer: organizer.value, guests: guests.value },
107
+ };
108
+ };
109
+ export const planCreateSends = (planned, stored, collectionTimezone) => ({
110
+ from: planned.organizer,
111
+ plans: requests(planned.guests, stored, collectionTimezone),
112
+ });
113
+ /**
114
+ * Decides who an update writes as and owes messages to, refused before
115
+ * anything is written: a guest list only its organizer may change, an answer
116
+ * only a guest may give, and nobody to send from refuses the save rather than
117
+ * leaving guests uninvited.
118
+ */
119
+ export const planEventUpdate = (calendar, accounts, settingAccountId, scope, update) => {
120
+ const self = selfAddressSet(accounts.map((account) => account.email));
121
+ const storedOrganizer = organizerOf(calendar.master);
122
+ const previousGuests = guestPeopleOf(calendar);
123
+ let answeringAs = "";
124
+ if (update.answer !== undefined) {
125
+ if (scope === RecurrenceScope.Following) {
126
+ return calendarFailure("InvalidScope", "an answer is to one occurrence (This) or to the series (All), never to the rest of it");
127
+ }
128
+ const participation = participationOf(calendar.master, self);
129
+ if (participation === CalendarParticipation.Organizer ||
130
+ participation === CalendarParticipation.Unscheduled) {
131
+ return calendarFailure("NotAGuest", "you are not a guest of this event, so there is no invitation to answer");
132
+ }
133
+ answeringAs =
134
+ calendar.master
135
+ .getAllProperties("attendee")
136
+ .map((line) => calendarAddressOf(line.getFirstValue()))
137
+ .find((address) => self.has(address)) ?? "";
138
+ }
139
+ if (update.organizerAccountId !== undefined) {
140
+ const named = sendingAccountFor(accounts, update.organizerAccountId);
141
+ if (!named.ok)
142
+ return named;
143
+ }
144
+ let organizer;
145
+ if (storedOrganizer !== "" && self.has(storedOrganizer)) {
146
+ organizer = accountByAddress(accounts, storedOrganizer);
147
+ }
148
+ if (update.attendees !== undefined) {
149
+ if (storedOrganizer !== "" && !organizer) {
150
+ return calendarFailure("NotOrganizer", `${storedOrganizer} organizes this event, so only they can change its guests`);
151
+ }
152
+ if (organizer &&
153
+ update.organizerAccountId !== undefined &&
154
+ update.organizerAccountId !== organizer.accountId) {
155
+ return calendarFailure("NotOrganizer", `${storedOrganizer} organizes this event; the organizer of an event never changes`);
156
+ }
157
+ if (!organizer) {
158
+ organizer =
159
+ update.organizerAccountId !== undefined
160
+ ? accounts.find((account) => account.accountId === update.organizerAccountId)
161
+ : invitationSenderInEffect(accounts, settingAccountId);
162
+ }
163
+ }
164
+ if (organizer &&
165
+ (update.attendees !== undefined || previousGuests.length > 0)) {
166
+ const sending = sendingAccountFor(accounts, organizer.accountId);
167
+ if (!sending.ok)
168
+ return sending;
169
+ }
170
+ if (update.attendees !== undefined && !organizer) {
171
+ return calendarFailure("OrganizerCannotSend", "none of your accounts sends mail, so no invitation can go out");
172
+ }
173
+ let guests = previousGuests;
174
+ if (update.attendees !== undefined && organizer) {
175
+ const listed = guestListOf(organizer.email, update.attendees);
176
+ if (!listed.ok)
177
+ return listed;
178
+ guests = listed.value;
179
+ }
180
+ return {
181
+ ok: true,
182
+ value: {
183
+ storedOrganizer,
184
+ previousGuests,
185
+ guests,
186
+ organizer,
187
+ answeringAs,
188
+ organized: organizer !== undefined && guests.length + previousGuests.length > 0,
189
+ },
190
+ };
191
+ };
192
+ const touchesSchedule = (patch) => patch.start !== undefined ||
193
+ patch.end !== undefined ||
194
+ patch.allDay !== undefined ||
195
+ patch.timeZone !== undefined ||
196
+ patch.recurrenceRule !== undefined;
197
+ /**
198
+ * The bytes an update stores: the guest list on the resource the patch applied
199
+ * to, SEQUENCE moved past the last revision when the organizer reschedules, and
200
+ * the reader's own answer on the occurrence or the series they answered.
201
+ */
202
+ export const shapeScheduledUpdate = async (write, planned, update, patch, collectionTimezone, scope, recurrenceId) => {
203
+ const reschedules = planned.organized && touchesSchedule(patch);
204
+ const finish = async (icalData, patched) => {
205
+ let next = icalData;
206
+ if (patched && planned.organizer && update.attendees !== undefined) {
207
+ next = await applyGuestList(next, { email: planned.organizer.email, displayName: "" }, planned.guests);
208
+ }
209
+ if (reschedules)
210
+ next = await bumpSequence(next);
211
+ return next;
212
+ };
213
+ if (write.kind === "Split") {
214
+ return {
215
+ ok: true,
216
+ value: {
217
+ kind: "Split",
218
+ icalData: await finish(write.icalData, false),
219
+ following: await finish(write.following, true),
220
+ },
221
+ };
222
+ }
223
+ const replaced = await finish(write.icalData, true);
224
+ if (update.answer === undefined) {
225
+ return { ok: true, value: { kind: "Replace", icalData: replaced } };
226
+ }
227
+ const answered = await applyAnswer(replaced, collectionTimezone, planned.answeringAs, update.answer, scope === RecurrenceScope.This ? recurrenceId : "");
228
+ if (!answered.ok)
229
+ return answered;
230
+ return { ok: true, value: { kind: "Replace", icalData: answered.value } };
231
+ };
232
+ const guestsOfStored = async (icalData) => {
233
+ const parsed = await parseCalendar(icalData);
234
+ if (!parsed.ok) {
235
+ throw new Error(`a calendar resource this server wrote no longer parses: ${parsed.error.message}`);
236
+ }
237
+ return guestAddressesOf(parsed.value);
238
+ };
239
+ /**
240
+ * The messages an update owes: the revision to every guest of each resource
241
+ * it wrote, a cancellation to each guest it removed, and the reader's reply to
242
+ * the organizer when they answered as a guest.
243
+ */
244
+ export const planUpdateSends = async (planned, accounts, update, original, written, collectionTimezone, scope, recurrenceId) => {
245
+ if (planned.organized && planned.organizer) {
246
+ const fanOut = planGuestFanOut(planned.previousGuests.map((guest) => guest.email), planned.guests.map((guest) => guest.email));
247
+ const plans = [];
248
+ for (const resource of written) {
249
+ const current = await guestsOfStored(resource.icalData);
250
+ plans.push(...requests(fanOut.request
251
+ .filter((email) => current.includes(email))
252
+ .map((email) => personOf(planned.guests, email)), resource, collectionTimezone));
253
+ }
254
+ for (const email of fanOut.cancel) {
255
+ const person = personOf(planned.previousGuests, email);
256
+ plans.push({
257
+ method: CalendarInviteMethod.Cancel,
258
+ calendarObjectId: original.calendarObjectId,
259
+ icalData: original.icalData,
260
+ collectionTimezone,
261
+ recipient: person,
262
+ attendee: person,
263
+ recurrenceId: "",
264
+ });
265
+ }
266
+ return { from: planned.organizer, plans };
267
+ }
268
+ const result = written.at(-1);
269
+ if (update.answer === undefined ||
270
+ !result ||
271
+ planned.storedOrganizer === "") {
272
+ return null;
273
+ }
274
+ const from = accountByAddress(accounts, planned.answeringAs);
275
+ if (!from) {
276
+ throw new Error(`${planned.answeringAs} answered as the reader but is none of their accounts`);
277
+ }
278
+ return {
279
+ from,
280
+ plans: [
281
+ {
282
+ method: CalendarInviteMethod.Reply,
283
+ calendarObjectId: result.calendarObjectId,
284
+ icalData: result.icalData,
285
+ collectionTimezone,
286
+ recipient: { email: planned.storedOrganizer, displayName: "" },
287
+ attendee: { email: planned.answeringAs, displayName: "" },
288
+ recurrenceId: scope === RecurrenceScope.This ? recurrenceId : "",
289
+ answer: update.answer,
290
+ },
291
+ ],
292
+ };
293
+ };
294
+ /** The messages a delete owes, only on an event the reader organizes. */
295
+ export const planDeleteSends = (calendar, accounts, original, remaining, collectionTimezone) => {
296
+ const self = selfAddressSet(accounts.map((account) => account.email));
297
+ const storedOrganizer = organizerOf(calendar.master);
298
+ const guests = guestPeopleOf(calendar);
299
+ const organizer = self.has(storedOrganizer)
300
+ ? accountByAddress(accounts, storedOrganizer)
301
+ : undefined;
302
+ if (!organizer || guests.length === 0)
303
+ return null;
304
+ const fanOut = planDeleteFanOut(remaining === null, guests.map((guest) => guest.email));
305
+ const source = remaining ?? original;
306
+ return {
307
+ from: organizer,
308
+ plans: fanOut.recipients.map((email) => {
309
+ const person = personOf(guests, email);
310
+ return {
311
+ method: fanOut.method,
312
+ calendarObjectId: original.calendarObjectId,
313
+ icalData: source.icalData,
314
+ collectionTimezone,
315
+ recipient: person,
316
+ attendee: person,
317
+ recurrenceId: "",
318
+ };
319
+ }),
320
+ };
321
+ };
@@ -1,4 +1,4 @@
1
- import type { CalendarRepositories, CalendarWrite, ICalendarStore, ICalendarSuggestionRepository } from "@remit/data-ports";
1
+ import type { CalendarCommitReport, CalendarRepositories, CalendarWrite, ICalendarStore, ICalendarSuggestionRepository } from "@remit/data-ports";
2
2
  import { NotFoundError } from "@remit/data-ports/errors";
3
3
  export declare class MissingRow extends NotFoundError {
4
4
  }
@@ -14,7 +14,7 @@ export declare class MemoryCalendarStore implements ICalendarStore {
14
14
  urlSegment: string;
15
15
  displayName: string;
16
16
  color: "Cal1" | "Cal2" | "Cal3" | "Cal4" | "Cal5" | "Cal6";
17
- componentSet: "VeventOnly";
17
+ componentSet: "VeventOnly" | "VtodoOnly";
18
18
  source: "Default" | "UserCreated" | "MailDerived" | "Subscribed";
19
19
  timezone: string;
20
20
  syncSequence: number;
@@ -100,6 +100,6 @@ export declare class MemoryCalendarStore implements ICalendarStore {
100
100
  private readonly feedTokenRepo;
101
101
  get calendarSuggestion(): ICalendarSuggestionRepository;
102
102
  get repos(): CalendarRepositories;
103
- commit(writes: readonly CalendarWrite[]): Promise<void>;
103
+ commit(writes: readonly CalendarWrite[]): Promise<CalendarCommitReport>;
104
104
  private apply;
105
105
  }
@@ -266,8 +266,14 @@ export class MemoryCalendarStore {
266
266
  };
267
267
  }
268
268
  async commit(writes) {
269
- for (const write of writes)
270
- await this.apply(write);
269
+ const staleReprojections = [];
270
+ for (const write of writes) {
271
+ const applied = await this.apply(write);
272
+ if (!applied && write.kind === "ReprojectObject") {
273
+ staleReprojections.push(deriveCalendarObjectId(write.object.calendarId, write.object.resourceName));
274
+ }
275
+ }
276
+ return { staleReprojections };
271
277
  }
272
278
  async apply(write) {
273
279
  switch (write.kind) {
@@ -279,32 +285,32 @@ export class MemoryCalendarStore {
279
285
  syncSequence,
280
286
  });
281
287
  await this.eventIndexRepo.replaceForObject(calendarId, object.calendarObjectId, write.occurrences);
282
- return;
288
+ return true;
283
289
  }
284
290
  case "ReprojectObject": {
285
291
  const { calendarId, resourceName } = write.object;
286
292
  const stored = await this.objectRepo.findByResourceName(calendarId, resourceName);
287
293
  if (stored === null || stored.etag !== write.readEtag)
288
- return;
294
+ return false;
289
295
  await this.apply({
290
296
  kind: "PutObject",
291
297
  accountConfigId: write.accountConfigId,
292
298
  object: write.object,
293
299
  occurrences: write.occurrences,
294
300
  });
295
- return;
301
+ return true;
296
302
  }
297
303
  case "DeleteObject":
298
304
  await this.eventIndexRepo.deleteForObject(write.calendarId, write.calendarObjectId);
299
305
  await this.objectRepo.delete(write.calendarId, write.calendarObjectId);
300
306
  await this.collectionRepo.bumpSyncSequence(write.accountConfigId, write.calendarId);
301
- return;
307
+ return true;
302
308
  case "SettleSuggestion":
303
309
  await this.suggestionRepo.settle(write.accountConfigId, write.suggestionId, write.settle);
304
- return;
310
+ return true;
305
311
  case "UpdateCollection":
306
312
  await this.collectionRepo.update(write.accountConfigId, write.calendarId, write.patch);
307
- return;
313
+ return true;
308
314
  case "DeleteCollection": {
309
315
  for (const object of await this.objectRepo.listByCalendar(write.calendarId)) {
310
316
  await this.eventIndexRepo.deleteForObject(write.calendarId, object.calendarObjectId);
@@ -312,7 +318,7 @@ export class MemoryCalendarStore {
312
318
  }
313
319
  await this.feedTokenRepo.delete(write.accountConfigId, write.calendarId);
314
320
  await this.collectionRepo.delete(write.accountConfigId, write.calendarId);
315
- return;
321
+ return true;
316
322
  }
317
323
  }
318
324
  }