# Self-Serve Module
This document explains the current self-serve implementation in `services/nginx/app/modules/selfserve`.
It is written for two audiences:
- API consumers who need to configure or call the self-serve endpoints.
- Engineers who need to extend the module without reverse-engineering the whole flow.
`openapi.yaml` in the repository root remains the contract source of truth. This guide explains how the module behaves at runtime and how the pieces fit together.
## What The Module Does
The self-serve module lets a customer answer eligibility questions for a vehicle, derives the tasks that apply to the lane, enables the machine relay when the answers allow it, records the physical machine start through a webhook, bills the wash when the lane is stopped, and keeps a session summary with questions, answers, tasks, and lifecycle events.
At a high level:
1. Shared self-serve questions are loaded.
2. Machine-type-specific conditions, rules, and tasks are resolved for the lane.
3. Vehicle answers are evaluated into condition results.
4. If all visible questions are answered and an active task exposes the `MACHINE` service, the lane relay is enabled.
5. When the physical machine button is pressed, `/relay/button/press/post` records the machine start.
6. When the lane is stopped, the wash is invoiced and the latest open self-serve session is completed with the generated order id.
## Main Flow
```mermaid
sequenceDiagram
autonumber
participant Customer
participant API as "Department Self-Serve API"
participant Flow as "selfserve_wash_flow"
participant Lane as "selfserve_lane"
participant Shelly as "Shelly relay"
participant Scanner as "Plate scanner webhook"
Customer->>API: GET /department/selfserve/vehicle/allowed?lane_id=12®=AB12345
API->>Flow: previewVehicleEligibility()
Flow-->>API: Questions, tasks, allowed=false/true
API-->>Customer: Eligibility preview
Customer->>API: POST /department/selfserve/vehicle/conditions
API->>Flow: synchronizeSession()
Flow->>Lane: setLaneCache(allowed_services)
Flow->>Lane: turnOnRelay(MACHINE)
Lane->>Shelly: switch(true)
Flow-->>API: Session summary with MACHINE_RELAY_ENABLED event
API-->>Customer: Updated summary
Scanner->>API: POST /relay/button/press/post
API->>Flow: recordMachineStartWebhook()
Flow->>Lane: set status/state, reg, customer, wash timer
Flow-->>API: Session summary with MACHINE_START_TRIGGERED event
Customer->>API: POST /modules/self-serve/lane/command (STOP)
API->>Lane: execute(STOP)
Lane->>Lane: invoice() and open exit port
Lane->>Flow: completeLatestSessionForLane(order_id)
Flow-->>API: Session summary with SESSION_COMPLETED event
API-->>Customer: Lane reset response
```
## Architecture
```mermaid
flowchart LR
Q["department_selfserve_questions
shared questions preferred"] --> F["selfserve_wash_flow"]
VC["department_selfserve_vehicle_conditions
vehicle answers"] --> F
C["department_selfserve_conditions
machine-type or legacy"] --> E["selfserve_condition_evaluator"]
R["department_selfserve_condition_rules"] --> E
T["department_selfserve_tasks
machine-type or legacy"] --> F
MT["selfserve_machine_types"] --> L["department_lanes.machine_type_id"]
E --> F
L --> F
F --> SL["selfserve_lane"]
SL --> SH["Shelly machine relay"]
F --> S["selfserve_wash_sessions"]
F --> SA["selfserve_wash_session_answers"]
F --> ST["selfserve_wash_session_tasks"]
F --> SE["selfserve_wash_session_events"]
```
## Domain Model
### Entity Roles
| Concept | Main object/class | Scope | Purpose |
| --- | --- | --- | --- |
| Shared questions | `department_selfserve_questions_o` | Shared rows are preferred over legacy lane/product rows | Defines the yes/no questions the customer must answer. |
| Conditions | `department_selfserve_conditions_o` | Prefer `machine_type_id`, else fall back to legacy department/lane/product rows | Groups rules into named boolean gates. |
| Condition rules | `department_selfserve_condition_rules_o` | Attached to a condition | Evaluates question answers or nested conditions. |
| Tasks | `department_selfserve_tasks_o` | Prefer `machine_type_id`, else fall back to legacy department/lane/product rows | Drives the visible self-serve tasks and exposed services such as `MACHINE`. |
| Machine types | `selfserve_machine_types_o` | Reusable across lanes | Lets multiple lanes share the same conditions and tasks. |
| Vehicle answers | `department_selfserve_vehicle_conditions_o` | Per department, lane, registration, and question | Stores the customer's answers. |
| Wash session | `selfserve_wash_sessions_o` | Per lane and vehicle | Tracks current wash lifecycle, timestamps, machine flags, and billing order. |
| Session answers | `selfserve_wash_session_answers_o` | Per session | Snapshot of the answered visible questions. |
| Session tasks | `selfserve_wash_session_tasks_o` | Per session | Snapshot of the active tasks and attached services/buttons. |
| Session events | `selfserve_wash_session_events_o` | Per session | Audit trail for sync, relay enable, machine start, and completion. |
| Lane runtime | `selfserve_lane` | Per lane | Applies lane status/state changes, relay control, billing, and STOP/RESET behavior. |
### Scoping Rules
- Questions should now be defined as shared questions. `selfserve_wash_flow::loadQuestions()` first calls `department_selfserve_questions_o::getSharedQuestions()`. If shared questions exist, they are used for every department and lane.
- Conditions and tasks are machine-type-first. If a lane has `department_lanes.machine_type_id` and matching rows exist, `selfserve_wash_flow` uses those rows and ignores the legacy lane/product rows.
- Legacy fallback still exists. If a lane has no machine type or there are no machine-type-specific rows, the flow falls back to the old department/lane/product plus vehicle-type lookup.
- Vehicle answers are still stored per department, lane, registration number, and question. Those answers feed both direct task gates and nested condition evaluation.
### Runtime Status Tables
#### Wash Session Status
| Status | Meaning |
| --- | --- |
| `PENDING_QUESTIONS` | At least one visible question has no answer yet. |
| `READY_FOR_MACHINE_START` | The snapshot is eligible and ready to enable or start the machine. |
| `MACHINE_NOT_ALLOWED` | All visible questions are answered, but the lane cannot start the machine. |
| `MACHINE_RELAY_ENABLED` | The relay has already been enabled for the session. |
| `MACHINE_STARTED` | The physical machine start webhook has been recorded. |
| `COMPLETED` | The STOP flow completed the session, optionally with an `order_id`. |
#### Wash Event Types
| Event | Meaning |
| --- | --- |
| `SESSION_SYNCED` | A snapshot was written to the current session. |
| `MACHINE_RELAY_ENABLED` | The relay was enabled because the snapshot allowed machine start. |
| `MACHINE_START_TRIGGERED` | The physical machine start webhook was recorded. |
| `SESSION_COMPLETED` | The latest open session for the lane was closed, usually from STOP. |
#### Lane Status
| Status | Meaning |
| --- | --- |
| `AVAILABLE` | Lane is free and ready. |
| `OCCUPIED` | Lane is currently in use. |
| `RESERVED` | Lane is reserved but not yet started. |
| `FAULT` | Lane cannot be used until the fault is cleared. |
| `MAINTENANCE` | Lane is intentionally unavailable. |
| `CLOSED` | Lane is closed. |
#### Lane State
| State | Meaning |
| --- | --- |
| `IDLE` | Resting lane state. |
| `ENTRANCE_PORT_OPEN_QUEUED` | Entrance gate open is queued. |
| `ENTRANCE_PORT_OPEN` | Entrance gate is open. |
| `MACHINE_RELAY_ON_QUEUED` | Machine relay enable is queued. |
| `MACHINE_RELAY_ON` | Machine relay is on. |
| `MACHINE_RELAY_OFF_QUEUED` | Machine relay disable is queued. |
| `MACHINE_RELAY_OFF` | Machine relay is off. |
| `IN_WASH` | The machine has started or the wash is in progress. |
| `EXIT_PORT_OPEN_QUEUED` | Exit gate open is queued. |
| `EXIT_PORT_OPEN` | Exit gate is open. |
| `FAULT` | Lane fault state. |
| `MAINTENANCE` | Maintenance state. |
| `CLOSED` | Closed state. |
## Eligibility Rules
`selfserve_wash_flow` allows machine start only when all of these are true:
1. Every visible question has an answer.
2. The lane has `relay_machine_id`.
3. At least one active task exposes the `MACHINE` service.
Visible questions are determined by question `condition_id` gates. Active tasks are determined by `selfserve_condition_evaluator::taskGateSatisfied()`.
`selfserve_condition_evaluator` uses these rules:
- Non-`IS_TRUE_OR_ANY_TRUE` rules are AND-ed together.
- `IS_TRUE_OR_ANY_TRUE` rules are OR-ed together inside the same condition.
- A task gate id is resolved as a condition result first. If there is no condition with that id, it falls back to the raw answer for that question id.
Supported rule types:
- `IS_TRUE`
- `IS_FALSE`
- `IS_SET`
- `IS_TRUE_OR_NOT_SET`
- `IS_FALSE_OR_NOT_SET`
- `IS_TRUE_OR_ANY_TRUE`
Supported rule object types:
- `question`
- `condition`
## Public APIs
All `/department/selfserve/*` and `/modules/self-serve/*` routes require an authenticated user session and the listed permissions.
`/relay/button/press/post` is different: it uses plate-scanner authentication through `authentication::get_plate_scanner()`, which accepts the token from:
- `Authorization: Bearer `
- query parameter `token`
- POST body `token`
- JSON body field `token`
### `/department/selfserve/questions`
Purpose: manage shared or legacy self-serve questions.
| Method | Required params | Permissions | Notes |
| --- | --- | --- | --- |
| `GET /department/selfserve/questions` | none, optional `id`, `department`, `lane`, `product` | `list_department_selfserve_questions`, optional `view_all_department_selfserve_questions` | Returns one question by `id` or a filtered paginated list. |
| `POST /department/selfserve/questions` | `question`, `description` | `add_department_selfserve_questions` | Optional `department`, `lane`, `product`, `condition_id`, `order_priority`. Use `0/0/0` for shared questions. |
| `PUT /department/selfserve/questions` | `id` | `edit_department_selfserve_questions` | Updates any subset of fields. |
| `DELETE /department/selfserve/questions` | `id` | `delete_department_selfserve_questions` | Soft-deletes the question. |
Typical failures:
- `400` missing required fields
- `403` user cannot access the target department
- `404` question not found
### `/department/selfserve/conditions`
Purpose: manage named condition gates used by tasks and question visibility.
| Method | Required params | Permissions | Notes |
| --- | --- | --- | --- |
| `GET /department/selfserve/conditions` | none, optional `id`, `department`, `lane`, `product`, `machine_type_id` | `list_department_selfserve_conditions`, optional `view_all_department_selfserve_conditions` | Lists one or many conditions. |
| `POST /department/selfserve/conditions` | `name`, `description`, and either `machine_type_id` or `department`+`lane`+`product` | `add_department_selfserve_conditions` | Optional parent `condition_id` supports nested trees. |
| `PUT /department/selfserve/conditions` | `id` | `update_department_selfserve_conditions` | Can move a condition between machine types or legacy scopes. |
| `DELETE /department/selfserve/conditions` | `id` | `delete_department_selfserve_conditions` | Soft-deletes the condition. |
Typical failures:
- `400` missing scope information
- `403` user cannot access the source or target department
- `404` condition not found
### `/department/selfserve/condition/rules`
Purpose: define how a condition becomes true or false.
| Method | Required params | Permissions | Notes |
| --- | --- | --- | --- |
| `GET /department/selfserve/condition/rules` | none, optional `id`, `condition_id`, `type`, `object_type`, `object_id` | `list_department_selfserve_condition_rules`, optional `view_all_department_selfserve_condition_rules` | Access is checked through the parent condition's department. |
| `POST /department/selfserve/condition/rules` | `condition_id`, `type`, `object_type`, `object_id`, `name`, `description` | `add_department_selfserve_condition_rules` | `object_type` must be `question` or `condition`. |
| `PUT /department/selfserve/condition/rules` | `id` | `update_department_selfserve_condition_rules` | Supports moving the rule to another condition. |
| `DELETE /department/selfserve/condition/rules` | `id` | `delete_department_selfserve_condition_rules` | Soft-deletes the rule. |
Typical failures:
- `400` missing fields
- `403` department access denied through the parent condition
- `404` rule or target condition not found
### `/department/selfserve/tasks`
Purpose: manage the task list shown after eligibility evaluation.
| Method | Required params | Permissions | Notes |
| --- | --- | --- | --- |
| `GET /department/selfserve/tasks` | none, optional `id`, `department`, `lane`, `product`, `condition_id`, `machine_type_id` | `list_department_selfserve_tasks`, optional `view_all_department_selfserve_tasks` | Lists one or many tasks. |
| `POST /department/selfserve/tasks` | `task`, `description`, and either `machine_type_id` or `department`+`lane`+`product` | `add_department_selfserve_tasks` | Optional `condition_id`, `order_priority`, `services`, `buttons`, `dynamic_images_vehicle_type`. |
| `PUT /department/selfserve/tasks` | `id` | `edit_department_selfserve_tasks` | Updates any subset of fields. |
| `DELETE /department/selfserve/tasks` | `id` | `delete_department_selfserve_tasks` | Soft-deletes the task. |
Notes:
- Route parameter name is `condition_id`. That value is used as the task gate id.
- `services` accepts an array, JSON array string, or comma-separated string. Current enum cases are `MACHINE` and `PROGRAM_PICKER`.
- If an active task does not expose `MACHINE`, the relay will not be enabled.
Typical failures:
- `400` missing fields or invalid `services` format
- `403` department access denied
- `404` task not found
### `/department/selfserve/tasks/attachments*`
Purpose: attach downloadable assets to self-serve tasks.
| Method | Required params | Permissions | Notes |
| --- | --- | --- | --- |
| `GET /department/selfserve/tasks/attachments` | `id` | `list_department_selfserve_task_attachments` | Lists attachments for a task. |
| `GET /department/selfserve/tasks/attachments/download` | `task_id`, `attachment_id` | `download_department_selfserve_task_attachments` | Returns a direct download URL. |
| `POST /department/selfserve/tasks/attachments/upload` | `task_id`, `base64_file`, `file_name` | `add_department_selfserve_task_attachments` | Stores an attachment and links it to the task. |
| `DELETE /department/selfserve/tasks/attachments` | `task_id`, `attachment_id` | `delete_department_selfserve_task_attachments` | Removes the linked attachment from the task. |
Typical failures:
- `400` missing fields
- `403` department access denied
- `404` task or attachment not found
### `/department/selfserve/vehicle/conditions`
Purpose: create and manage the vehicle-specific answers that drive eligibility.
| Method | Required params | Permissions | Notes |
| --- | --- | --- | --- |
| `GET /department/selfserve/vehicle/conditions` | none, optional `id`, `department`, `customer_id`, `lane`, `reg`, `question` | `list_department_selfserve_vehicle_conditions` or `list_own_department_selfserve_vehicle_conditions` | `own_*` access is restricted to the user's customer number. |
| `POST /department/selfserve/vehicle/conditions` | `department`, `lane`, `reg`, `question`, `value` | `add_department_selfserve_vehicle_conditions` or `add_own_department_selfserve_vehicle_conditions` | Calls `selfserve_wash_flow::synchronizeSession()` and returns both the condition row and `selfserve` summary. |
| `PUT /department/selfserve/vehicle/conditions` | `id` | `update_department_selfserve_vehicle_conditions` or `update_own_department_selfserve_vehicle_conditions` | Re-synchronizes the session after the update. |
| `DELETE /department/selfserve/vehicle/conditions` | `id` | `delete_department_selfserve_vehicle_conditions` or `delete_own_department_selfserve_vehicle_conditions` | Deletes the answer and attempts to re-synchronize the session. |
Typical failures:
- `400` missing fields or invalid session
- `403` permission denied, wrong department, or wrong customer ownership
- `404` answer row not found
### `/department/selfserve/vehicle/allowed`
Purpose: preview whether self-serve is currently allowed for a vehicle on a lane.
| Method | Required params | Permissions | Notes |
| --- | --- | --- | --- |
| `GET /department/selfserve/vehicle/allowed` | `lane_id`, `reg` | `list_department_selfserve_vehicle_conditions` or `list_own_department_selfserve_vehicle_conditions` | Calls `selfserve_wash_flow::previewVehicleEligibility()` and returns questions, tasks, allowed services, and the current session if one exists. Own-permission customers may evaluate borrowed plates; saved answers only apply when scoped to the authenticated customer. |
Typical failures:
- `400` missing `lane_id` or `reg`
- `403` permission denied, missing customer context, or wrong department
- `404` lane not found
### `/department/selfserve/washes/summary`
Purpose: inspect the summary of a self-serve wash.
| Method | Required params | Permissions | Notes |
| --- | --- | --- | --- |
| `GET /department/selfserve/washes/summary` | either `session_id`, or `lane_id` plus `reg` | `list_department_selfserve_vehicle_conditions` or `list_own_department_selfserve_vehicle_conditions` | Returns `session`, `lane`, `machine_type`, `questions`, `tasks`, and `events`. |
Typical failures:
- `400` missing identifying parameters
- `403` permission denied or wrong vehicle ownership
- `404` session not found, or no session exists for lane and vehicle
### `/department/selfserve/machine-types`
Purpose: manage reusable machine type profiles.
| Method | Required params | Permissions | Notes |
| --- | --- | --- | --- |
| `GET /department/selfserve/machine-types` | none, optional `id` | `list_department_selfserve_machine_types` | Lists one or many machine types. |
| `POST /department/selfserve/machine-types` | `name` | `add_department_selfserve_machine_types` | Optional `description`. |
| `PUT /department/selfserve/machine-types` | `id` | `update_department_selfserve_machine_types` | Updates `name` and/or `description`. |
| `DELETE /department/selfserve/machine-types` | `id` | `delete_department_selfserve_machine_types` | Soft-deletes the machine type. |
Typical failures:
- `400` missing `name` or empty update
- `404` machine type not found
### `/modules/self-serve/lane/*`
Purpose: operational lane control and relay management.
| Method | Required params | Permissions | Notes |
| --- | --- | --- | --- |
| `GET /modules/self-serve/lane/status` | optional `lane_id`, default `1` | `modules_selfserve_lane_status_view` | Returns lane status, mode, state, wash timer, reg, and customer number. |
| `POST /modules/self-serve/lane/command` | `lane_id`, `command` | `modules_selfserve_lane_command_execute` plus command-specific permission, or customer `list_own_department_selfserve_vehicle_conditions` for scoped `START`, scoped `STOP`, and property gate commands | Valid commands: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`, `OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`. Customer `START` requires an enabled self-serve lane. Customer `STOP` and property gate commands require the customer's active wash in the lane department. |
| `POST /modules/self-serve/lane/services/allowed` | `lane_id`, optional `task_ids` | `modules_selfserve_lane_services_set_allowed`, or customer `list_own_department_selfserve_vehicle_conditions` on an enabled self-serve lane | Writes allowed service names to the lane cache. This is still read-from-visible-tasks only; it does not activate relays. |
| `GET /modules/self-serve/lane/relay/machine_program_picker/status` | `lane_id` | `modules_selfserve_lane_relay_machine_program_picker_status_view` | Reads the Shelly MACHINE_PROGRAM_PICKER relay state (`on`/`off`) for the lane. |
| `POST /modules/self-serve/lane/relay/machine_program_picker/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_program_picker_status_set` | Sets Shelly MACHINE_PROGRAM_PICKER relay state directly (`on=true/false`) and returns updated status. |
| `GET /modules/self-serve/lane/relay/machine_cleaner/status` | `lane_id` | `modules_selfserve_lane_relay_machine_cleaner_status_view` | Reads the Shelly MACHINE_CLEANER relay state (`on`/`off`) for the lane. |
| `POST /modules/self-serve/lane/relay/machine_cleaner/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_cleaner_status_set` | Sets Shelly MACHINE_CLEANER relay state directly (`on=true/false`) and returns updated status. |
| `GET /modules/self-serve/lane/relay/machine/status` | `lane_id` | `modules_selfserve_lane_relay_machine_status_view` | Reads the Shelly MACHINE relay state (`on`/`off`) for the lane. |
| `POST /modules/self-serve/lane/relay/machine/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_status_set` | Sets Shelly MACHINE relay state directly (`on=true/false`) and returns updated status. |
| `POST /modules/self-serve/lane/relay/machine/enable` | `lane_id`, optional `duration` | `modules_selfserve_lane_relay_enable_machine`, or customer `list_own_department_selfserve_vehicle_conditions` with an active wash in the lane department | Manual enable, still gated by allowed services. Customer flow calls this only after `START` and only when `MACHINE` is allowed. |
| `POST /modules/self-serve/lane/force/machine/enable` | `lane_id`, optional `duration`, optional `license_plate` | `modules_selfserve_lane_force_machine_enable` | Bypasses service gating and marks the lane as in wash. |
| `POST /modules/self-serve/lane/force/machine/disable` | `lane_id`, optional `license_plate` | `modules_selfserve_lane_force_machine_disable` | Keeps the lane in wash but turns the machine relay off. |
Shelly transport behavior:
- The department variable `shelly_transport_mode` controls the default relay path. Missing or `cloud` keeps Shelly cloud as the default. `gateway` routes self-serve relay status, set, manual enable, force, START/STOP side effects, and gate relay operations through the edge gateway/local edge agent.
- Local edge operation requires an active edge gateway plus an `edge_gateway_relay_bindings` row for each logical Shelly relay id. Each binding must resolve a bound `device_id`, `local_ip`, and `channel`.
- Binding fallback stays unchanged: `PREFER_LOCAL` uses the local edge agent first and may fall back to cloud, `LOCAL_ONLY` fails instead of falling back, and `CLOUD_ONLY` bypasses local dispatch.
- Operator diagnostics can add `transport=local` or `transport=gateway` to the `/modules/self-serve/lane/*` relay, gate, command, and allowed-services endpoints to force local-only dispatch. `transport=cloud` forces Shelly cloud. Regular customer flows do not send these overrides.
- Relay status and set responses keep `relay_id`, `online`, `on`, and `status.switch:0.output` stable. Gateway responses may also include `binding`, `execution`, and `raw` metadata for diagnostics.
STOP flow details:
- `selfserve_lane_command::STOP` requires lane status `OCCUPIED`.
- Unless bypass is enabled, the lane customer number must match the current authenticated user's customer number.
- STOP calls `invoice()`, opens the exit port, turns off the machine relay if self-serve is enabled for the department, completes the latest open self-serve session, and then resets the lane.
Customer start-wash release checklist:
- Canary hardware validation must use the department and lane configured for the live Playwright/release credentials. Record the exact `department_id` and `lane_id` in the release notes before the live run.
- Verify the self-serve module is enabled, department self-serve is enabled, the target lane has `selfserve_enabled=1`, relays and property gates are bound, minute billing product is configured, and the machine task exposes the `MACHINE` service before promoting canary.
- Validate one supervised real-lane manual wash and, when configured, one machine wash before stable promotion. Confirm no relay changes before customer confirmation, active wash restore works across reloads, property gates open only during the active wash, `STOP` completes the session, and billing/order linkage is present.
Typical failures:
- `400` invalid parameters
- `403` missing permission
- `404` lane not found
- `403` from `/relay/machine/enable` if the `MACHINE` service is not currently allowed
### `/relay/button/press/post`
Purpose: record the physical machine start trigger.
| Method | Required params | Authentication | Notes |
| --- | --- | --- | --- |
| `GET /relay/button/press/post` | optional `reg`, optional `lane_id` depending on department lane count | Plate-scanner auth | Supported for devices that can only call GET. |
| `POST /relay/button/press/post` | optional `reg`, optional `lane_id` depending on department lane count | Plate-scanner auth | Same behavior as GET. |
Lane resolution behavior:
- If `lane_id` is provided, the route verifies that the lane belongs to the scanner's department.
- If `lane_id` is not provided and the department has exactly one lane, that lane is used automatically.
- If `lane_id` is not provided and the department has multiple lanes, the request fails with `400`.
Runtime behavior:
- Calls `selfserve_wash_flow::recordMachineStartWebhook()`.
- Marks the session as machine-started.
- Updates lane status/state to occupied and in-wash if needed.
- Sets registration number, customer number, and wash start timestamp on the lane.
Typical failures:
- `403` invalid plate scanner token
- `400` missing `lane_id` in a multi-lane department
- `403` lane does not belong to the scanner department
- `404` no active self-serve wash session for the lane
## Public Code Interfaces
### `selfserve_wash_flow` and `selfserve_wash_flow_i`
Main orchestration class for the self-serve lifecycle.
Public methods:
| Method | Use it when | Returns |
| --- | --- | --- |
| `previewVehicleEligibility(int $laneId, string $reg, ?int $customerNumber = null)` | You need a read-only eligibility preview without mutating state. | Snapshot with `questions`, `tasks`, `allowed_services`, `allowed`, and optional current `session`. |
| `synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true)` | Answers changed and you want session state, tasks, events, and relay enable to stay in sync. | Full session summary. |
| `recordMachineStartWebhook(int $laneId, ?string $reg = null, array $payload = [])` | The machine button or hardware event fired. | Full session summary after the machine-start event. |
| `getSessionSummary(int $sessionId)` | You have a session id already. | Full session summary. |
| `getLatestSessionSummary(int $laneId, string $reg)` | You want the latest session for a lane and vehicle. | Full session summary. |
| `completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true)` | STOP has finished and you want to close the latest open session. Normal STOP passes `false` because it already disabled relays before opening the exit port. | Full summary, or `null` if no open session exists. |
Key implementation details:
- Calls `selfserve_schema_bootstrap::ensureTables()` in the constructor, so the session and machine-type tables are created lazily and idempotently at runtime.
- Loads shared questions first, then machine-type conditions and tasks first, then legacy fallback only when needed.
- Persists answer, task, and event snapshots on every sync.
- Enables the Shelly machine relay automatically when the snapshot is eligible and `activateMachine` is `true`.
### `selfserve_condition_evaluator` and `selfserve_condition_evaluator_i`
Pure evaluation layer for conditions and task gates.
Public methods:
| Method | Use it when |
| --- | --- |
| `evaluate(array $conditions, array $rules, array $answers)` | You need a boolean result map keyed by condition id. |
| `taskGateSatisfied(?int $gateId, array $conditionResults, array $answers)` | You need to decide whether a task should be active. |
### `selfserve_lane`
Runtime lane aggregate built from traits.
Important operational methods used by the module:
- `execute(selfserve_lane_command $command, selfserve_lane_command_arguments $arguments)`
- `turnOnRelay(selfserve_lane_relay::MACHINE, ?int $duration = null)`
- `turnOffRelay(selfserve_lane_relay::MACHINE)`
- `forceTurnOnMachineRelay(?int $duration = null)`
- `forceTurnOffMachineRelay()`
- `getLaneStatus()`
- `getLaneState()`
- `setLaneStatus(...)`
- `setLaneState(...)`
- `setWashStartTime(...)`
- `getElapsedWashTime()`
- `setLicensePlate(...)`
- `setCustomerNumber(...)`
- `setLaneCache(...)`
- `invoice()`
Important enums:
- `selfserve_lane_command`: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`
- `selfserve_lane_status`: `AVAILABLE`, `OCCUPIED`, `RESERVED`, `FAULT`, `MAINTENANCE`, `CLOSED`
- `selfserve_lane_state`: `IDLE`, `IN_WASH`, relay states, gate states, and fault states
- `selfserve_lane_services`: currently `MACHINE` and `PROGRAM_PICKER`
### Machine Type And Wash Session Objects
`selfserve_machine_types_o` is a reusable configuration object. It stores the profile name and description and is attached to a lane through `department_lanes.machine_type_id`.
`selfserve_wash_sessions_o` is the lifecycle record. At behavior level it supports:
- creating a new session with lane, machine type, customer, vehicle, and metadata
- updating status
- marking relay enabled
- marking machine start triggered
- marking completion with optional `order_id`
- selecting the latest open or latest overall session for a lane and registration number
## End-To-End Example: Happy Path
The example below shows the intended production flow with one shared question and one machine-type-specific task that exposes `MACHINE`.
### 1. Create a machine type
```bash
curl -X POST "$BASE_URL/department/selfserve/machine-types" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "HighPressureFoam",
"description": "Shared profile for foam cannon lanes"
}'
```
### 2. Associate the machine type with the lane
This is done through the existing department lanes route, not a self-serve-specific route.
```bash
curl -X PUT "$BASE_URL/department/lanes" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"id": 12,
"machine_type_id": 3
}'
```
### 3. Create a shared question
```bash
curl -X POST "$BASE_URL/department/selfserve/questions" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"department": 0,
"lane": 0,
"product": 0,
"question": "Is the hydraulic lock engaged?",
"description": "Required before machine start",
"order_priority": 10
}'
```
### 4. Create a machine-type-specific condition and rule
```bash
curl -X POST "$BASE_URL/department/selfserve/conditions" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"machine_type_id": 3,
"name": "Machine may start",
"description": "All mandatory safety checks passed"
}'
```
```bash
curl -X POST "$BASE_URL/department/selfserve/condition/rules" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"condition_id": 14,
"type": "IS_TRUE",
"object_type": "question",
"object_id": 21,
"name": "Hydraulic lock is engaged",
"description": "Question 21 must be answered true"
}'
```
### 5. Create a machine-type-specific task that exposes `MACHINE`
```bash
curl -X POST "$BASE_URL/department/selfserve/tasks" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"machine_type_id": 3,
"condition_id": 14,
"task": "Press the machine start button",
"description": "The relay is enabled automatically when all answers allow it.",
"order_priority": 10,
"services": ["MACHINE"]
}'
```
### 6. Preview eligibility before answering
```bash
curl "$BASE_URL/department/selfserve/vehicle/allowed?lane_id=12®=AB12345" \
-H "Authorization: Bearer $TOKEN"
```
Typical result before all answers are present:
```json
{
"success": true,
"data": {
"allowed": false,
"all_visible_questions_answered": false,
"machine_available": true,
"questions": [
{
"id": 21,
"question": "Is the hydraulic lock engaged?",
"answer": null
}
],
"tasks": []
}
}
```
### 7. Submit the answer
```bash
curl -X POST "$BASE_URL/department/selfserve/vehicle/conditions" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"department": 7,
"lane": 12,
"reg": "AB12345",
"question": 21,
"value": true,
"customer_id": 100234
}'
```
The POST response includes a `selfserve` summary. When the answer makes the vehicle eligible, `synchronizeSession()` will:
- create or update the session
- snapshot the answer and tasks
- populate lane cache with `allowed_services`
- enable the Shelly relay
- log a `MACHINE_RELAY_ENABLED` event
### 8. Inspect the summary
```bash
curl "$BASE_URL/department/selfserve/washes/summary?lane_id=12®=AB12345" \
-H "Authorization: Bearer $TOKEN"
```
Expected highlights:
- `session.status` becomes `MACHINE_RELAY_ENABLED`
- `questions` contains the answered question
- `tasks` contains the machine start task
- `events` contains both `SESSION_SYNCED` and `MACHINE_RELAY_ENABLED`
### 9. Record the physical machine start
```bash
curl -X POST "$BASE_URL/relay/button/press/post?token=$SCANNER_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"lane_id": 12,
"reg": "AB12345",
"source": "shelly-button"
}'
```
Expected highlights:
- `session.status` becomes `MACHINE_STARTED`
- the lane is `OCCUPIED`
- the lane state is `IN_WASH`
- `machine_start_triggered_at` is set
- the summary includes a `MACHINE_START_TRIGGERED` event
### 10. Stop the lane and complete billing
```bash
curl -X POST "$BASE_URL/modules/self-serve/lane/command" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"lane_id": 12,
"command": "STOP"
}'
```
STOP invoices the elapsed wash time using the configured minute product, writes `order_id` to the session when available, logs `SESSION_COMPLETED`, and resets the lane.
## End-To-End Example: Machine Not Allowed
This example shows the failure mode the UI usually needs to handle.
### Preview shows the blocking reason
```bash
curl "$BASE_URL/department/selfserve/vehicle/allowed?lane_id=12®=AB12345" \
-H "Authorization: Bearer $TOKEN"
```
Example response:
```json
{
"success": true,
"data": {
"allowed": false,
"all_visible_questions_answered": true,
"machine_available": false,
"allowed_services": [],
"questions": [
{
"id": 21,
"question": "Is the hydraulic lock engaged?",
"answer": true
}
],
"tasks": []
}
}
```
Interpretation:
- All questions are answered.
- The machine is still not allowed because either `relay_machine_id` is missing on the lane or no active task exposed `MACHINE`.
If an operator tries to enable the relay manually anyway:
```bash
curl -X POST "$BASE_URL/modules/self-serve/lane/relay/machine/enable" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"lane_id": 12
}'
```
The route returns `403` when the lane cache does not currently allow `MACHINE`.
## PHP Examples
These examples assume you are running inside the app runtime after the normal bootstrap has loaded the classes and database connection.
### Preview eligibility
```php
previewVehicleEligibility(12, 'AB12345', 100234);
var_dump([
'allowed' => $preview['allowed'],
'all_visible_questions_answered' => $preview['all_visible_questions_answered'],
'questions' => $preview['questions'],
'tasks' => $preview['tasks'],
]);
```
### Synchronize a session after answers change
```php
add(
7,
12,
'AB12345',
21,
true,
100234
);
$flow = new selfserve_wash_flow();
$summary = $flow->synchronizeSession(12, 'AB12345', 100234);
var_dump([
'session_id' => $summary['session']['id'],
'status' => $summary['session']['status'],
'events' => $summary['events'],
]);
```
### Record the machine start webhook
```php
recordMachineStartWebhook(12, 'AB12345', [
'source' => 'manual-test',
'button' => 'start',
]);
var_dump([
'status' => $summary['session']['status'],
'machine_start_triggered' => $summary['session']['machine_start_triggered'],
'events' => $summary['events'],
]);
```
### Retrieve the latest wash summary
```php
getLatestSessionSummary(12, 'AB12345');
var_dump([
'session' => $summary['session'],
'questions' => $summary['questions'],
'tasks' => $summary['tasks'],
'events' => $summary['events'],
]);
```
### Define a machine type and associate it with a lane
```php
add(
'HighPressureFoam',
'Reusable profile for foam cannon lanes'
);
$lane = (new department_lanes_o())->select(12);
$lane->machine_type_id->set((int)$machineType->id);
var_dump([
'machine_type_id' => $machineType->id,
'lane_id' => $lane->id,
]);
```
### Add a shared question and machine-type-specific conditions, rules, and tasks
```php
add(
0,
0,
0,
'Is the hydraulic lock engaged?',
'Required before machine start',
null,
10
);
$condition = (new department_selfserve_conditions_o())->add(
0,
0,
0,
'Machine may start',
'All mandatory safety checks passed',
null,
3
);
(new department_selfserve_condition_rules_o())->add(
(int)$condition->id,
selfserve_condition_rule_type::IS_TRUE->value,
selfserve_condition_rule_object_type::QUESTION->value,
(int)$question->id,
'Hydraulic lock must be engaged',
'The machine may only start when the shared question is true'
);
$task = (new department_selfserve_tasks_o())->add(
0,
0,
0,
(int)$condition->id,
'Press the machine start button',
'The relay is already enabled when this task becomes active',
10,
[selfserve_lane_services::MACHINE],
null,
null,
3
);
var_dump([
'question_id' => $question->id,
'condition_id' => $condition->id,
'task_id' => $task->id,
]);
```
## Implementation Recipes
### Add a new self-serve machine type
1. Create a `selfserve_machine_types` row through `/department/selfserve/machine-types` or `selfserve_machine_types_o`.
2. Set `department_lanes.machine_type_id` through `/department/lanes` or `department_lanes_o`.
3. Add the machine-type-specific conditions.
4. Add the condition rules that evaluate your questions or nested conditions.
5. Add the machine-type-specific tasks.
6. Ensure at least one active task exposes `MACHINE` if the lane should auto-enable the relay.
### Configure shared questions
Use `/department/selfserve/questions` with:
- `department = 0`
- `lane = 0`
- `product = 0`
Once shared questions exist, `selfserve_wash_flow` prefers them over legacy lane/product question rows.
### Add machine-type-specific conditions, rules, and tasks
Recommended pattern:
1. Keep questions shared.
2. Define one or more conditions per machine type.
3. Attach rules that reference shared questions or nested conditions.
4. Gate tasks with `condition_id`.
5. Put `MACHINE` on the task that should allow relay enable.
### Understand webhook and STOP interaction
- `synchronizeSession()` can enable the machine relay before the physical machine has started.
- `/relay/button/press/post` is the authoritative machine-start signal. That is the point where the session becomes `MACHINE_STARTED` and the lane wash timer is initialized if needed.
- `STOP` is the point where billing is finalized.
- `selfserve_lane_invoice_t::invoice()` bills `ceil(elapsedWashTime / 60)` units of the configured minute product.
- The current implementation creates the order with system user id `2285`.
- `completeLatestSessionForLane()` stores the final `order_id` on the session when STOP can provide it.
### Billing prerequisites
Billing on STOP depends on:
- self-serve minute product config being set
- lane status being `OCCUPIED`
- lane customer number being set
- lane license plate being set
- a positive elapsed wash time
Relevant config:
- `selfserve.enabled`
- `selfserve.minute_product`
- department variable `selfserve_enabled`
## Troubleshooting
### `allowed` is always `false`
Check all three eligibility requirements:
1. Every visible question must have an answer.
2. The lane must have `relay_machine_id`.
3. At least one active task must expose `MACHINE`.
Also check whether the machine-type-specific tasks and conditions exist for the lane's `machine_type_id`. If they do not, the flow may fall back to legacy lane/product data instead.
### The relay is not enabled after an answer update
Common causes:
- `synchronizeSession()` was called with `activateMachine = false`
- the active task list does not expose `MACHINE`
- the lane is `CLOSED`, `MAINTENANCE`, or `FAULT`
- `relay_machine_id` is missing on the lane
Inspect the latest summary and look for:
- `allowed_services`
- `session.machine_relay_enabled`
- a `MACHINE_RELAY_ENABLED` event
### The machine button webhook returns `404`
This means the route could not find an active self-serve session for the resolved lane.
Check:
- the lane id resolved from the scanner department is correct
- the vehicle registration matches the session registration
- the session was synchronized before the button press
If you pass `reg` and no open session exists yet, `recordMachineStartWebhook()` will attempt a non-activating synchronize first. If that still cannot resolve a session, inspect the lane and answer data.
### The summary does not show questions, tasks, or events you expected
Check:
- whether you are reading by `session_id` or by `lane_id` plus `reg`
- whether a newer session exists for the same lane and vehicle
- whether the lane changed machine type after the session was created
- whether the question or task was visible at the moment the session was synchronized
Remember that session answers and tasks are snapshots, not live joins.
### The webhook fails in a multi-lane department
If the plate scanner belongs to a department with more than one lane, you must include `lane_id` in the webhook request.
### STOP did not create an `order_id` on the session
Check:
- the minute product configuration
- lane customer number and registration number
- that wash time was greater than zero
- whether `invoice()` threw before completion
The STOP flow tries not to let relay or session-completion errors block the lane reset. If billing failed earlier, the lane may still reset without a final `order_id`.
## Operational Notes
- Runtime schema changes are additive and lazy through `classes/selfserve_schema_bootstrap.php`.
- The session tables are safe to create idempotently from runtime flows because the project does not use a centralized migration runner.
- Machine relay control is delegated to `selfserve_lane_relay_controller_t`, which resolves Shelly cloud vs. edge gateway transport per department.
- Manual relay enable is still gated by the lane cache, while force enable and force disable bypass that gate.
## Suggested Usage Pattern
For new implementations, the intended setup is:
1. Define one or more reusable machine types.
2. Attach each self-serve lane to a machine type.
3. Keep questions shared.
4. Put machine-specific logic in machine-type conditions, rules, and tasks.
5. Let vehicle answer changes call `synchronizeSession()`.
6. Let the physical button or PLC call `/relay/button/press/post`.
7. Let the normal STOP lane command complete billing and close the session.