Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 8 additions & 21 deletions packages/backend/src/booking/services/public-booking.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,11 @@ const buildGuestActionUrl = (
CONFIG.FRONTEND_URL,
).href;

const guestActionUrls = (reservationId: string, token: string) => ({
cancelUrl: buildGuestActionUrl("cancel", reservationId, token),
rescheduleUrl: buildGuestActionUrl("reschedule", reservationId, token),
});

const assertGuestEmail = (email: string): void => {
if (!isGuestEmail(email)) {
throw bookingError("INVALID_INPUT", "Invalid guest email");
Expand Down Expand Up @@ -567,13 +572,7 @@ export class PublicBookingService {
const hostDisplayName = await getHostDisplayName(page.userId);
const cancelToken = generateCancelToken();
const reservationId = mongoService.objectId();
const cancelUrl = buildGuestActionUrl(
"cancel",
reservationId.toString(),
cancelToken,
);
const rescheduleUrl = buildGuestActionUrl(
"reschedule",
const { cancelUrl, rescheduleUrl } = guestActionUrls(
reservationId.toString(),
cancelToken,
);
Expand Down Expand Up @@ -713,13 +712,7 @@ export class PublicBookingService {
const guestName = input.name ?? reservation.guestName;
const notes = nextGuestNotes(input.notes, reservation.notes);
const hostDisplayName = await getHostDisplayName(page.userId);
const cancelUrl = buildGuestActionUrl(
"cancel",
reservationId.toString(),
input.token,
);
const rescheduleUrl = buildGuestActionUrl(
"reschedule",
const { cancelUrl, rescheduleUrl } = guestActionUrls(
reservationId.toString(),
input.token,
);
Expand Down Expand Up @@ -772,13 +765,7 @@ export class PublicBookingService {
const slotStart = new Date(input.slotStart);
const slotEnd = slotEndForStart(slotStart, input.durationMinutes);
const hostDisplayName = await getHostDisplayName(page.userId);
const cancelUrl = buildGuestActionUrl(
"cancel",
reservationId.toString(),
input.token,
);
const rescheduleUrl = buildGuestActionUrl(
"reschedule",
const { cancelUrl, rescheduleUrl } = guestActionUrls(
reservationId.toString(),
input.token,
);
Expand Down
53 changes: 6 additions & 47 deletions packages/sync/src/providers/apple/apple-calendar.adapter.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import {
type CalendarAccessRole,
type SyncCalendarCapabilities,
} from "@core/types/sync/connection.contracts";
import { type CalendarAccessRole } from "@core/types/sync/connection.contracts";
import {
type CaldavClient,
CaldavClientError,
type CaldavClientErrorReason,
createCaldavClient,
type DiscoveredCaldavCalendar,
discoverCalendars as discoverCaldavCalendars,
} from "@sync/providers/apple/caldav-client";
import { capabilitiesForAccessRole } from "@sync/providers/calendar-role-capabilities";
import {
type CalendarDiscovery,
type DiscoveredCalendar,
Expand All @@ -25,36 +22,6 @@ export type AppleCalendarClientFactory = (
const defaultClientFactory: AppleCalendarClientFactory = (username, password) =>
createCaldavClient({ username, password });

const CAPABILITIES_BY_ROLE: Record<
CalendarAccessRole,
SyncCalendarCapabilities
> = {
owner: {
canReadEvents: true,
canWriteEvents: true,
canReadBusy: true,
canInviteAttendees: true,
},
editor: {
canReadEvents: true,
canWriteEvents: true,
canReadBusy: true,
canInviteAttendees: true,
},
viewer: {
canReadEvents: true,
canWriteEvents: false,
canReadBusy: true,
canInviteAttendees: false,
},
busyOnly: {
canReadEvents: false,
canWriteEvents: false,
canReadBusy: true,
canInviteAttendees: false,
},
};

// Apple iCloud implementation of the calendar-discovery port. The access token
// custody hands in is the app-specific password; the account email is bound
// when the adapter is constructed for a connection.
Expand Down Expand Up @@ -87,11 +54,9 @@ export class AppleCalendarAdapter implements ProviderCalendarAdapter {
return { calendars, cursor: null };
} catch (error) {
if (error instanceof CaldavClientError) {
throw new ProviderCalendarError(
mapDiscoveryReason(error.reason),
error.message,
{ cause: error },
);
throw new ProviderCalendarError(error.reason, error.message, {
cause: error,
});
}
throw error;
}
Expand Down Expand Up @@ -134,7 +99,7 @@ function mapCalendar(calendar: DiscoveredCaldavCalendar): DiscoveredCalendar {
primary: false,
active: true,
accessRole,
capabilities: CAPABILITIES_BY_ROLE[accessRole],
capabilities: capabilitiesForAccessRole(accessRole),
createsGoogleMeet: false,
};
}
Expand All @@ -144,9 +109,3 @@ function accountDefaultName(username: string): string {
if (at <= 0) return username;
return username.slice(0, at);
}

function mapDiscoveryReason(
reason: CaldavClientErrorReason,
): "authExpired" | "transient" | "discoveryFailed" {
return reason;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import {
type AppleEventWriterApi,
appendExdateToMaster,
eventResourceHref,
parseAppleInstanceId,
} from "@sync/providers/apple/apple-event-writer.adapter";
import { parseAppleInstanceId } from "@sync/providers/apple/apple-instance-id";
import { type CaldavResponse } from "@sync/providers/apple/caldav-client";

const CALENDAR = "https://caldav.icloud.com/123/calendars/home/";
Expand Down
46 changes: 5 additions & 41 deletions packages/sync/src/providers/apple/apple-event-writer.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import {
serializeAppleEventInstance,
serializeAppleEventPatch,
} from "@sync/providers/apple/apple-event.serializer";
import { appleInstanceEventId } from "@sync/providers/apple/apple-instance-id";
import {
appleInstanceEventId,
type ParsedAppleInstanceId,
parseAppleInstanceId,
} from "@sync/providers/apple/apple-instance-id";
import {
type CaldavClient,
type CaldavResponse,
Expand Down Expand Up @@ -447,46 +451,6 @@ class CaldavAppleEventWriterApi implements AppleEventWriterApi {
}
}

interface ParsedAppleInstanceId {
readonly seriesUid: string;
readonly originalStartAt: string;
readonly scheduleKind: "timed" | "allDay";
}

export function parseAppleInstanceId(
providerEventId: string,
): ParsedAppleInstanceId | null {
const timedMatch = /^(.+)_(\d{8}T\d{6}Z)$/.exec(providerEventId);
if (timedMatch) {
const suffix = timedMatch[2]!;
const instant = dayjs.utc(suffix, RFC5545, true);
if (!instant.isValid()) return null;
return {
seriesUid: timedMatch[1]!,
originalStartAt: instant.toDate().toISOString(),
scheduleKind: "timed",
};
}

const allDayMatch = /^(.+)_(\d{8})$/.exec(providerEventId);
if (allDayMatch) {
const suffix = allDayMatch[2]!;
const instant = dayjs.utc(
suffix,
dayjs.DateFormat.YEAR_MONTH_DAY_COMPACT_FORMAT,
true,
);
if (!instant.isValid()) return null;
return {
seriesUid: allDayMatch[1]!,
originalStartAt: instant.toDate().toISOString(),
scheduleKind: "allDay",
};
}

return null;
}

export function eventResourceHref(calendarUrl: string, uid: string): string {
const base = calendarUrl.endsWith("/") ? calendarUrl : `${calendarUrl}/`;
return `${base}${uid}.ics`;
Expand Down
34 changes: 22 additions & 12 deletions packages/sync/src/providers/apple/apple-instance-id.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
import dayjs from "@core/util/date/dayjs";
import {
parseRecurringInstanceEventId,
recurringInstanceEventId,
} from "@sync/providers/recurring-instance-id";

// Apple recurring-instance ids mirror Google's `{seriesId}_{originalStart}`
// suffix so sparse cancellations and reader href mappings stay aligned with
// Compass projection recurrenceIds. iCloud shares one UID across master and
// exception VEVENTs in a resource; the suffix disambiguates instances.

export function appleInstanceEventId(
seriesProviderEventId: string,
originalStartAt: string,
scheduleKind: "timed" | "allDay",
): string {
const instant = dayjs.utc(originalStartAt);
const suffix =
scheduleKind === "allDay"
? instant.format(dayjs.DateFormat.YEAR_MONTH_DAY_COMPACT_FORMAT)
: instant.format(dayjs.DateFormat.RFC5545);
return `${seriesProviderEventId}_${suffix}`;
export const appleInstanceEventId = recurringInstanceEventId;

export interface ParsedAppleInstanceId {
readonly seriesUid: string;
readonly originalStartAt: string;
readonly scheduleKind: "timed" | "allDay";
}

export function parseAppleInstanceId(
providerEventId: string,
): ParsedAppleInstanceId | null {
const parsed = parseRecurringInstanceEventId(providerEventId);
if (!parsed) return null;
return {
seriesUid: parsed.seriesProviderId,
originalStartAt: parsed.recurrenceId,
scheduleKind: parsed.scheduleKind,
};
}
43 changes: 43 additions & 0 deletions packages/sync/src/providers/calendar-role-capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import {
type CalendarAccessRole,
type SyncCalendarCapabilities,
} from "@core/types/sync/connection.contracts";

// Operational capabilities implied by each access role. Invite ability follows
// write access — providers expose no separate per-calendar attendee-invite flag.

const CAPABILITIES_BY_ROLE: Record<
CalendarAccessRole,
SyncCalendarCapabilities
> = {
owner: {
canReadEvents: true,
canWriteEvents: true,
canReadBusy: true,
canInviteAttendees: true,
},
editor: {
canReadEvents: true,
canWriteEvents: true,
canReadBusy: true,
canInviteAttendees: true,
},
viewer: {
canReadEvents: true,
canWriteEvents: false,
canReadBusy: true,
canInviteAttendees: false,
},
busyOnly: {
canReadEvents: false,
canWriteEvents: false,
canReadBusy: true,
canInviteAttendees: false,
},
};

export function capabilitiesForAccessRole(
role: CalendarAccessRole,
): SyncCalendarCapabilities {
return CAPABILITIES_BY_ROLE[role];
}
40 changes: 3 additions & 37 deletions packages/sync/src/providers/google/google-calendar.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,8 @@ import {
type gSchema$Calendar,
type gSchema$CalendarListEntry,
} from "@core/types/gcal";
import {
type CalendarAccessRole,
type SyncCalendarCapabilities,
} from "@core/types/sync/connection.contracts";
import { type CalendarAccessRole } from "@core/types/sync/connection.contracts";
import { capabilitiesForAccessRole } from "@sync/providers/calendar-role-capabilities";
import {
googleFailureCause,
googleStatus,
Expand Down Expand Up @@ -174,38 +172,6 @@ const ACCESS_ROLE_BY_GOOGLE: Record<string, CalendarAccessRole> = {
freeBusyReader: "busyOnly",
};

// Operational capabilities implied by each role. Invite ability follows write
// access — Google exposes no separate per-calendar attendee-invite flag.
const CAPABILITIES_BY_ROLE: Record<
CalendarAccessRole,
SyncCalendarCapabilities
> = {
owner: {
canReadEvents: true,
canWriteEvents: true,
canReadBusy: true,
canInviteAttendees: true,
},
editor: {
canReadEvents: true,
canWriteEvents: true,
canReadBusy: true,
canInviteAttendees: true,
},
viewer: {
canReadEvents: true,
canWriteEvents: false,
canReadBusy: true,
canInviteAttendees: false,
},
busyOnly: {
canReadEvents: false,
canWriteEvents: false,
canReadBusy: true,
canInviteAttendees: false,
},
};

// Map one Google calendar-list entry to provider-neutral facts. An entry
// without an id is unusable (it cannot be keyed or persisted), so it is dropped.
async function mapCalendar(
Expand All @@ -229,7 +195,7 @@ async function mapCalendar(
// from before the hide, not a signal Google is still showing it.
active: item.deleted !== true && item.hidden !== true,
accessRole,
capabilities: CAPABILITIES_BY_ROLE[accessRole],
capabilities: capabilitiesForAccessRole(accessRole),
createsGoogleMeet: calendarCreatesGoogleMeet(
item.conferenceProperties?.allowedConferenceSolutionTypes,
),
Expand Down
Loading
Loading