@@ -2,9 +2,9 @@ import {
|
||||
hasFreeBusyConflict,
|
||||
useAttendeesFreeBusy
|
||||
} from '@/components/Attendees/useFreeBusy'
|
||||
import * as getFreeBusyREPORT from '@/features/Events/api/getFreeBusyForAddedAttendeesREPORT'
|
||||
import * as getFreeBusyPOST from '@/features/Events/api/getFreeBusyForEventAttendeesPOST'
|
||||
import * as getUserData from '@/features/Events/api/getUserDataFromEmail'
|
||||
import * as CalendarApi from '@/features/Calendars/CalendarApi'
|
||||
import * as FreeBusyDao from '@/features/Events/FreeBusyDao'
|
||||
import * as UserDao from '@/features/User/UserDao'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
|
||||
jest.mock('moment-timezone', () => {
|
||||
@@ -12,26 +12,130 @@ jest.mock('moment-timezone', () => {
|
||||
return actual
|
||||
})
|
||||
|
||||
jest.mock('@/features/Events/api/getUserDataFromEmail')
|
||||
jest.mock('@/features/Events/api/getFreeBusyForAddedAttendeesREPORT')
|
||||
jest.mock('@/features/Events/api/getFreeBusyForEventAttendeesPOST')
|
||||
jest.mock('@/features/Events/FreeBusyDao')
|
||||
jest.mock('@/features/User/UserDao')
|
||||
jest.mock('@/features/Calendars/CalendarApi')
|
||||
|
||||
const mockGetUserData = getUserData.getUserDataFromEmail as jest.MockedFunction<
|
||||
typeof getUserData.getUserDataFromEmail
|
||||
const mockGetUserData = UserDao.fetchUserByEmail as jest.MockedFunction<
|
||||
typeof UserDao.fetchUserByEmail
|
||||
>
|
||||
const mockREPORT =
|
||||
getFreeBusyREPORT.getFreeBusyForAddedAttendeesREPORT as jest.MockedFunction<
|
||||
typeof getFreeBusyREPORT.getFreeBusyForAddedAttendeesREPORT
|
||||
>
|
||||
const mockPOST =
|
||||
getFreeBusyPOST.getFreeBusyForEventAttendeesPOST as jest.MockedFunction<
|
||||
typeof getFreeBusyPOST.getFreeBusyForEventAttendeesPOST
|
||||
>
|
||||
const mockREPORT = FreeBusyDao.fetchFreeBusyReports as jest.MockedFunction<
|
||||
typeof FreeBusyDao.fetchFreeBusyReports
|
||||
>
|
||||
const mockPOST = FreeBusyDao.fetchFreeBusyPost as jest.MockedFunction<
|
||||
typeof FreeBusyDao.fetchFreeBusyPost
|
||||
>
|
||||
const mockGetCalendars = CalendarApi.getCalendars as jest.MockedFunction<
|
||||
typeof CalendarApi.getCalendars
|
||||
>
|
||||
|
||||
const mockCalendarsResponse = {
|
||||
_embedded: {
|
||||
'dav:calendar': [
|
||||
{
|
||||
_links: {
|
||||
self: {
|
||||
href: '/calendars/user-carol/user-carol'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const freeIcal = [
|
||||
{
|
||||
_links: {
|
||||
self: {
|
||||
href: '\/calendars\/user-carol\/e38d1ea7-6d2c-46c6-8933-cdb325d4458e'
|
||||
}
|
||||
},
|
||||
data: [
|
||||
'vcalendar',
|
||||
[],
|
||||
[
|
||||
[
|
||||
'vfreebusy',
|
||||
[
|
||||
['dtstart', {}, 'date-time', '2026-03-14T14:00:00Z'],
|
||||
['dtend', {}, 'date-time', '2026-03-14T15:00:00Z'],
|
||||
['dtstamp', {}, 'date-time', '2026-03-14T13:00:00Z']
|
||||
],
|
||||
[]
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const busyIcal = [
|
||||
{
|
||||
_links: {
|
||||
self: {
|
||||
href: '\/calendars\/user-carol\/e38d1ea7-6d2c-46c6-8933-cdb325d4458e'
|
||||
}
|
||||
},
|
||||
data: [
|
||||
'vcalendar',
|
||||
[],
|
||||
[
|
||||
[
|
||||
'vfreebusy',
|
||||
[
|
||||
['dtstart', {}, 'date-time', '2026-03-14T14:00:00Z'],
|
||||
['dtend', {}, 'date-time', '2026-03-14T15:00:00Z'],
|
||||
['dtstamp', {}, 'date-time', '2026-03-14T13:00:00Z'],
|
||||
[
|
||||
'freebusy',
|
||||
{},
|
||||
'period',
|
||||
['2026-03-14T14:00:00Z', '2026-03-14T15:00:00Z']
|
||||
]
|
||||
],
|
||||
[]
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const START = '2026-03-14T14:00:00'
|
||||
const END = '2026-03-14T15:00:00'
|
||||
const TZ = 'Europe/Paris'
|
||||
|
||||
const freePostResponse = {
|
||||
start: '20260314T130000',
|
||||
end: '20260314T140000',
|
||||
users: [
|
||||
{
|
||||
id: 'user-carol',
|
||||
calendars: [{ id: 'user-carol', busy: [] }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const busyPostResponse = {
|
||||
start: '20260314T130000',
|
||||
end: '20260314T140000',
|
||||
users: [
|
||||
{
|
||||
id: 'user-carol',
|
||||
calendars: [
|
||||
{
|
||||
id: 'user-carol',
|
||||
busy: [
|
||||
{
|
||||
uid: 'fa507111-07d5-44b4-a611-c27845a66ccf',
|
||||
start: START,
|
||||
end: END
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
describe('hasFreeBusyConflict', () => {
|
||||
it('returns false for non-vcalendar data', () => {
|
||||
expect(hasFreeBusyConflict({ data: ['not-vcalendar', [], []] })).toBe(false)
|
||||
@@ -82,13 +186,16 @@ describe('hasFreeBusyConflict', () => {
|
||||
})
|
||||
|
||||
describe('useAttendeesFreeBusy — Flow B (new attendees)', () => {
|
||||
beforeEach(() => jest.clearAllMocks())
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockGetCalendars.mockResolvedValue(mockCalendarsResponse as never)
|
||||
})
|
||||
|
||||
const newAttendee = { email: 'alice@example.com', userId: 'user-alice' }
|
||||
|
||||
it('shows loading then free for a new attendee', async () => {
|
||||
mockREPORT.mockResolvedValue(false)
|
||||
|
||||
mockGetCalendars.mockResolvedValue(mockCalendarsResponse as never)
|
||||
mockREPORT.mockResolvedValue(freeIcal)
|
||||
const { result } = renderHook(() =>
|
||||
useAttendeesFreeBusy({
|
||||
existingAttendees: [],
|
||||
@@ -105,8 +212,8 @@ describe('useAttendeesFreeBusy — Flow B (new attendees)', () => {
|
||||
})
|
||||
|
||||
it('shows busy when REPORT returns true', async () => {
|
||||
mockREPORT.mockResolvedValue(true)
|
||||
|
||||
mockGetCalendars.mockResolvedValue(mockCalendarsResponse as never)
|
||||
mockREPORT.mockResolvedValue(busyIcal)
|
||||
const { result } = renderHook(() =>
|
||||
useAttendeesFreeBusy({
|
||||
existingAttendees: [],
|
||||
@@ -140,8 +247,20 @@ describe('useAttendeesFreeBusy — Flow B (new attendees)', () => {
|
||||
|
||||
it('resolves userId via getUserDataFromEmail when not provided', async () => {
|
||||
mockGetUserData.mockResolvedValue([{ _id: 'resolved-id' }] as never)
|
||||
mockREPORT.mockResolvedValue(false)
|
||||
|
||||
mockGetCalendars.mockResolvedValue({
|
||||
_embedded: {
|
||||
'dav:calendar': [
|
||||
{
|
||||
_links: {
|
||||
self: {
|
||||
href: '/calendars/resolved-id/resolved-id'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
} as never)
|
||||
mockREPORT.mockResolvedValue(freeIcal)
|
||||
const attendeeWithoutId = { email: 'bob@example.com' }
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
@@ -159,9 +278,11 @@ describe('useAttendeesFreeBusy — Flow B (new attendees)', () => {
|
||||
)
|
||||
expect(mockGetUserData).toHaveBeenCalledWith('bob@example.com')
|
||||
expect(mockREPORT).toHaveBeenCalledWith(
|
||||
'resolved-id',
|
||||
expect.any(String),
|
||||
expect.any(String)
|
||||
expect.objectContaining({
|
||||
hrefs: ['/calendars/resolved-id/resolved-id'],
|
||||
start: expect.any(String),
|
||||
end: expect.any(String)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -185,8 +306,8 @@ describe('useAttendeesFreeBusy — Flow B (new attendees)', () => {
|
||||
})
|
||||
|
||||
it('does not re-fetch an attendee already fetched', async () => {
|
||||
mockREPORT.mockResolvedValue(false)
|
||||
|
||||
mockGetCalendars.mockResolvedValue(mockCalendarsResponse as never)
|
||||
mockREPORT.mockResolvedValue(freeIcal)
|
||||
const { result, rerender } = renderHook(
|
||||
({ attendees }) =>
|
||||
useAttendeesFreeBusy({
|
||||
@@ -206,8 +327,8 @@ describe('useAttendeesFreeBusy — Flow B (new attendees)', () => {
|
||||
})
|
||||
|
||||
it('removes departed attendee from the map', async () => {
|
||||
mockREPORT.mockResolvedValue(false)
|
||||
|
||||
mockGetCalendars.mockResolvedValue(mockCalendarsResponse as never)
|
||||
mockREPORT.mockResolvedValue(freeIcal)
|
||||
const { result, rerender } = renderHook(
|
||||
({ attendees }) =>
|
||||
useAttendeesFreeBusy({
|
||||
@@ -230,8 +351,8 @@ describe('useAttendeesFreeBusy — Flow B (new attendees)', () => {
|
||||
})
|
||||
|
||||
it('invalidates cache and re-fetches when time window changes', async () => {
|
||||
mockREPORT.mockResolvedValue(false)
|
||||
|
||||
mockGetCalendars.mockResolvedValue(mockCalendarsResponse as never)
|
||||
mockREPORT.mockResolvedValue(freeIcal)
|
||||
const { result, rerender } = renderHook(
|
||||
({ start, end }) =>
|
||||
useAttendeesFreeBusy({
|
||||
@@ -254,8 +375,8 @@ describe('useAttendeesFreeBusy — Flow B (new attendees)', () => {
|
||||
})
|
||||
|
||||
it('passes UTC-converted iCal times to the API', async () => {
|
||||
mockREPORT.mockResolvedValue(false)
|
||||
|
||||
mockGetCalendars.mockResolvedValue(mockCalendarsResponse as never)
|
||||
mockREPORT.mockResolvedValue(freeIcal)
|
||||
renderHook(() =>
|
||||
useAttendeesFreeBusy({
|
||||
existingAttendees: [],
|
||||
@@ -270,9 +391,10 @@ describe('useAttendeesFreeBusy — Flow B (new attendees)', () => {
|
||||
|
||||
// Paris is UTC+1 in March (before DST), so 16:00 → 15:00 UTC
|
||||
expect(mockREPORT).toHaveBeenCalledWith(
|
||||
'user-alice',
|
||||
'20260314T150000',
|
||||
'20260314T160000'
|
||||
expect.objectContaining({
|
||||
start: '20260314T150000',
|
||||
end: '20260314T160000'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -297,7 +419,7 @@ describe('useAttendeesFreeBusy — Flow A (existing attendees)', () => {
|
||||
})
|
||||
|
||||
it('shows loading then free for existing attendees', async () => {
|
||||
mockPOST.mockResolvedValue({ 'user-carol': false })
|
||||
mockPOST.mockResolvedValue(freePostResponse)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAttendeesFreeBusy({
|
||||
@@ -318,7 +440,7 @@ describe('useAttendeesFreeBusy — Flow A (existing attendees)', () => {
|
||||
})
|
||||
|
||||
it('shows busy when POST returns busy for userId', async () => {
|
||||
mockPOST.mockResolvedValue({ 'user-carol': true })
|
||||
mockPOST.mockResolvedValue(busyPostResponse)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAttendeesFreeBusy({
|
||||
@@ -331,9 +453,9 @@ describe('useAttendeesFreeBusy — Flow A (existing attendees)', () => {
|
||||
})
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
await waitFor(() => {
|
||||
expect(result.current[existingAttendee.email]).toBe('busy')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows unknown when POST throws', async () => {
|
||||
@@ -356,7 +478,7 @@ describe('useAttendeesFreeBusy — Flow A (existing attendees)', () => {
|
||||
})
|
||||
|
||||
it('passes the eventUid to the POST API', async () => {
|
||||
mockPOST.mockResolvedValue({ 'user-carol': false })
|
||||
mockPOST.mockResolvedValue(freePostResponse)
|
||||
|
||||
renderHook(() =>
|
||||
useAttendeesFreeBusy({
|
||||
@@ -370,12 +492,12 @@ describe('useAttendeesFreeBusy — Flow A (existing attendees)', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => expect(mockPOST).toHaveBeenCalled())
|
||||
expect(mockPOST).toHaveBeenCalledWith(
|
||||
['user-carol'],
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
'event-abc'
|
||||
)
|
||||
expect(mockPOST).toHaveBeenCalledWith({
|
||||
end: '20260314T140000',
|
||||
eventUid: 'event-abc',
|
||||
start: '20260314T130000',
|
||||
userIds: ['user-carol']
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { getFreeBusyForAddedAttendeesREPORT } from '@/features/Events/api/getFreeBusyForAddedAttendeesREPORT'
|
||||
import { getFreeBusyForEventAttendeesPOST } from '@/features/Events/api/getFreeBusyForEventAttendeesPOST'
|
||||
import { getUserDataFromEmail } from '@/features/Events/api/getUserDataFromEmail'
|
||||
import {
|
||||
getFreeBusyForAddedAttendee,
|
||||
getFreeBusyForEventAttendees
|
||||
} from '@/features/Events/FreeBusyApi'
|
||||
import { getUserDataFromEmail } from '@/features/User/userAPI'
|
||||
import moment from 'moment-timezone'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
@@ -150,7 +152,7 @@ export function useAttendeesFreeBusy({
|
||||
setStatusMap(prev => ({ ...prev, ...toLoadingMap(existingAttendees) }))
|
||||
|
||||
fetchFreeBusyMap(existingAttendees, resolved =>
|
||||
getFreeBusyForEventAttendeesPOST(
|
||||
getFreeBusyForEventAttendees(
|
||||
resolved.map(r => r.userId),
|
||||
toUtcIcal(start, timezone),
|
||||
toUtcIcal(end, timezone),
|
||||
@@ -208,7 +210,7 @@ export function useAttendeesFreeBusy({
|
||||
Promise.all(
|
||||
resolved.map(async ({ email, userId }) => {
|
||||
try {
|
||||
const busy = await getFreeBusyForAddedAttendeesREPORT(
|
||||
const busy = await getFreeBusyForAddedAttendee(
|
||||
userId,
|
||||
moment.tz(start, timezone).utc().format('YYYYMMDDTHHmmss'),
|
||||
moment.tz(end, timezone).utc().format('YYYYMMDDTHHmmss')
|
||||
|
||||
@@ -8,7 +8,7 @@ import { SnackbarAlert } from '@/components/Loading/SnackBarAlert'
|
||||
import moment from 'moment-timezone'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useI18n } from 'twake-i18n'
|
||||
import { postCounterProposal } from '../api/sendCounterProposal'
|
||||
import { postCounterProposal } from '../EventApi'
|
||||
import { EventTimeSubtitle } from '../EventPreview/EventTimeSubtitle'
|
||||
import { ContextualizedEvent } from '../EventsTypes'
|
||||
|
||||
|
||||
+181
-152
@@ -1,4 +1,4 @@
|
||||
import { api } from '@/utils/apiUtils'
|
||||
import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid'
|
||||
import { convertEventDateTimeToISO, resolveTimezoneId } from '@/utils/timezone'
|
||||
import { TIMEZONES } from '@/utils/timezone-data'
|
||||
import ICAL from 'ical.js'
|
||||
@@ -10,6 +10,19 @@ import {
|
||||
VObjectValue
|
||||
} from '../Calendars/types/CalendarData'
|
||||
import { SearchEventsResponse } from '../Search/types/SearchEventsResponse'
|
||||
import {
|
||||
CounterProposalPayload,
|
||||
deleteEventRaw,
|
||||
fetchAllRecurrentVevents,
|
||||
fetchEventIcs,
|
||||
fetchEventRaw,
|
||||
importEventRaw,
|
||||
moveEventRaw,
|
||||
postCounterProposalRaw,
|
||||
putEventRaw,
|
||||
reportEventRaw,
|
||||
searchEventRaw
|
||||
} from './EventDao'
|
||||
import { CalendarEvent } from './EventsTypes'
|
||||
import {
|
||||
calendarEventToJCal,
|
||||
@@ -22,36 +35,30 @@ export async function reportEvent(
|
||||
event: CalendarEvent,
|
||||
match: { start: string; end: string }
|
||||
): Promise<CalDavItem> {
|
||||
const response = await api(`dav${event.URL}`, {
|
||||
method: 'REPORT',
|
||||
body: JSON.stringify({ match }),
|
||||
headers: { Accept: 'application/json' }
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`REPORT request failed with status ${response.status}`)
|
||||
}
|
||||
const eventData: CalDavItem = await response.json()
|
||||
return eventData
|
||||
return reportEventRaw(event, match)
|
||||
}
|
||||
|
||||
export async function getEvent(event: CalendarEvent, isMaster?: boolean) {
|
||||
const response = await api.get(`dav${event.URL}`)
|
||||
const eventData = await response.text()
|
||||
export async function getEvent(
|
||||
event: CalendarEvent,
|
||||
isMaster?: boolean
|
||||
): Promise<CalendarEvent> {
|
||||
const eventData = await fetchEventRaw(event)
|
||||
|
||||
const eventical = ICAL.parse(eventData)
|
||||
const vevents = (eventical[2] || []).filter(
|
||||
([name]: [string]) => name.toLowerCase() === 'vevent'
|
||||
const eventical = ICAL.parse(eventData) as VCalComponent
|
||||
const vevents = (eventical[2] ?? []).filter(
|
||||
([name]) => name.toLowerCase() === 'vevent'
|
||||
)
|
||||
const vtimezones = (eventical[2] ?? []).filter(
|
||||
([name]) => name.toLowerCase() === 'vtimezone'
|
||||
)
|
||||
|
||||
const vtimezones = (eventical[2] || []).filter(
|
||||
([name]: [string]) => name.toLowerCase() === 'vtimezone'
|
||||
)
|
||||
|
||||
let targetVevent
|
||||
let targetVevent: VCalComponent | undefined
|
||||
if (isMaster) {
|
||||
targetVevent = vevents.find(
|
||||
([, props]: VCalComponent) =>
|
||||
!props.find(([k]) => k.toLowerCase() === 'recurrence-id')
|
||||
([, props]) =>
|
||||
!(props as VObjectProperty[]).find(
|
||||
([k]) => k.toLowerCase() === 'recurrence-id'
|
||||
)
|
||||
)
|
||||
if (!targetVevent) {
|
||||
targetVevent = vevents[0]
|
||||
@@ -63,11 +70,11 @@ export async function getEvent(event: CalendarEvent, isMaster?: boolean) {
|
||||
let timezoneFromVTimezone: string | undefined
|
||||
if (vtimezones.length > 0) {
|
||||
const vtimezone = vtimezones[0]
|
||||
const tzidProp = vtimezone[1]?.find(
|
||||
([k]: string[]) => k.toLowerCase() === 'tzid'
|
||||
const tzidProp = (vtimezone[1] as VObjectProperty[]).find(
|
||||
([k]) => k.toLowerCase() === 'tzid'
|
||||
)
|
||||
if (tzidProp && tzidProp[3]) {
|
||||
const resolvedTz = resolveTimezoneId(tzidProp[3])
|
||||
if (tzidProp?.[3]) {
|
||||
const resolvedTz = resolveTimezoneId(tzidProp[3] as string)
|
||||
if (resolvedTz) {
|
||||
timezoneFromVTimezone = resolvedTz
|
||||
}
|
||||
@@ -75,26 +82,20 @@ export async function getEvent(event: CalendarEvent, isMaster?: boolean) {
|
||||
}
|
||||
|
||||
let timezoneFromDTSTART: string | undefined
|
||||
const dtstartProp = targetVevent[1]?.find(
|
||||
([k]: string[]) => k.toLowerCase() === 'dtstart'
|
||||
const dtstartProp = (targetVevent[1] as VObjectProperty[]).find(
|
||||
([k]) => k.toLowerCase() === 'dtstart'
|
||||
)
|
||||
if (dtstartProp) {
|
||||
const dtstartParams = dtstartProp[1]
|
||||
const dtstartValue = dtstartProp[3]
|
||||
if (dtstartParams) {
|
||||
const dtstartParams = dtstartProp?.[1] as Record<string, string>
|
||||
const dtstartValue = dtstartProp?.[3]
|
||||
const tzParam =
|
||||
dtstartParams.tzid ||
|
||||
dtstartParams.TZID ||
|
||||
dtstartParams.Tzid ||
|
||||
dtstartParams.tZid ||
|
||||
dtstartParams.tzId
|
||||
if (tzParam) {
|
||||
const resolvedTz = resolveTimezoneId(tzParam)
|
||||
if (resolvedTz) {
|
||||
timezoneFromDTSTART = resolvedTz
|
||||
}
|
||||
}
|
||||
}
|
||||
dtstartParams?.['tzid'] ??
|
||||
dtstartParams?.['TZID'] ??
|
||||
dtstartParams?.['Tzid'] ??
|
||||
dtstartParams?.['tZid'] ??
|
||||
dtstartParams?.['tzId']
|
||||
|
||||
timezoneFromDTSTART = resolveTimezoneId(tzParam)
|
||||
|
||||
if (
|
||||
!timezoneFromDTSTART &&
|
||||
typeof dtstartValue === 'string' &&
|
||||
@@ -102,20 +103,20 @@ export async function getEvent(event: CalendarEvent, isMaster?: boolean) {
|
||||
) {
|
||||
timezoneFromDTSTART = 'Etc/UTC'
|
||||
}
|
||||
}
|
||||
|
||||
const eventjson = parseCalendarEvent(
|
||||
targetVevent[1],
|
||||
targetVevent[1] as VObjectProperty[],
|
||||
event.color ?? {},
|
||||
{ id: event?.calId } as Calendar,
|
||||
event.URL
|
||||
)
|
||||
|
||||
const finalTimezone =
|
||||
timezoneFromVTimezone ||
|
||||
timezoneFromDTSTART ||
|
||||
eventjson.timezone ||
|
||||
timezoneFromVTimezone ??
|
||||
timezoneFromDTSTART ??
|
||||
eventjson.timezone ??
|
||||
'Etc/UTC'
|
||||
|
||||
eventjson.timezone = finalTimezone
|
||||
|
||||
if (!eventjson.allday && eventjson.start && finalTimezone) {
|
||||
@@ -132,31 +133,22 @@ export async function getEvent(event: CalendarEvent, isMaster?: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
if (isMaster) {
|
||||
const merged = { ...event, ...eventjson }
|
||||
merged.timezone = finalTimezone
|
||||
return merged
|
||||
}
|
||||
const merged = { ...event, ...eventjson }
|
||||
merged.timezone = finalTimezone
|
||||
return merged
|
||||
}
|
||||
|
||||
export async function dlEvent(event: CalendarEvent) {
|
||||
const response = await api.get(`dav${event.URL}?export=`)
|
||||
const eventData = await response.text()
|
||||
|
||||
return eventData
|
||||
export async function dlEvent(event: CalendarEvent): Promise<string> {
|
||||
const response = await fetchEventIcs(event)
|
||||
return response
|
||||
}
|
||||
|
||||
export async function putEvent(event: CalendarEvent, calOwnerEmail?: string) {
|
||||
const response = await api(`dav${event.URL}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(calendarEventToJCal(event, calOwnerEmail)),
|
||||
headers: {
|
||||
'content-type': 'text/calendar; charset=utf-8'
|
||||
}
|
||||
})
|
||||
export async function putEvent(
|
||||
event: CalendarEvent,
|
||||
calOwnerEmail?: string
|
||||
): Promise<Response> {
|
||||
const jCal = calendarEventToJCal(event, calOwnerEmail)
|
||||
const response = await putEventRaw(event, jCal)
|
||||
|
||||
if (response.status === 201) {
|
||||
console.info('Event created successfully:', response.url || event.URL)
|
||||
@@ -168,8 +160,8 @@ export async function putEvent(event: CalendarEvent, calOwnerEmail?: string) {
|
||||
export async function putEventWithOverrides(
|
||||
updatedEvent: CalendarEvent,
|
||||
calOwnerEmail?: string
|
||||
) {
|
||||
const vevents = await getAllRecurrentEvent(updatedEvent)
|
||||
): Promise<Response> {
|
||||
const vevents = await fetchAllRecurrentVevents(updatedEvent)
|
||||
|
||||
const updatedVevent = makeVevent(
|
||||
updatedEvent,
|
||||
@@ -180,61 +172,64 @@ export async function putEventWithOverrides(
|
||||
let replaced = false
|
||||
for (let i = 0; i < vevents.length; i++) {
|
||||
const ve = vevents[i]
|
||||
const recurrenceId = ve[1].find(([k]: string[]) => k === 'recurrence-id')
|
||||
const recurrenceId = (ve[1] as VObjectProperty[]).find(
|
||||
([k]) => k === 'recurrence-id'
|
||||
)
|
||||
if (recurrenceId && recurrenceId[3] === updatedEvent.recurrenceId) {
|
||||
vevents[i] = updatedVevent // replace
|
||||
vevents[i] = updatedVevent as VCalComponent // replace
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!replaced && updatedEvent.recurrenceId) {
|
||||
vevents.push(updatedVevent) // add new override
|
||||
vevents.push(updatedVevent as VCalComponent) // add new override
|
||||
}
|
||||
|
||||
const timezoneData = TIMEZONES.zones[updatedEvent.timezone]
|
||||
const vtimezone = makeTimezone(timezoneData, updatedEvent)
|
||||
|
||||
const newJCal = ['vcalendar', [], [...vevents, vtimezone.component.jCal]]
|
||||
|
||||
return api(`dav${updatedEvent.URL}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(newJCal),
|
||||
headers: {
|
||||
'content-type': 'text/calendar; charset=utf-8'
|
||||
}
|
||||
})
|
||||
return putEventRaw(updatedEvent, newJCal)
|
||||
}
|
||||
|
||||
export const deleteEventInstance = async (event: CalendarEvent) => {
|
||||
export const deleteEventInstance = async (
|
||||
event: CalendarEvent
|
||||
): Promise<Response> => {
|
||||
// Get all VEVENTs (master + overrides) from the series
|
||||
const vevents = await getAllRecurrentEvent(event)
|
||||
const vevents = await fetchAllRecurrentVevents(event)
|
||||
|
||||
// Find the master VEVENT
|
||||
const masterIndex = vevents.findIndex(
|
||||
([, props]: VCalComponent) =>
|
||||
!props.find(([k]) => k.toLowerCase() === 'recurrence-id')
|
||||
([, props]) =>
|
||||
!(props as VObjectProperty[]).find(
|
||||
([k]) => k.toLowerCase() === 'recurrence-id'
|
||||
)
|
||||
)
|
||||
|
||||
if (masterIndex === -1) {
|
||||
throw new Error('No master VEVENT found for this series')
|
||||
}
|
||||
|
||||
const exdateValue = event.recurrenceId || event.start
|
||||
const exdateValue = event.recurrenceId ?? event.start
|
||||
const seriesEvent = parseCalendarEvent(
|
||||
vevents[masterIndex][1],
|
||||
vevents[masterIndex][1] as VObjectProperty[],
|
||||
{},
|
||||
{ id: event.calId } as Calendar,
|
||||
''
|
||||
)
|
||||
const masterProps = vevents[masterIndex][1]
|
||||
const masterProps = vevents[masterIndex][1] as VObjectProperty[]
|
||||
|
||||
const normalizeRecurrenceId = (id: VObjectValue): string =>
|
||||
(typeof id === 'string' || typeof id === 'number'
|
||||
? String(id)
|
||||
: ''
|
||||
).replace(/Z$/, '')
|
||||
|
||||
// Check if this date is already in EXDATE (avoid duplicates)
|
||||
const normalizeRecurrenceId = (id: VObjectValue) =>
|
||||
String(id ?? '').replace(/Z$/, '')
|
||||
const isDuplicate = masterProps.some((prop: VObjectProperty) => {
|
||||
if (prop[0].toLowerCase() === 'exdate' && prop[3]) {
|
||||
return (
|
||||
normalizeRecurrenceId(prop[3]) === normalizeRecurrenceId(exdateValue)
|
||||
normalizeRecurrenceId(prop[3]) ===
|
||||
normalizeRecurrenceId(exdateValue as VObjectValue)
|
||||
)
|
||||
}
|
||||
return false
|
||||
@@ -250,14 +245,14 @@ export const deleteEventInstance = async (event: CalendarEvent) => {
|
||||
vevents[masterIndex][1] = masterProps
|
||||
|
||||
// Remove the override instance if it exists (in case it was an override being deleted)
|
||||
const filteredVevents = vevents.filter(([, props]: VCalComponent) => {
|
||||
const recurrenceIdProp = props.find(
|
||||
const filteredVevents = vevents.filter(([, props]) => {
|
||||
const recurrenceIdProp = (props as VObjectProperty[]).find(
|
||||
([k]) => k.toLowerCase() === 'recurrence-id'
|
||||
)
|
||||
if (!recurrenceIdProp) return true // Keep master
|
||||
return (
|
||||
normalizeRecurrenceId(recurrenceIdProp[3]) !==
|
||||
normalizeRecurrenceId(event.recurrenceId ?? '')
|
||||
normalizeRecurrenceId((event.recurrenceId ?? '') as VObjectValue)
|
||||
) // Remove matching override
|
||||
})
|
||||
|
||||
@@ -271,39 +266,33 @@ export const deleteEventInstance = async (event: CalendarEvent) => {
|
||||
[...filteredVevents, vtimezone.component.jCal]
|
||||
]
|
||||
|
||||
return api(`dav${event.URL}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(newJCal),
|
||||
headers: {
|
||||
'content-type': 'text/calendar; charset=utf-8'
|
||||
}
|
||||
})
|
||||
return putEventRaw(event, newJCal)
|
||||
}
|
||||
|
||||
export const updateSeriesPartstat = async (
|
||||
event: CalendarEvent,
|
||||
attendeeEmail: string,
|
||||
partstat: string
|
||||
) => {
|
||||
const vevents = await getAllRecurrentEvent(event)
|
||||
): Promise<Response> => {
|
||||
const vevents = await fetchAllRecurrentVevents(event)
|
||||
|
||||
// Update PARTSTAT in ALL VEVENTs (master + exceptions)
|
||||
const updatedVevents = vevents.map((vevent: VCalComponent) => {
|
||||
const properties = vevent[1]
|
||||
const properties = vevent[1] as VObjectProperty[]
|
||||
const updatedProperties = properties.map((prop: VObjectProperty) => {
|
||||
// Find ATTENDEE properties
|
||||
if (prop[0] === 'attendee') {
|
||||
const calAddress = prop[3] as string
|
||||
// Check if this is the target attendee
|
||||
if (calAddress.toLowerCase().includes(attendeeEmail.toLowerCase())) {
|
||||
// Find ATTENDEE properties & Check if this is the target attendee
|
||||
if (
|
||||
prop[0] === 'attendee' &&
|
||||
calAddress.toLowerCase().includes(attendeeEmail.toLowerCase())
|
||||
) {
|
||||
// Update PARTSTAT parameter
|
||||
const params = { ...prop[1], partstat: partstat }
|
||||
return [prop[0], params, prop[2], prop[3]]
|
||||
}
|
||||
const params = { ...(prop[1] as Record<string, string>), partstat }
|
||||
return [prop[0], params, prop[2], prop[3]] as VObjectProperty
|
||||
}
|
||||
return prop
|
||||
})
|
||||
return [vevent[0], updatedProperties, vevent[2]]
|
||||
return [vevent[0], updatedProperties, vevent[2]] as VCalComponent
|
||||
})
|
||||
|
||||
const timezoneData = TIMEZONES.zones[event.timezone]
|
||||
@@ -315,45 +304,31 @@ export const updateSeriesPartstat = async (
|
||||
[...updatedVevents, vtimezone.component.jCal]
|
||||
]
|
||||
|
||||
return api(`dav${event.URL}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(newJCal),
|
||||
headers: {
|
||||
'content-type': 'text/calendar; charset=utf-8'
|
||||
}
|
||||
})
|
||||
return putEventRaw(event, newJCal)
|
||||
}
|
||||
|
||||
export async function getAllRecurrentEvent(event: CalendarEvent) {
|
||||
const response = await api.get(`dav${event.URL}`)
|
||||
const eventData = await response.text()
|
||||
const jcal = ICAL.parse(eventData)
|
||||
const vevents = jcal[2].filter(([name]: string[]) => name === 'vevent')
|
||||
return vevents
|
||||
export async function getAllRecurrentEvent(
|
||||
event: CalendarEvent
|
||||
): Promise<VCalComponent[]> {
|
||||
return fetchAllRecurrentVevents(event)
|
||||
}
|
||||
|
||||
export async function moveEvent(event: CalendarEvent, newUrl: string) {
|
||||
const response = await api(`dav${event.URL}`, {
|
||||
method: 'MOVE',
|
||||
headers: {
|
||||
destination: newUrl
|
||||
}
|
||||
})
|
||||
return response
|
||||
export async function moveEvent(
|
||||
event: CalendarEvent,
|
||||
newUrl: string
|
||||
): Promise<Response> {
|
||||
return moveEventRaw(event, newUrl)
|
||||
}
|
||||
|
||||
export async function deleteEvent(eventURL: string) {
|
||||
const response = await api(`dav${eventURL}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
return response
|
||||
export async function deleteEvent(eventURL: string): Promise<Response> {
|
||||
return deleteEventRaw({ URL: eventURL } as CalendarEvent)
|
||||
}
|
||||
|
||||
export async function importEventFromFile(id: string, calLink: string) {
|
||||
const response = await api.post(`api/import`, {
|
||||
body: JSON.stringify({ fileId: id, target: calLink })
|
||||
})
|
||||
return response
|
||||
export async function importEventFromFile(
|
||||
id: string,
|
||||
calLink: string
|
||||
): Promise<Response> {
|
||||
return importEventRaw(id, calLink)
|
||||
}
|
||||
|
||||
export async function searchEvent(
|
||||
@@ -385,11 +360,65 @@ export async function searchEvent(
|
||||
if (attendees.length) {
|
||||
reqParam.attendees = attendees
|
||||
}
|
||||
const response = await api
|
||||
.post('calendar/api/events/search?limit=30&offset=0', {
|
||||
body: JSON.stringify(reqParam)
|
||||
})
|
||||
.json()
|
||||
|
||||
return response as SearchEventsResponse
|
||||
return searchEventRaw(reqParam)
|
||||
}
|
||||
|
||||
export async function postCounterProposal({
|
||||
event,
|
||||
senderEmail,
|
||||
recipientEmail,
|
||||
proposedStart,
|
||||
proposedEnd,
|
||||
message
|
||||
}: {
|
||||
event: CalendarEvent
|
||||
senderEmail: string
|
||||
recipientEmail: string
|
||||
proposedStart: string
|
||||
proposedEnd: string
|
||||
message?: string
|
||||
}): Promise<Response> {
|
||||
// Build the counter event with proposed dates
|
||||
const counterEvent: CalendarEvent = {
|
||||
...event,
|
||||
start: proposedStart,
|
||||
end: proposedEnd,
|
||||
sequence: event.sequence ?? 0
|
||||
}
|
||||
// Build vevent jCal
|
||||
const vevent = makeVevent(
|
||||
counterEvent,
|
||||
counterEvent.timezone,
|
||||
senderEmail,
|
||||
!event.recurrenceId
|
||||
)
|
||||
if (message) {
|
||||
vevent[1].push(['comment', {}, 'text', message])
|
||||
}
|
||||
// Build vtimezone
|
||||
const timezoneData = TIMEZONES.zones[counterEvent.timezone]
|
||||
const vtimezone = makeTimezone(timezoneData, counterEvent)
|
||||
|
||||
// Assemble full vcalendar with METHOD:COUNTER
|
||||
const jcal = [
|
||||
'vcalendar',
|
||||
[
|
||||
['version', {}, 'text', '2.0'],
|
||||
['prodid', {}, 'text', '-//OpenPaaS//OpenPaaS//EN'],
|
||||
['method', {}, 'text', 'COUNTER']
|
||||
],
|
||||
[vevent, vtimezone.component.jCal]
|
||||
]
|
||||
|
||||
// Serialize to raw ICS
|
||||
const counterICS = new ICAL.Component(jcal).toString()
|
||||
const payload: CounterProposalPayload = {
|
||||
ical: counterICS,
|
||||
sender: senderEmail,
|
||||
recipient: recipientEmail,
|
||||
uid: extractEventBaseUuid(event.uid),
|
||||
sequence: counterEvent.sequence ?? 0,
|
||||
method: 'COUNTER'
|
||||
}
|
||||
return postCounterProposalRaw(event, payload)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { api } from '@/utils/apiUtils'
|
||||
import ICAL from 'ical.js'
|
||||
import { CalDavItem } from '../Calendars/api/types'
|
||||
import { VCalComponent } from '../Calendars/types/CalendarData'
|
||||
import { SearchEventsResponse } from '../Search/types/SearchEventsResponse'
|
||||
import { CalendarEvent } from './EventsTypes'
|
||||
|
||||
export async function reportEventRaw(
|
||||
event: CalendarEvent,
|
||||
match: { start: string; end: string }
|
||||
): Promise<CalDavItem> {
|
||||
const response = await api(`dav${event.URL}`, {
|
||||
method: 'REPORT',
|
||||
body: JSON.stringify({ match }),
|
||||
headers: { Accept: 'application/json' }
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`REPORT request failed with status ${response.status}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function fetchEventRaw(event: CalendarEvent): Promise<string> {
|
||||
const response = await api.get(`dav${event.URL}`)
|
||||
return response.text()
|
||||
}
|
||||
|
||||
export async function fetchEventIcs(event: CalendarEvent): Promise<string> {
|
||||
const response = await api.get(`dav${event.URL}?export=`)
|
||||
return response.text()
|
||||
}
|
||||
|
||||
export async function putEventRaw(
|
||||
event: CalendarEvent,
|
||||
jCal: unknown[]
|
||||
): Promise<Response> {
|
||||
return api(`dav${event.URL}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(jCal),
|
||||
headers: { 'content-type': 'text/calendar; charset=utf-8' }
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteEventRaw(event: CalendarEvent): Promise<Response> {
|
||||
return api(`dav${event.URL}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function moveEventRaw(
|
||||
event: CalendarEvent,
|
||||
toURL: string
|
||||
): Promise<Response> {
|
||||
return api(`dav${event.URL}`, {
|
||||
method: 'MOVE',
|
||||
headers: { destination: toURL }
|
||||
})
|
||||
}
|
||||
|
||||
export async function importEventRaw(
|
||||
id: string,
|
||||
calLink: string
|
||||
): Promise<Response> {
|
||||
return api.post('api/import', {
|
||||
body: JSON.stringify({ fileId: id, target: calLink })
|
||||
})
|
||||
}
|
||||
|
||||
export async function searchEventRaw(reqParam: {
|
||||
query: string
|
||||
calendars: { calendarId: string; userId: string }[]
|
||||
organizers?: string[]
|
||||
attendees?: string[]
|
||||
}): Promise<SearchEventsResponse> {
|
||||
return api
|
||||
.post('calendar/api/events/search?limit=30&offset=0', {
|
||||
body: JSON.stringify(reqParam)
|
||||
})
|
||||
.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all VEVENTs (master + overrides) for a recurring series.
|
||||
* Returns the raw jCal VEVENT components — no transformation applied.
|
||||
*/
|
||||
export async function fetchAllRecurrentVevents(
|
||||
event: CalendarEvent
|
||||
): Promise<VCalComponent[]> {
|
||||
const response = await api.get(`dav${event.URL}`)
|
||||
const eventData = await response.text()
|
||||
const jcal = ICAL.parse(eventData) as VCalComponent
|
||||
return (jcal[2] ?? []).filter(([name]) => name === 'vevent')
|
||||
}
|
||||
|
||||
/**
|
||||
* POSTs an iTIP COUNTER proposal for a calendar event.
|
||||
* Accepts a pre-serialized ICS string and the envelope metadata.
|
||||
*/
|
||||
export interface CounterProposalPayload {
|
||||
ical: string
|
||||
sender: string
|
||||
recipient: string
|
||||
uid: string
|
||||
sequence: number
|
||||
method: 'COUNTER'
|
||||
}
|
||||
export async function postCounterProposalRaw(
|
||||
event: CalendarEvent,
|
||||
payload: CounterProposalPayload
|
||||
): Promise<Response> {
|
||||
const response = await api(`dav${event.URL}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
headers: {
|
||||
accept: 'application/json,text/plain,*/*',
|
||||
'content-type': 'application/calendar+json',
|
||||
Prefer: 'return=representation',
|
||||
'X-Http-Method-Override': 'ITIP'
|
||||
}
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`postCounterProposal failed with status ${response.status}`)
|
||||
}
|
||||
return response
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { hasFreeBusyConflict } from '@/components/Attendees/useFreeBusy'
|
||||
import { extractCalendarHrefs } from '@/utils/extractCalendarHrefs'
|
||||
import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid'
|
||||
import { getCalendars } from '../Calendars/CalendarApi'
|
||||
import {
|
||||
fetchFreeBusyPost,
|
||||
fetchFreeBusyReports,
|
||||
FreeBusyPostQuery
|
||||
} from './FreeBusyDao'
|
||||
|
||||
export async function getFreeBusyForAddedAttendee(
|
||||
userId: string,
|
||||
start: string,
|
||||
end: string
|
||||
): Promise<boolean> {
|
||||
const calendars = await getCalendars(
|
||||
userId,
|
||||
'withFreeBusy=true&withRights=true'
|
||||
)
|
||||
const hrefs = extractCalendarHrefs(calendars)
|
||||
if (hrefs.length === 0) return false
|
||||
|
||||
const results = await fetchFreeBusyReports({ hrefs, start, end })
|
||||
return results.some(data => (data ? hasFreeBusyConflict(data) : false))
|
||||
}
|
||||
|
||||
interface BusySlot {
|
||||
uid: string
|
||||
start: string
|
||||
end: string
|
||||
}
|
||||
|
||||
interface CalendarFreeBusy {
|
||||
id: string
|
||||
busy: BusySlot[]
|
||||
}
|
||||
|
||||
interface UserFreeBusy {
|
||||
id: string
|
||||
calendars: CalendarFreeBusy[]
|
||||
}
|
||||
interface FreeBusyPayload {
|
||||
users?: UserFreeBusy[]
|
||||
}
|
||||
|
||||
export async function getFreeBusyForEventAttendees(
|
||||
userIds: string[],
|
||||
start: string,
|
||||
end: string,
|
||||
eventUid: string
|
||||
): Promise<Record<string, boolean>> {
|
||||
const query: FreeBusyPostQuery = { userIds, start, end, eventUid }
|
||||
const payload = (await fetchFreeBusyPost(query)) as FreeBusyPayload
|
||||
const users = Array.isArray(payload.users) ? payload.users : []
|
||||
const eventUidBase = extractEventBaseUuid(eventUid)
|
||||
return Object.fromEntries(
|
||||
users.map(u => {
|
||||
const isBusy = (u.calendars ?? []).some(cal =>
|
||||
(cal.busy ?? []).some(
|
||||
slot => extractEventBaseUuid(slot.uid) !== eventUidBase
|
||||
)
|
||||
)
|
||||
return [u.id, isBusy]
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { api } from '@/utils/apiUtils'
|
||||
|
||||
export interface FreeBusyQuery {
|
||||
hrefs: string[]
|
||||
start: string
|
||||
end: string
|
||||
}
|
||||
/**
|
||||
* Fires REPORT requests against each calendar href to collect raw free/busy
|
||||
* data. Returns an array of parsed JSON payloads (null on failure).
|
||||
* It's for getting the free busy for added attendees in an event (new or while modifying)
|
||||
*/
|
||||
export async function fetchFreeBusyReports(
|
||||
query: FreeBusyQuery
|
||||
): Promise<unknown[]> {
|
||||
const body = JSON.stringify({
|
||||
type: 'free-busy-query',
|
||||
match: { start: query.start, end: query.end }
|
||||
})
|
||||
return Promise.all(
|
||||
query.hrefs.map(href =>
|
||||
api(`dav${href}`, {
|
||||
method: 'REPORT',
|
||||
headers: { Accept: 'application/json, text/plain, */*' },
|
||||
body
|
||||
})
|
||||
.then(r => (r.ok ? r.json() : null))
|
||||
.catch(() => null)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export interface FreeBusyPostQuery {
|
||||
userIds: string[]
|
||||
start: string
|
||||
end: string
|
||||
eventUid: string
|
||||
}
|
||||
/**
|
||||
* POSTs to the freebusy endpoint and returns the raw response payload.
|
||||
* Throws on non-OK status.
|
||||
* It's for getting the free busy for present attendees while editing event
|
||||
*/
|
||||
export async function fetchFreeBusyPost(
|
||||
query: FreeBusyPostQuery
|
||||
): Promise<unknown> {
|
||||
const r = await api('dav/calendars/freebusy', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json, text/plain, */*' },
|
||||
body: JSON.stringify({
|
||||
start: query.start,
|
||||
end: query.end,
|
||||
users: query.userIds,
|
||||
uids: [query.eventUid]
|
||||
})
|
||||
})
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
return r.json()
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { getCalendars } from '@/features/Calendars/CalendarApi'
|
||||
|
||||
import { api } from '@/utils/apiUtils'
|
||||
import { extractCalendarHrefs } from '@/utils/extractCalendarHrefs'
|
||||
import { hasFreeBusyConflict } from '../../../components/Attendees/useFreeBusy'
|
||||
|
||||
export async function getFreeBusyForAddedAttendeesREPORT(
|
||||
userId: string,
|
||||
start: string,
|
||||
end: string
|
||||
): Promise<boolean> {
|
||||
const calendars = await getCalendars(
|
||||
userId,
|
||||
'withFreeBusy=true&withRights=true'
|
||||
)
|
||||
const hrefs = extractCalendarHrefs(calendars)
|
||||
if (hrefs.length === 0) return false
|
||||
|
||||
const freeBusyBody = JSON.stringify({
|
||||
type: 'free-busy-query',
|
||||
match: { start, end }
|
||||
})
|
||||
|
||||
const results = await Promise.all(
|
||||
hrefs.map(href =>
|
||||
api(`dav${href}`, {
|
||||
method: 'REPORT',
|
||||
headers: { Accept: 'application/json, text/plain, */*' },
|
||||
body: freeBusyBody
|
||||
})
|
||||
.then(r => (r.ok ? r.json() : null))
|
||||
.then(data => (data ? hasFreeBusyConflict(data) : false))
|
||||
.catch(() => false)
|
||||
)
|
||||
)
|
||||
return results.some(Boolean)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { api } from '@/utils/apiUtils'
|
||||
import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid'
|
||||
|
||||
interface BusySlot {
|
||||
uid: string
|
||||
start: string
|
||||
end: string
|
||||
}
|
||||
|
||||
interface CalendarFreeBusy {
|
||||
id: string
|
||||
busy: BusySlot[]
|
||||
}
|
||||
|
||||
interface UserFreeBusy {
|
||||
id: string
|
||||
calendars: CalendarFreeBusy[]
|
||||
}
|
||||
|
||||
export async function getFreeBusyForEventAttendeesPOST(
|
||||
userIds: string[],
|
||||
start: string,
|
||||
end: string,
|
||||
eventUid: string
|
||||
): Promise<Record<string, boolean>> {
|
||||
const r = await api('dav/calendars/freebusy', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json, text/plain, */*' },
|
||||
body: JSON.stringify({ start, end, users: userIds, uids: [eventUid] })
|
||||
})
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
|
||||
const payload: unknown = await r.json()
|
||||
const users = Array.isArray((payload as { users?: unknown })?.users)
|
||||
? ((payload as { users: UserFreeBusy[] }).users ?? [])
|
||||
: []
|
||||
|
||||
const eventUidBase = extractEventBaseUuid(eventUid)
|
||||
|
||||
return Object.fromEntries(
|
||||
users.map(u => {
|
||||
const isBusy = u.calendars.some(cal =>
|
||||
cal.busy.some(slot => extractEventBaseUuid(slot.uid) !== eventUidBase)
|
||||
)
|
||||
return [u.id, isBusy]
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { api } from '@/utils/apiUtils'
|
||||
import { isValidEmail } from '@/utils/isValidEmail'
|
||||
|
||||
export async function getUserDataFromEmail(email: string) {
|
||||
if (!isValidEmail(email)) {
|
||||
return []
|
||||
}
|
||||
const r = await api(`api/users?email=${encodeURIComponent(email)}`)
|
||||
const result: Array<Record<string, string>> = await r.json()
|
||||
return result
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { api } from '@/utils/apiUtils'
|
||||
import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid'
|
||||
import { TIMEZONES } from '@/utils/timezone-data'
|
||||
import ICAL from 'ical.js'
|
||||
import { CalendarEvent } from '../EventsTypes'
|
||||
import { makeTimezone, makeVevent } from '../utils'
|
||||
|
||||
export async function postCounterProposal({
|
||||
event,
|
||||
senderEmail,
|
||||
recipientEmail,
|
||||
proposedStart,
|
||||
proposedEnd,
|
||||
message
|
||||
}: {
|
||||
event: CalendarEvent
|
||||
senderEmail: string
|
||||
recipientEmail: string
|
||||
proposedStart: string
|
||||
proposedEnd: string
|
||||
message?: string
|
||||
}): Promise<Response> {
|
||||
// Build the counter event with proposed dates
|
||||
const counterEvent: CalendarEvent = {
|
||||
...event,
|
||||
start: proposedStart,
|
||||
end: proposedEnd,
|
||||
sequence: event.sequence ?? 0
|
||||
}
|
||||
|
||||
// Build vevent jCal
|
||||
const vevent = makeVevent(
|
||||
counterEvent,
|
||||
counterEvent.timezone,
|
||||
senderEmail,
|
||||
!event.recurrenceId
|
||||
)
|
||||
if (message) {
|
||||
vevent?.[1]?.push(['comment', {}, 'text', message])
|
||||
}
|
||||
// Build vtimezone
|
||||
const timezoneData = TIMEZONES.zones[counterEvent.timezone]
|
||||
const vtimezone = makeTimezone(timezoneData, counterEvent)
|
||||
|
||||
// Assemble full vcalendar with METHOD:COUNTER
|
||||
const jcal = [
|
||||
'vcalendar',
|
||||
[
|
||||
['version', {}, 'text', '2.0'],
|
||||
['prodid', {}, 'text', '-//OpenPaaS//OpenPaaS//EN'],
|
||||
['method', {}, 'text', 'COUNTER']
|
||||
],
|
||||
[vevent, vtimezone.component.jCal]
|
||||
]
|
||||
|
||||
// Serialize to raw ICS
|
||||
const counterICS = new ICAL.Component(jcal).toString()
|
||||
|
||||
const postResponse = await api(`dav${event.URL}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
ical: counterICS,
|
||||
sender: senderEmail,
|
||||
recipient: recipientEmail,
|
||||
uid: extractEventBaseUuid(event.uid),
|
||||
sequence: counterEvent.sequence,
|
||||
method: 'COUNTER'
|
||||
}),
|
||||
headers: {
|
||||
accept: 'application/json,text/plain,*/*',
|
||||
'content-type': 'application/calendar+json',
|
||||
Prefer: 'return=representation',
|
||||
'X-Http-Method-Override': 'ITIP'
|
||||
}
|
||||
})
|
||||
|
||||
if (!postResponse.ok) {
|
||||
throw new Error(
|
||||
`postCounterProposal failed with status ${postResponse.status}`
|
||||
)
|
||||
}
|
||||
|
||||
return postResponse
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { api } from '@/utils/apiUtils'
|
||||
import { TIMEZONES } from '@/utils/timezone-data'
|
||||
import {
|
||||
VCalComponent,
|
||||
VObjectProperty
|
||||
} from '../../Calendars/types/CalendarData'
|
||||
import { getAllRecurrentEvent } from '../EventApi'
|
||||
import { fetchAllRecurrentVevents, putEventRaw } from '../EventDao'
|
||||
import { CalendarEvent } from '../EventsTypes'
|
||||
import { makeTimezone, makeVevent } from '../utils'
|
||||
|
||||
@@ -20,26 +19,22 @@ const METADATA_FIELDS = [
|
||||
] as const
|
||||
|
||||
// Helper function to get field values from props
|
||||
const getFieldValues = (props: VObjectProperty[], fieldName: string) => {
|
||||
return props.filter(([k]) => k.toLowerCase() === fieldName.toLowerCase())
|
||||
}
|
||||
const getFieldValues = (props: VObjectProperty[], fieldName: string) =>
|
||||
props.filter(([k]) => k.toLowerCase() === fieldName.toLowerCase())
|
||||
|
||||
// Helper function to find a single field value from props
|
||||
const findFieldValue = (props: VObjectProperty[], fieldName: string) => {
|
||||
return props.find(([k]) => k.toLowerCase() === fieldName.toLowerCase())
|
||||
}
|
||||
const findFieldValue = (props: VObjectProperty[], fieldName: string) =>
|
||||
props.find(([k]) => k.toLowerCase() === fieldName.toLowerCase())
|
||||
|
||||
// Helper function to serialize for comparison
|
||||
const serialize = (values: VObjectProperty[] | VCalComponent[]) => {
|
||||
return JSON.stringify(values)
|
||||
}
|
||||
const serialize = (values: VObjectProperty[] | VCalComponent[]) =>
|
||||
JSON.stringify(values)
|
||||
|
||||
// Helper function to filter components by name
|
||||
const filterComponentsByName = (components: VCalComponent[], name: string) => {
|
||||
return components.filter(
|
||||
const filterComponentsByName = (components: VCalComponent[], name: string) =>
|
||||
components.filter(
|
||||
([componentName]) => componentName.toLowerCase() === name.toLowerCase()
|
||||
)
|
||||
}
|
||||
|
||||
// Detect which metadata fields changed between old and new master
|
||||
const detectChangedMetadataFields = (
|
||||
@@ -125,11 +120,10 @@ const incrementSequenceNumber = (props: VObjectProperty[]) => {
|
||||
const updateValarmComponents = (
|
||||
components: VCalComponent[],
|
||||
newValarm: VCalComponent[]
|
||||
) => {
|
||||
return components
|
||||
) =>
|
||||
components
|
||||
.filter(([name]) => name.toLowerCase() !== 'valarm')
|
||||
.concat(newValarm)
|
||||
}
|
||||
|
||||
// Update a single vevent with metadata changes
|
||||
const updateVeventWithMetadataChanges = (
|
||||
@@ -156,10 +150,9 @@ const updateVeventWithMetadataChanges = (
|
||||
}
|
||||
|
||||
// Handle VALARM component updates
|
||||
let updatedComponents = components
|
||||
if (valarmChanged) {
|
||||
updatedComponents = updateValarmComponents(components, newValarm)
|
||||
}
|
||||
const updatedComponents = valarmChanged
|
||||
? updateValarmComponents(components, newValarm)
|
||||
: components
|
||||
|
||||
return [veventType, newProps, updatedComponents]
|
||||
}
|
||||
@@ -205,7 +198,8 @@ export const updateSeries = async (
|
||||
calOwnerEmail?: string,
|
||||
removeOverrides: boolean = true
|
||||
) => {
|
||||
const vevents = (await getAllRecurrentEvent(event)) as VCalComponent[]
|
||||
const vevents = await fetchAllRecurrentVevents(event)
|
||||
|
||||
const masterIndex = vevents.findIndex(
|
||||
([, props]) => !findFieldValue(props, 'recurrence-id')
|
||||
)
|
||||
@@ -249,11 +243,5 @@ export const updateSeries = async (
|
||||
|
||||
const newJCal = ['vcalendar', [], [...finalVevents, vtimezone.component.jCal]]
|
||||
|
||||
return api(`dav${event.URL}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(newJCal),
|
||||
headers: {
|
||||
'content-type': 'text/calendar; charset=utf-8'
|
||||
}
|
||||
})
|
||||
return putEventRaw(event, newJCal)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { api } from '@/utils/apiUtils'
|
||||
|
||||
/**
|
||||
* Fetches user data by email. Returns raw JSON array.
|
||||
*/
|
||||
export async function fetchUserByEmail(
|
||||
email: string
|
||||
): Promise<Array<Record<string, string>>> {
|
||||
const r = await api(`api/users?email=${encodeURIComponent(email)}`)
|
||||
if (!r.ok) {
|
||||
return []
|
||||
}
|
||||
return r.json()
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import { User } from '@/components/Attendees/types'
|
||||
import { api } from '@/utils/apiUtils'
|
||||
import { isValidEmail } from '@/utils/isValidEmail'
|
||||
import { BusinessHour } from '../Settings/SettingsSlice'
|
||||
import { OpenPaasUserData } from './type/OpenPaasUserData'
|
||||
import { ResourceData } from './type/ResourceData'
|
||||
import { fetchUserByEmail } from './UserDao'
|
||||
import {
|
||||
ConfigurationItem,
|
||||
ModuleConfiguration,
|
||||
SearchResponseItem
|
||||
} from './userDataTypes'
|
||||
import { ResourceData } from './type/ResourceData'
|
||||
|
||||
export async function getOpenPaasUser() {
|
||||
const user = await api.get(`api/user`)
|
||||
@@ -146,3 +148,10 @@ export async function updateUserConfigurations(
|
||||
json: modules
|
||||
})
|
||||
}
|
||||
|
||||
export async function getUserDataFromEmail(
|
||||
email: string
|
||||
): Promise<Array<Record<string, string>>> {
|
||||
if (!isValidEmail(email)) return []
|
||||
return fetchUserByEmail(email)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user