[#741] separation of base eventAPI to EventDao and eventApi (#812)

This commit is contained in:
Camille Moussu
2026-04-21 11:20:44 +02:00
committed by GitHub
parent 1a2e538d04
commit 7f922cbea1
14 changed files with 657 additions and 425 deletions
+170 -48
View File
@@ -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']
})
})
})
+7 -5
View File
@@ -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'
+190 -161
View File
@@ -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,47 +82,41 @@ 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 tzParam =
dtstartParams.tzid ||
dtstartParams.TZID ||
dtstartParams.Tzid ||
dtstartParams.tZid ||
dtstartParams.tzId
if (tzParam) {
const resolvedTz = resolveTimezoneId(tzParam)
if (resolvedTz) {
timezoneFromDTSTART = resolvedTz
}
}
}
if (
!timezoneFromDTSTART &&
typeof dtstartValue === 'string' &&
dtstartValue.endsWith('Z')
) {
timezoneFromDTSTART = 'Etc/UTC'
}
const dtstartParams = dtstartProp?.[1] as Record<string, string>
const dtstartValue = dtstartProp?.[3]
const tzParam =
dtstartParams?.['tzid'] ??
dtstartParams?.['TZID'] ??
dtstartParams?.['Tzid'] ??
dtstartParams?.['tZid'] ??
dtstartParams?.['tzId']
timezoneFromDTSTART = resolveTimezoneId(tzParam)
if (
!timezoneFromDTSTART &&
typeof dtstartValue === 'string' &&
dtstartValue.endsWith('Z')
) {
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())) {
// Update PARTSTAT parameter
const params = { ...prop[1], partstat: partstat }
return [prop[0], params, prop[2], prop[3]]
}
const calAddress = prop[3] as string
// 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] 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)
}
+123
View File
@@ -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
}
+66
View File
@@ -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]
})
)
}
+59
View File
@@ -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
}
+17 -29
View File
@@ -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)
}
+14
View File
@@ -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()
}
+10 -1
View File
@@ -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)
}