Add unit tests for invoicing, orders normalization, gateway commands, and department complaints. Update schema bootstraps and improve agent command execution logic.

This commit is contained in:
Jeppe Bundgaard
2026-04-09 12:24:22 +02:00
parent 9e9372db05
commit 22f856c62a
35 changed files with 2346 additions and 167 deletions
+516 -11
View File
@@ -901,14 +901,16 @@ paths:
summary: Process inbound Bird voice call lifecycle
operationId: birdInboundVoiceCallWebhook
description: >
Stateful inbound-call webhook that answers the call immediately, tracks DTMF input, and enforces a timeout hangup flow.
During the first 300 seconds from call start, DTMF input is extracted from fixed payload fields
(`dtmf`, `digit`, `digits`, `keys`, and known nested variants). A Slack message is sent whenever any DTMF
value is captured, the call acknowledges input, and gather is re-issued with retry-loop semantics
checking for input every 2 seconds until a digit is entered. At or after 300 seconds, the webhook
says `timeout reached`, waits 10
seconds, sends a hangup command once, and polls Bird call status until terminal. Call lifecycle
state is only finalized and cleared after terminal status is observed.
Stateful inbound-call webhook that answers the call immediately, runs a DTMF-driven IVR for
phone-controlled entrance and exit gates, and enforces a timeout hangup flow. During the
first 300 seconds from call start, DTMF input is extracted from payload fields such as
`dtmf`, `digit`, `digits`, `keys`, `result.keys`, and nested `conditions[].value`. Values
like `1#` are normalized to a single menu digit before the route resolves the department
selection and then the gate type selection (`1=entrance`, `2=exit`). When a valid gate is
resolved, the webhook triggers the corresponding `department_gates` phone-call gate, announces
the result, sends a hangup command, and keeps the completed state cached until Bird reports a
terminal call status. At or after 300 seconds, the webhook says `timeout reached`, waits 10
seconds, sends a hangup command once, and polls Bird call status until terminal.
parameters:
- in: query
name: workspaceId
@@ -9878,6 +9880,33 @@ paths:
'403':
$ref: '#/components/responses/Forbidden'
/superuser/system/status:
get:
tags:
- Superuser
summary: Aggregated system status snapshot
description: Returns a read-only snapshot of runtime health, dependency connectivity, module configuration/probe status, and active user session activity for the superuser dashboard.
operationId: getSuperuserSystemStatus
parameters:
- in: query
name: force
required: false
schema:
type: boolean
default: false
description: Bypass cached external module probes for this request.
responses:
'200':
description: System status snapshot returned successfully
content:
application/json:
schema:
$ref: '#/components/schemas/SuperuserSystemStatusResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
# Configuration Endpoints
/economic/config:
get:
@@ -10938,6 +10967,127 @@ paths:
application/json:
schema: {}
/departments/daily-reports/complaints:
get:
tags:
- Departments
summary: List or fetch daily report customer complaints
operationId: listDailyReportComplaints
parameters:
- name: id
in: query
required: false
schema: {type: integer}
- name: page
in: query
required: false
schema: {type: integer}
- name: limit
in: query
required: false
schema: {type: integer}
- name: search
in: query
required: false
schema: {type: string}
- name: filters
in: query
required: false
schema: {type: string}
- name: order
in: query
required: false
schema: {type: string}
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportComplaintCollectionResponse'
post:
tags:
- Departments
summary: Create daily report customer complaint
operationId: createDailyReportComplaint
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportComplaintCreateRequest'
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportComplaintResponse'
put:
tags:
- Departments
summary: Update daily report customer complaint
operationId: updateDailyReportComplaint
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportComplaintUpdateRequest'
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportComplaintResponse'
delete:
tags:
- Departments
summary: Delete daily report customer complaint
operationId: deleteDailyReportComplaint
parameters:
- name: id
in: query
required: true
schema: {type: integer}
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportComplaintDeleteResponse'
/departments/daily-reports/complaints/customers:
get:
tags:
- Departments
summary: Search selectable customers for daily report complaints
operationId: searchDailyReportComplaintCustomers
parameters:
- name: search
in: query
required: true
schema:
type: string
minLength: 2
- name: limit
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 20
default: 10
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportComplaintCustomerSearchResponse'
/departments/daily-reports/product-count:
get:
tags:
@@ -11262,6 +11412,245 @@ components:
type: integer
description: HTTP status code
SuperuserSystemStatusResponse:
type: object
properties:
success:
type: boolean
data:
$ref: '#/components/schemas/SuperuserSystemStatusPayload'
meta:
type: object
additionalProperties: true
includes:
type: object
additionalProperties: true
required:
- success
- data
- meta
- includes
SuperuserSystemStatusPayload:
type: object
properties:
overall_status:
$ref: '#/components/schemas/SuperuserSystemStatusEnum'
generated_at:
type: string
format: date-time
refresh_after_seconds:
type: integer
runtime:
type: object
properties:
cpu:
$ref: '#/components/schemas/SuperuserRuntimeMetric'
memory:
$ref: '#/components/schemas/SuperuserRuntimeMetric'
disk:
$ref: '#/components/schemas/SuperuserRuntimeMetric'
dependencies:
type: object
properties:
database:
$ref: '#/components/schemas/SuperuserDependencyStatus'
redis:
$ref: '#/components/schemas/SuperuserDependencyStatus'
minio:
$ref: '#/components/schemas/SuperuserMinioDependencyStatus'
modules:
type: array
items:
$ref: '#/components/schemas/SuperuserModuleStatus'
sessions:
$ref: '#/components/schemas/SuperuserSessionStatus'
warnings:
type: array
items:
type: string
required:
- overall_status
- generated_at
- refresh_after_seconds
- runtime
- dependencies
- modules
- sessions
- warnings
SuperuserSystemStatusEnum:
type: string
enum: [ok, degraded, down]
SuperuserModuleStatusEnum:
type: string
enum: [disabled, not_configured, configured, ok, degraded, down]
SuperuserRuntimeMetric:
type: object
properties:
status:
$ref: '#/components/schemas/SuperuserSystemStatusEnum'
usage_percent:
type: number
format: float
nullable: true
used_bytes:
type: integer
nullable: true
free_bytes:
type: integer
nullable: true
total_bytes:
type: integer
nullable: true
path:
type: string
nullable: true
source:
type: string
nullable: true
checked_at:
type: string
format: date-time
SuperuserDependencyStatus:
type: object
properties:
status:
$ref: '#/components/schemas/SuperuserSystemStatusEnum'
latency_ms:
type: number
format: float
nullable: true
database:
oneOf:
- type: integer
- type: string
nullable: true
server_version:
type: string
nullable: true
http_status:
type: integer
nullable: true
checked_at:
type: string
format: date-time
error:
type: string
nullable: true
SuperuserMinioDependencyStatus:
type: object
properties:
status:
$ref: '#/components/schemas/SuperuserSystemStatusEnum'
latency_ms:
type: number
format: float
nullable: true
endpoint:
type: string
nullable: true
http_status:
type: integer
nullable: true
buckets:
type: array
items:
type: object
properties:
name:
type: string
status:
type: string
error:
type: string
nullable: true
checked_at:
type: string
format: date-time
error:
type: string
nullable: true
SuperuserModuleStatus:
type: object
properties:
key:
type: string
enabled:
type: boolean
configured:
type: boolean
probe_supported:
type: boolean
status:
$ref: '#/components/schemas/SuperuserModuleStatusEnum'
status_reason:
type: string
nullable: true
checked_at:
type: string
format: date-time
required:
- key
- enabled
- configured
- probe_supported
- status
- checked_at
SuperuserSessionStatus:
type: object
properties:
active_window_minutes:
type: integer
active_users:
type: integer
active_sessions:
type: integer
recent_sessions:
type: array
items:
type: object
properties:
session_kind:
type: string
principal_id:
type: integer
display_name:
type: string
context_label:
type: string
nullable: true
customer_number_context:
type: integer
nullable: true
device_type:
type: string
user_agent:
type: string
last_route:
type: string
first_seen_at:
type: string
format: date-time
nullable: true
last_seen_at:
type: string
format: date-time
nullable: true
active:
type: boolean
required:
- active_window_minutes
- active_users
- active_sessions
- recent_sessions
SystemSearchEntityType:
type: string
enum:
@@ -16077,16 +16466,20 @@ components:
example: ongoing
dtmf:
type: string
description: DTMF value when present
description: DTMF value when present, for example `1` or `1#`
example: "5"
digit:
type: string
description: Alternate DTMF field
description: Alternate DTMF field, also accepts values such as `1#`
example: "5"
digits:
type: string
description: Alternate DTMF field
example: "5"
keys:
type: string
description: Alternate DTMF field returned by gather results
example: "1#"
call:
type: object
additionalProperties: true
@@ -16110,7 +16503,11 @@ components:
properties:
phase:
type: string
enum: [lock_not_acquired, input_window, timeout_window, terminal_completion]
enum: [lock_not_acquired, input_window, timeout_window, completed, terminal_completion]
stage:
type: string
nullable: true
enum: [department_select, gate_type_select, completed]
call_id:
type: string
example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09"
@@ -16131,6 +16528,19 @@ components:
last_input:
type: string
nullable: true
selected_department_id:
type: integer
nullable: true
selected_gate_type:
type: string
nullable: true
enum: [entrance, exit]
gate_id:
type: integer
nullable: true
gate_opened:
type: boolean
nullable: true
answered_at:
type: integer
nullable: true
@@ -16616,6 +17026,101 @@ components:
type: array
items: { type: integer }
DepartmentDailyReportComplaintCreateRequest:
type: object
required: [department_id, description]
properties:
department_id: { type: integer }
customer_number:
type: integer
nullable: true
description:
type: string
minLength: 1
maxLength: 4000
DepartmentDailyReportComplaintUpdateRequest:
type: object
required: [id]
properties:
id: { type: integer }
department_id: { type: integer }
customer_number:
type: integer
nullable: true
description:
type: string
minLength: 1
maxLength: 4000
DepartmentDailyReportComplaintCustomerSearchResult:
type: object
properties:
customer_number:
type: integer
customer_name:
type: string
nullable: true
DepartmentDailyReportComplaint:
type: object
properties:
id: { type: integer }
department_id: { type: integer }
department_name:
type: string
nullable: true
customer_number:
type: integer
nullable: true
customer_name:
type: string
nullable: true
description: { type: string }
created_by: { type: integer }
created_by_name:
type: string
nullable: true
created_at:
type: string
format: date-time
DepartmentDailyReportComplaintResponse:
type: object
properties:
success: { type: boolean, example: true }
data:
$ref: '#/components/schemas/DepartmentDailyReportComplaint'
DepartmentDailyReportComplaintCollectionResponse:
type: object
properties:
success: { type: boolean, example: true }
data:
oneOf:
- $ref: '#/components/schemas/DepartmentDailyReportComplaint'
- type: array
items:
$ref: '#/components/schemas/DepartmentDailyReportComplaint'
DepartmentDailyReportComplaintCustomerSearchResponse:
type: object
properties:
success: { type: boolean, example: true }
data:
type: array
items:
$ref: '#/components/schemas/DepartmentDailyReportComplaintCustomerSearchResult'
DepartmentDailyReportComplaintDeleteResponse:
type: object
properties:
success: { type: boolean, example: true }
data:
type: object
properties:
message: { type: string }
DepartmentDailyReportProductTile:
type: object
properties:
+220 -44
View File
@@ -1,23 +1,22 @@
import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import os from "node:os";
import { spawn } from "node:child_process";
import process from "node:process";
import WebSocket from "ws";
const DEFAULT_VERSION = "0.1.0";
const DEFAULT_RECONNECT_DELAY_MS = 5000;
const DEFAULT_SHELL_COLS = 120;
const DEFAULT_SHELL_ROWS = 32;
function buildBrokerHeartbeatState(isConnected) {
return isConnected
? {
status: "ONLINE",
discovery_status: "READY",
metadata: { broker_connected: true },
}
: {
status: "DEGRADED",
discovery_status: "BROKER_DISCONNECTED",
metadata: { broker_connected: false },
};
}
@@ -219,52 +218,123 @@ function defaultShellCommand() {
return { command: process.env.SHELL || "/bin/sh", args: [] };
}
export function createShellBridge(sendMessage) {
function normalizeShellSize(value, fallback) {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric > 0 ? Math.round(numeric) : fallback;
}
async function createDefaultPtyProcess(options = {}) {
const nodePtyModule = await import("node-pty");
const spawnPty =
nodePtyModule.spawn ??
nodePtyModule.default?.spawn ??
nodePtyModule.default;
if (typeof spawnPty !== "function") {
throw new Error("node-pty spawn is unavailable");
}
return spawnPty(options.command, options.args || [], {
name: options.env?.TERM || "xterm-256color",
cols: normalizeShellSize(options.cols, DEFAULT_SHELL_COLS),
rows: normalizeShellSize(options.rows, DEFAULT_SHELL_ROWS),
cwd: options.cwd || process.cwd(),
env: options.env || process.env,
});
}
export function createShellBridge(sendMessage, { createPtyProcess = createDefaultPtyProcess } = {}) {
const sessions = new Map();
const open = (payload = {}) => {
const sessionId = String(payload.sessionId);
const shell = payload.shellCommand ? { command: payload.shellCommand, args: payload.shellArgs || [] } : defaultShellCommand();
const proc = spawn(shell.command, shell.args, {
cwd: payload.cwd || process.cwd(),
env: process.env,
stdio: "pipe",
});
const open = async (payload = {}) => {
const sessionId = String(payload.sessionId || "");
if (sessionId === "") {
return;
}
proc.stdout.on("data", (chunk) => {
sendMessage({ type: "SHELL_OUTPUT", sessionId, data: chunk.toString("utf8") });
});
proc.stderr.on("data", (chunk) => {
sendMessage({ type: "SHELL_OUTPUT", sessionId, data: chunk.toString("utf8") });
});
proc.on("close", (code) => {
const existingSession = sessions.get(sessionId);
if (existingSession) {
existingSession.pty.kill();
sessions.delete(sessionId);
sendMessage({ type: "SHELL_EXIT", sessionId, code });
});
}
sessions.set(sessionId, proc);
sendMessage({ type: "SHELL_OPENED", sessionId });
const shell = payload.shellCommand
? { command: payload.shellCommand, args: payload.shellArgs || [] }
: defaultShellCommand();
try {
const pty = await Promise.resolve(createPtyProcess({
command: shell.command,
args: shell.args,
cwd: payload.cwd || process.cwd(),
cols: normalizeShellSize(payload.cols, DEFAULT_SHELL_COLS),
rows: normalizeShellSize(payload.rows, DEFAULT_SHELL_ROWS),
env: {
...process.env,
TERM: payload.term || process.env.TERM || "xterm-256color",
},
}));
const sessionRecord = {
pty,
dataSubscription: null,
exitSubscription: null,
};
sessionRecord.dataSubscription = pty.onData((data) => {
sendMessage({ type: "SHELL_OUTPUT", sessionId, data: String(data || "") });
});
sessionRecord.exitSubscription = pty.onExit(({ exitCode }) => {
sessions.delete(sessionId);
sessionRecord.dataSubscription?.dispose?.();
sessionRecord.exitSubscription?.dispose?.();
sendMessage({ type: "SHELL_EXIT", sessionId, code: Number.isFinite(exitCode) ? exitCode : 0 });
});
sessions.set(sessionId, sessionRecord);
sendMessage({ type: "SHELL_OPENED", sessionId });
} catch (error) {
sendMessage({
type: "SHELL_OUTPUT",
sessionId,
data: `Failed to start root shell: ${error instanceof Error ? error.message : String(error)}\r\n`,
});
sendMessage({ type: "SHELL_EXIT", sessionId, code: 1 });
}
};
const input = (payload = {}) => {
const proc = sessions.get(String(payload.sessionId));
if (!proc) {
const sessionRecord = sessions.get(String(payload.sessionId || ""));
if (!sessionRecord) {
return;
}
proc.stdin.write(String(payload.data || ""));
sessionRecord.pty.write(String(payload.data || ""));
};
const resize = (payload = {}) => {
const sessionRecord = sessions.get(String(payload.sessionId || ""));
if (!sessionRecord) {
return;
}
sessionRecord.pty.resize(
normalizeShellSize(payload.cols, DEFAULT_SHELL_COLS),
normalizeShellSize(payload.rows, DEFAULT_SHELL_ROWS)
);
};
const close = (payload = {}) => {
const sessionId = String(payload.sessionId);
const proc = sessions.get(sessionId);
if (!proc) {
const sessionId = String(payload.sessionId || "");
const sessionRecord = sessions.get(sessionId);
if (!sessionRecord) {
return;
}
proc.kill();
sessions.delete(sessionId);
sessionRecord.pty.kill();
};
return { open, input, close };
return { open, input, resize, close };
}
export async function handleAgentCommand(command, deps = {}) {
@@ -288,18 +358,26 @@ export async function handleAgentCommand(command, deps = {}) {
}
export function buildHeartbeatPayload(config, extra = {}) {
return {
const payload = {
hostname: os.hostname(),
installed_version: config.installedVersion || DEFAULT_VERSION,
target_version: config.targetVersion || config.installedVersion || DEFAULT_VERSION,
status: extra.status || "ONLINE",
discovery_status: extra.discovery_status || "READY",
inventory: extra.inventory || [],
metadata: {
release_channel: config.releaseChannel || "stable",
...extra.metadata,
},
};
if (Object.prototype.hasOwnProperty.call(extra, "discovery_status")) {
payload.discovery_status = extra.discovery_status;
}
if (Array.isArray(extra.inventory)) {
payload.inventory = extra.inventory;
}
return payload;
}
export async function sendHeartbeat(config, fetchImpl = fetch, extra = {}) {
@@ -318,17 +396,80 @@ export async function sendHeartbeat(config, fetchImpl = fetch, extra = {}) {
);
}
export async function pollCommandJob(config, fetchImpl = fetch, waitSeconds = 20) {
if (!config.gatewayId || !config.agentToken) {
throw new Error("Gateway claim must complete before polling commands");
}
return apiRequest(
config.apiUrl,
`/edge-agent/gateways/${config.gatewayId}/commands/poll`,
"POST",
{
agent_token: config.agentToken,
wait_seconds: waitSeconds,
},
fetchImpl
);
}
export async function submitCommandJobResult(config, jobId, { ok, payload = {}, error = null } = {}, fetchImpl = fetch) {
if (!config.gatewayId || !config.agentToken) {
throw new Error("Gateway claim must complete before submitting command results");
}
return apiRequest(
config.apiUrl,
`/edge-agent/gateways/${config.gatewayId}/commands/${jobId}/result`,
"POST",
{
agent_token: config.agentToken,
ok: Boolean(ok),
payload,
error,
},
fetchImpl
);
}
export async function processPolledCommand(config, command, fetchImpl = fetch) {
const jobId = command?.id;
const commandType = command?.commandType || command?.command_type;
const payload = command?.payload || {};
if (!jobId || !commandType) {
return null;
}
try {
const result = await handleAgentCommand({ commandType, payload }, { fetchImpl });
await submitCommandJobResult(config, jobId, {
ok: true,
payload: result,
}, fetchImpl);
return { ok: true, payload: result };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await submitCommandJobResult(config, jobId, {
ok: false,
error: message,
}, fetchImpl);
return { ok: false, error: message };
}
}
export function connectBroker(
config,
{
fetchImpl = fetch,
wsFactory = (url) => new WebSocket(url),
createShellBridgeImpl = createShellBridge,
onOpen = null,
onClose = null,
onError = null,
} = {}
) {
const shell = createShellBridge((message) => {
const shell = createShellBridgeImpl((message) => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message));
}
@@ -352,9 +493,13 @@ export function connectBroker(
const message = JSON.parse(raw.toString());
try {
if (message.type === "COMMAND") {
const normalizedPayload =
message.payload && typeof message.payload === "object" && message.payload.payload
? message.payload.payload
: (message.payload || {});
const payload = await handleAgentCommand({
commandType: message.commandType,
payload: message.payload || {},
payload: normalizedPayload,
}, { fetchImpl });
socket.send(JSON.stringify({
type: "COMMAND_RESULT",
@@ -366,19 +511,23 @@ export function connectBroker(
}
if (message.type === "OPEN_ROOT_SHELL") {
shell.open(message.payload || {});
await shell.open(message.payload || {});
} else if (message.type === "SHELL_INPUT") {
shell.input(message.payload || {});
} else if (message.type === "RESIZE_ROOT_SHELL") {
shell.resize(message.payload || {});
} else if (message.type === "CLOSE_ROOT_SHELL") {
shell.close(message.payload || {});
}
} catch (error) {
socket.send(JSON.stringify({
type: "COMMAND_RESULT",
commandId: message.commandId,
ok: false,
error: error instanceof Error ? error.message : String(error),
}));
if (message.type === "COMMAND") {
socket.send(JSON.stringify({
type: "COMMAND_RESULT",
commandId: message.commandId,
ok: false,
error: error instanceof Error ? error.message : String(error),
}));
}
}
});
@@ -394,6 +543,8 @@ export async function startAgent({ configPath, fetchImpl = fetch, wsFactory } =
config = await claimIfNeeded(config, configPath, fetchImpl);
const intervalMs = Number(config.heartbeatIntervalSeconds || 15) * 1000;
const reconnectDelayMs = Number(config.reconnectDelayMs || DEFAULT_RECONNECT_DELAY_MS);
const commandPollTimeoutSeconds = Number(config.commandPollTimeoutSeconds || 20);
const commandPollRetryDelayMs = Number(config.commandPollRetryDelayMs || 1000);
let brokerConnected = false;
let socket = null;
@@ -417,6 +568,29 @@ export async function startAgent({ configPath, fetchImpl = fetch, wsFactory } =
}, reconnectDelayMs);
};
const runCommandPollLoop = async () => {
while (!stopped) {
try {
const command = await pollCommandJob(config, fetchImpl, commandPollTimeoutSeconds);
if (stopped) {
break;
}
if (!command) {
continue;
}
await processPolledCommand(config, command, fetchImpl);
} catch {
if (stopped) {
break;
}
await new Promise((resolve) => setTimeout(resolve, commandPollRetryDelayMs));
}
}
};
const openBrokerConnection = () => {
if (stopped) {
return;
@@ -442,6 +616,7 @@ export async function startAgent({ configPath, fetchImpl = fetch, wsFactory } =
await sendBrokerAwareHeartbeat();
openBrokerConnection();
const commandPollPromise = runCommandPollLoop();
const timer = setInterval(() => {
sendBrokerAwareHeartbeat().catch(() => {});
@@ -458,6 +633,7 @@ export async function startAgent({ configPath, fetchImpl = fetch, wsFactory } =
};
return {
commandPollPromise,
get socket() {
return socket;
},
+1
View File
@@ -3,6 +3,7 @@
"private": true,
"type": "module",
"dependencies": {
"node-pty": "^1.1.0",
"ws": "^8.18.0"
}
}
+17
View File
@@ -6,9 +6,26 @@
"": {
"name": "truckwash-edge-agent",
"dependencies": {
"node-pty": "^1.1.0",
"ws": "^8.18.0"
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
"node_modules/node-pty": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^7.1.0"
}
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
+1
View File
@@ -6,6 +6,7 @@
"test": "node --test"
},
"dependencies": {
"node-pty": "^1.1.0",
"ws": "^8.18.0"
}
}
+163 -27
View File
@@ -9,8 +9,8 @@ import {
claimIfNeeded,
createShellBridge,
getRelayStatus,
startAgent,
setRelayState,
startAgent,
} from "../dist/agent.mjs";
test("claimIfNeeded persists claimed gateway credentials", async () => {
@@ -81,27 +81,90 @@ test("relay status and switch commands support both Shelly RPC and legacy endpoi
assert.equal(switched.on, false);
});
test("shell bridge streams child process output", async () => {
test("shell bridge proxies PTY output, input, resize, and close events", async () => {
const messages = [];
const shell = createShellBridge((message) => messages.push(message));
const createdPtys = [];
shell.open({
const shell = createShellBridge(
(message) => messages.push(message),
{
createPtyProcess: async (options) => {
const listeners = {
data: null,
exit: null,
};
const pty = {
options,
writes: [],
resizes: [],
killed: false,
onData(callback) {
listeners.data = callback;
return {
dispose() {
listeners.data = null;
},
};
},
onExit(callback) {
listeners.exit = callback;
return {
dispose() {
listeners.exit = null;
},
};
},
write(data) {
this.writes.push(data);
},
resize(cols, rows) {
this.resizes.push({ cols, rows });
},
kill() {
this.killed = true;
listeners.exit?.({ exitCode: 0 });
},
emitData(data) {
listeners.data?.(data);
},
};
createdPtys.push(pty);
return pty;
},
}
);
await shell.open({
sessionId: "test-shell",
shellCommand: process.execPath,
shellArgs: ["-e", "process.stdin.on('data', (d) => process.stdout.write(d))"],
shellCommand: "/bin/bash",
shellArgs: ["-l"],
cols: 90,
rows: 24,
cwd: "/tmp",
});
await new Promise((resolve) => setTimeout(resolve, 50));
shell.input({ sessionId: "test-shell", data: "hello\n" });
await new Promise((resolve) => setTimeout(resolve, 50));
createdPtys[0].emitData("root@pi:~# ");
shell.input({ sessionId: "test-shell", data: "ls\r" });
shell.resize({ sessionId: "test-shell", cols: 120, rows: 40 });
shell.close({ sessionId: "test-shell" });
await new Promise((resolve) => setTimeout(resolve, 50));
assert.ok(messages.some((message) => message.type === "SHELL_OPENED"));
assert.ok(messages.some((message) => message.type === "SHELL_OUTPUT" && message.data.includes("hello")));
assert.equal(createdPtys.length, 1);
assert.equal(createdPtys[0].options.command, "/bin/bash");
assert.deepEqual(createdPtys[0].options.args, ["-l"]);
assert.equal(createdPtys[0].options.cols, 90);
assert.equal(createdPtys[0].options.rows, 24);
assert.equal(createdPtys[0].options.cwd, "/tmp");
assert.deepEqual(createdPtys[0].writes, ["ls\r"]);
assert.deepEqual(createdPtys[0].resizes, [{ cols: 120, rows: 40 }]);
assert.equal(createdPtys[0].killed, true);
assert.ok(messages.some((message) => message.type === "SHELL_OPENED" && message.sessionId === "test-shell"));
assert.ok(messages.some((message) => message.type === "SHELL_OUTPUT" && message.data.includes("root@pi")));
assert.ok(messages.some((message) => message.type === "SHELL_EXIT" && message.code === 0));
});
test("startAgent retries the broker tunnel and reports degraded heartbeats while disconnected", async () => {
test("startAgent reports broker state via metadata and executes polled commands", async () => {
class FakeSocket extends EventEmitter {
constructor(url) {
super();
@@ -129,21 +192,85 @@ test("startAgent retries the broker tunnel and reports degraded heartbeats while
agentToken: "agent-token",
heartbeatIntervalSeconds: 60,
reconnectDelayMs: 10,
commandPollTimeoutSeconds: 0,
commandPollRetryDelayMs: 5,
}));
const heartbeats = [];
const fakeFetch = async (url, options = {}) => {
heartbeats.push({
url,
body: JSON.parse(options.body ?? "{}"),
});
const resultPosts = [];
let polledCommandDelivered = false;
return {
ok: true,
async json() {
return { data: { ok: true } };
},
};
const fakeFetch = async (url, options = {}) => {
const body = options.body ? JSON.parse(options.body) : {};
if (String(url).endsWith("/heartbeat")) {
heartbeats.push({
url,
body,
});
return {
ok: true,
async json() {
return { data: { ok: true } };
},
};
}
if (String(url).endsWith("/commands/poll")) {
if (!polledCommandDelivered) {
polledCommandDelivered = true;
return {
ok: true,
async json() {
return {
data: {
id: 99,
commandType: "DISCOVER_SHELLY",
payload: {
candidateIps: ["10.1.0.31"],
},
},
};
},
};
}
await new Promise((resolve) => setTimeout(resolve, 5));
return {
ok: true,
async json() {
return { data: null };
},
};
}
if (String(url).endsWith("/commands/99/result")) {
resultPosts.push({
url,
body,
});
return {
ok: true,
async json() {
return { data: { acknowledged: true } };
},
};
}
if (String(url) === "http://10.1.0.31/shelly") {
return {
ok: true,
async json() {
return {
mac: "AA:BB:CC:DD:EE:FF",
model: "Shelly Plus 1PM",
num_switches: 1,
};
},
};
}
throw new Error(`Unexpected URL: ${url}`);
};
const sockets = [];
@@ -160,19 +287,28 @@ test("startAgent retries the broker tunnel and reports degraded heartbeats while
});
assert.equal(heartbeats[0].body.status, "DEGRADED");
assert.equal(heartbeats[0].body.discovery_status, "BROKER_DISCONNECTED");
assert.equal(heartbeats[0].body.metadata.broker_connected, false);
assert.equal("discovery_status" in heartbeats[0].body, false);
assert.equal(sockets.length, 1);
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(resultPosts.length, 1);
assert.equal(resultPosts[0].body.ok, true);
assert.equal(Array.isArray(resultPosts[0].body.payload.inventory), true);
assert.equal(resultPosts[0].body.payload.inventory[0].device_id, "AA:BB:CC:DD:EE:FF");
sockets[0].readyState = 1;
sockets[0].emit("open");
await new Promise((resolve) => setTimeout(resolve, 5));
assert.ok(heartbeats.some((heartbeat) => heartbeat.body.discovery_status === "READY"));
assert.ok(heartbeats.some((heartbeat) => heartbeat.body.metadata.broker_connected === true));
assert.ok(heartbeats.every((heartbeat) => !("discovery_status" in heartbeat.body)));
sockets[0].emit("close");
await new Promise((resolve) => setTimeout(resolve, 25));
assert.ok(heartbeats.some((heartbeat) => heartbeat.body.discovery_status === "BROKER_DISCONNECTED"));
assert.ok(heartbeats.some((heartbeat) => heartbeat.body.status === "DEGRADED" && heartbeat.body.metadata.broker_connected === false));
assert.equal(sockets.length, 2);
agent.stop();
+10
View File
@@ -202,6 +202,16 @@ export function createBrokerServer(options = {}) {
},
}));
}
if (message.type === "resize") {
agent.send(JSON.stringify({
type: "RESIZE_ROOT_SHELL",
payload: {
sessionId,
cols: Number(message.cols || 0),
rows: Number(message.rows || 0),
},
}));
}
if (message.type === "close") {
agent.send(JSON.stringify({
type: "CLOSE_ROOT_SHELL",
+46
View File
@@ -97,3 +97,49 @@ test("broker bridges browser shell sessions through the connected agent", async
agent.terminate();
await broker.close();
});
test("broker forwards browser shell input, resize, and close events to the agent", async () => {
const broker = createBrokerServer({
authMode: "stub",
validateShellSession: async () => ({ id: "shell-2", gateway_id: "701", reason: "diagnostic" }),
});
const address = await broker.listen(0);
const port = address.port;
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
await new Promise((resolve) => agent.once("open", resolve));
const agentMessages = collectMessages(agent);
const browser = new WebSocket(`ws://127.0.0.1:${port}/ws/browser-shell?token=session-token`);
await new Promise((resolve) => browser.once("open", resolve));
await waitForMessage(agent);
browser.send(JSON.stringify({ type: "input", data: "ls\r" }));
browser.send(JSON.stringify({ type: "resize", cols: 140, rows: 44 }));
browser.send(JSON.stringify({ type: "close" }));
await new Promise((resolve) => setTimeout(resolve, 50));
assert.ok(agentMessages.some((message) => message.type === "OPEN_ROOT_SHELL" && message.payload.sessionId === "shell-2"));
assert.ok(
agentMessages.some(
(message) =>
message.type === "SHELL_INPUT" &&
message.payload.sessionId === "shell-2" &&
message.payload.data === "ls\r"
)
);
assert.ok(
agentMessages.some(
(message) =>
message.type === "RESIZE_ROOT_SHELL" &&
message.payload.sessionId === "shell-2" &&
message.payload.cols === 140 &&
message.payload.rows === 44
)
);
assert.ok(agentMessages.some((message) => message.type === "CLOSE_ROOT_SHELL" && message.payload.sessionId === "shell-2"));
browser.terminate();
agent.terminate();
await broker.close();
});
@@ -363,7 +363,8 @@ class economic_v2_distribution_service
$department_id = (int)$order['department_id'];
$created_at = (string)$order['created_at'];
if (!$this->isDepartmentEligible($department_id)) {
$order_is_included = $this->isOrderEligible($order);
if (!$order_is_included) {
continue;
}
@@ -398,7 +399,7 @@ class economic_v2_distribution_service
];
}
$customers[$customer_number]['transactions'][] = $this->buildTransactionObject($order_id, $created_at, $department_id, $order_discount_total);
$customers[$customer_number]['transactions'][] = $this->buildTransactionObject($order_id, $created_at, $department_id, $order_discount_total, $order_is_included);
$customers[$customer_number]['meta']['customer_prices']['discount_total'] += $order_discount_total;
if (!isset($customers[$customer_number]['meta']['customer_prices']['department_discount_totals'][$department_id])) {
$customers[$customer_number]['meta']['customer_prices']['department_discount_totals'][$department_id] = 0.0;
@@ -1044,7 +1045,7 @@ class economic_v2_distribution_service
if (!$this->isSystemOrderCandidate($order, self::FIXED_PRICING_SYSTEM_ORDER_REFERENCE)) {
continue;
}
} elseif (!$this->isDepartmentEligible($department_id)) {
} elseif (!$this->isOrderEligible($order)) {
continue;
}
@@ -1087,7 +1088,13 @@ class economic_v2_distribution_service
$groups[$group_key]['order_ids'][] = $order_id;
if (!isset($customer_transactions[$customer_number][$order_id])) {
$customer_transactions[$customer_number][$order_id] = $this->buildTransactionObject($order_id, $created_at, $department_id);
$customer_transactions[$customer_number][$order_id] = $this->buildTransactionObject(
$order_id,
$created_at,
$department_id,
null,
$this->isOrderEligible($order)
);
}
}
@@ -1115,7 +1122,7 @@ class economic_v2_distribution_service
if (!$this->isSystemOrderCandidate($order, self::WASH_SUBSCRIPTION_SYSTEM_ORDER_REFERENCE)) {
continue;
}
} elseif (!$this->isDepartmentEligible($department_id)) {
} elseif (!$this->isOrderEligible($order)) {
continue;
}
@@ -1179,7 +1186,13 @@ class economic_v2_distribution_service
}
if ($matched_order && !isset($customer_transactions[$customer_number][$order_id])) {
$customer_transactions[$customer_number][$order_id] = $this->buildTransactionObject($order_id, $created_at, $department_id);
$customer_transactions[$customer_number][$order_id] = $this->buildTransactionObject(
$order_id,
$created_at,
$department_id,
null,
$this->isOrderEligible($order)
);
}
}
@@ -1208,7 +1221,7 @@ class economic_v2_distribution_service
global $db;
$from = $db->escape_string($from_ts);
$to = $db->escape_string($to_ts);
$sql = "SELECT id, customer_id, department_id, created_at, reg_1, reference
$sql = "SELECT id, customer_id, department_id, created_at, include_in_invoice, reg_1, reference
FROM orders
WHERE deleted_at IS NULL
AND created_at >= '$from'
@@ -1540,7 +1553,7 @@ class economic_v2_distribution_service
];
}
protected function buildTransactionObject(int $order_id, string $created_at, int $department_id, ?float $amount = null): array
protected function buildTransactionObject(int $order_id, string $created_at, int $department_id, ?float $amount = null, ?bool $included = null): array
{
$order = (new orders_o())->select($order_id);
return [
@@ -1549,10 +1562,25 @@ class economic_v2_distribution_service
'amount' => round((float)($amount ?? (float)$order->getNetAmount()), 5),
'booked' => $order->isBooked(true),
'department_id' => $department_id,
'excluded' => !$this->isDepartmentEligible($department_id),
'excluded' => !($included ?? $this->isDepartmentEligible($department_id)),
];
}
protected function isOrderEligible(array $order): bool
{
$department_id = (int)($order['department_id'] ?? 0);
if ($department_id === self::SYSTEM_ORDER_DEPARTMENT_ID || $department_id <= 0) {
return false;
}
$override = orders_o::normalizeNullableBooleanValue($order['include_in_invoice'] ?? null);
if ($override !== null) {
return $override;
}
return $this->isDepartmentEligible($department_id);
}
private function getCustomerName(int $customer_number): string
{
if (!isset($this->customer_name_cache[$customer_number])) {
@@ -4,6 +4,32 @@ namespace classes;
use Exception;
class edge_broker_transport_exception extends Exception
{
public function __construct(string $message, private readonly int $curlErrno = 0, int $code = 0, ?Exception $previous = null)
{
parent::__construct($message, $code, $previous);
}
public function curlErrno(): int
{
return $this->curlErrno;
}
}
class edge_broker_http_exception extends Exception
{
public function __construct(string $message, private readonly int $statusCode, int $code = 0, ?Exception $previous = null)
{
parent::__construct($message, $code, $previous);
}
public function statusCode(): int
{
return $this->statusCode;
}
}
class edge_broker_client
{
private const DEFAULT_BROKER_URL = 'http://edge-broker:4300';
@@ -99,11 +125,12 @@ class edge_broker_client
$rawResponse = curl_exec($ch);
$statusCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErrno = curl_errno($ch);
$curlError = curl_error($ch);
curl_close($ch);
if ($rawResponse === false) {
throw new Exception('Edge broker request failed: ' . $curlError);
throw new edge_broker_transport_exception('Edge broker request failed: ' . $curlError, $curlErrno);
}
$decoded = json_decode((string)$rawResponse, true);
@@ -111,7 +138,7 @@ class edge_broker_client
$message = is_array($decoded)
? (string)($decoded['error'] ?? $decoded['message'] ?? 'Edge broker request failed')
: 'Edge broker request failed';
throw new Exception($message);
throw new edge_broker_http_exception($message, $statusCode);
}
return $decoded;
@@ -28,6 +28,10 @@ class edge_gateway_manager
public const SHELL_SESSION_TTL_SECONDS = 900;
public const HEARTBEAT_DEGRADED_AFTER_SECONDS = 60;
public const HEARTBEAT_OFFLINE_AFTER_SECONDS = 300;
public const COMMAND_WAIT_TIMEOUT_SECONDS = 10;
public const COMMAND_POLL_TIMEOUT_SECONDS = 20;
public const COMMAND_POLL_INTERVAL_MICROSECONDS = 250000;
public const COMMAND_DISPATCH_STALE_AFTER_SECONDS = 30;
public function __construct(private readonly ?edge_broker_client $brokerClient = null)
{
@@ -168,7 +172,6 @@ class edge_gateway_manager
$gateway->target_version->set($payload['target_version'] ?? $gateway->target_version->value());
$gateway->last_heartbeat_at->set($this->now());
$gateway->last_seen_ip->set($this->remoteIp());
$gateway->discovery_status->set((string)($payload['discovery_status'] ?? $gateway->discovery_status->value()));
$gateway->metadata_json->set((array)($payload['metadata'] ?? $gateway->metadata_json->value() ?? []));
if (isset($payload['inventory']) && is_array($payload['inventory'])) {
@@ -324,15 +327,9 @@ class edge_gateway_manager
*/
public function queueDiscovery(int $gatewayId, ?int $userId = null): array
{
$gateway = $this->requireGateway($gatewayId);
$job = $this->createCommandJob($gatewayId, 'DISCOVER_SHELLY', [], $userId);
$response = $this->dispatchCommandJob($job, $gateway, [
'requestedBy' => $userId,
]);
if (isset($response['inventory']) && is_array($response['inventory'])) {
$this->syncDeviceInventory($gatewayId, $response['inventory']);
}
$gateway = $this->requireDispatchableGateway($gatewayId);
$gateway->discovery_status->set('PENDING');
$this->createCommandJob($gatewayId, 'DISCOVER_SHELLY', [], $userId);
return $this->getGateway($gatewayId);
}
@@ -342,10 +339,13 @@ class edge_gateway_manager
*/
public function queueUpdate(int $gatewayId, string $targetVersion, string $releaseChannel, ?int $userId = null): array
{
$gateway = $this->requireGateway($gatewayId);
$gateway = $this->requireDispatchableGateway($gatewayId);
$gateway->target_version->set($targetVersion);
$jobObject = new edge_gateway_update_jobs_o();
$jobId = $jobObject->add_object([
'gateway_id' => $gatewayId,
'command_job_id' => null,
'target_version' => $targetVersion,
'release_channel' => $releaseChannel,
'status' => 'PENDING',
@@ -359,22 +359,7 @@ class edge_gateway_manager
'targetVersion' => $targetVersion,
'releaseChannel' => $releaseChannel,
], $userId);
try {
$response = $this->dispatchCommandJob($command, $gateway, [
'targetVersion' => $targetVersion,
'releaseChannel' => $releaseChannel,
]);
$jobObject->status->set('COMPLETED');
$jobObject->started_at->set($this->now());
$jobObject->completed_at->set($this->now());
$jobObject->result_json->set($response);
} catch (\Throwable $throwable) {
$jobObject->status->set('FAILED');
$jobObject->completed_at->set($this->now());
$jobObject->result_json->set(['error' => $throwable->getMessage()]);
throw $throwable;
}
$jobObject->command_job_id->set((int)$command->id);
$this->writeAudit(
$gatewayId,
@@ -387,6 +372,71 @@ class edge_gateway_manager
return $jobObject->asArray();
}
/**
* @throws Exception
*/
public function pollCommand(int $gatewayId, string $plainToken, int $waitSeconds = self::COMMAND_POLL_TIMEOUT_SECONDS): ?array
{
$gateway = $this->authenticateGateway($gatewayId, $plainToken);
$deadline = microtime(true) + max(0, $waitSeconds);
do {
$job = $this->claimNextCommandJob($gateway);
if ($job !== null) {
return $this->formatAgentCommandJob($job, $gateway);
}
if (microtime(true) >= $deadline) {
break;
}
usleep(self::COMMAND_POLL_INTERVAL_MICROSECONDS);
} while (true);
return null;
}
/**
* @throws Exception
*/
public function submitCommandResult(
int $gatewayId,
int $jobId,
string $plainToken,
bool $ok,
array $payload = [],
?string $error = null
): array {
$gateway = $this->authenticateGateway($gatewayId, $plainToken);
$job = (new edge_gateway_command_jobs_o())->select($jobId);
if (!$job->exists()) {
throw new Exception('Edge gateway command job not found');
}
if ((int)$job->gateway_id->value() !== (int)$gateway->id) {
throw new Exception('Edge gateway command job does not belong to this gateway');
}
$status = (string)$job->status->value();
if (in_array($status, ['COMPLETED', 'FAILED'], true)) {
return [
'acknowledged' => true,
'job' => $job->asArray(),
];
}
$errorMessage = $ok ? null : trim((string)$error);
if (!$ok && $errorMessage === '') {
$errorMessage = 'Edge gateway command failed';
}
$this->finalizeCommandJob($job, $ok, $payload, $errorMessage, $gateway);
return [
'acknowledged' => true,
'job' => $job->asArray(),
];
}
/**
* @throws Exception
*/
@@ -502,7 +552,7 @@ class edge_gateway_manager
public function dispatchRelayStatus(int $departmentId, string $logicalRelayId): array
{
$binding = $this->resolveRelayBinding($departmentId, $logicalRelayId);
$gateway = $this->requireGateway((int)$binding['gateway_id']);
$gateway = $this->requireDispatchableGateway((int)$binding['gateway_id']);
$job = $this->createCommandJob((int)$gateway->id, 'GET_RELAY_STATUS', [
'relayId' => $logicalRelayId,
'deviceId' => $binding['device_id'],
@@ -510,12 +560,9 @@ class edge_gateway_manager
'channel' => (int)$binding['channel'],
], null);
return $this->dispatchCommandJob($job, $gateway, [
'relayId' => $logicalRelayId,
'deviceId' => $binding['device_id'],
'localIp' => $binding['local_ip'],
'channel' => (int)$binding['channel'],
]);
$this->tryImmediateBrokerDispatch($job, $gateway);
return $this->waitForCommandResult((int)$job->id);
}
/**
@@ -524,7 +571,7 @@ class edge_gateway_manager
public function dispatchRelaySwitch(int $departmentId, string $logicalRelayId, bool $on): array
{
$binding = $this->resolveRelayBinding($departmentId, $logicalRelayId);
$gateway = $this->requireGateway((int)$binding['gateway_id']);
$gateway = $this->requireDispatchableGateway((int)$binding['gateway_id']);
$job = $this->createCommandJob((int)$gateway->id, 'SET_RELAY_STATE', [
'relayId' => $logicalRelayId,
'deviceId' => $binding['device_id'],
@@ -533,13 +580,9 @@ class edge_gateway_manager
'on' => $on,
], null);
return $this->dispatchCommandJob($job, $gateway, [
'relayId' => $logicalRelayId,
'deviceId' => $binding['device_id'],
'localIp' => $binding['local_ip'],
'channel' => (int)$binding['channel'],
'on' => $on,
]);
$this->tryImmediateBrokerDispatch($job, $gateway);
return $this->waitForCommandResult((int)$job->id);
}
/**
@@ -592,7 +635,7 @@ INSTALL_DIR=/opt/truckwash-edge-agent
mkdir -p "$INSTALL_DIR"
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y curl ca-certificates nodejs npm
apt-get install -y curl ca-certificates nodejs npm python3 make g++
curl -fsSL "__PACKAGE_URL__" -o "$INSTALL_DIR/package.json"
curl -fsSL "__AGENT_URL__" -o "$INSTALL_DIR/agent.mjs"
cat > "$INSTALL_DIR/config.json" <<'EOF_JSON'
@@ -654,7 +697,7 @@ BASH;
{
$configured = trim((string)(getenv('EDGE_BROKER_PUBLIC_URL') ?: ''));
if ($configured !== '') {
return $configured;
return $this->normalizeBrokerPublicUrl($configured);
}
$apiBaseUrl = $this->getApiBaseUrl();
@@ -665,7 +708,7 @@ BASH;
$scheme = ($parsed['scheme'] ?? 'https') === 'https' ? 'https' : 'http';
$port = getenv('EDGE_BROKER_PUBLIC_PORT') ?: '4300';
return $scheme . '://' . $parsed['host'] . ':' . $port;
return $this->normalizeBrokerPublicUrl($scheme . '://' . $parsed['host'] . ':' . $port);
}
public function getApiBaseUrl(): string
@@ -740,6 +783,76 @@ BASH;
return null;
}
private function normalizeBrokerPublicUrl(string $url): string
{
$parsed = parse_url($url);
if (!is_array($parsed) || !isset($parsed['host'])) {
return trim($url);
}
$host = (string)$parsed['host'];
$scheme = strtolower((string)($parsed['scheme'] ?? ''));
if ($scheme === '' || ($scheme === 'http' && $this->shouldUseSecureBrokerScheme($host))) {
$scheme = $this->shouldUseSecureBrokerScheme($host) ? 'https' : 'http';
}
$normalized = $scheme . '://' . $host;
if (isset($parsed['port'])) {
$normalized .= ':' . $parsed['port'];
}
if (isset($parsed['path'])) {
$normalized .= $parsed['path'];
}
if (isset($parsed['query'])) {
$normalized .= '?' . $parsed['query'];
}
if (isset($parsed['fragment'])) {
$normalized .= '#' . $parsed['fragment'];
}
return $normalized;
}
private function shouldUseSecureBrokerScheme(string $host): bool
{
$normalized = strtolower(trim($host, '[]'));
if ($normalized === '' || $normalized === 'localhost' || $normalized === 'edge-broker') {
return false;
}
if (str_ends_with($normalized, '.localhost')
|| str_ends_with($normalized, '.local')
|| str_ends_with($normalized, '.lan')
|| str_ends_with($normalized, '.internal')
|| str_ends_with($normalized, '.home.arpa')) {
return false;
}
if (filter_var($normalized, FILTER_VALIDATE_IP) !== false) {
return filter_var($normalized, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
}
return str_contains($normalized, '.');
}
/**
* @throws Exception
*/
private function requireDispatchableGateway(int $gatewayId): edge_gateways_o
{
$gateway = $this->requireGateway($gatewayId);
$effectiveStatus = self::resolveGatewayStatus(
$gateway->status->value() === null ? null : (string)$gateway->status->value(),
$gateway->last_heartbeat_at->value() === null ? null : (string)$gateway->last_heartbeat_at->value()
);
if (!in_array($effectiveStatus, [self::STATUS_ONLINE, self::STATUS_DEGRADED], true)) {
throw new Exception('Gateway agent is offline');
}
return $gateway;
}
/**
* @throws Exception
*/
@@ -856,32 +969,282 @@ BASH;
/**
* @throws Exception
*/
private function dispatchCommandJob(edge_gateway_command_jobs_o $job, edge_gateways_o $gateway, array $payload): array
private function tryImmediateBrokerDispatch(edge_gateway_command_jobs_o $job, edge_gateways_o $gateway): void
{
$job->status->set('DISPATCHING');
$this->markCommandJobDispatching($job);
$response = $this->broker()->dispatchCommand(
(int)$gateway->id,
(string)$job->command_type->value(),
[
'jobId' => (int)$job->id,
'gatewayId' => (int)$gateway->id,
'departmentId' => (int)$gateway->department_id->value(),
'payload' => $payload,
]
);
try {
$response = $this->broker()->dispatchCommand(
(int)$gateway->id,
(string)$job->command_type->value(),
$this->buildCommandExecutionPayload($job, $gateway)
);
$ok = (bool)($response['ok'] ?? false);
$payload = (array)($response['payload'] ?? []);
$errorMessage = $ok ? null : trim((string)($response['error'] ?? 'Edge broker command failed'));
$this->finalizeCommandJob($job, $ok, $payload, $errorMessage, $gateway);
if (!$ok) {
throw new Exception($errorMessage ?: 'Edge broker command failed');
}
} catch (\Throwable $throwable) {
if ($this->shouldFallbackToQueuedDelivery($throwable)) {
$this->releaseCommandJobToQueue($job);
return;
}
$this->finalizeCommandJob($job, false, [], $throwable->getMessage(), $gateway);
throw $throwable;
}
}
/**
* @throws Exception
*/
private function waitForCommandResult(int $jobId, int $timeoutSeconds = self::COMMAND_WAIT_TIMEOUT_SECONDS): array
{
$deadline = microtime(true) + max(0, $timeoutSeconds);
do {
$job = (new edge_gateway_command_jobs_o())->select($jobId);
if (!$job->exists()) {
throw new Exception('Edge gateway command job not found');
}
$status = (string)$job->status->value();
if ($status === 'COMPLETED') {
$response = (array)($job->response_json->value() ?? []);
return (array)($response['payload'] ?? []);
}
if ($status === 'FAILED') {
$errorMessage = trim((string)($job->error_message->value() ?? ''));
throw new Exception($errorMessage !== '' ? $errorMessage : 'Edge gateway command failed');
}
if (microtime(true) >= $deadline) {
break;
}
usleep(self::COMMAND_POLL_INTERVAL_MICROSECONDS);
} while (true);
throw new Exception('Edge gateway command timed out');
}
private function claimNextCommandJob(edge_gateways_o $gateway): ?edge_gateway_command_jobs_o
{
$pdo = db::getPDO();
$pdo->beginTransaction();
try {
$statement = $pdo->prepare(
'SELECT id
FROM edge_gateway_command_jobs
WHERE gateway_id = :gateway_id
AND deleted_at IS NULL
AND (
status = :pending_status_match
OR (
status = :dispatching_status_match
AND COALESCE(updated_at, created_at, requested_at) <= :stale_before
)
)
ORDER BY CASE WHEN status = :pending_status_order THEN 0 ELSE 1 END, requested_at ASC, id ASC
LIMIT 1
FOR UPDATE'
);
$statement->execute([
':gateway_id' => (int)$gateway->id,
':pending_status_match' => 'PENDING',
':dispatching_status_match' => 'DISPATCHING',
':pending_status_order' => 'PENDING',
':stale_before' => $this->formatDateTime(time() - self::COMMAND_DISPATCH_STALE_AFTER_SECONDS),
]);
$row = $statement->fetch();
if (!is_array($row) || !isset($row['id'])) {
$pdo->commit();
return null;
}
$update = $pdo->prepare(
'UPDATE edge_gateway_command_jobs
SET status = :status,
response_json = :response_json,
error_message = NULL,
completed_at = NULL
WHERE id = :id'
);
$update->execute([
':status' => 'DISPATCHING',
':response_json' => json_encode([], JSON_UNESCAPED_UNICODE),
':id' => (int)$row['id'],
]);
$pdo->commit();
} catch (\Throwable $throwable) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $throwable;
}
$job = (new edge_gateway_command_jobs_o())->select((int)$row['id']);
$this->markLinkedUpdateJobStarted((int)$job->id);
return $job;
}
private function formatAgentCommandJob(edge_gateway_command_jobs_o $job, edge_gateways_o $gateway): array
{
return [
'id' => (int)$job->id,
'gateway_id' => (int)$gateway->id,
'department_id' => (int)$gateway->department_id->value(),
'command_type' => (string)$job->command_type->value(),
'commandType' => (string)$job->command_type->value(),
'payload' => $this->buildCommandExecutionPayload($job, $gateway),
'requested_at' => (string)$job->requested_at->value(),
];
}
private function buildCommandExecutionPayload(edge_gateway_command_jobs_o $job, edge_gateways_o $gateway): array
{
return array_merge([
'jobId' => (int)$job->id,
'gatewayId' => (int)$gateway->id,
'departmentId' => (int)$gateway->department_id->value(),
'correlationId' => (string)$job->correlation_id->value(),
], (array)($job->request_json->value() ?? []));
}
private function finalizeCommandJob(
edge_gateway_command_jobs_o $job,
bool $ok,
array $payload = [],
?string $errorMessage = null,
?edge_gateways_o $gateway = null
): void {
$gatewayObject = $gateway ?? $this->requireGateway((int)$job->gateway_id->value());
$response = [
'ok' => $ok,
'payload' => $payload,
];
if (!$ok && $errorMessage !== null && trim($errorMessage) !== '') {
$response['error'] = $errorMessage;
}
$job->response_json->set($response);
$job->completed_at->set($this->now());
$ok = (bool)($response['ok'] ?? false);
$job->error_message->set($ok ? null : $errorMessage);
$job->status->set($ok ? 'COMPLETED' : 'FAILED');
if (!$ok) {
$job->error_message->set((string)($response['error'] ?? 'Edge broker command failed'));
throw new Exception((string)($response['error'] ?? 'Edge broker command failed'));
$this->applyCommandResult($gatewayObject, $job, $ok, $payload, $errorMessage);
}
private function applyCommandResult(
edge_gateways_o $gateway,
edge_gateway_command_jobs_o $job,
bool $ok,
array $payload,
?string $errorMessage
): void {
$commandType = (string)$job->command_type->value();
if ($commandType === 'DISCOVER_SHELLY') {
if ($ok) {
$inventory = isset($payload['inventory']) && is_array($payload['inventory']) ? $payload['inventory'] : [];
$this->syncDeviceInventory((int)$gateway->id, $inventory);
$gateway->discovery_status->set('READY');
} else {
$gateway->discovery_status->set('FAILED');
}
return;
}
return (array)($response['payload'] ?? $response);
if ($commandType === 'RUN_UPDATE') {
$this->finalizeLinkedUpdateJob((int)$job->id, $ok, $payload, $errorMessage);
}
}
private function releaseCommandJobToQueue(edge_gateway_command_jobs_o $job): void
{
$job->response_json->set([]);
$job->error_message->set(null);
$job->completed_at->set(null);
$job->status->set('PENDING');
}
private function markCommandJobDispatching(edge_gateway_command_jobs_o $job): void
{
$job->response_json->set([]);
$job->error_message->set(null);
$job->completed_at->set(null);
$job->status->set('DISPATCHING');
}
private function shouldFallbackToQueuedDelivery(\Throwable $throwable): bool
{
if ($throwable instanceof edge_broker_transport_exception) {
return true;
}
if ($throwable instanceof edge_broker_http_exception) {
return $throwable->statusCode() === 503;
}
$message = $throwable->getMessage();
return str_contains($message, 'Gateway agent is offline')
|| str_contains($message, 'Could not resolve host:')
|| str_contains($message, 'Failed to connect')
|| str_contains($message, 'Connection refused');
}
private function markLinkedUpdateJobStarted(int $commandJobId): void
{
$updateJob = $this->findLinkedUpdateJobByCommandId($commandJobId);
if ($updateJob === null) {
return;
}
if ($updateJob->started_at->value() === null) {
$updateJob->started_at->set($this->now());
}
}
private function finalizeLinkedUpdateJob(int $commandJobId, bool $ok, array $payload, ?string $errorMessage): void
{
$updateJob = $this->findLinkedUpdateJobByCommandId($commandJobId);
if ($updateJob === null) {
return;
}
if ($updateJob->started_at->value() === null) {
$updateJob->started_at->set($this->now());
}
$updateJob->status->set($ok ? 'COMPLETED' : 'FAILED');
$updateJob->completed_at->set($this->now());
$updateJob->result_json->set($ok
? $payload
: ['error' => $errorMessage ?: 'Edge gateway update failed']);
}
private function findLinkedUpdateJobByCommandId(int $commandJobId): ?edge_gateway_update_jobs_o
{
$rows = (new edge_gateway_update_jobs_o())->getFieldsWhere([
'command_job_id' => $commandJobId,
'deleted_at' => null,
], ['id']);
if ($rows === []) {
return null;
}
return (new edge_gateway_update_jobs_o())->select((int)$rows[0]['id']);
}
/**
@@ -125,6 +125,7 @@ class edge_gateway_schema_bootstrap
"CREATE TABLE IF NOT EXISTS edge_gateway_update_jobs (
id INT AUTO_INCREMENT PRIMARY KEY,
gateway_id INT NOT NULL,
command_job_id INT NULL,
target_version VARCHAR(64) NOT NULL,
release_channel VARCHAR(32) NOT NULL DEFAULT 'stable',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
@@ -137,6 +138,7 @@ class edge_gateway_schema_bootstrap
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
INDEX idx_edge_gateway_update_gateway (gateway_id),
INDEX idx_edge_gateway_update_command (command_job_id),
INDEX idx_edge_gateway_update_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
@@ -182,6 +184,38 @@ class edge_gateway_schema_bootstrap
$db->query($sql);
}
if (!self::tableHasColumn('edge_gateway_update_jobs', 'command_job_id')) {
$db->query(
"ALTER TABLE edge_gateway_update_jobs
ADD COLUMN command_job_id INT NULL AFTER gateway_id,
ADD INDEX idx_edge_gateway_update_command (command_job_id)"
);
}
self::$initialized = true;
}
private static function tableHasColumn(string $table, string $column): bool
{
global $db;
$table = $db->escape_string($table);
$column = $db->escape_string($column);
$database = $db->escape_string($db->getDatabase());
$result = $db->query(
"SELECT COUNT(*) AS c
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = '$database'
AND TABLE_NAME = '$table'
AND COLUMN_NAME = '$column'"
);
if (!$result) {
return false;
}
$row = $result->fetch_assoc();
return ((int)($row['c'] ?? 0)) > 0;
}
}
@@ -0,0 +1,66 @@
<?php
namespace classes;
use DateTimeImmutable;
use InvalidArgumentException;
class orders_input_normalizer
{
public static function normalizeCreatedAt(mixed $value): string
{
if ($value instanceof \DateTimeInterface) {
return $value->format('Y-m-d H:i:s');
}
if (!is_string($value)) {
throw new InvalidArgumentException('created_at must be a string');
}
$trimmed = trim($value);
if ($trimmed === '') {
throw new InvalidArgumentException('created_at cannot be empty');
}
foreach (['Y-m-d H:i:s', 'Y-m-d\TH:i:s', 'Y-m-d\TH:i'] as $format) {
$parsed = DateTimeImmutable::createFromFormat($format, $trimmed);
if ($parsed instanceof DateTimeImmutable && $parsed->format($format) === $trimmed) {
return $parsed->format('Y-m-d H:i:s');
}
}
throw new InvalidArgumentException('created_at must be a valid datetime');
}
public static function normalizeIncludeInInvoice(mixed $value): ?bool
{
if ($value === null) {
return null;
}
if (is_bool($value)) {
return $value;
}
if (is_int($value)) {
if ($value === 1) {
return true;
}
if ($value === 0) {
return false;
}
}
if (is_string($value)) {
$normalized = strtolower(trim($value));
return match ($normalized) {
'', 'null', 'use_department' => null,
'1', 'true', 'include', 'included', 'yes' => true,
'0', 'false', 'exclude', 'excluded', 'no' => false,
default => throw new InvalidArgumentException('include_in_invoice must be use_department, include, or exclude'),
};
}
throw new InvalidArgumentException('include_in_invoice must be use_department, include, or exclude');
}
}
@@ -0,0 +1,32 @@
<?php
namespace classes;
/**
* Ensures additive schema for orders metadata.
*/
class orders_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
$db->query(
"ALTER TABLE orders
ADD COLUMN IF NOT EXISTS include_in_invoice TINYINT(1) NULL DEFAULT NULL
AFTER created_at"
);
self::$initialized = true;
}
}
@@ -73,11 +73,52 @@ class economicCustomers extends economic_m
if ($customer_number === 0) {
return 0;
}
// Get the customer products
$products = $this->getCustomerProducts($customer_number, 1)->collection;
$discount = $this->getCustomerProductDiscount($customer_number, $products[0]->product->productNumber);
// Since the discount is global, we only need to get the discount for one product
return $discount->discountPercentage ?? 0;
// The discount is global, but e-conomic resolves it through a product-specific
// invoice-line template. For foreign-currency customers some templates can fail
// if that product has no price in the customer currency, so try a few products
// before falling back to zero.
$products = $this->getCustomerProducts($customer_number, 10);
foreach ($this->extractCustomerProductNumbers($products) as $product_number) {
try {
$discount = $this->getCustomerProductDiscount($customer_number, $product_number);
return (int)($discount->discountPercentage ?? 0);
} catch (\RuntimeException $exception) {
if (!$this->isMissingCurrencyPriceLookupError($exception)) {
throw $exception;
}
}
}
return 0;
}
/**
* @return int[]
*/
private function extractCustomerProductNumbers(object $products): array
{
if (!isset($products->collection) || !is_array($products->collection)) {
return [];
}
$product_numbers = [];
foreach ( $products->collection as $product ) {
$product_number = $product->product->productNumber ?? null;
if ($product_number === null || $product_number === '') {
continue;
}
$product_numbers[] = (int)$product_number;
}
return array_values(array_unique($product_numbers));
}
private function isMissingCurrencyPriceLookupError(\RuntimeException $exception): bool
{
$message = $exception->getMessage();
return str_contains($message, 'No price in currency')
&& str_contains($message, 'can be found for the product');
}
public function getCustomerProducts(int $customer_number, int $limit = 10, int $page = 1): object
@@ -12,6 +12,7 @@ class edge_gateway_update_jobs_o extends db
use db_object_t;
public object_property $gateway_id;
public object_property $command_job_id;
public object_property $target_version;
public object_property $release_channel;
public object_property $status;
@@ -33,6 +34,7 @@ class edge_gateway_update_jobs_o extends db
public function getObjectProperties(): void
{
$this->gateway_id = new object_property($this->table, $this->id, 'gateway_id', 'int', false);
$this->command_job_id = new object_property($this->table, $this->id, 'command_job_id', 'int', false);
$this->target_version = new object_property($this->table, $this->id, 'target_version', 'string', false);
$this->release_channel = new object_property($this->table, $this->id, 'release_channel', 'string', false);
$this->status = new object_property($this->table, $this->id, 'status', 'string', false);
@@ -57,6 +59,7 @@ class edge_gateway_update_jobs_o extends db
return [
'id' => (int)$this->id,
'gateway_id' => (int)$this->gateway_id->value(),
'command_job_id' => $this->command_job_id->value() === null ? null : (int)$this->command_job_id->value(),
'target_version' => (string)$this->target_version->value(),
'release_channel' => (string)$this->release_channel->value(),
'status' => (string)$this->status->value(),
+44
View File
@@ -6,6 +6,7 @@ use attachments\helpers\attachment_content;
use classes\db;
use classes\email;
use classes\invoicing_period_utils;
use classes\orders_schema_bootstrap;
use classes\pdf_generator;
use classes\motorapi;
use classes\object_property;
@@ -31,6 +32,7 @@ class orders_o extends db
public object_property $reg_3;
public economic_module_orders $economic_module_orders;
public object_property $created_at;
public object_property $include_in_invoice;
public object_property $deleted_at;
public object_property $completed_at;
@@ -53,6 +55,7 @@ class orders_o extends db
public function structure(): void
{
orders_schema_bootstrap::ensureTables();
$this->setTable('orders');
}
@@ -86,6 +89,7 @@ class orders_o extends db
$this->reg_2 = new object_property($this->table, $this->id, 'reg_2', 'string', false);
$this->reg_3 = new object_property($this->table, $this->id, 'reg_3', 'string', false);
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp', false);
$this->include_in_invoice = new object_property($this->table, $this->id, 'include_in_invoice', 'bool', false);
$this->completed_at = new object_property($this->table, $this->id, 'completed_at', 'timestamp', false);
$this->economic_module_orders = (new economic_module_orders())->getByOrderId($this->id);
$this->stripe_module_orders = (new stripe_module_orders_o())->select($this->id);
@@ -568,6 +572,8 @@ class orders_o extends db
'reg_3' => $this->reg_3->value(),
'completed_at' => $this->completed_at->value(),
'created_at' => $this->created_at->value(),
'include_in_invoice' => $this->getIncludeInInvoiceOverride(),
'include_in_invoice_effective' => $this->isIncludedInInvoicing(),
'deleted_at' => $this->deleted_at->value(),
'total_net_amount' => $this->temporary_net_amount ?: $this->getNetAmount(),
'invoice_collection_id' => (int)$this->invoice_collection_id->value(),
@@ -1491,6 +1497,44 @@ class orders_o extends db
public function isIncludedInInvoicing(): bool
{
self::requireSelected();
$override = $this->getIncludeInInvoiceOverride();
if ($override !== null) {
return $override;
}
return $this->resolveDepartmentIncludedInInvoicing();
}
public static function normalizeNullableBooleanValue(mixed $value): ?bool
{
if ($value === null || $value === '') {
return null;
}
if (is_bool($value)) {
return $value;
}
if (is_int($value)) {
return $value === 1 ? true : ($value === 0 ? false : null);
}
$normalized = strtolower(trim((string)$value));
return match ($normalized) {
'1', 'true' => true,
'0', 'false' => false,
default => null,
};
}
public function getIncludeInInvoiceOverride(): ?bool
{
self::requireSelected();
return self::normalizeNullableBooleanValue($this->include_in_invoice->value());
}
protected function resolveDepartmentIncludedInInvoicing(): bool
{
return !(new departments_o())->select((int)$this->department_id->value())->isExcludedFromInvoicing();
}
+47
View File
@@ -11059,6 +11059,35 @@ paths:
schema:
$ref: '#/components/schemas/DepartmentDailyReportComplaintDeleteResponse'
/departments/daily-reports/complaints/customers:
get:
tags:
- Departments
summary: Search selectable customers for daily report complaints
operationId: searchDailyReportComplaintCustomers
parameters:
- name: search
in: query
required: true
schema:
type: string
minLength: 2
- name: limit
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 20
default: 10
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/DepartmentDailyReportComplaintCustomerSearchResponse'
/departments/daily-reports/product-count:
get:
tags:
@@ -17024,6 +17053,15 @@ components:
minLength: 1
maxLength: 4000
DepartmentDailyReportComplaintCustomerSearchResult:
type: object
properties:
customer_number:
type: integer
customer_name:
type: string
nullable: true
DepartmentDailyReportComplaint:
type: object
properties:
@@ -17065,6 +17103,15 @@ components:
items:
$ref: '#/components/schemas/DepartmentDailyReportComplaint'
DepartmentDailyReportComplaintCustomerSearchResponse:
type: object
properties:
success: { type: boolean, example: true }
data:
type: array
items:
$ref: '#/components/schemas/DepartmentDailyReportComplaintCustomerSearchResult'
DepartmentDailyReportComplaintDeleteResponse:
type: object
properties:
@@ -1327,7 +1327,7 @@ class InvoicingPeriodRoute
'amount' => $transaction->temporary_net_amount, // Use the temporary net amount set earlier
'booked' => $transaction->isBooked(true),
'department_id' => $departmentId,
'excluded' => self::isDepartmentExcludedFromInvoicingCached($departmentId),
'excluded' => !$transaction->isIncludedInInvoicing(),
'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null,
'queue_status' => null,
'queue_job_id' => null,
@@ -5,6 +5,7 @@ namespace routes;
use classes\authentication;
use classes\department_outside_hours_statistics_service;
use classes\workfeed;
use customers\economicCustomers;
use DateInterval;
use DateTime;
use DateTimeZone;
@@ -343,6 +344,119 @@ class departmentDailyReportsRoute
]
);
$this->get('/departments/daily-reports/complaints/customers', function () {
global $response;
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('departments', 'global', 1, 0, 'LIST_DEPARTMENT_DAILY_REPORT_COMPLAINT_CUSTOMERS', 'No user found, or invalid session');
$response->error('Invalid session', 400);
return;
}
$has_create_permission = $this->hasPermission('create_department_daily_report_complaints');
$has_edit_permission = $this->hasPermission('edit_department_daily_report_complaints');
if (!$has_create_permission && !$has_edit_permission) {
$this->emitForbidden([
'create_department_daily_report_complaints',
'edit_department_daily_report_complaints',
]);
return;
}
self::requireParameters(['search']);
self::requireType(
self::getParameter('search'),
self::type_string()
);
$search = trim((string)self::getParameter('search'));
if (mb_strlen($search) < 2) {
$response->error('Search must be at least 2 characters', 400);
return;
}
$limit = 10;
if (
self::isParametersSet(['limit'])
&& self::getParameter('limit') !== null
&& trim((string)self::getParameter('limit')) !== ''
) {
self::requireType(
(int)self::getParameter('limit'),
self::type_int()
);
self::requireMinValue(
(int)self::getParameter('limit'),
1
);
self::requireMaxValue(
(int)self::getParameter('limit'),
20
);
$limit = (int)self::getParameter('limit');
}
try {
$result = $this->complaintCustomerSearchService()->listCustomers(1, $limit, $search, null);
$collection = is_array($result->collection ?? null) ? $result->collection : [];
} catch (\Throwable $throwable) {
$upstream_message = $this->sanitizeComplaintCustomerLookupUpstreamErrorMessage($throwable);
(new logs_o())->add(
'departments',
'global',
1,
$user->id,
'LIST_DEPARTMENT_DAILY_REPORT_COMPLAINT_CUSTOMERS_FAILED',
'Failed to search complaint customers from e-conomic: ' . $upstream_message
);
$response->error([
'message' => 'Failed to fetch complaint customers from e-conomic',
'upstream_message' => $upstream_message,
], 502);
return;
}
$matches = array_values(array_filter(array_map(
static function (mixed $customer): ?array {
$customer_number = 0;
$customer_name = null;
if (is_object($customer)) {
$customer_number = (int)($customer->customerNumber ?? 0);
$name = trim((string)($customer->name ?? ''));
$customer_name = $name === '' ? null : $name;
} elseif (is_array($customer)) {
$customer_number = (int)($customer['customerNumber'] ?? 0);
$name = trim((string)($customer['name'] ?? ''));
$customer_name = $name === '' ? null : $name;
}
if ($customer_number <= 0) {
return null;
}
return [
'customer_number' => $customer_number,
'customer_name' => $customer_name,
];
},
$collection
)));
(new logs_o())->add('departments', 'global', 1, $user->id, 'LIST_DEPARTMENT_DAILY_REPORT_COMPLAINT_CUSTOMERS', 'Successfully searched complaint customers');
$response->success($matches);
},
[
'create_department_daily_report_complaints' => 'Search customers when creating department daily report customer complaints',
'edit_department_daily_report_complaints' => 'Search customers when editing department daily report customer complaints',
]
);
$this->get('/departments/daily-reports/complaints', function () {
global $response;
$this->requirePermission('list_department_daily_report_complaints');
@@ -1901,11 +2015,31 @@ class departmentDailyReportsRoute
return new department_daily_report_complaints_o();
}
protected function complaintCustomerSearchService(): economicCustomers
{
return new economicCustomers();
}
protected function outsideHoursStatisticsService(): department_outside_hours_statistics_service
{
return new department_outside_hours_statistics_service();
}
private function sanitizeComplaintCustomerLookupUpstreamErrorMessage(\Throwable $throwable): string
{
$message = trim($throwable->getMessage());
if ($message === '') {
return 'Unexpected e-conomic integration error.';
}
$message = preg_replace('/\s+/', ' ', $message);
if (!is_string($message)) {
return 'Unexpected e-conomic integration error.';
}
return substr($message, 0, 500);
}
private function departmentIdFromValue(mixed $department): int
{
if (is_array($department)) {
@@ -185,6 +185,8 @@ class edgeGatewaysRoute
$this->get('/edge-agent/artifacts/package.json', fn() => $this->renderAgentArtifact('package.json', 'application/json; charset=utf-8'));
$this->post('/edge-agent/claim', fn() => $this->handleAgentClaim());
$this->post('/edge-agent/gateways/{id}/heartbeat', fn() => $this->handleAgentHeartbeat());
$this->post('/edge-agent/gateways/{id}/commands/poll', fn() => $this->handleAgentCommandPoll());
$this->post('/edge-agent/gateways/{id}/commands/{jobId}/result', fn() => $this->handleAgentCommandResult());
$this->post('/edge-agent/internal/agent/auth', fn() => $this->handleInternalAgentAuth());
$this->post('/edge-agent/internal/shell/auth', fn() => $this->handleInternalShellAuth());
$this->post('/edge-agent/internal/shell-sessions/{id}/close', fn() => $this->handleInternalShellClose());
@@ -245,6 +247,45 @@ class edgeGatewaysRoute
$response->success((new edge_gateway_manager())->recordHeartbeat($gatewayId, $token, $payload));
}
private function handleAgentCommandPoll(): void
{
global /** @var response $response */ $response;
$gatewayId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($gatewayId, 'id');
$payload = self::getParametersAsArray();
$token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token')));
if ($token === '') {
$response->error('Missing edge gateway agent token', 401);
}
$waitSeconds = isset($payload['wait_seconds']) ? (int)$payload['wait_seconds'] : edge_gateway_manager::COMMAND_POLL_TIMEOUT_SECONDS;
$response->success((new edge_gateway_manager())->pollCommand($gatewayId, $token, $waitSeconds));
}
private function handleAgentCommandResult(): void
{
global /** @var response $response */ $response;
$gatewayId = (int)$this->fromRoute('id');
$jobId = (int)$this->fromRoute('jobId');
self::requireParameterIntPositive($gatewayId, 'id');
self::requireParameterIntPositive($jobId, 'jobId');
$payload = self::getParametersAsArray();
$token = trim((string)($payload['agent_token'] ?? $this->fromRequest('agent_token')));
if ($token === '') {
$response->error('Missing edge gateway agent token', 401);
}
$response->success((new edge_gateway_manager())->submitCommandResult(
$gatewayId,
$jobId,
$token,
(bool)($payload['ok'] ?? false),
isset($payload['payload']) && is_array($payload['payload']) ? (array)$payload['payload'] : [],
isset($payload['error']) ? (string)$payload['error'] : null
));
}
private function handleInternalAgentAuth(): void
{
global /** @var response $response */ $response;
+27 -2
View File
@@ -6,6 +6,7 @@ use attachments\helpers\attachment_content;
use classes\attachment_store;
use classes\attachments;
use classes\authentication;
use classes\orders_input_normalizer;
use classes\response;
use classes\stripe;
use JetBrains\PhpStorm\NoReturn;
@@ -141,6 +142,15 @@ class ordersRoute
$reg_1 = preg_replace('/\s+/', '', $reg_1);
$reg_2 = preg_replace('/\s+/', '', $reg_2);
$reg_3 = preg_replace('/\s+/', '', $reg_3);
try {
$createdAt = orders_input_normalizer::normalizeCreatedAt($data['created_at'] ?? date('Y-m-d H:i:s'));
$includeInInvoice = array_key_exists('include_in_invoice', $data)
? orders_input_normalizer::normalizeIncludeInInvoice($data['include_in_invoice'])
: null;
} catch (\InvalidArgumentException $e) {
$response->error($e->getMessage(), 400);
}
$new_data = [
'customer_id' => (int)$data['customer_id'],
'department_id' => (int)$data['department_id'],
@@ -153,7 +163,8 @@ class ordersRoute
...(!empty($data['lane']) ? ['lane' => (int)$data['lane']] : []), // Optional lane
...(!empty($data['wash_id']) ? ['wash_id' => (string)$data['wash_id']] : []), // Optional wash ID
...(!empty($data['booking_id']) ? ['booking_id' => (int)$data['booking_id']] : []), // Optional booking ID
'created_at' => (string)($data['created_at'] ?? date('Y-m-d H:i:s')), // Default to current time if not set
'created_at' => $createdAt, // Default to current time if not set
...(array_key_exists('include_in_invoice', $data) ? ['include_in_invoice' => $includeInInvoice] : []),
];
// Create the order
//$order = (new orders_o())->add((int)$data['customer_id'], $user->id, $data['reference'], $data['notes'], (int)$data['department_id'], (string)$reg_1, (string)$reg_2, (string)$reg_3);
@@ -1056,7 +1067,21 @@ class ordersRoute
}
// Check if the created_at is set
if (isset($data['created_at'])) {
$order->created_at->set($data['created_at']);
try {
$order->created_at->set(orders_input_normalizer::normalizeCreatedAt($data['created_at']));
} catch (\InvalidArgumentException $e) {
$response->error($e->getMessage(), 400);
}
}
// Check if include_in_invoice is set
if (array_key_exists('include_in_invoice', $data)) {
try {
$order->include_in_invoice->set(
orders_input_normalizer::normalizeIncludeInInvoice($data['include_in_invoice'])
);
} catch (\InvalidArgumentException $e) {
$response->error($e->getMessage(), 400);
}
}
// Void any cached key for the order
$order->objectChanged();
@@ -88,6 +88,7 @@ function department_outside_hours_prepare_tables(db $db): void
reg_2 VARCHAR(64) NULL,
reg_3 VARCHAR(64) NULL,
created_at DATETIME NOT NULL,
include_in_invoice TINYINT(1) NULL,
wash_id VARCHAR(255) NULL,
deleted_at DATETIME NULL
)'
@@ -27,17 +27,21 @@ function department_daily_reports_complaints_openapi_content_or_skip(): string
test()->markTestSkipped('openapi.yaml is not mounted in this test container.');
}
it('documents the daily report complaints CRUD endpoints and schemas in openapi', function (): void {
it('documents the daily report complaints CRUD and customer lookup endpoints in openapi', function (): void {
$content = department_daily_reports_complaints_openapi_content_or_skip();
expect($content)->toContain('/departments/daily-reports/complaints:');
expect($content)->toContain('/departments/daily-reports/complaints/customers:');
expect($content)->toContain('operationId: listDailyReportComplaints');
expect($content)->toContain('operationId: searchDailyReportComplaintCustomers');
expect($content)->toContain('operationId: createDailyReportComplaint');
expect($content)->toContain('operationId: updateDailyReportComplaint');
expect($content)->toContain('operationId: deleteDailyReportComplaint');
expect($content)->toContain('DepartmentDailyReportComplaintCreateRequest:');
expect($content)->toContain('DepartmentDailyReportComplaintUpdateRequest:');
expect($content)->toContain('DepartmentDailyReportComplaint:');
expect($content)->toContain('DepartmentDailyReportComplaintCustomerSearchResult:');
expect($content)->toContain('DepartmentDailyReportComplaintCustomerSearchResponse:');
expect($content)->toContain('DepartmentDailyReportComplaintCollectionResponse:');
expect($content)->toContain('DepartmentDailyReportComplaintDeleteResponse:');
expect($content)->toContain('department_name:');
@@ -1,15 +1,20 @@
<?php
it('wires complaint create, list, edit, and delete routes with validation and parsing', function (): void {
it('wires complaint create, lookup, list, edit, and delete routes with validation and parsing', function (): void {
$content = (string)file_get_contents(app_path('routes/departmentDailyReportsRoute.php'));
expect($content)->toContain('/departments/daily-reports/complaints');
expect($content)->toContain('/departments/daily-reports/complaints/customers');
expect($content)->toContain("requirePermission('create_department_daily_report_complaints')");
expect($content)->toContain("requirePermission('list_department_daily_report_complaints')");
expect($content)->toContain("requirePermission('edit_department_daily_report_complaints')");
expect($content)->toContain("requirePermission('delete_department_daily_report_complaints')");
expect($content)->toContain("hasPermission('create_department_daily_report_complaints')");
expect($content)->toContain("hasPermission('edit_department_daily_report_complaints')");
expect($content)->toContain("requireDepartmentAccess((int)self::getParameter('department_id'))");
expect($content)->toContain("getOrImportCustomerByCustomerNumber");
expect($content)->toContain("Search must be at least 2 characters");
expect($content)->toContain("Failed to fetch complaint customers from e-conomic");
expect($content)->toContain("Description is required");
expect($content)->toContain("dailyReportComplaintsRepository()->addComplaint");
expect($content)->toContain("Complaint not found");
@@ -0,0 +1,80 @@
<?php
app_require('modules/economic/economic_m.php');
app_require('modules/economic/customers/economicCustomers.php');
use customers\economicCustomers;
class economicCustomersDiscountFallbackTestDouble extends economicCustomers
{
/** @var array<int, int|string> */
public array $product_numbers = [];
/** @var array<int, object|\RuntimeException> */
public array $discount_responses = [];
public function __construct()
{
}
public function getCustomerProducts(int $customer_number, int $limit = 10, int $page = 1): object
{
return (object)[
'collection' => array_map(
fn (int|string $product_number): object => (object)[
'product' => (object)[
'productNumber' => (string)$product_number,
],
],
$this->product_numbers
),
];
}
public function getCustomerProductDiscount(int $customer_number, int $product_id)
{
$response = $this->discount_responses[$product_id] ?? (object)[
'discountPercentage' => 0,
];
if ($response instanceof \RuntimeException) {
throw $response;
}
return $response;
}
}
it('falls back to a later customer template product when earlier probes fail on missing currency prices', function (): void {
$economic = new economicCustomersDiscountFallbackTestDouble();
$economic->product_numbers = [1, 5, 9];
$economic->discount_responses = [
1 => new RuntimeException('e-conomic request failed with HTTP 400: No price in currency EUR can be found for the product. | details={"httpStatusCode":400}'),
5 => (object)['discountPercentage' => 12],
];
expect($economic->getCustomerDiscountPercentage(23152645))->toBe(12);
});
it('returns zero when every customer template lookup fails due to missing currency prices', function (): void {
$economic = new economicCustomersDiscountFallbackTestDouble();
$economic->product_numbers = [1, 2, 3];
$economic->discount_responses = [
1 => new RuntimeException('e-conomic request failed with HTTP 400: No price in currency EUR can be found for the product. | details={"httpStatusCode":400}'),
2 => new RuntimeException('e-conomic request failed with HTTP 400: No price in currency EUR can be found for the product. | details={"httpStatusCode":400}'),
3 => new RuntimeException('e-conomic request failed with HTTP 400: No price in currency EUR can be found for the product. | details={"httpStatusCode":400}'),
];
expect($economic->getCustomerDiscountPercentage(23152645))->toBe(0);
});
it('rethrows unrelated discount lookup failures', function (): void {
$economic = new economicCustomersDiscountFallbackTestDouble();
$economic->product_numbers = [1];
$economic->discount_responses = [
1 => new RuntimeException('HTTP 503 upstream unavailable'),
];
expect(fn () => $economic->getCustomerDiscountPercentage(23152645))
->toThrow(RuntimeException::class, 'HTTP 503 upstream unavailable');
});
@@ -0,0 +1,88 @@
<?php
app_require('classes/economic_v2_versioning_service.php');
app_require('classes/economic_v2_distribution_service.php');
use classes\economic_v2_distribution_service;
use classes\economic_v2_versioning_service;
if (!class_exists('EconomicV2DistributionServiceOrderOverrideVersioningDouble')) {
class EconomicV2DistributionServiceOrderOverrideVersioningDouble extends economic_v2_versioning_service
{
public function __construct()
{
}
public function resolveFixedPricingVersionAt(int $customer_number, string $timestamp): ?array
{
return null;
}
public function resolveVehicleSubscriptionVersionsAt(int $customer_number, string $timestamp): array
{
return [];
}
public function resolveDiscountOverrideAt(int $customer_number, bool $is_category, string|int $object_id, string $timestamp): ?array
{
return null;
}
public function runBestEffortBackfill(): array
{
return [];
}
}
}
if (!class_exists('EconomicV2DistributionServiceOrderOverrideDouble')) {
class EconomicV2DistributionServiceOrderOverrideDouble extends economic_v2_distribution_service
{
public function __construct()
{
parent::__construct(new EconomicV2DistributionServiceOrderOverrideVersioningDouble());
}
public function exposeIsOrderEligible(array $order): bool
{
return $this->isOrderEligible($order);
}
protected function isDepartmentEligible(int $department_id): bool
{
return $department_id === 12;
}
}
}
it('inherits department eligibility when no order override is set', function (): void {
$service = new EconomicV2DistributionServiceOrderOverrideDouble();
expect($service->exposeIsOrderEligible([
'department_id' => 12,
'include_in_invoice' => null,
]))->toBeTrue();
expect($service->exposeIsOrderEligible([
'department_id' => 13,
'include_in_invoice' => null,
]))->toBeFalse();
});
it('allows an order-level include override to overrule department exclusion', function (): void {
$service = new EconomicV2DistributionServiceOrderOverrideDouble();
expect($service->exposeIsOrderEligible([
'department_id' => 13,
'include_in_invoice' => '1',
]))->toBeTrue();
});
it('allows an order-level exclude override to overrule department inclusion', function (): void {
$service = new EconomicV2DistributionServiceOrderOverrideDouble();
expect($service->exposeIsOrderEligible([
'department_id' => 12,
'include_in_invoice' => '0',
]))->toBeFalse();
});
@@ -0,0 +1,94 @@
<?php
app_require('classes/object_property.php');
app_require('objects/orders_o.php');
use classes\object_property;
use objects\orders_o;
if (!class_exists('OrdersIncludeInInvoiceOverrideOrderDouble')) {
class OrdersIncludeInInvoiceOverrideOrderDouble extends orders_o
{
public bool $departmentIncluded = true;
public function __construct()
{
$this->id = -1;
$this->customer_id = new object_property('orders', -1, 'customer_id', 'int', true);
$this->cashier_id = new object_property('orders', -1, 'cashier_id', 'int', true);
$this->reference = new object_property('orders', -1, 'reference', 'string', false);
$this->notes = new object_property('orders', -1, 'notes', 'string', false);
$this->department_id = new object_property('orders', -1, 'department_id', 'int', true);
$this->reg_1 = new object_property('orders', -1, 'reg_1', 'string', false);
$this->reg_2 = new object_property('orders', -1, 'reg_2', 'string', false);
$this->reg_3 = new object_property('orders', -1, 'reg_3', 'string', false);
$this->created_at = new object_property('orders', -1, 'created_at', 'timestamp', false);
$this->include_in_invoice = new object_property('orders', -1, 'include_in_invoice', 'bool', false);
$this->completed_at = new object_property('orders', -1, 'completed_at', 'timestamp', false);
$this->deleted_at = new object_property('orders', -1, 'deleted_at', 'timestamp', false);
$this->invoice_collection_id = new object_property('orders', -1, 'invoice_collection_id', 'int', false);
$this->booking_id = new object_property('orders', -1, 'booking_id', 'int', false);
$this->wash_id = new object_property('orders', -1, 'wash_id', 'string', false);
$this->lane = new object_property('orders', -1, 'lane', 'string', false);
$this->po = new object_property('orders', -1, 'po', 'string', false);
$this->using_hand_held = new object_property('orders', -1, 'using_hand_held', 'bool', false);
$this->temporary_net_amount = 125.5;
$this->customer_id->set(1234567);
$this->cashier_id->set(42);
$this->department_id->set(12);
$this->created_at->set('2026-04-09 12:34:56');
$this->reference->set('Ref');
}
protected function resolveDepartmentIncludedInInvoicing(): bool
{
return $this->departmentIncluded;
}
public function isPendingHandheld(): bool
{
return false;
}
}
}
it('lets the order-level override include an otherwise excluded order', function (): void {
$order = new OrdersIncludeInInvoiceOverrideOrderDouble();
$order->departmentIncluded = false;
$order->include_in_invoice->set(true);
expect($order->getIncludeInInvoiceOverride())->toBeTrue();
expect($order->isIncludedInInvoicing())->toBeTrue();
});
it('lets the order-level override exclude an otherwise included order', function (): void {
$order = new OrdersIncludeInInvoiceOverrideOrderDouble();
$order->departmentIncluded = true;
$order->include_in_invoice->set(false);
expect($order->getIncludeInInvoiceOverride())->toBeFalse();
expect($order->isIncludedInInvoicing())->toBeFalse();
});
it('falls back to the department invoicing rule when the override is null', function (): void {
$order = new OrdersIncludeInInvoiceOverrideOrderDouble();
$order->departmentIncluded = false;
$order->include_in_invoice->set(null);
expect($order->getIncludeInInvoiceOverride())->toBeNull();
expect($order->isIncludedInInvoicing())->toBeFalse();
});
it('serializes both raw and effective include_in_invoice values', function (): void {
$order = new OrdersIncludeInInvoiceOverrideOrderDouble();
$order->departmentIncluded = true;
$order->include_in_invoice->set(null);
expect($order->asArray(true, false))->toMatchArray([
'include_in_invoice' => null,
'include_in_invoice_effective' => true,
'created_at' => '2026-04-09 12:34:56',
'total_net_amount' => 125.5,
]);
});
@@ -0,0 +1,28 @@
<?php
app_require('classes/orders_input_normalizer.php');
use classes\orders_input_normalizer;
it('normalizes sql and datetime-local created_at values', function (): void {
expect(orders_input_normalizer::normalizeCreatedAt('2026-04-09 12:34:56'))->toBe('2026-04-09 12:34:56');
expect(orders_input_normalizer::normalizeCreatedAt('2026-04-09T12:34'))->toBe('2026-04-09 12:34:00');
expect(orders_input_normalizer::normalizeCreatedAt('2026-04-09T12:34:56'))->toBe('2026-04-09 12:34:56');
});
it('rejects invalid created_at values', function (): void {
orders_input_normalizer::normalizeCreatedAt('2026/04/09 12:34');
})->throws(InvalidArgumentException::class, 'created_at must be a valid datetime');
it('normalizes include_in_invoice tri-state inputs', function (): void {
expect(orders_input_normalizer::normalizeIncludeInInvoice(null))->toBeNull();
expect(orders_input_normalizer::normalizeIncludeInInvoice('use_department'))->toBeNull();
expect(orders_input_normalizer::normalizeIncludeInInvoice('include'))->toBeTrue();
expect(orders_input_normalizer::normalizeIncludeInInvoice('exclude'))->toBeFalse();
expect(orders_input_normalizer::normalizeIncludeInInvoice(true))->toBeTrue();
expect(orders_input_normalizer::normalizeIncludeInInvoice(false))->toBeFalse();
});
it('rejects invalid include_in_invoice values', function (): void {
orders_input_normalizer::normalizeIncludeInInvoice('maybe');
})->throws(InvalidArgumentException::class, 'include_in_invoice must be use_department, include, or exclude');
@@ -0,0 +1,24 @@
<?php
it('wires order create and update routes through the settings normalizers', function (): void {
$routeFile = app_path('routes/ordersRoute.php');
$content = file_get_contents($routeFile);
expect($content)->not->toBeFalse();
expect($content)->toContain('use classes\orders_input_normalizer;');
expect($content)->toContain("orders_input_normalizer::normalizeCreatedAt(\$data['created_at'] ?? date('Y-m-d H:i:s'))");
expect($content)->toContain("array_key_exists('include_in_invoice', \$data)");
expect($content)->toContain("orders_input_normalizer::normalizeIncludeInInvoice(\$data['include_in_invoice'])");
expect($content)->toContain("\$order->include_in_invoice->set(");
expect($content)->toContain("\$response->error(\$e->getMessage(), 400);");
});
it('keeps line-item invoice filtering in the order net amount calculation', function (): void {
$objectFile = app_path('objects/orders_o.php');
$content = file_get_contents($objectFile);
expect($content)->not->toBeFalse();
expect($content)->toContain("'include_in_invoice' => \$this->getIncludeInInvoiceOverride()");
expect($content)->toContain("'include_in_invoice_effective' => \$this->isIncludedInInvoicing()");
expect($content)->toContain("if (!\$item['include_in_invoice'])");
});
@@ -6,6 +6,8 @@ it('uses the same default broker url and shared secret fallback as the docker st
expect($source)->not->toBeFalse();
expect($source)->toContain("private const DEFAULT_BROKER_URL = 'http://edge-broker:4300';");
expect($source)->toContain("private const DEFAULT_SHARED_SECRET = 'truckwash-edge-dev';");
expect($source)->toContain('class edge_broker_transport_exception extends Exception');
expect($source)->toContain('class edge_broker_http_exception extends Exception');
expect($source)->toContain("getenv('EDGE_BROKER_SHARED_SECRET')");
expect($source)->toContain("getenv('EDGE_INTERNAL_SECRET')");
});
@@ -0,0 +1,20 @@
<?php
it('queues admin commands, exposes agent poll/result handlers, and keeps heartbeats from mutating discovery state', function (): void {
$source = file_get_contents(app_path('classes/edge_gateway_manager.php'));
expect($source)->not->toBeFalse();
expect($source)->toContain('public function pollCommand');
expect($source)->toContain('public function submitCommandResult');
expect($source)->toContain('private function tryImmediateBrokerDispatch');
expect($source)->toContain('private function waitForCommandResult');
expect($source)->toContain('private function claimNextCommandJob');
expect($source)->toContain("COALESCE(updated_at, created_at, requested_at) <= :stale_before");
expect($source)->toContain("throw new Exception('Edge gateway command timed out');");
expect($source)->toContain("throw new Exception('Gateway agent is offline');");
expect($source)->toContain("\$gateway->discovery_status->set('PENDING');");
expect($source)->toContain("\$gateway->discovery_status->set('READY');");
expect($source)->toContain("\$gateway->discovery_status->set('FAILED');");
expect($source)->toContain("\$updateJob->status->set(\$ok ? 'COMPLETED' : 'FAILED');");
expect($source)->not->toContain("\$gateway->discovery_status->set((string)(\$payload['discovery_status'] ?? \$gateway->discovery_status->value()));");
});
@@ -15,6 +15,8 @@ function with_edge_gateway_server_state(array $server, callable $callback): void
{
$originalServer = $_SERVER;
$originalPublicApiUrl = getenv('EDGE_PUBLIC_API_URL');
$originalBrokerPublicUrl = getenv('EDGE_BROKER_PUBLIC_URL');
$originalBrokerPublicPort = getenv('EDGE_BROKER_PUBLIC_PORT');
$_SERVER = $server;
@@ -27,6 +29,16 @@ function with_edge_gateway_server_state(array $server, callable $callback): void
} else {
putenv('EDGE_PUBLIC_API_URL=' . $originalPublicApiUrl);
}
if ($originalBrokerPublicUrl === false) {
putenv('EDGE_BROKER_PUBLIC_URL');
} else {
putenv('EDGE_BROKER_PUBLIC_URL=' . $originalBrokerPublicUrl);
}
if ($originalBrokerPublicPort === false) {
putenv('EDGE_BROKER_PUBLIC_PORT');
} else {
putenv('EDGE_BROKER_PUBLIC_PORT=' . $originalBrokerPublicPort);
}
}
}
@@ -89,3 +101,44 @@ it('prefers EDGE_PUBLIC_API_URL when explicitly configured', function (): void {
expect($manager->buildInstallScriptUrl('token-1'))->toBe('https://edge.example.test/api/edge-agent/install.sh?token=token-1');
});
});
it('normalizes public broker overrides to https and browser shell urls to wss', function (): void {
with_edge_gateway_server_state([
'HTTP_HOST' => 'localhost',
'HTTP_X_FORWARDED_PROTO' => 'http',
], function (): void {
putenv('EDGE_BROKER_PUBLIC_URL=http://api.truckwash.io:4300');
$manager = new EdgeGatewayManagerUrlHarness();
expect($manager->getBrokerPublicUrl())->toBe('https://api.truckwash.io:4300');
expect($manager->buildBrowserShellWsUrl('shell-token'))->toBe('wss://api.truckwash.io:4300/ws/browser-shell?token=shell-token');
});
});
it('keeps localhost broker overrides on plain http and ws for local development', function (): void {
with_edge_gateway_server_state([
'HTTP_HOST' => 'localhost:5173',
'HTTP_X_FORWARDED_PROTO' => 'http',
], function (): void {
putenv('EDGE_BROKER_PUBLIC_URL=http://localhost:4300');
$manager = new EdgeGatewayManagerUrlHarness();
expect($manager->getBrokerPublicUrl())->toBe('http://localhost:4300');
expect($manager->buildBrowserShellWsUrl('shell-token'))->toBe('ws://localhost:4300/ws/browser-shell?token=shell-token');
});
});
it('includes node-pty build prerequisites in the generated install script', function (): void {
with_edge_gateway_server_state([
'HTTP_HOST' => 'api.truckwash.io:4433',
'HTTP_X_FORWARDED_PROTO' => 'https',
], function (): void {
$manager = new EdgeGatewayManagerUrlHarness();
$script = $manager->buildInstallScript('pty-token');
expect($script)->toContain('apt-get install -y curl ca-certificates nodejs npm python3 make g++');
expect($script)->toContain('npm install --omit=dev');
});
});
@@ -15,7 +15,7 @@ it('registers the edge gateway management REST endpoints', function (): void {
expect($route)->toContain("'/departments/{id}/gateway-cutover'");
});
it('registers public installer, claim, heartbeat, and internal broker auth endpoints', function (): void {
it('registers public installer, claim, heartbeat, command polling, and internal broker auth endpoints', function (): void {
$route = file_get_contents(app_path('routes/edgeGatewaysRoute.php'));
expect($route)->toContain("'/edge-agent/install.sh'");
@@ -23,6 +23,8 @@ it('registers public installer, claim, heartbeat, and internal broker auth endpo
expect($route)->toContain("'/edge-agent/artifacts/package.json'");
expect($route)->toContain("'/edge-agent/claim'");
expect($route)->toContain("'/edge-agent/gateways/{id}/heartbeat'");
expect($route)->toContain("'/edge-agent/gateways/{id}/commands/poll'");
expect($route)->toContain("'/edge-agent/gateways/{id}/commands/{jobId}/result'");
expect($route)->toContain("'/edge-agent/internal/agent/auth'");
expect($route)->toContain("'/edge-agent/internal/shell/auth'");
expect($route)->toContain("'/edge-agent/internal/shell-sessions/{id}/close'");
@@ -18,6 +18,7 @@ it('stores edge gateway heartbeats, bindings, and shell transcript data', functi
$bootstrapContent = file_get_contents(app_path('classes/edge_gateway_schema_bootstrap.php'));
expect($bootstrapContent)->toContain('last_heartbeat_at DATETIME NULL');
expect($bootstrapContent)->toContain('command_job_id INT NULL');
expect($bootstrapContent)->toContain('channel INT NOT NULL DEFAULT 0');
expect($bootstrapContent)->toContain('transcript_text LONGTEXT NULL');
expect($bootstrapContent)->toContain('context_json JSON NULL');