Continue phase 2 device ops and dynamic QR lifecycle
This commit is contained in:
158
dist/routes/admin.js
vendored
158
dist/routes/admin.js
vendored
@ -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
15
dist/routes/device.js
vendored
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user