Continue phase 2 device ops and dynamic QR lifecycle

This commit is contained in:
2026-05-26 21:25:07 +07:00
parent 5624b92872
commit e0b8f9af9a
22 changed files with 1050 additions and 92 deletions

158
dist/routes/admin.js vendored
View File

@ -9,7 +9,7 @@ import { createMerchant, getMerchantById, listMerchants, patchMerchant, toMercha
import { createOutlet, createTerminal, getOutletById, getTerminalById, listOutlets, listTerminals, patchOutlet, patchTerminal, toOutletPayload, toTerminalPayload } from "../shared/store/locationStore";
import { bindDevice, getActiveBindingByDevice, getActiveBindingByTerminal, toBindingPayload, unbindDevice } from "../shared/store/bindingStore";
import { createDevice, getDeviceById, listDevices, patchDevice, toDevicePayload } from "../shared/store/deviceStore";
import { deriveDeviceStatus, getHeartbeatCountForDeviceLastHours, getLatestHeartbeatByDeviceId, listHeartbeats, createDeviceHeartbeat } from "../shared/store/heartbeatStore";
import { deriveDeviceHealthSummary, getHeartbeatCountForDeviceLastHours, getLatestHeartbeatByDeviceId, listHeartbeats, createDeviceHeartbeat } from "../shared/store/heartbeatStore";
import { createDeviceCommand, getDeviceCommandById, listDeviceCommands, toDeviceCommandPayload, toDeviceCommandPayloadBrief } from "../shared/store/deviceCommandStore";
import { createTransaction, getTransactionById, listTransactions, toTransactionEventPayload, toTransactionPayload, getTransactionEvents } from "../shared/store/transactionStore";
import { getNotificationByTransactionId, listNotifications, listNotificationsByDevice, toNotificationPayload } from "../shared/store/notificationStore";
@ -20,6 +20,8 @@ import { resolveDeviceCapabilitySummary } from "../shared/services/deviceCapabil
import { getOrCreateDeviceConfig, listDeviceConfigAcks, toDeviceConfigAckPayload, toDeviceConfigPayload, upsertDeviceConfig } from "../shared/store/deviceConfigStore";
import { listMqttMessages, toMqttMessagePayload, createMqttMessage } from "../shared/store/mqttMessageStore";
import { publishConfigPush } from "../shared/services/mqttPublisher";
import { buildDeviceConfigStatus } from "../shared/services/deviceConfigStatus";
import { expireDueDynamicQrTransactions } from "../shared/services/dynamicQrExpiry";
const router = Router();
function parseIdempotentReplay(req) {
return req.body.__idempotentReplay;
@ -124,14 +126,16 @@ function buildBindingSummary(binding) {
}
async function buildDeviceAdminPayload(device) {
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
const healthSummary = deriveDeviceHealthSummary({
last_seen_at: device.last_seen_at,
network_strength: latestHeartbeat?.network_strength ?? null,
battery_level: latestHeartbeat?.battery_level ?? null
});
return {
...toDevicePayload(device),
capability_summary: resolveDeviceCapabilitySummary(device),
derived_status: deriveDeviceStatus({
last_seen_at: device.last_seen_at,
network_strength: latestHeartbeat?.network_strength ?? null,
battery_level: latestHeartbeat?.battery_level ?? null
}),
derived_status: healthSummary.status,
health_summary: healthSummary,
heartbeat_count_24h: await getHeartbeatCountForDeviceLastHours(device.id),
binding_summary: buildBindingSummary(await getActiveBindingByDevice(device.id)),
latest_heartbeat: latestHeartbeat
@ -151,13 +155,15 @@ async function deriveDeviceStatusesForDashboard() {
const devices = await listDevices();
return Promise.all(devices.map(async (device) => {
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
const healthSummary = deriveDeviceHealthSummary({
last_seen_at: device.last_seen_at,
network_strength: latestHeartbeat?.network_strength ?? null,
battery_level: latestHeartbeat?.battery_level ?? null
});
return {
device,
status: deriveDeviceStatus({
last_seen_at: device.last_seen_at,
network_strength: latestHeartbeat?.network_strength ?? null,
battery_level: latestHeartbeat?.battery_level ?? null
})
status: healthSummary.status,
healthSummary
};
}));
}
@ -197,6 +203,24 @@ async function auditAdminAction(req, payload) {
trace_id: req.traceId
});
}
async function publishDeviceConfigPush(deviceId, config) {
const mqttPayload = {
message_type: "config_push",
config_version: config.config_version,
settings: config.settings_json
};
const publishResult = await publishConfigPush(deviceId, mqttPayload);
return createMqttMessage({
direction: "downlink",
device_id: deviceId,
topic: publishResult.topic,
message_type: "config_push",
correlation_id: `config:${config.config_version}`,
payload_json: mqttPayload,
publish_status: publishResult.ok ? "sent" : "failed",
reason: publishResult.reason
});
}
function validatePayoutConfig(payload) {
const mode = normalizeMerchantMode(payload.payout_mode);
if (mode === "merchant_direct") {
@ -739,15 +763,16 @@ router.get("/devices", requireAdminToken, async (req, res) => {
const evaluated = await Promise.all(rawDevices.map(async (device) => {
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
const binding = merchantId ? await getActiveBindingByDevice(device.id) : null;
const healthSummary = deriveDeviceHealthSummary({
last_seen_at: device.last_seen_at,
network_strength: latestHeartbeat?.network_strength ?? null,
battery_level: latestHeartbeat?.battery_level ?? null
});
return {
device,
latestHeartbeat,
binding,
derivedStatus: deriveDeviceStatus({
last_seen_at: device.last_seen_at,
network_strength: latestHeartbeat?.network_strength ?? null,
battery_level: latestHeartbeat?.battery_level ?? null
})
derivedStatus: healthSummary.status
};
}));
const data = evaluated
@ -789,6 +814,11 @@ router.get("/devices/:deviceId", requireAdminToken, async (req, res, next) => {
const activeBinding = await getActiveBindingByDevice(device.id);
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
const heartbeatCount24h = await getHeartbeatCountForDeviceLastHours(device.id);
const healthSummary = deriveDeviceHealthSummary({
last_seen_at: device.last_seen_at,
network_strength: latestHeartbeat?.network_strength ?? null,
battery_level: latestHeartbeat?.battery_level ?? null
});
const notifications = (await listNotificationsByDevice(device.id))
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.slice(0, 10)
@ -796,11 +826,8 @@ router.get("/devices/:deviceId", requireAdminToken, async (req, res, next) => {
res.json(successResponse(req, {
...toDevicePayload(device),
capability_summary: resolveDeviceCapabilitySummary(device),
derived_status: deriveDeviceStatus({
last_seen_at: device.last_seen_at,
network_strength: latestHeartbeat?.network_strength ?? null,
battery_level: latestHeartbeat?.battery_level ?? null
}),
derived_status: healthSummary.status,
health_summary: healthSummary,
active_binding: activeBinding ? toBindingPayload(activeBinding) : null,
latest_heartbeat: latestHeartbeat
? {
@ -994,7 +1021,16 @@ router.get("/devices/:deviceId/config", requireAdminToken, async (req, res, next
}
const config = await getOrCreateDeviceConfig(device.id);
const acks = (await listDeviceConfigAcks(device.id, 10)).map(toDeviceConfigAckPayload);
res.json(successResponse(req, { ...toDeviceConfigPayload(config), latest_acks: acks }));
const status = await buildDeviceConfigStatus(config);
res.json(successResponse(req, { ...toDeviceConfigPayload(config), latest_acks: acks, config_status: status }));
});
router.get("/devices/:deviceId/config/status", requireAdminToken, async (req, res, next) => {
const device = await getDeviceById(req.params.deviceId);
if (!device) {
return next(new ApiError("NOT_FOUND", "device not found", 404));
}
const config = await getOrCreateDeviceConfig(device.id);
res.json(successResponse(req, await buildDeviceConfigStatus(config)));
});
router.patch("/devices/:deviceId/config", requireAdminToken, async (req, res, next) => {
const device = await getDeviceById(req.params.deviceId);
@ -1010,22 +1046,7 @@ router.patch("/devices/:deviceId/config", requireAdminToken, async (req, res, ne
settings_json: payload.settings,
config_version: payload.config_version
});
const mqttPayload = {
message_type: "config_push",
config_version: config.config_version,
settings: config.settings_json
};
const publishResult = await publishConfigPush(device.id, mqttPayload);
const outbox = await createMqttMessage({
direction: "downlink",
device_id: device.id,
topic: publishResult.topic,
message_type: "config_push",
correlation_id: `config:${config.config_version}`,
payload_json: mqttPayload,
publish_status: publishResult.ok ? "sent" : "failed",
reason: publishResult.reason
});
const outbox = await publishDeviceConfigPush(device.id, config);
await auditAdminAction(req, {
action: "device.config_push",
entity_type: "device",
@ -1040,6 +1061,36 @@ router.patch("/devices/:deviceId/config", requireAdminToken, async (req, res, ne
downlink_message: toMqttMessagePayload(outbox)
}));
});
router.post("/devices/:deviceId/config/retry-push", requireAdminToken, async (req, res, next) => {
const device = await getDeviceById(req.params.deviceId);
if (!device) {
return next(new ApiError("NOT_FOUND", "device not found", 404));
}
const payload = (req.body || {});
const config = await getOrCreateDeviceConfig(device.id);
const beforeStatus = await buildDeviceConfigStatus(config);
if (beforeStatus.drift_status === "applied" && !payload.force) {
return next(new ApiError("CONFIG_ALREADY_APPLIED", "config already applied; set force=true to push again", 409));
}
const outbox = await publishDeviceConfigPush(device.id, config);
const afterStatus = await buildDeviceConfigStatus(config);
await auditAdminAction(req, {
action: "device.config_retry_push",
entity_type: "device",
entity_id: device.id,
before_json: beforeStatus,
after_json: {
...afterStatus,
downlink_message_id: outbox.id,
force: payload.force === true
}
});
res.json(successResponse(req, {
config: toDeviceConfigPayload(config),
config_status: afterStatus,
downlink_message: toMqttMessagePayload(outbox)
}));
});
router.get("/devices/:deviceId/mqtt-messages", requireAdminToken, async (req, res, next) => {
const device = await getDeviceById(req.params.deviceId);
if (!device) {
@ -1106,6 +1157,9 @@ router.post("/transactions", requireAdminToken, idempotency({ scope: "transactio
if (payload.status && !parseTransactionStatusFilter(payload.status)) {
return next(new ApiError("BAD_REQUEST", "invalid status", 400));
}
if (payload.expired_at && Number.isNaN(Date.parse(payload.expired_at))) {
return next(new ApiError("BAD_REQUEST", "expired_at must be valid ISO datetime", 400));
}
const created = await createTransaction({
merchant_id: merchant.id,
outlet_id: outlet.id,
@ -1116,7 +1170,8 @@ router.post("/transactions", requireAdminToken, idempotency({ scope: "transactio
currency: payload.currency,
qr_mode: payload.qr_mode || "static",
initiation_mode: payload.initiation_mode || "static",
status: payload.status || "initiated"
status: payload.status || "initiated",
expired_at: payload.expired_at
});
await auditAdminAction(req, {
action: "transaction.create",
@ -1161,6 +1216,31 @@ router.get("/transactions", requireAdminToken, async (req, res, next) => {
})
.map(toTransactionPayload)));
});
router.post("/transactions/expire-due", requireAdminToken, async (req, res, next) => {
const limitRaw = req.body?.limit ?? req.query.limit;
const limit = limitRaw === undefined || limitRaw === "" ? 100 : Number(limitRaw);
if (!Number.isFinite(limit) || limit <= 0) {
return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400));
}
const result = await expireDueDynamicQrTransactions({
limit,
source: "admin",
request_id: req.requestId
});
await auditAdminAction(req, {
action: "transaction.expire_due_dynamic_qr",
entity_type: "transaction_batch",
entity_id: `dynamic_qr_expiry_${result.swept_at}`,
after_json: {
scanned: result.scanned,
expired_count: result.expired_count,
skipped_count: result.skipped_count,
expired_ids: result.expired.map((tx) => tx.id),
skipped: result.skipped
}
});
res.json(successResponse(req, result));
});
router.get("/transactions/:transactionId", requireAdminToken, async (req, res, next) => {
const tx = await getTransactionById(req.params.transactionId);
if (!tx) {

15
dist/routes/device.js vendored
View File

@ -340,6 +340,21 @@ router.post("/config/ack", requireDeviceToken, async (req, res, next) => {
reason: payload.reason,
payload_json: payload.result_payload || {}
});
await createMqttMessage({
direction: "uplink",
device_id: device.id,
topic: `devices/${device.id}/uplink/config/ack`,
message_type: "config_ack",
correlation_id: `config:${configVersion}`,
payload_json: {
message_type: "config_ack",
device_id: device.id,
config_version: configVersion,
status: payload.status,
reason: payload.reason,
result_payload: payload.result_payload || {}
}
});
res.json(successResponse(req, toDeviceConfigAckPayload(ack)));
});
export default router;

View File

@ -0,0 +1,37 @@
import { getLatestDeviceConfigAck, toDeviceConfigAckPayload, toDeviceConfigPayload } from "../store/deviceConfigStore";
import { listMqttMessages, toMqttMessagePayload } from "../store/mqttMessageStore";
function deriveConfigDriftStatus(config, latestAck) {
if (!latestAck) {
return "pending_ack";
}
if (latestAck.config_version < config.config_version) {
return "stale_ack";
}
if (latestAck.config_version > config.config_version) {
return "pending_ack";
}
if (latestAck.status === "failed") {
return "failed_ack";
}
return "applied";
}
export async function buildDeviceConfigStatus(config) {
const latestAck = await getLatestDeviceConfigAck(config.device_id);
const latestPush = (await listMqttMessages({
device_id: config.device_id,
direction: "downlink",
message_type: "config_push",
correlation_id: `config:${config.config_version}`,
limit: 1
}))[0];
const driftStatus = latestPush ? deriveConfigDriftStatus(config, latestAck) : "never_pushed";
return {
device_id: config.device_id,
config: toDeviceConfigPayload(config),
drift_status: driftStatus,
desired_config_version: config.config_version,
latest_ack: latestAck ? toDeviceConfigAckPayload(latestAck) : null,
latest_push: latestPush ? toMqttMessagePayload(latestPush) : null,
retry_recommended: driftStatus !== "applied"
};
}

37
dist/shared/services/dynamicQrExpiry.js vendored Normal file
View File

@ -0,0 +1,37 @@
import { listDueDynamicQrTransactions, toTransactionPayload, updateTransactionStatus } from "../store/transactionStore";
export async function expireDueDynamicQrTransactions(input) {
const due = await listDueDynamicQrTransactions(input?.limit || 100);
const expired = [];
const skipped = [];
const sweptAt = new Date().toISOString();
for (const tx of due) {
try {
const updated = await updateTransactionStatus(tx.id, "expired", {
source: input?.source || "system",
expired_at: tx.expired_at || sweptAt,
eventContext: {
reason: "dynamic_qr_expired",
expired_at: tx.expired_at,
swept_at: sweptAt,
request_id: input?.request_id
}
});
expired.push(toTransactionPayload(updated));
}
catch (error) {
skipped.push({
transaction_id: tx.id,
partner_reference: tx.partner_reference,
reason: error instanceof Error ? error.message : "UNKNOWN_ERROR"
});
}
}
return {
scanned: due.length,
expired_count: expired.length,
skipped_count: skipped.length,
swept_at: sweptAt,
expired,
skipped
};
}

View File

@ -81,6 +81,13 @@ export async function listDeviceConfigAcks(deviceId, limit = 50) {
LIMIT $2`, [deviceId, Math.min(Math.max(limit, 1), 200)]);
return rows.map(mapAck);
}
export async function getLatestDeviceConfigAck(deviceId) {
const { rows } = await getPool().query(`SELECT * FROM device_config_acks
WHERE device_id = $1
ORDER BY acked_at DESC
LIMIT 1`, [deviceId]);
return rows[0] ? mapAck(rows[0]) : null;
}
export function toDeviceConfigPayload(config) {
return { ...config };
}

View File

@ -100,3 +100,63 @@ export function deriveDeviceStatus(input) {
}
return "online";
}
export function deriveDeviceHealthSummary(input) {
const now = Date.now();
const lastSeen = Date.parse(input?.last_seen_at || "");
const reasons = [];
if (!Number.isFinite(lastSeen)) {
return {
status: "offline",
score: 0,
age_seconds: null,
reasons: ["no_heartbeat"]
};
}
const ageSeconds = Math.max(0, Math.floor((now - lastSeen) / 1000));
const networkStrength = typeof input?.network_strength === "number" ? input.network_strength : null;
const batteryLevel = typeof input?.battery_level === "number" ? input.battery_level : null;
if (ageSeconds > 900) {
reasons.push("offline_threshold_exceeded");
}
else if (ageSeconds > 90) {
reasons.push("stale_threshold_exceeded");
}
if (typeof networkStrength === "number" && networkStrength < 40) {
reasons.push("low_signal");
}
if (typeof batteryLevel === "number" && batteryLevel < 20) {
reasons.push("low_battery");
}
let status = "online";
if (reasons.includes("offline_threshold_exceeded")) {
status = "offline";
}
else if (reasons.includes("stale_threshold_exceeded")) {
status = "stale";
}
else if (reasons.includes("low_signal") || reasons.includes("low_battery")) {
status = "degraded";
}
let score = 100;
if (ageSeconds > 900) {
score -= 80;
}
else if (ageSeconds > 90) {
score -= 35;
}
else if (ageSeconds > 60) {
score -= 10;
}
if (typeof networkStrength === "number") {
score -= Math.max(0, 40 - networkStrength);
}
if (typeof batteryLevel === "number") {
score -= Math.max(0, 20 - batteryLevel);
}
return {
status,
score: Math.max(0, Math.min(100, Math.round(score))),
age_seconds: ageSeconds,
reasons
};
}

View File

@ -192,6 +192,17 @@ export async function listTransactions(filter) {
const { rows } = await getPool().query("SELECT * FROM transactions ORDER BY created_at DESC");
return rows.map(mapTransaction);
}
export async function listDueDynamicQrTransactions(limit = 100) {
const safeLimit = Math.min(Math.max(limit, 1), 500);
const { rows } = await getPool().query(`SELECT * FROM transactions
WHERE qr_mode = 'dynamic'
AND status = 'awaiting_payment'
AND expired_at IS NOT NULL
AND expired_at <= NOW()
ORDER BY expired_at ASC
LIMIT ${safeLimit}`);
return rows.map(mapTransaction);
}
export async function getTransactionEvents(transactionId) {
const { rows } = await getPool().query("SELECT * FROM transaction_events WHERE transaction_id = $1 ORDER BY created_at ASC", [transactionId]);
return rows.map(mapEvent);