From 9326ca2adb726d65252bde191f7cf79a60cbc6c8 Mon Sep 17 00:00:00 2001 From: Ishaan Garg <168962484+Ishaang19@users.noreply.github.com> Date: Wed, 15 Oct 2025 21:06:12 +0530 Subject: [PATCH 1/6] Issue #5 Support dark mode fix #5 --- client/src/components/ThemeModeToggle.tsx | 171 +++++ client/src/context/PreferencesContext.tsx | 208 ++++-- client/src/pages/Settings/PreferenceView.tsx | 704 +++++++++++++------ client/src/theme/AppTheme.tsx | 124 +++- client/src/theme/primitives/color-schemes.ts | 530 +++++++++----- 5 files changed, 1235 insertions(+), 502 deletions(-) create mode 100644 client/src/components/ThemeModeToggle.tsx diff --git a/client/src/components/ThemeModeToggle.tsx b/client/src/components/ThemeModeToggle.tsx new file mode 100644 index 0000000..248e456 --- /dev/null +++ b/client/src/components/ThemeModeToggle.tsx @@ -0,0 +1,171 @@ +import { Box, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'; + +import LightModeIcon from '@mui/icons-material/LightMode'; + +import DarkModeIcon from '@mui/icons-material/DarkMode'; + +import SettingsBrightnessIcon from '@mui/icons-material/SettingsBrightness'; + +import { usePreferences } from '@/context/PreferencesContext'; + + + +export default function ThemeModeToggle() { + +const { preferences, setPreferences } = usePreferences(); + + + +const handleThemeChange = (_event: React.MouseEvent, newMode: 'system' | 'light' | 'dark' | null) => { + +if (newMode !== null) { + +setPreferences({ + +...preferences, + +themeMode: newMode, + +}); + +} + +}; + + + +return ( + + + + + +Theme + + + + + + + + + + + +Light + + + + + + + + + + + +System + + + + + + + + + + + +Dark + + + + + + + + + +); + +} + + \ No newline at end of file diff --git a/client/src/context/PreferencesContext.tsx b/client/src/context/PreferencesContext.tsx index 3fb3e8d..1f5c440 100644 --- a/client/src/context/PreferencesContext.tsx +++ b/client/src/context/PreferencesContext.tsx @@ -1,67 +1,141 @@ -import { constants } from '@/config/constants'; -import { CacheService, CacheServiceFactory } from '@/helpers/cache'; -import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; - -interface Preferences { - duration: number; - seats: number; - title?: string; - floor?: string; -} - -interface PreferencesContextType { - preferences: Preferences; - setPreferences: React.Dispatch>; -} - -interface PreferencesProviderProps { - children: ReactNode; -} - -const PreferencesContext = createContext(undefined); - -const defaultPreferences = { - duration: 30, - seats: 1, - title: constants.defaultTitle, -}; - -export const PreferencesProvider = ({ children }: PreferencesProviderProps) => { - const cacheService: CacheService = CacheServiceFactory.getCacheService(); - const [preferences, setPreferences] = useState({ - duration: defaultPreferences.duration, - seats: defaultPreferences.seats, - title: defaultPreferences.title, - }); - const [loading, setLoading] = useState(true); - - useEffect(() => { - const loadPreferences = async () => { - const savedPreferences = await cacheService.get('preferences'); - if (savedPreferences) { - const parsedPref = JSON.parse(savedPreferences); - setPreferences(parsedPref); - } - - setLoading(false); - }; - - loadPreferences(); - }, []); - - useEffect(() => { - if (!preferences.title) { - preferences.title = defaultPreferences.title; - } - - cacheService.save('preferences', JSON.stringify(preferences)); - }, [preferences]); - - if (loading) { - return <>; - } - - return {children}; -}; - -export const usePreferences = () => useContext(PreferencesContext)!; +import { constants } from '@/config/constants'; + +import { CacheService, CacheServiceFactory } from '@/helpers/cache'; + +import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; + + + +interface Preferences { + +duration: number; + +seats: number; + +title?: string; + +floor?: string; + +themeMode?: 'system' | 'light' | 'dark'; + +} + + + +interface PreferencesContextType { + +preferences: Preferences; + +setPreferences: React.Dispatch>; + +} + + + +interface PreferencesProviderProps { + +children: ReactNode; + +} + + + +const PreferencesContext = createContext(undefined); + + + +const defaultPreferences = { + +duration: 30, + +seats: 1, + +title: constants.defaultTitle, + +themeMode: 'system' as const, + +}; + + + +export const PreferencesProvider = ({ children }: PreferencesProviderProps) => { + +const cacheService: CacheService = CacheServiceFactory.getCacheService(); + +const [preferences, setPreferences] = useState({ + +duration: defaultPreferences.duration, + +seats: defaultPreferences.seats, + +title: defaultPreferences.title, + +themeMode: defaultPreferences.themeMode, + +}); + +const [loading, setLoading] = useState(true); + + + +useEffect(() => { + +const loadPreferences = async () => { + +const savedPreferences = await cacheService.get('preferences'); + +if (savedPreferences) { + +const parsedPref = JSON.parse(savedPreferences); + +setPreferences(parsedPref); + +} + + + +setLoading(false); + +}; + + + +loadPreferences(); + +}, []); + + + +useEffect(() => { + +if (!preferences.title) { + +preferences.title = defaultPreferences.title; + +} + + + +cacheService.save('preferences', JSON.stringify(preferences)); + +}, [preferences]); + + + +if (loading) { + +return <>; + +} + + + +return {children}; + +}; + + + +export const usePreferences = () => useContext(PreferencesContext)!; + + \ No newline at end of file diff --git a/client/src/pages/Settings/PreferenceView.tsx b/client/src/pages/Settings/PreferenceView.tsx index eb02f7a..0ed1e8b 100644 --- a/client/src/pages/Settings/PreferenceView.tsx +++ b/client/src/pages/Settings/PreferenceView.tsx @@ -1,231 +1,473 @@ -import { DropdownOption } from '@/components/Dropdown'; -import Dropdown from '@/components/Dropdown'; -import StyledTextField from '@/components/StyledTextField'; -import { usePreferences } from '@/context/PreferencesContext'; -import { createDropdownOptions, isChromeExt, populateDurationOptions, populateRoomCapacity, renderError } from '@/helpers/utility'; -import { Box, Button, Typography } from '@mui/material'; -import { useEffect, useState } from 'react'; -import toast from 'react-hot-toast'; -import { useNavigate } from 'react-router-dom'; -import EventSeatRoundedIcon from '@mui/icons-material/EventSeatRounded'; -import HourglassBottomRoundedIcon from '@mui/icons-material/HourglassBottomRounded'; -import StairsIcon from '@mui/icons-material/Stairs'; -import TitleIcon from '@mui/icons-material/Title'; -import { useApi } from '@/context/ApiContext'; - -export default function PreferenceView() { - // Form state - const [formData, setFormData] = useState({ - floor: '', - duration: '30', - seats: 1, - title: '', - }); - - // Dropdown options state - const [floorOptions, setFloorOptions] = useState([]); - const [durationOptions, setDurationOptions] = useState([]); - const [roomCapacityOptions, setRoomCapacityOptions] = useState([]); - - // Utilities and services - const api = useApi(); - const navigate = useNavigate(); - - // Context or global state - const { preferences, setPreferences } = usePreferences(); - - // Derived data - const durations = populateDurationOptions(); - - useEffect(() => { - const init = (floors: string[], capacities: string[]) => { - const floorOptions = createDropdownOptions(floors); - floorOptions.unshift({ text: 'No preference', value: '' }); - - setFloorOptions(floorOptions); - setRoomCapacityOptions(createDropdownOptions(capacities)); - setDurationOptions(createDropdownOptions(durations, 'time')); - - const { floor, duration, title, seats } = preferences; - setFormData({ - floor: floor || '', - title: title || '', - duration: String(duration) || durations[0], - seats: seats || 1, - }); - }; - - const loadInitialData = async () => { - try { - let floors = []; - let res = await api.getFloors(); - const { data, status } = res || {}; - - if (status !== 'success' || !data) { - renderError(res, navigate); - return; - } - - floors = data; - - res = await api.getMaxSeatCount(); - const capacities = populateRoomCapacity(res?.data || 0); - - init(floors, capacities); - } catch (error: any) { - renderError(error, navigate); - } - }; - - loadInitialData(); - }, []); - - const handleInputChange = (id: string, value: string | number) => { - setFormData((prevData) => ({ - ...prevData, - [id]: value, - })); - }; - - const onSaveClick = async () => { - setPreferences({ - seats: formData.seats, - floor: formData.floor, - title: formData.title, - duration: Number(formData.duration), - }); - - toast.success('Saved successfully!'); - }; - - return ( - - - - ({ - color: theme.palette.grey[50], - }), - ]} - /> - } - /> - - ({ - color: theme.palette.grey[50], - }), - ]} - /> - } - /> - - ({ - color: theme.palette.grey[50], - }), - ]} - /> - } - /> - - ({ - color: theme.palette.grey[50], - }), - ]} - /> - } - id="title" - placeholder="Add preferred title" - onChange={handleInputChange} - /> - - - - - - - - ); -} +import { DropdownOption } from '@/components/Dropdown'; + +import Dropdown from '@/components/Dropdown'; + +import StyledTextField from '@/components/StyledTextField'; + +import ThemeModeToggle from '@/components/ThemeModeToggle'; + +import { usePreferences } from '@/context/PreferencesContext'; + +import { createDropdownOptions, isChromeExt, populateDurationOptions, populateRoomCapacity, renderError } from '@/helpers/utility'; + +import { Box, Button, Typography } from '@mui/material'; + +import { useEffect, useState } from 'react'; + +import toast from 'react-hot-toast'; + +import { useNavigate } from 'react-router-dom'; + +import EventSeatRoundedIcon from '@mui/icons-material/EventSeatRounded'; + +import HourglassBottomRoundedIcon from '@mui/icons-material/HourglassBottomRounded'; + +import StairsIcon from '@mui/icons-material/Stairs'; + +import TitleIcon from '@mui/icons-material/Title'; + +import { useApi } from '@/context/ApiContext'; + + + +export default function PreferenceView() { + +// Form state + +const [formData, setFormData] = useState({ + +floor: '', + +duration: '30', + +seats: 1, + +title: '', + +}); + + + +// Dropdown options state + +const [floorOptions, setFloorOptions] = useState([]); + +const [durationOptions, setDurationOptions] = useState([]); + +const [roomCapacityOptions, setRoomCapacityOptions] = useState([]); + + + +// Utilities and services + +const api = useApi(); + +const navigate = useNavigate(); + + + +// Context or global state + +const { preferences, setPreferences } = usePreferences(); + + + +// Derived data + +const durations = populateDurationOptions(); + + + +useEffect(() => { + +const init = (floors: string[], capacities: string[]) => { + +const floorOptions = createDropdownOptions(floors); + +floorOptions.unshift({ text: 'No preference', value: '' }); + + + +setFloorOptions(floorOptions); + +setRoomCapacityOptions(createDropdownOptions(capacities)); + +setDurationOptions(createDropdownOptions(durations, 'time')); + + + +const { floor, duration, title, seats } = preferences; + +setFormData({ + +floor: floor || '', + +title: title || '', + +duration: String(duration) || durations[0], + +seats: seats || 1, + +}); + +}; + + + +const loadInitialData = async () => { + +try { + +let floors = []; + +let res = await api.getFloors(); + +const { data, status } = res || {}; + + + +if (status !== 'success' || !data) { + +renderError(res, navigate); + +return; + +} + + + +floors = data; + + + +res = await api.getMaxSeatCount(); + +const capacities = populateRoomCapacity(res?.data || 0); + + + +init(floors, capacities); + +} catch (error: any) { + +renderError(error, navigate); + +} + +}; + + + +loadInitialData(); + +}, []); + + + +const handleInputChange = (id: string, value: string | number) => { + +setFormData((prevData) => ({ + +...prevData, + +[id]: value, + +})); + +}; + + + +const onSaveClick = async () => { + +setPreferences({ + +seats: formData.seats, + +floor: formData.floor, + +title: formData.title, + +duration: Number(formData.duration), + +}); + + + +toast.success('Saved successfully!'); + +}; + + + +return ( + + + + + + + + ({ + +color: theme.palette.grey[50], + +}), + +]} + +/> + +} + +/> + + + + ({ + +color: theme.palette.grey[50], + +}), + +]} + +/> + +} + +/> + + + + ({ + +color: theme.palette.grey[50], + +}), + +]} + +/> + +} + +/> + + + + ({ + +color: theme.palette.grey[50], + +}), + +]} + +/> + +} + +id="title" + +placeholder="Add preferred title" + +onChange={handleInputChange} + +/> + + + + + + + + + + + + + + + + + + + + + + + +); + +} + + \ No newline at end of file diff --git a/client/src/theme/AppTheme.tsx b/client/src/theme/AppTheme.tsx index 83ad252..e12c277 100644 --- a/client/src/theme/AppTheme.tsx +++ b/client/src/theme/AppTheme.tsx @@ -1,27 +1,97 @@ -import { useMemo } from 'react'; -import { createTheme, ThemeProvider } from '@mui/material/styles'; -import colorSchemes from './primitives/color-schemes'; -import typography from './primitives/typography'; -import shape from './primitives/shape'; -import componentsOverride from './components'; - -interface AppThemeProps { - children: React.ReactNode; -} - -export default function AppTheme({ children }: AppThemeProps) { - const theme = useMemo(() => { - return createTheme({ - colorSchemes, - typography, - shape, - cssVariables: { - colorSchemeSelector: 'data-mui-color-scheme', - cssVarPrefix: 'quickmeet', - }, - }); - }, []); - - theme.components = componentsOverride(); - return {children}; -} +import { useMemo, useEffect } from 'react'; + +import { createTheme, ThemeProvider } from '@mui/material/styles'; + +import { useColorScheme } from '@mui/material/styles'; + +import colorSchemes from './primitives/color-schemes'; + +import typography from './primitives/typography'; + +import shape from './primitives/shape'; + +import componentsOverride from './components'; + +import { usePreferences } from '@/context/PreferencesContext'; + + + +interface AppThemeProps { + +children: React.ReactNode; + +} + + + +function ThemeApplier({ children }: { children: React.ReactNode }) { + +const { mode, setMode } = useColorScheme(); + +const { preferences } = usePreferences(); + + + +useEffect(() => { + +const themeMode = preferences.themeMode || 'system'; + +if (mode !== themeMode) { + +setMode(themeMode); + +} + +}, [preferences.themeMode, mode, setMode]); + + + +return <>{children}; + +} + + + +export default function AppTheme({ children }: AppThemeProps) { + +const theme = useMemo(() => { + +return createTheme({ + +colorSchemes, + +typography, + +shape, + +cssVariables: { + +colorSchemeSelector: 'data-mui-color-scheme', + +cssVarPrefix: 'quickmeet', + +}, + +}); + +}, []); + + + +theme.components = componentsOverride(); + + + +return ( + + + +{children} + + + +); + +} + + \ No newline at end of file diff --git a/client/src/theme/primitives/color-schemes.ts b/client/src/theme/primitives/color-schemes.ts index b9015af..d34f27c 100644 --- a/client/src/theme/primitives/color-schemes.ts +++ b/client/src/theme/primitives/color-schemes.ts @@ -1,177 +1,353 @@ -import { alpha } from '@mui/material/styles'; - -export const brand = { - 50: 'hsl(210, 100%, 95%)', // very light - 100: 'hsl(210, 100%, 90%)', - 200: 'hsl(210, 100%, 80%)', - 300: '#8B8B8B', - 400: '#414141', - 500: '#414141', // original color - 600: 'hsl(210, 98%, 35%)', - 700: 'hsl(210, 100%, 30%)', // darker - 800: 'hsl(210, 100%, 20%)', - 900: 'hsl(210, 100%, 15%)', // very dark -}; - -export const gray = { - 10: '#FCFCFC', - 50: '#C8C8C8', - 100: '#E1E1E1', - 200: '#8B8B8B', - 300: 'hsl(220, 20%, 80%)', - 400: '#8B8B8B', - 500: 'hsl(220, 20%, 42%)', - 600: '#8B8B8B', - 700: '#414141', - 800: '#414141', - 900: 'hsl(220, 35%, 3%)', -}; - -export const green = { - 50: 'hsl(120, 80%, 98%)', - 100: 'hsl(120, 75%, 94%)', - 200: 'hsl(120, 75%, 87%)', - 300: 'hsl(120, 61%, 77%)', - 400: 'hsl(120, 44%, 53%)', - 500: 'hsl(120, 59%, 30%)', - 600: 'hsl(120, 70%, 25%)', - 700: 'hsl(120, 75%, 16%)', - 800: 'hsl(120, 84%, 10%)', - 900: 'hsl(120, 87%, 6%)', -}; - -export const orange = { - 50: 'hsl(45, 100%, 97%)', - 100: 'hsl(45, 92%, 90%)', - 200: 'hsl(45, 94%, 80%)', - 300: 'hsl(45, 90%, 65%)', - 400: 'hsl(45, 90%, 40%)', - 500: 'hsl(45, 90%, 35%)', - 600: 'hsl(45, 91%, 25%)', - 700: 'hsl(45, 94%, 20%)', - 800: 'hsl(45, 95%, 16%)', - 900: 'hsl(45, 93%, 12%)', -}; - -export const red = { - 50: 'hsl(0, 100%, 97%)', - 100: 'hsl(0, 92%, 90%)', - 200: 'hsl(0, 94%, 80%)', - 300: 'hsl(0, 90%, 65%)', - 400: '#FF6A6A', - 500: 'hsl(0, 90%, 30%)', - 600: 'hsl(0, 91%, 25%)', - 700: 'hsl(0, 94%, 18%)', - 800: 'hsl(0, 95%, 12%)', - 900: 'hsl(0, 93%, 6%)', -}; - -export const colorSchemes = { - light: { - palette: { - primary: { - light: brand[200], - main: brand[400], - dark: brand[700], - contrastText: brand[50], - }, - info: { - light: brand[100], - main: brand[300], - dark: brand[600], - contrastText: gray[50], - }, - warning: { - light: orange[300], - main: orange[400], - dark: orange[800], - }, - error: { - light: red[300], - main: red[400], - dark: red[800], - }, - success: { - light: green[300], - main: green[400], - dark: green[800], - }, - common: { - black: gray[700], - }, - grey: { - ...gray, - }, - divider: alpha(gray[300], 0.4), - background: { - default: 'hsl(0, 0%, 99%)', - paper: 'hsl(220, 35%, 97%)', - }, - text: { - primary: gray[800], - secondary: gray[600], - warning: orange[400], - }, - action: { - hover: alpha(gray[200], 0.2), - selected: `${alpha(gray[200], 0.3)}`, - }, - baseShadow: 'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px', - }, - }, - // todo: add dark mode later; utilize client/src/components/ColorModeSelect.tsx - // dark: { - // palette: { - // primary: { - // light: brand[200], - // main: brand[400], - // dark: brand[700], - // contrastText: brand[50], - // }, - // info: { - // light: brand[100], - // main: brand[300], - // dark: brand[600], - // contrastText: gray[50], - // }, - // warning: { - // light: orange[300], - // main: orange[400], - // dark: orange[800], - // }, - // error: { - // light: red[300], - // main: red[400], - // dark: red[800], - // }, - // success: { - // light: green[300], - // main: green[400], - // dark: green[800], - // }, - // common: { - // black: gray[700], - // }, - // grey: { - // ...gray, - // }, - // divider: alpha(gray[300], 0.4), - // background: { - // default: 'hsl(0, 0%, 99%)', - // paper: 'hsl(220, 35%, 97%)', - // }, - // text: { - // primary: gray[800], - // secondary: gray[600], - // warning: orange[400], - // }, - // action: { - // hover: alpha(gray[200], 0.2), - // selected: `${alpha(gray[200], 0.3)}`, - // }, - // baseShadow: 'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px', - // }, - // }, -}; - -export default colorSchemes; +import { alpha } from '@mui/material/styles'; + + + +export const brand = { + +50: 'hsl(210, 100%, 95%)', // very light + +100: 'hsl(210, 100%, 90%)', + +200: 'hsl(210, 100%, 80%)', + +300: '#8B8B8B', + +400: '#414141', + +500: '#414141', // original color + +600: 'hsl(210, 98%, 35%)', + +700: 'hsl(210, 100%, 30%)', // darker + +800: 'hsl(210, 100%, 20%)', + +900: 'hsl(210, 100%, 15%)', // very dark + +}; + + + +export const gray = { + +10: '#FCFCFC', + +50: '#C8C8C8', + +100: '#E1E1E1', + +200: '#8B8B8B', + +300: 'hsl(220, 20%, 80%)', + +400: '#8B8B8B', + +500: 'hsl(220, 20%, 42%)', + +600: '#8B8B8B', + +700: '#414141', + +800: '#414141', + +900: 'hsl(220, 35%, 3%)', + +}; + + + +export const green = { + +50: 'hsl(120, 80%, 98%)', + +100: 'hsl(120, 75%, 94%)', + +200: 'hsl(120, 75%, 87%)', + +300: 'hsl(120, 61%, 77%)', + +400: 'hsl(120, 44%, 53%)', + +500: 'hsl(120, 59%, 30%)', + +600: 'hsl(120, 70%, 25%)', + +700: 'hsl(120, 75%, 16%)', + +800: 'hsl(120, 84%, 10%)', + +900: 'hsl(120, 87%, 6%)', + +}; + + + +export const orange = { + +50: 'hsl(45, 100%, 97%)', + +100: 'hsl(45, 92%, 90%)', + +200: 'hsl(45, 94%, 80%)', + +300: 'hsl(45, 90%, 65%)', + +400: 'hsl(45, 90%, 40%)', + +500: 'hsl(45, 90%, 35%)', + +600: 'hsl(45, 91%, 25%)', + +700: 'hsl(45, 94%, 20%)', + +800: 'hsl(45, 95%, 16%)', + +900: 'hsl(45, 93%, 12%)', + +}; + + + +export const red = { + +50: 'hsl(0, 100%, 97%)', + +100: 'hsl(0, 92%, 90%)', + +200: 'hsl(0, 94%, 80%)', + +300: 'hsl(0, 90%, 65%)', + +400: '#FF6A6A', + +500: 'hsl(0, 90%, 30%)', + +600: 'hsl(0, 91%, 25%)', + +700: 'hsl(0, 94%, 18%)', + +800: 'hsl(0, 95%, 12%)', + +900: 'hsl(0, 93%, 6%)', + +}; + + + +export const colorSchemes = { + +light: { + +palette: { + +primary: { + +light: brand[200], + +main: brand[400], + +dark: brand[700], + +contrastText: brand[50], + +}, + +info: { + +light: brand[100], + +main: brand[300], + +dark: brand[600], + +contrastText: gray[50], + +}, + +warning: { + +light: orange[300], + +main: orange[400], + +dark: orange[800], + +}, + +error: { + +light: red[300], + +main: red[400], + +dark: red[800], + +}, + +success: { + +light: green[300], + +main: green[400], + +dark: green[800], + +}, + +common: { + +black: gray[700], + +}, + +grey: { + +...gray, + +}, + +divider: alpha(gray[300], 0.4), + +background: { + +default: 'hsl(0, 0%, 99%)', + +paper: 'hsl(220, 35%, 97%)', + +}, + +text: { + +primary: gray[800], + +secondary: gray[600], + +warning: orange[400], + +}, + +action: { + +hover: alpha(gray[200], 0.2), + +selected: `${alpha(gray[200], 0.3)}`, + +}, + +baseShadow: 'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px', + +}, + +}, + +dark: { + +palette: { + +primary: { + +light: brand[300], + +main: brand[400], + +dark: brand[700], + +contrastText: brand[50], + +}, + +info: { + +light: brand[500], + +main: brand[600], + +dark: brand[700], + +contrastText: gray[50], + +}, + +warning: { + +light: orange[400], + +main: orange[500], + +dark: orange[700], + +}, + +error: { + +light: red[400], + +main: red[500], + +dark: red[700], + +}, + +success: { + +light: green[400], + +main: green[500], + +dark: green[700], + +}, + +common: { + +black: gray[50], + +}, + +grey: { + +...gray, + +}, + +divider: alpha(gray[600], 0.3), + +background: { + +default: 'hsl(0, 0%, 10%)', + +paper: 'hsl(220, 30%, 12%)', + +}, + +text: { + +primary: gray[50], + +secondary: gray[200], + +warning: orange[400], + +}, + +action: { + +hover: alpha(gray[600], 0.2), + +selected: `${alpha(gray[600], 0.3)}`, + +}, + +baseShadow: 'hsla(220, 30%, 5%, 0.5) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.08) 0px 8px 16px -5px', + +}, + +}, + +}; + + + +export default colorSchemes; + + \ No newline at end of file From b48a48ca383f1260a8b55c32949b053fb8454ca5 Mon Sep 17 00:00:00 2001 From: Ishaan Garg <168962484+Ishaang19@users.noreply.github.com> Date: Wed, 15 Oct 2025 21:08:01 +0530 Subject: [PATCH 2/6] Issue #4 pressing back shows up an error toast fix #4 --- client/src/pages/Settings/PreferenceView.tsx | 30 ++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/client/src/pages/Settings/PreferenceView.tsx b/client/src/pages/Settings/PreferenceView.tsx index 0ed1e8b..bdd34c6 100644 --- a/client/src/pages/Settings/PreferenceView.tsx +++ b/client/src/pages/Settings/PreferenceView.tsx @@ -80,8 +80,16 @@ const durations = populateDurationOptions(); useEffect(() => { +let isMounted = true; + + + const init = (floors: string[], capacities: string[]) => { +if (!isMounted) return; + + + const floorOptions = createDropdownOptions(floors); floorOptions.unshift({ text: 'No preference', value: '' }); @@ -122,6 +130,12 @@ let floors = []; let res = await api.getFloors(); + + +if (!isMounted) return; + + + const { data, status } = res || {}; @@ -142,6 +156,12 @@ floors = data; res = await api.getMaxSeatCount(); + + +if (!isMounted) return; + + + const capacities = populateRoomCapacity(res?.data || 0); @@ -150,6 +170,8 @@ init(floors, capacities); } catch (error: any) { +if (!isMounted) return; + renderError(error, navigate); } @@ -160,6 +182,14 @@ renderError(error, navigate); loadInitialData(); + + +return () => { + +isMounted = false; + +}; + }, []); From 8f1f5d62c48656fbb6db338c90149cb00424deaa Mon Sep 17 00:00:00 2001 From: Ishaan Garg <168962484+Ishaang19@users.noreply.github.com> Date: Wed, 15 Oct 2025 21:09:11 +0530 Subject: [PATCH 3/6] Issue #2 attendee list cannot be scrolled from a mobile device fix #2 --- client/src/components/AttendeeInput.tsx | 723 ++++++++++++++++-------- 1 file changed, 499 insertions(+), 224 deletions(-) diff --git a/client/src/components/AttendeeInput.tsx b/client/src/components/AttendeeInput.tsx index 235183f..b1b73af 100644 --- a/client/src/components/AttendeeInput.tsx +++ b/client/src/components/AttendeeInput.tsx @@ -1,224 +1,499 @@ -import { useApi } from '@/context/ApiContext'; -import { isEmailValid } from '@/helpers/utility'; -import PeopleAltRoundedIcon from '@mui/icons-material/PeopleAltRounded'; -import { Autocomplete, Box, Chip, debounce, TextField, Typography } from '@mui/material'; -import Avatar from '@mui/material/Avatar'; -import { IPeopleInformation } from '@quickmeet/shared'; -import { useState } from 'react'; -import toast from 'react-hot-toast'; - -interface AttendeeInputProps { - id: string; - value?: any[]; - disabled?: boolean; - onChange: (id: string, value: IPeopleInformation[]) => void; - type?: string; -} - -export default function AttendeeInput({ id, onChange, value, type }: AttendeeInputProps) { - const [options, setOptions] = useState([]); - const [textInput, setTextInput] = useState(''); - - const api = useApi(); - - const handleInputChange = async (_: React.SyntheticEvent, newInputValue: string) => { - if (newInputValue.length > 2) { - const res = await api.searchPeople(newInputValue); - if (res.status === 'success') { - setOptions((res.data as IPeopleInformation[]) || []); - } - } - }; - - const handleSelectionChange = (_: React.SyntheticEvent, newValue: Array) => { - const emails = newValue.map((option) => (typeof option === 'object' && option.email ? option.email : (option as string))); - const filteredEmails = emails - .join(' ') - .split(/\s+/) - .map((email) => email.trim()) - .filter((email) => email !== ''); - - const uniqueEmails = [...new Set(filteredEmails)]; - const validPeople: IPeopleInformation[] = []; - const invalidEmails: string[] = []; - - uniqueEmails.forEach((email) => { - if (isEmailValid(email)) { - const existingPerson = newValue.find((option) => typeof option === 'object' && option.email === email) as IPeopleInformation; - if (existingPerson) { - validPeople.push(existingPerson); - } else { - const firstPartOfEmail = email.split('@')[0]; - validPeople.push({ name: firstPartOfEmail, email, photo: '' }); - } - } else { - invalidEmails.push(email); - } - }); - - invalidEmails.length > 0 && toast.error('Invalid email(s) entered.'); - - if (validPeople.length >= 0) { - onChange(id, validPeople); - } - setTextInput(''); - }; - const debouncedInputChange = debounce(handleInputChange, 300); - return ( - ({ - gap: '8px', - padding: '10px', - borderRadius: 1, - backgroundColor: theme.palette.common.white, - '&:focus-within': { - border: 'none', - }, - maxHeight: '65px', - overflowY: 'auto', - }), - ]} - > - - ({ - color: theme.palette.grey[50], - position: 'sticky', - top: 0, - bottom: 0, - }), - ]} - /> - (typeof option === 'object' && option.email ? option.email : '')} - noOptionsText="" - freeSolo - inputValue={textInput} - fullWidth - onChange={handleSelectionChange} - slotProps={{ - listbox: { - sx: { - backgroundColor: 'rgba(245, 245, 245)', - '& .MuiAutocomplete-option': { - padding: 1, - mx: 1, - borderRadius: 1, - }, - }, - }, - popper: { - sx: { - boxShadow: '0px 4px 10px rgba(0, 0, 0, 0.1)', - }, - }, - }} - onInputChange={debouncedInputChange} - renderTags={(value: readonly IPeopleInformation[], getTagProps) => - value.map((option, index) => { - const { key, ...tagProps } = getTagProps({ index }); - return ( - ({ - bgcolor: theme.palette.grey[50], - }), - ]} - /> - } - variant="outlined" - label={option.name} - key={key} - {...tagProps} - /> - ); - }) - } - renderInput={(params) => ( - setTextInput(e.target.value)} - type={type} - variant="standard" - placeholder="Attendees" - slotProps={{ - input: { - ...params.InputProps, - endAdornment: null, - }, - }} - sx={[ - (theme) => ({ - flex: 1, - py: 0, - px: 1, - '& .MuiInputBase-input': { - fontSize: theme.typography.subtitle1, - }, - '& .MuiInputBase-input::placeholder': { - color: theme.palette.primary.main, - fontSize: theme.typography.subtitle1, - }, - '& .MuiInput-underline:before, & .MuiInput-underline:hover:before': { - borderBottom: 'none !important', - }, - '& .MuiInput-underline:after': { - borderBottom: 'none', - }, - }), - ]} - /> - )} - renderOption={(props, option) => { - const { key, ...optionProps } = props; - const isSelected = value?.includes(option.email); - return ( - ({ - '& > img': { mr: 2, flexShrink: 0 }, - backgroundColor: isSelected ? theme.palette.grey[100] : 'transparent', - }), - ]} - {...optionProps} - gap={1} - > - - - - {option.name} - - - {option.email} - - - - ); - }} - /> - - - ); -} +import { useApi } from '@/context/ApiContext'; + +import { isEmailValid } from '@/helpers/utility'; + +import PeopleAltRoundedIcon from '@mui/icons-material/PeopleAltRounded'; + +import { Autocomplete, Box, Chip, debounce, TextField, Typography } from '@mui/material'; + +import Avatar from '@mui/material/Avatar'; + +import { IPeopleInformation } from '@quickmeet/shared'; + +import { useState } from 'react'; + +import toast from 'react-hot-toast'; + + + +interface AttendeeInputProps { + +id: string; + +value?: any[]; + +disabled?: boolean; + +onChange: (id: string, value: IPeopleInformation[]) => void; + +type?: string; + +} + + + +export default function AttendeeInput({ id, onChange, value, type }: AttendeeInputProps) { + +const [options, setOptions] = useState([]); + +const [textInput, setTextInput] = useState(''); + + + +const api = useApi(); + + + +const handleInputChange = async (_: React.SyntheticEvent, newInputValue: string) => { + +if (newInputValue.length > 2) { + +const res = await api.searchPeople(newInputValue); + +if (res.status === 'success') { + +setOptions((res.data as IPeopleInformation[]) || []); + +} + +} + +}; + + + +const handleSelectionChange = (_: React.SyntheticEvent, newValue: Array) => { + +const emails = newValue.map((option) => (typeof option === 'object' && option.email ? option.email : (option as string))); + +const filteredEmails = emails + +.join(' ') + +.split(/\s+/) + +.map((email) => email.trim()) + +.filter((email) => email !== ''); + + + +const uniqueEmails = [...new Set(filteredEmails)]; + +const validPeople: IPeopleInformation[] = []; + +const invalidEmails: string[] = []; + + + +uniqueEmails.forEach((email) => { + +if (isEmailValid(email)) { + +const existingPerson = newValue.find((option) => typeof option === 'object' && option.email === email) as IPeopleInformation; + +if (existingPerson) { + +validPeople.push(existingPerson); + +} else { + +const firstPartOfEmail = email.split('@')[0]; + +validPeople.push({ name: firstPartOfEmail, email, photo: '' }); + +} + +} else { + +invalidEmails.push(email); + +} + +}); + + + +invalidEmails.length > 0 && toast.error('Invalid email(s) entered.'); + + + +if (validPeople.length >= 0) { + +onChange(id, validPeople); + +} + +setTextInput(''); + +}; + +const debouncedInputChange = debounce(handleInputChange, 300); + +return ( + + ({ + +gap: '8px', + +padding: '10px', + +borderRadius: 1, + +backgroundColor: theme.palette.common.white, + +'&:focus-within': { + +border: 'none', + +}, + +maxHeight: '150px', + +overflowY: 'auto', + +WebkitOverflowScrolling: 'touch', + +'&::-webkit-scrollbar': { + +width: '4px', + +}, + +'&::-webkit-scrollbar-thumb': { + +backgroundColor: theme.palette.grey[300], + +borderRadius: '4px', + +}, + +}), + +]} + +> + + + + ({ + +color: theme.palette.grey[50], + +position: 'sticky', + +top: 0, + +bottom: 0, + +}), + +]} + +/> + + (typeof option === 'object' && option.email ? option.email : '')} + +noOptionsText="" + +freeSolo + +inputValue={textInput} + +fullWidth + +onChange={handleSelectionChange} + +sx={{ + +'& .MuiAutocomplete-inputRoot': { + +flexWrap: 'wrap', + +gap: '4px', + +}, + +}} + +slotProps={{ + +listbox: { + +sx: { + +backgroundColor: 'rgba(245, 245, 245)', + +'& .MuiAutocomplete-option': { + +padding: 1, + +mx: 1, + +borderRadius: 1, + +}, + +}, + +}, + +popper: { + +sx: { + +boxShadow: '0px 4px 10px rgba(0, 0, 0, 0.1)', + +}, + +}, + +}} + +onInputChange={debouncedInputChange} + +renderTags={(value: readonly IPeopleInformation[], getTagProps) => + +value.map((option, index) => { + +const { key, ...tagProps } = getTagProps({ index }); + +return ( + + ({ + +bgcolor: theme.palette.grey[50], + +width: 24, + +height: 24, + +}), + +]} + +/> + +} + +variant="outlined" + +label={option.name} + +key={key} + +size="small" + +sx={{ + +maxWidth: '140px', + +'& .MuiChip-label': { + +overflow: 'hidden', + +textOverflow: 'ellipsis', + +whiteSpace: 'nowrap', + +}, + +}} + +{...tagProps} + +/> + +); + +}) + +} + +renderInput={(params) => ( + + setTextInput(e.target.value)} + +type={type} + +variant="standard" + +placeholder="Attendees" + +slotProps={{ + +input: { + +...params.InputProps, + +endAdornment: null, + +}, + +}} + +sx={[ + +(theme) => ({ + +flex: 1, + +py: 0, + +px: 1, + +'& .MuiInputBase-input': { + +fontSize: theme.typography.subtitle1, + +}, + +'& .MuiInputBase-input::placeholder': { + +color: theme.palette.primary.main, + +fontSize: theme.typography.subtitle1, + +}, + +'& .MuiInput-underline:before, & .MuiInput-underline:hover:before': { + +borderBottom: 'none !important', + +}, + +'& .MuiInput-underline:after': { + +borderBottom: 'none', + +}, + +}), + +]} + +/> + +)} + +renderOption={(props, option) => { + +const { key, ...optionProps } = props; + +const isSelected = value?.includes(option.email); + +return ( + + ({ + +'& > img': { mr: 2, flexShrink: 0 }, + +backgroundColor: isSelected ? theme.palette.grey[100] : 'transparent', + +}), + +]} + +{...optionProps} + +gap={1} + +> + + + + + + + +{option.name} + + + + + +{option.email} + + + + + + + +); + +}} + +/> + + + + + +); + +} + + \ No newline at end of file From 72398c5183f77f91387bac8937b3491d4c431bc3 Mon Sep 17 00:00:00 2001 From: Ishaan Garg <168962484+Ishaang19@users.noreply.github.com> Date: Wed, 15 Oct 2025 21:14:38 +0530 Subject: [PATCH 4/6] Issue #10 Show current user's information (name, email, profile photo, organization name etc) fix #10 --- client/src/api/api.ts | 772 +++++++++++++------ client/src/components/UserProfile.tsx | 239 ++++++ client/src/pages/Settings/PreferenceView.tsx | 22 +- server/src/auth/auth.controller.ts | 284 ++++--- shared/dto/index.ts | 30 +- shared/dto/user-info.response.ts | 11 + 6 files changed, 1004 insertions(+), 354 deletions(-) create mode 100644 client/src/components/UserProfile.tsx create mode 100644 shared/dto/user-info.response.ts diff --git a/client/src/api/api.ts b/client/src/api/api.ts index 864be34..d29c9d9 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -1,251 +1,521 @@ -import { ROUTES } from '@/config/routes'; -import { secrets } from '@config/secrets'; -import { CacheService, CacheServiceFactory } from '@helpers/cache'; -import { ApiResponse, BookRoomDto, DeleteResponse, EventResponse, GetAvailableRoomsQueryDto, IAvailableRooms, StatusTypes } from '@quickmeet/shared'; -import axios, { AxiosInstance } from 'axios'; -import { toast } from 'react-hot-toast'; -import { NavigateFunction } from 'react-router-dom'; - -/** - * @description Serves as the base API endpoint for the application. It provides the authorization token in every request - */ -export default class Api { - private static instance: Api; - apiToken?: string; - client: AxiosInstance; - private navigate: NavigateFunction | undefined; - - cacheService: CacheService = CacheServiceFactory.getCacheService(); - - constructor(navigate?: NavigateFunction) { - this.client = axios.create({ - baseURL: secrets.backendEndpoint, - timeout: secrets.nodeEnvironment === 'development' ? 1000000 : 10000, - headers: this.getHeaders(), - withCredentials: secrets.nodeEnvironment === 'development' ? true : false, - }); - - this.navigate = navigate; - this.handleTokenRefresh(); - } - - static getInstance(navigate: NavigateFunction): Api { - if (!Api.instance) { - Api.instance = new Api(navigate); - } - return Api.instance; - } - - getHeaders() { - return { - Accept: 'application/json', - 'x-mock-api': secrets.mockCalender, - }; - } - - async refreshToken() { - try { - const res = await axios.get('/auth/token/refresh', { - baseURL: secrets.backendEndpoint, - timeout: secrets.nodeEnvironment === 'development' ? 1000000 : 10000, - headers: this.getHeaders(), - withCredentials: secrets.nodeEnvironment === 'development' ? true : false, - }); - - return res.data.data; - } catch (error) { - return null; - } - } - - async handleTokenRefresh() { - this.client.interceptors.response.use( - (response) => response, - async (error) => { - const originalRequest = error.config; - - if (error.response?.status === 401 && !originalRequest._retry) { - originalRequest._retry = true; - - const renewedToken = await this.refreshToken(); - if (!renewedToken) { - await this.logout(); - - this.navigate && this.navigate(ROUTES.signIn); - return Promise.reject(error); - } - - return this.client(originalRequest); - } - - return Promise.reject(error); - }, - ); - } - - async getOAuthUrl(client: 'chrome' | 'web' = 'web') { - try { - const { data } = await this.client.get('/auth/oauth2/url', { - params: { - client, - }, - }); - return this.createReply('success', '', data.data) as ApiResponse; - } catch (error: any) { - return this.handleError(error); - } - } - - async handleOAuthCallback(code: string) { - try { - const payload = { - code, - }; - - await this.client.post('/auth/oauth2/callback', payload); - return this.createReply() as ApiResponse; - } catch (error: any) { - return this.handleError(error); - } - } - - async logout(revokeToken: boolean = false) { - try { - await this.client.post('/auth/logout', { - revokeToken, - }); - return true; - } catch (error: any) { - return false; - } - } - - async login() { - const { data } = await this.getOAuthUrl(secrets.appEnvironment); - if (!data) { - toast.error('Failed to retrieve oauth callback url'); - return; - } - - secrets.appEnvironment === 'chrome' ? window.open(data) : (window.location.href = data); - } - - async getAvailableRooms(signal: AbortSignal, startTime: string, duration: number, timeZone: string, seats: number, floor?: string, eventId?: string) { - try { - const params: GetAvailableRoomsQueryDto = { startTime, duration, timeZone, seats, floor, eventId }; - const res = await this.client.get('/api/rooms/available', { params, signal }); - - return res.data as ApiResponse; - } catch (error: any) { - return this.handleError(error); - } - } - - async getEvents(signal: AbortSignal, startTime: string, endTime: string, timeZone: string) { - try { - const res = await this.client.get('/api/events', { - params: { - startTime, - endTime, - timeZone, - }, - signal, - }); - - return res.data as ApiResponse; - } catch (error: any) { - return this.handleError(error); - } - } - - async createEvent(payload: BookRoomDto) { - try { - const res = await this.client.post('/api/event', payload); - - return res.data as ApiResponse; - } catch (error: any) { - return this.handleError(error); - } - } - - async updateEvent(eventId: string, payload: BookRoomDto) { - try { - const res = await this.client.put('/api/event', { eventId, ...payload }); - - return res.data as ApiResponse; - } catch (error: any) { - return this.handleError(error); - } - } - - async updateEventResponse(eventId: string, response: string) { - try { - const res = await this.client.put('/api/event/response', { eventId, response }); - - return res.data as ApiResponse; - } catch (error: any) { - return this.handleError(error); - } - } - - async deleteEvent(eventId: string) { - try { - const res = await this.client.delete(`/api/event?id=${eventId}`); - - return res.data as ApiResponse; - } catch (error: any) { - return this.handleError(error); - } - } - - async getMaxSeatCount(): Promise> { - try { - const res = await this.client.get('/api/rooms/highest-seat-count'); - - return res.data as ApiResponse; - } catch (error: any) { - return this.handleError(error); - } - } - - async searchPeople(email: string): Promise> { - try { - const res = await this.client.get('/api/directory/people', { - params: { - email, - }, - }); - - return res.data as ApiResponse; - } catch (error: any) { - return this.handleError(error); - } - } - async getFloors() { - try { - const res = await this.client.get('/api/floors'); - - return res.data as ApiResponse; - } catch (error: any) { - return this.handleError(error); - } - } - - createReply(status: StatusTypes = 'success', message?: string, data?: any): ApiResponse { - return { status, message, data }; - } - - handleError(error: any) { - // used for Abort request controllers - if (error.code === 'ERR_CANCELED') { - return this.createReply('ignore', 'Pending request aborted', null); - } - - const res: ApiResponse = error?.response?.data; - if (res) { - return res; - } - - return this.createReply('error', 'Something went wrong', null); - } -} +import { ROUTES } from '@/config/routes'; + +import { secrets } from '@config/secrets'; + +import { CacheService, CacheServiceFactory } from '@helpers/cache'; + +import { ApiResponse, BookRoomDto, DeleteResponse, EventResponse, GetAvailableRoomsQueryDto, IAvailableRooms, StatusTypes } from '@quickmeet/shared'; + +import axios, { AxiosInstance } from 'axios'; + +import { toast } from 'react-hot-toast'; + +import { NavigateFunction } from 'react-router-dom'; + + + +/** + +* @description Serves as the base API endpoint for the application. It provides the authorization token in every request + +*/ + +export default class Api { + +private static instance: Api; + +apiToken?: string; + +client: AxiosInstance; + +private navigate: NavigateFunction | undefined; + + + +cacheService: CacheService = CacheServiceFactory.getCacheService(); + + + +constructor(navigate?: NavigateFunction) { + +this.client = axios.create({ + +baseURL: secrets.backendEndpoint, + +timeout: secrets.nodeEnvironment === 'development' ? 1000000 : 10000, + +headers: this.getHeaders(), + +withCredentials: secrets.nodeEnvironment === 'development' ? true : false, + +}); + + + +this.navigate = navigate; + +this.handleTokenRefresh(); + +} + + + +static getInstance(navigate: NavigateFunction): Api { + +if (!Api.instance) { + +Api.instance = new Api(navigate); + +} + +return Api.instance; + +} + + + +getHeaders() { + +return { + +Accept: 'application/json', + +'x-mock-api': secrets.mockCalender, + +}; + +} + + + +async refreshToken() { + +try { + +const res = await axios.get('/auth/token/refresh', { + +baseURL: secrets.backendEndpoint, + +timeout: secrets.nodeEnvironment === 'development' ? 1000000 : 10000, + +headers: this.getHeaders(), + +withCredentials: secrets.nodeEnvironment === 'development' ? true : false, + +}); + + + +return res.data.data; + +} catch (error) { + +return null; + +} + +} + + + +async handleTokenRefresh() { + +this.client.interceptors.response.use( + +(response) => response, + +async (error) => { + +const originalRequest = error.config; + + + +if (error.response?.status === 401 && !originalRequest._retry) { + +originalRequest._retry = true; + + + +const renewedToken = await this.refreshToken(); + +if (!renewedToken) { + +await this.logout(); + + + +this.navigate && this.navigate(ROUTES.signIn); + +return Promise.reject(error); + +} + + + +return this.client(originalRequest); + +} + + + +return Promise.reject(error); + +}, + +); + +} + + + +async getOAuthUrl(client: 'chrome' | 'web' = 'web') { + +try { + +const { data } = await this.client.get('/auth/oauth2/url', { + +params: { + +client, + +}, + +}); + +return this.createReply('success', '', data.data) as ApiResponse; + +} catch (error: any) { + +return this.handleError(error); + +} + +} + + + +async handleOAuthCallback(code: string) { + +try { + +const payload = { + +code, + +}; + + + +await this.client.post('/auth/oauth2/callback', payload); + +return this.createReply() as ApiResponse; + +} catch (error: any) { + +return this.handleError(error); + +} + +} + + + +async logout(revokeToken: boolean = false) { + +try { + +await this.client.post('/auth/logout', { + +revokeToken, + +}); + +return true; + +} catch (error: any) { + +return false; + +} + +} + + + +async login() { + +const { data } = await this.getOAuthUrl(secrets.appEnvironment); + +if (!data) { + +toast.error('Failed to retrieve oauth callback url'); + +return; + +} + + + +secrets.appEnvironment === 'chrome' ? window.open(data) : (window.location.href = data); + +} + + + +async getAvailableRooms(signal: AbortSignal, startTime: string, duration: number, timeZone: string, seats: number, floor?: string, eventId?: string) { + +try { + +const params: GetAvailableRoomsQueryDto = { startTime, duration, timeZone, seats, floor, eventId }; + +const res = await this.client.get('/api/rooms/available', { params, signal }); + + + +return res.data as ApiResponse; + +} catch (error: any) { + +return this.handleError(error); + +} + +} + + + +async getEvents(signal: AbortSignal, startTime: string, endTime: string, timeZone: string) { + +try { + +const res = await this.client.get('/api/events', { + +params: { + +startTime, + +endTime, + +timeZone, + +}, + +signal, + +}); + + + +return res.data as ApiResponse; + +} catch (error: any) { + +return this.handleError(error); + +} + +} + + + +async createEvent(payload: BookRoomDto) { + +try { + +const res = await this.client.post('/api/event', payload); + + + +return res.data as ApiResponse; + +} catch (error: any) { + +return this.handleError(error); + +} + +} + + + +async updateEvent(eventId: string, payload: BookRoomDto) { + +try { + +const res = await this.client.put('/api/event', { eventId, ...payload }); + + + +return res.data as ApiResponse; + +} catch (error: any) { + +return this.handleError(error); + +} + +} + + + +async updateEventResponse(eventId: string, response: string) { + +try { + +const res = await this.client.put('/api/event/response', { eventId, response }); + + + +return res.data as ApiResponse; + +} catch (error: any) { + +return this.handleError(error); + +} + +} + + + +async deleteEvent(eventId: string) { + +try { + +const res = await this.client.delete(`/api/event?id=${eventId}`); + + + +return res.data as ApiResponse; + +} catch (error: any) { + +return this.handleError(error); + +} + +} + + + +async getMaxSeatCount(): Promise> { + +try { + +const res = await this.client.get('/api/rooms/highest-seat-count'); + + + +return res.data as ApiResponse; + +} catch (error: any) { + +return this.handleError(error); + +} + +} + + + +async searchPeople(email: string): Promise> { + +try { + +const res = await this.client.get('/api/directory/people', { + +params: { + +email, + +}, + +}); + + + +return res.data as ApiResponse; + +} catch (error: any) { + +return this.handleError(error); + +} + +} + +async getFloors() { + +try { + +const res = await this.client.get('/api/floors'); + + + +return res.data as ApiResponse; + +} catch (error: any) { + +return this.handleError(error); + +} + +} + + + +async getUserInfo() { + +try { + +const res = await this.client.get('/auth/user/info'); + + + +return res.data as ApiResponse; + +} catch (error: any) { + +return this.handleError(error); + +} + +} + + + +createReply(status: StatusTypes = 'success', message?: string, data?: any): ApiResponse { + +return { status, message, data }; + +} + + + +handleError(error: any) { + +// used for Abort request controllers + +if (error.code === 'ERR_CANCELED') { + +return this.createReply('ignore', 'Pending request aborted', null); + +} + + + +const res: ApiResponse = error?.response?.data; + +if (res) { + +return res; + +} + + + +return this.createReply('error', 'Something went wrong', null); + +} + +} \ No newline at end of file diff --git a/client/src/components/UserProfile.tsx b/client/src/components/UserProfile.tsx new file mode 100644 index 0000000..c3f0b3d --- /dev/null +++ b/client/src/components/UserProfile.tsx @@ -0,0 +1,239 @@ +import { useApi } from '@/context/ApiContext'; + +import { Box, Avatar, Typography, Skeleton, Divider } from '@mui/material'; + +import { useEffect, useState } from 'react'; + +import { UserInfoResponse } from '@quickmeet/shared'; + +import EmailIcon from '@mui/icons-material/Email'; + +import BusinessIcon from '@mui/icons-material/Business'; + + + +export default function UserProfile() { + +const [userInfo, setUserInfo] = useState(null); + +const [loading, setLoading] = useState(true); + +const api = useApi(); + + + +useEffect(() => { + +let isMounted = true; + + + +const fetchUserInfo = async () => { + +try { + +const res = await api.getUserInfo(); + + + +if (!isMounted) return; + + + +if (res.status === 'success' && res.data) { + +setUserInfo(res.data); + +} + +} catch (error) { + +console.error('Failed to fetch user info:', error); + +} finally { + +if (isMounted) { + +setLoading(false); + +} + +} + +}; + + + +fetchUserInfo(); + + + +return () => { + +isMounted = false; + +}; + +}, []); + + + +if (loading) { + +return ( + + + + + + + + + + + + + + + + + + + +); + +} + + + +if (!userInfo) { + +return null; + +} + + + +return ( + + + + + +Account + + + + + + + + `2px solid ${theme.palette.divider}`, + +}} + +/> + + + + + +{userInfo.name} + + + + + + + + + +{userInfo.email} + + + + + +{userInfo.hd && ( + + + + + + + +{userInfo.hd} + + + + + +)} + + + + + + + + + +); + +} \ No newline at end of file diff --git a/client/src/pages/Settings/PreferenceView.tsx b/client/src/pages/Settings/PreferenceView.tsx index bdd34c6..65366a5 100644 --- a/client/src/pages/Settings/PreferenceView.tsx +++ b/client/src/pages/Settings/PreferenceView.tsx @@ -6,6 +6,8 @@ import StyledTextField from '@/components/StyledTextField'; import ThemeModeToggle from '@/components/ThemeModeToggle'; +import UserProfile from '@/components/UserProfile'; + import { usePreferences } from '@/context/PreferencesContext'; import { createDropdownOptions, isChromeExt, populateDurationOptions, populateRoomCapacity, renderError } from '@/helpers/utility'; @@ -232,6 +234,10 @@ toast.success('Saved successfully!'); return ( +<> + + + + + + + +Preferences + + + + + -); + -} +); - \ No newline at end of file +} \ No newline at end of file diff --git a/server/src/auth/auth.controller.ts b/server/src/auth/auth.controller.ts index dd5bb05..cdea602 100644 --- a/server/src/auth/auth.controller.ts +++ b/server/src/auth/auth.controller.ts @@ -1,91 +1,193 @@ -import { ApiResponse } from '@quickmeet/shared'; -import { Body, Controller, Get, Inject, Post, Req, Res, Logger, Query, UnauthorizedException } from '@nestjs/common'; -import { AuthService } from './auth.service'; -import { _OAuth2Client } from './decorators'; -import { createResponse } from 'src/helpers/payload.util'; -import { Response, CookieOptions, Request } from 'express'; -import { GoogleApiService } from 'src/google-api/google-api.service'; -import { _Request } from './interfaces'; -import { toMs } from 'src/helpers/helper.util'; -import { EncryptionService } from './encryption.service'; - -@Controller('auth') -export class AuthController { - constructor( - private readonly authService: AuthService, - private encryptionService: EncryptionService, - @Inject('GoogleApiService') private readonly googleApiService: GoogleApiService, - private logger: Logger, - ) {} - - @Post('/oauth2/callback') - async oAuthCallback(@Body('code') code: string, @Res({ passthrough: true }) res: Response): Promise> { - const { accessToken, accessTokenIv, refreshToken, refreshTokenIv, hd, email } = await this.authService.login(code); - - if (refreshToken && refreshTokenIv) { - this.setCookie(res, 'refreshToken', refreshToken); - this.setCookie(res, 'refreshTokenIv', refreshTokenIv); - } - - this.setCookie(res, 'accessToken', accessToken, toMs('1h')); - this.setCookie(res, 'accessTokenIv', accessTokenIv, toMs('1h')); - - this.setCookie(res, 'hd', hd); - this.setCookie(res, 'email', email); - - this.logger.log(`OAuth flow completed for user ${email} with accessToken ${accessToken} and refreshToken ${refreshToken}`); - return createResponse(true); - } - - @Post('/logout') - async logout(@Req() req: _Request, @Res({ passthrough: true }) res: Response, @Body('revokeToken') revokeToken?: boolean): Promise> { - res.clearCookie('accessToken'); - res.clearCookie('accessTokenIv'); - res.clearCookie('hd'); - res.clearCookie('email'); - - if (revokeToken) { - this.logger.debug('revoking refresh token'); - res.clearCookie('refreshToken'); - res.clearCookie('refreshTokenIv'); - - const { accessToken, accessTokenIv } = req.cookies; - if (accessToken && accessTokenIv) { - const decryptedAccessToken = await this.encryptionService.decrypt(accessToken, accessTokenIv); - - const client = this.googleApiService.getOAuthClient(); - - client.setCredentials({ access_token: decryptedAccessToken }); - - await this.authService.logout(client); - } - } - - return createResponse(true); - } - - @Get('/oauth2/url') - getOAuthUrl(@Query('client') client: 'chrome' | 'web'): ApiResponse { - const url = this.googleApiService.getOAuthUrl(client); - - return createResponse(url); - } - - @Get('/token/refresh') - async refreshAppToken(@Req() req: Request, @Res({ passthrough: true }) res: Response): Promise> { - const refreshToken = await this.encryptionService.decrypt(req.cookies.refreshToken, req.cookies.refreshTokenIv); - const accessToken = await this.authService.refreshAppToken(refreshToken); - - const { iv, encryptedData } = await this.encryptionService.encrypt(accessToken); - - this.setCookie(res, 'accessToken', encryptedData, toMs('1h')); - this.setCookie(res, 'accessTokenIv', iv, toMs('1h')); - - return createResponse(true); - } - - private setCookie(res: Response, name: string, value: string, maxAge: number = toMs('30d')): void { - const cookieOptions: CookieOptions = this.authService.getCookieOptions(maxAge); - res.cookie(name, value, cookieOptions); - } -} +import { ApiResponse } from '@quickmeet/shared'; + +import { Body, Controller, Get, Inject, Post, Req, Res, Logger, Query, UnauthorizedException } from '@nestjs/common'; + +import { AuthService } from './auth.service'; + +import { _OAuth2Client } from './decorators'; + +import { createResponse } from 'src/helpers/payload.util'; + +import { Response, CookieOptions, Request } from 'express'; + +import { GoogleApiService } from 'src/google-api/google-api.service'; + +import { _Request } from './interfaces'; + +import { toMs } from 'src/helpers/helper.util'; + +import { EncryptionService } from './encryption.service'; + + + +@Controller('auth') + +export class AuthController { + +constructor( + +private readonly authService: AuthService, + +private encryptionService: EncryptionService, + +@Inject('GoogleApiService') private readonly googleApiService: GoogleApiService, + +private logger: Logger, + +) {} + + + +@Post('/oauth2/callback') + +async oAuthCallback(@Body('code') code: string, @Res({ passthrough: true }) res: Response): Promise> { + +const { accessToken, accessTokenIv, refreshToken, refreshTokenIv, hd, email } = await this.authService.login(code); + + + +if (refreshToken && refreshTokenIv) { + +this.setCookie(res, 'refreshToken', refreshToken); + +this.setCookie(res, 'refreshTokenIv', refreshTokenIv); + +} + + + +this.setCookie(res, 'accessToken', accessToken, toMs('1h')); + +this.setCookie(res, 'accessTokenIv', accessTokenIv, toMs('1h')); + + + +this.setCookie(res, 'hd', hd); + +this.setCookie(res, 'email', email); + + + +this.logger.log(`OAuth flow completed for user ${email} with accessToken ${accessToken} and refreshToken ${refreshToken}`); + +return createResponse(true); + +} + + + +@Post('/logout') + +async logout(@Req() req: _Request, @Res({ passthrough: true }) res: Response, @Body('revokeToken') revokeToken?: boolean): Promise> { + +res.clearCookie('accessToken'); + +res.clearCookie('accessTokenIv'); + +res.clearCookie('hd'); + +res.clearCookie('email'); + + + +if (revokeToken) { + +this.logger.debug('revoking refresh token'); + +res.clearCookie('refreshToken'); + +res.clearCookie('refreshTokenIv'); + + + +const { accessToken, accessTokenIv } = req.cookies; + +if (accessToken && accessTokenIv) { + +const decryptedAccessToken = await this.encryptionService.decrypt(accessToken, accessTokenIv); + + + +const client = this.googleApiService.getOAuthClient(); + + + +client.setCredentials({ access_token: decryptedAccessToken }); + + + +await this.authService.logout(client); + +} + +} + + + +return createResponse(true); + +} + + + +@Get('/oauth2/url') + +getOAuthUrl(@Query('client') client: 'chrome' | 'web'): ApiResponse { + +const url = this.googleApiService.getOAuthUrl(client); + + + +return createResponse(url); + +} + + + +@Get('/token/refresh') + +async refreshAppToken(@Req() req: Request, @Res({ passthrough: true }) res: Response): Promise> { + +const refreshToken = await this.encryptionService.decrypt(req.cookies.refreshToken, req.cookies.refreshTokenIv); + +const accessToken = await this.authService.refreshAppToken(refreshToken); + + + +const { iv, encryptedData } = await this.encryptionService.encrypt(accessToken); + + + +this.setCookie(res, 'accessToken', encryptedData, toMs('1h')); + +this.setCookie(res, 'accessTokenIv', iv, toMs('1h')); + + + +return createResponse(true); + +} + + + +@Get('/user/info') + +async getUserInfo(@_OAuth2Client() client: OAuth2Client): Promise> { + +const userInfo = await this.googleApiService.getUserInfo(client); + +return createResponse(userInfo); + +} + + + +private setCookie(res: Response, name: string, value: string, maxAge: number = toMs('30d')): void { + +const cookieOptions: CookieOptions = this.authService.getCookieOptions(maxAge); + +res.cookie(name, value, cookieOptions); + +} + +} \ No newline at end of file diff --git a/shared/dto/index.ts b/shared/dto/index.ts index 93eab4f..9755a3f 100644 --- a/shared/dto/index.ts +++ b/shared/dto/index.ts @@ -1,9 +1,21 @@ -export * from './api.response'; -export * from './error.response'; - -export * from './book-room.dto'; -export * from './list-rooms.dto'; -export * from './get-available-rooms.dto'; -export * from './delete.response'; -export * from './event-update.response'; -export * from './event.response'; +export * from './api.response'; + +export * from './error.response'; + + + +export * from './book-room.dto'; + +export * from './list-rooms.dto'; + +export * from './get-available-rooms.dto'; + +export * from './delete.response'; + +export * from './event-update.response'; + +export * from './event.response'; + +export * from './user-info.response'; + + \ No newline at end of file diff --git a/shared/dto/user-info.response.ts b/shared/dto/user-info.response.ts new file mode 100644 index 0000000..e16697c --- /dev/null +++ b/shared/dto/user-info.response.ts @@ -0,0 +1,11 @@ +export interface UserInfoResponse { +id: string; +email: string; +verified_email: boolean; +name: string; +given_name: string; +family_name: string; +picture: string; +locale: string; +hd?: string; +} \ No newline at end of file From 06a2127d4f8b5b078d55148f096c774987795628 Mon Sep 17 00:00:00 2001 From: Ishaan Garg <168962484+Ishaang19@users.noreply.github.com> Date: Wed, 15 Oct 2025 21:29:08 +0530 Subject: [PATCH 5/6] Issue #8 Add option to end meeting fix #8 --- client/src/api/api.ts | 20 + client/src/components/EventCard.tsx | 2062 ++++++++++++++---- client/src/pages/Home/MyEventsView/index.tsx | 1071 ++++++--- server/src/calender/calender.controller.ts | 491 +++-- server/src/calender/calender.service.ts | 1679 +++++++++----- 5 files changed, 3906 insertions(+), 1417 deletions(-) diff --git a/client/src/api/api.ts b/client/src/api/api.ts index d29c9d9..962f64f 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -484,6 +484,26 @@ return this.handleError(error); +async endEvent(eventId: string) { + +try { + +const res = await this.client.put('/api/event/end', { eventId }); + + + +return res.data as ApiResponse; + +} catch (error: any) { + +return this.handleError(error); + +} + +} + + + createReply(status: StatusTypes = 'success', message?: string, data?: any): ApiResponse { return { status, message, data }; diff --git a/client/src/components/EventCard.tsx b/client/src/components/EventCard.tsx index b12bb69..993ce57 100644 --- a/client/src/components/EventCard.tsx +++ b/client/src/components/EventCard.tsx @@ -1,401 +1,1661 @@ -import { Typography, Chip, IconButton, Box, styled, Theme, SxProps, Menu, MenuItem, Tooltip } from '@mui/material'; -import InsertLinkRoundedIcon from '@mui/icons-material/InsertLinkRounded'; -import React, { useEffect, useState } from 'react'; -import { EventResponse } from '@quickmeet/shared'; -import MeetingRoomRoundedIcon from '@mui/icons-material/MeetingRoomRounded'; -import StairsIcon from '@mui/icons-material/Stairs'; -import AccessTimeFilledRoundedIcon from '@mui/icons-material/AccessTimeFilledRounded'; -import EventSeatRoundedIcon from '@mui/icons-material/EventSeatRounded'; -import { convertToLocaleTime, isPastDate } from '@helpers/utility'; -import MoreHorizIcon from '@mui/icons-material/MoreHoriz'; -import LocationOnRoundedIcon from '@mui/icons-material/LocationOnRounded'; -import EmailRoundedIcon from '@mui/icons-material/EmailRounded'; -import ContentCopyIcon from '@mui/icons-material/ContentCopy'; -import CheckCircleIcon from '@mui/icons-material/CheckCircle'; -import CancelOutlinedIcon from '@mui/icons-material/CancelOutlined'; -import PendingOutlinedIcon from '@mui/icons-material/PendingOutlined'; -import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; -import toast from 'react-hot-toast'; - -const ListItem = styled('li')(({ theme }) => ({ - margin: theme.spacing(0.3), -})); - -export const createChips = (event: EventResponse) => { - const chips: ChipData[] = []; - - if (!event) { - return []; - } - - if (event.start && event.end) { - chips.push({ - label: convertToLocaleTime(event.start) + ' - ' + convertToLocaleTime(event.end), - icon: , - }); - } - - if (event.seats) { - chips.push({ - label: event.seats.toString(), - icon: , - }); - } - - if (event.room) { - chips.push({ - label: event.room, - icon: , - }); - } - - if (event.floor) { - chips.push({ - label: event.floor, - icon: , - }); - } - - if (event.meet) { - let locationIcon = ; - let endIcon; - - let tooltip = ''; - let domain = '-'; - let clickable = false; - let hasMeetingLink = false; - - try { - const url = new URL(event.meet); - domain = url.hostname; - clickable = true; - hasMeetingLink = true; - } catch (error) { - if (event.meet.length > 15) { - tooltip = event.meet; - domain = event.meet.slice(0, 15) + '...'; - } else { - domain = event.meet; - } - - locationIcon = ; - } - - if (hasMeetingLink) { - endIcon = ( - - - - ); - } - - chips.push({ - label: domain, - value: event.meet, - icon: locationIcon, - clickable, - tooltip, - endIcon, - action: () => { - clickable && window.open(event.meet, '_blank'); - }, - endIconAction: () => { - if (event.meet) { - navigator.clipboard.writeText(event.meet); - toast.success('Meeting link copied to clipboard'); - } - }, - }); - } - - if (event.attendees?.length) { - chips.push({ - label: event.attendees.length.toString(), - icon: , - }); - } - - return chips; -}; - -interface EventCardProps { - sx?: SxProps; - event: EventResponse; - disabled?: boolean; - isEditable?: boolean; - hideMenu?: boolean; - - onDelete?: (id?: string) => void; - handleEditClick?: (id: string) => void; - handleEventResponse?: (eventId: string, response: string) => void; -} - -interface ChipData { - icon: React.ReactElement; - endIcon?: React.ReactElement; - label: string; - color?: string; - clickable?: boolean; - value?: string; - tooltip?: string; - hasMeetingLink?: boolean; - endIconAction?: () => void; - action?: () => void; -} - -const EventCard = ({ sx, event, onDelete, handleEditClick, isEditable, handleEventResponse, hideMenu }: EventCardProps) => { - const [chips, setChips] = useState([]); - const [isOngoingEvent, setIsOngoingEvent] = useState(false); - const [anchorEl, setAnchorEl] = useState(null); - - const [menuItems, setMenuItems] = useState([]); - const [responseIcon, setResponseIcon] = useState(); - const [responseTooltipLabel, setResponseTooltipLabel] = useState('Acccepted'); - - const open = Boolean(anchorEl); - - useEffect(() => { - setOngoingEvent(); - - const chips: ChipData[] = createChips(event); - setChips(chips); - - createMenuItems(); - - createResponseIcon(); - }, [event]); - - const setOngoingEvent = () => { - const startInMs = new Date(event.start!).getTime(); - const endInMs = new Date(event.end!).getTime(); - const currentTimeInMs = Date.now(); - - if (currentTimeInMs >= startInMs && currentTimeInMs <= endInMs) { - setIsOngoingEvent(true); - } else { - setIsOngoingEvent(false); - } - }; - - const createResponseIcon = () => { - if (event.responseStatus === 'accepted') { - if (event.isEditable) { - setResponseIcon( - , - ); - setResponseTooltipLabel('Organizer'); - } else { - setResponseIcon( - , - ); - setResponseTooltipLabel('Accepted'); - } - } else if (event.responseStatus === 'declined') { - setResponseIcon( - , - ); - setResponseTooltipLabel('Declined'); - } else { - setResponseIcon( - , - ); - setResponseTooltipLabel('Pending'); - } - }; - - const createMenuItems = () => { - const menuItems: JSX.Element[] = []; - if (isEditable) { - menuItems.push( - - Edit - , - ); - menuItems.push( - - Delete - , - ); - } else { - menuItems.push( - - Accept - , - - Reject - , - ); - } - - setMenuItems(menuItems); - }; - - const handleOptionsMenuClick = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }; - - const onEditClick = () => { - typeof handleEditClick === 'function' && handleEditClick(event.eventId!); - setAnchorEl(null); - }; - - const onAcceptClick = () => { - typeof handleEventResponse === 'function' && handleEventResponse(event.eventId!, 'accepted'); - setAnchorEl(null); - }; - - const onRejectClick = () => { - typeof handleEventResponse === 'function' && handleEventResponse(event.eventId!, 'declined'); - setAnchorEl(null); - }; - - const handleDeleteClick = () => { - typeof onDelete === 'function' && onDelete(event.eventId); - setAnchorEl(null); - }; - - return ( - - - ({ - textAlign: 'left', - color: event.summary ?? theme.palette.grey[400], - fontStyle: event.summary ?? 'italic', - display: 'flex', - alignItems: 'center', - }), - ]} - > - - {event?.summary || 'No title'} - - {responseIcon} - - - - - - - {/* Options menu */} - - {!hideMenu && ( - <> - {isOngoingEvent ? ( - } onDelete={handleOptionsMenuClick} /> - ) : ( - <> - { - - - - } - - )} - - )} - - setAnchorEl(null)} - MenuListProps={{ - 'aria-labelledby': 'basic-button', - }} - anchorOrigin={{ - vertical: 'top', - horizontal: 'right', - }} - transformOrigin={{ - vertical: 'top', - horizontal: 'right', - }} - slotProps={{ - paper: { - style: { - width: '15ch', - }, - }, - }} - > - {menuItems.map((item) => item)} - - - - - {chips.map((chip, i) => { - return ( - - - - - - ); - })} - - - ); -}; - -export default EventCard; +import { Typography, Chip, IconButton, Box, styled, Theme, SxProps, Menu, MenuItem, Tooltip } from '@mui/material'; + +import InsertLinkRoundedIcon from '@mui/icons-material/InsertLinkRounded'; + +import React, { useEffect, useState } from 'react'; + +import { EventResponse } from '@quickmeet/shared'; + +import MeetingRoomRoundedIcon from '@mui/icons-material/MeetingRoomRounded'; + +import StairsIcon from '@mui/icons-material/Stairs'; + +import AccessTimeFilledRoundedIcon from '@mui/icons-material/AccessTimeFilledRounded'; + +import EventSeatRoundedIcon from '@mui/icons-material/EventSeatRounded'; + +import { convertToLocaleTime, isPastDate } from '@helpers/utility'; + +import MoreHorizIcon from '@mui/icons-material/MoreHoriz'; + +import LocationOnRoundedIcon from '@mui/icons-material/LocationOnRounded'; + +import EmailRoundedIcon from '@mui/icons-material/EmailRounded'; + +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; + +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; + +import CancelOutlinedIcon from '@mui/icons-material/CancelOutlined'; + +import PendingOutlinedIcon from '@mui/icons-material/PendingOutlined'; + +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; + +import toast from 'react-hot-toast'; + + + +const ListItem = styled('li')(({ theme }) => ({ + +margin: theme.spacing(0.3), + +})); + + + +export const createChips = (event: EventResponse) => { + +const chips: ChipData[] = []; + + + +if (!event) { + +return []; + +} + + + +if (event.start && event.end) { + +chips.push({ + +label: convertToLocaleTime(event.start) + ' - ' + convertToLocaleTime(event.end), + +icon: , + +}); + +} + + + +if (event.seats) { + +chips.push({ + +label: event.seats.toString(), + +icon: , + +}); + +} + + + +if (event.room) { + +chips.push({ + +label: event.room, + +icon: , + +}); + +} + + + +if (event.floor) { + +chips.push({ + +label: event.floor, + +icon: , + +}); + +} + + + +if (event.meet) { + +let locationIcon = ; + +let endIcon; + + + +let tooltip = ''; + +let domain = '-'; + +let clickable = false; + +let hasMeetingLink = false; + + + +try { + +const url = new URL(event.meet); + +domain = url.hostname; + +clickable = true; + +hasMeetingLink = true; + +} catch (error) { + +if (event.meet.length > 15) { + +tooltip = event.meet; + +domain = event.meet.slice(0, 15) + '...'; + +} else { + +domain = event.meet; + +} + + + +locationIcon = ; + +} + + + +if (hasMeetingLink) { + +endIcon = ( + + + + + + + +); + +} + + + +chips.push({ + +label: domain, + +value: event.meet, + +icon: locationIcon, + +clickable, + +tooltip, + +endIcon, + +action: () => { + +clickable && window.open(event.meet, '_blank'); + +}, + +endIconAction: () => { + +if (event.meet) { + +navigator.clipboard.writeText(event.meet); + +toast.success('Meeting link copied to clipboard'); + +} + +}, + +}); + +} + + + +if (event.attendees?.length) { + +chips.push({ + +label: event.attendees.length.toString(), + +icon: , + +}); + +} + + + +return chips; + +}; + + + +interface EventCardProps { + +sx?: SxProps; + +event: EventResponse; + +disabled?: boolean; + +isEditable?: boolean; + +hideMenu?: boolean; + + + +onDelete?: (id?: string) => void; + +handleEditClick?: (id: string) => void; + +handleEventResponse?: (eventId: string, response: string) => void; + +handleEndMeeting?: (eventId: string) => void; + +} + + + +interface ChipData { + +icon: React.ReactElement; + +endIcon?: React.ReactElement; + +label: string; + +color?: string; + +clickable?: boolean; + +value?: string; + +tooltip?: string; + +hasMeetingLink?: boolean; + +endIconAction?: () => void; + +action?: () => void; + +} + + + +const EventCard = ({ sx, event, onDelete, handleEditClick, isEditable, handleEventResponse, handleEndMeeting, hideMenu }: EventCardProps) => { + +const [chips, setChips] = useState([]); + +const [isOngoingEvent, setIsOngoingEvent] = useState(false); + +const [anchorEl, setAnchorEl] = useState(null); + + + +const [menuItems, setMenuItems] = useState([]); + +const [responseIcon, setResponseIcon] = useState(); + +const [responseTooltipLabel, setResponseTooltipLabel] = useState('Acccepted'); + + + +const open = Boolean(anchorEl); + + + +useEffect(() => { + +setOngoingEvent(); + + + +const chips: ChipData[] = createChips(event); + +setChips(chips); + + + +createResponseIcon(); + +}, [event]); + + + +useEffect(() => { + +createMenuItems(); + +}, [event, isOngoingEvent]); + + + +const setOngoingEvent = () => { + +const startInMs = new Date(event.start!).getTime(); + +const endInMs = new Date(event.end!).getTime(); + +const currentTimeInMs = Date.now(); + + + +if (currentTimeInMs >= startInMs && currentTimeInMs <= endInMs) { + +setIsOngoingEvent(true); + +} else { + +setIsOngoingEvent(false); + +} + +}; + + + +const createResponseIcon = () => { + +if (event.responseStatus === 'accepted') { + +if (event.isEditable) { + +setResponseIcon( + +, + +); + +setResponseTooltipLabel('Organizer'); + +} else { + +setResponseIcon( + +, + +); + +setResponseTooltipLabel('Accepted'); + +} + +} else if (event.responseStatus === 'declined') { + +setResponseIcon( + +, + +); + +setResponseTooltipLabel('Declined'); + +} else { + +setResponseIcon( + +, + +); + +setResponseTooltipLabel('Pending'); + +} + +}; + + + +const createMenuItems = () => { + +const menuItems: JSX.Element[] = []; + +if (isEditable) { + +menuItems.push( + + + +Edit + +, + +); + +if (isOngoingEvent) { + +menuItems.push( + + + +End meeting + +, + +); + +} + +menuItems.push( + + + +Delete + +, + +); + +} else { + +menuItems.push( + + + +Accept + +, + + + +Reject + +, + +); + +} + + + +setMenuItems(menuItems); + +}; + + + +const handleOptionsMenuClick = (event: React.MouseEvent) => { + +setAnchorEl(event.currentTarget); + +}; + + + +const onEditClick = () => { + +typeof handleEditClick === 'function' && handleEditClick(event.eventId!); + +setAnchorEl(null); + +}; + + + +const onAcceptClick = () => { + +typeof handleEventResponse === 'function' && handleEventResponse(event.eventId!, 'accepted'); + +setAnchorEl(null); + +}; + + + +const onRejectClick = () => { + +typeof handleEventResponse === 'function' && handleEventResponse(event.eventId!, 'declined'); + +setAnchorEl(null); + +}; + + + +const handleDeleteClick = () => { + +typeof onDelete === 'function' && onDelete(event.eventId); + +setAnchorEl(null); + +}; + + + +const onEndMeetingClick = () => { + +typeof handleEndMeeting === 'function' && handleEndMeeting(event.eventId!); + +setAnchorEl(null); + +}; + + + +return ( + + + + + + ({ + +textAlign: 'left', + +color: event.summary ?? theme.palette.grey[400], + +fontStyle: event.summary ?? 'italic', + +display: 'flex', + +alignItems: 'center', + +}), + +]} + +> + + + +{event?.summary || 'No title'} + + + +{responseIcon} + + + + + + + + + + + + + +{/* Options menu */} + + + +{!hideMenu && ( + +<> + +{isOngoingEvent ? ( + +} onDelete={handleOptionsMenuClick} /> + +) : ( + +<> + +{ + + + + + + + +} + + + +)} + + + +)} + + + + setAnchorEl(null)} + +MenuListProps={{ + +'aria-labelledby': 'basic-button', + +}} + +anchorOrigin={{ + +vertical: 'top', + +horizontal: 'right', + +}} + +transformOrigin={{ + +vertical: 'top', + +horizontal: 'right', + +}} + +slotProps={{ + +paper: { + +style: { + +width: '15ch', + +}, + +}, + +}} + +> + +{menuItems.map((item) => item)} + + + + + + + + + +{chips.map((chip, i) => { + +return ( + + + + + + + + + + + +); + +})} + + + + + +); + +}; + + + +export default EventCard; import { Typography, Chip, IconButton, Box, styled, Theme, SxProps, Menu, MenuItem, Tooltip } from '@mui/material'; + +import InsertLinkRoundedIcon from '@mui/icons-material/InsertLinkRounded'; + +import React, { useEffect, useState } from 'react'; + +import { EventResponse } from '@quickmeet/shared'; + +import MeetingRoomRoundedIcon from '@mui/icons-material/MeetingRoomRounded'; + +import StairsIcon from '@mui/icons-material/Stairs'; + +import AccessTimeFilledRoundedIcon from '@mui/icons-material/AccessTimeFilledRounded'; + +import EventSeatRoundedIcon from '@mui/icons-material/EventSeatRounded'; + +import { convertToLocaleTime, isPastDate } from '@helpers/utility'; + +import MoreHorizIcon from '@mui/icons-material/MoreHoriz'; + +import LocationOnRoundedIcon from '@mui/icons-material/LocationOnRounded'; + +import EmailRoundedIcon from '@mui/icons-material/EmailRounded'; + +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; + +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; + +import CancelOutlinedIcon from '@mui/icons-material/CancelOutlined'; + +import PendingOutlinedIcon from '@mui/icons-material/PendingOutlined'; + +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; + +import toast from 'react-hot-toast'; + + + +const ListItem = styled('li')(({ theme }) => ({ + +margin: theme.spacing(0.3), + +})); + + + +export const createChips = (event: EventResponse) => { + +const chips: ChipData[] = []; + + + +if (!event) { + +return []; + +} + + + +if (event.start && event.end) { + +chips.push({ + +label: convertToLocaleTime(event.start) + ' - ' + convertToLocaleTime(event.end), + +icon: , + +}); + +} + + + +if (event.seats) { + +chips.push({ + +label: event.seats.toString(), + +icon: , + +}); + +} + + + +if (event.room) { + +chips.push({ + +label: event.room, + +icon: , + +}); + +} + + + +if (event.floor) { + +chips.push({ + +label: event.floor, + +icon: , + +}); + +} + + + +if (event.meet) { + +let locationIcon = ; + +let endIcon; + + + +let tooltip = ''; + +let domain = '-'; + +let clickable = false; + +let hasMeetingLink = false; + + + +try { + +const url = new URL(event.meet); + +domain = url.hostname; + +clickable = true; + +hasMeetingLink = true; + +} catch (error) { + +if (event.meet.length > 15) { + +tooltip = event.meet; + +domain = event.meet.slice(0, 15) + '...'; + +} else { + +domain = event.meet; + +} + + + +locationIcon = ; + +} + + + +if (hasMeetingLink) { + +endIcon = ( + + + + + + + +); + +} + + + +chips.push({ + +label: domain, + +value: event.meet, + +icon: locationIcon, + +clickable, + +tooltip, + +endIcon, + +action: () => { + +clickable && window.open(event.meet, '_blank'); + +}, + +endIconAction: () => { + +if (event.meet) { + +navigator.clipboard.writeText(event.meet); + +toast.success('Meeting link copied to clipboard'); + +} + +}, + +}); + +} + + + +if (event.attendees?.length) { + +chips.push({ + +label: event.attendees.length.toString(), + +icon: , + +}); + +} + + + +return chips; + +}; + + + +interface EventCardProps { + +sx?: SxProps; + +event: EventResponse; + +disabled?: boolean; + +isEditable?: boolean; + +hideMenu?: boolean; + + + +onDelete?: (id?: string) => void; + +handleEditClick?: (id: string) => void; + +handleEventResponse?: (eventId: string, response: string) => void; + +handleEndMeeting?: (eventId: string) => void; + +} + + + +interface ChipData { + +icon: React.ReactElement; + +endIcon?: React.ReactElement; + +label: string; + +color?: string; + +clickable?: boolean; + +value?: string; + +tooltip?: string; + +hasMeetingLink?: boolean; + +endIconAction?: () => void; + +action?: () => void; + +} + + + +const EventCard = ({ sx, event, onDelete, handleEditClick, isEditable, handleEventResponse, handleEndMeeting, hideMenu }: EventCardProps) => { + +const [chips, setChips] = useState([]); + +const [isOngoingEvent, setIsOngoingEvent] = useState(false); + +const [anchorEl, setAnchorEl] = useState(null); + + + +const [menuItems, setMenuItems] = useState([]); + +const [responseIcon, setResponseIcon] = useState(); + +const [responseTooltipLabel, setResponseTooltipLabel] = useState('Acccepted'); + + + +const open = Boolean(anchorEl); + + + +useEffect(() => { + +setOngoingEvent(); + + + +const chips: ChipData[] = createChips(event); + +setChips(chips); + + + +createResponseIcon(); + +}, [event]); + + + +useEffect(() => { + +createMenuItems(); + +}, [event, isOngoingEvent]); + + + +const setOngoingEvent = () => { + +const startInMs = new Date(event.start!).getTime(); + +const endInMs = new Date(event.end!).getTime(); + +const currentTimeInMs = Date.now(); + + + +if (currentTimeInMs >= startInMs && currentTimeInMs <= endInMs) { + +setIsOngoingEvent(true); + +} else { + +setIsOngoingEvent(false); + +} + +}; + + + +const createResponseIcon = () => { + +if (event.responseStatus === 'accepted') { + +if (event.isEditable) { + +setResponseIcon( + +, + +); + +setResponseTooltipLabel('Organizer'); + +} else { + +setResponseIcon( + +, + +); + +setResponseTooltipLabel('Accepted'); + +} + +} else if (event.responseStatus === 'declined') { + +setResponseIcon( + +, + +); + +setResponseTooltipLabel('Declined'); + +} else { + +setResponseIcon( + +, + +); + +setResponseTooltipLabel('Pending'); + +} + +}; + + + +const createMenuItems = () => { + +const menuItems: JSX.Element[] = []; + +if (isEditable) { + +menuItems.push( + + + +Edit + +, + +); + +if (isOngoingEvent) { + +menuItems.push( + + + +End meeting + +, + +); + +} + +menuItems.push( + + + +Delete + +, + +); + +} else { + +menuItems.push( + + + +Accept + +, + + + +Reject + +, + +); + +} + + + +setMenuItems(menuItems); + +}; + + + +const handleOptionsMenuClick = (event: React.MouseEvent) => { + +setAnchorEl(event.currentTarget); + +}; + + + +const onEditClick = () => { + +typeof handleEditClick === 'function' && handleEditClick(event.eventId!); + +setAnchorEl(null); + +}; + + + +const onAcceptClick = () => { + +typeof handleEventResponse === 'function' && handleEventResponse(event.eventId!, 'accepted'); + +setAnchorEl(null); + +}; + + + +const onRejectClick = () => { + +typeof handleEventResponse === 'function' && handleEventResponse(event.eventId!, 'declined'); + +setAnchorEl(null); + +}; + + + +const handleDeleteClick = () => { + +typeof onDelete === 'function' && onDelete(event.eventId); + +setAnchorEl(null); + +}; + + + +const onEndMeetingClick = () => { + +typeof handleEndMeeting === 'function' && handleEndMeeting(event.eventId!); + +setAnchorEl(null); + +}; + + + +return ( + + + + + + ({ + +textAlign: 'left', + +color: event.summary ?? theme.palette.grey[400], + +fontStyle: event.summary ?? 'italic', + +display: 'flex', + +alignItems: 'center', + +}), + +]} + +> + + + +{event?.summary || 'No title'} + + + +{responseIcon} + + + + + + + + + + + + + +{/* Options menu */} + + + +{!hideMenu && ( + +<> + +{isOngoingEvent ? ( + +} onDelete={handleOptionsMenuClick} /> + +) : ( + +<> + +{ + + + + + + + +} + + + +)} + + + +)} + + + + setAnchorEl(null)} + +MenuListProps={{ + +'aria-labelledby': 'basic-button', + +}} + +anchorOrigin={{ + +vertical: 'top', + +horizontal: 'right', + +}} + +transformOrigin={{ + +vertical: 'top', + +horizontal: 'right', + +}} + +slotProps={{ + +paper: { + +style: { + +width: '15ch', + +}, + +}, + +}} + +> + +{menuItems.map((item) => item)} + + + + + + + + + +{chips.map((chip, i) => { + +return ( + + + + + + + + + + + +); + +})} + + + + + +); + +}; + + + +export default EventCard; \ No newline at end of file diff --git a/client/src/pages/Home/MyEventsView/index.tsx b/client/src/pages/Home/MyEventsView/index.tsx index f050814..9694313 100644 --- a/client/src/pages/Home/MyEventsView/index.tsx +++ b/client/src/pages/Home/MyEventsView/index.tsx @@ -1,340 +1,731 @@ -import { useApi } from '@/context/ApiContext'; -import { usePreferences } from '@/context/PreferencesContext'; -import DateNavigator from '@/pages/Home/MyEventsView/DateNavigator'; -import DeleteConfirmationView from '@components/DeleteConfirmationView'; -import EventCard from '@components/EventCard'; -import { ROUTES } from '@config/routes'; -import { FormData } from '@helpers/types'; -import { Box, Divider, Skeleton, Stack, Typography } from '@mui/material'; -import React, { useEffect, useRef, useState } from 'react'; -import { BookRoomDto, EventResponse, IConferenceRoom } from '@quickmeet/shared'; -import { useNavigate } from 'react-router-dom'; -import { convertToRFC3339, getTimeZoneString, renderError } from '@helpers/utility'; -import toast from 'react-hot-toast'; -import EditEventsView from './EditEventsView'; -import dayjs from 'dayjs'; -import DatePickerPopper from '@/pages/Home/MyEventsView/DatePickerPopper'; - -interface MyEventsViewProps { - redirectedDate?: string; -} - -export default function MyEventsView({ redirectedDate }: MyEventsViewProps) { - const [loading, setLoading] = useState(true); - const [editLoading, setEditLoading] = useState(false); - const [events, setEvents] = useState([]); - const navigate = useNavigate(); - const [deleteEventViewOpen, setDeleteEventViewOpen] = useState(false); - const [deleteEventId, setDeleteEventId] = useState(null); - const [editView, setEditView] = useState(null); - const [currentRoom, setCurrentRoom] = useState(); - const api = useApi(); - const { preferences } = usePreferences(); - - const [datePickerOpen, setDatePickerOpen] = useState(false); - - const [currentDate, setCurrentDate] = useState(dayjs(redirectedDate) || dayjs()); - const [datePickerAnchorEl, setDatePickerAnchorEl] = useState(null); - - const abortControllerRef = useRef(null); - - useEffect(() => { - const fetchEvents = async () => { - const isToday = currentDate.isSame(new Date(), 'day'); - - const query = { - startTime: isToday ? new Date().toISOString() : currentDate.startOf('day').toISOString(), - endTime: currentDate.endOf('day').toISOString(), - timeZone: getTimeZoneString(), - }; - - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - - abortControllerRef.current = new AbortController(); - const res = await api.getEvents(abortControllerRef.current.signal, query.startTime, query.endTime, query.timeZone); - const { data, status } = res; - setLoading(false); - - if (status !== 'success') { - renderError(res, navigate); - } - - if (!data?.length) { - setEvents([]); - return; - } - - setEvents(data); - }; - - setLoading(true); - fetchEvents(); - }, [currentDate]); - - useEffect(() => { - return () => { - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - }; - }, []); - - const handleDatePopperClick = (event: React.MouseEvent) => { - setDatePickerAnchorEl(event.currentTarget); - setDatePickerOpen(true); - }; - - const handleDeleteClick = (id: string) => { - setDeleteEventId(id); - setDeleteEventViewOpen(true); - }; - - const handleDeleteEventClose = () => { - setDeleteEventViewOpen(false); - setDeleteEventId(null); - }; - - const handleConfirmDelete = async () => { - setDeleteEventViewOpen(false); - setLoading(true); - - if (!deleteEventId) { - toast.error('Please select the event to delete'); - return; - } - - const res = await api.deleteEvent(deleteEventId); - - setLoading(false); - - const { data, status } = res; - - if (status !== 'success') { - return renderError(res, navigate); - } - - if (data) { - setEvents(events.filter((e) => e.eventId !== deleteEventId)); - toast.success('Deleted event!'); - } - }; - - const onEditConfirmed = async (data: FormData) => { - if (!data || !data.eventId || !data.room) { - toast.error('Room was not updated'); - return; - } - - setEditLoading(true); - - const { startTime, date, duration, seats, conference, attendees, title, room, eventId } = data; - - if (!room) { - return; - } - - if (!date) { - toast.error('No date selected'); - return; - } - - const eventDate = date.split('T')[0]; - const formattedStartTime = convertToRFC3339(eventDate, startTime); - - const payload: BookRoomDto = { - startTime: formattedStartTime, - duration: Number(duration), - timeZone: getTimeZoneString(), - seats: Number(seats), - createConference: conference, - title: title || preferences.title, - room: room, - attendees: attendees, - }; - - const res = await api.updateEvent(eventId, payload); - if (res?.redirect) { - toast.error("Couldn't complete request. Redirecting to login page"); - setTimeout(() => { - navigate(ROUTES.signIn); - }, 1000); - } - - if (res?.status === 'error') { - res.message && toast.error(res.message); - setEditLoading(false); - return; - } - - setEvents((prevEvents) => - prevEvents - .map((event) => (event.eventId === data.eventId ? res.data : event)) - .filter((event) => event.start.split('T')[0] === currentDate.toISOString().split('T')[0]), - ); - - toast.success('Room has been updated'); - setEditView(null); - setEditLoading(false); - }; - - const handleEditClick = (eventId: string) => { - const eventData = events.find((ev) => ev.eventId === eventId) || ({} as EventResponse); - setEditView(eventData); - if (eventData.roomEmail) { - setCurrentRoom({ - email: eventData.roomEmail, - name: eventData.room, - seats: eventData.seats, - floor: eventData.floor, - }); - } - }; - - const handleEventResponse = async (eventId: string, response: string) => { - if (!eventId) { - toast.error('Room was not updated'); - return; - } - - setEditLoading(true); - - const res = await api.updateEventResponse(eventId, response); - if (res?.status === 'error') { - res.message && toast.error(res.message); - setEditLoading(false); - return; - } - - setEvents((prevEvents) => - prevEvents - .map((event) => (event.eventId === eventId ? { ...event, ...res.data } : event)) - .filter((event) => event.start.split('T')[0] === currentDate.toISOString().split('T')[0]), - ); - - toast.success('Event response has been updated'); - setEditLoading(false); - }; - - const handleEditEventViewClose = () => { - setEditView(null); - }; - - const handleNextDate = async () => { - setCurrentDate(currentDate.add(1, 'day')); - }; - - const handlePrevDate = async () => { - setCurrentDate(currentDate.subtract(1, 'day')); - }; - - if (deleteEventViewOpen) { - const event = events.find((e) => e.eventId === deleteEventId); - return ( - - ); - } - - if (editView) { - return ( - - ); - } - - return ( - - - {/* Date Navigator */} - - - {currentDate.format('ddd MMM D, YYYY')} - - - - - {loading ? ( - - - - - - - ) : ( - <> - {events.length == 0 ? ( - - No events to show - - ) : ( - - {events.map((event, i) => ( - - event.eventId && handleDeleteClick(event.eventId)} - isEditable={event.isEditable} - handleEventResponse={handleEventResponse} - /> - {i !== events.length - 1 && } - - ))} - - )} - - )} - - - ); -} +import { useApi } from '@/context/ApiContext'; + +import { usePreferences } from '@/context/PreferencesContext'; + +import DateNavigator from '@/pages/Home/MyEventsView/DateNavigator'; + +import DeleteConfirmationView from '@components/DeleteConfirmationView'; + +import EventCard from '@components/EventCard'; + +import { ROUTES } from '@config/routes'; + +import { FormData } from '@helpers/types'; + +import { Box, Divider, Skeleton, Stack, Typography } from '@mui/material'; + +import React, { useEffect, useRef, useState } from 'react'; + +import { BookRoomDto, EventResponse, IConferenceRoom } from '@quickmeet/shared'; + +import { useNavigate } from 'react-router-dom'; + +import { convertToRFC3339, getTimeZoneString, renderError } from '@helpers/utility'; + +import toast from 'react-hot-toast'; + +import EditEventsView from './EditEventsView'; + +import dayjs from 'dayjs'; + +import DatePickerPopper from '@/pages/Home/MyEventsView/DatePickerPopper'; + + + +interface MyEventsViewProps { + +redirectedDate?: string; + +} + + + +export default function MyEventsView({ redirectedDate }: MyEventsViewProps) { + +const [loading, setLoading] = useState(true); + +const [editLoading, setEditLoading] = useState(false); + +const [events, setEvents] = useState([]); + +const navigate = useNavigate(); + +const [deleteEventViewOpen, setDeleteEventViewOpen] = useState(false); + +const [deleteEventId, setDeleteEventId] = useState(null); + +const [editView, setEditView] = useState(null); + +const [currentRoom, setCurrentRoom] = useState(); + +const api = useApi(); + +const { preferences } = usePreferences(); + + + +const [datePickerOpen, setDatePickerOpen] = useState(false); + + + +const [currentDate, setCurrentDate] = useState(dayjs(redirectedDate) || dayjs()); + +const [datePickerAnchorEl, setDatePickerAnchorEl] = useState(null); + + + +const abortControllerRef = useRef(null); + + + +useEffect(() => { + +const fetchEvents = async () => { + +const isToday = currentDate.isSame(new Date(), 'day'); + + + +const query = { + +startTime: isToday ? new Date().toISOString() : currentDate.startOf('day').toISOString(), + +endTime: currentDate.endOf('day').toISOString(), + +timeZone: getTimeZoneString(), + +}; + + + +if (abortControllerRef.current) { + +abortControllerRef.current.abort(); + +} + + + +abortControllerRef.current = new AbortController(); + +const res = await api.getEvents(abortControllerRef.current.signal, query.startTime, query.endTime, query.timeZone); + +const { data, status } = res; + +setLoading(false); + + + +if (status !== 'success') { + +renderError(res, navigate); + +} + + + +if (!data?.length) { + +setEvents([]); + +return; + +} + + + +setEvents(data); + +}; + + + +setLoading(true); + +fetchEvents(); + +}, [currentDate]); + + + +useEffect(() => { + +return () => { + +if (abortControllerRef.current) { + +abortControllerRef.current.abort(); + +} + +}; + +}, []); + + + +const handleDatePopperClick = (event: React.MouseEvent) => { + +setDatePickerAnchorEl(event.currentTarget); + +setDatePickerOpen(true); + +}; + + + +const handleDeleteClick = (id: string) => { + +setDeleteEventId(id); + +setDeleteEventViewOpen(true); + +}; + + + +const handleDeleteEventClose = () => { + +setDeleteEventViewOpen(false); + +setDeleteEventId(null); + +}; + + + +const handleConfirmDelete = async () => { + +setDeleteEventViewOpen(false); + +setLoading(true); + + + +if (!deleteEventId) { + +toast.error('Please select the event to delete'); + +return; + +} + + + +const res = await api.deleteEvent(deleteEventId); + + + +setLoading(false); + + + +const { data, status } = res; + + + +if (status !== 'success') { + +return renderError(res, navigate); + +} + + + +if (data) { + +setEvents(events.filter((e) => e.eventId !== deleteEventId)); + +toast.success('Deleted event!'); + +} + +}; + + + +const onEditConfirmed = async (data: FormData) => { + +if (!data || !data.eventId || !data.room) { + +toast.error('Room was not updated'); + +return; + +} + + + +setEditLoading(true); + + + +const { startTime, date, duration, seats, conference, attendees, title, room, eventId } = data; + + + +if (!room) { + +return; + +} + + + +if (!date) { + +toast.error('No date selected'); + +return; + +} + + + +const eventDate = date.split('T')[0]; + +const formattedStartTime = convertToRFC3339(eventDate, startTime); + + + +const payload: BookRoomDto = { + +startTime: formattedStartTime, + +duration: Number(duration), + +timeZone: getTimeZoneString(), + +seats: Number(seats), + +createConference: conference, + +title: title || preferences.title, + +room: room, + +attendees: attendees, + +}; + + + +const res = await api.updateEvent(eventId, payload); + +if (res?.redirect) { + +toast.error("Couldn't complete request. Redirecting to login page"); + +setTimeout(() => { + +navigate(ROUTES.signIn); + +}, 1000); + +} + + + +if (res?.status === 'error') { + +res.message && toast.error(res.message); + +setEditLoading(false); + +return; + +} + + + +setEvents((prevEvents) => + +prevEvents + +.map((event) => (event.eventId === data.eventId ? res.data : event)) + +.filter((event) => event.start.split('T')[0] === currentDate.toISOString().split('T')[0]), + +); + + + +toast.success('Room has been updated'); + +setEditView(null); + +setEditLoading(false); + +}; + + + +const handleEditClick = (eventId: string) => { + +const eventData = events.find((ev) => ev.eventId === eventId) || ({} as EventResponse); + +setEditView(eventData); + +if (eventData.roomEmail) { + +setCurrentRoom({ + +email: eventData.roomEmail, + +name: eventData.room, + +seats: eventData.seats, + +floor: eventData.floor, + +}); + +} + +}; + + + +const handleEventResponse = async (eventId: string, response: string) => { + +if (!eventId) { + +toast.error('Room was not updated'); + +return; + +} + + + +setEditLoading(true); + + + +const res = await api.updateEventResponse(eventId, response); + +if (res?.status === 'error') { + +res.message && toast.error(res.message); + +setEditLoading(false); + +return; + +} + + + +setEvents((prevEvents) => + +prevEvents + +.map((event) => (event.eventId === eventId ? { ...event, ...res.data } : event)) + +.filter((event) => event.start.split('T')[0] === currentDate.toISOString().split('T')[0]), + +); + + + +toast.success('Event response has been updated'); + +setEditLoading(false); + +}; + + + +const handleEndMeeting = async (eventId: string) => { + +if (!eventId) { + +toast.error('Event ID not provided'); + +return; + +} + + + +setEditLoading(true); + + + +const res = await api.endEvent(eventId); + +if (res?.status === 'error') { + +res.message && toast.error(res.message); + +setEditLoading(false); + +return; + +} + + + +setEvents((prevEvents) => + +prevEvents + +.map((event) => (event.eventId === eventId ? { ...event, ...res.data } : event)) + +.filter((event) => event.start.split('T')[0] === currentDate.toISOString().split('T')[0]), + +); + + + +toast.success('Meeting has been ended successfully'); + +setEditLoading(false); + +}; + + + +const handleEditEventViewClose = () => { + +setEditView(null); + +}; + + + +const handleNextDate = async () => { + +setCurrentDate(currentDate.add(1, 'day')); + +}; + + + +const handlePrevDate = async () => { + +setCurrentDate(currentDate.subtract(1, 'day')); + +}; + + + +if (deleteEventViewOpen) { + +const event = events.find((e) => e.eventId === deleteEventId); + +return ( + + + +); + +} + + + +if (editView) { + +return ( + + + +); + +} + + + +return ( + + + + + +{/* Date Navigator */} + + + + + +{currentDate.format('ddd MMM D, YYYY')} + + + + + + + + + +{loading ? ( + + + + + + + + + + + + + +) : ( + +<> + +{events.length == 0 ? ( + + + +No events to show + + + +) : ( + + + +{events.map((event, i) => ( + + + + event.eventId && handleDeleteClick(event.eventId)} + +isEditable={event.isEditable} + +handleEventResponse={handleEventResponse} + +handleEndMeeting={handleEndMeeting} + +/> + +{i !== events.length - 1 && } + + + +))} + + + +)} + + + +)} + + + + + +); + +} \ No newline at end of file diff --git a/server/src/calender/calender.controller.ts b/server/src/calender/calender.controller.ts index 71b0a8f..69fea7a 100644 --- a/server/src/calender/calender.controller.ts +++ b/server/src/calender/calender.controller.ts @@ -1,156 +1,335 @@ -import { OAuth2Client } from 'google-auth-library'; -import { BadRequestException, Body, Controller, Delete, Get, Post, Put, Query, Req, UseGuards, UseInterceptors } from '@nestjs/common'; -import { CalenderService } from './calender.service'; -import { AuthGuard } from '../auth/auth.guard'; -import { _OAuth2Client } from '../auth/decorators'; -import { OauthInterceptor } from '../auth/oauth.interceptor'; -import { - ApiResponse, - BookRoomDto, - ListRoomsQueryDto, - GetAvailableRoomsQueryDto, - DeleteResponse, - EventResponse, - EventUpdateResponse, - IPeopleInformation, - IAvailableRooms, -} from '@quickmeet/shared'; -import { createResponse } from 'src/helpers/payload.util'; -import { _Request } from 'src/auth/interfaces'; -import { SkipThrottle, Throttle } from '@nestjs/throttler'; - -@Controller('api') -export class CalenderController { - constructor(private readonly calenderService: CalenderService) {} - - @UseGuards(AuthGuard) - @UseInterceptors(OauthInterceptor) - @Get('/events') - async getEvents( - @_OAuth2Client() client: OAuth2Client, - @Req() req: _Request, - @Query() listRoomsQueryDto: ListRoomsQueryDto, - ): Promise> { - const { startTime, endTime, timeZone } = listRoomsQueryDto; - const userEmail = req.email; - - const events = await this.calenderService.getEvents(client, startTime, endTime, timeZone, userEmail); - return createResponse(events); - } - - @UseGuards(AuthGuard) - @UseInterceptors(OauthInterceptor) - @Get('/rooms/available') - async getAvailableRooms( - @_OAuth2Client() client: OAuth2Client, - @Query() getAvailableRoomsQueryDto: GetAvailableRoomsQueryDto, - ): Promise> { - const { startTime, duration, timeZone, seats, floor, eventId } = getAvailableRoomsQueryDto; - - if (!seats && eventId) { - return createResponse({ others: [], preferred: [] }); - } - - const startDate = new Date(startTime); - startDate.setMinutes(startDate.getMinutes() + duration); - - const endTime = startDate.toISOString(); - const rooms = await this.calenderService.getAvailableRooms(client, startTime, endTime, timeZone, seats, floor, eventId); - return createResponse(rooms); - } - - @SkipThrottle() - @UseGuards(AuthGuard) - @Get('/rooms/highest-seat-count') - async getMaxSeatCapacity(@_OAuth2Client() client: OAuth2Client): Promise> { - const count = await this.calenderService.getHighestSeatCapacity(client); - return createResponse(count); - } - - @UseGuards(AuthGuard) - @UseInterceptors(OauthInterceptor) - @Post('/event') - async createEvent(@_OAuth2Client() client: OAuth2Client, @Req() req: _Request, @Body() bookRoomDto: BookRoomDto): Promise> { - const { startTime, duration, createConference, title, attendees, room } = bookRoomDto; - const userEmail = req.email; - - // end time - const startDate = new Date(startTime); - startDate.setMinutes(startDate.getMinutes() + duration); - const endTime = startDate.toISOString(); - - const event = await this.calenderService.createEvent(client, startTime, endTime, room, userEmail, createConference, title, attendees); - return createResponse(event, 'Room has been booked'); - } - - @Throttle({ default: { limit: 100, ttl: 60000 } }) - @UseGuards(AuthGuard) - @UseInterceptors(OauthInterceptor) - @Get('/directory/people') - async searchPeople(@_OAuth2Client() client: OAuth2Client, @Query('email') email: string): Promise> { - const peoples = await this.calenderService.searchPeople(client, email); - - return createResponse(peoples); - } - - @UseGuards(AuthGuard) - @UseInterceptors(OauthInterceptor) - @Put('/event') - async updateEvent( - @_OAuth2Client() client: OAuth2Client, - @Req() req: _Request, - @Body('eventId') eventId: string, - @Body() bookRoomDto: BookRoomDto, - ): Promise> { - const { startTime, duration, createConference, title, attendees, room } = bookRoomDto; - const userEmail = req.email; - - // end time - const startDate = new Date(startTime); - startDate.setMinutes(startDate.getMinutes() + duration); - const endTime = startDate.toISOString(); - - const updatedEvent = await this.calenderService.updateEvent(client, eventId, startTime, endTime, userEmail, room, createConference, title, attendees); - return createResponse(updatedEvent, 'Event has been updated!'); - } - - @UseGuards(AuthGuard) - @UseInterceptors(OauthInterceptor) - @Put('/event/response') - async updateEventResponse( - @_OAuth2Client() client: OAuth2Client, - @Req() req: _Request, - @Body('eventId') eventId: string, - @Body('response') response: string, - ): Promise> { - const userEmail = req.email; - - if (!response) { - throw new BadRequestException('No response provided'); - } - - if (!eventId) { - throw new BadRequestException('No event id provided'); - } - - const updatedEvent = await this.calenderService.updateEventResponse(client, userEmail, eventId, response); - return createResponse(updatedEvent, 'Event has been updated!'); - } - - @UseGuards(AuthGuard) - @UseInterceptors(OauthInterceptor) - @Delete('/event') - async deleteEvent(@_OAuth2Client() client: OAuth2Client, @Query('id') eventId: string): Promise> { - const deleted = await this.calenderService.deleteEvent(client, eventId); - - return createResponse(deleted, 'Event has been deleted'); - } - - @SkipThrottle() - @UseGuards(AuthGuard) - @Get('/floors') - async listFloors(@_OAuth2Client() client: OAuth2Client): Promise> { - const floors = await this.calenderService.listFloors(client); - return createResponse(floors); - } -} +import { OAuth2Client } from 'google-auth-library'; + +import { BadRequestException, Body, Controller, Delete, Get, Post, Put, Query, Req, UseGuards, UseInterceptors } from '@nestjs/common'; + +import { CalenderService } from './calender.service'; + +import { AuthGuard } from '../auth/auth.guard'; + +import { _OAuth2Client } from '../auth/decorators'; + +import { OauthInterceptor } from '../auth/oauth.interceptor'; + +import { + +ApiResponse, + +BookRoomDto, + +ListRoomsQueryDto, + +GetAvailableRoomsQueryDto, + +DeleteResponse, + +EventResponse, + +EventUpdateResponse, + +IPeopleInformation, + +IAvailableRooms, + +} from '@quickmeet/shared'; + +import { createResponse } from 'src/helpers/payload.util'; + +import { _Request } from 'src/auth/interfaces'; + +import { SkipThrottle, Throttle } from '@nestjs/throttler'; + + + +@Controller('api') + +export class CalenderController { + +constructor(private readonly calenderService: CalenderService) {} + + + +@UseGuards(AuthGuard) + +@UseInterceptors(OauthInterceptor) + +@Get('/events') + +async getEvents( + +@_OAuth2Client() client: OAuth2Client, + +@Req() req: _Request, + +@Query() listRoomsQueryDto: ListRoomsQueryDto, + +): Promise> { + +const { startTime, endTime, timeZone } = listRoomsQueryDto; + +const userEmail = req.email; + + + +const events = await this.calenderService.getEvents(client, startTime, endTime, timeZone, userEmail); + +return createResponse(events); + +} + + + +@UseGuards(AuthGuard) + +@UseInterceptors(OauthInterceptor) + +@Get('/rooms/available') + +async getAvailableRooms( + +@_OAuth2Client() client: OAuth2Client, + +@Query() getAvailableRoomsQueryDto: GetAvailableRoomsQueryDto, + +): Promise> { + +const { startTime, duration, timeZone, seats, floor, eventId } = getAvailableRoomsQueryDto; + + + +if (!seats && eventId) { + +return createResponse({ others: [], preferred: [] }); + +} + + + +const startDate = new Date(startTime); + +startDate.setMinutes(startDate.getMinutes() + duration); + + + +const endTime = startDate.toISOString(); + +const rooms = await this.calenderService.getAvailableRooms(client, startTime, endTime, timeZone, seats, floor, eventId); + +return createResponse(rooms); + +} + + + +@SkipThrottle() + +@UseGuards(AuthGuard) + +@Get('/rooms/highest-seat-count') + +async getMaxSeatCapacity(@_OAuth2Client() client: OAuth2Client): Promise> { + +const count = await this.calenderService.getHighestSeatCapacity(client); + +return createResponse(count); + +} + + + +@UseGuards(AuthGuard) + +@UseInterceptors(OauthInterceptor) + +@Post('/event') + +async createEvent(@_OAuth2Client() client: OAuth2Client, @Req() req: _Request, @Body() bookRoomDto: BookRoomDto): Promise> { + +const { startTime, duration, createConference, title, attendees, room } = bookRoomDto; + +const userEmail = req.email; + + + +// end time + +const startDate = new Date(startTime); + +startDate.setMinutes(startDate.getMinutes() + duration); + +const endTime = startDate.toISOString(); + + + +const event = await this.calenderService.createEvent(client, startTime, endTime, room, userEmail, createConference, title, attendees); + +return createResponse(event, 'Room has been booked'); + +} + + + +@Throttle({ default: { limit: 100, ttl: 60000 } }) + +@UseGuards(AuthGuard) + +@UseInterceptors(OauthInterceptor) + +@Get('/directory/people') + +async searchPeople(@_OAuth2Client() client: OAuth2Client, @Query('email') email: string): Promise> { + +const peoples = await this.calenderService.searchPeople(client, email); + + + +return createResponse(peoples); + +} + + + +@UseGuards(AuthGuard) + +@UseInterceptors(OauthInterceptor) + +@Put('/event') + +async updateEvent( + +@_OAuth2Client() client: OAuth2Client, + +@Req() req: _Request, + +@Body('eventId') eventId: string, + +@Body() bookRoomDto: BookRoomDto, + +): Promise> { + +const { startTime, duration, createConference, title, attendees, room } = bookRoomDto; + +const userEmail = req.email; + + + +// end time + +const startDate = new Date(startTime); + +startDate.setMinutes(startDate.getMinutes() + duration); + +const endTime = startDate.toISOString(); + + + +const updatedEvent = await this.calenderService.updateEvent(client, eventId, startTime, endTime, userEmail, room, createConference, title, attendees); + +return createResponse(updatedEvent, 'Event has been updated!'); + +} + + + +@UseGuards(AuthGuard) + +@UseInterceptors(OauthInterceptor) + +@Put('/event/response') + +async updateEventResponse( + +@_OAuth2Client() client: OAuth2Client, + +@Req() req: _Request, + +@Body('eventId') eventId: string, + +@Body('response') response: string, + +): Promise> { + +const userEmail = req.email; + + + +if (!response) { + +throw new BadRequestException('No response provided'); + +} + + + +if (!eventId) { + +throw new BadRequestException('No event id provided'); + +} + + + +const updatedEvent = await this.calenderService.updateEventResponse(client, userEmail, eventId, response); + +return createResponse(updatedEvent, 'Event has been updated!'); + +} + + + +@UseGuards(AuthGuard) + +@UseInterceptors(OauthInterceptor) + +@Delete('/event') + +async deleteEvent(@_OAuth2Client() client: OAuth2Client, @Query('id') eventId: string): Promise> { + +const deleted = await this.calenderService.deleteEvent(client, eventId); + + + +return createResponse(deleted, 'Event has been deleted'); + +} + + + +@UseGuards(AuthGuard) + +@UseInterceptors(OauthInterceptor) + +@Put('/event/end') + +async endEvent(@_OAuth2Client() client: OAuth2Client, @Body('eventId') eventId: string): Promise> { + +if (!eventId) { + +throw new BadRequestException('No event id provided'); + +} + + + +const updatedEvent = await this.calenderService.endEvent(client, eventId); + +return createResponse(updatedEvent, 'Meeting has been ended'); + +} + + + +@SkipThrottle() + +@UseGuards(AuthGuard) + +@Get('/floors') + +async listFloors(@_OAuth2Client() client: OAuth2Client): Promise> { + +const floors = await this.calenderService.listFloors(client); + +return createResponse(floors); + +} + +} \ No newline at end of file diff --git a/server/src/calender/calender.service.ts b/server/src/calender/calender.service.ts index 1fd480f..d375bf6 100644 --- a/server/src/calender/calender.service.ts +++ b/server/src/calender/calender.service.ts @@ -1,520 +1,1159 @@ -import { BadRequestException, ConflictException, ForbiddenException, Inject, Injectable, NotFoundException, Logger } from '@nestjs/common'; -import { calendar_v3 } from 'googleapis'; -import { extractRoomByEmail, isRoomAvailable, validateEmail } from './util/calender.util'; -import { DeleteResponse, EventResponse, EventUpdateResponse, IConferenceRoom, IPeopleInformation, IAvailableRooms } from '@quickmeet/shared'; -import { OAuth2Client } from 'google-auth-library'; -import { GoogleApiService } from 'src/google-api/google-api.service'; -import { AuthService } from '../auth/auth.service'; - -@Injectable() -export class CalenderService { - constructor( - private authService: AuthService, - @Inject('GoogleApiService') private readonly googleApiService: GoogleApiService, - private logger: Logger, - ) {} - - async createEvent( - client: OAuth2Client, - startTime: string, - endTime: string, - room: string, - organizerEmail: string, - createConference?: boolean, - eventTitle?: string, - attendees?: IPeopleInformation[], - ): Promise { - const rooms = await this.authService.getDirectoryResources(client); - - const filteredAttendees: IPeopleInformation[] = []; - if (attendees?.length) { - for (const attendee of attendees) { - if (validateEmail(attendee.email)) { - filteredAttendees.push({ email: attendee.email, photo: attendee.photo.length > 1024 ? '' : attendee.photo, name: attendee.name }); - } else { - throw new BadRequestException('Invalid attendee email provided: ' + attendee); - } - } - } - - let conference = {}; - if (createConference) { - conference = { - conferenceData: { - createRequest: { - requestId: Math.random().toString(36).substring(7), - conferenceSolutionKey: { - type: 'hangoutsMeet', - }, - }, - }, - }; - } - - const pickedRoom = extractRoomByEmail(rooms, room); - if (!pickedRoom) { - throw new NotFoundException('Incorrect room picked!'); - } - - const isAvailable = await this.isRoomAvailable(client, startTime, endTime, pickedRoom.email); - if (!isAvailable) { - throw new ConflictException('Room has already been booked.'); - } - - const event: calendar_v3.Schema$Event = { - summary: eventTitle?.trim() || '-', - location: pickedRoom.name, - description: 'A quick meeting created by Quick Meet', - start: { - dateTime: startTime, - }, - end: { - dateTime: endTime, - }, - attendees: [...filteredAttendees, { email: pickedRoom.email }, { email: organizerEmail, organizer: true, responseStatus: 'accepted' }], - colorId: '3', - // https://developers.google.com/calendar/api/guides/extended-properties - extendedProperties: { - private: { - createdAt: new Date().toISOString(), // Adding custom createdAt timestamp - }, - }, - ...conference, - }; - - const createdEvent = await this.googleApiService.createCalenderEvent(client, event); - - this.logger.log('[CreateEvent] Room has been booked' + JSON.stringify(createdEvent)); - - const data: EventResponse = { - eventId: createdEvent.id, - summary: createdEvent.summary, - meet: createdEvent.hangoutLink, - start: createdEvent.start.dateTime, - end: createdEvent.end.dateTime, - room: pickedRoom.name, - roomEmail: pickedRoom.email, - roomId: pickedRoom.id, - seats: pickedRoom.seats, - isEditable: true, - floor: pickedRoom.floor, - }; - - return data; - } - - async getHighestSeatCapacity(client: OAuth2Client): Promise { - const rooms = await this.authService.getDirectoryResources(client); - let max = -1; - for (const room of rooms) { - if (room.seats > max) { - max = room.seats; - } - } - - return max; - } - - async getAvailableRooms( - client: OAuth2Client, - start: string, - end: string, - timeZone: string, - minSeats: number, - floor?: string, - eventId?: string, - ): Promise { - const filteredRoomEmails: string[] = []; - const otherRoomEmails: string[] = []; - const isPreferredRoom: Record = {}; - - const rooms = await this.authService.getDirectoryResources(client); - for (const room of rooms) { - if (room.seats >= Number(minSeats)) { - if (floor === undefined || floor === '' || room.floor === floor) { - filteredRoomEmails.push(room.email); - isPreferredRoom[room.email] = true; - } else { - otherRoomEmails.push(room.email); - isPreferredRoom[room.email] = false; - } - } - } - - if (filteredRoomEmails.length === 0 && otherRoomEmails.length === 0) { - return { preferred: [], others: [] }; - } - - const calenders = await this.googleApiService.getCalenderSchedule(client, start, end, timeZone, [...filteredRoomEmails, ...otherRoomEmails]); - - const availableRooms: IAvailableRooms = { others: [], preferred: [] }; - let room: IConferenceRoom = null; - - for (const roomEmail of Object.keys(calenders)) { - const isAvailable = isRoomAvailable(calenders[roomEmail].busy, new Date(start), new Date(end)); - if (isAvailable) { - room = rooms.find((room) => room.email === roomEmail); - if (isPreferredRoom[room.email]) { - availableRooms.preferred.push(room); - } else { - availableRooms.others.push(room); - } - } - } - - if (eventId) { - const event = await this.googleApiService.getCalenderEvent(client, eventId); - const roomEmail = (event.attendees || []).find((attendee) => attendee.resource && attendee.responseStatus !== 'declined'); - - if (roomEmail) { - const currentStartTime = new Date(event.start.dateTime).getTime(); - const currentEndTime = new Date(event.end.dateTime).getTime(); - - const requestStart = new Date(start).getTime(); - const requestEnd = new Date(end).getTime(); - - const currentRoom = extractRoomByEmail(rooms, roomEmail.email); - - const { timeZone } = event.start; - const originalEventDate = new Date(event.start.dateTime).toDateString(); - const newEventDate = new Date(start).toDateString(); - - let isEventRoomAvailable = true; - - if (originalEventDate !== newEventDate) { - const isAvailable = await this.isRoomAvailable(client, start, end, roomEmail.email, timeZone); - if (!isAvailable) { - isEventRoomAvailable = false; - } - } else { - if (requestStart < currentStartTime) { - const isAvailable = await this.isRoomAvailable(client, start, event.start.dateTime, roomEmail.email, timeZone); - if (!isAvailable) { - isEventRoomAvailable = false; - } - } - - if (requestEnd > currentEndTime) { - const isAvailable = await this.isRoomAvailable(client, event.end.dateTime, end, roomEmail.email, timeZone); - if (!isAvailable) { - isEventRoomAvailable = false; - } - } - } - - if (isEventRoomAvailable) { - if (isPreferredRoom[currentRoom.email]) { - availableRooms.preferred.unshift(currentRoom); - } else { - availableRooms.others.unshift(currentRoom); - } - } - } - } - - return availableRooms; - } - - async isRoomAvailable(client: OAuth2Client, start: string, end: string, roomEmail: string, timeZone?: string): Promise { - const calenders = await this.googleApiService.getCalenderSchedule(client, start, end, timeZone, [roomEmail]); - - const availableRooms: IConferenceRoom[] = []; - const room: IConferenceRoom = null; - - for (const roomEmail of Object.keys(calenders)) { - const isAvailable = isRoomAvailable(calenders[roomEmail].busy, new Date(start), new Date(end)); - if (isAvailable) { - availableRooms.push(room); - } - } - - if (availableRooms.length === 0) { - return false; - } - - return true; - } - - async getEvents(client: OAuth2Client, startTime: string, endTime: string, timeZone: string, userEmail: string): Promise { - const rooms = await this.authService.getDirectoryResources(client); - const events = await this.googleApiService.getCalenderEvents(client, startTime, endTime, timeZone); - const people = await this.authService.getPeopleResources(client); - - const formattedEvents = []; - - for (const event of events) { - let room: IConferenceRoom | null = null; - let responseStatus: string; - - const attendees: IPeopleInformation[] = []; - - if (event.attendees) { - for (const attendee of event.attendees) { - // current user's response status - if (userEmail === attendee.email) { - responseStatus = attendee.responseStatus; - } - - if (!attendee.resource && !attendee.organizer) { - let person = people.find((person: IPeopleInformation) => person.email === attendee.email); - - if (!person) { - const emailParts = attendee.email.split('@'); - person = { email: attendee.email, name: emailParts[0], photo: '' }; - } - - attendees.push(person); - } else if (attendee.resource) { - room = rooms.find((_room) => _room.email === attendee.email); - } - } - } - - if (!room && event.location) { - // event location must be an external link - event.hangoutLink = event.location; - } - - const _event: EventResponse = { - meet: event.hangoutLink, - room: room?.name, - roomEmail: room?.email, - eventId: event.id, - seats: room?.seats, - floor: room?.floor, - summary: event.summary, - start: event.start.dateTime, - attendees: attendees, - end: event.end.dateTime, - createdAt: event.extendedProperties?.private?.createdAt ? new Date(event.extendedProperties.private.createdAt).getTime() : Date.now(), - isEditable: event.organizer.email === userEmail, - responseStatus: responseStatus, - }; - - formattedEvents.push(_event); - } - - const sortedEvents = formattedEvents.sort((a, b) => { - const startA = new Date(a.start).getTime(); - const startB = new Date(b.start).getTime(); - if (startA !== startB) { - return startA - startB; - } - const createdAtA = new Date(a.createdAt).getTime(); - const createdAtB = new Date(b.createdAt).getTime(); - const timestamps = [createdAtA, createdAtB]; - const firstCreated = Math.min(...timestamps); - return firstCreated === createdAtA ? 1 : -1; - }); - - return sortedEvents; - } - - async updateEvent( - client: OAuth2Client, - eventId: string, - startTime: string, - endTime: string, - userEmail: string, - room?: string, - createConference?: boolean, - eventTitle?: string, - attendees?: IPeopleInformation[], - ): Promise { - const event = await this.googleApiService.getCalenderEvent(client, eventId); - const rooms = await this.authService.getDirectoryResources(client); - - if (event.organizer.email !== userEmail) { - throw new ForbiddenException('Not allowed to update this event'); - } - - const pickedRoom = extractRoomByEmail(rooms, room); - if (!pickedRoom) { - throw new NotFoundException('Incorrect room picked!'); - } - - // check if event room is available - if (event.attendees?.some((attendee) => attendee.email === room)) { - const currentStartTime = new Date(event.start.dateTime).getTime(); - const currentEndTime = new Date(event.end.dateTime).getTime(); - - const newStartTime = new Date(startTime).getTime(); - const newEndTime = new Date(endTime).getTime(); - - const { timeZone } = event.start; - const originalEventDate = new Date(event.start.dateTime).toDateString(); - const newEventDate = new Date(startTime).toDateString(); - - if (originalEventDate !== newEventDate) { - const isAvailable = await this.isRoomAvailable(client, startTime, endTime, pickedRoom.email, timeZone); - if (!isAvailable) { - throw new ConflictException('Room is not available within the set duration'); - } - } else { - if (newStartTime < currentStartTime) { - const isAvailable = await this.isRoomAvailable(client, startTime, event.start.dateTime, room, timeZone); - if (!isAvailable) { - throw new ConflictException('Room is not available within the set duration'); - } - } - - if (newEndTime > currentEndTime) { - const isAvailable = await this.isRoomAvailable(client, event.end.dateTime, endTime, room, timeZone); - if (!isAvailable) { - throw new ConflictException('Room is not available within the set duration'); - } - } - } - } - - attendees.push({ email: event.organizer.email }); - - const filteredAttendees: IPeopleInformation[] = []; - let responseStatus: string; - - if (attendees?.length) { - for (const attendee of attendees) { - if (validateEmail(attendee.email)) { - const existingAttendee = event.attendees?.find((a) => a.email === attendee.email); - if (existingAttendee) { - filteredAttendees.push({ ...attendee, ...existingAttendee }); - if (userEmail === existingAttendee.email) { - responseStatus = existingAttendee.responseStatus; - } - } else { - filteredAttendees.push({ email: attendee.email }); - } - } else { - throw new BadRequestException('Invalid attendee email provided: ' + attendee); - } - } - } - - let conference = {}; - if (createConference) { - conference = { - conferenceData: { - createRequest: { - requestId: Math.random().toString(36).substring(7), - conferenceSolutionKey: { - type: 'hangoutsMeet', - }, - }, - }, - }; - } else { - conference = { - conferenceData: null, - }; - } - - const updatedEvent: calendar_v3.Schema$Event = { - ...event, - summary: eventTitle?.trim() || '-', - location: pickedRoom.name, - description: 'A quick meeting created by Quick Meet', - start: { - dateTime: startTime, - }, - end: { - dateTime: endTime, - }, - attendees: [...filteredAttendees, { email: pickedRoom.email }], - colorId: '3', - extendedProperties: { - private: { - createdAt: new Date().toISOString(), // Adding custom createdAt timestamp to order events - }, - }, - ...conference, - }; - - const result = await this.googleApiService.updateCalenderEvent(client, eventId, updatedEvent); - const people = await this.authService.getPeopleResources(client); - - let updatedAttendees: IPeopleInformation[] = []; - - for (const attendee of result.attendees) { - if (!attendee.email.endsWith('resource.calendar.google.com') && attendee.email !== event.organizer.email) { - const person = people.find((person) => person.email === attendee.email); - updatedAttendees.push({ ...attendee, ...(person || {}) }); - } - } - - this.logger.log('[UpdateEvent] Room has been updated' + JSON.stringify(result)); - - const eventResponse: EventResponse = { - eventId: updatedEvent.id, - summary: updatedEvent.summary, - meet: result.hangoutLink, - start: updatedEvent.start.dateTime, - end: updatedEvent.end.dateTime, - room: pickedRoom.name, - roomEmail: pickedRoom.email, - roomId: pickedRoom.id, - seats: pickedRoom.seats, - attendees: updatedAttendees, - isEditable: true, - floor: pickedRoom.floor, - responseStatus, - }; - - return eventResponse; - } - - async deleteEvent(client: OAuth2Client, id: string): Promise { - await this.googleApiService.deleteEvent(client, id); - - const data: DeleteResponse = { - deleted: true, - }; - - return data; - } - - async updateEventResponse(client: OAuth2Client, userEmail: string, eventId: string, responseStatus: string): Promise { - const event = await this.googleApiService.getCalenderEvent(client, eventId); - - if (!event.attendees.some(({ email }) => email === userEmail)) { - throw new ForbiddenException('Not authorized to respond to this event'); - } - - for (const attendee of event.attendees) { - if (attendee.email === userEmail && !attendee.email.endsWith('resource.calendar.google.com') && attendee.email !== event.organizer.email) { - attendee.responseStatus = responseStatus; - break; - } - } - - const eventPayload: calendar_v3.Schema$Event = { - ...event, - attendees: event.attendees, - colorId: '3', - }; - - const result = await this.googleApiService.updateCalenderEvent(client, eventId, eventPayload); - const updatedAttendees = result.attendees.filter( - (attendee) => !attendee.email.endsWith('resource.calendar.google.com') && attendee.email !== event.organizer.email, - ); - - const eventResponse: EventResponse = { - eventId: result.id, - attendees: updatedAttendees, - responseStatus, - }; - - return eventResponse; - } - - async listFloors(client: OAuth2Client): Promise { - const floors = await this.authService.getFloors(client); - return floors; - } - - async searchPeople(client: OAuth2Client, emailQuery: string): Promise { - const people = await this.authService.getPeopleResources(client); - const searchedPeople = people.filter((person) => person.email?.toLowerCase().includes(emailQuery.toLowerCase())); - - return searchedPeople; - } -} +import { BadRequestException, ConflictException, ForbiddenException, Inject, Injectable, NotFoundException, Logger } from '@nestjs/common'; + +import { calendar_v3 } from 'googleapis'; + +import { extractRoomByEmail, isRoomAvailable, validateEmail } from './util/calender.util'; + +import { DeleteResponse, EventResponse, EventUpdateResponse, IConferenceRoom, IPeopleInformation, IAvailableRooms } from '@quickmeet/shared'; + +import { OAuth2Client } from 'google-auth-library'; + +import { GoogleApiService } from 'src/google-api/google-api.service'; + +import { AuthService } from '../auth/auth.service'; + + + +@Injectable() + +export class CalenderService { + +constructor( + +private authService: AuthService, + +@Inject('GoogleApiService') private readonly googleApiService: GoogleApiService, + +private logger: Logger, + +) {} + + + +async createEvent( + +client: OAuth2Client, + +startTime: string, + +endTime: string, + +room: string, + +organizerEmail: string, + +createConference?: boolean, + +eventTitle?: string, + +attendees?: IPeopleInformation[], + +): Promise { + +const rooms = await this.authService.getDirectoryResources(client); + + + +const filteredAttendees: IPeopleInformation[] = []; + +if (attendees?.length) { + +for (const attendee of attendees) { + +if (validateEmail(attendee.email)) { + +filteredAttendees.push({ email: attendee.email, photo: attendee.photo.length > 1024 ? '' : attendee.photo, name: attendee.name }); + +} else { + +throw new BadRequestException('Invalid attendee email provided: ' + attendee); + +} + +} + +} + + + +let conference = {}; + +if (createConference) { + +conference = { + +conferenceData: { + +createRequest: { + +requestId: Math.random().toString(36).substring(7), + +conferenceSolutionKey: { + +type: 'hangoutsMeet', + +}, + +}, + +}, + +}; + +} + + + +const pickedRoom = extractRoomByEmail(rooms, room); + +if (!pickedRoom) { + +throw new NotFoundException('Incorrect room picked!'); + +} + + + +const isAvailable = await this.isRoomAvailable(client, startTime, endTime, pickedRoom.email); + +if (!isAvailable) { + +throw new ConflictException('Room has already been booked.'); + +} + + + +const event: calendar_v3.Schema$Event = { + +summary: eventTitle?.trim() || '-', + +location: pickedRoom.name, + +description: 'A quick meeting created by Quick Meet', + +start: { + +dateTime: startTime, + +}, + +end: { + +dateTime: endTime, + +}, + +attendees: [...filteredAttendees, { email: pickedRoom.email }, { email: organizerEmail, organizer: true, responseStatus: 'accepted' }], + +colorId: '3', + +// https://developers.google.com/calendar/api/guides/extended-properties + +extendedProperties: { + +private: { + +createdAt: new Date().toISOString(), // Adding custom createdAt timestamp + +}, + +}, + +...conference, + +}; + + + +const createdEvent = await this.googleApiService.createCalenderEvent(client, event); + + + +this.logger.log('[CreateEvent] Room has been booked' + JSON.stringify(createdEvent)); + + + +const data: EventResponse = { + +eventId: createdEvent.id, + +summary: createdEvent.summary, + +meet: createdEvent.hangoutLink, + +start: createdEvent.start.dateTime, + +end: createdEvent.end.dateTime, + +room: pickedRoom.name, + +roomEmail: pickedRoom.email, + +roomId: pickedRoom.id, + +seats: pickedRoom.seats, + +isEditable: true, + +floor: pickedRoom.floor, + +}; + + + +return data; + +} + + + +async getHighestSeatCapacity(client: OAuth2Client): Promise { + +const rooms = await this.authService.getDirectoryResources(client); + +let max = -1; + +for (const room of rooms) { + +if (room.seats > max) { + +max = room.seats; + +} + +} + + + +return max; + +} + + + +async getAvailableRooms( + +client: OAuth2Client, + +start: string, + +end: string, + +timeZone: string, + +minSeats: number, + +floor?: string, + +eventId?: string, + +): Promise { + +const filteredRoomEmails: string[] = []; + +const otherRoomEmails: string[] = []; + +const isPreferredRoom: Record = {}; + + + +const rooms = await this.authService.getDirectoryResources(client); + +for (const room of rooms) { + +if (room.seats >= Number(minSeats)) { + +if (floor === undefined || floor === '' || room.floor === floor) { + +filteredRoomEmails.push(room.email); + +isPreferredRoom[room.email] = true; + +} else { + +otherRoomEmails.push(room.email); + +isPreferredRoom[room.email] = false; + +} + +} + +} + + + +if (filteredRoomEmails.length === 0 && otherRoomEmails.length === 0) { + +return { preferred: [], others: [] }; + +} + + + +const calenders = await this.googleApiService.getCalenderSchedule(client, start, end, timeZone, [...filteredRoomEmails, ...otherRoomEmails]); + + + +const availableRooms: IAvailableRooms = { others: [], preferred: [] }; + +let room: IConferenceRoom = null; + + + +for (const roomEmail of Object.keys(calenders)) { + +const isAvailable = isRoomAvailable(calenders[roomEmail].busy, new Date(start), new Date(end)); + +if (isAvailable) { + +room = rooms.find((room) => room.email === roomEmail); + +if (isPreferredRoom[room.email]) { + +availableRooms.preferred.push(room); + +} else { + +availableRooms.others.push(room); + +} + +} + +} + + + +if (eventId) { + +const event = await this.googleApiService.getCalenderEvent(client, eventId); + +const roomEmail = (event.attendees || []).find((attendee) => attendee.resource && attendee.responseStatus !== 'declined'); + + + +if (roomEmail) { + +const currentStartTime = new Date(event.start.dateTime).getTime(); + +const currentEndTime = new Date(event.end.dateTime).getTime(); + + + +const requestStart = new Date(start).getTime(); + +const requestEnd = new Date(end).getTime(); + + + +const currentRoom = extractRoomByEmail(rooms, roomEmail.email); + + + +const { timeZone } = event.start; + +const originalEventDate = new Date(event.start.dateTime).toDateString(); + +const newEventDate = new Date(start).toDateString(); + + + +let isEventRoomAvailable = true; + + + +if (originalEventDate !== newEventDate) { + +const isAvailable = await this.isRoomAvailable(client, start, end, roomEmail.email, timeZone); + +if (!isAvailable) { + +isEventRoomAvailable = false; + +} + +} else { + +if (requestStart < currentStartTime) { + +const isAvailable = await this.isRoomAvailable(client, start, event.start.dateTime, roomEmail.email, timeZone); + +if (!isAvailable) { + +isEventRoomAvailable = false; + +} + +} + + + +if (requestEnd > currentEndTime) { + +const isAvailable = await this.isRoomAvailable(client, event.end.dateTime, end, roomEmail.email, timeZone); + +if (!isAvailable) { + +isEventRoomAvailable = false; + +} + +} + +} + + + +if (isEventRoomAvailable) { + +if (isPreferredRoom[currentRoom.email]) { + +availableRooms.preferred.unshift(currentRoom); + +} else { + +availableRooms.others.unshift(currentRoom); + +} + +} + +} + +} + + + +return availableRooms; + +} + + + +async isRoomAvailable(client: OAuth2Client, start: string, end: string, roomEmail: string, timeZone?: string): Promise { + +const calenders = await this.googleApiService.getCalenderSchedule(client, start, end, timeZone, [roomEmail]); + + + +const availableRooms: IConferenceRoom[] = []; + +const room: IConferenceRoom = null; + + + +for (const roomEmail of Object.keys(calenders)) { + +const isAvailable = isRoomAvailable(calenders[roomEmail].busy, new Date(start), new Date(end)); + +if (isAvailable) { + +availableRooms.push(room); + +} + +} + + + +if (availableRooms.length === 0) { + +return false; + +} + + + +return true; + +} + + + +async getEvents(client: OAuth2Client, startTime: string, endTime: string, timeZone: string, userEmail: string): Promise { + +const rooms = await this.authService.getDirectoryResources(client); + +const events = await this.googleApiService.getCalenderEvents(client, startTime, endTime, timeZone); + +const people = await this.authService.getPeopleResources(client); + + + +const formattedEvents = []; + + + +for (const event of events) { + +let room: IConferenceRoom | null = null; + +let responseStatus: string; + + + +const attendees: IPeopleInformation[] = []; + + + +if (event.attendees) { + +for (const attendee of event.attendees) { + +// current user's response status + +if (userEmail === attendee.email) { + +responseStatus = attendee.responseStatus; + +} + + + +if (!attendee.resource && !attendee.organizer) { + +let person = people.find((person: IPeopleInformation) => person.email === attendee.email); + + + +if (!person) { + +const emailParts = attendee.email.split('@'); + +person = { email: attendee.email, name: emailParts[0], photo: '' }; + +} + + + +attendees.push(person); + +} else if (attendee.resource) { + +room = rooms.find((_room) => _room.email === attendee.email); + +} + +} + +} + + + +if (!room && event.location) { + +// event location must be an external link + +event.hangoutLink = event.location; + +} + + + +const _event: EventResponse = { + +meet: event.hangoutLink, + +room: room?.name, + +roomEmail: room?.email, + +eventId: event.id, + +seats: room?.seats, + +floor: room?.floor, + +summary: event.summary, + +start: event.start.dateTime, + +attendees: attendees, + +end: event.end.dateTime, + +createdAt: event.extendedProperties?.private?.createdAt ? new Date(event.extendedProperties.private.createdAt).getTime() : Date.now(), + +isEditable: event.organizer.email === userEmail, + +responseStatus: responseStatus, + +}; + + + +formattedEvents.push(_event); + +} + + + +const sortedEvents = formattedEvents.sort((a, b) => { + +const startA = new Date(a.start).getTime(); + +const startB = new Date(b.start).getTime(); + +if (startA !== startB) { + +return startA - startB; + +} + +const createdAtA = new Date(a.createdAt).getTime(); + +const createdAtB = new Date(b.createdAt).getTime(); + +const timestamps = [createdAtA, createdAtB]; + +const firstCreated = Math.min(...timestamps); + +return firstCreated === createdAtA ? 1 : -1; + +}); + + + +return sortedEvents; + +} + + + +async updateEvent( + +client: OAuth2Client, + +eventId: string, + +startTime: string, + +endTime: string, + +userEmail: string, + +room?: string, + +createConference?: boolean, + +eventTitle?: string, + +attendees?: IPeopleInformation[], + +): Promise { + +const event = await this.googleApiService.getCalenderEvent(client, eventId); + +const rooms = await this.authService.getDirectoryResources(client); + + + +if (event.organizer.email !== userEmail) { + +throw new ForbiddenException('Not allowed to update this event'); + +} + + + +const pickedRoom = extractRoomByEmail(rooms, room); + +if (!pickedRoom) { + +throw new NotFoundException('Incorrect room picked!'); + +} + + + +// check if event room is available + +if (event.attendees?.some((attendee) => attendee.email === room)) { + +const currentStartTime = new Date(event.start.dateTime).getTime(); + +const currentEndTime = new Date(event.end.dateTime).getTime(); + + + +const newStartTime = new Date(startTime).getTime(); + +const newEndTime = new Date(endTime).getTime(); + + + +const { timeZone } = event.start; + +const originalEventDate = new Date(event.start.dateTime).toDateString(); + +const newEventDate = new Date(startTime).toDateString(); + + + +if (originalEventDate !== newEventDate) { + +const isAvailable = await this.isRoomAvailable(client, startTime, endTime, pickedRoom.email, timeZone); + +if (!isAvailable) { + +throw new ConflictException('Room is not available within the set duration'); + +} + +} else { + +if (newStartTime < currentStartTime) { + +const isAvailable = await this.isRoomAvailable(client, startTime, event.start.dateTime, room, timeZone); + +if (!isAvailable) { + +throw new ConflictException('Room is not available within the set duration'); + +} + +} + + + +if (newEndTime > currentEndTime) { + +const isAvailable = await this.isRoomAvailable(client, event.end.dateTime, endTime, room, timeZone); + +if (!isAvailable) { + +throw new ConflictException('Room is not available within the set duration'); + +} + +} + +} + +} + + + +attendees.push({ email: event.organizer.email }); + + + +const filteredAttendees: IPeopleInformation[] = []; + +let responseStatus: string; + + + +if (attendees?.length) { + +for (const attendee of attendees) { + +if (validateEmail(attendee.email)) { + +const existingAttendee = event.attendees?.find((a) => a.email === attendee.email); + +if (existingAttendee) { + +filteredAttendees.push({ ...attendee, ...existingAttendee }); + +if (userEmail === existingAttendee.email) { + +responseStatus = existingAttendee.responseStatus; + +} + +} else { + +filteredAttendees.push({ email: attendee.email }); + +} + +} else { + +throw new BadRequestException('Invalid attendee email provided: ' + attendee); + +} + +} + +} + + + +let conference = {}; + +if (createConference) { + +conference = { + +conferenceData: { + +createRequest: { + +requestId: Math.random().toString(36).substring(7), + +conferenceSolutionKey: { + +type: 'hangoutsMeet', + +}, + +}, + +}, + +}; + +} else { + +conference = { + +conferenceData: null, + +}; + +} + + + +const updatedEvent: calendar_v3.Schema$Event = { + +...event, + +summary: eventTitle?.trim() || '-', + +location: pickedRoom.name, + +description: 'A quick meeting created by Quick Meet', + +start: { + +dateTime: startTime, + +}, + +end: { + +dateTime: endTime, + +}, + +attendees: [...filteredAttendees, { email: pickedRoom.email }], + +colorId: '3', + +extendedProperties: { + +private: { + +createdAt: new Date().toISOString(), // Adding custom createdAt timestamp to order events + +}, + +}, + +...conference, + +}; + + + +const result = await this.googleApiService.updateCalenderEvent(client, eventId, updatedEvent); + +const people = await this.authService.getPeopleResources(client); + + + +let updatedAttendees: IPeopleInformation[] = []; + + + +for (const attendee of result.attendees) { + +if (!attendee.email.endsWith('resource.calendar.google.com') && attendee.email !== event.organizer.email) { + +const person = people.find((person) => person.email === attendee.email); + +updatedAttendees.push({ ...attendee, ...(person || {}) }); + +} + +} + + + +this.logger.log('[UpdateEvent] Room has been updated' + JSON.stringify(result)); + + + +const eventResponse: EventResponse = { + +eventId: updatedEvent.id, + +summary: updatedEvent.summary, + +meet: result.hangoutLink, + +start: updatedEvent.start.dateTime, + +end: updatedEvent.end.dateTime, + +room: pickedRoom.name, + +roomEmail: pickedRoom.email, + +roomId: pickedRoom.id, + +seats: pickedRoom.seats, + +attendees: updatedAttendees, + +isEditable: true, + +floor: pickedRoom.floor, + +responseStatus, + +}; + + + +return eventResponse; + +} + + + +async deleteEvent(client: OAuth2Client, id: string): Promise { + +await this.googleApiService.deleteEvent(client, id); + + + +const data: DeleteResponse = { + +deleted: true, + +}; + + + +return data; + +} + + + +async updateEventResponse(client: OAuth2Client, userEmail: string, eventId: string, responseStatus: string): Promise { + +const event = await this.googleApiService.getCalenderEvent(client, eventId); + + + +if (!event.attendees.some(({ email }) => email === userEmail)) { + +throw new ForbiddenException('Not authorized to respond to this event'); + +} + + + +for (const attendee of event.attendees) { + +if (attendee.email === userEmail && !attendee.email.endsWith('resource.calendar.google.com') && attendee.email !== event.organizer.email) { + +attendee.responseStatus = responseStatus; + +break; + +} + +} + + + +const eventPayload: calendar_v3.Schema$Event = { + +...event, + +attendees: event.attendees, + +colorId: '3', + +}; + + + +const result = await this.googleApiService.updateCalenderEvent(client, eventId, eventPayload); + +const updatedAttendees = result.attendees.filter( + +(attendee) => !attendee.email.endsWith('resource.calendar.google.com') && attendee.email !== event.organizer.email, + +); + + + +const eventResponse: EventResponse = { + +eventId: result.id, + +attendees: updatedAttendees, + +responseStatus, + +}; + + + +return eventResponse; + +} + + + +async listFloors(client: OAuth2Client): Promise { + +const floors = await this.authService.getFloors(client); + +return floors; + +} + + + +async searchPeople(client: OAuth2Client, emailQuery: string): Promise { + +const people = await this.authService.getPeopleResources(client); + +const searchedPeople = people.filter((person) => person.email?.toLowerCase().includes(emailQuery.toLowerCase())); + + + +return searchedPeople; + +} + + + +async endEvent(client: OAuth2Client, eventId: string): Promise { + +const event = await this.googleApiService.getCalenderEvent(client, eventId); + + + +if (!event) { + +throw new NotFoundException('Event not found'); + +} + + + +const currentTime = new Date().toISOString(); + +const startTime = new Date(event.start.dateTime); + +const now = new Date(); + + + +if (now < startTime) { + +throw new BadRequestException('Cannot end a meeting that has not started yet'); + +} + + + +if (now > new Date(event.end.dateTime)) { + +throw new BadRequestException('Meeting has already ended'); + +} + + + +const updatedEvent: calendar_v3.Schema$Event = { + +...event, + +end: { + +dateTime: currentTime, + +timeZone: event.end.timeZone, + +}, + +}; + + + +const result = await this.googleApiService.updateCalenderEvent(client, eventId, updatedEvent); + + + +this.logger.log('[EndEvent] Meeting has been ended: ' + JSON.stringify(result)); + + + +const rooms = await this.authService.getDirectoryResources(client); + +let room: IConferenceRoom | null = null; + + + +if (event.attendees) { + +for (const attendee of event.attendees) { + +if (attendee.resource) { + +room = rooms.find((_room) => _room.email === attendee.email); + +break; + +} + +} + +} + + + +const eventResponse: EventResponse = { + +eventId: result.id, + +summary: result.summary, + +meet: result.hangoutLink, + +start: result.start.dateTime, + +end: result.end.dateTime, + +room: room?.name, + +roomEmail: room?.email, + +roomId: room?.id, + +seats: room?.seats, + +floor: room?.floor, + +isEditable: true, + +}; + + + +return eventResponse; + +} + +} \ No newline at end of file From ff883ee3c62d4aeb6a8b9a311cdce909a2218fe5 Mon Sep 17 00:00:00 2001 From: Ishaan Garg <168962484+Ishaang19@users.noreply.github.com> Date: Wed, 15 Oct 2025 21:30:19 +0530 Subject: [PATCH 6/6] Issue #1 [BUG] in-memory events data is being reset after creating a new event fix #1 --- server/src/google-api/mock.database.ts | 987 +++++++++++++++++-------- 1 file changed, 664 insertions(+), 323 deletions(-) diff --git a/server/src/google-api/mock.database.ts b/server/src/google-api/mock.database.ts index adacaaf..94525f3 100644 --- a/server/src/google-api/mock.database.ts +++ b/server/src/google-api/mock.database.ts @@ -1,326 +1,667 @@ -import { admin_directory_v1, calendar_v3, people_v1 } from 'googleapis'; -import { toMs } from '../helpers/helper.util'; -import { Cache } from 'cache-manager'; -import { Inject } from '@nestjs/common'; -import { CACHE_MANAGER } from '@nestjs/cache-manager'; - -export class CalenderMockDb { - rooms: admin_directory_v1.Schema$CalendarResource[]; - people: people_v1.Schema$Person[]; - - constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) { - this.seedData(); - } - - seedData() { - this.rooms = [ - { - resourceId: 'room102', - resourceName: 'Cedar', - resourceEmail: 'cedar.room@resource.calendar.google.com', - userVisibleDescription: 'A cozy room with wooden accents and a large display screen.', - floorName: 'F1', - capacity: 8, - }, - { - resourceId: 'room112', - resourceName: 'Aurora', - resourceEmail: 'aurora.room@resource.calendar.google.com', - userVisibleDescription: 'A high-tech room with smart lighting and advanced video conferencing equipment.', - floorName: 'F1', - capacity: 12, - }, - { - resourceId: 'room203', - resourceName: 'Oasis', - resourceEmail: 'oasis.room@resource.calendar.google.com', - userVisibleDescription: 'A relaxing room with plants and a calming atmosphere, ideal for creative sessions.', - floorName: 'F2', - capacity: 7, - }, - { - resourceId: 'room306', - resourceName: 'Summit', - resourceEmail: 'summit.room@resource.calendar.google.com', - userVisibleDescription: 'An executive boardroom with premium furnishings and a city skyline view.', - floorName: 'F3', - capacity: 18, - }, - { - resourceId: 'room207', - resourceName: 'Cascade', - resourceEmail: 'cascade.room@@resource.calendar.google.com', - userVisibleDescription: 'A quiet room with comfortable seating and a large whiteboard for brainstorming.', - floorName: 'F2', - capacity: 6, - }, - { - resourceId: 'room307', - resourceName: 'Zen Conference', - resourceEmail: 'zen.room@resource.calendar.google.com', - userVisibleDescription: 'A minimalist room with natural lighting and a video wall for presentations.', - floorName: 'F3', - capacity: 10, - }, - { - resourceId: 'room108', - resourceName: 'Galaxy', - resourceEmail: 'galaxy.room@resource.calendar.google.com', - userVisibleDescription: 'A futuristic room with interactive displays and advanced connectivity options.', - floorName: 'F1', - capacity: 12, - }, - { - resourceId: 'room401', - resourceName: 'Nebula Boardroom', - resourceEmail: 'nebula.room@resource.calendar.google.com', - userVisibleDescription: 'A top-floor boardroom with a stunning view and high-end presentation tools.', - floorName: 'F4', - capacity: 20, - }, - ]; - - this.people = [ - { - names: [ - { - metadata: { - primary: true, - }, - displayName: 'Example User', - }, - ], - photos: [ - { - metadata: { - primary: true, - }, - url: 'https://example.com/photo.jpg', - }, - ], - - emailAddresses: [ - { - metadata: { - primary: true, - verified: true, - }, - value: 'example@org.com', - }, - ], - }, - { - names: [ - { - metadata: { - primary: true, - }, - displayName: 'John Doe', - }, - ], - photos: [ - { - metadata: { - primary: true, - }, - url: 'https://example.com/photo.jpg', - }, - ], - emailAddresses: [ - { - metadata: { - primary: true, - verified: true, - }, - value: 'john.doe@org.com', - }, - ], - }, - { - names: [ - { - metadata: { - primary: true, - }, - displayName: 'Jane Doe', - }, - ], - photos: [ - { - metadata: { - primary: true, - }, - url: 'https://example.com/photo.jpg', - }, - ], - emailAddresses: [ - { - metadata: { - primary: true, - verified: true, - }, - value: 'jane.doe@org.com', - }, - ], - }, - { - names: [ - { - metadata: { - primary: true, - }, - displayName: 'Sam Lee', - }, - ], - photos: [ - { - metadata: { - primary: true, - }, - url: 'https://example.com/photo.jpg', - }, - ], - emailAddresses: [ - { - metadata: { - primary: true, - verified: false, - }, - value: 'unverified@org.com', - }, - ], - }, - { - names: [ - { - metadata: { - primary: true, - }, - displayName: 'Test User', - }, - ], - photos: [ - { - metadata: { - primary: true, - }, - url: 'https://example.com/photo.jpg', - }, - ], - emailAddresses: [ - { - metadata: { - primary: true, - verified: true, - }, - value: 'test.user@org.com', - }, - ], - }, - ]; - } - - async saveToCache(key: string, value: unknown, expiry = toMs('15d')): Promise { - await this.cacheManager.set(key, value, expiry); - } - - async getFromCache(key: string, defaultValue?: any): Promise { - return (await this.cacheManager.get(key)) || defaultValue; - } - - listDirectoryPeople(query?: string) { - if (query) { - return this.people.filter((person) => person.emailAddresses?.some((email) => email.value?.toLowerCase().includes(query.toLowerCase()))); - } - - return this.people; - } - - async getRooms() { - return this.rooms; - } - - getRoom(name: string) { - return this.rooms.find((r) => r.resourceName === name); - } - - async createEvent(event: calendar_v3.Schema$Event) { - const randomId = `event-${Date.now()}-${Math.floor(Math.random() * 1000)}`; - - const emailPool = ['john.doe@example.com', 'jane.smith@example.com', 'alice.johnson@example.com', 'bob.williams@example.com', 'charlie.brown@example.com']; - event.organizer = { email: emailPool[Math.floor(Math.random() * emailPool.length)] }; - - const events = await this.getFromCache('events', []); - events.push({ ...event, id: randomId }); - await this.saveToCache('events', events); - - return event; - } - - async getEvent(eventId: string): Promise { - const events: calendar_v3.Schema$Event[] = await this.getFromCache('events', []); - return events.find((event) => event.id === eventId); - } - - async updateEvent(eventId: string, updatedEvent: Partial): Promise { - const events: calendar_v3.Schema$Event[] = await this.getFromCache('events', []); - - const eventIndex = events.findIndex((event) => event.id === eventId); - if (eventIndex !== -1) { - events[eventIndex] = { - ...events[eventIndex], - ...updatedEvent, - }; - - await this.saveToCache('events', events); - return events[eventIndex]; - } - - return undefined; - } - - async deleteEvent(eventId: string): Promise { - const events: calendar_v3.Schema$Event[] = await this.getFromCache('events', []); - const eventIndex = events.findIndex((event) => event.id === eventId); - - if (eventIndex !== -1) { - events.splice(eventIndex, 1); - await this.saveToCache('events', events); - - return true; - } - - return false; - } - - async listEvents(start?: string, end?: string, limit?: number): Promise { - const events: calendar_v3.Schema$Event[] = await this.getFromCache('events', []); - - const startTime = start ? new Date(start) : null; - const endTime = end ? new Date(end) : null; - - const OFFSET_MS = 15 * 60 * 1000; - - const filteredEvents = events.filter((event) => { - if (!event.start?.dateTime || !event.end?.dateTime) return false; - - const eventStart = new Date(event.start.dateTime); - const eventEnd = new Date(event.end.dateTime); - - const adjustedStart = new Date(eventStart.getTime() - OFFSET_MS); - const adjustedEnd = new Date(eventEnd.getTime() + OFFSET_MS); +import { admin_directory_v1, calendar_v3, people_v1 } from 'googleapis'; - return (!startTime || adjustedEnd >= startTime) && (!endTime || adjustedStart <= endTime); - }); +import { toMs } from '../helpers/helper.util'; - for (const event of filteredEvents) { - for (const attendee of event.attendees) { - if (attendee.email.includes('resource.calendar.google.com')) { - attendee.resource = true; - } - } - } +import { Cache } from 'cache-manager'; - return limit ? filteredEvents.slice(0, limit) : filteredEvents; - } -} +import { Inject } from '@nestjs/common'; + +import { CACHE_MANAGER } from '@nestjs/cache-manager'; + + + +export class CalenderMockDb { + +rooms: admin_directory_v1.Schema$CalendarResource[]; + +people: people_v1.Schema$Person[]; + + + +constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) { + +this.seedData(); + +} + + + +seedData() { + +this.rooms = [ + +{ + +resourceId: 'room102', + +resourceName: 'Cedar', + +resourceEmail: 'cedar.room@resource.calendar.google.com', + +userVisibleDescription: 'A cozy room with wooden accents and a large display screen.', + +floorName: 'F1', + +capacity: 8, + +}, + +{ + +resourceId: 'room112', + +resourceName: 'Aurora', + +resourceEmail: 'aurora.room@resource.calendar.google.com', + +userVisibleDescription: 'A high-tech room with smart lighting and advanced video conferencing equipment.', + +floorName: 'F1', + +capacity: 12, + +}, + +{ + +resourceId: 'room203', + +resourceName: 'Oasis', + +resourceEmail: 'oasis.room@resource.calendar.google.com', + +userVisibleDescription: 'A relaxing room with plants and a calming atmosphere, ideal for creative sessions.', + +floorName: 'F2', + +capacity: 7, + +}, + +{ + +resourceId: 'room306', + +resourceName: 'Summit', + +resourceEmail: 'summit.room@resource.calendar.google.com', + +userVisibleDescription: 'An executive boardroom with premium furnishings and a city skyline view.', + +floorName: 'F3', + +capacity: 18, + +}, + +{ + +resourceId: 'room207', + +resourceName: 'Cascade', + +resourceEmail: 'cascade.room@@resource.calendar.google.com', + +userVisibleDescription: 'A quiet room with comfortable seating and a large whiteboard for brainstorming.', + +floorName: 'F2', + +capacity: 6, + +}, + +{ + +resourceId: 'room307', + +resourceName: 'Zen Conference', + +resourceEmail: 'zen.room@resource.calendar.google.com', + +userVisibleDescription: 'A minimalist room with natural lighting and a video wall for presentations.', + +floorName: 'F3', + +capacity: 10, + +}, + +{ + +resourceId: 'room108', + +resourceName: 'Galaxy', + +resourceEmail: 'galaxy.room@resource.calendar.google.com', + +userVisibleDescription: 'A futuristic room with interactive displays and advanced connectivity options.', + +floorName: 'F1', + +capacity: 12, + +}, + +{ + +resourceId: 'room401', + +resourceName: 'Nebula Boardroom', + +resourceEmail: 'nebula.room@resource.calendar.google.com', + +userVisibleDescription: 'A top-floor boardroom with a stunning view and high-end presentation tools.', + +floorName: 'F4', + +capacity: 20, + +}, + +]; + + + +this.people = [ + +{ + +names: [ + +{ + +metadata: { + +primary: true, + +}, + +displayName: 'Example User', + +}, + +], + +photos: [ + +{ + +metadata: { + +primary: true, + +}, + +url: 'https://example.com/photo.jpg', + +}, + +], + + + +emailAddresses: [ + +{ + +metadata: { + +primary: true, + +verified: true, + +}, + +value: 'example@org.com', + +}, + +], + +}, + +{ + +names: [ + +{ + +metadata: { + +primary: true, + +}, + +displayName: 'John Doe', + +}, + +], + +photos: [ + +{ + +metadata: { + +primary: true, + +}, + +url: 'https://example.com/photo.jpg', + +}, + +], + +emailAddresses: [ + +{ + +metadata: { + +primary: true, + +verified: true, + +}, + +value: 'john.doe@org.com', + +}, + +], + +}, + +{ + +names: [ + +{ + +metadata: { + +primary: true, + +}, + +displayName: 'Jane Doe', + +}, + +], + +photos: [ + +{ + +metadata: { + +primary: true, + +}, + +url: 'https://example.com/photo.jpg', + +}, + +], + +emailAddresses: [ + +{ + +metadata: { + +primary: true, + +verified: true, + +}, + +value: 'jane.doe@org.com', + +}, + +], + +}, + +{ + +names: [ + +{ + +metadata: { + +primary: true, + +}, + +displayName: 'Sam Lee', + +}, + +], + +photos: [ + +{ + +metadata: { + +primary: true, + +}, + +url: 'https://example.com/photo.jpg', + +}, + +], + +emailAddresses: [ + +{ + +metadata: { + +primary: true, + +verified: false, + +}, + +value: 'unverified@org.com', + +}, + +], + +}, + +{ + +names: [ + +{ + +metadata: { + +primary: true, + +}, + +displayName: 'Test User', + +}, + +], + +photos: [ + +{ + +metadata: { + +primary: true, + +}, + +url: 'https://example.com/photo.jpg', + +}, + +], + +emailAddresses: [ + +{ + +metadata: { + +primary: true, + +verified: true, + +}, + +value: 'test.user@org.com', + +}, + +], + +}, + +]; + +} + + + +async saveToCache(key: string, value: unknown, expiry = toMs('15d')): Promise { + +await this.cacheManager.set(key, value, expiry); + +} + + + +async getFromCache(key: string, defaultValue?: any): Promise { + +return (await this.cacheManager.get(key)) || defaultValue; + +} + + + +listDirectoryPeople(query?: string) { + +if (query) { + +return this.people.filter((person) => person.emailAddresses?.some((email) => email.value?.toLowerCase().includes(query.toLowerCase()))); + +} + + + +return this.people; + +} + + + +async getRooms() { + +return this.rooms; + +} + + + +getRoom(name: string) { + +return this.rooms.find((r) => r.resourceName === name); + +} + + + +async createEvent(event: calendar_v3.Schema$Event) { + +const randomId = `event-${Date.now()}-${Math.floor(Math.random() * 1000)}`; + + + +// Find the organizer from attendees + +const organizerAttendee = event.attendees?.find((attendee) => attendee.organizer === true); + +if (organizerAttendee) { + +event.organizer = { email: organizerAttendee.email }; + +} else { + +// Fallback to random email if no organizer found in attendees + +const emailPool = ['john.doe@example.com', 'jane.smith@example.com', 'alice.johnson@example.com', 'bob.williams@example.com', 'charlie.brown@example.com']; + +event.organizer = { email: emailPool[Math.floor(Math.random() * emailPool.length)] }; + +} + + + +const newEvent = { ...event, id: randomId }; + +const events = await this.getFromCache('events', []); + +events.push(newEvent); + +await this.saveToCache('events', events); + + + +return newEvent; + +} + + + +async getEvent(eventId: string): Promise { + +const events: calendar_v3.Schema$Event[] = await this.getFromCache('events', []); + +return events.find((event) => event.id === eventId); + +} + + + +async updateEvent(eventId: string, updatedEvent: Partial): Promise { + +const events: calendar_v3.Schema$Event[] = await this.getFromCache('events', []); + + + +const eventIndex = events.findIndex((event) => event.id === eventId); + +if (eventIndex !== -1) { + +events[eventIndex] = { + +...events[eventIndex], + +...updatedEvent, + +}; + + + +await this.saveToCache('events', events); + +return events[eventIndex]; + +} + + + +return undefined; + +} + + + +async deleteEvent(eventId: string): Promise { + +const events: calendar_v3.Schema$Event[] = await this.getFromCache('events', []); + +const eventIndex = events.findIndex((event) => event.id === eventId); + + + +if (eventIndex !== -1) { + +events.splice(eventIndex, 1); + +await this.saveToCache('events', events); + + + +return true; + +} + + + +return false; + +} + + + +async listEvents(start?: string, end?: string, limit?: number): Promise { + +const events: calendar_v3.Schema$Event[] = await this.getFromCache('events', []); + + + +const startTime = start ? new Date(start) : null; + +const endTime = end ? new Date(end) : null; + + + +const OFFSET_MS = 15 * 60 * 1000; + + + +const filteredEvents = events.filter((event) => { + +if (!event.start?.dateTime || !event.end?.dateTime) return false; + + + +const eventStart = new Date(event.start.dateTime); + +const eventEnd = new Date(event.end.dateTime); + + + +const adjustedStart = new Date(eventStart.getTime() - OFFSET_MS); + +const adjustedEnd = new Date(eventEnd.getTime() + OFFSET_MS); + + + +return (!startTime || adjustedEnd >= startTime) && (!endTime || adjustedStart <= endTime); + +}); + + + +for (const event of filteredEvents) { + +for (const attendee of event.attendees) { + +if (attendee.email.includes('resource.calendar.google.com')) { + +attendee.resource = true; + +} + +} + +} + + + +return limit ? filteredEvents.slice(0, limit) : filteredEvents; + +} + +} \ No newline at end of file