diff --git a/backend/src/appointment/routes/account.py b/backend/src/appointment/routes/account.py index edb3a323a..cf7aa76be 100644 --- a/backend/src/appointment/routes/account.py +++ b/backend/src/appointment/routes/account.py @@ -27,7 +27,7 @@ def get_external_connections(subscriber: Subscriber = Depends(get_subscriber)): external_connections = defaultdict(list) if os.getenv('ZOOM_API_ENABLED'): - external_connections['Zoom'] = [] + external_connections['zoom'] = [] for ec in subscriber.external_connections: external_connections[ec.type.name].append( diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs index 5860e69d9..95bc8a8b4 100644 --- a/frontend/.eslintrc.cjs +++ b/frontend/.eslintrc.cjs @@ -22,6 +22,7 @@ module.exports = { rules: { 'import/extensions': ['error', 'ignorePackages', { '': 'never', + ts: 'never', js: 'never', vue: 'off', }], @@ -37,7 +38,7 @@ module.exports = { map: [ ['@', './src'], ], - extensions: ['.js', '.vue'], + extensions: ['.ts', '.js', '.vue'], }, }, }, diff --git a/frontend/src/components/CalendarQalendar.vue b/frontend/src/components/CalendarQalendar.vue index 09f2c71e6..96e8f9c13 100644 --- a/frontend/src/components/CalendarQalendar.vue +++ b/frontend/src/components/CalendarQalendar.vue @@ -174,7 +174,7 @@ const processCalendarColorScheme = (calendarTitle, calendarColor) => { * @param d - DayJS date object * @returns {*} */ -const applyTimezone = (d) => dj.utc(d).tz(dj.tz.guess()); +const applyTimezone = (d) => dj(d).utc().tz(dj.tz.guess()); /** * Generates a list of Qalendar friendly event objects from events and appointments props. diff --git a/frontend/src/composables/dayjs.ts b/frontend/src/composables/dayjs.ts index 346acb495..58bb2687d 100644 --- a/frontend/src/composables/dayjs.ts +++ b/frontend/src/composables/dayjs.ts @@ -39,6 +39,7 @@ export default function useDayJS(app: App, locale: string) { // provide the configured dayjs instance as well es some helper functions // TODO: provide method to live update the dayjs locale app.provide('dayjs', dayjs); + app.provide('tzGuess', dayjs.tz.guess()); const hDuration = (m: number): string => ((m < 60) ? dayjs.duration(m, 'minutes').humanize() diff --git a/frontend/src/models.ts b/frontend/src/models.ts new file mode 100644 index 000000000..2c7bc2aed --- /dev/null +++ b/frontend/src/models.ts @@ -0,0 +1,144 @@ +import { Dayjs } from "dayjs"; +import { UseFetchReturn } from '@vueuse/core'; + +export type Attendee = { + id: number; + email: string; + name: string; + timezone: string; +} + +export type Slot = { + id: number; + start: Dayjs; + duration: number; + attendee_id: number; + booking_tkn: string; + booking_expires_at: string; + booking_status: number; + meeting_link_id: number; + meeting_link_url: string; + appointment_id: number; + subscriber_id: number; + time_updated: string; + attendee: Attendee; +} + +export type Appointment = { + id: number; + title: string; + details: string; + slug: string; + location_url: string; + calendar_id: number; + duration: number; + location_type: number; + location_suggestions: string; + location_selected: number; + location_name: string; + location_phone: string; + keep_open: boolean; + status: number; + meeting_link_provider: string; + uuid: string; + time_created: string; + time_updated: string; + slots: Slot[]; + calendar_title: string; + calendar_color: string; + active: boolean; +}; + +export type Calendar = { + id?: number; + connected: boolean; + title: string; + color: string; +}; + +export type ExternalConnection = { + owner_id: number; + name: string; + type: string; + type_id: string; +}; + +export type ExternalConnectionCollection = { + fxa?: ExternalConnection[]; + google?: ExternalConnection[]; + zoom?: ExternalConnection[]; +}; + +// This will be used later if we provide custom availabilities +// in addition to general availability too +export type Availability = { + id?: number; +}; + +export type Schedule = { + active: boolean; + name: string; + slug: string; + calendar_id: number; + location_type: number; + location_url: string; + details: string; + start_date: string; + end_date: string; + start_time: string; + end_time: string; + earliest_booking: number; + farthest_booking: number; + weekdays: number[]; + slot_duration: number; + meeting_link_provider: string; + id: number; + time_created: string; + time_updated: string; + availabilities?: Availability[]; + calendar: Calendar; +}; + +export type User = { + email: string; + preferredEmail: string; + level: number; + name: string; + timezone: string; + username: string; + signedUrl: string; + avatarUrl: string; + accessToken: string; + scheduleSlugs: string[]; +} + +export type Subscriber = { + username: string; + name: string; + email: string; + preferred_email: string; + level: number; + timezone: string; + avatar_url: string; +} + +export type Signature = { + url: string; +} + +// Types and aliases used for our custom createFetch API calls and return types +export type Fetch = (url: string) => UseFetchReturn & PromiseLike>; +export type BooleanResponse = UseFetchReturn; +export type SignatureResponse = UseFetchReturn; +export type SubscriberResponse = UseFetchReturn; +export type TokenResponse = UseFetchReturn; +export type AppointmentListResponse = UseFetchReturn; +export type CalendarListResponse = UseFetchReturn; +export type ScheduleListResponse = UseFetchReturn; +export type ExternalConnectionCollectionResponse = UseFetchReturn; + +export type Error = { error: boolean|string|null }; +export type Token = { + access_token: string; + token_type: string; +} diff --git a/frontend/src/stores/alert-store.js b/frontend/src/stores/alert-store.ts similarity index 64% rename from frontend/src/stores/alert-store.js rename to frontend/src/stores/alert-store.ts index 12dd785ec..64a87f6b6 100644 --- a/frontend/src/stores/alert-store.js +++ b/frontend/src/stores/alert-store.ts @@ -14,25 +14,24 @@ export const useSiteNotificationStore = defineStore('siteNotification', () => { /** * Check a given id if it is currently locked - * @param {string} checkId notification id to check agains current id - * @returns {boolean} + * @param checkId notification id to check agains current id */ - const isSame = (checkId) => id.value === checkId; + const isSame = (checkId: string) => id.value === checkId; /** * Lock the given id - * @param {string} lockId notification id to lock in + * @param lockId notification id to lock in */ - const lock = (lockId) => { id.value = lockId; }; + const lock = (lockId: string) => { id.value = lockId; }; /** * Make a notification with given configuration appear - * @param {string} showId notification identifier - * @param {string} showTitle notification title - * @param {string} showMessage notification message - * @param {string} showActionUrl target url if notification should be a link + * @param showId notification identifier + * @param showTitle notification title + * @param showMessage notification message + * @param showActionUrl target url if notification should be a link */ - const show = (showId, showTitle, showMessage, showActionUrl) => { + const show = (showId: string, showTitle: string, showMessage: string, showActionUrl: string) => { isVisible.value = true; id.value = showId; title.value = showTitle; diff --git a/frontend/src/stores/appointment-store.js b/frontend/src/stores/appointment-store.ts similarity index 65% rename from frontend/src/stores/appointment-store.js rename to frontend/src/stores/appointment-store.ts index d97534ca2..c60c455cc 100644 --- a/frontend/src/stores/appointment-store.js +++ b/frontend/src/stores/appointment-store.ts @@ -1,19 +1,22 @@ +import { Dayjs, ConfigType } from 'dayjs'; import { defineStore } from 'pinia'; import { ref, computed, inject } from 'vue'; import { bookingStatus } from '@/definitions'; import { useUserStore } from '@/stores/user-store'; +import { Appointment, AppointmentListResponse, Fetch, Slot } from '@/models'; // eslint-disable-next-line import/prefer-default-export export const useAppointmentStore = defineStore('appointments', () => { - const dj = inject('dayjs'); + const dj = inject<(date?: ConfigType) => Dayjs>('dayjs'); + const tzGuess = inject('tzGuess'); // State const isLoaded = ref(false); // Data - const appointments = ref([]); + const appointments = ref([]); const pendingAppointments = computed( - () => appointments.value.filter((a) => a?.slots[0]?.booking_status === bookingStatus.requested), + (): Appointment[] => appointments.value.filter((a) => a?.slots[0]?.booking_status === bookingStatus.requested), ); /** @@ -25,18 +28,18 @@ export const useAppointmentStore = defineStore('appointments', () => { appointments.value.forEach((a) => { a.active = a.status !== bookingStatus.booked; // convert start dates from UTC back to users timezone - a.slots.forEach((s) => { - s.start = dj.utc(s.start).tz(userStore.data.timezone ?? dj.tz.guess()); + a.slots.forEach((s: Slot) => { + s.start = dj(s.start).utc().tz(userStore.data.timezone ?? tzGuess); }); }); }; /** * Get all appointments for current user - * @param {function} call preconfigured API fetch function + * @param call preconfigured API fetch function */ - const fetch = async (call) => { - const { data, error } = await call('me/appointments').get().json(); + const fetch = async (call: Fetch) => { + const { data, error }: AppointmentListResponse = await call('me/appointments').get().json(); if (!error.value) { if (data.value === null || typeof data.value === 'undefined') return; appointments.value = data.value; diff --git a/frontend/src/stores/booking-modal-store.js b/frontend/src/stores/booking-modal-store.ts similarity index 96% rename from frontend/src/stores/booking-modal-store.js rename to frontend/src/stores/booking-modal-store.ts index 4ad4cb638..3e7b58e2b 100644 --- a/frontend/src/stores/booking-modal-store.js +++ b/frontend/src/stores/booking-modal-store.ts @@ -6,7 +6,7 @@ import { modalStates } from '@/definitions'; export const useBookingModalStore = defineStore('bookingModal', () => { const open = ref(false); const state = ref(modalStates.open); - const stateData = ref(null); + const stateData = ref(null); const isLoading = computed(() => state.value === modalStates.loading); const isFinished = computed(() => state.value === modalStates.finished); diff --git a/frontend/src/stores/booking-view-store.js b/frontend/src/stores/booking-view-store.ts similarity index 75% rename from frontend/src/stores/booking-view-store.js rename to frontend/src/stores/booking-view-store.ts index 33d395750..d1ad51854 100644 --- a/frontend/src/stores/booking-view-store.js +++ b/frontend/src/stores/booking-view-store.ts @@ -1,21 +1,23 @@ +import { Dayjs, ConfigType } from 'dayjs'; import { defineStore } from 'pinia'; import { ref, inject } from 'vue'; import { bookingCalendarViews } from '@/definitions'; +import { Appointment, Attendee } from '@/models'; /** * Store for BookingView and its tightly coupled components. */ // eslint-disable-next-line import/prefer-default-export export const useBookingViewStore = defineStore('bookingView', () => { - const dj = inject('dayjs'); + const dj = inject<(date?: ConfigType) => Dayjs>('dayjs'); // States const activeView = ref(bookingCalendarViews.loading); const activeDate = ref(dj()); // Data - const selectedEvent = ref(null); - const appointment = ref(null); - const attendee = ref(null); + const selectedEvent = ref(null); + const appointment = ref(null); + const attendee = ref(null); /** * Restore default state, set date to today and remove other data diff --git a/frontend/src/stores/calendar-store.js b/frontend/src/stores/calendar-store.ts similarity index 62% rename from frontend/src/stores/calendar-store.js rename to frontend/src/stores/calendar-store.ts index 92882ca72..4516d5252 100644 --- a/frontend/src/stores/calendar-store.js +++ b/frontend/src/stores/calendar-store.ts @@ -1,3 +1,4 @@ +import { Calendar, CalendarListResponse, Fetch } from '@/models'; import { defineStore } from 'pinia'; import { ref, computed } from 'vue'; @@ -7,22 +8,22 @@ export const useCalendarStore = defineStore('calendars', () => { const isLoaded = ref(false); // Data - const calendars = ref([]); - const unconnectedCalendars = computed(() => calendars.value.filter((cal) => !cal.connected)); - const connectedCalendars = computed(() => calendars.value.filter((cal) => cal.connected)); + const calendars = ref([]); + const unconnectedCalendars = computed((): Calendar[] => calendars.value.filter((cal) => !cal.connected)); + const connectedCalendars = computed((): Calendar[] => calendars.value.filter((cal) => cal.connected)); const hasConnectedCalendars = computed(() => connectedCalendars.value.length > 0); /** * Get all calendars for current user - * @param {function} call preconfigured API fetch function + * @param call preconfigured API fetch function */ - const fetch = async (call) => { + const fetch = async (call: Fetch) => { if (isLoaded.value) { return; } - const { data, error } = await call('me/calendars?only_connected=false').get().json(); + const { data, error }: CalendarListResponse = await call('me/calendars?only_connected=false').get().json(); if (!error.value) { if (data.value === null || typeof data.value === 'undefined') return; calendars.value = data.value; diff --git a/frontend/src/stores/external-connections-store.js b/frontend/src/stores/external-connections-store.ts similarity index 64% rename from frontend/src/stores/external-connections-store.js rename to frontend/src/stores/external-connections-store.ts index 753409585..df00e1c76 100644 --- a/frontend/src/stores/external-connections-store.js +++ b/frontend/src/stores/external-connections-store.ts @@ -1,3 +1,4 @@ +import { ExternalConnection, ExternalConnectionCollection, Fetch, ExternalConnectionCollectionResponse } from '@/models'; import { defineStore } from 'pinia'; import { ref, computed } from 'vue'; @@ -7,10 +8,10 @@ export const useExternalConnectionsStore = defineStore('externalConnections', () const isLoaded = ref(false); // Data - const zoom = ref([]); - const fxa = ref([]); - const google = ref([]); - const connections = computed(() => ({ + const zoom = ref([]); + const fxa = ref([]); + const google = ref([]); + const connections = computed((): ExternalConnectionCollection => ({ // FXA should be at the top since it represents the Appointment subscriber. fxa: fxa.value, google: google.value, @@ -19,14 +20,14 @@ export const useExternalConnectionsStore = defineStore('externalConnections', () /** * Get all external connections for current user - * @param {function} call preconfigured API fetch function + * @param call preconfigured API fetch function */ - const fetch = async (call) => { + const fetch = async (call: Fetch) => { if (isLoaded.value) { return; } - const { data } = await call('account/external-connections').get().json(); + const { data }: ExternalConnectionCollectionResponse = await call('account/external-connections').get().json(); zoom.value = data.value?.zoom ?? []; fxa.value = data.value?.fxa ?? []; google.value = data.value?.google ?? []; diff --git a/frontend/src/stores/schedule-store.js b/frontend/src/stores/schedule-store.ts similarity index 60% rename from frontend/src/stores/schedule-store.js rename to frontend/src/stores/schedule-store.ts index 2075d2b2a..19af828d6 100644 --- a/frontend/src/stores/schedule-store.js +++ b/frontend/src/stores/schedule-store.ts @@ -1,6 +1,7 @@ import { defineStore } from 'pinia'; import { ref, computed } from 'vue'; import { useUserStore } from '@/stores/user-store'; +import { Fetch, Schedule, ScheduleListResponse } from '@/models'; // eslint-disable-next-line import/prefer-default-export export const useScheduleStore = defineStore('schedules', () => { @@ -8,21 +9,21 @@ export const useScheduleStore = defineStore('schedules', () => { const isLoaded = ref(false); // Data - const schedules = ref([]); - const inactiveSchedules = computed(() => schedules.value.filter((schedule) => !schedule.active)); - const activeSchedules = computed(() => schedules.value.filter((schedule) => schedule.active)); + const schedules = ref([]); + const inactiveSchedules = computed((): Schedule[] => schedules.value.filter((schedule) => !schedule.active)); + const activeSchedules = computed((): Schedule[] => schedules.value.filter((schedule) => schedule.active)); /** * Get all calendars for current user - * @param {function} call preconfigured API fetch function - * @param {boolean} force Force a fetch even if we already have data + * @param call preconfigured API fetch function + * @param force Force a fetch even if we already have data */ - const fetch = async (call, force = false) => { + const fetch = async (call: Fetch, force: boolean = false) => { if (isLoaded.value && !force) { return; } - const { data, error } = await call('schedule/').get().json(); + const { data, error }: ScheduleListResponse = await call('schedule/').get().json(); if (error.value || data.value === null || typeof data.value === 'undefined') { return; diff --git a/frontend/src/stores/user-store.js b/frontend/src/stores/user-store.ts similarity index 58% rename from frontend/src/stores/user-store.js rename to frontend/src/stores/user-store.ts index 6497f2a59..dda936169 100644 --- a/frontend/src/stores/user-store.js +++ b/frontend/src/stores/user-store.ts @@ -2,6 +2,7 @@ import { defineStore } from 'pinia'; import { useLocalStorage } from '@vueuse/core'; import { i18n } from '@/composables/i18n'; import { computed } from 'vue'; +import { Schedule, Subscriber, User, Fetch, Error, BooleanResponse, SignatureResponse, SubscriberResponse, TokenResponse } from '@/models'; const initialUserObject = { email: null, @@ -14,12 +15,12 @@ const initialUserObject = { avatarUrl: null, accessToken: null, scheduleSlugs: [], -}; +} as User; export const useUserStore = defineStore('user', () => { const data = useLocalStorage('tba/user', structuredClone(initialUserObject)); - const myLink = computed(() => { + const myLink = computed((): string => { const scheduleSlug = data.value?.scheduleSlugs?.length > 0 ? data.value?.scheduleSlugs[0] : null; if (scheduleSlug) { return `${import.meta.env.VITE_SHORT_BASE_URL}/${data.value.username}/${scheduleSlug}/`; @@ -32,22 +33,22 @@ export const useUserStore = defineStore('user', () => { data.value = structuredClone(initialUserObject); }; - const updateProfile = (userData) => { + const updateProfile = (subscriber: Subscriber) => { data.value = { // Include the previous values first ...data.value, // Then the new ones! - username: userData.username, - name: userData.name, - email: userData.email, - preferredEmail: userData?.preferred_email ?? userData.email, - level: userData.level, - timezone: userData.timezone, - avatarUrl: userData.avatar_url, + username: subscriber.username, + name: subscriber.name, + email: subscriber.email, + preferredEmail: subscriber?.preferred_email ?? subscriber.email, + level: subscriber.level, + timezone: subscriber.timezone, + avatarUrl: subscriber.avatar_url, }; }; - const updateScheduleUrls = (scheduleData) => { + const updateScheduleUrls = (scheduleData: Schedule[]) => { data.value = { ...data.value, scheduleSlugs: scheduleData.map((schedule) => schedule?.slug), @@ -56,14 +57,13 @@ export const useUserStore = defineStore('user', () => { /** * Retrieve the current signed url and update store - * @param {function} fetch preconfigured API fetch function - * @return {boolean} + * @param call preconfigured API fetch function */ - const updateSignedUrl = async (fetch) => { - const { error, data: sigData } = await fetch('me/signature').get().json(); + const updateSignedUrl = async (call: Fetch): Promise => { + const { error, data: sigData }: SignatureResponse = await call('me/signature').get().json(); if (error.value || !sigData.value?.url) { - return { error: sigData.value?.detail ?? error.value }; + return { error: sigData.value ?? error.value }; } data.value.signedUrl = sigData.value.url; @@ -73,46 +73,43 @@ export const useUserStore = defineStore('user', () => { /** * Update store with profile data from db - * @param {function} fetch preconfigured API fetch function - * @return {boolean} + * @param call preconfigured API fetch function */ - const profile = async (fetch) => { - const { error, data: userData } = await fetch('me').get().json(); + const profile = async (call: Fetch): Promise => { + const { error, data: userData }: SubscriberResponse = await call('me').get().json(); // Failed to get profile data, log this user out and return false if (error.value || !userData.value) { $reset(); - return { error: userData.value?.detail ?? error.value }; + return { error: userData.value ?? error.value }; } updateProfile(userData.value); - return updateSignedUrl(fetch); + return updateSignedUrl(call); }; /** * Invalidate the current signed url and replace it with a new one - * @param {function} fetch preconfigured API fetch function - * @return {boolean} + * @param call preconfigured API fetch function */ - const changeSignedUrl = async (fetch) => { - const { error, data: sigData } = await fetch('me/signature').post().json(); + const changeSignedUrl = async (call: Fetch): Promise => { + const { error, data: sigData }: BooleanResponse = await call('me/signature').post().json(); if (error.value) { - return { error: sigData.value?.detail ?? error.value }; + return { error: sigData.value ?? error.value }; } - return updateSignedUrl(fetch); + return updateSignedUrl(call); }; /** * Request subscriber login - * @param {function} fetch preconfigured API fetch function - * @param {string} username - * @param {string|null} password or null if fxa authentication - * @returns {Promise} true if login was successful + * @param call preconfigured API fetch function + * @param username + * @param password or null if fxa authentication */ - const login = async (fetch, username, password) => { + const login = async (call: Fetch, username: string, password: string|null): Promise => { $reset(); if (import.meta.env.VITE_AUTH_SCHEME === 'password') { @@ -120,10 +117,10 @@ export const useUserStore = defineStore('user', () => { const formData = new FormData(document.createElement('form')); formData.set('username', username); formData.set('password', password); - const { error, data: tokenData } = await fetch('token').post(formData).json(); + const { error, data: tokenData }: TokenResponse = await call('token').post(formData).json(); if (error.value || !tokenData.value.access_token) { - return { error: tokenData.value?.detail ?? error.value }; + return { error: tokenData.value ?? error.value }; } data.value.accessToken = tokenData.value.access_token; @@ -134,15 +131,15 @@ export const useUserStore = defineStore('user', () => { return { error: i18n.t('error.loginMethodNotSupported') }; } - return profile(fetch); + return profile(call); }; /** * Do subscriber logout and reset store - * @param {function} fetch preconfigured API fetch function + * @param call preconfigured API fetch function */ - const logout = async (fetch) => { - const { error } = await fetch('logout').get().json(); + const logout = async (call: Fetch) => { + const { error }: BooleanResponse = await call('logout').get().json(); if (error.value) { // TODO: show error message