[#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, hasFreeBusyConflict,
useAttendeesFreeBusy useAttendeesFreeBusy
} from '@/components/Attendees/useFreeBusy' } from '@/components/Attendees/useFreeBusy'
import * as getFreeBusyREPORT from '@/features/Events/api/getFreeBusyForAddedAttendeesREPORT' import * as CalendarApi from '@/features/Calendars/CalendarApi'
import * as getFreeBusyPOST from '@/features/Events/api/getFreeBusyForEventAttendeesPOST' import * as FreeBusyDao from '@/features/Events/FreeBusyDao'
import * as getUserData from '@/features/Events/api/getUserDataFromEmail' import * as UserDao from '@/features/User/UserDao'
import { renderHook, waitFor } from '@testing-library/react' import { renderHook, waitFor } from '@testing-library/react'
jest.mock('moment-timezone', () => { jest.mock('moment-timezone', () => {
@@ -12,26 +12,130 @@ jest.mock('moment-timezone', () => {
return actual return actual
}) })
jest.mock('@/features/Events/api/getUserDataFromEmail') jest.mock('@/features/Events/FreeBusyDao')
jest.mock('@/features/Events/api/getFreeBusyForAddedAttendeesREPORT') jest.mock('@/features/User/UserDao')
jest.mock('@/features/Events/api/getFreeBusyForEventAttendeesPOST') jest.mock('@/features/Calendars/CalendarApi')
const mockGetUserData = getUserData.getUserDataFromEmail as jest.MockedFunction< const mockGetUserData = UserDao.fetchUserByEmail as jest.MockedFunction<
typeof getUserData.getUserDataFromEmail typeof UserDao.fetchUserByEmail
> >
const mockREPORT = const mockREPORT = FreeBusyDao.fetchFreeBusyReports as jest.MockedFunction<
getFreeBusyREPORT.getFreeBusyForAddedAttendeesREPORT as jest.MockedFunction< typeof FreeBusyDao.fetchFreeBusyReports
typeof getFreeBusyREPORT.getFreeBusyForAddedAttendeesREPORT >
> const mockPOST = FreeBusyDao.fetchFreeBusyPost as jest.MockedFunction<
const mockPOST = typeof FreeBusyDao.fetchFreeBusyPost
getFreeBusyPOST.getFreeBusyForEventAttendeesPOST as jest.MockedFunction< >
typeof getFreeBusyPOST.getFreeBusyForEventAttendeesPOST 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 START = '2026-03-14T14:00:00'
const END = '2026-03-14T15:00:00' const END = '2026-03-14T15:00:00'
const TZ = 'Europe/Paris' 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', () => { describe('hasFreeBusyConflict', () => {
it('returns false for non-vcalendar data', () => { it('returns false for non-vcalendar data', () => {
expect(hasFreeBusyConflict({ data: ['not-vcalendar', [], []] })).toBe(false) expect(hasFreeBusyConflict({ data: ['not-vcalendar', [], []] })).toBe(false)
@@ -82,13 +186,16 @@ describe('hasFreeBusyConflict', () => {
}) })
describe('useAttendeesFreeBusy — Flow B (new attendees)', () => { 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' } const newAttendee = { email: 'alice@example.com', userId: 'user-alice' }
it('shows loading then free for a new attendee', async () => { it('shows loading then free for a new attendee', async () => {
mockREPORT.mockResolvedValue(false) mockGetCalendars.mockResolvedValue(mockCalendarsResponse as never)
mockREPORT.mockResolvedValue(freeIcal)
const { result } = renderHook(() => const { result } = renderHook(() =>
useAttendeesFreeBusy({ useAttendeesFreeBusy({
existingAttendees: [], existingAttendees: [],
@@ -105,8 +212,8 @@ describe('useAttendeesFreeBusy — Flow B (new attendees)', () => {
}) })
it('shows busy when REPORT returns true', async () => { it('shows busy when REPORT returns true', async () => {
mockREPORT.mockResolvedValue(true) mockGetCalendars.mockResolvedValue(mockCalendarsResponse as never)
mockREPORT.mockResolvedValue(busyIcal)
const { result } = renderHook(() => const { result } = renderHook(() =>
useAttendeesFreeBusy({ useAttendeesFreeBusy({
existingAttendees: [], existingAttendees: [],
@@ -140,8 +247,20 @@ describe('useAttendeesFreeBusy — Flow B (new attendees)', () => {
it('resolves userId via getUserDataFromEmail when not provided', async () => { it('resolves userId via getUserDataFromEmail when not provided', async () => {
mockGetUserData.mockResolvedValue([{ _id: 'resolved-id' }] as never) 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 attendeeWithoutId = { email: 'bob@example.com' }
const { result } = renderHook(() => const { result } = renderHook(() =>
@@ -159,9 +278,11 @@ describe('useAttendeesFreeBusy — Flow B (new attendees)', () => {
) )
expect(mockGetUserData).toHaveBeenCalledWith('bob@example.com') expect(mockGetUserData).toHaveBeenCalledWith('bob@example.com')
expect(mockREPORT).toHaveBeenCalledWith( expect(mockREPORT).toHaveBeenCalledWith(
'resolved-id', expect.objectContaining({
expect.any(String), hrefs: ['/calendars/resolved-id/resolved-id'],
expect.any(String) 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 () => { 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( const { result, rerender } = renderHook(
({ attendees }) => ({ attendees }) =>
useAttendeesFreeBusy({ useAttendeesFreeBusy({
@@ -206,8 +327,8 @@ describe('useAttendeesFreeBusy — Flow B (new attendees)', () => {
}) })
it('removes departed attendee from the map', async () => { it('removes departed attendee from the map', async () => {
mockREPORT.mockResolvedValue(false) mockGetCalendars.mockResolvedValue(mockCalendarsResponse as never)
mockREPORT.mockResolvedValue(freeIcal)
const { result, rerender } = renderHook( const { result, rerender } = renderHook(
({ attendees }) => ({ attendees }) =>
useAttendeesFreeBusy({ useAttendeesFreeBusy({
@@ -230,8 +351,8 @@ describe('useAttendeesFreeBusy — Flow B (new attendees)', () => {
}) })
it('invalidates cache and re-fetches when time window changes', async () => { 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( const { result, rerender } = renderHook(
({ start, end }) => ({ start, end }) =>
useAttendeesFreeBusy({ useAttendeesFreeBusy({
@@ -254,8 +375,8 @@ describe('useAttendeesFreeBusy — Flow B (new attendees)', () => {
}) })
it('passes UTC-converted iCal times to the API', async () => { it('passes UTC-converted iCal times to the API', async () => {
mockREPORT.mockResolvedValue(false) mockGetCalendars.mockResolvedValue(mockCalendarsResponse as never)
mockREPORT.mockResolvedValue(freeIcal)
renderHook(() => renderHook(() =>
useAttendeesFreeBusy({ useAttendeesFreeBusy({
existingAttendees: [], 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 // Paris is UTC+1 in March (before DST), so 16:00 → 15:00 UTC
expect(mockREPORT).toHaveBeenCalledWith( expect(mockREPORT).toHaveBeenCalledWith(
'user-alice', expect.objectContaining({
'20260314T150000', start: '20260314T150000',
'20260314T160000' end: '20260314T160000'
})
) )
}) })
}) })
@@ -297,7 +419,7 @@ describe('useAttendeesFreeBusy — Flow A (existing attendees)', () => {
}) })
it('shows loading then free for existing attendees', async () => { it('shows loading then free for existing attendees', async () => {
mockPOST.mockResolvedValue({ 'user-carol': false }) mockPOST.mockResolvedValue(freePostResponse)
const { result } = renderHook(() => const { result } = renderHook(() =>
useAttendeesFreeBusy({ useAttendeesFreeBusy({
@@ -318,7 +440,7 @@ describe('useAttendeesFreeBusy — Flow A (existing attendees)', () => {
}) })
it('shows busy when POST returns busy for userId', async () => { it('shows busy when POST returns busy for userId', async () => {
mockPOST.mockResolvedValue({ 'user-carol': true }) mockPOST.mockResolvedValue(busyPostResponse)
const { result } = renderHook(() => const { result } = renderHook(() =>
useAttendeesFreeBusy({ useAttendeesFreeBusy({
@@ -331,9 +453,9 @@ describe('useAttendeesFreeBusy — Flow A (existing attendees)', () => {
}) })
) )
await waitFor(() => await waitFor(() => {
expect(result.current[existingAttendee.email]).toBe('busy') expect(result.current[existingAttendee.email]).toBe('busy')
) })
}) })
it('shows unknown when POST throws', async () => { 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 () => { it('passes the eventUid to the POST API', async () => {
mockPOST.mockResolvedValue({ 'user-carol': false }) mockPOST.mockResolvedValue(freePostResponse)
renderHook(() => renderHook(() =>
useAttendeesFreeBusy({ useAttendeesFreeBusy({
@@ -370,12 +492,12 @@ describe('useAttendeesFreeBusy — Flow A (existing attendees)', () => {
) )
await waitFor(() => expect(mockPOST).toHaveBeenCalled()) await waitFor(() => expect(mockPOST).toHaveBeenCalled())
expect(mockPOST).toHaveBeenCalledWith( expect(mockPOST).toHaveBeenCalledWith({
['user-carol'], end: '20260314T140000',
expect.any(String), eventUid: 'event-abc',
expect.any(String), start: '20260314T130000',
'event-abc' userIds: ['user-carol']
) })
}) })
}) })
+7 -5
View File
@@ -1,6 +1,8 @@
import { getFreeBusyForAddedAttendeesREPORT } from '@/features/Events/api/getFreeBusyForAddedAttendeesREPORT' import {
import { getFreeBusyForEventAttendeesPOST } from '@/features/Events/api/getFreeBusyForEventAttendeesPOST' getFreeBusyForAddedAttendee,
import { getUserDataFromEmail } from '@/features/Events/api/getUserDataFromEmail' getFreeBusyForEventAttendees
} from '@/features/Events/FreeBusyApi'
import { getUserDataFromEmail } from '@/features/User/userAPI'
import moment from 'moment-timezone' import moment from 'moment-timezone'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
@@ -150,7 +152,7 @@ export function useAttendeesFreeBusy({
setStatusMap(prev => ({ ...prev, ...toLoadingMap(existingAttendees) })) setStatusMap(prev => ({ ...prev, ...toLoadingMap(existingAttendees) }))
fetchFreeBusyMap(existingAttendees, resolved => fetchFreeBusyMap(existingAttendees, resolved =>
getFreeBusyForEventAttendeesPOST( getFreeBusyForEventAttendees(
resolved.map(r => r.userId), resolved.map(r => r.userId),
toUtcIcal(start, timezone), toUtcIcal(start, timezone),
toUtcIcal(end, timezone), toUtcIcal(end, timezone),
@@ -208,7 +210,7 @@ export function useAttendeesFreeBusy({
Promise.all( Promise.all(
resolved.map(async ({ email, userId }) => { resolved.map(async ({ email, userId }) => {
try { try {
const busy = await getFreeBusyForAddedAttendeesREPORT( const busy = await getFreeBusyForAddedAttendee(
userId, userId,
moment.tz(start, timezone).utc().format('YYYYMMDDTHHmmss'), moment.tz(start, timezone).utc().format('YYYYMMDDTHHmmss'),
moment.tz(end, 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 moment from 'moment-timezone'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useI18n } from 'twake-i18n' import { useI18n } from 'twake-i18n'
import { postCounterProposal } from '../api/sendCounterProposal' import { postCounterProposal } from '../EventApi'
import { EventTimeSubtitle } from '../EventPreview/EventTimeSubtitle' import { EventTimeSubtitle } from '../EventPreview/EventTimeSubtitle'
import { ContextualizedEvent } from '../EventsTypes' 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 { convertEventDateTimeToISO, resolveTimezoneId } from '@/utils/timezone'
import { TIMEZONES } from '@/utils/timezone-data' import { TIMEZONES } from '@/utils/timezone-data'
import ICAL from 'ical.js' import ICAL from 'ical.js'
@@ -10,6 +10,19 @@ import {
VObjectValue VObjectValue
} from '../Calendars/types/CalendarData' } from '../Calendars/types/CalendarData'
import { SearchEventsResponse } from '../Search/types/SearchEventsResponse' 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 { CalendarEvent } from './EventsTypes'
import { import {
calendarEventToJCal, calendarEventToJCal,
@@ -22,36 +35,30 @@ export async function reportEvent(
event: CalendarEvent, event: CalendarEvent,
match: { start: string; end: string } match: { start: string; end: string }
): Promise<CalDavItem> { ): Promise<CalDavItem> {
const response = await api(`dav${event.URL}`, { return reportEventRaw(event, match)
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
} }
export async function getEvent(event: CalendarEvent, isMaster?: boolean) { export async function getEvent(
const response = await api.get(`dav${event.URL}`) event: CalendarEvent,
const eventData = await response.text() isMaster?: boolean
): Promise<CalendarEvent> {
const eventData = await fetchEventRaw(event)
const eventical = ICAL.parse(eventData) const eventical = ICAL.parse(eventData) as VCalComponent
const vevents = (eventical[2] || []).filter( const vevents = (eventical[2] ?? []).filter(
([name]: [string]) => name.toLowerCase() === 'vevent' ([name]) => name.toLowerCase() === 'vevent'
)
const vtimezones = (eventical[2] ?? []).filter(
([name]) => name.toLowerCase() === 'vtimezone'
) )
const vtimezones = (eventical[2] || []).filter( let targetVevent: VCalComponent | undefined
([name]: [string]) => name.toLowerCase() === 'vtimezone'
)
let targetVevent
if (isMaster) { if (isMaster) {
targetVevent = vevents.find( targetVevent = vevents.find(
([, props]: VCalComponent) => ([, props]) =>
!props.find(([k]) => k.toLowerCase() === 'recurrence-id') !(props as VObjectProperty[]).find(
([k]) => k.toLowerCase() === 'recurrence-id'
)
) )
if (!targetVevent) { if (!targetVevent) {
targetVevent = vevents[0] targetVevent = vevents[0]
@@ -63,11 +70,11 @@ export async function getEvent(event: CalendarEvent, isMaster?: boolean) {
let timezoneFromVTimezone: string | undefined let timezoneFromVTimezone: string | undefined
if (vtimezones.length > 0) { if (vtimezones.length > 0) {
const vtimezone = vtimezones[0] const vtimezone = vtimezones[0]
const tzidProp = vtimezone[1]?.find( const tzidProp = (vtimezone[1] as VObjectProperty[]).find(
([k]: string[]) => k.toLowerCase() === 'tzid' ([k]) => k.toLowerCase() === 'tzid'
) )
if (tzidProp && tzidProp[3]) { if (tzidProp?.[3]) {
const resolvedTz = resolveTimezoneId(tzidProp[3]) const resolvedTz = resolveTimezoneId(tzidProp[3] as string)
if (resolvedTz) { if (resolvedTz) {
timezoneFromVTimezone = resolvedTz timezoneFromVTimezone = resolvedTz
} }
@@ -75,47 +82,41 @@ export async function getEvent(event: CalendarEvent, isMaster?: boolean) {
} }
let timezoneFromDTSTART: string | undefined let timezoneFromDTSTART: string | undefined
const dtstartProp = targetVevent[1]?.find( const dtstartProp = (targetVevent[1] as VObjectProperty[]).find(
([k]: string[]) => k.toLowerCase() === 'dtstart' ([k]) => k.toLowerCase() === 'dtstart'
) )
if (dtstartProp) { const dtstartParams = dtstartProp?.[1] as Record<string, string>
const dtstartParams = dtstartProp[1] const dtstartValue = dtstartProp?.[3]
const dtstartValue = dtstartProp[3] const tzParam =
if (dtstartParams) { dtstartParams?.['tzid'] ??
const tzParam = dtstartParams?.['TZID'] ??
dtstartParams.tzid || dtstartParams?.['Tzid'] ??
dtstartParams.TZID || dtstartParams?.['tZid'] ??
dtstartParams.Tzid || dtstartParams?.['tzId']
dtstartParams.tZid ||
dtstartParams.tzId timezoneFromDTSTART = resolveTimezoneId(tzParam)
if (tzParam) {
const resolvedTz = resolveTimezoneId(tzParam) if (
if (resolvedTz) { !timezoneFromDTSTART &&
timezoneFromDTSTART = resolvedTz typeof dtstartValue === 'string' &&
} dtstartValue.endsWith('Z')
} ) {
} timezoneFromDTSTART = 'Etc/UTC'
if (
!timezoneFromDTSTART &&
typeof dtstartValue === 'string' &&
dtstartValue.endsWith('Z')
) {
timezoneFromDTSTART = 'Etc/UTC'
}
} }
const eventjson = parseCalendarEvent( const eventjson = parseCalendarEvent(
targetVevent[1], targetVevent[1] as VObjectProperty[],
event.color ?? {}, event.color ?? {},
{ id: event?.calId } as Calendar, { id: event?.calId } as Calendar,
event.URL event.URL
) )
const finalTimezone = const finalTimezone =
timezoneFromVTimezone || timezoneFromVTimezone ??
timezoneFromDTSTART || timezoneFromDTSTART ??
eventjson.timezone || eventjson.timezone ??
'Etc/UTC' 'Etc/UTC'
eventjson.timezone = finalTimezone eventjson.timezone = finalTimezone
if (!eventjson.allday && eventjson.start && 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 } const merged = { ...event, ...eventjson }
merged.timezone = finalTimezone merged.timezone = finalTimezone
return merged return merged
} }
export async function dlEvent(event: CalendarEvent) { export async function dlEvent(event: CalendarEvent): Promise<string> {
const response = await api.get(`dav${event.URL}?export=`) const response = await fetchEventIcs(event)
const eventData = await response.text() return response
return eventData
} }
export async function putEvent(event: CalendarEvent, calOwnerEmail?: string) { export async function putEvent(
const response = await api(`dav${event.URL}`, { event: CalendarEvent,
method: 'PUT', calOwnerEmail?: string
body: JSON.stringify(calendarEventToJCal(event, calOwnerEmail)), ): Promise<Response> {
headers: { const jCal = calendarEventToJCal(event, calOwnerEmail)
'content-type': 'text/calendar; charset=utf-8' const response = await putEventRaw(event, jCal)
}
})
if (response.status === 201) { if (response.status === 201) {
console.info('Event created successfully:', response.url || event.URL) 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( export async function putEventWithOverrides(
updatedEvent: CalendarEvent, updatedEvent: CalendarEvent,
calOwnerEmail?: string calOwnerEmail?: string
) { ): Promise<Response> {
const vevents = await getAllRecurrentEvent(updatedEvent) const vevents = await fetchAllRecurrentVevents(updatedEvent)
const updatedVevent = makeVevent( const updatedVevent = makeVevent(
updatedEvent, updatedEvent,
@@ -180,61 +172,64 @@ export async function putEventWithOverrides(
let replaced = false let replaced = false
for (let i = 0; i < vevents.length; i++) { for (let i = 0; i < vevents.length; i++) {
const ve = vevents[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) { if (recurrenceId && recurrenceId[3] === updatedEvent.recurrenceId) {
vevents[i] = updatedVevent // replace vevents[i] = updatedVevent as VCalComponent // replace
replaced = true replaced = true
break break
} }
} }
if (!replaced && updatedEvent.recurrenceId) { 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 timezoneData = TIMEZONES.zones[updatedEvent.timezone]
const vtimezone = makeTimezone(timezoneData, updatedEvent) const vtimezone = makeTimezone(timezoneData, updatedEvent)
const newJCal = ['vcalendar', [], [...vevents, vtimezone.component.jCal]] const newJCal = ['vcalendar', [], [...vevents, vtimezone.component.jCal]]
return putEventRaw(updatedEvent, newJCal)
return api(`dav${updatedEvent.URL}`, {
method: 'PUT',
body: JSON.stringify(newJCal),
headers: {
'content-type': 'text/calendar; charset=utf-8'
}
})
} }
export const deleteEventInstance = async (event: CalendarEvent) => { export const deleteEventInstance = async (
event: CalendarEvent
): Promise<Response> => {
// Get all VEVENTs (master + overrides) from the series // Get all VEVENTs (master + overrides) from the series
const vevents = await getAllRecurrentEvent(event) const vevents = await fetchAllRecurrentVevents(event)
// Find the master VEVENT // Find the master VEVENT
const masterIndex = vevents.findIndex( const masterIndex = vevents.findIndex(
([, props]: VCalComponent) => ([, props]) =>
!props.find(([k]) => k.toLowerCase() === 'recurrence-id') !(props as VObjectProperty[]).find(
([k]) => k.toLowerCase() === 'recurrence-id'
)
) )
if (masterIndex === -1) { if (masterIndex === -1) {
throw new Error('No master VEVENT found for this series') 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( const seriesEvent = parseCalendarEvent(
vevents[masterIndex][1], vevents[masterIndex][1] as VObjectProperty[],
{}, {},
{ id: event.calId } as Calendar, { 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) => { const isDuplicate = masterProps.some((prop: VObjectProperty) => {
if (prop[0].toLowerCase() === 'exdate' && prop[3]) { if (prop[0].toLowerCase() === 'exdate' && prop[3]) {
return ( return (
normalizeRecurrenceId(prop[3]) === normalizeRecurrenceId(exdateValue) normalizeRecurrenceId(prop[3]) ===
normalizeRecurrenceId(exdateValue as VObjectValue)
) )
} }
return false return false
@@ -250,14 +245,14 @@ export const deleteEventInstance = async (event: CalendarEvent) => {
vevents[masterIndex][1] = masterProps vevents[masterIndex][1] = masterProps
// Remove the override instance if it exists (in case it was an override being deleted) // Remove the override instance if it exists (in case it was an override being deleted)
const filteredVevents = vevents.filter(([, props]: VCalComponent) => { const filteredVevents = vevents.filter(([, props]) => {
const recurrenceIdProp = props.find( const recurrenceIdProp = (props as VObjectProperty[]).find(
([k]) => k.toLowerCase() === 'recurrence-id' ([k]) => k.toLowerCase() === 'recurrence-id'
) )
if (!recurrenceIdProp) return true // Keep master if (!recurrenceIdProp) return true // Keep master
return ( return (
normalizeRecurrenceId(recurrenceIdProp[3]) !== normalizeRecurrenceId(recurrenceIdProp[3]) !==
normalizeRecurrenceId(event.recurrenceId ?? '') normalizeRecurrenceId((event.recurrenceId ?? '') as VObjectValue)
) // Remove matching override ) // Remove matching override
}) })
@@ -271,39 +266,33 @@ export const deleteEventInstance = async (event: CalendarEvent) => {
[...filteredVevents, vtimezone.component.jCal] [...filteredVevents, vtimezone.component.jCal]
] ]
return api(`dav${event.URL}`, { return putEventRaw(event, newJCal)
method: 'PUT',
body: JSON.stringify(newJCal),
headers: {
'content-type': 'text/calendar; charset=utf-8'
}
})
} }
export const updateSeriesPartstat = async ( export const updateSeriesPartstat = async (
event: CalendarEvent, event: CalendarEvent,
attendeeEmail: string, attendeeEmail: string,
partstat: string partstat: string
) => { ): Promise<Response> => {
const vevents = await getAllRecurrentEvent(event) const vevents = await fetchAllRecurrentVevents(event)
// Update PARTSTAT in ALL VEVENTs (master + exceptions) // Update PARTSTAT in ALL VEVENTs (master + exceptions)
const updatedVevents = vevents.map((vevent: VCalComponent) => { const updatedVevents = vevents.map((vevent: VCalComponent) => {
const properties = vevent[1] const properties = vevent[1] as VObjectProperty[]
const updatedProperties = properties.map((prop: VObjectProperty) => { const updatedProperties = properties.map((prop: VObjectProperty) => {
// Find ATTENDEE properties const calAddress = prop[3] as string
if (prop[0] === 'attendee') { // Find ATTENDEE properties & Check if this is the target attendee
const calAddress = prop[3] as string if (
// Check if this is the target attendee prop[0] === 'attendee' &&
if (calAddress.toLowerCase().includes(attendeeEmail.toLowerCase())) { calAddress.toLowerCase().includes(attendeeEmail.toLowerCase())
// Update PARTSTAT parameter ) {
const params = { ...prop[1], partstat: partstat } // Update PARTSTAT parameter
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 prop
}) })
return [vevent[0], updatedProperties, vevent[2]] return [vevent[0], updatedProperties, vevent[2]] as VCalComponent
}) })
const timezoneData = TIMEZONES.zones[event.timezone] const timezoneData = TIMEZONES.zones[event.timezone]
@@ -315,45 +304,31 @@ export const updateSeriesPartstat = async (
[...updatedVevents, vtimezone.component.jCal] [...updatedVevents, vtimezone.component.jCal]
] ]
return api(`dav${event.URL}`, { return putEventRaw(event, newJCal)
method: 'PUT',
body: JSON.stringify(newJCal),
headers: {
'content-type': 'text/calendar; charset=utf-8'
}
})
} }
export async function getAllRecurrentEvent(event: CalendarEvent) { export async function getAllRecurrentEvent(
const response = await api.get(`dav${event.URL}`) event: CalendarEvent
const eventData = await response.text() ): Promise<VCalComponent[]> {
const jcal = ICAL.parse(eventData) return fetchAllRecurrentVevents(event)
const vevents = jcal[2].filter(([name]: string[]) => name === 'vevent')
return vevents
} }
export async function moveEvent(event: CalendarEvent, newUrl: string) { export async function moveEvent(
const response = await api(`dav${event.URL}`, { event: CalendarEvent,
method: 'MOVE', newUrl: string
headers: { ): Promise<Response> {
destination: newUrl return moveEventRaw(event, newUrl)
}
})
return response
} }
export async function deleteEvent(eventURL: string) { export async function deleteEvent(eventURL: string): Promise<Response> {
const response = await api(`dav${eventURL}`, { return deleteEventRaw({ URL: eventURL } as CalendarEvent)
method: 'DELETE'
})
return response
} }
export async function importEventFromFile(id: string, calLink: string) { export async function importEventFromFile(
const response = await api.post(`api/import`, { id: string,
body: JSON.stringify({ fileId: id, target: calLink }) calLink: string
}) ): Promise<Response> {
return response return importEventRaw(id, calLink)
} }
export async function searchEvent( export async function searchEvent(
@@ -385,11 +360,65 @@ export async function searchEvent(
if (attendees.length) { if (attendees.length) {
reqParam.attendees = attendees reqParam.attendees = attendees
} }
const response = await api return searchEventRaw(reqParam)
.post('calendar/api/events/search?limit=30&offset=0', { }
body: JSON.stringify(reqParam)
}) export async function postCounterProposal({
.json() event,
senderEmail,
return response as SearchEventsResponse 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 { TIMEZONES } from '@/utils/timezone-data'
import { import {
VCalComponent, VCalComponent,
VObjectProperty VObjectProperty
} from '../../Calendars/types/CalendarData' } from '../../Calendars/types/CalendarData'
import { getAllRecurrentEvent } from '../EventApi' import { fetchAllRecurrentVevents, putEventRaw } from '../EventDao'
import { CalendarEvent } from '../EventsTypes' import { CalendarEvent } from '../EventsTypes'
import { makeTimezone, makeVevent } from '../utils' import { makeTimezone, makeVevent } from '../utils'
@@ -20,26 +19,22 @@ const METADATA_FIELDS = [
] as const ] as const
// Helper function to get field values from props // Helper function to get field values from props
const getFieldValues = (props: VObjectProperty[], fieldName: string) => { const getFieldValues = (props: VObjectProperty[], fieldName: string) =>
return props.filter(([k]) => k.toLowerCase() === fieldName.toLowerCase()) props.filter(([k]) => k.toLowerCase() === fieldName.toLowerCase())
}
// Helper function to find a single field value from props // Helper function to find a single field value from props
const findFieldValue = (props: VObjectProperty[], fieldName: string) => { const findFieldValue = (props: VObjectProperty[], fieldName: string) =>
return props.find(([k]) => k.toLowerCase() === fieldName.toLowerCase()) props.find(([k]) => k.toLowerCase() === fieldName.toLowerCase())
}
// Helper function to serialize for comparison // Helper function to serialize for comparison
const serialize = (values: VObjectProperty[] | VCalComponent[]) => { const serialize = (values: VObjectProperty[] | VCalComponent[]) =>
return JSON.stringify(values) JSON.stringify(values)
}
// Helper function to filter components by name // Helper function to filter components by name
const filterComponentsByName = (components: VCalComponent[], name: string) => { const filterComponentsByName = (components: VCalComponent[], name: string) =>
return components.filter( components.filter(
([componentName]) => componentName.toLowerCase() === name.toLowerCase() ([componentName]) => componentName.toLowerCase() === name.toLowerCase()
) )
}
// Detect which metadata fields changed between old and new master // Detect which metadata fields changed between old and new master
const detectChangedMetadataFields = ( const detectChangedMetadataFields = (
@@ -125,11 +120,10 @@ const incrementSequenceNumber = (props: VObjectProperty[]) => {
const updateValarmComponents = ( const updateValarmComponents = (
components: VCalComponent[], components: VCalComponent[],
newValarm: VCalComponent[] newValarm: VCalComponent[]
) => { ) =>
return components components
.filter(([name]) => name.toLowerCase() !== 'valarm') .filter(([name]) => name.toLowerCase() !== 'valarm')
.concat(newValarm) .concat(newValarm)
}
// Update a single vevent with metadata changes // Update a single vevent with metadata changes
const updateVeventWithMetadataChanges = ( const updateVeventWithMetadataChanges = (
@@ -156,10 +150,9 @@ const updateVeventWithMetadataChanges = (
} }
// Handle VALARM component updates // Handle VALARM component updates
let updatedComponents = components const updatedComponents = valarmChanged
if (valarmChanged) { ? updateValarmComponents(components, newValarm)
updatedComponents = updateValarmComponents(components, newValarm) : components
}
return [veventType, newProps, updatedComponents] return [veventType, newProps, updatedComponents]
} }
@@ -205,7 +198,8 @@ export const updateSeries = async (
calOwnerEmail?: string, calOwnerEmail?: string,
removeOverrides: boolean = true removeOverrides: boolean = true
) => { ) => {
const vevents = (await getAllRecurrentEvent(event)) as VCalComponent[] const vevents = await fetchAllRecurrentVevents(event)
const masterIndex = vevents.findIndex( const masterIndex = vevents.findIndex(
([, props]) => !findFieldValue(props, 'recurrence-id') ([, props]) => !findFieldValue(props, 'recurrence-id')
) )
@@ -249,11 +243,5 @@ export const updateSeries = async (
const newJCal = ['vcalendar', [], [...finalVevents, vtimezone.component.jCal]] const newJCal = ['vcalendar', [], [...finalVevents, vtimezone.component.jCal]]
return api(`dav${event.URL}`, { return putEventRaw(event, newJCal)
method: 'PUT',
body: JSON.stringify(newJCal),
headers: {
'content-type': 'text/calendar; charset=utf-8'
}
})
} }
+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 { User } from '@/components/Attendees/types'
import { api } from '@/utils/apiUtils' import { api } from '@/utils/apiUtils'
import { isValidEmail } from '@/utils/isValidEmail'
import { BusinessHour } from '../Settings/SettingsSlice' import { BusinessHour } from '../Settings/SettingsSlice'
import { OpenPaasUserData } from './type/OpenPaasUserData' import { OpenPaasUserData } from './type/OpenPaasUserData'
import { ResourceData } from './type/ResourceData'
import { fetchUserByEmail } from './UserDao'
import { import {
ConfigurationItem, ConfigurationItem,
ModuleConfiguration, ModuleConfiguration,
SearchResponseItem SearchResponseItem
} from './userDataTypes' } from './userDataTypes'
import { ResourceData } from './type/ResourceData'
export async function getOpenPaasUser() { export async function getOpenPaasUser() {
const user = await api.get(`api/user`) const user = await api.get(`api/user`)
@@ -146,3 +148,10 @@ export async function updateUserConfigurations(
json: modules json: modules
}) })
} }
export async function getUserDataFromEmail(
email: string
): Promise<Array<Record<string, string>>> {
if (!isValidEmail(email)) return []
return fetchUserByEmail(email)
}