From a7eaf0f948ef7c584cbcbe8edcfd1797fd1ee2dc Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Wed, 29 Apr 2026 11:06:46 +0200 Subject: [PATCH] style(app): apply eslint --fix to Tier 2 (TypeScript plumbing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WS-3 session 1b-i Tier 2. Scope: composables, lib, stores, plugins, types, utils, navigation, main.ts. Mechanical fixes only — predominantly newline-before-return, arrow-parens, antfu/if-newline, padding-line-between-statements, plus one unicorn/prefer-includes (.some(p => x === p) → .includes(x)) in router guards. Excludes (per session prompt): - apps/app/vite.config.ts (Tier 3) - apps/app/themeConfig.ts (Tier 3) - apps/app/vitest.config.ts (Tier 3) - All .vue files (already in Tier 1) Hand-reviewed diffs for the three auth/router-critical files before committing: - src/lib/axios.ts: reviewed clean. Pure mechanical (quote-props on Accept header, curly-strip on single-statement ifs, one blank line before impersonationStore.clearState()). No type-import changes, no logic touched. - src/stores/useAuthStore.ts: reviewed clean. curly-strip + padding before returns. The initialize()/doInitialize() race-condition guard on isInitialized is preserved verbatim. - src/plugins/1.router/guards.ts: reviewed clean. if-newline reformat + one .some() → .includes() rewrite that's behaviorally identical for primitive equality on the guestOnlyPaths string array. Tests + typecheck verified green post-fix: - apps/app vitest: 49 passed (unchanged) - apps/app vue-tsc: clean (unchanged) Lint baseline progression: - Pre-Tier-2: 422 problems (post-Tier-1) - Post-Tier-2: 246 problems Co-Authored-By: Claude Opus 4.7 --- apps/app/src/composables/api/useAccount.ts | 4 ++ apps/app/src/composables/api/useAdmin.ts | 9 ++++ apps/app/src/composables/api/useAuth.ts | 9 ++-- apps/app/src/composables/api/useCompanies.ts | 2 +- apps/app/src/composables/api/useCrowdLists.ts | 2 +- apps/app/src/composables/api/useCrowdTypes.ts | 4 +- apps/app/src/composables/api/useEmail.ts | 18 +++++--- apps/app/src/composables/api/useEvents.ts | 9 ++++ apps/app/src/composables/api/useMembers.ts | 6 +++ .../src/composables/api/useOrganisations.ts | 7 ++- apps/app/src/composables/api/usePersonTags.ts | 4 +- .../api/useRegistrationFieldTemplates.ts | 2 +- .../api/useRegistrationFormFields.ts | 9 ++-- apps/app/src/composables/api/useSections.ts | 8 ++-- .../composables/api/useShiftAssignments.ts | 12 +++-- apps/app/src/composables/api/useTimeSlots.ts | 6 ++- .../src/composables/useTimeSlotDropdown.ts | 33 ++++++++------ apps/app/src/lib/apiErrors.ts | 12 +++-- apps/app/src/lib/axios.ts | 24 ++++------ apps/app/src/lib/dutch-plural.ts | 12 +++-- apps/app/src/plugins/1.router/guards.ts | 45 +++++++++++++------ apps/app/src/stores/useAuthStore.ts | 19 +++++--- apps/app/src/stores/useImpersonationStore.ts | 16 +++---- apps/app/src/stores/useShiftDetailStore.ts | 3 +- apps/app/src/types/admin.ts | 4 +- apps/app/src/types/event.ts | 1 + apps/app/src/types/formSchema.ts | 3 ++ apps/app/src/types/section.ts | 3 +- 28 files changed, 189 insertions(+), 97 deletions(-) diff --git a/apps/app/src/composables/api/useAccount.ts b/apps/app/src/composables/api/useAccount.ts index 75cbd3f2..c6b9a336 100644 --- a/apps/app/src/composables/api/useAccount.ts +++ b/apps/app/src/composables/api/useAccount.ts @@ -42,6 +42,7 @@ export function useUpdateProfile() { '/me/profile', payload, ) + return data }, onSuccess: () => { @@ -57,6 +58,7 @@ export function useChangePassword() { '/me/change-password', payload, ) + return data }, }) @@ -69,6 +71,7 @@ export function useChangeEmail() { '/me/change-email', payload, ) + return data }, }) @@ -81,6 +84,7 @@ export function useAdminChangeEmail(orgId: Ref) { `/organisations/${orgId.value}/members/${userId}/change-email`, { new_email: newEmail }, ) + return data }, }) diff --git a/apps/app/src/composables/api/useAdmin.ts b/apps/app/src/composables/api/useAdmin.ts index 5b6a675c..4e2a8235 100644 --- a/apps/app/src/composables/api/useAdmin.ts +++ b/apps/app/src/composables/api/useAdmin.ts @@ -39,6 +39,7 @@ export function useAdminOrganisations(params: Ref) { const { data } = await apiClient.get>( `/admin/organisations/${id.value}`, ) + return data.data }, enabled: () => !!id.value, @@ -66,6 +68,7 @@ export function useUpdateAdminOrganisation() { `/admin/organisations/${id}`, payload, ) + return data.data }, onSuccess: () => { @@ -83,6 +86,7 @@ export function useCreateOrganisation() { '/organisations', payload, ) + return data.data }, onSuccess: () => { @@ -114,6 +118,7 @@ export function useAdminUsers(params: Ref) { const { data } = await apiClient.get>( `/admin/users/${id.value}`, ) + return data.data }, enabled: () => !!id.value, @@ -141,6 +147,7 @@ export function useUpdateAdminUser() { `/admin/users/${id}`, payload, ) + return data.data }, onSuccess: () => { @@ -171,6 +178,7 @@ export function usePlatformStats() { const { data } = await apiClient.get<{ data: PlatformStats }>( '/admin/stats', ) + return data.data }, }) @@ -186,6 +194,7 @@ export function useAdminActivityLog(params: Ref { const { data } = await apiClient.post('/auth/login', credentials) + return data }, - onSuccess: (data) => { + onSuccess: data => { // Token is set automatically via httpOnly Set-Cookie header authStore.setUser(data.data.user) queryClient.setQueryData(['auth', 'me'], data.data.user) @@ -27,12 +28,14 @@ export function useMe() { queryKey: ['auth', 'me'], queryFn: async () => { const { data } = await apiClient.get<{ success: boolean; data: MeResponse }>('/auth/me') + return data.data }, - staleTime: Infinity, + staleTime: Number.POSITIVE_INFINITY, enabled: () => authStore.isAuthenticated, - select: (data) => { + select: data => { authStore.setUser(data) + return data }, }) diff --git a/apps/app/src/composables/api/useCompanies.ts b/apps/app/src/composables/api/useCompanies.ts index e63cf6e3..1a4f64bf 100644 --- a/apps/app/src/composables/api/useCompanies.ts +++ b/apps/app/src/composables/api/useCompanies.ts @@ -20,7 +20,7 @@ export function useCompanies(orgId: Ref) { return data.data }, enabled: () => !!orgId.value, - staleTime: Infinity, + staleTime: Number.POSITIVE_INFINITY, }) } diff --git a/apps/app/src/composables/api/useCrowdLists.ts b/apps/app/src/composables/api/useCrowdLists.ts index 2f07306e..b3aab415 100644 --- a/apps/app/src/composables/api/useCrowdLists.ts +++ b/apps/app/src/composables/api/useCrowdLists.ts @@ -1,7 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' import type { Ref } from 'vue' import { apiClient } from '@/lib/axios' -import type { CrowdList, CreateCrowdListDto, UpdateCrowdListDto } from '@/types/crowdList' +import type { CreateCrowdListDto, CrowdList, UpdateCrowdListDto } from '@/types/crowdList' import type { Person } from '@/types/person' interface ApiResponse { diff --git a/apps/app/src/composables/api/useCrowdTypes.ts b/apps/app/src/composables/api/useCrowdTypes.ts index ea79665b..10193063 100644 --- a/apps/app/src/composables/api/useCrowdTypes.ts +++ b/apps/app/src/composables/api/useCrowdTypes.ts @@ -31,7 +31,7 @@ export function useCrowdTypeList(orgId: Ref) { return data.data }, enabled: () => !!orgId.value, - staleTime: Infinity, + staleTime: Number.POSITIVE_INFINITY, }) } @@ -44,6 +44,7 @@ export function useCreateCrowdType(orgId: Ref) { `/organisations/${orgId.value}/crowd-types`, payload, ) + return data.data }, onSuccess: () => { @@ -61,6 +62,7 @@ export function useUpdateCrowdType(orgId: Ref) { `/organisations/${orgId.value}/crowd-types/${id}`, payload, ) + return data.data }, onSuccess: () => { diff --git a/apps/app/src/composables/api/useEmail.ts b/apps/app/src/composables/api/useEmail.ts index ba24a957..e6c24eb7 100644 --- a/apps/app/src/composables/api/useEmail.ts +++ b/apps/app/src/composables/api/useEmail.ts @@ -145,12 +145,18 @@ export function useEmailLogs(orgId: Ref, filters: Ref) per_page: filters.value.perPage, } - if (filters.value.search) params.search = filters.value.search - if (filters.value.status) params.status = filters.value.status - if (filters.value.templateType) params.template_type = filters.value.templateType - if (filters.value.eventId) params.event_id = filters.value.eventId - if (filters.value.from) params.from = filters.value.from - if (filters.value.to) params.to = filters.value.to + if (filters.value.search) + params.search = filters.value.search + if (filters.value.status) + params.status = filters.value.status + if (filters.value.templateType) + params.template_type = filters.value.templateType + if (filters.value.eventId) + params.event_id = filters.value.eventId + if (filters.value.from) + params.from = filters.value.from + if (filters.value.to) + params.to = filters.value.to const { data } = await apiClient.get>>( `/organisations/${orgId.value}/email-logs`, diff --git a/apps/app/src/composables/api/useEvents.ts b/apps/app/src/composables/api/useEvents.ts index 3f073d3b..49ca7545 100644 --- a/apps/app/src/composables/api/useEvents.ts +++ b/apps/app/src/composables/api/useEvents.ts @@ -34,6 +34,7 @@ export function useEventList(orgId: Ref) { `/organisations/${orgId.value}/events`, { params: { include_children: true } }, ) + return data.data }, enabled: () => !!orgId.value, @@ -47,6 +48,7 @@ export function useEventDetail(orgId: Ref, id: Ref) { const { data } = await apiClient.get>( `/organisations/${orgId.value}/events/${id.value}`, ) + return data.data }, enabled: () => !!orgId.value && !!id.value, @@ -60,6 +62,7 @@ export function useEventChildren(orgId: Ref, eventId: Ref) { const { data } = await apiClient.get>( `/organisations/${orgId.value}/events/${eventId.value}/children`, ) + return data.data }, enabled: () => !!orgId.value && !!eventId.value, @@ -75,6 +78,7 @@ export function useCreateEvent(orgId: Ref) { `/organisations/${orgId.value}/events`, payload, ) + return data.data }, onSuccess: () => { @@ -92,6 +96,7 @@ export function useCreateSubEvent(orgId: Ref, parentEventId: Ref `/organisations/${orgId.value}/events`, payload, ) + return data.data }, onSuccess: () => { @@ -124,6 +129,7 @@ export function useUpdateEvent(orgId: Ref, id: Ref) { `/organisations/${orgId.value}/events/${id.value}`, payload, ) + return data.data }, onSuccess: () => { @@ -142,6 +148,7 @@ export function useTransitionEventStatus(orgId: Ref, eventId: Ref { @@ -158,6 +165,7 @@ export function useUploadEventImage(orgId: Ref, eventId: Ref) { return useMutation({ mutationFn: async ({ file, type }: { file: File; type: 'banner' | 'logo' }) => { const formData = new FormData() + formData.append('image', file) formData.append('type', type) @@ -182,6 +190,7 @@ export function useEventStats(orgId: Ref, eventId: Ref) { const { data } = await apiClient.get<{ data: EventStats }>( `/organisations/${orgId.value}/events/${eventId.value}/stats`, ) + return data.data }, enabled: () => !!orgId.value && !!eventId.value, diff --git a/apps/app/src/composables/api/useMembers.ts b/apps/app/src/composables/api/useMembers.ts index 994c3230..2842ab92 100644 --- a/apps/app/src/composables/api/useMembers.ts +++ b/apps/app/src/composables/api/useMembers.ts @@ -53,6 +53,7 @@ export function useMembersList(scope: MemberScope, orgId: Ref) { const url = endpointList(scope, orgId.value) if (scope === 'platform') { const { data } = await apiClient.get>(url) + return { data: data.data.members, meta: { @@ -63,6 +64,7 @@ export function useMembersList(scope: MemberScope, orgId: Ref) { } satisfies MemberListResponse } const { data } = await apiClient.get(url) + return data }, enabled: () => !!orgId.value, @@ -75,6 +77,7 @@ export function useInviteMember(scope: MemberScope, orgId: Ref) { return useMutation({ mutationFn: async (payload: InviteMemberPayload) => { const { data } = await apiClient.post>(endpointInvite(scope, orgId.value), payload) + return data.data }, onSuccess: () => invalidateScopedKeys(scope, orgId, queryClient), @@ -90,6 +93,7 @@ export function useUpdateMemberRole(scope: MemberScope, orgId: Ref) { endpointMember(scope, orgId.value, userId), { role }, ) + return data.data }, onSuccess: () => invalidateScopedKeys(scope, orgId, queryClient), @@ -125,6 +129,7 @@ export function useInvitationDetail(token: Ref) { queryKey: ['invitation', token], queryFn: async () => { const { data } = await apiClient.get>(`/invitations/${token.value}`) + return data.data }, enabled: () => !!token.value, @@ -136,6 +141,7 @@ export function useAcceptInvitation() { return useMutation({ mutationFn: async ({ token, ...payload }: AcceptInvitationPayload & { token: string }) => { const { data } = await apiClient.post(`/invitations/${token}/accept`, payload) + return data }, }) diff --git a/apps/app/src/composables/api/useOrganisations.ts b/apps/app/src/composables/api/useOrganisations.ts index 0224d561..09ed8ffc 100644 --- a/apps/app/src/composables/api/useOrganisations.ts +++ b/apps/app/src/composables/api/useOrganisations.ts @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' -import { computed, type Ref } from 'vue' +import { type Ref, computed } from 'vue' import { apiClient } from '@/lib/axios' import { useAuthStore } from '@/stores/useAuthStore' import type { @@ -30,6 +30,7 @@ export function useOrganisationList() { queryKey: ['organisations'], queryFn: async () => { const { data } = await apiClient.get>('/organisations') + return data.data }, }) @@ -40,6 +41,7 @@ export function useOrganisationDetail(id: Ref) { queryKey: ['organisations', id], queryFn: async () => { const { data } = await apiClient.get>(`/organisations/${id.value}`) + return data.data }, enabled: () => !!id.value, @@ -54,6 +56,7 @@ export function useMyOrganisation() { queryKey: ['organisations', id], queryFn: async () => { const { data } = await apiClient.get>(`/organisations/${id.value}`) + return data.data }, enabled: () => !!id.value, @@ -66,6 +69,7 @@ export function useUpdateOrganisation() { return useMutation({ mutationFn: async ({ id, ...payload }: UpdateOrganisationPayload & { id: string }) => { const { data } = await apiClient.put>(`/organisations/${id}`, payload) + return data.data }, onSuccess: (_data, variables) => { @@ -81,6 +85,7 @@ export function useOrganisationDashboardStats(id: Ref) { queryKey: ['organisation-dashboard-stats', id], queryFn: async () => { const { data } = await apiClient.get>(`/organisations/${id.value}/dashboard-stats`) + return data.data }, enabled: () => !!id.value, diff --git a/apps/app/src/composables/api/usePersonTags.ts b/apps/app/src/composables/api/usePersonTags.ts index b2811d51..20f31f04 100644 --- a/apps/app/src/composables/api/usePersonTags.ts +++ b/apps/app/src/composables/api/usePersonTags.ts @@ -20,7 +20,7 @@ export function usePersonTags(orgId: Ref) { return data.data }, enabled: () => !!orgId.value, - staleTime: Infinity, + staleTime: Number.POSITIVE_INFINITY, }) } @@ -35,7 +35,7 @@ export function usePersonTagCategories(orgId: Ref) { return data.data }, enabled: () => !!orgId.value, - staleTime: Infinity, + staleTime: Number.POSITIVE_INFINITY, }) } diff --git a/apps/app/src/composables/api/useRegistrationFieldTemplates.ts b/apps/app/src/composables/api/useRegistrationFieldTemplates.ts index feeaa97a..99724c3b 100644 --- a/apps/app/src/composables/api/useRegistrationFieldTemplates.ts +++ b/apps/app/src/composables/api/useRegistrationFieldTemplates.ts @@ -20,7 +20,7 @@ export function useRegistrationFieldTemplates(orgId: Ref) { return data.data }, enabled: () => !!orgId.value, - staleTime: Infinity, + staleTime: Number.POSITIVE_INFINITY, }) } diff --git a/apps/app/src/composables/api/useRegistrationFormFields.ts b/apps/app/src/composables/api/useRegistrationFormFields.ts index 17611351..9545c756 100644 --- a/apps/app/src/composables/api/useRegistrationFormFields.ts +++ b/apps/app/src/composables/api/useRegistrationFormFields.ts @@ -24,7 +24,7 @@ export function useRegistrationFormFields(orgId: Ref, eventId: Ref !!orgId.value && !!eventId.value, - staleTime: Infinity, + staleTime: Number.POSITIVE_INFINITY, }) } @@ -87,22 +87,23 @@ export function useReorderRegistrationFormFields(orgId: Ref, eventId: Re ids: orderedIds, }) }, - onMutate: async (orderedIds) => { + onMutate: async orderedIds => { await queryClient.cancelQueries({ queryKey: ['registration-form-fields', eventId.value] }) previousFields = queryClient.getQueryData(['registration-form-fields', eventId.value]) if (previousFields) { const byId = new Map(previousFields.map(f => [f.id, f])) + const reordered = orderedIds .map(id => byId.get(id)) .filter((f): f is RegistrationFormField => !!f) + queryClient.setQueryData(['registration-form-fields', eventId.value], reordered) } }, onError: () => { - if (previousFields) { + if (previousFields) queryClient.setQueryData(['registration-form-fields', eventId.value], previousFields) - } }, }) } diff --git a/apps/app/src/composables/api/useSections.ts b/apps/app/src/composables/api/useSections.ts index af518672..50e08632 100644 --- a/apps/app/src/composables/api/useSections.ts +++ b/apps/app/src/composables/api/useSections.ts @@ -111,24 +111,26 @@ export function useReorderSections(orgId: Ref, eventId: Ref) { sections: orderedIds, }) }, - onMutate: async (orderedIds) => { + onMutate: async orderedIds => { await queryClient.cancelQueries({ queryKey: ['sections', eventId.value] }) previousSections = queryClient.getQueryData(['sections', eventId.value]) // Optimistically update query cache so watch doesn't snap back if (previousSections) { const byId = new Map(previousSections.map(s => [s.id, s])) + const reordered = orderedIds .map(id => byId.get(id)) .filter((s): s is FestivalSection => !!s) + queryClient.setQueryData(['sections', eventId.value], reordered) } }, onError: () => { - if (previousSections) { + if (previousSections) queryClient.setQueryData(['sections', eventId.value], previousSections) - } }, + // No onSuccess invalidation — query cache and v-model are already in sync }) } diff --git a/apps/app/src/composables/api/useShiftAssignments.ts b/apps/app/src/composables/api/useShiftAssignments.ts index b6fb2f2f..f856466e 100644 --- a/apps/app/src/composables/api/useShiftAssignments.ts +++ b/apps/app/src/composables/api/useShiftAssignments.ts @@ -37,10 +37,14 @@ export function useShiftAssignmentList( queryKey: ['shift-assignments', eventId, filters], queryFn: async () => { const params: Record = {} - if (filters?.value?.shift_id) params.shift_id = filters.value.shift_id - if (filters?.value?.person_id) params.person_id = filters.value.person_id - if (filters?.value?.section_id) params.section_id = filters.value.section_id - if (filters?.value?.status) params.status = filters.value.status + if (filters?.value?.shift_id) + params.shift_id = filters.value.shift_id + if (filters?.value?.person_id) + params.person_id = filters.value.person_id + if (filters?.value?.section_id) + params.section_id = filters.value.section_id + if (filters?.value?.status) + params.status = filters.value.status const { data } = await apiClient.get>( `/organisations/${orgId.value}/events/${eventId.value}/shift-assignments`, diff --git a/apps/app/src/composables/api/useTimeSlots.ts b/apps/app/src/composables/api/useTimeSlots.ts index 0192112d..8a634569 100644 --- a/apps/app/src/composables/api/useTimeSlots.ts +++ b/apps/app/src/composables/api/useTimeSlots.ts @@ -21,8 +21,10 @@ export function useTimeSlotList(orgId: Ref, eventId: Ref, option queryKey: ['time-slots', eventId, includeParent, includeChildren], queryFn: async () => { const params: Record = {} - if (includeParent?.value) params.include_parent = 'true' - if (includeChildren?.value) params.include_children = 'true' + if (includeParent?.value) + params.include_parent = 'true' + if (includeChildren?.value) + params.include_children = 'true' const { data } = await apiClient.get>( `/organisations/${orgId.value}/events/${eventId.value}/time-slots`, diff --git a/apps/app/src/composables/useTimeSlotDropdown.ts b/apps/app/src/composables/useTimeSlotDropdown.ts index 3da96fb4..4cdf92ad 100644 --- a/apps/app/src/composables/useTimeSlotDropdown.ts +++ b/apps/app/src/composables/useTimeSlotDropdown.ts @@ -1,4 +1,4 @@ -import { computed, type Ref } from 'vue' +import { type Ref, computed } from 'vue' import type { EventItem } from '@/types/event' import type { FestivalSection } from '@/types/section' import type { TimeSlot } from '@/types/timeSlot' @@ -24,20 +24,24 @@ export function useTimeSlotDropdown( section: Ref, ) { const scenario = computed(() => { - if (!event.value) return 'flat' + if (!event.value) + return 'flat' const isSubEvent = !!event.value.parent_event_id const hasChildren = event.value.has_children const isCrossEvent = section.value?.type === 'cross_event' // Flat event — no hierarchy - if (!isSubEvent && !hasChildren) return 'flat' + if (!isSubEvent && !hasChildren) + return 'flat' // Cross_event section — needs all time slots - if (isCrossEvent) return 'cross_event' + if (isCrossEvent) + return 'cross_event' // Standard section on sub-event — own + parent - if (isSubEvent) return 'sub_event_standard' + if (isSubEvent) + return 'sub_event_standard' // Standard section on festival level — own only return 'festival_standard' @@ -61,12 +65,12 @@ export function useTimeSlotDropdown( case 'cross_event': return { main: `${sectionName} is een festival-brede sectie — actief bij elk programmaonderdeel. Je kunt tijdsloten kiezen van ${festivalName} en van alle programmaonderdelen.`, - tip: `Plan diensten per programmaonderdeel (bijv. een showavond) of festival-breed (bijv. opbouw, nachtsecurity).`, + tip: 'Plan diensten per programmaonderdeel (bijv. een showavond) of festival-breed (bijv. opbouw, nachtsecurity).', } case 'festival_standard': return { main: `Kies een tijdslot van ${eventName} voor deze dienst.`, - tip: `Alleen tijdsloten op festivalniveau zijn beschikbaar voor deze sectie.`, + tip: 'Alleen tijdsloten op festivalniveau zijn beschikbaar voor deze sectie.', } default: return null @@ -93,9 +97,8 @@ export function useTimeSlotDropdown( * in the template by comparing adjacent items' groupName. */ function sortedItems(timeSlots: TimeSlot[]): TimeSlotDropdownItem[] { - if (scenario.value === 'flat') { + if (scenario.value === 'flat') return timeSlots.map(ts => toDropdownItem(ts, false, '')) - } // Classify each slot into a group and determine isOwn per group const groups = new Map() @@ -105,6 +108,7 @@ export function useTimeSlotDropdown( const isOwn = scenario.value === 'sub_event_standard' ? ts.source === 'sub_event' : ts.source === 'own' + groups.set(key, { slots: [], isOwn }) } groups.get(key)!.slots.push(ts) @@ -112,17 +116,19 @@ export function useTimeSlotDropdown( // Own group first, then others alphabetically const sorted = [...groups.entries()].sort(([nameA, a], [nameB, b]) => { - if (a.isOwn && !b.isOwn) return -1 - if (!a.isOwn && b.isOwn) return 1 + if (a.isOwn && !b.isOwn) + return -1 + if (!a.isOwn && b.isOwn) + return 1 + return nameA.localeCompare(nameB) }) const items: TimeSlotDropdownItem[] = [] for (const [groupName, { slots, isOwn }] of sorted) { const isDimmed = scenario.value === 'sub_event_standard' && !isOwn - for (const ts of slots) { + for (const ts of slots) items.push(toDropdownItem(ts, isDimmed, groupName)) - } } return items @@ -140,6 +146,7 @@ export function useTimeSlotDropdown( function toDropdownItem(ts: TimeSlot, isDimmed: boolean, groupName: string): TimeSlotDropdownItem { const timeRange = `${ts.start_time} – ${ts.end_time}` + return { id: ts.id, name: ts.name, diff --git a/apps/app/src/lib/apiErrors.ts b/apps/app/src/lib/apiErrors.ts index cf9a8258..a77c802a 100644 --- a/apps/app/src/lib/apiErrors.ts +++ b/apps/app/src/lib/apiErrors.ts @@ -4,10 +4,12 @@ import { isAxiosError } from 'axios' * Human-readable message from Laravel API validation / exception responses. */ export function getApiErrorMessage(error: unknown, fallback: string): string { - if (!isAxiosError(error)) return fallback + if (!isAxiosError(error)) + return fallback const data = error.response?.data as Record | undefined - if (!data) return fallback + if (!data) + return fallback const errors = data.errors if (errors && typeof errors === 'object' && errors !== null && !Array.isArray(errors)) { @@ -15,12 +17,14 @@ export function getApiErrorMessage(error: unknown, fallback: string): string { if (Array.isArray(value) && value.length > 0 && typeof value[0] === 'string') return value[0] - if (typeof value === 'string') return value + if (typeof value === 'string') + return value } } const message = data.message - if (typeof message === 'string' && message.trim() !== '') return message + if (typeof message === 'string' && message.trim() !== '') + return message return fallback } diff --git a/apps/app/src/lib/axios.ts b/apps/app/src/lib/axios.ts index e717df72..dc1c2097 100644 --- a/apps/app/src/lib/axios.ts +++ b/apps/app/src/lib/axios.ts @@ -7,7 +7,7 @@ const apiClient: AxiosInstance = axios.create({ baseURL: import.meta.env.VITE_API_URL, withCredentials: true, headers: { - Accept: 'application/json', + 'Accept': 'application/json', 'Content-Type': 'application/json', }, timeout: 30000, @@ -17,9 +17,8 @@ apiClient.interceptors.request.use( (config: InternalAxiosRequestConfig) => { const orgStore = useOrganisationStore() - if (orgStore.activeOrganisationId) { + if (orgStore.activeOrganisationId) config.headers['X-Organisation-Id'] = orgStore.activeOrganisationId - } // Add impersonation header when active // Lazy import to avoid circular dependency with store @@ -27,18 +26,16 @@ apiClient.interceptors.request.use( if (impersonationData) { try { const parsed = JSON.parse(impersonationData) as { targetUserId?: string } - if (parsed.targetUserId) { + if (parsed.targetUserId) config.headers['X-Impersonate-User'] = parsed.targetUserId - } } catch { // Invalid data — ignore } } - if (import.meta.env.DEV) { + if (import.meta.env.DEV) console.log(`🚀 ${config.method?.toUpperCase()} ${config.url}`, config.data) - } return config }, @@ -47,16 +44,14 @@ apiClient.interceptors.request.use( apiClient.interceptors.response.use( response => { - if (import.meta.env.DEV) { + if (import.meta.env.DEV) console.log(`✅ ${response.status} ${response.config.url}`, response.data) - } return response }, error => { - if (import.meta.env.DEV) { + if (import.meta.env.DEV) console.error(`❌ ${error.response?.status} ${error.config?.url}`, error.response?.data) - } const status = error.response?.status const notificationStore = useNotificationStore() @@ -65,6 +60,7 @@ apiClient.interceptors.response.use( if (status === 403 && error.response?.data?.impersonation_ended) { import('@/stores/useImpersonationStore').then(({ useImpersonationStore }) => { const impersonationStore = useImpersonationStore() + impersonationStore.clearState() window.location.href = '/platform' }) @@ -76,9 +72,8 @@ apiClient.interceptors.response.use( // Lazy import to avoid circular dependency import('@/stores/useAuthStore').then(({ useAuthStore }) => { const authStore = useAuthStore() - if (authStore.isInitialized) { + if (authStore.isInitialized) authStore.handleUnauthorized() - } }) } else if (status === 403) { @@ -90,9 +85,8 @@ apiClient.interceptors.response.use( else if (status === 422) { // Show validation message to user; still reject so component onError handlers can react const message = error.response?.data?.message - if (message && typeof message === 'string') { + if (message && typeof message === 'string') notificationStore.show(message, 'error') - } } else if (status === 503) { notificationStore.show('Service temporarily unavailable. Please try again later.', 'error') diff --git a/apps/app/src/lib/dutch-plural.ts b/apps/app/src/lib/dutch-plural.ts index 2544d90e..26254c80 100644 --- a/apps/app/src/lib/dutch-plural.ts +++ b/apps/app/src/lib/dutch-plural.ts @@ -4,12 +4,18 @@ */ export function dutchPlural(word: string): string { // Words ending in -ie: add -s (editie → edities, locatie → locaties) - if (word.endsWith('ie')) return `${word}s` + if (word.endsWith('ie')) + return `${word}s` + // Words ending in -e: add -s (ronde → rondes) - if (word.endsWith('e')) return `${word}s` + if (word.endsWith('e')) + return `${word}s` + // Double vowel before final consonant(s): single vowel + en (onderdeel → onderdelen) const match = word.match(/^(.*)([aeiou])\2([^aeiou]+)$/i) - if (match) return `${match[1]}${match[2]}${match[3]}en` + if (match) + return `${match[1]}${match[2]}${match[3]}en` + // Default: add -en (dag → dagen) return `${word}en` } diff --git a/apps/app/src/plugins/1.router/guards.ts b/apps/app/src/plugins/1.router/guards.ts index d8c8929b..de9ed5d1 100644 --- a/apps/app/src/plugins/1.router/guards.ts +++ b/apps/app/src/plugins/1.router/guards.ts @@ -7,9 +7,8 @@ export function setupGuards(router: Router) { const authStore = useAuthStore() // Wait for initialization to complete (only blocks on first navigation) - if (!authStore.isInitialized) { + if (!authStore.isInitialized) await authStore.initialize() - } if (import.meta.env.DEV) { console.log('🔒 Router Guard:', { @@ -24,40 +23,55 @@ export function setupGuards(router: Router) { // Allow public routes (login, auth pages, 404) — but redirect authenticated users away from login if (isPublic) { const guestOnlyPaths = ['/login', '/forgot-password', '/reset-password', '/verify-email-change'] - if (authStore.isAuthenticated && guestOnlyPaths.some(p => to.path === p)) { - if (import.meta.env.DEV) console.log('🔄 Redirecting logged-in user away from login page') + if (authStore.isAuthenticated && guestOnlyPaths.includes(to.path)) { + if (import.meta.env.DEV) + console.log('🔄 Redirecting logged-in user away from login page') + return { name: 'dashboard' } } - if (import.meta.env.DEV) console.log('✅ Public route, allowing access') + if (import.meta.env.DEV) + console.log('✅ Public route, allowing access') + return } // Routes that opt out of auth (e.g. invitations) if (to.meta.requiresAuth === false) { - if (import.meta.env.DEV) console.log('✅ Route does not require auth') + if (import.meta.env.DEV) + console.log('✅ Route does not require auth') + return } // Not authenticated → redirect to login with return URL if (!authStore.isAuthenticated) { - if (import.meta.env.DEV) console.log('🚫 Not authenticated, redirecting to login') + if (import.meta.env.DEV) + console.log('🚫 Not authenticated, redirecting to login') + return { path: '/login', query: { to: to.fullPath } } } // MFA enforcement — redirect to security settings if MFA setup is required if (authStore.mfaSetupRequired && to.path !== '/account-settings') { - if (import.meta.env.DEV) console.log('🔒 MFA setup required, redirecting to security settings') + if (import.meta.env.DEV) + console.log('🔒 MFA setup required, redirecting to security settings') + return { path: '/account-settings', query: { tab: 'security' } } } // Platform admin routes — require super_admin role if (to.path.startsWith('/platform')) { if (!authStore.isSuperAdmin) { - if (import.meta.env.DEV) console.log('🚫 Not a super admin, redirecting to dashboard') + if (import.meta.env.DEV) + console.log('🚫 Not a super admin, redirecting to dashboard') + return { name: 'dashboard' } } + // Platform routes don't require organisation selection - if (import.meta.env.DEV) console.log('✅ Super admin access to platform route') + if (import.meta.env.DEV) + console.log('✅ Super admin access to platform route') + return } @@ -66,16 +80,21 @@ export function setupGuards(router: Router) { const isSelectOrgPage = to.path === '/select-organisation' if (isSelectOrgPage) { - if (import.meta.env.DEV) console.log('✅ Organisation selection page') + if (import.meta.env.DEV) + console.log('✅ Organisation selection page') + return } // If user has organisations but none selected → redirect to selection if (authStore.organisations.length > 0 && !orgStore.hasOrganisation) { - if (import.meta.env.DEV) console.log('🔄 No organisation selected, redirecting') + if (import.meta.env.DEV) + console.log('🔄 No organisation selected, redirecting') + return { path: '/select-organisation' } } - if (import.meta.env.DEV) console.log('✅ Access granted') + if (import.meta.env.DEV) + console.log('✅ Access granted') }) } diff --git a/apps/app/src/stores/useAuthStore.ts b/apps/app/src/stores/useAuthStore.ts index 1db98c15..ef8fed32 100644 --- a/apps/app/src/stores/useAuthStore.ts +++ b/apps/app/src/stores/useAuthStore.ts @@ -18,6 +18,7 @@ export const useAuthStore = defineStore('auth', () => { const currentOrganisation = computed(() => { const orgStore = useOrganisationStore() + return organisations.value.find(o => o.id === orgStore.activeOrganisationId) ?? organisations.value[0] ?? null @@ -43,13 +44,13 @@ export const useAuthStore = defineStore('auth', () => { // Auto-select first organisation if none is active const orgStore = useOrganisationStore() - if (!orgStore.activeOrganisationId && me.organisations.length > 0) { + if (!orgStore.activeOrganisationId && me.organisations.length > 0) orgStore.setActiveOrganisation(me.organisations[0].id) - } } function setActiveOrganisation(id: string) { const orgStore = useOrganisationStore() + orgStore.setActiveOrganisation(id) } @@ -61,19 +62,20 @@ export const useAuthStore = defineStore('auth', () => { mfaSetupRequired.value = false const orgStore = useOrganisationStore() + orgStore.clear() } function handleUnauthorized() { clearState() + // Do NOT reset isInitialized — the full page reload (below) resets all JS state. // Resetting it here causes a race condition: the async 401 interceptor fires // after doInitialize() sets isInitialized=true, putting the app back into // a loading state that never resolves. - if (typeof window !== 'undefined' && window.location.pathname !== '/login') { + if (typeof window !== 'undefined' && window.location.pathname !== '/login') window.location.href = '/login' - } } async function logout() { @@ -95,6 +97,7 @@ export const useAuthStore = defineStore('auth', () => { async function refreshUser(): Promise { try { const { data } = await apiClient.get<{ success: boolean; data: MeResponse }>('/auth/me') + setUser(data.data) } catch { @@ -110,16 +113,18 @@ export const useAuthStore = defineStore('auth', () => { let initializePromise: Promise | null = null function initialize(): Promise { - if (isInitialized.value) return Promise.resolve() - if (!initializePromise) { + if (isInitialized.value) + return Promise.resolve() + if (!initializePromise) initializePromise = doInitialize() - } + return initializePromise } async function doInitialize(): Promise { try { const { data } = await apiClient.get<{ success: boolean; data: MeResponse }>('/auth/me') + setUser(data.data) } catch { diff --git a/apps/app/src/stores/useImpersonationStore.ts b/apps/app/src/stores/useImpersonationStore.ts index 4e9d985d..1ee318ac 100644 --- a/apps/app/src/stores/useImpersonationStore.ts +++ b/apps/app/src/stores/useImpersonationStore.ts @@ -1,7 +1,7 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' import { apiClient } from '@/lib/axios' -import type { AdminUser, ImpersonationSession, ImpersonationStartResponse, ImpersonationStatusResponse, StartImpersonationPayload } from '@/types/admin' +import type { AdminUser, ImpersonationStartResponse, ImpersonationStatusResponse, StartImpersonationPayload } from '@/types/admin' const SESSION_STORAGE_KEY = 'crewli_impersonation' const BROADCAST_CHANNEL_NAME = 'crewli_impersonation_sync' @@ -27,12 +27,11 @@ export const useImpersonationStore = defineStore('impersonation', () => { const expiresAt = computed(() => state.value?.expiresAt ? new Date(state.value.expiresAt) : null) function persistState(): void { - if (state.value) { + if (state.value) sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(state.value)) - } - else { + + else sessionStorage.removeItem(SESSION_STORAGE_KEY) - } } async function start( @@ -69,12 +68,12 @@ export const useImpersonationStore = defineStore('impersonation', () => { // Call stop WITHOUT the X-Impersonate-User header // The interceptor won't add it because we clear state first const currentState = state.value + state.value = null persistState() - if (currentState) { + if (currentState) await apiClient.post('/admin/stop-impersonation') - } } catch { // Even if API call fails, state is already cleared @@ -130,7 +129,8 @@ export const useImpersonationStore = defineStore('impersonation', () => { } function listenForBroadcasts(): void { - if (broadcastChannel) return + if (broadcastChannel) + return try { broadcastChannel = new BroadcastChannel(BROADCAST_CHANNEL_NAME) diff --git a/apps/app/src/stores/useShiftDetailStore.ts b/apps/app/src/stores/useShiftDetailStore.ts index 57986bef..8f8f0bb5 100644 --- a/apps/app/src/stores/useShiftDetailStore.ts +++ b/apps/app/src/stores/useShiftDetailStore.ts @@ -25,7 +25,8 @@ export const useShiftDetailStore = defineStore('shiftDetail', () => { function toggleAssignmentSelection(id: string) { const idx = selectedAssignmentIds.value.indexOf(id) - if (idx === -1) selectedAssignmentIds.value.push(id) + if (idx === -1) + selectedAssignmentIds.value.push(id) else selectedAssignmentIds.value.splice(idx, 1) } diff --git a/apps/app/src/types/admin.ts b/apps/app/src/types/admin.ts index e4d56e33..4c5e7868 100644 --- a/apps/app/src/types/admin.ts +++ b/apps/app/src/types/admin.ts @@ -1,3 +1,5 @@ +import type { Member } from './member' + export type BillingStatus = 'trial' | 'active' | 'suspended' | 'cancelled' export interface AdminOrganisation { @@ -97,8 +99,6 @@ export interface StartImpersonationPayload { mfa_method: 'totp' | 'email' | 'backup_code' } -import type { Member } from './member' - export interface AdminOrganisationDetail { organisation: AdminOrganisation members: Member[] diff --git a/apps/app/src/types/event.ts b/apps/app/src/types/event.ts index ac843317..8b132495 100644 --- a/apps/app/src/types/event.ts +++ b/apps/app/src/types/event.ts @@ -16,6 +16,7 @@ export interface EventItem { name: string slug: string status: EventStatus + /** Valid next statuses from the API state machine (detail + transition responses). */ allowed_transitions?: EventStatus[] event_type: EventTypeEnum diff --git a/apps/app/src/types/formSchema.ts b/apps/app/src/types/formSchema.ts index f6efe54d..721ab8ce 100644 --- a/apps/app/src/types/formSchema.ts +++ b/apps/app/src/types/formSchema.ts @@ -78,6 +78,7 @@ export interface FormSchemaSummary { is_published: boolean version: number updated_at: string | null + // Both counts are always present in the index response because the // controller calls ->withCount(['fields', 'submissions']). submissions_count: number @@ -121,8 +122,10 @@ export interface FormSchema { fields_count: number submissions_count: number | null has_submissions: boolean | null + // TODO(PR-b3): replace with FormField[] once organizer field types land fields: unknown[] + // TODO(PR-b3): replace with FormSchemaSection[] once organizer section types land sections: unknown[] created_at: string | null diff --git a/apps/app/src/types/section.ts b/apps/app/src/types/section.ts index a5a3be67..e9dbb36a 100644 --- a/apps/app/src/types/section.ts +++ b/apps/app/src/types/section.ts @@ -1,4 +1,4 @@ -import type { TimeSlot, PersonType, TimeSlotSource } from '@/types/timeSlot' +import type { PersonType, TimeSlot, TimeSlotSource } from '@/types/timeSlot' export type { TimeSlot, PersonType, TimeSlotSource } export type { CreateTimeSlotPayload, UpdateTimeSlotPayload } from '@/types/timeSlot' @@ -71,7 +71,6 @@ export interface CreateSectionPayload { export interface UpdateSectionPayload extends Partial {} - export interface CreateShiftPayload { time_slot_id: string location_id?: string