From 5204536f922a47a3629505c76cafae0fdcc54ff1 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Tue, 28 Jul 2026 18:34:35 +0200 Subject: [PATCH] Fix self-serve settings state and contract (#229) Ensure department-scoped self-serve settings load and save safely across route transitions, document the API contract, and cover stale in-flight state. --- openapi.yaml | 28 +++ src/composables/departmentSelfServeEnabled.js | 43 ++++- src/router.js | 6 + .../DepartmentTimeBookingsOpeningHours.vue | 119 +++++++++++-- ...SuperUserDashboardDepartmentNavigation.vue | 6 + .../modules/DepartmentModulesSetup.vue | 164 ++++++++++++++---- ...artment-modules-display-self-serve.spec.js | 1 + ...epartment-modules-setup-self-serve.spec.js | 39 ++++- ...epartment-opening-hours-route-sync.spec.js | 37 ++++ 9 files changed, 385 insertions(+), 58 deletions(-) create mode 100644 tests/unit/department-opening-hours-route-sync.spec.js diff --git a/openapi.yaml b/openapi.yaml index 689e13cd..e1653d57 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3534,6 +3534,19 @@ paths: properties: enabled: type: boolean + auto_deactivation: + type: object + required: [at, timezone, label] + properties: + at: + type: string + format: date-time + nullable: true + timezone: + type: string + example: Europe/Copenhagen + label: + type: string '404': $ref: '#/components/responses/NotFound' put: @@ -3566,6 +3579,21 @@ paths: properties: message: type: string + enabled: + type: boolean + auto_deactivation: + type: object + required: [at, timezone, label] + properties: + at: + type: string + format: date-time + nullable: true + timezone: + type: string + example: Europe/Copenhagen + label: + type: string '404': $ref: '#/components/responses/NotFound' diff --git a/src/composables/departmentSelfServeEnabled.js b/src/composables/departmentSelfServeEnabled.js index 846e226c..14ce29bf 100644 --- a/src/composables/departmentSelfServeEnabled.js +++ b/src/composables/departmentSelfServeEnabled.js @@ -43,17 +43,40 @@ const extractEnabledValue = (response) => { return undefined; }; -export const getDepartmentSelfServeEnabled = async (departmentId) => { +const extractSelfServeStatus = (response, fallbackEnabled = false) => { + const payload = response?.data?.data ?? response?.data ?? response; + const enabled = Object.prototype.hasOwnProperty.call(payload || {}, "enabled") + ? normalizeEnabledValue(payload.enabled) + : normalizeEnabledValue(fallbackEnabled); + const autoDeactivation = payload?.auto_deactivation || payload?.autoDeactivation || null; + + return { + enabled, + hasAutoDeactivation: autoDeactivation !== null, + autoDeactivation: { + at: autoDeactivation?.at || null, + timezone: autoDeactivation?.timezone || "Europe/Copenhagen", + label: autoDeactivation?.label || (autoDeactivation?.at ? String(autoDeactivation.at) : "NEVER"), + }, + }; +}; + +export const getDepartmentSelfServeStatus = async (departmentId) => { const normalizedDepartmentId = normalizeDepartmentId(departmentId); const response = await authenticatedRequest( `/departments/self-serve/enabled?id=${normalizedDepartmentId}`, "GET" ); - return normalizeEnabledValue(extractEnabledValue(response)); + return extractSelfServeStatus(response); }; -export const setDepartmentSelfServeEnabled = async (departmentId, enabled) => { +export const getDepartmentSelfServeEnabled = async (departmentId) => { + const status = await getDepartmentSelfServeStatus(departmentId); + return status.enabled; +}; + +export const setDepartmentSelfServeStatus = async (departmentId, enabled) => { const normalizedDepartmentId = normalizeDepartmentId(departmentId); const normalizedEnabled = normalizeEnabledValue(enabled); @@ -63,12 +86,20 @@ export const setDepartmentSelfServeEnabled = async (departmentId, enabled) => { ); const responseEnabled = extractEnabledValue(response); - return responseEnabled === undefined - ? normalizedEnabled - : normalizeEnabledValue(responseEnabled); + return extractSelfServeStatus( + response, + responseEnabled === undefined ? normalizedEnabled : normalizeEnabledValue(responseEnabled) + ); +}; + +export const setDepartmentSelfServeEnabled = async (departmentId, enabled) => { + const status = await setDepartmentSelfServeStatus(departmentId, enabled); + return status.enabled; }; export default { getDepartmentSelfServeEnabled, + getDepartmentSelfServeStatus, setDepartmentSelfServeEnabled, + setDepartmentSelfServeStatus, }; diff --git a/src/router.js b/src/router.js index b3f871f1..3e760e17 100644 --- a/src/router.js +++ b/src/router.js @@ -965,6 +965,12 @@ export const router = createRouter({ component: DepartmentModulesSetup, meta: { middleware: superUserMiddleware } }, + { + name: 'departmentsopeninghours', + path: '/superuser/departments/:departmentId/opening-hours', + component: DepartmentTimeBookingsOpeningHours, + meta: { middleware: superUserMiddleware } + }, { name: 'departmentsgateways', path: '/superuser/departments/:departmentId/gateways', diff --git a/src/views/dashboards/departmentDashboard/modules/time-bookings/DepartmentTimeBookingsOpeningHours.vue b/src/views/dashboards/departmentDashboard/modules/time-bookings/DepartmentTimeBookingsOpeningHours.vue index 6160ce43..df0f015f 100644 --- a/src/views/dashboards/departmentDashboard/modules/time-bookings/DepartmentTimeBookingsOpeningHours.vue +++ b/src/views/dashboards/departmentDashboard/modules/time-bookings/DepartmentTimeBookingsOpeningHours.vue @@ -2,10 +2,27 @@ import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue"; import {SessionUser} from "@/components/session/token/SessionUser.vue"; import DepartmentDashboardPageWrapper from "@/views/dashboards/departmentDashboard/DepartmentDashboardPageWrapper.vue"; +import DepartmentSubPageWrapper from "@/views/dashboards/superUserDashboard/department/DepartmentSubPageWrapper.vue"; +import { setDepartment as setSelectedDepartment } from "@/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue"; import NotFoundFallBackPageWrapper from "@/components/page/wrappers/NotFoundFallBackPageWrapper.vue"; -import {ref} from 'vue'; +import PageTitle from "@/components/global/PageTitle.vue"; +import {computed, ref, watch} from 'vue'; +import {useRoute} from "vue-router"; const opening_hours = ref(null); +const route = useRoute(); +const departmentId = computed(() => route.params.departmentId || SessionUser.functions.getDepartmentIdFromUrl()); +const isSuperuserDepartmentRoute = computed(() => String(route.path || "").startsWith("/superuser/departments/")); +const pageTitle = computed(() => SessionUser.objects.global.language.opening_hours); +const pageSubtitle = "Afdelingens åbningstider"; +const hasPagePermission = computed(() => ( + isSuperuserDepartmentRoute.value + ? SessionUser.canAccessSuperUser() + : SessionUser.canAccessAdmin() +)); +const hasDepartmentAccess = computed(() => ( + Boolean(departmentId.value) && SessionUser.canAccessDepartment(departmentId.value) +)); const isOpeningHoursLoaded = () => { return opening_hours.value !== null; } @@ -54,30 +71,50 @@ const getControlClass = () => { return ''; } -const getOpeningHours = (departmentId) => { +let openingHoursRequest = 0; +const getOpeningHours = (selectedDepartmentId) => { + const requestId = ++openingHoursRequest; + opening_hours.value = null; // Fetch the opening hours for the department - SessionUser.request( + return SessionUser.request( SessionUser.objects.department_time_bookings_opening_hours.meta.endpoint, 'GET', { - department: departmentId + department: selectedDepartmentId } ).then((response) => { + if (requestId !== openingHoursRequest) { + return; + } console.log('response', response.data.data); // Check if there's any object in the response opening_hours.value = response.data.data; }) .catch((error) => { - console.error('Error fetching opening hours:', error); + if (requestId === openingHoursRequest) { + console.error('Error fetching opening hours:', error); + } }); } -const departmentId = SessionUser.functions.getDepartmentIdFromUrl(); -if (departmentId) { - getOpeningHours(departmentId); -} else { +watch( + () => [departmentId.value, isSuperuserDepartmentRoute.value], + ([selectedDepartmentId, isSuperuserRoute], [previousDepartmentId] = []) => { + if (selectedDepartmentId) { + if (isSuperuserRoute) { + setSelectedDepartment(selectedDepartmentId); + } + if (selectedDepartmentId !== previousDepartmentId) { + getOpeningHours(selectedDepartmentId); + } + return; + } + openingHoursRequest++; + opening_hours.value = null; console.error('Department ID not found in URL'); -} + }, + { immediate: true } +); const getOpeningHoursValue = (name) => { if (opening_hours.value === null) { @@ -88,12 +125,64 @@ const getOpeningHoursValue = (name) => { \ No newline at end of file + diff --git a/src/views/dashboards/superUserDashboard/department/SuperUserDashboardDepartmentNavigation.vue b/src/views/dashboards/superUserDashboard/department/SuperUserDashboardDepartmentNavigation.vue index 5734121e..7554ec33 100644 --- a/src/views/dashboards/superUserDashboard/department/SuperUserDashboardDepartmentNavigation.vue +++ b/src/views/dashboards/superUserDashboard/department/SuperUserDashboardDepartmentNavigation.vue @@ -26,6 +26,12 @@ const tabs = computed(() => to: `${departmentPath.value}/modules`, active: (path) => path.startsWith(`${departmentPath.value}/modules`), }, + { + key: "opening-hours", + label: "Opening hours", + to: `${departmentPath.value}/opening-hours`, + active: (path) => path.startsWith(`${departmentPath.value}/opening-hours`), + }, { key: "branding", label: t("superuser_dashboard.department_navigation.branding"), diff --git a/src/views/dashboards/superUserDashboard/department/modules/DepartmentModulesSetup.vue b/src/views/dashboards/superUserDashboard/department/modules/DepartmentModulesSetup.vue index 6f8141b6..521b7ab5 100644 --- a/src/views/dashboards/superUserDashboard/department/modules/DepartmentModulesSetup.vue +++ b/src/views/dashboards/superUserDashboard/department/modules/DepartmentModulesSetup.vue @@ -5,7 +5,7 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue"; import DepartmentSubPageWrapper from "@/views/dashboards/superUserDashboard/department/DepartmentSubPageWrapper.vue"; import { departmentAdvanced, setDepartment, departmentId } from "@/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue"; import { useRouter } from "vue-router"; -import { computed, ref } from "vue"; +import { computed, ref, watch } from "vue"; import { useI18n } from "vue-i18n"; import ConfigurationSelect from "@/components/displays/superuser/configuration/ConfigurationSelect.vue"; import SuperuserOverviewPanel from "@/components/displays/superuser/overview/SuperuserOverviewPanel.vue"; @@ -14,14 +14,13 @@ import SuperUserDashboardDepartmentModulesNavigation import ConfigurationSwitch from "@/components/displays/superuser/configuration/ConfigurationSwitch.vue"; import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue"; import { - getDepartmentSelfServeEnabled, - setDepartmentSelfServeEnabled, + getDepartmentSelfServeStatus, + setDepartmentSelfServeStatus, } from "@/composables/departmentSelfServeEnabled.js"; // Get the department from the route const router = useRouter(); const { t } = useI18n({ useScope: "global" }); -setDepartment(router.currentRoute.value.params.departmentId); const departmentTitle = computed(() => departmentAdvanced.value.name || t("superuser_dashboard.department_navigation.modules")); const pageSubtitle = computed(() => t("superuser_dashboard.department_pages.modules.subtitle")); @@ -30,9 +29,13 @@ const workfeedDepartmentOptions = ref([{ value: "", label: "No Workfeed departme const isLoadingWorkfeedDepartments = ref(false); const workfeedDepartmentOptionsError = ref(""); const selfServeEnabled = ref(false); +const selfServeAutoDeactivation = ref({ at: null, timezone: "Europe/Copenhagen", label: "NEVER" }); const isLoadingSelfServeEnabled = ref(false); const isSavingSelfServeEnabled = ref(false); const selfServeEnabledError = ref(""); +let departmentVariablesRequestSequence = 0; +let selfServeStatusRequestSequence = 0; +let selfServeSaveRequestSequence = 0; const departmentVariableDescriptions = computed(() => ({ bookingsystem_enabled: t("superuser_dashboard.department_pages.modules.variables.bookingsystem_enabled"), bookingsystem_time_based_enabled: t("superuser_dashboard.department_pages.modules.variables.bookingsystem_time_based_enabled"), @@ -47,20 +50,44 @@ const departmentVariableRows = computed(() => departmentVariables.value.map((var value: variable.value, }))); -const getDepartmentVariables = async () => { - await SessionUser.request( - SessionUser.objects.department_variables.meta.endpoint, - "GET", - { - filters: "department_id:" + departmentId.value, +const selfServeAutoDeactivationText = computed(() => { + if (!selfServeEnabled.value) { + return "Automatic deactivation: not scheduled while self-serve is disabled."; + } + + const label = String(selfServeAutoDeactivation.value?.label || "").trim(); + return `Automatic deactivation: ${label || "NEVER"}`; +}); + +const getDepartmentVariables = async ( + requestedDepartmentId = String(departmentId.value) +) => { + const requestSequence = ++departmentVariablesRequestSequence; + try { + const response = await SessionUser.request( + SessionUser.objects.department_variables.meta.endpoint, + "GET", + { + filters: "department_id:" + requestedDepartmentId, + } + ); + if ( + requestSequence !== departmentVariablesRequestSequence + || String(departmentId.value) !== requestedDepartmentId + ) { + return false; } - ) - .then((response) => { - departmentVariables.value = response.data.data; - }) - .catch((error) => { + departmentVariables.value = response.data.data; + return true; + } catch (error) { + if ( + requestSequence === departmentVariablesRequestSequence + && String(departmentId.value) === requestedDepartmentId + ) { console.log(error); - }); + } + return false; + } }; const getVariableValue = (variable) => { @@ -153,40 +180,107 @@ const setWorkfeedDepartmentId = async (selectedDepartmentId) => { }); }; -const loadSelfServeEnabled = async () => { +const loadSelfServeEnabled = async ({ + preserveEnabledOnError = false, + requestedDepartmentId = String(departmentId.value), +} = {}) => { + const requestSequence = ++selfServeStatusRequestSequence; isLoadingSelfServeEnabled.value = true; selfServeEnabledError.value = ""; try { - selfServeEnabled.value = await getDepartmentSelfServeEnabled(departmentId.value); + const status = await getDepartmentSelfServeStatus(requestedDepartmentId); + if ( + requestSequence !== selfServeStatusRequestSequence + || String(departmentId.value) !== requestedDepartmentId + ) { + return false; + } + selfServeEnabled.value = status.enabled; + selfServeAutoDeactivation.value = status.autoDeactivation; + return true; } catch (error) { console.log(error); - selfServeEnabledError.value = "Unable to load self-serve status."; - selfServeEnabled.value = false; + if ( + requestSequence === selfServeStatusRequestSequence + && String(departmentId.value) === requestedDepartmentId + ) { + selfServeEnabledError.value = "Unable to load self-serve status."; + if (!preserveEnabledOnError) { + selfServeEnabled.value = false; + } + } + return false; } finally { - isLoadingSelfServeEnabled.value = false; + if (requestSequence === selfServeStatusRequestSequence) { + isLoadingSelfServeEnabled.value = false; + } } }; const updateSelfServeEnabled = async (enabled) => { + const requestedDepartmentId = String(departmentId.value); + const requestSequence = ++selfServeSaveRequestSequence; const previousValue = selfServeEnabled.value; selfServeEnabled.value = Boolean(enabled); isSavingSelfServeEnabled.value = true; selfServeEnabledError.value = ""; try { - selfServeEnabled.value = await setDepartmentSelfServeEnabled(departmentId.value, enabled); + const status = await setDepartmentSelfServeStatus(requestedDepartmentId, enabled); + if ( + requestSequence !== selfServeSaveRequestSequence + || String(departmentId.value) !== requestedDepartmentId + ) { + return; + } + selfServeEnabled.value = status.enabled; + if (status.hasAutoDeactivation) { + selfServeAutoDeactivation.value = status.autoDeactivation; + } else { + await loadSelfServeEnabled({ + preserveEnabledOnError: true, + requestedDepartmentId, + }); + } } catch (error) { console.log(error); - selfServeEnabled.value = previousValue; - selfServeEnabledError.value = "Unable to update self-serve status."; + if ( + requestSequence === selfServeSaveRequestSequence + && String(departmentId.value) === requestedDepartmentId + ) { + selfServeEnabled.value = previousValue; + selfServeEnabledError.value = "Unable to update self-serve status."; + } } finally { - isSavingSelfServeEnabled.value = false; + if ( + requestSequence === selfServeSaveRequestSequence + && String(departmentId.value) === requestedDepartmentId + ) { + isSavingSelfServeEnabled.value = false; + } } }; -getDepartmentVariables(); getWorkfeedDepartmentOptions(); -loadSelfServeEnabled(); +watch( + () => router.currentRoute.value.params.departmentId, + (nextDepartmentId) => { + selfServeSaveRequestSequence++; + isSavingSelfServeEnabled.value = false; + setDepartment(nextDepartmentId); + departmentVariables.value = []; + selfServeEnabled.value = false; + selfServeAutoDeactivation.value = { + at: null, + timezone: "Europe/Copenhagen", + label: "NEVER", + }; + selfServeEnabledError.value = ""; + getDepartmentVariables(String(nextDepartmentId)); + loadSelfServeEnabled(); + }, + { immediate: true } +);