40 KiB
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:
- Shared self-serve questions are loaded.
- Machine-type-specific conditions, rules, and tasks are resolved for the lane.
- Vehicle answers are evaluated into condition results.
- If all visible questions are answered and an active task exposes the
MACHINEservice, the lane relay is enabled. - When the physical machine button is pressed,
/relay/button/press/postrecords the machine start. - 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
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
flowchart LR
Q["department_selfserve_questions<br/>shared questions preferred"] --> F["selfserve_wash_flow"]
VC["department_selfserve_vehicle_conditions<br/>vehicle answers"] --> F
C["department_selfserve_conditions<br/>machine-type or legacy"] --> E["selfserve_condition_evaluator"]
R["department_selfserve_condition_rules"] --> E
T["department_selfserve_tasks<br/>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 callsdepartment_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_idand matching rows exist,selfserve_wash_flowuses 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:
- Every visible question has an answer.
- The lane has
relay_machine_id. - At least one active task exposes the
MACHINEservice.
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_TRUErules are AND-ed together. IS_TRUE_OR_ANY_TRUErules 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_TRUEIS_FALSEIS_SETIS_TRUE_OR_NOT_SETIS_FALSE_OR_NOT_SETIS_TRUE_OR_ANY_TRUE
Supported rule object types:
questioncondition
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 <token>- 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:
400missing required fields403user cannot access the target department404question 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:
400missing scope information403user cannot access the source or target department404condition 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:
400missing fields403department access denied through the parent condition404rule 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. servicesaccepts an array, JSON array string, or comma-separated string. The only current enum case isMACHINE.- If an active task does not expose
MACHINE, the relay will not be enabled.
Typical failures:
400missing fields or invalidservicesformat403department access denied404task 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:
400missing fields403department access denied404task 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:
400missing fields or invalid session403permission denied, wrong department, or wrong customer ownership404answer 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. |
Typical failures:
400missinglane_idorreg403permission denied or wrong vehicle ownership404lane 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:
400missing identifying parameters403permission denied or wrong vehicle ownership404session 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:
400missingnameor empty update404machine 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 |
Valid commands: START, STOP, RESET, RESERVE, RELEASE. |
POST /modules/self-serve/lane/services/allowed |
lane_id, optional task_ids |
modules_selfserve_lane_services_set_allowed |
Writes allowed service names to the lane cache. |
POST /modules/self-serve/lane/relay/machine/enable |
lane_id, optional duration |
modules_selfserve_lane_relay_enable_machine |
Manual enable, still gated by allowed services. |
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. |
STOP flow details:
selfserve_lane_command::STOPrequires lane statusOCCUPIED.- 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.
Typical failures:
400invalid parameters403missing permission404lane not found403from/relay/machine/enableif theMACHINEservice 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_idis provided, the route verifies that the lane belongs to the scanner's department. - If
lane_idis not provided and the department has exactly one lane, that lane is used automatically. - If
lane_idis not provided and the department has multiple lanes, the request fails with400.
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:
403invalid plate scanner token400missinglane_idin a multi-lane department403lane does not belong to the scanner department404no 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) |
STOP has finished and you want to close the latest open session. | 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
activateMachineistrue.
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,RELEASEselfserve_lane_status:AVAILABLE,OCCUPIED,RESERVED,FAULT,MAINTENANCE,CLOSEDselfserve_lane_state:IDLE,IN_WASH, relay states, gate states, and fault statesselfserve_lane_services: currently onlyMACHINE
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
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.
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
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
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"
}'
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
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
curl "$BASE_URL/department/selfserve/vehicle/allowed?lane_id=12®=AB12345" \
-H "Authorization: Bearer $TOKEN"
Typical result before all answers are present:
{
"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
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_ENABLEDevent
8. Inspect the summary
curl "$BASE_URL/department/selfserve/washes/summary?lane_id=12®=AB12345" \
-H "Authorization: Bearer $TOKEN"
Expected highlights:
session.statusbecomesMACHINE_RELAY_ENABLEDquestionscontains the answered questiontaskscontains the machine start taskeventscontains bothSESSION_SYNCEDandMACHINE_RELAY_ENABLED
9. Record the physical machine start
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.statusbecomesMACHINE_STARTED- the lane is
OCCUPIED - the lane state is
IN_WASH machine_start_triggered_atis set- the summary includes a
MACHINE_START_TRIGGEREDevent
10. Stop the lane and complete billing
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
curl "$BASE_URL/department/selfserve/vehicle/allowed?lane_id=12®=AB12345" \
-H "Authorization: Bearer $TOKEN"
Example response:
{
"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_idis missing on the lane or no active task exposedMACHINE.
If an operator tries to enable the relay manually anyway:
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
use modules\selfserve\classes\selfserve_wash_flow;
$flow = new selfserve_wash_flow();
$preview = $flow->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
use modules\selfserve\classes\selfserve_wash_flow;
use objects\department_selfserve_vehicle_conditions_o;
$answers = new department_selfserve_vehicle_conditions_o();
$answers->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
use modules\selfserve\classes\selfserve_wash_flow;
$flow = new selfserve_wash_flow();
$summary = $flow->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
use modules\selfserve\classes\selfserve_wash_flow;
$flow = new selfserve_wash_flow();
$summary = $flow->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
use objects\department_lanes_o;
use objects\selfserve_machine_types_o;
$machineType = (new selfserve_machine_types_o())->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
use modules\selfserve\helpers\selfserve_condition_rule_object_type;
use modules\selfserve\helpers\selfserve_condition_rule_type;
use modules\selfserve\helpers\selfserve_lane_services;
use objects\department_selfserve_condition_rules_o;
use objects\department_selfserve_conditions_o;
use objects\department_selfserve_questions_o;
use objects\department_selfserve_tasks_o;
$question = (new department_selfserve_questions_o())->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
- Create a
selfserve_machine_typesrow through/department/selfserve/machine-typesorselfserve_machine_types_o. - Set
department_lanes.machine_type_idthrough/department/lanesordepartment_lanes_o. - Add the machine-type-specific conditions.
- Add the condition rules that evaluate your questions or nested conditions.
- Add the machine-type-specific tasks.
- Ensure at least one active task exposes
MACHINEif the lane should auto-enable the relay.
Configure shared questions
Use /department/selfserve/questions with:
department = 0lane = 0product = 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:
- Keep questions shared.
- Define one or more conditions per machine type.
- Attach rules that reference shared questions or nested conditions.
- Gate tasks with
condition_id. - Put
MACHINEon 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/postis the authoritative machine-start signal. That is the point where the session becomesMACHINE_STARTEDand the lane wash timer is initialized if needed.STOPis the point where billing is finalized.selfserve_lane_invoice_t::invoice()billsceil(elapsedWashTime / 60)units of the configured minute product.- The current implementation creates the order with system user id
2285. completeLatestSessionForLane()stores the finalorder_idon 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.enabledselfserve.minute_product- department variable
selfserve_enabled
Troubleshooting
allowed is always false
Check all three eligibility requirements:
- Every visible question must have an answer.
- The lane must have
relay_machine_id. - 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 withactivateMachine = false- the active task list does not expose
MACHINE - the lane is
CLOSED,MAINTENANCE, orFAULT relay_machine_idis missing on the lane
Inspect the latest summary and look for:
allowed_servicessession.machine_relay_enabled- a
MACHINE_RELAY_ENABLEDevent
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_idor bylane_idplusreg - 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 the Shelly module through
selfserve_lane_relay_controller_t. - 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:
- Define one or more reusable machine types.
- Attach each self-serve lane to a machine type.
- Keep questions shared.
- Put machine-specific logic in machine-type conditions, rules, and tasks.
- Let vehicle answer changes call
synchronizeSession(). - Let the physical button or PLC call
/relay/button/press/post. - Let the normal STOP lane command complete billing and close the session.