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.
This commit is contained in:
Jeppe B
2026-07-28 18:34:35 +02:00
committed by GitHub
parent 7a84cd9162
commit 5204536f92
9 changed files with 385 additions and 58 deletions
+28
View File
@@ -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'
+37 -6
View File
@@ -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,
};
+6
View File
@@ -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',
@@ -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) => {
</script>
<template>
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessAdmin()">
<RestrictedPageWrapper :hasPermission="hasPagePermission">
<DepartmentSubPageWrapper v-if="isSuperuserDepartmentRoute">
<template #title>
<PageTitle :title="pageTitle" :subtitle="pageSubtitle" />
</template>
<NotFoundFallBackPageWrapper :exists="hasDepartmentAccess" :error="$t('admin.errors.select_department')">
<table class="table is-striped is-bordered is-hoverable is-fullwidth">
<thead>
<tr>
<th>{{SessionUser.objects.global.language.weekday}}</th>
<th>{{SessionUser.objects.global.language.opening_hour}}</th>
<th>{{SessionUser.objects.global.language.closing_hour}}</th>
</tr>
</thead>
<tbody>
<tr v-for="(weekday, index) in weekdays" :key="index">
<td>{{ weekday.label }}</td>
<td>
<div class="field has-addons">
<div class="control is-expanded" :class="getControlClass(weekday, 'start')">
<input type="time" class="input is-fullwidth" @change="onChangeTime(weekday.name + '_start', $event.target.value)" v-if="isOpeningHoursLoaded()" :value="getOpeningHoursValue(weekday.name + '_start')" :id="weekday.name + '_start'" />
<input type="time" class="input is-skeleton is-fullwidth" v-else disabled/>
</div>
<div class="control">
<button class="button is-danger" @click="onChangeTime(weekday.name + '_start', null)" v-bind:disabled="!getOpeningHoursValue(weekday.name + '_start')">
<span class="icon is-small">
<i class="fas fa-trash"></i>
</span>
</button>
</div>
</div>
</td>
<td>
<div class="field has-addons">
<div class="control is-expanded" :class="getControlClass(weekday, 'end')">
<input type="time" class="input is-fullwidth" @change="onChangeTime(weekday.name + '_end', $event.target.value)" v-if="isOpeningHoursLoaded()" :value="getOpeningHoursValue(weekday.name + '_end')" :id="weekday.name + '_end'" />
<input type="time" class="input is-skeleton is-fullwidth" v-else disabled/>
</div>
<div class="control">
<button class="button is-danger" @click="onChangeTime(weekday.name + '_end', null)" v-bind:disabled="!getOpeningHoursValue(weekday.name + '_end')">
<span class="icon is-small">
<i class="fas fa-trash"></i>
</span>
</button>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</NotFoundFallBackPageWrapper>
</DepartmentSubPageWrapper>
<DepartmentDashboardPageWrapper
:title="SessionUser.objects.global.language.opening_hours"
subtitle="Afdelingens åbningstider"
v-else
:title="pageTitle"
:subtitle="pageSubtitle"
>
<NotFoundFallBackPageWrapper :exists="SessionUser.functions.getDepartmentIdFromUrl() && SessionUser.canAccessDepartment(SessionUser.functions.getDepartmentIdFromUrl())" :error="$t('admin.errors.select_department')">
<NotFoundFallBackPageWrapper :exists="hasDepartmentAccess" :error="$t('admin.errors.select_department')">
<table class="table is-striped is-bordered is-hoverable is-fullwidth">
<thead>
<tr>
@@ -146,4 +235,4 @@ const getOpeningHoursValue = (name) => {
</template>
<style scoped>
</style>
</style>
@@ -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"),
@@ -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 }
);
</script>
<template>
@@ -287,13 +381,15 @@ loadSelfServeEnabled();
:subtitle="$t('superuser_dashboard.department_pages.modules.subtitle')"
>
<template #default>
<ConfigurationSwitch
title="Selvvask"
description="Aktiver selvvask i denne afdeling."
:value="selfServeEnabled"
:disabled="isLoadingSelfServeEnabled || isSavingSelfServeEnabled"
:on-switch="updateSelfServeEnabled"
></ConfigurationSwitch>
<div :title="selfServeAutoDeactivationText">
<ConfigurationSwitch
title="Selvvask"
:description="selfServeAutoDeactivationText"
:value="selfServeEnabled"
:disabled="isLoadingSelfServeEnabled || isSavingSelfServeEnabled"
:on-switch="updateSelfServeEnabled"
></ConfigurationSwitch>
</div>
<p class="help is-danger" v-if="selfServeEnabledError">
{{ selfServeEnabledError }}
</p>
@@ -84,6 +84,7 @@ import { __pushMock, __routeRef } from "vue-router";
const flushMicrotasks = async () => {
await Promise.resolve();
await Promise.resolve();
await nextTick();
};
const createDeferred = () => {
@@ -9,14 +9,47 @@ const source = readFileSync(
describe("DepartmentModulesSetup self-serve management regression", () => {
it("loads and updates department self-serve status through shared helpers", () => {
expect(source).toContain("getDepartmentSelfServeEnabled");
expect(source).toContain("setDepartmentSelfServeEnabled");
expect(source).toContain("getDepartmentSelfServeStatus");
expect(source).toContain("setDepartmentSelfServeStatus");
expect(source).toContain("loadSelfServeEnabled()");
expect(source).toContain("updateSelfServeEnabled");
});
it("renders a dedicated self-serve module switch in setup UI", () => {
expect(source).toContain('title="Selvvask"');
expect(source).toContain("Aktiver selvvask i denne afdeling.");
expect(source).toContain(':title="selfServeAutoDeactivationText"');
expect(source).toContain(':description="selfServeAutoDeactivationText"');
expect(source).toContain("NEVER");
});
it("refetches scheduling metadata for legacy message-only update responses", () => {
expect(source).toContain("if (status.hasAutoDeactivation)");
expect(source).toContain("preserveEnabledOnError: true");
expect(source).toContain("requestedDepartmentId");
expect(source).toContain("if (!preserveEnabledOnError)");
});
it("reloads self-serve status safely when the route department changes", () => {
expect(source).toContain("watch(");
expect(source).toContain("router.currentRoute.value.params.departmentId");
expect(source).toContain("selfServeStatusRequestSequence");
expect(source).toContain("requestSequence !== selfServeStatusRequestSequence");
expect(source).toContain("{ immediate: true }");
});
it("invalidates an in-flight save when the route department changes", () => {
expect(source).toContain("selfServeSaveRequestSequence");
expect(source).toContain("const requestSequence = ++selfServeSaveRequestSequence");
expect(source).toContain("requestSequence !== selfServeSaveRequestSequence");
expect(source).toContain("selfServeSaveRequestSequence++");
expect(source).toContain("isSavingSelfServeEnabled.value = false");
});
it("ignores department-variable responses from a previously selected route department", () => {
expect(source).toContain("departmentVariablesRequestSequence");
expect(source).toContain("const requestSequence = ++departmentVariablesRequestSequence");
expect(source).toContain("requestSequence !== departmentVariablesRequestSequence");
expect(source).toContain("String(departmentId.value) !== requestedDepartmentId");
expect(source).toContain("getDepartmentVariables(String(nextDepartmentId))");
});
});
@@ -0,0 +1,37 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const source = readFileSync(
join(
process.cwd(),
"src/views/dashboards/departmentDashboard/modules/time-bookings/DepartmentTimeBookingsOpeningHours.vue"
),
"utf8"
);
describe("Department opening-hours route synchronization", () => {
it("clears and reloads opening hours when the selected department changes", () => {
expect(source).toContain("departmentId.value, isSuperuserDepartmentRoute.value");
expect(source).toContain("{ immediate: true }");
expect(source).toContain("opening_hours.value = null");
expect(source).toContain("department: selectedDepartmentId");
});
it("ignores a slower response from the previously selected department", () => {
expect(source).toContain("const requestId = ++openingHoursRequest");
expect(source).toContain("requestId !== openingHoursRequest");
});
it("initializes the selected superuser department for direct and reloaded routes", () => {
expect(source).toContain("setDepartment as setSelectedDepartment");
expect(source).toContain("if (isSuperuserRoute)");
expect(source).toContain("setSelectedDepartment(selectedDepartmentId)");
});
it("initializes superuser state when only the route context changes", () => {
expect(source).toContain("isSuperuserDepartmentRoute.value");
expect(source).toContain("[selectedDepartmentId, isSuperuserRoute]");
expect(source).toContain("selectedDepartmentId !== previousDepartmentId");
});
});