|
|
|
@@ -1,6 +1,7 @@
|
|
|
|
|
import { Injectable, Logger } from '@nestjs/common'
|
|
|
|
|
import type {
|
|
|
|
|
BookingEvent,
|
|
|
|
|
BusyOptions,
|
|
|
|
|
CalendarGateway,
|
|
|
|
|
CalendarRef,
|
|
|
|
|
HostCalendarAccess,
|
|
|
|
@@ -88,7 +89,12 @@ export class JmapCalendarGateway implements CalendarGateway {
|
|
|
|
|
return list.map((c) => ({ id: c.id, name: c.name }))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getBusyIntervals(access: HostCalendarAccess, fromUtc: Date, toUtc: Date): Promise<Interval[]> {
|
|
|
|
|
async getBusyIntervals(
|
|
|
|
|
access: HostCalendarAccess,
|
|
|
|
|
fromUtc: Date,
|
|
|
|
|
toUtc: Date,
|
|
|
|
|
options?: BusyOptions,
|
|
|
|
|
): Promise<Interval[]> {
|
|
|
|
|
const session = await this.session(access)
|
|
|
|
|
const accountId = this.calAccountId(session)
|
|
|
|
|
const resp = await this.call(
|
|
|
|
@@ -109,9 +115,85 @@ export class JmapCalendarGateway implements CalendarGateway {
|
|
|
|
|
}
|
|
|
|
|
const list = (out[1]?.list ?? []) as Array<{ utcStart: string; utcEnd: string; busyStatus?: string }>
|
|
|
|
|
// Drop free/tentative-cancelled markers; count confirmed + tentative as busy.
|
|
|
|
|
return list
|
|
|
|
|
const busy = list
|
|
|
|
|
.filter((b) => b.busyStatus !== 'free')
|
|
|
|
|
.map((b) => ({ startUtc: new Date(b.utcStart), endUtc: new Date(b.utcEnd) }))
|
|
|
|
|
|
|
|
|
|
if (!options?.ignoreAllDayEvents) return busy
|
|
|
|
|
|
|
|
|
|
// Best-effort all-day exclusion. Principal/getAvailability collapses every
|
|
|
|
|
// event to a plain UTC interval with no all-day flag (Phase 0 finding —
|
|
|
|
|
// reference_stalwart_calendar_jmap), so we cannot tell an all-day event from
|
|
|
|
|
// a same-length timed event by its busy interval alone. To honour the flag we
|
|
|
|
|
// separately query CalendarEvent over the window and read each event's
|
|
|
|
|
// showWithoutTime / date-only `start` to recognise all-day events, then drop
|
|
|
|
|
// any busy interval that an all-day event covers.
|
|
|
|
|
//
|
|
|
|
|
// This is intentionally fail-soft: if the (occasionally flaky) CalendarEvent
|
|
|
|
|
// query errors, we keep ALL busy intervals (fail toward over-blocking, never
|
|
|
|
|
// toward a double-book) rather than propagating and breaking slot computation.
|
|
|
|
|
try {
|
|
|
|
|
const allDay = await this.allDayIntervals(access, accountId, fromUtc, toUtc)
|
|
|
|
|
if (allDay.length === 0) return busy
|
|
|
|
|
return busy.filter((b) => !allDay.some((ad) => coversInterval(ad, b)))
|
|
|
|
|
} catch (err) {
|
|
|
|
|
this.logger.warn(
|
|
|
|
|
`ignoreAllDayEvents: all-day detection failed for ${access.email}, keeping all busy intervals: ${(err as Error).message}`,
|
|
|
|
|
)
|
|
|
|
|
return busy
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Returns the UTC spans of all-day events in [fromUtc, toUtc). An all-day event
|
|
|
|
|
// in JSCalendar is signalled by showWithoutTime:true and/or a date-only `start`
|
|
|
|
|
// ("YYYY-MM-DD") with no `timeZone`; multi-day all-day events carry a P..D
|
|
|
|
|
// duration. We expand each to [start-of-day, start-of-day + duration) in the
|
|
|
|
|
// event's local zone (falling back to UTC), which is enough to overlap-match
|
|
|
|
|
// the busy intervals getAvailability produced for the same events.
|
|
|
|
|
private async allDayIntervals(
|
|
|
|
|
access: HostCalendarAccess,
|
|
|
|
|
accountId: string,
|
|
|
|
|
fromUtc: Date,
|
|
|
|
|
toUtc: Date,
|
|
|
|
|
): Promise<Interval[]> {
|
|
|
|
|
const resp = await this.call(
|
|
|
|
|
access,
|
|
|
|
|
[CORE, CALENDARS],
|
|
|
|
|
[
|
|
|
|
|
[
|
|
|
|
|
'CalendarEvent/query',
|
|
|
|
|
{
|
|
|
|
|
accountId,
|
|
|
|
|
filter: { before: toUtc.toISOString(), after: fromUtc.toISOString() },
|
|
|
|
|
},
|
|
|
|
|
'q',
|
|
|
|
|
],
|
|
|
|
|
[
|
|
|
|
|
'CalendarEvent/get',
|
|
|
|
|
{
|
|
|
|
|
accountId,
|
|
|
|
|
'#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' },
|
|
|
|
|
properties: ['start', 'duration', 'timeZone', 'showWithoutTime', 'recurrenceOverrides'],
|
|
|
|
|
},
|
|
|
|
|
'g',
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
const got = resp.find((r) => r[0] === 'CalendarEvent/get')
|
|
|
|
|
if (!got) return []
|
|
|
|
|
const events = (got[1]?.list ?? []) as Array<{
|
|
|
|
|
start?: string
|
|
|
|
|
duration?: string
|
|
|
|
|
timeZone?: string
|
|
|
|
|
showWithoutTime?: boolean
|
|
|
|
|
}>
|
|
|
|
|
const out: Interval[] = []
|
|
|
|
|
for (const ev of events) {
|
|
|
|
|
if (!isAllDay(ev)) continue
|
|
|
|
|
const span = allDaySpan(ev)
|
|
|
|
|
if (span) out.push(span)
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async createEvent(access: HostCalendarAccess, event: BookingEvent): Promise<{ uid: string; id: string }> {
|
|
|
|
@@ -197,6 +279,73 @@ function toJsCalLocal(utc: Date, tz: string): string {
|
|
|
|
|
return `${get('year')}-${get('month')}-${get('day')}T${hour}:${get('minute')}:${get('second')}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// True if a JSCalendar event represents an all-day event: either the explicit
|
|
|
|
|
// showWithoutTime flag, or a date-only `start` ("YYYY-MM-DD" with no time part).
|
|
|
|
|
function isAllDay(ev: { start?: string; showWithoutTime?: boolean }): boolean {
|
|
|
|
|
if (ev.showWithoutTime === true) return true
|
|
|
|
|
return typeof ev.start === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(ev.start)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Convert an all-day event to a concrete UTC interval. `start` is a local date
|
|
|
|
|
// (date-only) or local date-time; `duration` defaults to one day. We anchor the
|
|
|
|
|
// span at local midnight in the event's zone (or UTC if absent) so it overlaps
|
|
|
|
|
// the busy interval getAvailability emitted for the same event.
|
|
|
|
|
function allDaySpan(ev: { start?: string; duration?: string; timeZone?: string }): Interval | null {
|
|
|
|
|
if (!ev.start) return null
|
|
|
|
|
const datePart = ev.start.slice(0, 10)
|
|
|
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(datePart)) return null
|
|
|
|
|
const startUtc = localMidnightToUtc(datePart, ev.timeZone)
|
|
|
|
|
if (!startUtc) return null
|
|
|
|
|
const days = Math.max(1, durationToDays(ev.duration))
|
|
|
|
|
const endUtc = new Date(startUtc.getTime() + days * 86_400_000)
|
|
|
|
|
return { startUtc, endUtc }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Interpret "YYYY-MM-DD" at 00:00 in `tz` (default UTC) as a UTC instant. We
|
|
|
|
|
// derive the zone offset for that date via Intl and subtract it from the naive
|
|
|
|
|
// UTC midnight.
|
|
|
|
|
function localMidnightToUtc(date: string, tz?: string): Date | null {
|
|
|
|
|
const naive = new Date(`${date}T00:00:00Z`)
|
|
|
|
|
if (Number.isNaN(naive.getTime())) return null
|
|
|
|
|
if (!tz) return naive
|
|
|
|
|
try {
|
|
|
|
|
const offsetMs = zoneOffsetMs(naive, tz)
|
|
|
|
|
return new Date(naive.getTime() - offsetMs)
|
|
|
|
|
} catch {
|
|
|
|
|
return naive
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Offset (ms) of `tz` from UTC at instant `at` (positive east of UTC).
|
|
|
|
|
function zoneOffsetMs(at: Date, tz: string): number {
|
|
|
|
|
const parts = new Intl.DateTimeFormat('en-US', {
|
|
|
|
|
timeZone: tz,
|
|
|
|
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
|
|
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
|
|
|
hour12: false,
|
|
|
|
|
}).formatToParts(at)
|
|
|
|
|
const get = (t: string) => Number(parts.find((p) => p.type === t)?.value ?? '0')
|
|
|
|
|
const hour = get('hour') === 24 ? 0 : get('hour')
|
|
|
|
|
const asUtc = Date.UTC(get('year'), get('month') - 1, get('day'), hour, get('minute'), get('second'))
|
|
|
|
|
return asUtc - at.getTime()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Whole days in an ISO-8601 duration like "P2D" / "P1W". All-day events use
|
|
|
|
|
// day/week granularity; sub-day parts (PT..) round up to at least one day.
|
|
|
|
|
function durationToDays(duration?: string): number {
|
|
|
|
|
if (!duration) return 1
|
|
|
|
|
const weeks = Number(/(\d+)W/.exec(duration)?.[1] ?? 0)
|
|
|
|
|
const days = Number(/(\d+)D/.exec(duration)?.[1] ?? 0)
|
|
|
|
|
const total = weeks * 7 + days
|
|
|
|
|
return total > 0 ? total : 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// True if all-day interval `ad` fully covers busy interval `b` (so `b` is the
|
|
|
|
|
// busy span of an all-day event and should be dropped when ignoring all-day).
|
|
|
|
|
function coversInterval(ad: Interval, b: Interval): boolean {
|
|
|
|
|
return ad.startUtc.getTime() <= b.startUtc.getTime() && ad.endUtc.getTime() >= b.endUtc.getTime()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Milliseconds → ISO-8601 duration (whole minutes are enough for bookings).
|
|
|
|
|
function isoDuration(ms: number): string {
|
|
|
|
|
const totalMinutes = Math.round(ms / 60000)
|
|
|
|
|