Skip to content

[UX] customer-portal — Book service ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch: develop @ 693a76c · Files reviewed: 13 Patterns: forms/date-picker

Summary ​

BookServicePage.vue is a single-page form (not a wizard, despite the queue's advanced/wizard tag), and it is the point where a customer commits to a real service booking. The single most important thing: once "Send Request" is clicked the request becomes immutable server-side and the only mechanism that tells Aadalen about it is a notification email whose failure the backend deliberately swallows — so the page can report success for a booking that was never delivered, after clearing the form the user would need to re-enter. Around that sit a missing idempotency key (unlike every sibling write endpoint in the same API), an attachment list that re-uploads on every save and shows nothing when a draft is reopened, and a form whose seven labels are associated with no control at all.

Positives worth recording: serviceRequest.* has complete en/nb parity (CONTENT-01 passes), the page uses PageLayout so it gets a real <h1> (A11Y-04 passes — see the PROJECT-LEVEL correction), the load failure is surfaced rather than rendered as an empty state, and searchMachines has a proper request-id race guard (:136-157). Errors are never surfaced as raw SDK strings here, so the project-level MSG-02 helper defect does not reach this page.

Project-level, not re-filed: NAV-03 — fails project-wide, see PROJECT-LEVEL.md (one static document title). Also repo-wide rather than feature-specific: there is no onBeforeRouteLeave/beforeunload guard anywhere in apps/web/src, so navigating away silently discards an unsaved booking — that is the FORM-12(d) candidate already listed in PROJECT-LEVEL.md's ID-collision section.

Findings ​

1. A submitted request reports success even when nothing was delivered — High · MSG-06 (proposed, see additions) ​

Where: apps/web/src/pages/BookServicePage.vue:209, apps/api/src/modules/service-requests/service-requests.service.ts:39-54, apps/api/src/common/email/email.service.ts:77-80What: Submitting sets status: "sent", and the only downstream consumer of a sent request is EmailService.sendServiceRequestNotification. A grep of services/worker/src finds no Dataverse command for service requests at all, so that email is the whole delivery path. Two ways it silently does nothing: sendNotificationEmail wraps the call in try { … } catch { this.logger.error(…) } (service-requests.service.ts:52-54), and enqueue returns null without throwing when no recipient is configured (email.service.ts:77-80 — logged as a warning, treated as SKIPPED). create() returns 200 in both cases, the page sets saveSuccess.value = true (:209), then clears the entire form (:214). Why it matters: With the serviceRequest notification-email setting unset (or Redis unavailable), a customer books a repair, is told it succeeded, loses everything they typed, cannot edit the record (the backend refuses updates to a sent request, service-requests.service.ts:110-112) and cannot cancel it (there is no delete endpoint in the controller). Aadalen never hears about it. Nobody on either side can detect the failure. Severity call: this is borderline Blocker. Under correct configuration the happy path works, which is why I placed it at High — but in the misconfigured or degraded state it is a Blocker in practice, because the feature silently does nothing while claiming otherwise, and the copy that would let the user notice was deliberately removed. Fix: Have create/update return the delivery outcome (email row id or a notified: false flag) rather than discarding it, and have the page render "received, but we could not notify the service desk — call +47 69 14 15 00" instead of a plain success when delivery was not recorded. Longer term, the booking should not depend on a best-effort email at all; it should go through the durable command store like every other write.

2. An irreversible submit with no confirmation, and success copy that says "saved" — High · MSG-05, CONTENT-03 ​

Where: apps/web/src/pages/BookServicePage.vue:472-487; copy at apps/web/src/i18n/messages/en.yml → serviceRequest.saveSuccessWhat: "Send Request" (:473-479) calls save('sent') directly — no ConfirmDialog, no summary of what is being committed. The action is irreversible: service-requests.service.ts:110-112 throws Cannot update a sent request, and the controller exposes no cancel or delete. It also sits immediately beside "Save as Draft" with identical styling apart from severity. Worse, both actions show the same message: saveSuccess: "Service request saved successfully." / "Serviceforespørsel lagret." — so the user who clicks Send is told their request was saved, which is the word for the other, reversible button. Why it matters: A user who meant to save a draft and mis-clicked Send gets confirmation language that matches what they intended to do, and has no way to undo, edit, or even tell that anything different happened. MSG-05 requires the irreversible action to confirm first and say what will be lost. Fix: Confirm before save('sent'), naming the machine, service type and date. Split the copy: sendSuccess ("Request sent — we'll be in touch within one working day") vs draftSaved. Ideally add a cancel path for a sent-but-unhandled request.

3. No idempotency key on the write, unlike every sibling endpoint — High · FORM-IDEMPOTENT-WRITE (proposed) ​

Where: apps/web/src/pages/BookServicePage.vue:176-198, apps/api/src/modules/service-requests/service-requests.controller.ts:43-53What: serviceRequestsCreate (:192-195) sends no Idempotency-Key header, and the controller accepts none — while contact.controller.ts:23, bids.controller.ts:55 and :73, receipts, customer-assets and chat all declare @ApiHeader({ name: "idempotency-key", required: true }) and route through apps/api/src/common/idempotent-command.ts. This directly contradicts the repo's own rule (apps/api/CLAUDE.md §3, root CLAUDE.md: "all mutation endpoints require an Idempotency-Key header"). save() (:176) also has no re-entrancy guard of its own — the only protection is the :disabled binding at :474, which depends on Vue's DOM flush landing between two clicks. The DTO adds nothing: CreateServiceRequestBody (service-requests.dto.ts:44-67) carries only Swagger decorators, no class-validator. Why it matters: A POST that times out at the proxy but succeeds at the server leaves the user with an error message and a filled form; retrying creates a second real service request. Neither the customer nor the page can tell, and there is no way to withdraw either one (see finding 2). Aadalen dispatches twice. Severity call: High rather than Blocker — it needs a retry or a network fault to fire, not a single normal click. Fix: Generate one key per booking attempt (not per click), store it in a ref so a retry reuses it, and add the @Headers("idempotency-key") + idempotentCommand wrapper the sibling modules already use. Add if (isSaving.value) return; at the top of save().

4. Attachments re-upload on every save, and a reopened draft shows none of them — High · FORM-09 ​

Where: apps/web/src/pages/BookServicePage.vue:200-215, :88-130What: Three connected defects in the attachment lifecycle:

  1. The upload loop (:201-207) posts every entry in attachments.value, but the array is cleared only in the status === "sent" branch (:211-215). Attach a file, "Save as Draft", change the description, "Save as Draft" again — the same file is uploaded a second time.
  2. onMounted (:88-130) restores the draft's scalar fields and re-fetches the machine, but never lists the draft's existing documents. Reopen a saved draft and the attachment area is empty even though the server holds the files — so the user cannot see them, cannot remove them, and naturally re-attaches, producing a third copy.
  3. If serviceRequestsCreate succeeds but an upload throws, the catch at :222 sets saveError for the whole operation, even though the service request was in fact created (and existingDraftId was already set at :197). Why it matters: The service desk receives the same photo three times and the customer is shown a failure for a booking that exists. Files the user attached are invisible and unremovable after any page reload — including any they attached by mistake. Fix: Fetch the draft's documents on mount and render them alongside the pending ones with a delete action; clear attachments.value after a successful upload regardless of status; report partial failure ("request saved, 1 of 2 attachments failed — retry upload") instead of a blanket error.

5. No label is associated with any control — High · FORM-01 ​

Where: apps/web/src/pages/BookServicePage.vue:313, :336, :357, :372, :384, :398, :411What: All seven <label> elements are bare <label class="mb-1 block …"> — no for, and they are siblings rather than wrappers. No inputId, labelId or aria-label is passed to AutoComplete (:316), Select (:345, :360, :387), DatePicker (:375) or Textarea (:401). Why it matters: A screen-reader user hears "combobox" / "edit text" with no name on every field of a booking form, and cannot tell the Priority select from the Preferred Time select. Clicking a label focuses nothing, which also costs sighted mouse users a hit target. The forms/date-picker reference markup pairs <label for="appt-date"> with an aria-describedby help element for exactly this reason. Fix: Give each control an input-id and point the label's for at it. The required-field asterisks (:314, :337, :373) should also be conveyed non-visually (aria-required plus a legend explaining *), and the duplicated "Select Machine *" placeholder (:321) can then be dropped.

6. The attach-file control cannot be reached from the keyboard — High · A11Y-03 ​

Where: apps/web/src/pages/BookServicePage.vue:439-449What: The file input is <input type="file" class="hidden">. Tailwind's hidden is display: none, which removes the element from the accessibility tree and the tab order. The wrapping <label> that provides the visible affordance is a plain <label> with no tabindex, role="button" or key handler, so it is not focusable either. Why it matters: Keyboard-only and switch users cannot attach a photo of the fault to a service request at all — and photos are the main way a customer describes a machine problem. There is no alternative path. Fix: Use a visually-hidden-but-focusable technique (sr-only, or opacity-0 absolute inset-0) instead of hidden, so the input keeps focus and Enter/Space opens the picker; or drive a real <Button> that calls input.click().

7. The preferred date accepts any value, including dates in the past — Medium · FORM-04 ​

Where: apps/web/src/pages/BookServicePage.vue:375-380; apps/api/src/modules/service-requests/service-requests.dto.ts:44-67What: <DatePicker> sets date-format, class and input-class and nothing else — no :min-date, :max-date or :disabled-days. The backend does not compensate: CreateServiceRequestBody has only @ApiString (a thin @ApiProperty wrapper, apps/api/src/common/swagger.decorators.ts:4) with no class-validator decorators, so preferredDate is passed straight to new Date(...) and stored (service-requests.service.ts:84). Why it matters: A customer can request service for last Tuesday, get a success message, and only find out by phone. Nor is any constraint stated before they choose — service hours ("Mon-Fri 07:00-18:00") are shown in a sidebar card, but the picker happily accepts Sunday. The forms/date-picker pattern has a dedicated With Min/Max Date Range variation and lists "cannot select dates outside the allowed range" as the reason to use a calendar at all. Fix: :min-date="new Date()" plus a lead-time offset if one applies, disable weekends, and add aria-describedby help text stating the constraint before the user opens the calendar. Validate preferredDate server-side too.

8. Send is disabled while invalid, with nothing telling the user why — Medium · FORM-05, FORM-06 ​

Where: apps/web/src/pages/BookServicePage.vue:73, :473-479What: isFormValid (:73) requires machine, service type and preferred date, and :disabled="!isFormValid || isSaving" (:474) greys out the submit until all three are set. No field ever renders an error, a hint or an invalid state — the only signal is the three * characters. FORM-06 additionally: the button is disabled during submission (both via :disabled="… || isSaving" and :loading="isSaving"), so focus drops to <body> at the moment the user activates it. Why it matters: A user who filled two of the three required fields sees a dead button and no explanation of what is missing; the usual response is to re-check every field or leave. Keyboard users lose their place on submit and the "loading" state is never announced. Fix: Keep the button enabled, validate on submit, and show per-field messages naming what is missing. Replace the submit-time disabled with aria-busy="true" plus the if (isSaving.value) return; guard from finding 3.

9. A service type prefilled from the URL is locked and unvalidated — Medium · FORM-PREFILL-EDITABLE (proposed) ​

Where: apps/web/src/pages/BookServicePage.vue:74, :339-353What: When ?serviceType= is present, isServiceTypePrefilled (:74) replaces the Select with a static read-only <div> (:339-344). There is no control to unlock it, and goBack is the only way out. The value is never checked against serviceTypeOptions: t(\serviceRequest.serviceTypes.${form.serviceType}`, form.serviceType) (:343) falls back to echoing the raw query string, and it is submitted verbatim (:166) into an unvalidated DTO. **Why it matters:** A customer who follows a link from the wrong place — or an old bookmark — cannot correct the service type and must abandon the form and start over. And ?serviceType=Free%20warranty%20repairrenders as the confirmed service type in the UI and lands in the record and the notification email, which is a plausible way to make a request look pre-approved. (Vue escapes the text, so this is not XSS; the issue is unvalidated data presented as authoritative.) **Fix:** Treat the query value as a *default selection* in the normalSelect` rather than a locked display; ignore it unless it matches a known option; add an enum validator on the DTO.

10. Load failure tells the user to try again but offers no way to — Medium · MSG-03, MSG-04 ​

Where: apps/web/src/pages/BookServicePage.vue:293-299What: On any failure in onMounted the page renders common.loadError — "Kunne ikke laste innholdet. Prøv igjen." / "Could not load the content. Try again." — in a :closable="false" Message, and nothing else. There is no retry button, even though common.retry ("Prøv igjen") exists in both locales, and the whole form is replaced, so the user cannot proceed at all. Why it matters: The copy instructs the user to do something the page provides no control for; the only recovery is a manual browser reload. Note that Promise.all (:90) means a failure of the history list alone blocks the booking form, which does not depend on it. Fix: Add a retry button that re-runs the loader, and settle the two requests independently so a failed history fetch degrades that panel instead of the form.

11. Icon-only remove-attachment button has no accessible name — Low · A11Y-05 ​

Where: apps/web/src/pages/BookServicePage.vue:428-436What: <Button icon="pi pi-times" rounded severity="danger" size="small" text /> with no aria-label and no visible text. Why it matters: Announced as "button" with no indication of what it removes; with several attachments listed, a screen-reader user has no way to tell them apart. Fix: :aria-label="t('serviceRequest.fields.removeAttachment', { name: att.file.name })".

Where: apps/web/src/pages/BookServicePage.vue:516-524What: The machine name in each previous-request card is a <button> calling router.push (:20-22), decorated with pi pi-external-link (:523). Why it matters: It cannot be middle-clicked, opened in a new tab, or have its target previewed, and the external-link icon promises a new tab that never opens — so the user loses their place in the booking form when it navigates in-place. Fix: Use <RouterLink :to="{ name: 'customer-assets.detail', params: { id } }"> and drop the external-link icon (or keep it and add target="_blank" semantics).

Unverified ​

  • A11Y-01 (contrast) — needs computed colour values. text-text-3 on bg-sunken for the attachment file size (:426) and text-text-2 on bg-paper for the dashed upload affordance (:439) are the two worth measuring; both are the muted end of the ramp on a light surface.
  • A11Y-06 (short viewport / mobile keyboard) — needs a rendered page. Specific thing to check: the success/error Message renders between the last field and the action row (:453-470), so on a 320px viewport with the keyboard open it may push the buttons off-screen, or appear off-screen itself after a long form.
  • MSG-01 — whether PrimeVue's Message carries role="alert" (error) and role="status" (success) could not be confirmed: node_modules is not installed in this checkout. If it does not, findings 1 and 10 are silent to screen-reader users. The isLoading skeleton (:256-291) has no aria-busy/live region either way, and the load→content swap is announced by nothing.
  • FORM-06 — whether PrimeVue's :loading prop additionally sets disabled is unverifiable here, but it does not affect the finding: :disabled is bound explicitly at :474 and :481, so the focus drop is asserted from this file.
  • DatePicker / AutoComplete internals — calendar-grid keyboard navigation, aria-activedescendant, focus return on close, and the AutoComplete's listbox semantics are all PrimeVue's responsibility and could not be read. The forms/date-picker pattern flags "no keyboard navigation in calendar grid" and "not returning focus after close" as its top anti-patterns; worth a rendered check rather than an assumption.
  • complete-on-focus on the machine AutoComplete (:318) triggers a search on focus. Whether that is disruptive (the pattern lists "opening the calendar on input focus" as an anti-pattern for the analogous case) needs a rendered test.

Baseline additions ​

Descriptive IDs plus definitions; the orchestrator should renumber and merge.

  • MSG-DELIVERY-UNCONFIRMED — Success is claimed only for state the server has confirmed. When the sole delivery mechanism for a user's commitment is a queued, best-effort or fire-and-forget side effect, its outcome must be propagated to the UI; a swallowed failure must never render as success. This is the same rule as the MSG-06 (d) variant already listed in PROJECT-LEVEL.md ("result-returning SDKs must be inspected") and the playout "success feedback fires before the write resolves" note — merge, do not add a new ID.
  • FORM-IDEMPOTENT-WRITE — A request that creates a real-world commitment carries an idempotency key that is stable across retries of the same user intent, and its handler guards against re-entry. Relying on a disabled attribute to prevent a double submit is not sufficient.
  • FORM-ATTACHMENT-STATE — Files already attached to a persisted draft are listed and removable when that draft is reopened, and re-saving does not re-upload them. Generalises to any child collection saved alongside a parent record.
  • FORM-PREFILL-EDITABLE — A value prefilled from the URL remains editable and is validated against the allowed set before being displayed as confirmed. Distinct from the existing FORM-09 (which is about not retyping) — this is about not being trapped by, or misled by, a value you did not choose.

Cross-project note ​

  • Finding 1 (queued/best-effort work reported as success) is the cross-project MSG-06 theme, already marked ✗ for all four projects in PROJECT-LEVEL.md, and was the specific shape the bids and marketplace audits found in this repo. This is a third confirmed instance in customer-portal — strong enough to promote to a real baseline rule rather than a candidate.
  • Finding 3 (missing idempotency key) is, unusually, not a project-wide pattern here — five sibling modules do it correctly, so this is one endpoint that was missed. The second half (no in-flight guard in the handler, relying on disabled) is likely repo-wide and worth grepping for in the other three.
  • Findings 5 and 8 (unassociated labels, disabled-until-valid submit) — tt-time-tracker is the other PrimeVue project and the same <label>-without- for shape should be checked there; playout uses FormKit, which associates labels automatically, so it is probably clean.
  • Finding 6 (display:none file input) — check any file-upload surface in playout and members; the class="hidden" idiom is a common Tailwind mistake and this repo also uses it in the warranty submission flow (feature #1), which is public and therefore higher-stakes.
  • Currency: not applicable — this feature displays no money, so it adds no data point to the maximumFractionDigits table.