diff --git a/__test__/features/Events/EventModal.test.tsx b/__test__/features/Events/EventModal.test.tsx index 51094ba..cf716a3 100644 --- a/__test__/features/Events/EventModal.test.tsx +++ b/__test__/features/Events/EventModal.test.tsx @@ -1,3 +1,4 @@ +import * as calendarsApi from "@/features/Calendars/CalendarApi"; import * as eventThunks from "@/features/Calendars/services"; import EventPopover from "@/features/Events/EventModal"; import { api } from "@/utils/apiUtils"; @@ -210,6 +211,9 @@ describe("EventPopover", () => { }); it("adds a attendee", async () => { jest.useFakeTimers(); + jest + .spyOn(calendarsApi, "getCalendars") + .mockReturnValue({ json: jest.fn() }); renderPopover(); fireEvent.change(screen.getByLabelText("event.form.title"), { target: { value: "newEvent" }, diff --git a/__test__/features/Events/FreeBusy.test.tsx b/__test__/features/Events/FreeBusy.test.tsx new file mode 100644 index 0000000..c4931f2 --- /dev/null +++ b/__test__/features/Events/FreeBusy.test.tsx @@ -0,0 +1,401 @@ +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 { renderHook, waitFor } from "@testing-library/react"; + +jest.mock("moment-timezone", () => { + const actual = jest.requireActual("moment-timezone"); + return actual; +}); + +jest.mock("@/features/Events/api/getUserDataFromEmail"); +jest.mock("@/features/Events/api/getFreeBusyForAddedAttendeesREPORT"); +jest.mock("@/features/Events/api/getFreeBusyForEventAttendeesPOST"); + +const mockGetUserData = getUserData.getUserDataFromEmail as jest.MockedFunction< + typeof getUserData.getUserDataFromEmail +>; +const mockREPORT = + getFreeBusyREPORT.getFreeBusyForAddedAttendeesREPORT as jest.MockedFunction< + typeof getFreeBusyREPORT.getFreeBusyForAddedAttendeesREPORT + >; +const mockPOST = + getFreeBusyPOST.getFreeBusyForEventAttendeesPOST as jest.MockedFunction< + typeof getFreeBusyPOST.getFreeBusyForEventAttendeesPOST + >; + +const START = "2026-03-14T14:00:00"; +const END = "2026-03-14T15:00:00"; +const TZ = "Europe/Paris"; + +describe("hasFreeBusyConflict", () => { + it("returns false for non-vcalendar data", () => { + expect(hasFreeBusyConflict({ data: ["not-vcalendar", [], []] })).toBe( + false + ); + }); + + it("returns false when vfreebusy has no freebusy property", () => { + const data = { + data: [ + "vcalendar", + [], + [ + [ + "vfreebusy", + [["dtstart", {}, "date-time", "2026-03-14T14:00:00Z"]], + [], + ], + ], + ], + }; + expect(hasFreeBusyConflict(data)).toBe(false); + }); + + it("returns true when vfreebusy contains a freebusy property", () => { + const data = { + data: [ + "vcalendar", + [], + [ + [ + "vfreebusy", + [ + ["dtstart", {}, "date-time", "2026-03-14T14:00:00Z"], + ["freebusy", {}, "period", "2026-03-14T14:00:00Z/PT1H"], + ], + [], + ], + ], + ], + }; + expect(hasFreeBusyConflict(data)).toBe(true); + }); + + it("returns false for malformed data without throwing", () => { + expect(hasFreeBusyConflict(null)).toBe(false); + expect(hasFreeBusyConflict("garbage")).toBe(false); + expect(hasFreeBusyConflict({ data: null })).toBe(false); + }); +}); + +describe("useAttendeesFreeBusy — Flow B (new attendees)", () => { + beforeEach(() => jest.clearAllMocks()); + + const newAttendee = { email: "alice@example.com", userId: "user-alice" }; + + it("shows loading then free for a new attendee", async () => { + mockREPORT.mockResolvedValue(false); + + const { result } = renderHook(() => + useAttendeesFreeBusy({ + existingAttendees: [], + newAttendees: [newAttendee], + start: START, + end: END, + timezone: TZ, + }) + ); + + expect(result.current[newAttendee.email]).toBe("loading"); + + await waitFor(() => expect(result.current[newAttendee.email]).toBe("free")); + }); + + it("shows busy when REPORT returns true", async () => { + mockREPORT.mockResolvedValue(true); + + const { result } = renderHook(() => + useAttendeesFreeBusy({ + existingAttendees: [], + newAttendees: [newAttendee], + start: START, + end: END, + timezone: TZ, + }) + ); + + await waitFor(() => expect(result.current[newAttendee.email]).toBe("busy")); + }); + + it("shows unknown when REPORT throws", async () => { + mockREPORT.mockRejectedValue(new Error("network error")); + + const { result } = renderHook(() => + useAttendeesFreeBusy({ + existingAttendees: [], + newAttendees: [newAttendee], + start: START, + end: END, + timezone: TZ, + }) + ); + + await waitFor(() => + expect(result.current[newAttendee.email]).toBe("unknown") + ); + }); + + it("resolves userId via getUserDataFromEmail when not provided", async () => { + mockGetUserData.mockResolvedValue([{ _id: "resolved-id" }] as never); + mockREPORT.mockResolvedValue(false); + + const attendeeWithoutId = { email: "bob@example.com" }; + + const { result } = renderHook(() => + useAttendeesFreeBusy({ + existingAttendees: [], + newAttendees: [attendeeWithoutId], + start: START, + end: END, + timezone: TZ, + }) + ); + + await waitFor(() => + expect(result.current[attendeeWithoutId.email]).toBe("free") + ); + expect(mockGetUserData).toHaveBeenCalledWith("bob@example.com"); + expect(mockREPORT).toHaveBeenCalledWith( + "resolved-id", + expect.any(String), + expect.any(String) + ); + }); + + it("shows unknown when userId cannot be resolved", async () => { + mockGetUserData.mockResolvedValue([]); + + const { result } = renderHook(() => + useAttendeesFreeBusy({ + existingAttendees: [], + newAttendees: [{ email: "ghost@example.com" }], + start: START, + end: END, + timezone: TZ, + }) + ); + + await waitFor(() => + expect(result.current["ghost@example.com"]).toBe("unknown") + ); + expect(mockREPORT).not.toHaveBeenCalled(); + }); + + it("does not re-fetch an attendee already fetched", async () => { + mockREPORT.mockResolvedValue(false); + + const { result, rerender } = renderHook( + ({ attendees }) => + useAttendeesFreeBusy({ + existingAttendees: [], + newAttendees: attendees, + start: START, + end: END, + timezone: TZ, + }), + { initialProps: { attendees: [newAttendee] } } + ); + + await waitFor(() => expect(result.current[newAttendee.email]).toBe("free")); + + rerender({ attendees: [newAttendee] }); + expect(mockREPORT).toHaveBeenCalledTimes(1); + }); + + it("removes departed attendee from the map", async () => { + mockREPORT.mockResolvedValue(false); + + const { result, rerender } = renderHook( + ({ attendees }) => + useAttendeesFreeBusy({ + existingAttendees: [], + newAttendees: attendees, + start: START, + end: END, + timezone: TZ, + }), + { initialProps: { attendees: [newAttendee] } } + ); + + await waitFor(() => expect(result.current[newAttendee.email]).toBe("free")); + + rerender({ attendees: [] }); + + await waitFor(() => + expect(result.current[newAttendee.email]).toBeUndefined() + ); + }); + + it("invalidates cache and re-fetches when time window changes", async () => { + mockREPORT.mockResolvedValue(false); + + const { result, rerender } = renderHook( + ({ start, end }) => + useAttendeesFreeBusy({ + existingAttendees: [], + newAttendees: [newAttendee], + start, + end, + timezone: TZ, + }), + { initialProps: { start: START, end: END } } + ); + + await waitFor(() => expect(result.current[newAttendee.email]).toBe("free")); + + rerender({ start: "2026-03-15T14:00:00", end: "2026-03-15T15:00:00" }); + + await waitFor(() => expect(result.current[newAttendee.email]).toBe("free")); + + expect(mockREPORT).toHaveBeenCalledTimes(2); + }); + + it("passes UTC-converted iCal times to the API", async () => { + mockREPORT.mockResolvedValue(false); + + renderHook(() => + useAttendeesFreeBusy({ + existingAttendees: [], + newAttendees: [newAttendee], + start: "2026-03-14T16:00:00", // 16:00 Paris = 15:00 UTC in winter + end: "2026-03-14T17:00:00", + timezone: "Europe/Paris", + }) + ); + + await waitFor(() => expect(mockREPORT).toHaveBeenCalled()); + + // Paris is UTC+1 in March (before DST), so 16:00 → 15:00 UTC + expect(mockREPORT).toHaveBeenCalledWith( + "user-alice", + "20260314T150000", + "20260314T160000" + ); + }); +}); + +describe("useAttendeesFreeBusy — Flow A (existing attendees)", () => { + beforeEach(() => jest.clearAllMocks()); + + const existingAttendee = { email: "carol@example.com", userId: "user-carol" }; + + it("does not fetch when eventUid is missing", () => { + renderHook(() => + useAttendeesFreeBusy({ + existingAttendees: [existingAttendee], + newAttendees: [], + start: START, + end: END, + timezone: TZ, + }) + ); + + expect(mockPOST).not.toHaveBeenCalled(); + }); + + it("shows loading then free for existing attendees", async () => { + mockPOST.mockResolvedValue({ "user-carol": false }); + + const { result } = renderHook(() => + useAttendeesFreeBusy({ + existingAttendees: [existingAttendee], + newAttendees: [], + start: START, + end: END, + timezone: TZ, + eventUid: "event-123", + }) + ); + + expect(result.current[existingAttendee.email]).toBe("loading"); + + await waitFor(() => + expect(result.current[existingAttendee.email]).toBe("free") + ); + }); + + it("shows busy when POST returns busy for userId", async () => { + mockPOST.mockResolvedValue({ "user-carol": true }); + + const { result } = renderHook(() => + useAttendeesFreeBusy({ + existingAttendees: [existingAttendee], + newAttendees: [], + start: START, + end: END, + timezone: TZ, + eventUid: "event-123", + }) + ); + + await waitFor(() => + expect(result.current[existingAttendee.email]).toBe("busy") + ); + }); + + it("shows unknown when POST throws", async () => { + mockPOST.mockRejectedValue(new Error("server error")); + + const { result } = renderHook(() => + useAttendeesFreeBusy({ + existingAttendees: [existingAttendee], + newAttendees: [], + start: START, + end: END, + timezone: TZ, + eventUid: "event-123", + }) + ); + + await waitFor(() => + expect(result.current[existingAttendee.email]).toBe("unknown") + ); + }); + + it("passes the eventUid to the POST API", async () => { + mockPOST.mockResolvedValue({ "user-carol": false }); + + renderHook(() => + useAttendeesFreeBusy({ + existingAttendees: [existingAttendee], + newAttendees: [], + start: START, + end: END, + timezone: TZ, + eventUid: "event-abc", + }) + ); + + await waitFor(() => expect(mockPOST).toHaveBeenCalled()); + expect(mockPOST).toHaveBeenCalledWith( + ["user-carol"], + expect.any(String), + expect.any(String), + "event-abc" + ); + }); +}); + +describe("useAttendeesFreeBusy — enabled flag", () => { + it("does not fetch when disabled", () => { + renderHook(() => + useAttendeesFreeBusy({ + existingAttendees: [{ email: "x@x.com", userId: "uid" }], + newAttendees: [{ email: "y@y.com", userId: "uid2" }], + start: START, + end: END, + timezone: TZ, + eventUid: "event-123", + enabled: false, + }) + ); + + expect(mockPOST).not.toHaveBeenCalled(); + expect(mockREPORT).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/Attendees/AttendeeSearch.tsx b/src/components/Attendees/AttendeeSearch.tsx index 99cd962..5a78bb9 100644 --- a/src/components/Attendees/AttendeeSearch.tsx +++ b/src/components/Attendees/AttendeeSearch.tsx @@ -1,18 +1,33 @@ import { userAttendee } from "@/features/User/models/attendee"; import { createAttendee } from "@/features/User/models/attendee.mapper"; -import { useEffect, useState } from "react"; +import { useRef, useState } from "react"; +import { FreeBusyIndicator } from "./FreeBusyIndicator"; import { ExtendedAutocompleteRenderInputParams, PeopleSearch, User, } from "./PeopleSearch"; +import { FreeBusyMap, useAttendeesFreeBusy } from "./useFreeBusy"; -export default function UserSearch({ +const attendeeToUser = (a: userAttendee, openpaasId = ""): User => ({ + email: a.cal_address, + displayName: a.cn ?? "", + avatarUrl: "", + openpaasId, +}); + +const hasCalendar = (u: User) => u.objectType === "user" && !!u.openpaasId; + +export default function AttendeeSearch({ attendees, setAttendees, disabled, inputSlot, placeholder, + start, + end, + timezone, + eventUid, }: { attendees: userAttendee[]; setAttendees: (attendees: userAttendee[]) => void; @@ -21,25 +36,59 @@ export default function UserSearch({ params: ExtendedAutocompleteRenderInputParams ) => React.ReactNode; placeholder?: string; + start?: string; + end?: string; + timezone?: string; + eventUid?: string | null; }) { - const [selectedUsers, setSelectedUsers] = useState( - attendees.map((attendee) => ({ - email: attendee.cal_address, - displayName: attendee.cn ?? "", - avatarUrl: "", - openpaasId: "", - })) ?? [] + const [userIdMap, setUserIdMap] = useState>({}); + const [addedUsers, setAddedUsers] = useState([]); + const initialEmailsRef = useRef | null>(null); + if (initialEmailsRef.current === null && !!eventUid && attendees.length > 0) { + initialEmailsRef.current = new Set(attendees.map((a) => a.cal_address)); + } + const initialEmails = eventUid + ? (initialEmailsRef.current ?? new Set()) + : new Set(); + + const selectedUsers: User[] = [ + ...addedUsers, + ...attendees + .map((a) => attendeeToUser(a, userIdMap[a.cal_address])) + .filter((a) => !addedUsers.find((u) => a.email === u.email)), + ]; + + const toAttendee = (u: User) => ({ + email: u.email, + userId: u.openpaasId || userIdMap[u.email] || null, + }); + + const existingAttendees = selectedUsers + .filter((u) => initialEmails.has(u.email)) + .map(toAttendee); + const newAttendees = selectedUsers + .filter((u) => !initialEmails.has(u.email) && hasCalendar(u)) + .map(toAttendee); + + // Contacts and freeSolo users get a static "contact" status — no API call needed + const contactMap: FreeBusyMap = Object.fromEntries( + selectedUsers + .filter((u) => !initialEmails.has(u.email) && !hasCalendar(u)) + .map((u) => [u.email, "contact" as const]) ); - useEffect(() => { - setSelectedUsers( - attendees.map((attendee) => ({ - email: attendee.cal_address, - displayName: attendee.cn ?? "", - avatarUrl: "", - openpaasId: "", - })) - ); - }, [attendees]); + + const freeBusyMap = useAttendeesFreeBusy({ + existingAttendees, + newAttendees, + start: start ?? "", + end: end ?? "", + timezone: timezone ?? "", + eventUid, + enabled: !!(start && end && selectedUsers.length > 0), + }); + + const statusMap = { ...freeBusyMap, ...contactMap }; + return ( ( + + ) + : undefined + } onChange={(_event, value: User[]) => { + setUserIdMap((prev) => { + const next = { ...prev }; + for (const u of value) { + if (u.openpaasId && u.email) next[u.email] = u.openpaasId; + } + return next; + }); + setAddedUsers(value.filter((u) => !initialEmails.has(u.email))); setAttendees( - value.map((attendee: User) => - createAttendee({ - cal_address: attendee.email, - cn: attendee.displayName, - }) + value.map((u) => + createAttendee({ cal_address: u.email, cn: u.displayName }) ) ); - setSelectedUsers(value); }} freeSolo /> diff --git a/src/components/Attendees/FreeBusyIndicator.tsx b/src/components/Attendees/FreeBusyIndicator.tsx new file mode 100644 index 0000000..cd4a22d --- /dev/null +++ b/src/components/Attendees/FreeBusyIndicator.tsx @@ -0,0 +1,50 @@ +import { Tooltip } from "@linagora/twake-mui"; +import AccessTimeFilledIcon from "@mui/icons-material/AccessTimeFilled"; +import CloseIcon from "@mui/icons-material/Close"; +import { useEffect, useState } from "react"; +import { useI18n } from "twake-i18n"; +import { FreeBusyStatus } from "./useFreeBusy"; + +interface FreeBusyIndicatorProps { + status: FreeBusyStatus; + size?: number; +} + +export function FreeBusyIndicator({ status }: FreeBusyIndicatorProps) { + const { t } = useI18n(); + const [open, setOpen] = useState(false); + useEffect(() => { + if (status === "busy") setOpen(true); + }, [status]); + + if (status !== "busy") return null; + + return ( + + {t("event.freeBusy.busy")} + setOpen(false)} + /> + + } + open={open} + disableHoverListener + placement="bottom-start" + onClose={() => setOpen(false)} + slotProps={{ tooltip: { sx: { opacity: 1, bgcolor: "grey.900" } } }} + > + + + ); +} diff --git a/src/components/Attendees/PeopleSearch.tsx b/src/components/Attendees/PeopleSearch.tsx index 6c9214d..93d017f 100644 --- a/src/components/Attendees/PeopleSearch.tsx +++ b/src/components/Attendees/PeopleSearch.tsx @@ -1,6 +1,8 @@ import { getAccessiblePair } from "@/components/Calendar/utils/calendarColorsUtils"; import { stringAvatar } from "@/components/Event/utils/eventUtils"; import { SnackbarAlert } from "@/components/Loading/SnackBarAlert"; +import CloseIcon from "@mui/icons-material/Close"; + import { searchUsers } from "@/features/User/userAPI"; import { Autocomplete, @@ -33,6 +35,7 @@ export interface User { avatarUrl?: string; openpaasId?: string; color?: Record; + objectType?: string; } export interface ExtendedAutocompleteRenderInputParams extends AutocompleteRenderInputParams { @@ -54,6 +57,7 @@ export function PeopleSearch({ inputSlot, customRenderInput, customSlotProps, + getChipIcon, }: { selectedUsers: User[]; onChange: (event: SyntheticEvent, users: User[]) => void; @@ -75,6 +79,7 @@ export function PeopleSearch({ paper?: Partial; listbox?: Partial>; }; + getChipIcon?: (user: User) => ReactNode; }) { const { t } = useI18n(); const [query, setQuery] = useState(""); @@ -307,7 +312,6 @@ export function PeopleSearch({ ...customSlotProps?.popper, }, }} - // When render input is custom, the adornments should be handled by the custom component forcePopupIcon={false} disableClearable renderInput={(params) => @@ -341,14 +345,18 @@ export function PeopleSearch({ ? option : option.displayName || option.email; const chipColor = isString - ? theme.palette.grey[300] - : (option.color?.light ?? theme.palette.grey[300]); + ? theme.palette.grey[200] + : (option.color?.light ?? theme.palette.grey[200]); const textColor = getAccessiblePair(chipColor, theme); return ( } style={{ backgroundColor: chipColor, color: textColor, diff --git a/src/components/Attendees/useFreeBusy.ts b/src/components/Attendees/useFreeBusy.ts new file mode 100644 index 0000000..ca685c5 --- /dev/null +++ b/src/components/Attendees/useFreeBusy.ts @@ -0,0 +1,251 @@ +import { getFreeBusyForAddedAttendeesREPORT } from "@/features/Events/api/getFreeBusyForAddedAttendeesREPORT"; +import { getFreeBusyForEventAttendeesPOST } from "@/features/Events/api/getFreeBusyForEventAttendeesPOST"; +import { getUserDataFromEmail } from "@/features/Events/api/getUserDataFromEmail"; +import moment from "moment-timezone"; +import { useEffect, useRef, useState } from "react"; + +export type FreeBusyStatus = + | "free" + | "busy" + | "loading" + | "contact" + | "unknown"; +export type FreeBusyMap = Record; + +interface Attendee { + email: string; + userId?: string | null; +} + +interface ResolvedAttendee { + email: string; + userId: string; +} + +// Helpers +async function resolveUserId(attendee: Attendee): Promise { + if (attendee.userId) return attendee.userId; + return getUserDataFromEmail(attendee.email) + .then((u) => u[0]?._id ?? null) + .catch(() => null); +} + +async function resolveAll(attendees: Attendee[]): Promise { + const results = await Promise.all( + attendees.map(async (a) => { + const userId = await resolveUserId(a); + return userId ? { email: a.email, userId } : null; + }) + ); + return results.filter((r): r is ResolvedAttendee => r !== null); +} + +export function hasFreeBusyConflict(data: unknown): boolean { + try { + const jcal = (data as { data: unknown[] }).data; + if (!Array.isArray(jcal) || jcal[0] !== "vcalendar") return false; + const components = jcal[2] as unknown[][]; + return ( + Array.isArray(components) && components.some(isVFreeBusyWithConflict) + ); + } catch { + return false; + } +} + +function isVFreeBusyWithConflict(component: unknown): boolean { + if (!Array.isArray(component) || component[0] !== "vfreebusy") return false; + const props = component[1] as unknown[][]; + return ( + Array.isArray(props) && + props.some((p) => Array.isArray(p) && p[0] === "freebusy") + ); +} + +function toUtcIcal(datetime: string, timezone: string): string { + return moment.tz(datetime, timezone).utc().format("YYYYMMDDTHHmmss"); +} + +async function fetchFreeBusyMap( + attendees: Attendee[], + fetcher: (resolved: ResolvedAttendee[]) => Promise +): Promise { + const resolved = await resolveAll(attendees); + + const unresolved: FreeBusyMap = Object.fromEntries( + attendees + .filter((a) => !resolved.find((r) => r.email === a.email)) + .map((a) => [a.email, "unknown" as FreeBusyStatus]) + ); + + if (resolved.length === 0) return unresolved; + + const fetched = await fetcher(resolved); + return { ...unresolved, ...fetched }; +} + +function toLoadingMap(attendees: Attendee[]): FreeBusyMap { + return Object.fromEntries( + attendees.map((a) => [a.email, "loading" as FreeBusyStatus]) + ); +} + +function toUnknownMap(attendees: Attendee[]): FreeBusyMap { + return Object.fromEntries( + attendees.map((a) => [a.email, "unknown" as FreeBusyStatus]) + ); +} + +function toFreeBusyMap( + resolved: ResolvedAttendee[] +): (busyByUserId: Record) => FreeBusyMap { + const userIdToEmail = Object.fromEntries( + resolved.map(({ email, userId }) => [userId, email]) + ); + return (busyByUserId) => + Object.fromEntries( + Object.entries(busyByUserId).flatMap(([uid, busy]) => { + const email = userIdToEmail[uid]; + return email + ? [[email, (busy ? "busy" : "free") as FreeBusyStatus]] + : []; + }) + ); +} + +interface UseAttendeesFreeBusyOptions { + existingAttendees: Attendee[]; + newAttendees: Attendee[]; + start: string; + end: string; + timezone: string; + eventUid?: string | null; + enabled?: boolean; +} + +export function useAttendeesFreeBusy({ + existingAttendees, + newAttendees, + start, + end, + timezone, + eventUid, + enabled = true, +}: UseAttendeesFreeBusyOptions): FreeBusyMap { + const [statusMap, setStatusMap] = useState({}); + const fetchedNewEmailsRef = useRef>(new Set()); + + const existingKey = existingAttendees.map((a) => a.email).join(","); + const newKey = newAttendees.map((a) => a.email).join(","); + + useEffect(() => { + fetchedNewEmailsRef.current = new Set(); + setStatusMap({}); + }, [start, end, timezone]); + + useEffect(() => { + if ( + !enabled || + !start || + !end || + !eventUid || + existingAttendees.length === 0 + ) + return; + + let cancelled = false; + setStatusMap((prev) => ({ ...prev, ...toLoadingMap(existingAttendees) })); + + fetchFreeBusyMap(existingAttendees, (resolved) => + getFreeBusyForEventAttendeesPOST( + resolved.map((r) => r.userId), + toUtcIcal(start, timezone), + toUtcIcal(end, timezone), + eventUid! + ).then(toFreeBusyMap(resolved)) + ) + .then((updates) => { + if (!cancelled) setStatusMap((prev) => ({ ...prev, ...updates })); + }) + .catch(() => { + if (!cancelled) + setStatusMap((prev) => ({ + ...prev, + ...toUnknownMap(existingAttendees), + })); + }); + + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [existingKey, start, end, eventUid, enabled, timezone]); + + useEffect(() => { + if (!enabled || !start || !end) return; + + const currentEmails = new Set(newAttendees.map((a) => a.email)); + const removedEmails = [...fetchedNewEmailsRef.current].filter( + (e) => !currentEmails.has(e) + ); + if (removedEmails.length > 0) { + removedEmails.forEach((e) => { + fetchedNewEmailsRef.current.delete(e); + }); + setStatusMap((prev) => { + const next = { ...prev }; + removedEmails.forEach((e) => { + delete next[e]; + }); + return next; + }); + } + + const toFetch = newAttendees.filter( + (a) => !fetchedNewEmailsRef.current.has(a.email) + ); + if (toFetch.length === 0) return; + + let cancelled = false; + setStatusMap((prev) => ({ ...prev, ...toLoadingMap(toFetch) })); + fetchFreeBusyMap(toFetch, (resolved) => + Promise.all( + resolved.map(async ({ email, userId }) => { + try { + const busy = await getFreeBusyForAddedAttendeesREPORT( + userId, + moment.tz(start, timezone).utc().format("YYYYMMDDTHHmmss"), + moment.tz(end, timezone).utc().format("YYYYMMDDTHHmmss") + ); + return [email, (busy ? "busy" : "free") as FreeBusyStatus] as const; + } catch { + return [email, "unknown" as FreeBusyStatus] as const; + } + }) + ).then(Object.fromEntries) + ) + .then((updates) => { + if (!cancelled) { + Object.keys(updates).forEach((e) => { + fetchedNewEmailsRef.current.add(e); + }); + setStatusMap((prev) => ({ ...prev, ...updates })); + } + }) + .catch(() => { + if (!cancelled) { + toFetch.forEach((a) => { + fetchedNewEmailsRef.current.add(a.email); + }); + setStatusMap((prev) => ({ ...prev, ...toUnknownMap(toFetch) })); + } + }); + + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [newKey, start, end, enabled, timezone]); + + return statusMap; +} diff --git a/src/components/Event/EventFormFields.tsx b/src/components/Event/EventFormFields.tsx index 5b28bb6..552cf9c 100644 --- a/src/components/Event/EventFormFields.tsx +++ b/src/components/Event/EventFormFields.tsx @@ -100,6 +100,7 @@ interface EventFormFieldsProps { browserTz: string; getTimezoneOffset: (tzName: string, date: Date) => string; }; + eventId?: string | null; // Event handlers onStartChange?: (newStart: string) => void; @@ -157,6 +158,7 @@ export default function EventFormFields({ isOpen = false, userPersonalCalendars, timezoneList, + eventId, onStartChange, onEndChange, onAllDayChange, @@ -633,6 +635,10 @@ export default function EventFormFields({ } /> diff --git a/src/features/Events/EventUpdateModal.tsx b/src/features/Events/EventUpdateModal.tsx index 20499d6..6c039aa 100644 --- a/src/features/Events/EventUpdateModal.tsx +++ b/src/features/Events/EventUpdateModal.tsx @@ -1092,6 +1092,7 @@ function EventUpdateModal({ setEventClass={setEventClass} timezone={timezone} setTimezone={setTimezone} + eventId={event.uid} calendarid={calendarid} setCalendarid={setCalendarid} hasVideoConference={hasVideoConference} diff --git a/src/features/Events/api/getFreeBusyForAddedAttendeesREPORT.ts b/src/features/Events/api/getFreeBusyForAddedAttendeesREPORT.ts new file mode 100644 index 0000000..7c3ffd6 --- /dev/null +++ b/src/features/Events/api/getFreeBusyForAddedAttendeesREPORT.ts @@ -0,0 +1,37 @@ +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 { + 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); +} diff --git a/src/features/Events/api/getFreeBusyForEventAttendeesPOST.ts b/src/features/Events/api/getFreeBusyForEventAttendeesPOST.ts new file mode 100644 index 0000000..8a18564 --- /dev/null +++ b/src/features/Events/api/getFreeBusyForEventAttendeesPOST.ts @@ -0,0 +1,22 @@ +import { api } from "@/utils/apiUtils"; + +export async function getFreeBusyForEventAttendeesPOST( + userIds: string[], + start: string, + end: string, + eventUid: string +): Promise> { + 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 { users } = (await r.json()) as { + users: { id: string; calendars: { busy: unknown[] }[] }[]; + }; + return Object.fromEntries( + users.map((u) => [u.id, u.calendars.some((cal) => cal.busy.length > 0)]) + ); +} diff --git a/src/features/Events/api/getUserDataFromEmail.ts b/src/features/Events/api/getUserDataFromEmail.ts new file mode 100644 index 0000000..8317f55 --- /dev/null +++ b/src/features/Events/api/getUserDataFromEmail.ts @@ -0,0 +1,7 @@ +import { api } from "@/utils/apiUtils"; + +export async function getUserDataFromEmail(email: string) { + const r = await api(`api/users?email=${encodeURIComponent(email)}`); + const result: Array> = await r.json(); + return result; +} diff --git a/src/features/User/userAPI.ts b/src/features/User/userAPI.ts index 7716564..6e649c4 100644 --- a/src/features/User/userAPI.ts +++ b/src/features/User/userAPI.ts @@ -29,9 +29,10 @@ export async function searchUsers( return response.map((user) => ({ email: user.emailAddresses?.[0]?.value || "", displayName: - user.names?.[0]?.displayName || user.emailAddresses?.[0]?.value, + user.names?.[0]?.displayName || user.emailAddresses?.[0]?.value || "", avatarUrl: user.photos?.[0]?.url || "", openpaasId: user.id || "", + objectType: user.objectType, })); } diff --git a/src/features/User/userDataTypes.ts b/src/features/User/userDataTypes.ts index 555663b..6c4d183 100644 --- a/src/features/User/userDataTypes.ts +++ b/src/features/User/userDataTypes.ts @@ -33,6 +33,7 @@ export interface SearchResponseItem { emailAddresses?: Array<{ value?: string }>; names?: Array<{ displayName?: string }>; photos?: Array<{ url?: string }>; + objectType?: string; } // Type for configuration item diff --git a/src/locales/en.json b/src/locales/en.json index 1e31d41..8111161 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -130,6 +130,12 @@ "occurrences": "occurrences" } }, + "freeBusy": { + "unknown": "Unknown status", + "busy": "This person is busy", + "free": "User is free", + "loading": "Status is loading" + }, "form": { "title": "Title", "titlePlaceholder": "Add title", diff --git a/src/locales/fr.json b/src/locales/fr.json index 1d445ae..faab80c 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -131,6 +131,12 @@ "occurrences": "occurrences" } }, + "freeBusy": { + "unknown": "Statut inconnu", + "busy": "Cette personne est occupée", + "free": "L'utilisateur est disponible", + "loading": "Chargement du statut" + }, "form": { "title": "Titre", "titlePlaceholder": "Ajouter un titre", diff --git a/src/locales/ru.json b/src/locales/ru.json index 1091463..69b86f6 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -131,6 +131,12 @@ "occurrences": "повторений" } }, + "freeBusy": { + "unknown": "Статус неизвестен", + "busy": "Пользователь занят", + "free": "Пользователь свободен", + "loading": "Загрузка статуса" + }, "form": { "title": "Название", "titlePlaceholder": "Добавить название", diff --git a/src/locales/vi.json b/src/locales/vi.json index 718f094..2a92d53 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -129,6 +129,12 @@ "occurrences": "lần" } }, + "freeBusy": { + "unknown": "Trạng thái không rõ", + "busy": "Người này đang bận", + "free": "Người dùng đang rảnh", + "loading": "Đang tải trạng thái" + }, "form": { "title": "Tiêu đề", "titlePlaceholder": "Thêm tiêu đề", diff --git a/src/utils/extractCalendarHrefs.ts b/src/utils/extractCalendarHrefs.ts new file mode 100644 index 0000000..a62fb9f --- /dev/null +++ b/src/utils/extractCalendarHrefs.ts @@ -0,0 +1,15 @@ +export function extractCalendarHrefs(data: unknown): string[] { + try { + const calendars = ( + data as { + _embedded: { "dav:calendar": { _links: { self: { href: string } } }[] }; + } + )._embedded["dav:calendar"]; + + return Array.isArray(calendars) + ? calendars.map((cal) => cal?._links?.self?.href ?? "").filter(Boolean) + : []; + } catch { + return []; + } +}