@remit/calendar-service 0.0.20 → 1.0.0
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 +2 -0
- package/dist/accept.js +6 -7
- package/dist/errors.d.ts +13 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.js +7 -2
- package/dist/invitations.d.ts +120 -0
- package/dist/invitations.js +321 -0
- package/dist/memory-store.d.ts +3 -3
- package/dist/memory-store.js +15 -9
- package/dist/participation.d.ts +36 -0
- package/dist/participation.js +104 -0
- package/dist/put.d.ts +7 -0
- package/dist/put.js +14 -1
- package/dist/reply.d.ts +30 -0
- package/dist/reply.js +154 -0
- package/dist/scheduling.d.ts +67 -0
- package/dist/scheduling.js +208 -0
- package/dist/task.d.ts +33 -0
- package/dist/task.js +153 -0
- package/dist/window.d.ts +8 -5
- package/dist/window.js +39 -33
- package/package.json +1 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { CalendarAttendee, CalendarAttendeeCounts, CalendarInviteAnswer as CalendarInviteAnswerValue, CalendarParticipation as CalendarParticipationValue } from "@remit/api-openapi-types";
|
|
2
|
+
import type ICAL from "ical.js";
|
|
3
|
+
import type { ParsedCalendar } from "./parse.js";
|
|
4
|
+
/** The PARTSTAT each answer a guest gives is written as. */
|
|
5
|
+
export declare const PARTSTAT_OF_ANSWER: Record<CalendarInviteAnswerValue, string>;
|
|
6
|
+
/** A CAL-ADDRESS as the lower-cased mail address it names. */
|
|
7
|
+
export declare const calendarAddressOf: (value: unknown) => string;
|
|
8
|
+
/** The addresses that are the reader, lower-cased once for every comparison. */
|
|
9
|
+
export declare const selfAddressSet: (addresses: readonly string[]) => Set<string>;
|
|
10
|
+
/** A PARTSTAT as an answer. Anything that is not a yes, no or maybe is still owed one. */
|
|
11
|
+
export declare const attendeeStatusOf: (property: ICAL.Property) => CalendarAttendee["status"];
|
|
12
|
+
export declare const organizerOf: (component: ICAL.Component) => string;
|
|
13
|
+
/** The ATTENDEE line naming an address, if the component carries one. */
|
|
14
|
+
export declare const attendeeLineOf: (component: ICAL.Component, address: string) => ICAL.Property | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Where the reader stands on one VEVENT.
|
|
17
|
+
*
|
|
18
|
+
* An event with no ATTENDEE line has nobody to schedule, whoever it names as
|
|
19
|
+
* ORGANIZER. One the reader organizes is theirs. Otherwise the reader's own
|
|
20
|
+
* ATTENDEE line carries their answer, and an event that lists other people
|
|
21
|
+
* but not the reader has nothing for the reader to answer.
|
|
22
|
+
*/
|
|
23
|
+
export declare const participationOf: (component: ICAL.Component, self: ReadonlySet<string>) => CalendarParticipationValue;
|
|
24
|
+
/**
|
|
25
|
+
* Where the reader stands on one occurrence of a stored resource: on the
|
|
26
|
+
* override for its slot when the series has one, which carries its own
|
|
27
|
+
* PARTSTAT, and on the master otherwise. Read from the bytes on every read, so
|
|
28
|
+
* nothing stored can disagree with them.
|
|
29
|
+
*/
|
|
30
|
+
export declare const participationAtSlot: (calendar: ParsedCalendar, collectionTimezone: string, recurrenceId: string, self: ReadonlySet<string>) => CalendarParticipationValue;
|
|
31
|
+
/**
|
|
32
|
+
* The guest list of one VEVENT, organizer first. The organizer counts as a
|
|
33
|
+
* yes and appears once, even when an ATTENDEE line names them too.
|
|
34
|
+
*/
|
|
35
|
+
export declare const attendeesOf: (component: ICAL.Component) => CalendarAttendee[];
|
|
36
|
+
export declare const countAttendees: (attendees: readonly CalendarAttendee[]) => CalendarAttendeeCounts;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { CalendarAttendeeRole, CalendarAttendeeStatus, CalendarParticipation, } from "@remit/domain-enums";
|
|
2
|
+
import { overridesBySlot } from "./expand.js";
|
|
3
|
+
import { mailAddressOf } from "./suggest.js";
|
|
4
|
+
/** The PARTSTAT each answer a guest gives is written as. */
|
|
5
|
+
export const PARTSTAT_OF_ANSWER = {
|
|
6
|
+
Accepted: "ACCEPTED",
|
|
7
|
+
Tentative: "TENTATIVE",
|
|
8
|
+
Declined: "DECLINED",
|
|
9
|
+
};
|
|
10
|
+
/** A CAL-ADDRESS as the lower-cased mail address it names. */
|
|
11
|
+
export const calendarAddressOf = (value) => mailAddressOf(String(value ?? ""))
|
|
12
|
+
.trim()
|
|
13
|
+
.toLowerCase();
|
|
14
|
+
/** The addresses that are the reader, lower-cased once for every comparison. */
|
|
15
|
+
export const selfAddressSet = (addresses) => new Set(addresses.map((address) => address.trim().toLowerCase()));
|
|
16
|
+
const STATUS_BY_PARTSTAT = {
|
|
17
|
+
ACCEPTED: CalendarAttendeeStatus.Accepted,
|
|
18
|
+
TENTATIVE: CalendarAttendeeStatus.Tentative,
|
|
19
|
+
DECLINED: CalendarAttendeeStatus.Declined,
|
|
20
|
+
};
|
|
21
|
+
const PARTICIPATION_BY_STATUS = {
|
|
22
|
+
[CalendarAttendeeStatus.NeedsAction]: CalendarParticipation.NeedsAction,
|
|
23
|
+
[CalendarAttendeeStatus.Accepted]: CalendarParticipation.Accepted,
|
|
24
|
+
[CalendarAttendeeStatus.Tentative]: CalendarParticipation.Tentative,
|
|
25
|
+
[CalendarAttendeeStatus.Declined]: CalendarParticipation.Declined,
|
|
26
|
+
};
|
|
27
|
+
/** A PARTSTAT as an answer. Anything that is not a yes, no or maybe is still owed one. */
|
|
28
|
+
export const attendeeStatusOf = (property) => STATUS_BY_PARTSTAT[String(property.getParameter("partstat") ?? "").toUpperCase()] ?? CalendarAttendeeStatus.NeedsAction;
|
|
29
|
+
export const organizerOf = (component) => calendarAddressOf(component.getFirstPropertyValue("organizer"));
|
|
30
|
+
/** The ATTENDEE line naming an address, if the component carries one. */
|
|
31
|
+
export const attendeeLineOf = (component, address) => component
|
|
32
|
+
.getAllProperties("attendee")
|
|
33
|
+
.find((property) => calendarAddressOf(property.getFirstValue()) === address);
|
|
34
|
+
/**
|
|
35
|
+
* Where the reader stands on one VEVENT.
|
|
36
|
+
*
|
|
37
|
+
* An event with no ATTENDEE line has nobody to schedule, whoever it names as
|
|
38
|
+
* ORGANIZER. One the reader organizes is theirs. Otherwise the reader's own
|
|
39
|
+
* ATTENDEE line carries their answer, and an event that lists other people
|
|
40
|
+
* but not the reader has nothing for the reader to answer.
|
|
41
|
+
*/
|
|
42
|
+
export const participationOf = (component, self) => {
|
|
43
|
+
const attendees = component.getAllProperties("attendee");
|
|
44
|
+
if (attendees.length === 0)
|
|
45
|
+
return CalendarParticipation.Unscheduled;
|
|
46
|
+
if (self.has(organizerOf(component)))
|
|
47
|
+
return CalendarParticipation.Organizer;
|
|
48
|
+
const own = attendees.find((property) => self.has(calendarAddressOf(property.getFirstValue())));
|
|
49
|
+
if (!own)
|
|
50
|
+
return CalendarParticipation.Unscheduled;
|
|
51
|
+
return PARTICIPATION_BY_STATUS[attendeeStatusOf(own)];
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Where the reader stands on one occurrence of a stored resource: on the
|
|
55
|
+
* override for its slot when the series has one, which carries its own
|
|
56
|
+
* PARTSTAT, and on the master otherwise. Read from the bytes on every read, so
|
|
57
|
+
* nothing stored can disagree with them.
|
|
58
|
+
*/
|
|
59
|
+
export const participationAtSlot = (calendar, collectionTimezone, recurrenceId, self) => {
|
|
60
|
+
const override = recurrenceId === ""
|
|
61
|
+
? undefined
|
|
62
|
+
: overridesBySlot(calendar, collectionTimezone).get(recurrenceId);
|
|
63
|
+
return participationOf(override ?? calendar.master, self);
|
|
64
|
+
};
|
|
65
|
+
const textParameter = (property, name) => {
|
|
66
|
+
const value = property.getParameter(name);
|
|
67
|
+
return typeof value === "string" ? value : "";
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* The guest list of one VEVENT, organizer first. The organizer counts as a
|
|
71
|
+
* yes and appears once, even when an ATTENDEE line names them too.
|
|
72
|
+
*/
|
|
73
|
+
export const attendeesOf = (component) => {
|
|
74
|
+
const lines = component.getAllProperties("attendee");
|
|
75
|
+
if (lines.length === 0)
|
|
76
|
+
return [];
|
|
77
|
+
const organizerProperty = component.getFirstProperty("organizer");
|
|
78
|
+
const organizer = organizerOf(component);
|
|
79
|
+
const guests = lines
|
|
80
|
+
.map((property) => ({
|
|
81
|
+
email: calendarAddressOf(property.getFirstValue()),
|
|
82
|
+
displayName: textParameter(property, "cn"),
|
|
83
|
+
role: CalendarAttendeeRole.Guest,
|
|
84
|
+
status: attendeeStatusOf(property),
|
|
85
|
+
}))
|
|
86
|
+
.filter((guest) => guest.email !== "" && guest.email !== organizer);
|
|
87
|
+
if (organizer === "" || !organizerProperty)
|
|
88
|
+
return guests;
|
|
89
|
+
return [
|
|
90
|
+
{
|
|
91
|
+
email: organizer,
|
|
92
|
+
displayName: textParameter(organizerProperty, "cn"),
|
|
93
|
+
role: CalendarAttendeeRole.Organizer,
|
|
94
|
+
status: CalendarAttendeeStatus.Accepted,
|
|
95
|
+
},
|
|
96
|
+
...guests,
|
|
97
|
+
];
|
|
98
|
+
};
|
|
99
|
+
export const countAttendees = (attendees) => ({
|
|
100
|
+
accepted: attendees.filter((attendee) => attendee.status === CalendarAttendeeStatus.Accepted).length,
|
|
101
|
+
declined: attendees.filter((attendee) => attendee.status === CalendarAttendeeStatus.Declined).length,
|
|
102
|
+
tentative: attendees.filter((attendee) => attendee.status === CalendarAttendeeStatus.Tentative).length,
|
|
103
|
+
needsAction: attendees.filter((attendee) => attendee.status === CalendarAttendeeStatus.NeedsAction).length,
|
|
104
|
+
});
|
package/dist/put.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { CalendarCollectionItem, CalendarObjectItem, DeleteCalendarObjectWrite, ICalendarStore, PutCalendarObjectWrite } from "@remit/data-ports";
|
|
2
2
|
import type { CalendarResult } from "./errors.js";
|
|
3
|
+
/** URL segment of the task collection every account config is provisioned with. */
|
|
4
|
+
export declare const TASK_CALENDAR_URL_SEGMENT = "tasks";
|
|
3
5
|
/** URL segment of the collection every account config is provisioned with. */
|
|
4
6
|
export declare const DEFAULT_CALENDAR_URL_SEGMENT = "default";
|
|
5
7
|
export interface PutCalendarObjectInput {
|
|
@@ -50,3 +52,8 @@ export declare const deleteCalendarObject: (store: ICalendarStore, input: {
|
|
|
50
52
|
* the collection the first one made rather than a rival copy of it.
|
|
51
53
|
*/
|
|
52
54
|
export declare const provisionDefaultCalendar: (store: ICalendarStore, accountConfigId: string, displayName?: string) => Promise<CalendarCollectionItem>;
|
|
55
|
+
/**
|
|
56
|
+
* The collection an account config keeps its tasks in. Provisioned beside the
|
|
57
|
+
* default event collection, idempotently, for the same reason.
|
|
58
|
+
*/
|
|
59
|
+
export declare const provisionTaskCalendar: (store: ICalendarStore, accountConfigId: string, displayName?: string) => Promise<CalendarCollectionItem>;
|
package/dist/put.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { deriveCalendarObjectId } from "@remit/data-ports/id";
|
|
2
|
-
import { CalendarSource } from "@remit/domain-enums";
|
|
2
|
+
import { CalendarComponentSet, CalendarSource } from "@remit/domain-enums";
|
|
3
3
|
import { computeEtag } from "./etag.js";
|
|
4
4
|
import { expandCalendar } from "./expand.js";
|
|
5
5
|
import { parseCalendar } from "./parse.js";
|
|
6
6
|
import { projectCalendar } from "./project.js";
|
|
7
|
+
/** URL segment of the task collection every account config is provisioned with. */
|
|
8
|
+
export const TASK_CALENDAR_URL_SEGMENT = "tasks";
|
|
7
9
|
/** URL segment of the collection every account config is provisioned with. */
|
|
8
10
|
export const DEFAULT_CALENDAR_URL_SEGMENT = "default";
|
|
9
11
|
/**
|
|
@@ -76,3 +78,14 @@ export const provisionDefaultCalendar = (store, accountConfigId, displayName = "
|
|
|
76
78
|
displayName,
|
|
77
79
|
source: CalendarSource.Default,
|
|
78
80
|
});
|
|
81
|
+
/**
|
|
82
|
+
* The collection an account config keeps its tasks in. Provisioned beside the
|
|
83
|
+
* default event collection, idempotently, for the same reason.
|
|
84
|
+
*/
|
|
85
|
+
export const provisionTaskCalendar = (store, accountConfigId, displayName = "Tasks") => store.repos.calendarCollection.create({
|
|
86
|
+
accountConfigId,
|
|
87
|
+
urlSegment: TASK_CALENDAR_URL_SEGMENT,
|
|
88
|
+
displayName,
|
|
89
|
+
componentSet: CalendarComponentSet.VtodoOnly,
|
|
90
|
+
source: CalendarSource.Default,
|
|
91
|
+
});
|
package/dist/reply.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ICalendarStore } from "@remit/data-ports";
|
|
2
|
+
/** A reply's place in the order replies are applied in. */
|
|
3
|
+
export interface ReplyRevision {
|
|
4
|
+
sequence: number;
|
|
5
|
+
/** The reply's DTSTAMP, UTC basic form (`20261103T120000Z`), or `""`. */
|
|
6
|
+
stamp: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Whether a reply overtakes the answer the organizer's copy already holds
|
|
10
|
+
* (RFC 5546 2.1.5): a reply to an older revision of the event is stale, and of
|
|
11
|
+
* two replies to the same revision the later DTSTAMP wins.
|
|
12
|
+
*/
|
|
13
|
+
export declare const replyOvertakes: (stored: ReplyRevision, reply: ReplyRevision) => boolean;
|
|
14
|
+
export type RecordCalendarReplyOutcome = "Recorded" | "NotAReply" | "Unreadable" | "NotOrganized" | "Stale";
|
|
15
|
+
export interface RecordCalendarReplyInput {
|
|
16
|
+
accountConfigId: string;
|
|
17
|
+
/** The `text/calendar` part as it arrived. */
|
|
18
|
+
icalData: string;
|
|
19
|
+
/** The addresses of the account config's accounts. */
|
|
20
|
+
selfAddresses: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Applies a guest's `METHOD:REPLY` to the event the reader organizes.
|
|
24
|
+
*
|
|
25
|
+
* The write is local: the organizer's copy is the calendar row, and it goes
|
|
26
|
+
* through the single write path so the guest's answer reaches the occurrence
|
|
27
|
+
* index too. Nothing is sent back. A reply the reader does not organize, or
|
|
28
|
+
* one older than the answer already held, changes nothing.
|
|
29
|
+
*/
|
|
30
|
+
export declare const recordCalendarReply: (store: ICalendarStore, input: RecordCalendarReplyInput) => Promise<RecordCalendarReplyOutcome>;
|
package/dist/reply.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { CalendarInviteMethod, RecurrenceScope } from "@remit/domain-enums";
|
|
2
|
+
import ICAL from "ical.js";
|
|
3
|
+
import { overridesBySlot } from "./expand.js";
|
|
4
|
+
import { parseCalendar, serializeCalendar, } from "./parse.js";
|
|
5
|
+
import { attendeeLineOf, calendarAddressOf, organizerOf, selfAddressSet, } from "./participation.js";
|
|
6
|
+
/**
|
|
7
|
+
* Where the organizer's copy keeps the DTSTAMP of the reply it last applied for
|
|
8
|
+
* a guest, so a reply that arrives late cannot undo a newer one.
|
|
9
|
+
*/
|
|
10
|
+
const APPLIED_REPLY_STAMP = "x-remit-reply-stamp";
|
|
11
|
+
import { prepareCalendarObject } from "./put.js";
|
|
12
|
+
import { applyScopedUpdate } from "./scope.js";
|
|
13
|
+
import { inviteMethodOf } from "./suggest.js";
|
|
14
|
+
import { resolveTime, tzidOf } from "./time.js";
|
|
15
|
+
/**
|
|
16
|
+
* Whether a reply overtakes the answer the organizer's copy already holds
|
|
17
|
+
* (RFC 5546 2.1.5): a reply to an older revision of the event is stale, and of
|
|
18
|
+
* two replies to the same revision the later DTSTAMP wins.
|
|
19
|
+
*/
|
|
20
|
+
export const replyOvertakes = (stored, reply) => {
|
|
21
|
+
if (reply.sequence < stored.sequence)
|
|
22
|
+
return false;
|
|
23
|
+
if (stored.stamp === "")
|
|
24
|
+
return true;
|
|
25
|
+
return reply.stamp > stored.stamp;
|
|
26
|
+
};
|
|
27
|
+
const sequenceOf = (component) => {
|
|
28
|
+
const value = component.getFirstPropertyValue("sequence");
|
|
29
|
+
return typeof value === "number" ? value : 0;
|
|
30
|
+
};
|
|
31
|
+
const stampOf = (component) => {
|
|
32
|
+
const value = component.getFirstPropertyValue("dtstamp");
|
|
33
|
+
return value instanceof ICAL.Time
|
|
34
|
+
? value.convertToZone(ICAL.Timezone.utcTimezone).toICALString()
|
|
35
|
+
: "";
|
|
36
|
+
};
|
|
37
|
+
const recurrenceSlotOf = (component, collectionTimezone) => {
|
|
38
|
+
const value = component.getFirstPropertyValue("recurrence-id");
|
|
39
|
+
if (!(value instanceof ICAL.Time))
|
|
40
|
+
return "";
|
|
41
|
+
return resolveTime(value, tzidOf(component.getFirstProperty("recurrence-id")), collectionTimezone).isoUtc;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* The organizer's VEVENTs one reply VEVENT answers: the override for its
|
|
45
|
+
* RECURRENCE-ID, made when the series has none yet, or every VEVENT when the
|
|
46
|
+
* reply answers the series.
|
|
47
|
+
*/
|
|
48
|
+
const targetsOf = async (stored, collectionTimezone, slot) => {
|
|
49
|
+
if (slot === "") {
|
|
50
|
+
return { calendar: stored, targets: [stored.master, ...stored.overrides] };
|
|
51
|
+
}
|
|
52
|
+
const existing = overridesBySlot(stored, collectionTimezone).get(slot);
|
|
53
|
+
if (existing)
|
|
54
|
+
return { calendar: stored, targets: [existing] };
|
|
55
|
+
const written = await applyScopedUpdate(stored, collectionTimezone, { scope: RecurrenceScope.This, recurrenceId: slot, followingUid: "" }, {});
|
|
56
|
+
if (!written.ok) {
|
|
57
|
+
throw new Error(`a reply to ${slot} could not be given an override of its own: ${written.error.message}`);
|
|
58
|
+
}
|
|
59
|
+
if (written.value.kind !== "Replace") {
|
|
60
|
+
throw new Error(`a reply to ${slot} resolved to a ${written.value.kind} rather than an override`);
|
|
61
|
+
}
|
|
62
|
+
const reparsed = await parseCalendar(written.value.icalData);
|
|
63
|
+
if (!reparsed.ok) {
|
|
64
|
+
throw new Error(`an override this server wrote no longer parses: ${reparsed.error.message}`);
|
|
65
|
+
}
|
|
66
|
+
const override = overridesBySlot(reparsed.value, collectionTimezone).get(slot);
|
|
67
|
+
return { calendar: reparsed.value, targets: override ? [override] : [] };
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Applies a guest's `METHOD:REPLY` to the event the reader organizes.
|
|
71
|
+
*
|
|
72
|
+
* The write is local: the organizer's copy is the calendar row, and it goes
|
|
73
|
+
* through the single write path so the guest's answer reaches the occurrence
|
|
74
|
+
* index too. Nothing is sent back. A reply the reader does not organize, or
|
|
75
|
+
* one older than the answer already held, changes nothing.
|
|
76
|
+
*/
|
|
77
|
+
export const recordCalendarReply = async (store, input) => {
|
|
78
|
+
const reply = await parseCalendar(input.icalData);
|
|
79
|
+
if (!reply.ok)
|
|
80
|
+
return "Unreadable";
|
|
81
|
+
if (inviteMethodOf(reply.value.component) !== CalendarInviteMethod.Reply) {
|
|
82
|
+
return "NotAReply";
|
|
83
|
+
}
|
|
84
|
+
const self = selfAddressSet(input.selfAddresses);
|
|
85
|
+
const collections = await store.repos.calendarCollection.listByAccountConfig(input.accountConfigId);
|
|
86
|
+
for (const collection of collections) {
|
|
87
|
+
const object = await store.repos.calendarObject.findByUid(collection.calendarId, reply.value.uid);
|
|
88
|
+
if (!object)
|
|
89
|
+
continue;
|
|
90
|
+
const stored = await parseCalendar(object.icalData);
|
|
91
|
+
if (!stored.ok) {
|
|
92
|
+
throw new Error(`stored calendar object ${object.calendarObjectId} no longer parses: ${stored.error.message}`);
|
|
93
|
+
}
|
|
94
|
+
if (!self.has(organizerOf(stored.value.master)))
|
|
95
|
+
return "NotOrganized";
|
|
96
|
+
let calendar = stored.value;
|
|
97
|
+
let changed = false;
|
|
98
|
+
for (const answered of [reply.value.master, ...reply.value.overrides]) {
|
|
99
|
+
const revision = {
|
|
100
|
+
sequence: sequenceOf(answered),
|
|
101
|
+
stamp: stampOf(answered),
|
|
102
|
+
};
|
|
103
|
+
const found = await targetsOf(calendar, collection.timezone, recurrenceSlotOf(answered, collection.timezone));
|
|
104
|
+
calendar = found.calendar;
|
|
105
|
+
for (const line of answered.getAllProperties("attendee")) {
|
|
106
|
+
const guest = calendarAddressOf(line.getFirstValue());
|
|
107
|
+
const partstat = line.getParameter("partstat");
|
|
108
|
+
if (typeof partstat !== "string")
|
|
109
|
+
continue;
|
|
110
|
+
for (const target of found.targets) {
|
|
111
|
+
const own = attendeeLineOf(target, guest);
|
|
112
|
+
if (!own)
|
|
113
|
+
continue;
|
|
114
|
+
const heldStamp = own.getParameter(APPLIED_REPLY_STAMP);
|
|
115
|
+
const held = {
|
|
116
|
+
sequence: sequenceOf(target),
|
|
117
|
+
stamp: typeof heldStamp === "string" ? heldStamp : "",
|
|
118
|
+
};
|
|
119
|
+
if (!replyOvertakes(held, revision))
|
|
120
|
+
continue;
|
|
121
|
+
own.setParameter("partstat", partstat.toUpperCase());
|
|
122
|
+
own.removeParameter("rsvp");
|
|
123
|
+
if (revision.stamp !== "") {
|
|
124
|
+
own.setParameter(APPLIED_REPLY_STAMP, revision.stamp);
|
|
125
|
+
}
|
|
126
|
+
changed = true;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (!changed)
|
|
131
|
+
return "Stale";
|
|
132
|
+
const write = await prepareCalendarObject(collection, {
|
|
133
|
+
accountConfigId: input.accountConfigId,
|
|
134
|
+
calendarId: collection.calendarId,
|
|
135
|
+
resourceName: object.resourceName,
|
|
136
|
+
icalData: serializeCalendar(calendar.component),
|
|
137
|
+
});
|
|
138
|
+
if (!write.ok) {
|
|
139
|
+
throw new Error(`a reply applied to ${object.calendarObjectId} was refused by the write path: ${write.error.message}`);
|
|
140
|
+
}
|
|
141
|
+
// Conditional on the version the reply was applied to. An edit that
|
|
142
|
+
// landed meanwhile is a conflict: the write is refused loudly, the
|
|
143
|
+
// body-sync job fails, and the queue's redelivery applies the reply to
|
|
144
|
+
// the event as it now stands.
|
|
145
|
+
const report = await store.commit([
|
|
146
|
+
{ ...write.value, kind: "ReprojectObject", readEtag: object.etag },
|
|
147
|
+
]);
|
|
148
|
+
if (report.staleReprojections.length > 0) {
|
|
149
|
+
throw new Error(`${object.calendarObjectId} changed while a guest's reply was applied to it`);
|
|
150
|
+
}
|
|
151
|
+
return "Recorded";
|
|
152
|
+
}
|
|
153
|
+
return "NotOrganized";
|
|
154
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { CalendarInviteAnswer as CalendarInviteAnswerValue } from "@remit/api-openapi-types";
|
|
2
|
+
import { CalendarInviteMethod } from "@remit/domain-enums";
|
|
3
|
+
import { type CalendarResult } from "./errors.js";
|
|
4
|
+
import { type ParsedCalendar } from "./parse.js";
|
|
5
|
+
/** A person as the scheduling lines name them. */
|
|
6
|
+
export interface CalendarPerson {
|
|
7
|
+
email: string;
|
|
8
|
+
displayName: string;
|
|
9
|
+
}
|
|
10
|
+
/** The iTIP methods this server sends. */
|
|
11
|
+
export type SchedulingMethod = typeof CalendarInviteMethod.Request | typeof CalendarInviteMethod.Cancel | typeof CalendarInviteMethod.Reply;
|
|
12
|
+
/** The guest addresses of a resource's master, organizer excluded. */
|
|
13
|
+
export declare const guestAddressesOf: (calendar: ParsedCalendar) => string[];
|
|
14
|
+
/**
|
|
15
|
+
* Two addresses for one guest is one guest. A guest list that names the
|
|
16
|
+
* organizer drops them: the organizer is on the event as its ORGANIZER.
|
|
17
|
+
*/
|
|
18
|
+
export declare const distinctGuests: (organizer: string, guests: readonly CalendarPerson[]) => CalendarPerson[];
|
|
19
|
+
/** The guest an editor sent that is not a mail address, if there is one. */
|
|
20
|
+
export declare const invalidGuestOf: (guests: readonly CalendarPerson[]) => CalendarPerson | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* Writes the organizer and the guest list onto every VEVENT of a resource.
|
|
23
|
+
*
|
|
24
|
+
* A guest already on the list keeps their ATTENDEE line as it stands, answer
|
|
25
|
+
* included; a new one is owed an answer. The organizer is never an ATTENDEE of their own event here: they are
|
|
26
|
+
* the ORGANIZER, and every client reads that as a yes.
|
|
27
|
+
*/
|
|
28
|
+
export declare const applyGuestList: (icalData: string, organizer: CalendarPerson, guests: readonly CalendarPerson[]) => Promise<string>;
|
|
29
|
+
/**
|
|
30
|
+
* Moves SEQUENCE past every value the resource carries, on every VEVENT. A
|
|
31
|
+
* recipient keeps the highest revision it has seen, so a reschedule has to
|
|
32
|
+
* outrank the invitation it replaces.
|
|
33
|
+
*/
|
|
34
|
+
export declare const bumpSequence: (icalData: string) => Promise<string>;
|
|
35
|
+
/**
|
|
36
|
+
* Writes the reader's PARTSTAT. `recurrenceId` names the one occurrence
|
|
37
|
+
* answered, or `""` for the series. Answered DTSTAMP is the organizer's
|
|
38
|
+
* ordering key when two replies cross, so the reply is stamped, not the event.
|
|
39
|
+
*/
|
|
40
|
+
export declare const applyAnswer: (icalData: string, collectionTimezone: string, attendee: string, answer: CalendarInviteAnswerValue, recurrenceId: string) => Promise<CalendarResult<string>>;
|
|
41
|
+
export interface SchedulingMessageInput {
|
|
42
|
+
method: SchedulingMethod;
|
|
43
|
+
/** When the message is built; written as every VEVENT's DTSTAMP. */
|
|
44
|
+
now: Date;
|
|
45
|
+
/**
|
|
46
|
+
* A REQUEST or CANCEL: the guest it goes to, so a cancellation to a
|
|
47
|
+
* removed guest still names them. A REPLY: the guest answering, the only
|
|
48
|
+
* ATTENDEE it carries.
|
|
49
|
+
*/
|
|
50
|
+
attendee: CalendarPerson;
|
|
51
|
+
/** A REPLY to one occurrence names it here; `""` answers the series. */
|
|
52
|
+
recurrenceId: string;
|
|
53
|
+
collectionTimezone: string;
|
|
54
|
+
}
|
|
55
|
+
export interface SchedulingMessage {
|
|
56
|
+
icalData: string;
|
|
57
|
+
sequence: number;
|
|
58
|
+
summary: string;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The VCALENDAR one scheduling message carries (RFC 5546), built from the
|
|
62
|
+
* stored resource it is about. The stored resource never carries METHOD
|
|
63
|
+
* (RFC 4791 4.1); the message always does.
|
|
64
|
+
*/
|
|
65
|
+
export declare const buildSchedulingMessage: (icalData: string, input: SchedulingMessageInput) => Promise<SchedulingMessage>;
|
|
66
|
+
/** The subject line a scheduling message goes out under. */
|
|
67
|
+
export declare const schedulingSubject: (method: SchedulingMethod, summary: string, answer?: CalendarInviteAnswerValue) => string;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { CalendarInviteAnswer, CalendarInviteMethod, } from "@remit/domain-enums";
|
|
2
|
+
import ICAL from "ical.js";
|
|
3
|
+
import { calendarFailure } from "./errors.js";
|
|
4
|
+
import { overridesBySlot } from "./expand.js";
|
|
5
|
+
import { parseCalendar, serializeCalendar, } from "./parse.js";
|
|
6
|
+
import { attendeeLineOf, calendarAddressOf, organizerOf, PARTSTAT_OF_ANSWER, } from "./participation.js";
|
|
7
|
+
import { readString } from "./project.js";
|
|
8
|
+
const ICAL_METHOD = {
|
|
9
|
+
[CalendarInviteMethod.Request]: "REQUEST",
|
|
10
|
+
[CalendarInviteMethod.Cancel]: "CANCEL",
|
|
11
|
+
[CalendarInviteMethod.Reply]: "REPLY",
|
|
12
|
+
};
|
|
13
|
+
const eventsOf = (calendar) => [
|
|
14
|
+
calendar.master,
|
|
15
|
+
...calendar.overrides,
|
|
16
|
+
];
|
|
17
|
+
const normalized = (email) => email.trim().toLowerCase();
|
|
18
|
+
const personProperty = (name, person) => {
|
|
19
|
+
const property = new ICAL.Property(name);
|
|
20
|
+
property.setValue(`mailto:${normalized(person.email)}`);
|
|
21
|
+
if (person.displayName !== "")
|
|
22
|
+
property.setParameter("cn", person.displayName);
|
|
23
|
+
return property;
|
|
24
|
+
};
|
|
25
|
+
const parsedOrThrow = async (icalData) => {
|
|
26
|
+
const parsed = await parseCalendar(icalData);
|
|
27
|
+
if (!parsed.ok) {
|
|
28
|
+
throw new Error(`a calendar resource this server wrote no longer parses: ${parsed.error.message}`);
|
|
29
|
+
}
|
|
30
|
+
return parsed.value;
|
|
31
|
+
};
|
|
32
|
+
/** The guest addresses of a resource's master, organizer excluded. */
|
|
33
|
+
export const guestAddressesOf = (calendar) => {
|
|
34
|
+
const organizer = organizerOf(calendar.master);
|
|
35
|
+
return calendar.master
|
|
36
|
+
.getAllProperties("attendee")
|
|
37
|
+
.map((property) => calendarAddressOf(property.getFirstValue()))
|
|
38
|
+
.filter((email) => email !== "" && email !== organizer);
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Two addresses for one guest is one guest. A guest list that names the
|
|
42
|
+
* organizer drops them: the organizer is on the event as its ORGANIZER.
|
|
43
|
+
*/
|
|
44
|
+
export const distinctGuests = (organizer, guests) => {
|
|
45
|
+
const seen = new Set([normalized(organizer)]);
|
|
46
|
+
const distinct = [];
|
|
47
|
+
for (const guest of guests) {
|
|
48
|
+
const email = normalized(guest.email);
|
|
49
|
+
if (email === "" || seen.has(email))
|
|
50
|
+
continue;
|
|
51
|
+
seen.add(email);
|
|
52
|
+
distinct.push({ email, displayName: guest.displayName });
|
|
53
|
+
}
|
|
54
|
+
return distinct;
|
|
55
|
+
};
|
|
56
|
+
const EMAIL_SHAPE = /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/;
|
|
57
|
+
/** The guest an editor sent that is not a mail address, if there is one. */
|
|
58
|
+
export const invalidGuestOf = (guests) => guests.find((guest) => !EMAIL_SHAPE.test(guest.email.trim()));
|
|
59
|
+
/**
|
|
60
|
+
* Writes the organizer and the guest list onto every VEVENT of a resource.
|
|
61
|
+
*
|
|
62
|
+
* A guest already on the list keeps their ATTENDEE line as it stands, answer
|
|
63
|
+
* included; a new one is owed an answer. The organizer is never an ATTENDEE of their own event here: they are
|
|
64
|
+
* the ORGANIZER, and every client reads that as a yes.
|
|
65
|
+
*/
|
|
66
|
+
export const applyGuestList = async (icalData, organizer, guests) => {
|
|
67
|
+
const calendar = await parsedOrThrow(icalData);
|
|
68
|
+
const listed = distinctGuests(organizer.email, guests);
|
|
69
|
+
for (const event of eventsOf(calendar)) {
|
|
70
|
+
const previous = new Map(event
|
|
71
|
+
.getAllProperties("attendee")
|
|
72
|
+
.map((property) => [
|
|
73
|
+
calendarAddressOf(property.getFirstValue()),
|
|
74
|
+
property,
|
|
75
|
+
]));
|
|
76
|
+
event.removeAllProperties("organizer");
|
|
77
|
+
event.removeAllProperties("attendee");
|
|
78
|
+
if (listed.length === 0)
|
|
79
|
+
continue;
|
|
80
|
+
event.addProperty(personProperty("organizer", organizer));
|
|
81
|
+
for (const guest of listed) {
|
|
82
|
+
const kept = previous.get(guest.email);
|
|
83
|
+
if (kept) {
|
|
84
|
+
if (guest.displayName !== "")
|
|
85
|
+
kept.setParameter("cn", guest.displayName);
|
|
86
|
+
event.addProperty(kept);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const property = personProperty("attendee", guest);
|
|
90
|
+
property.setParameter("role", "REQ-PARTICIPANT");
|
|
91
|
+
property.setParameter("partstat", "NEEDS-ACTION");
|
|
92
|
+
property.setParameter("rsvp", "TRUE");
|
|
93
|
+
event.addProperty(property);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return serializeCalendar(calendar.component);
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* Moves SEQUENCE past every value the resource carries, on every VEVENT. A
|
|
100
|
+
* recipient keeps the highest revision it has seen, so a reschedule has to
|
|
101
|
+
* outrank the invitation it replaces.
|
|
102
|
+
*/
|
|
103
|
+
export const bumpSequence = async (icalData) => {
|
|
104
|
+
const calendar = await parsedOrThrow(icalData);
|
|
105
|
+
const events = eventsOf(calendar);
|
|
106
|
+
const highest = Math.max(...events.map((event) => {
|
|
107
|
+
const value = event.getFirstPropertyValue("sequence");
|
|
108
|
+
return typeof value === "number" ? value : 0;
|
|
109
|
+
}));
|
|
110
|
+
for (const event of events) {
|
|
111
|
+
event.removeAllProperties("sequence");
|
|
112
|
+
event.addPropertyWithValue("sequence", highest + 1);
|
|
113
|
+
}
|
|
114
|
+
return serializeCalendar(calendar.component);
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* The VEVENTs an answer is written on: the override for one occurrence, or
|
|
118
|
+
* every VEVENT for the series. The override is the one `applyScopedUpdate`
|
|
119
|
+
* made or found for that slot, so it is already in the resource.
|
|
120
|
+
*/
|
|
121
|
+
const answeredEvents = (calendar, collectionTimezone, recurrenceId) => {
|
|
122
|
+
if (recurrenceId === "")
|
|
123
|
+
return eventsOf(calendar);
|
|
124
|
+
const override = overridesBySlot(calendar, collectionTimezone).get(recurrenceId);
|
|
125
|
+
return override ? [override] : [];
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* Writes the reader's PARTSTAT. `recurrenceId` names the one occurrence
|
|
129
|
+
* answered, or `""` for the series. Answered DTSTAMP is the organizer's
|
|
130
|
+
* ordering key when two replies cross, so the reply is stamped, not the event.
|
|
131
|
+
*/
|
|
132
|
+
export const applyAnswer = async (icalData, collectionTimezone, attendee, answer, recurrenceId) => {
|
|
133
|
+
const calendar = await parsedOrThrow(icalData);
|
|
134
|
+
const events = answeredEvents(calendar, collectionTimezone, recurrenceId);
|
|
135
|
+
const lines = events.flatMap((event) => {
|
|
136
|
+
const line = attendeeLineOf(event, attendee);
|
|
137
|
+
return line ? [line] : [];
|
|
138
|
+
});
|
|
139
|
+
if (lines.length === 0) {
|
|
140
|
+
return calendarFailure("UnknownOccurrence", `${attendee} is not on the guest list of that occurrence`);
|
|
141
|
+
}
|
|
142
|
+
for (const line of lines) {
|
|
143
|
+
line.setParameter("partstat", PARTSTAT_OF_ANSWER[answer]);
|
|
144
|
+
line.removeParameter("rsvp");
|
|
145
|
+
}
|
|
146
|
+
return { ok: true, value: serializeCalendar(calendar.component) };
|
|
147
|
+
};
|
|
148
|
+
/**
|
|
149
|
+
* The VCALENDAR one scheduling message carries (RFC 5546), built from the
|
|
150
|
+
* stored resource it is about. The stored resource never carries METHOD
|
|
151
|
+
* (RFC 4791 4.1); the message always does.
|
|
152
|
+
*/
|
|
153
|
+
export const buildSchedulingMessage = async (icalData, input) => {
|
|
154
|
+
const calendar = await parsedOrThrow(icalData);
|
|
155
|
+
const { component } = calendar;
|
|
156
|
+
component.removeAllProperties("method");
|
|
157
|
+
component.addPropertyWithValue("method", ICAL_METHOD[input.method]);
|
|
158
|
+
const stamp = ICAL.Time.fromJSDate(input.now, true);
|
|
159
|
+
const attendee = normalized(input.attendee.email);
|
|
160
|
+
let events = eventsOf(calendar);
|
|
161
|
+
if (input.method === CalendarInviteMethod.Reply &&
|
|
162
|
+
input.recurrenceId !== "") {
|
|
163
|
+
events = answeredEvents(calendar, input.collectionTimezone, input.recurrenceId);
|
|
164
|
+
for (const event of eventsOf(calendar)) {
|
|
165
|
+
if (!events.includes(event))
|
|
166
|
+
component.removeSubcomponent(event);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
for (const event of events) {
|
|
170
|
+
event.removeAllProperties("dtstamp");
|
|
171
|
+
event.addPropertyWithValue("dtstamp", stamp);
|
|
172
|
+
if (input.method === CalendarInviteMethod.Cancel) {
|
|
173
|
+
event.removeAllProperties("status");
|
|
174
|
+
event.addPropertyWithValue("status", "CANCELLED");
|
|
175
|
+
if (!attendeeLineOf(event, attendee)) {
|
|
176
|
+
event.addProperty(personProperty("attendee", input.attendee));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (input.method === CalendarInviteMethod.Reply) {
|
|
180
|
+
for (const line of event.getAllProperties("attendee")) {
|
|
181
|
+
if (calendarAddressOf(line.getFirstValue()) !== attendee) {
|
|
182
|
+
event.removeProperty(line);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const sequence = calendar.master.getFirstPropertyValue("sequence");
|
|
188
|
+
return {
|
|
189
|
+
icalData: serializeCalendar(component),
|
|
190
|
+
sequence: typeof sequence === "number" ? sequence : 0,
|
|
191
|
+
summary: readString(calendar.master, "summary"),
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
/** The subject line a scheduling message goes out under. */
|
|
195
|
+
export const schedulingSubject = (method, summary, answer) => {
|
|
196
|
+
const title = summary === "" ? "(no title)" : summary;
|
|
197
|
+
if (method === CalendarInviteMethod.Cancel)
|
|
198
|
+
return `Cancelled: ${title}`;
|
|
199
|
+
if (method === CalendarInviteMethod.Reply) {
|
|
200
|
+
const word = answer === CalendarInviteAnswer.Declined
|
|
201
|
+
? "Declined"
|
|
202
|
+
: answer === CalendarInviteAnswer.Tentative
|
|
203
|
+
? "Tentative"
|
|
204
|
+
: "Accepted";
|
|
205
|
+
return `${word}: ${title}`;
|
|
206
|
+
}
|
|
207
|
+
return `Invitation: ${title}`;
|
|
208
|
+
};
|