diff --git a/openapi.yaml b/openapi.yaml index a2baa51e..cad439ec 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -4772,6 +4772,7 @@ paths: tags: - Self-Serve summary: Check whether self-serve is allowed for a vehicle on a lane + description: Customers with own self-serve permissions may evaluate any registration plate for their wash. Persisted self-serve answers are only applied when they are scoped to the authenticated customer. operationId: getSelfserveVehicleAllowed parameters: - name: lane_id @@ -8737,6 +8738,7 @@ paths: description: | Send a command (e.g., start, stop, reset) to a self-serve lane. Property gate commands (`OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`) are also supported here. + Property gate command permissions are bypassed for authenticated customers with an active self-serve wash in the target department. operationId: sendSelfServeLaneCommand requestBody: required: true diff --git a/src/views/dashboards/userDashboard/wash/MyWashStart.vue b/src/views/dashboards/userDashboard/wash/MyWashStart.vue index 9468cebc..9c7153f4 100644 --- a/src/views/dashboards/userDashboard/wash/MyWashStart.vue +++ b/src/views/dashboards/userDashboard/wash/MyWashStart.vue @@ -274,6 +274,22 @@ const normalizeLicensePlate = (value: string | null) => (value || "").trim().toU const getNumericCustomerNumber = () => resolveEffectiveCustomerNumber(customerNumberInput.value); +const unwrapApiData = (response: any) => response?.data?.data ?? response?.data ?? response; + +const parseServerDateTimeMs = (value: any) => { + if (!value) { + return null; + } + + const normalized = String(value).trim(); + if (!normalized) { + return null; + } + + const timestamp = Date.parse(normalized.includes("T") ? normalized : normalized.replace(" ", "T")); + return Number.isFinite(timestamp) ? timestamp : null; +}; + const extractErrorMessage = (error: any, fallback: string) => { if (error?.response?.data?.data?.message) { return error.response.data.data.message; @@ -454,6 +470,144 @@ const fetchSelfServeData = async () => { ); }; +const getServerActiveWashCandidates = () => { + const departments = Array.isArray(guestDepartments.value) ? guestDepartments.value : []; + return departments.flatMap((department: any) => { + const lanes = Array.isArray(department?.lanes) ? department.lanes : []; + return lanes + .map((lane: any) => ({ + department, + lane, + laneId: normalizeLaneId(lane?.id), + })) + .filter((candidate: any) => candidate.laneId); + }); +}; + +const isAuthenticatedCustomerActiveWash = (details: any, customerNumber: number) => { + if (!details?.in_progress) { + return false; + } + + const sessionCustomerNumber = normalizePositiveInteger( + details?.session?.customer_number + ?? details?.customer?.customer_number + ); + + return sessionCustomerNumber === customerNumber; +}; + +const activeWashTimestamp = (details: any) => ( + parseServerDateTimeMs(details?.session?.wash_started_at) + ?? parseServerDateTimeMs(details?.session?.machine_start_triggered_at) + ?? parseServerDateTimeMs(details?.session?.machine_relay_enabled_at) + ?? parseServerDateTimeMs(details?.session?.updated_at) + ?? parseServerDateTimeMs(details?.session?.created_at) + ?? 0 +); + +const fetchServerActiveWash = async () => { + const customerNumber = getAuthenticatedCustomerNumber(); + if (!customerNumber) { + return null; + } + + const candidates = getServerActiveWashCandidates(); + if (candidates.length === 0) { + return null; + } + + const responses = await Promise.allSettled(candidates.map(async (candidate: any) => { + const response = await SessionUser.request("/modules/self-serve/lane/wash/in-progress", "GET", { + lane_id: candidate.laneId, + }); + return { + ...candidate, + details: unwrapApiData(response), + }; + })); + + const activeMatches = responses + .filter((entry): entry is PromiseFulfilledResult => entry.status === "fulfilled") + .map((entry) => entry.value) + .filter((entry) => isAuthenticatedCustomerActiveWash(entry.details, customerNumber)) + .sort((left, right) => activeWashTimestamp(right.details) - activeWashTimestamp(left.details)); + + return activeMatches[0] || null; +}; + +const applyServerActiveWash = async (activeWash: any) => { + const details = activeWash?.details; + const session = details?.session || {}; + const vehicle = details?.vehicle || {}; + const laneId = normalizeLaneId(details?.lane_id ?? session?.lane_id ?? activeWash?.laneId); + const departmentId = normalizeLaneId(activeWash?.department?.id ?? session?.department_id ?? activeWash?.lane?.department); + const reg = normalizeLicensePlate(session?.reg ?? vehicle?.reg ?? licensePlateInput.value); + + if (!laneId || !reg) { + return false; + } + + if (departmentId && nearestDepartment.value?.id !== departmentId) { + isForcingNearestDepartment.value = true; + forceNearestDepartmentEvaluationId.value = departmentId; + evaluateLocationDepartments(locations.location.value); + } + + washInProgress.value = true; + washLaneId.value = laneId; + radioLaneOption.value = laneId; + licensePlateInput.value = reg; + applyEffectiveCustomerNumberInput(session?.customer_number ?? details?.customer?.customer_number); + vehicleTypeSelect.value = normalizePositiveInteger(session?.vehicle_type_id ?? vehicle?.type) ?? vehicleTypeSelect.value; + radioWashType.value = session?.machine_relay_enabled ? "Machine" : "Manual"; + washStartTime.value = ( + parseServerDateTimeMs(session?.wash_started_at) + ?? parseServerDateTimeMs(session?.machine_start_triggered_at) + ?? parseServerDateTimeMs(session?.machine_relay_enabled_at) + ?? Date.now() + ); + completedDurationMs.value = null; + editAnswers.value = false; + washActionError.value = null; + vehicleStepError.value = null; + currentStep.value = steps.WASH_IN_PROGRESS; + + startElapsedTimer(); + + const summaryParams: Record = session?.id + ? { session_id: session.id } + : { lane_id: laneId, reg }; + const selectedVehicleTypeId = normalizePositiveInteger(vehicleTypeSelect.value); + if (selectedVehicleTypeId) { + summaryParams.vehicle_type = selectedVehicleTypeId; + } + + const summary = await fetchWashSummary(summaryParams, false); + if (!summary) { + await fetchSelfServeDataInternal( + departmentId || nearestDepartment.value?.id, + selectedVehicleTypeId, + laneId, + reg + ); + } + + saveProgress("serverActiveWash"); + return true; +}; + +const restoreServerActiveWash = async () => { + try { + const activeWash = await fetchServerActiveWash(); + if (activeWash) { + await applyServerActiveWash(activeWash); + } + } catch (error) { + console.warn("Failed to restore active self-serve wash:", error); + } +}; + const retrySelfServeData = async () => { if (isSelfServeRetrying.value) { return; @@ -589,7 +743,13 @@ onMounted(async () => { applyEffectiveCustomerNumberInput(customerNumberInput.value); await fetchCustomerVehicles(); await fetchVehicleTypes(); - restoreProgress(); + const restoredProgress = restoreProgress(); + if (!restoredProgress?.washInProgress) { + const restoreDelayMs = restoredProgress ? 1600 : 0; + window.setTimeout(() => { + restoreServerActiveWash(); + }, restoreDelayMs); + } }); onUnmounted(() => { diff --git a/tests/e2e/self-serve-wash.spec.js b/tests/e2e/self-serve-wash.spec.js index 9ed3f7ae..97640fb6 100644 --- a/tests/e2e/self-serve-wash.spec.js +++ b/tests/e2e/self-serve-wash.spec.js @@ -191,6 +191,74 @@ test.describe("Self-serve wash", () => { await expect(page.getByTestId("self-serve-nav-complete")).toBeVisible(); }); + test("start route resumes the authenticated customer's active server wash from another device", async ({ page }) => { + const requests = captureSelfServeGatewayRequests(page); + + await mockApi(page, { + authenticated: true, + permissions: ["user"], + sessionData: { + customer_number: 12345679, + }, + selfServe: { + inProgressByLaneId: { + 9: { + lane_id: 9, + in_progress: true, + session: { + id: 704, + lane_id: 9, + department_id: 2, + reg: "CD67890", + customer_number: 12345679, + vehicle_type_id: 2, + machine_relay_enabled: true, + wash_started_at: "2026-04-28 10:15:00", + status: "MACHINE_STARTED", + }, + customer: { customer_number: 12345679 }, + vehicle: { reg: "CD67890", type: 2 }, + }, + }, + summaryBySessionId: { + 704: { + session: { + id: 704, + lane_id: 9, + reg: "CD67890", + status: "MACHINE_STARTED", + allowed: true, + vehicle_type_id: 2, + }, + lane: { id: 9, name: "9", department: 2 }, + questions: [], + conditions: [], + rules: [], + tasks: [], + events: [{ id: 7041, type: "MACHINE_STARTED", created_at: "2026-04-28T10:15:00.000Z" }], + }, + }, + }, + }); + await primeSession(page, { + token: "self-serve-active-server-wash-token", + permissions: ["user"], + }); + + await page.goto("/user/wash/start"); + await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId("self-serve-department-name")).toContainText("Odense"); + expect(requests.commands).toHaveLength(0); + + const accessCommandRequestPromise = waitForLaneCommandRequest(page, "OPEN_PROPERTY_ACCESS_GATE"); + await page.getByTestId("self-serve-nav-open-property-access-gate").click(); + const accessCommandRequest = await accessCommandRequestPromise; + expect(accessCommandRequest.postDataJSON?.()).toMatchObject({ + lane_id: 9, + command: "OPEN_PROPERTY_ACCESS_GATE", + }); + }); + test("superuser can force a department and clear the override", async ({ page }) => { await mockApi(page, { authenticated: true, diff --git a/tests/e2e/support/network.js b/tests/e2e/support/network.js index d36472f4..4f18e3ed 100644 --- a/tests/e2e/support/network.js +++ b/tests/e2e/support/network.js @@ -99,6 +99,10 @@ function mergeFixture(base, overrides = {}) { ...(base.laneAllowedServicesByLane || {}), ...(overrides.laneAllowedServicesByLane || {}), }, + inProgressByLaneId: { + ...(base.inProgressByLaneId || {}), + ...(overrides.inProgressByLaneId || {}), + }, relayStatuses: { ...(base.relayStatuses || {}), ...(overrides.relayStatuses || {}), @@ -1010,6 +1014,7 @@ function createSelfServeFixture(overrides = {}) { laneAllowedServices: ["MACHINE"], laneAllowedServiceResponse: null, laneAllowedServiceResponses: null, + inProgressByLaneId: {}, allowedServiceRequests: [], answerRequests: [], commandResponse: { success: true }, @@ -6113,6 +6118,23 @@ export async function mockApi(page, options = {}) { return; } + if (pathname.endsWith("/modules/self-serve/lane/wash/in-progress") && method === "GET") { + const laneId = parsedUrl.searchParams.get("lane_id"); + const details = selfServe.inProgressByLaneId?.[String(laneId || "")] || null; + await route.fulfill( + json({ + data: details || { + lane_id: Number(laneId || 0), + in_progress: false, + session: null, + customer: null, + vehicle: null, + }, + }) + ); + return; + } + if (pathname.endsWith("/department/selfserve/tasks/attachments") && method === "GET") { const taskId = parsedUrl.searchParams.get("id"); await route.fulfill( diff --git a/tests/unit/my-wash-start.spec.js b/tests/unit/my-wash-start.spec.js index b805ce88..c4764400 100644 --- a/tests/unit/my-wash-start.spec.js +++ b/tests/unit/my-wash-start.spec.js @@ -81,8 +81,8 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({ }, canAccessSuperUser: () => true, request: vi.fn(async (...args) => { - mocks.sessionRequest(...args); - return { status: 200, data: { data: {} } }; + const response = await mocks.sessionRequest(...args); + return response ?? { status: 200, data: { data: {} } }; }), functions: { contact: { @@ -337,6 +337,7 @@ describe("MyWashStart", () => { mocks.setShowFooterInContent.mockClear(); mocks.addVehicle.mockClear(); mocks.sessionRequest.mockClear(); + mocks.sessionRequest.mockResolvedValue(undefined); mocks.openPropertyAccessGate.mockClear(); mocks.openPropertyExitGate.mockClear(); }); @@ -577,4 +578,81 @@ describe("MyWashStart", () => { expect(mocks.openPropertyAccessGate).toHaveBeenCalledWith(7); expect(mocks.openPropertyExitGate).toHaveBeenCalledWith(7); }); + + it("restores an active wash from the server when local progress is missing", async () => { + mocks.nearestDepartment.value = { + id: 6, + name: "Roskilde", + address: "Industrivej 45, 4000 Roskilde", + self_serve_enabled: true, + lanes: [{ id: 7, name: "7", status: "AVAILABLE", products: [2], machine_available: true }], + }; + const odense = { + id: 2, + name: "Odense", + address: "Beta 2", + self_serve_enabled: true, + lanes: [{ id: 9, name: "9", status: "OCCUPIED", products: [2], machine_available: true }], + }; + mocks.guestDepartments.value = [mocks.nearestDepartment.value, odense]; + mocks.sessionRequest.mockImplementation(async (path, method, payload) => { + if (path === "/modules/self-serve/lane/wash/in-progress" && method === "GET" && payload?.lane_id === 9) { + return { + data: { + data: { + lane_id: 9, + in_progress: true, + session: { + id: 704, + reg: "CD67890", + customer_number: 12345679, + vehicle_type_id: 2, + machine_relay_enabled: true, + wash_started_at: "2026-04-28 10:15:00", + }, + customer: { customer_number: 12345679 }, + vehicle: { reg: "CD67890", type: 2 }, + }, + }, + }; + } + + return { + data: { + data: { + lane_id: payload?.lane_id, + in_progress: false, + session: null, + customer: null, + vehicle: null, + }, + }, + }; + }); + + const wrapper = mountWithApp(MyWashStart, { + global: { + stubs: stubComponents, + }, + }); + + await flushPromises(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await flushPromises(); + + expect(mocks.sessionRequest).toHaveBeenCalledWith("/modules/self-serve/lane/wash/in-progress", "GET", { + lane_id: 7, + }); + expect(mocks.sessionRequest).toHaveBeenCalledWith("/modules/self-serve/lane/wash/in-progress", "GET", { + lane_id: 9, + }); + expect(mocks.fetchWashSummary).toHaveBeenCalledWith({ session_id: 704, vehicle_type: 2 }, false); + expect(mocks.startElapsedTimer).toHaveBeenCalled(); + expect(mocks.saveProgress).toHaveBeenCalledWith("serverActiveWash"); + + const accessGateButton = wrapper.get('[data-testid="self-serve-nav-open-property-access-gate"]'); + expect(accessGateButton.attributes("style") || "").not.toContain("display: none"); + await accessGateButton.trigger("click"); + expect(mocks.openPropertyAccessGate).toHaveBeenCalledWith(9); + }); });