diff --git a/CODEX_HANDOFF.md b/CODEX_HANDOFF.md index f21e2c1..bc4743e 100644 --- a/CODEX_HANDOFF.md +++ b/CODEX_HANDOFF.md @@ -9,7 +9,11 @@ - endpoint admin `GET /admin/audit-logs` dan `GET /admin/ledger-entries` - awal Fase 2: capability resolver + `POST /device/transactions/dynamic-qr` API-direct - lanjutan Fase 2: MQTT dynamic QR simulator/outbox + device config push/ack - - smoke e2e mencakup duplicate callback, invalid signature, ledger, audit, terminal tanpa binding, dynamic QR API-direct, dynamic QR MQTT, dan device config + - lanjutan Fase 2 berikutnya: config drift status + retry push config + MQTT trace untuk config ACK + - health summary device Fase 2 untuk admin list/detail: status, score, age_seconds, reasons + - UI ops Fase 2 di device registry/detail: health score/reasons, config drift, retry config push + - dynamic QR expiry sweep via `POST /admin/transactions/expire-due` + - smoke e2e mencakup duplicate callback, invalid signature, ledger, audit, terminal tanpa binding, dynamic QR API-direct, dynamic QR expiry sweep, dynamic QR MQTT, dan device config push/status/retry/ack - fix UI lokal: - CSP Helmet dilonggarkan untuk Tailwind CDN, Google Fonts/Material Symbols, dan image Googleusercontent agar desain render normal - panel kanan login admin dibuat dark glass supaya teks putih terbaca @@ -42,10 +46,18 @@ - Resolver capability untuk flow dynamic QR API/MQTT. - [Backend: dynamicQrOrchestrator](/home/wira/work/codex/qris-soundbox-platform/src/shared/services/dynamicQrOrchestrator.ts) - Membuat transaksi dynamic `awaiting_payment` dan mock QR payload. +- [Backend: dynamicQrExpiry](/home/wira/work/codex/qris-soundbox-platform/src/shared/services/dynamicQrExpiry.ts) + - Sweep transaksi dynamic QR `awaiting_payment` yang sudah melewati `expired_at`. - [Backend: mqttMessageStore](/home/wira/work/codex/qris-soundbox-platform/src/shared/store/mqttMessageStore.ts) - Outbox/trace MQTT uplink dan downlink. - [Backend: deviceConfigStore](/home/wira/work/codex/qris-soundbox-platform/src/shared/store/deviceConfigStore.ts) - Config versioned dan ACK device. +- [Backend: deviceConfigStatus](/home/wira/work/codex/qris-soundbox-platform/src/shared/services/deviceConfigStatus.ts) + - Derivasi status drift config: `applied`, `pending_ack`, `failed_ack`, `stale_ack`, `never_pushed`. +- [UI: device-registry-monitoring](/home/wira/work/codex/qris-soundbox-platform/ui/device-registry-monitoring/index.html) + - Drawer ops menampilkan health score/reasons, config drift, latest push/ACK, dan retry config push. +- [UI: device-technical-detail](/home/wira/work/codex/qris-soundbox-platform/ui/device-technical-detail/index.html) + - Detail device menampilkan health summary dan config delivery panel dengan retry push. - [App CSP](/home/wira/work/codex/qris-soundbox-platform/src/app.ts) - Helmet CSP disesuaikan agar asset desain eksternal dapat dimuat di lokal. - [UI: admin-login](/home/wira/work/codex/qris-soundbox-platform/ui/admin-login/index.html) @@ -62,7 +74,9 @@ 6. Ledger Fase 1 masih placeholder `gross_income`; jangan perluas fee/payable sebelum Fase 3 kecuali diminta eksplisit. 7. Dynamic QR Fase 2 saat ini memakai mock QRIS payload lokal; integrasi partner sungguhan belum dipasang. 8. MQTT Fase 2 saat ini memakai simulator HTTP + `mqtt_messages` outbox; broker sungguhan belum dipasang. -9. Untuk cek UI lokal, gunakan `http://127.0.0.1:3100/ui/admin-login`; credential dev adalah username `admin`, password `admin`. +9. Config retry Fase 2 mengirim ulang config version yang sama; jangan naikkan versi kecuali settings berubah. +10. Dynamic QR expiry sweep saat ini endpoint admin/manual; bisa dinaikkan menjadi scheduler/background worker. +11. Untuk cek UI lokal, gunakan `http://127.0.0.1:3100/ui/admin-login`; credential dev adalah username `admin`, password `admin`. ## Urutan kerja selanjutnya (disarankan) 1. UI/manual sanity lanjut dari titik terakhir: @@ -74,9 +88,10 @@ 2. Jalankan lagi `npm run smoke:e2e` sebelum lanjut Fase 2 atau sebelum commit besar. 3. Jika ada regresi, cek log server di `/tmp/qris-smoke-e2e-server.log`. 4. Lanjut Fase 2 berikutnya: - - health score/filter heartbeat yang lebih akurat - adapter broker MQTT sungguhan dari `mqtt_messages` outbox - - config drift/retry policy untuk device yang belum ACK + - scheduler otomatis untuk dynamic QR expiry sweep + - filter/sorting UI berbasis `health_summary.score` dan `health_summary.reasons` + - manual visual QA device registry/detail untuk layout mobile dan drawer 5. Sebelum wiring UI baru, pastikan halaman tetap mengikuti desain `design/*/code.html` dan cek kontras teks pada panel transparan/overlay. ## Note kalau meneruskan sesi berikutnya diff --git a/DECISIONS_LOG.md b/DECISIONS_LOG.md index e285273..a47e755 100644 --- a/DECISIONS_LOG.md +++ b/DECISIONS_LOG.md @@ -233,3 +233,58 @@ Log keputusan arsitektur dan implementasi yang harus dijadikan acuan eksekusi. - Dynamic MQTT request memakai `request_id` sebagai `correlation_id` dan idempotency key. - Device config selalu versioned; ACK dicatat terpisah di `device_config_acks`. - Status: Active + +## D-022 — Config Drift dan Retry Push Fase 2 +- Tanggal: 2026-05-26 +- Keputusan: + - Fase 2 menambahkan status drift config device melalui `GET /admin/devices/{deviceId}/config/status`. + - Retry config dilakukan via `POST /admin/devices/{deviceId}/config/retry-push` tanpa menaikkan `config_version`. + - ACK config dari device juga dicatat sebagai uplink trace di `mqtt_messages` dengan `message_type=config_ack`. +- Alasan: + - Operasi perlu membedakan config `applied`, `pending_ack`, `failed_ack`, `stale_ack`, dan `never_pushed` sebelum broker MQTT sungguhan dipasang. + - Retry harus mengirim ulang desired config yang sama agar idempotent dan tidak membuat drift versi buatan. +- Dampak / implikasi: + - Config yang sudah `applied` tidak boleh di-retry kecuali admin mengirim `force=true`. + - `mqtt_messages` menjadi timeline awal untuk config push dan ACK device. + - Saat broker MQTT asli masuk, endpoint retry tetap menjadi trigger adapter/outbox, bukan tempat menyimpan logic konfigurasi baru. +- Status: Active + +## D-023 — Health Summary Device Fase 2 +- Tanggal: 2026-05-26 +- Keputusan: + - Endpoint admin device menambahkan `health_summary` berisi `status`, `score`, `age_seconds`, dan `reasons`. + - `derived_status` tetap dipertahankan untuk kompatibilitas UI, namun nilainya berasal dari rule health summary yang sama. +- Alasan: + - Operasi membutuhkan konteks kenapa device online/degraded/stale/offline, bukan hanya label status. + - Skor 0-100 memudahkan sorting/filter dashboard tanpa mengubah threshold status Fase 1. +- Dampak / implikasi: + - Status masih mengikuti threshold Fase 1: online <90 detik, stale >90 detik, offline >15 menit, degraded untuk sinyal/baterai buruk. + - UI dapat memakai `health_summary.reasons` untuk badge/tooltip ops. +- Status: Active + +## D-024 — UI Ops Device Fase 2 +- Tanggal: 2026-05-26 +- Keputusan: + - UI device registry dan device technical detail menampilkan `health_summary` dan config delivery status. + - Retry config push tersedia dari drawer device registry dan halaman detail device. +- Alasan: + - Operator perlu melihat status Fase 2 tanpa membuka raw payload/API response. + - Retry config adalah tindakan operasional langsung, sehingga harus dekat dengan status drift config. +- Dampak / implikasi: + - UI memakai endpoint `GET /admin/devices/{deviceId}/config/status` dan `POST /admin/devices/{deviceId}/config/retry-push`. + - `derived_status` tetap dipakai sebagai fallback/compatibility, sementara health score/reasons menjadi konteks tambahan. +- Status: Active + +## D-025 — Dynamic QR Expiry Sweep Fase 2 +- Tanggal: 2026-05-26 +- Keputusan: + - Dynamic QR `awaiting_payment` yang melewati `expired_at` dapat ditutup oleh sweep internal `POST /admin/transactions/expire-due`. + - Sweep hanya berlaku untuk transaksi `qr_mode=dynamic` dan status `awaiting_payment`. +- Alasan: + - Fase 2 tidak boleh bergantung penuh pada callback partner untuk menutup QR yang kadaluarsa. + - Expiry internal menjaga daftar transaksi pending tetap akurat untuk ops dan device flow. +- Dampak / implikasi: + - Sweep menulis `STATE_CHANGED` event dengan reason `dynamic_qr_expired`. + - Callback paid yang datang setelah transaksi sudah `expired` tetap ditolak oleh state transition guard. + - Endpoint admin ini bisa menjadi dasar scheduler/background worker saat fase operasional berikutnya. +- Status: Active diff --git a/README.md b/README.md index d246530..0515b0d 100644 --- a/README.md +++ b/README.md @@ -59,12 +59,15 @@ Dokumen ini dibuat supaya tim bisa langsung mulai: - `GET /admin/devices/{id}/notifications` - `GET /admin/devices/{id}/config` - `PATCH /admin/devices/{id}/config` + - `GET /admin/devices/{id}/config/status` + - `POST /admin/devices/{id}/config/retry-push` - `GET /admin/devices/{id}/mqtt-messages` - `GET /admin/audit-logs` - `GET /admin/ledger-entries` - `GET /admin/transactions` - `GET /admin/transactions/{transactionId}` - `POST /admin/transactions` + - `POST /admin/transactions/expire-due` - `GET /admin/transactions/{transactionId}/events` - `POST /admin/transactions/{transactionId}/retry-notification` - `POST /admin/seed` @@ -94,7 +97,7 @@ Cleanup hanya menarget entitas smoke (`Smoke Merchant`, `PR-`, `DEV-`) agar data PORT=3100 ADMIN_TOKEN=admin-dev-token DEVICE_TOKEN=device-dev-token INTEGRATION_WEBHOOK_SECRET=dev-callback-secret PGHOST=127.0.0.1 PGPORT=5432 PGUSER=postgres PGPASSWORD=postgres PGDATABASE=qris_soundbox_platform npm run smoke:flow ``` -Smoke flow akan melakukan create merchant/device/transaction + heartbeat + callback paid + verifikasi event/heartbeat/notification, duplicate callback, invalid signature, audit log, ledger placeholder, skenario terminal tanpa binding, dynamic QR API-direct, dynamic QR MQTT, dan device config push/ack. +Smoke flow akan melakukan create merchant/device/transaction + heartbeat + callback paid + verifikasi event/heartbeat/notification, duplicate callback, invalid signature, audit log, ledger placeholder, skenario terminal tanpa binding, dynamic QR API-direct, expiry sweep dynamic QR, dynamic QR MQTT, device config push/retry/status/ack, dan trace MQTT config ack. ### Smoke test end-to-end (bootstrap + flow + cleanup) @@ -118,4 +121,6 @@ Perintah ini menjalankan: - `GET /ui` => katalog halaman UI dari seluruh `design/*`. - `GET /ui/:page` => buka halaman berdasarkan slug (contoh: `/ui/admin-login`, `/ui/admin-dashboard-overview`, `/ui/merchant-login`). -Status lanjutan: Fase 1 core flow sudah tercakup smoke e2e. Fase 2 sudah aktif untuk capability resolver, dynamic QR API-direct, dynamic QR MQTT via outbox, dan device config push/ack. +Status lanjutan: Fase 1 core flow sudah tercakup smoke e2e. Fase 2 sudah aktif untuk capability resolver, dynamic QR API-direct, dynamic QR MQTT via outbox, dan device config push/status/retry/ack. + +Catatan Fase 2 ops: endpoint daftar/detail device admin juga mengirim `health_summary` (`status`, `score`, `age_seconds`, `reasons`) untuk membantu triage device. UI device registry dan device technical detail sudah menampilkan health summary, config drift, dan retry config push. diff --git a/dist/routes/admin.js b/dist/routes/admin.js index e4125c2..184dbd7 100644 --- a/dist/routes/admin.js +++ b/dist/routes/admin.js @@ -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) { diff --git a/dist/routes/device.js b/dist/routes/device.js index 6ae7b12..dd97178 100644 --- a/dist/routes/device.js +++ b/dist/routes/device.js @@ -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; diff --git a/dist/shared/services/deviceConfigStatus.js b/dist/shared/services/deviceConfigStatus.js new file mode 100644 index 0000000..416c8ed --- /dev/null +++ b/dist/shared/services/deviceConfigStatus.js @@ -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" + }; +} diff --git a/dist/shared/services/dynamicQrExpiry.js b/dist/shared/services/dynamicQrExpiry.js new file mode 100644 index 0000000..ca5c2df --- /dev/null +++ b/dist/shared/services/dynamicQrExpiry.js @@ -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 + }; +} diff --git a/dist/shared/store/deviceConfigStore.js b/dist/shared/store/deviceConfigStore.js index 161363b..ce092cd 100644 --- a/dist/shared/store/deviceConfigStore.js +++ b/dist/shared/store/deviceConfigStore.js @@ -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 }; } diff --git a/dist/shared/store/heartbeatStore.js b/dist/shared/store/heartbeatStore.js index 17a116e..0cda17a 100644 --- a/dist/shared/store/heartbeatStore.js +++ b/dist/shared/store/heartbeatStore.js @@ -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 + }; +} diff --git a/dist/shared/store/transactionStore.js b/dist/shared/store/transactionStore.js index 42eeaa7..f917d87 100644 --- a/dist/shared/store/transactionStore.js +++ b/dist/shared/store/transactionStore.js @@ -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); diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 9e4cc67..ca3d521 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -197,6 +197,7 @@ async function reqDevice(path, opts = {}) { await reqAdmin(`/admin/audit-logs?entity_id=${txId}`, { _label: 'GET /admin/audit-logs' }); await reqAdmin(`/admin/transactions/${txId}/heartbeats`, { _label: 'GET /admin/transactions/:id/heartbeats' }); await reqAdmin(`/admin/devices/${deviceId}/heartbeats`, { _label: 'GET /admin/devices/:id/heartbeats' }); + await reqAdmin(`/admin/devices/${deviceId}`, { _label: 'GET /admin/devices/:id health summary' }); await reqAdmin('/admin/notifications/failed', { _label: 'GET /admin/notifications/failed' }); await reqAdmin(`/admin/transactions/${txId}/retry-notification`, { method: 'POST', @@ -349,6 +350,31 @@ async function reqDevice(path, opts = {}) { await reqAdmin(`/admin/transactions/${dynamicQr?.data?.transaction_id}`, { _label: 'GET /admin/transactions/:id dynamic-api' }); + const dueDynamicTx = await reqAdmin('/admin/transactions', { + method: 'POST', + body: { + partner_reference: `DUE-DYN-${ts}`, + merchant_id: merchantId, + outlet_id: dynamicOutletId, + terminal_id: dynamicTerminalId, + device_id: dynamicDeviceId, + amount: 12000, + currency: 'IDR', + qr_mode: 'dynamic', + initiation_mode: 'dynamic_api', + status: 'awaiting_payment', + expired_at: new Date(Date.now() - 60_000).toISOString() + }, + _label: 'POST /admin/transactions due dynamic' + }); + await reqAdmin('/admin/transactions/expire-due', { + method: 'POST', + body: { limit: 10 }, + _label: 'POST /admin/transactions/expire-due' + }); + await reqAdmin(`/admin/transactions/${dueDynamicTx?.data?.id}`, { + _label: 'GET /admin/transactions/:id expired dynamic' + }); const mqttOutlet = await reqAdmin(`/admin/merchants/${merchantId}/outlets`, { method: 'POST', @@ -452,6 +478,14 @@ async function reqDevice(path, opts = {}) { _label: 'PATCH /admin/devices/:id/config' }); const configVersion = pushedConfig?.data?.config?.config_version; + await reqAdmin(`/admin/devices/${dynamicDeviceId}/config/status`, { + _label: 'GET /admin/devices/:id/config/status pending' + }); + await reqAdmin(`/admin/devices/${dynamicDeviceId}/config/retry-push`, { + method: 'POST', + body: {}, + _label: 'POST /admin/devices/:id/config/retry-push' + }); await reqDevice(`/device/config?device_id=${dynamicDeviceId}`, { _label: 'GET /device/config' }); @@ -468,9 +502,21 @@ async function reqDevice(path, opts = {}) { await reqAdmin(`/admin/devices/${dynamicDeviceId}/config`, { _label: 'GET /admin/devices/:id/config' }); + await reqAdmin(`/admin/devices/${dynamicDeviceId}/config/status`, { + _label: 'GET /admin/devices/:id/config/status applied' + }); + await reqExpect(`/admin/devices/${dynamicDeviceId}/config/retry-push`, 409, { + method: 'POST', + headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }, + body: {}, + _label: 'POST /admin/devices/:id/config/retry-push already applied' + }); await reqAdmin(`/admin/devices/${dynamicDeviceId}/mqtt-messages?message_type=config_push`, { _label: 'GET /admin/devices/:id/mqtt-messages config' }); + await reqAdmin(`/admin/devices/${dynamicDeviceId}/mqtt-messages?message_type=config_ack`, { + _label: 'GET /admin/devices/:id/mqtt-messages config ack' + }); console.log(`Smoke point 4 flow done. tx=${txId} device=${deviceId}`); })(); diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 23ee8ec..a2ff8a3 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -28,7 +28,7 @@ import { toDevicePayload } from "../shared/store/deviceStore"; import { - deriveDeviceStatus, + deriveDeviceHealthSummary, getHeartbeatCountForDeviceLastHours, getLatestHeartbeatByDeviceId, listHeartbeats, @@ -68,6 +68,8 @@ import { } 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(); @@ -121,6 +123,10 @@ type DeviceConfigInput = { config_version?: number; }; +type DeviceConfigRetryInput = { + force?: boolean; +}; + type BindingInput = { merchant_id?: string; outlet_id?: string; @@ -147,6 +153,7 @@ type TransactionCreateInput = { qr_mode?: "static" | "dynamic"; initiation_mode?: "manual" | "dynamic_api" | "dynamic_mqtt" | "static"; status?: "initiated" | "awaiting_payment"; + expired_at?: string; }; function parseIdempotentReplay(req: Request) { @@ -277,14 +284,16 @@ function buildBindingSummary( async function buildDeviceAdminPayload(device: DeviceEntity) { 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 @@ -307,13 +316,15 @@ async function deriveDeviceStatusesForDashboard() { 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 }; }) ); @@ -369,6 +380,25 @@ async function auditAdminAction( }); } +async function publishDeviceConfigPush(deviceId: string, config: Awaited>) { + const mqttPayload = { + message_type: "config_push" as const, + 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: MerchantCreateInput) { const mode = normalizeMerchantMode(payload.payout_mode); if (mode === "merchant_direct") { @@ -1038,15 +1068,16 @@ router.get("/devices", requireAdminToken, async (req: Request, res: Response) => 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 }; }) ); @@ -1098,9 +1129,14 @@ router.get("/devices/:deviceId", requireAdminToken, async (req: Request, res: Re return next(new ApiError("NOT_FOUND", "device not found", 404)); } - const activeBinding = await getActiveBindingByDevice(device.id); + 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) @@ -1110,11 +1146,8 @@ router.get("/devices/:deviceId", requireAdminToken, async (req: Request, res: Re 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 ? { @@ -1360,7 +1393,18 @@ router.get("/devices/:deviceId/config", requireAdminToken, async (req: Request, 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: Request, res: Response, next: NextFunction) => { + 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: Request, res: Response, next: NextFunction) => { @@ -1379,22 +1423,7 @@ router.patch("/devices/:deviceId/config", requireAdminToken, async (req: Request settings_json: payload.settings, config_version: payload.config_version }); - const mqttPayload = { - message_type: "config_push" as const, - 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", @@ -1414,6 +1443,43 @@ router.patch("/devices/:deviceId/config", requireAdminToken, async (req: Request ); }); +router.post("/devices/:deviceId/config/retry-push", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + const device = await getDeviceById(req.params.deviceId); + if (!device) { + return next(new ApiError("NOT_FOUND", "device not found", 404)); + } + + const payload = (req.body || {}) as DeviceConfigRetryInput; + 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: Request, res: Response, next: NextFunction) => { const device = await getDeviceById(req.params.deviceId); if (!device) { @@ -1501,6 +1567,10 @@ router.post( 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, @@ -1511,7 +1581,8 @@ router.post( 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, { @@ -1574,6 +1645,35 @@ router.get("/transactions", requireAdminToken, async (req: Request, res: Respons ); }); +router.post("/transactions/expire-due", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + const limitRaw = (req.body as { limit?: unknown } | undefined)?.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, diff --git a/src/routes/device.ts b/src/routes/device.ts index c95afa1..921f603 100644 --- a/src/routes/device.ts +++ b/src/routes/device.ts @@ -455,6 +455,21 @@ router.post("/config/ack", requireDeviceToken, async (req: Request, res: Respons 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))); }); diff --git a/src/shared/errors/index.ts b/src/shared/errors/index.ts index 3d26172..ff092b9 100644 --- a/src/shared/errors/index.ts +++ b/src/shared/errors/index.ts @@ -20,7 +20,8 @@ export type ErrorCode = | "NOTIFICATION_RETRY_EXHAUSTED" | "INVALID_AMOUNT" | "DEVICE_NOT_BOUND" - | "DEVICE_CAPABILITY_NOT_SUPPORTED"; + | "DEVICE_CAPABILITY_NOT_SUPPORTED" + | "CONFIG_ALREADY_APPLIED"; export interface ApiErrorShape { code: ErrorCode; diff --git a/src/shared/services/deviceConfigStatus.ts b/src/shared/services/deviceConfigStatus.ts new file mode 100644 index 0000000..cfbd337 --- /dev/null +++ b/src/shared/services/deviceConfigStatus.ts @@ -0,0 +1,55 @@ +import { + DeviceConfigAckEntity, + DeviceConfigEntity, + getLatestDeviceConfigAck, + toDeviceConfigAckPayload, + toDeviceConfigPayload +} from "../store/deviceConfigStore"; +import { listMqttMessages, toMqttMessagePayload } from "../store/mqttMessageStore"; + +type ConfigDriftStatus = "applied" | "pending_ack" | "failed_ack" | "stale_ack" | "never_pushed"; + +function deriveConfigDriftStatus(config: DeviceConfigEntity, latestAck: DeviceConfigAckEntity | null): ConfigDriftStatus { + 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: DeviceConfigEntity) { + 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" + }; +} diff --git a/src/shared/services/dynamicQrExpiry.ts b/src/shared/services/dynamicQrExpiry.ts new file mode 100644 index 0000000..180c062 --- /dev/null +++ b/src/shared/services/dynamicQrExpiry.ts @@ -0,0 +1,47 @@ +import { + listDueDynamicQrTransactions, + toTransactionPayload, + updateTransactionStatus +} from "../store/transactionStore"; + +export async function expireDueDynamicQrTransactions(input?: { + limit?: number; + source?: "system" | "admin"; + request_id?: string; +}) { + 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 + }; +} diff --git a/src/shared/store/deviceConfigStore.ts b/src/shared/store/deviceConfigStore.ts index be1f7f8..c231546 100644 --- a/src/shared/store/deviceConfigStore.ts +++ b/src/shared/store/deviceConfigStore.ts @@ -131,6 +131,18 @@ export async function listDeviceConfigAcks(deviceId: string, limit = 50): Promis return rows.map(mapAck); } +export async function getLatestDeviceConfigAck(deviceId: string): Promise { + 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: DeviceConfigEntity) { return { ...config }; } diff --git a/src/shared/store/heartbeatStore.ts b/src/shared/store/heartbeatStore.ts index 6164fe1..43bae45 100644 --- a/src/shared/store/heartbeatStore.ts +++ b/src/shared/store/heartbeatStore.ts @@ -1,5 +1,12 @@ export type DeviceHealthStatus = "online" | "offline" | "degraded" | "stale"; +export type DeviceHealthReason = + | "no_heartbeat" + | "offline_threshold_exceeded" + | "stale_threshold_exceeded" + | "low_signal" + | "low_battery"; + export interface DeviceHeartbeatEntity { id: string; device_id: string; @@ -171,3 +178,75 @@ export function deriveDeviceStatus( return "online"; } + +export function deriveDeviceHealthSummary( + input?: { + last_seen_at?: string | null; + network_strength?: number | null; + battery_level?: number | null; + } +) { + const now = Date.now(); + const lastSeen = Date.parse(input?.last_seen_at || ""); + const reasons: DeviceHealthReason[] = []; + + if (!Number.isFinite(lastSeen)) { + return { + status: "offline" as DeviceHealthStatus, + score: 0, + age_seconds: null, + reasons: ["no_heartbeat"] as DeviceHealthReason[] + }; + } + + 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: DeviceHealthStatus = "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 + }; +} diff --git a/src/shared/store/transactionStore.ts b/src/shared/store/transactionStore.ts index 00141c5..c03a3eb 100644 --- a/src/shared/store/transactionStore.ts +++ b/src/shared/store/transactionStore.ts @@ -320,6 +320,21 @@ export async function listTransactions(filter?: { return rows.map(mapTransaction); } +export async function listDueDynamicQrTransactions(limit = 100): Promise { + 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: string): Promise { const { rows } = await getPool().query( "SELECT * FROM transaction_events WHERE transaction_id = $1 ORDER BY created_at ASC", diff --git a/ui/device-registry-monitoring/index.html b/ui/device-registry-monitoring/index.html index b244629..2169c86 100644 --- a/ui/device-registry-monitoring/index.html +++ b/ui/device-registry-monitoring/index.html @@ -422,7 +422,19 @@ return `${days} day${days === 1 ? "" : "s"} ago`; }; - const healthMeta = (value) => { + const healthMeta = (value, summary) => { + if (summary && typeof summary.score === "number") { + if (summary.score >= 90) { + return { value: `${summary.score}%`, className: "text-success", icon: "favorite" }; + } + if (summary.score >= 65) { + return { value: `${summary.score}%`, className: "text-success", icon: "favorite" }; + } + if (summary.score >= 35) { + return { value: `${summary.score}%`, className: "text-warning", icon: "heart_minus" }; + } + return { value: `${summary.score}%`, className: "text-danger", icon: "heart_broken" }; + } if (!value) { return { value: "N/A", className: "text-slate-500", icon: "heart_broken" }; } @@ -443,6 +455,31 @@ return { value: "Poor", className: "text-danger", icon: "heart_broken" }; }; + const reasonLabels = { + no_heartbeat: "No heartbeat", + offline_threshold_exceeded: "Offline threshold", + stale_threshold_exceeded: "Stale heartbeat", + low_signal: "Low signal", + low_battery: "Low battery" + }; + + const configStatusMeta = (status) => { + const value = normalizeText(status); + if (value === "applied") { + return { label: "Applied", className: "bg-success/10 text-success border-success/20", icon: "check_circle" }; + } + if (value === "pending_ack") { + return { label: "Pending ACK", className: "bg-warning/10 text-warning border-warning/20", icon: "pending" }; + } + if (value === "failed_ack") { + return { label: "Failed ACK", className: "bg-danger/10 text-danger border-danger/20", icon: "error" }; + } + if (value === "stale_ack") { + return { label: "Stale ACK", className: "bg-warning/10 text-warning border-warning/20", icon: "sync_problem" }; + } + return { label: "Never pushed", className: "bg-slate-100 text-slate-600 border-slate-200", icon: "cloud_off" }; + }; + const statusMeta = (status) => { const value = normalizeText(status); if (value === "online") { @@ -459,6 +496,13 @@ dot: "bg-warning" }; } + if (value === "stale") { + return { + label: "Stale", + className: "bg-warning/10 text-warning border border-warning/20", + dot: "bg-warning" + }; + } return { label: "Offline", className: "bg-slate-100 text-slate-500 border border-slate-200", @@ -501,7 +545,7 @@ const merchantName = merchantMap.get(binding.merchant_id) || "Unassigned"; const status = statusMeta(device.derived_status); const connection = connectionMeta(device.communication_mode); - const health = healthMeta(device.latest_heartbeat); + const health = healthMeta(device.latest_heartbeat, device.health_summary); return ` @@ -649,7 +693,11 @@ const binding = device.binding_summary || {}; const connection = connectionMeta(device.communication_mode); const status = statusMeta(device.derived_status); - const health = healthMeta(device.latest_heartbeat); + const health = healthMeta(device.latest_heartbeat, device.health_summary); + const summary = device.health_summary || {}; + const reasons = Array.isArray(summary.reasons) && summary.reasons.length + ? summary.reasons.map((item) => reasonLabels[item] || item).join(", ") + : "No active warning"; const id = device.device_code || device.id || "-"; detailTitle.textContent = id; @@ -683,6 +731,22 @@ Health ${health.value} +
+
+ Heartbeat Age + ${typeof summary.age_seconds === "number" ? `${summary.age_seconds}s` : "-"} +
+
+ Reason + ${reasons} +
+
+ + +
+
Config Delivery
+
+

Loading config status...

@@ -713,6 +777,59 @@ detailOverlay.classList.remove("pointer-events-none", "opacity-0"); detailOverlay.classList.add("opacity-100"); detailDrawer.classList.remove("translate-x-full"); + + loadDrawerConfig(device.id); + }; + + const loadDrawerConfig = async (deviceId) => { + const box = document.getElementById("device-config-status-box"); + if (!box || !deviceId) { + return; + } + + try { + const configStatus = await api.getDeviceConfigStatus(deviceId); + const meta = configStatusMeta(configStatus.drift_status); + const latestPush = configStatus.latest_push; + const latestAck = configStatus.latest_ack; + const canRetry = configStatus.retry_recommended; + box.innerHTML = ` +
+ + ${meta.icon} + ${meta.label} + + v${configStatus.desired_config_version || "-"} +
+
+
+

Latest Push

+

${latestPush ? formatLastSeen(latestPush.created_at) : "-"}

+
+
+

Latest ACK

+

${latestAck ? latestAck.status : "-"}

+
+
+ + `; + const retryButton = document.getElementById("device-config-retry"); + retryButton?.addEventListener("click", async () => { + retryButton.disabled = true; + retryButton.textContent = "Retrying..."; + try { + await api.retryDeviceConfigPush(deviceId, canRetry ? {} : { force: true }); + await loadDrawerConfig(deviceId); + } catch (error) { + retryButton.disabled = false; + retryButton.textContent = "Retry Failed"; + } + }); + } catch (error) { + box.innerHTML = '

Unable to load config status.

'; + } }; const closeDrawer = () => { diff --git a/ui/device-technical-detail/index.html b/ui/device-technical-detail/index.html index 757fcc5..746e181 100644 --- a/ui/device-technical-detail/index.html +++ b/ui/device-technical-detail/index.html @@ -287,6 +287,38 @@

+ +
+
+
+
+

Health Summary

+

-

+
+ +monitor_heart +Unknown + +
+

No health summary yet.

+
+
+
+
+

Config Delivery

+

Config -

+
+ +pending +Loading + +
+
+

Waiting for config status.

+ +
+
+
@@ -421,7 +453,14 @@ firmwareStatus: document.getElementById("device-firmware-status"), bindingMerchant: document.getElementById("device-binding-merchant"), bindingMerchantId: document.getElementById("device-binding-merchant-id"), - bindingSince: document.getElementById("device-binding-since") + bindingSince: document.getElementById("device-binding-since"), + healthScore: document.getElementById("device-health-score"), + healthStatus: document.getElementById("device-health-status"), + healthReasons: document.getElementById("device-health-reasons"), + configVersion: document.getElementById("device-config-version"), + configStatus: document.getElementById("device-config-status"), + configDetail: document.getElementById("device-config-detail"), + configRetry: document.getElementById("device-config-retry") }; const setText = (el, value, fallback = "-") => { @@ -500,8 +539,8 @@ return {}; } return { - signal: heartbeat.rssi ?? heartbeat.rssi_dbm ?? heartbeat.signal ?? heartbeat.signal_strength ?? heartbeat.rsrp, - battery: heartbeat.battery_voltage ?? heartbeat.v_batt ?? heartbeat.voltage ?? heartbeat.battery ?? heartbeat.batt ?? null, + signal: heartbeat.network_strength ?? heartbeat.rssi ?? heartbeat.rssi_dbm ?? heartbeat.signal ?? heartbeat.signal_strength ?? heartbeat.rsrp, + battery: heartbeat.battery_level ?? heartbeat.battery_voltage ?? heartbeat.v_batt ?? heartbeat.voltage ?? heartbeat.battery ?? heartbeat.batt ?? null, ts: heartbeat.ts ?? heartbeat.timestamp ?? heartbeat.created_at ?? heartbeat.updated_at }; }; @@ -633,6 +672,89 @@ } }; + const healthReasonLabels = { + no_heartbeat: "No heartbeat", + offline_threshold_exceeded: "Offline threshold exceeded", + stale_threshold_exceeded: "Heartbeat is stale", + low_signal: "Low signal", + low_battery: "Low battery" + }; + + const statusPillClass = (status) => { + const normalized = String(status || "").toLowerCase(); + if (normalized === "online" || normalized === "applied") { + return "bg-success/10 text-success border-success/20"; + } + if (normalized === "degraded" || normalized === "stale" || normalized === "pending_ack" || normalized === "stale_ack") { + return "bg-warning/10 text-warning border-warning/20"; + } + if (normalized === "offline" || normalized === "failed_ack") { + return "bg-danger/10 text-danger border-danger/20"; + } + return "bg-slate-100 text-slate-600 border-slate-200"; + }; + + const renderHealthSummary = (summary) => { + if (!summary) { + setText(els.healthScore, "-"); + setText(els.healthReasons, "No health summary yet."); + return; + } + + setText(els.healthScore, typeof summary.score === "number" ? `${summary.score}%` : "-"); + const status = summary.status || "unknown"; + if (els.healthStatus) { + els.healthStatus.className = `inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold border ${statusPillClass(status)}`; + els.healthStatus.innerHTML = `monitor_heart${String(status).replace("_", " ").toUpperCase()}`; + } + const reasons = Array.isArray(summary.reasons) && summary.reasons.length + ? summary.reasons.map((item) => healthReasonLabels[item] || item).join(", ") + : "No active warning"; + setText( + els.healthReasons, + `${reasons}${typeof summary.age_seconds === "number" ? ` · ${summary.age_seconds}s since heartbeat` : ""}` + ); + }; + + const renderConfigStatus = (status) => { + if (!status) { + return; + } + + const drift = status.drift_status || "unknown"; + const version = status.desired_config_version || status.config?.config_version || "-"; + setText(els.configVersion, `Config v${version}`); + if (els.configStatus) { + const label = String(drift).replace("_", " ").toUpperCase(); + const icon = drift === "applied" ? "check_circle" : drift === "failed_ack" ? "error" : "pending"; + els.configStatus.className = `inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold border ${statusPillClass(drift)}`; + els.configStatus.innerHTML = `${icon}${label}`; + } + + const ack = status.latest_ack ? `${status.latest_ack.status} at ${formatDateTime(status.latest_ack.acked_at, "-")}` : "No ACK"; + const push = status.latest_push ? formatDateTime(status.latest_push.created_at, "-") : "No push"; + setText(els.configDetail, `Push: ${push} · ACK: ${ack}`); + if (els.configRetry) { + els.configRetry.disabled = status.retry_recommended === false; + els.configRetry.textContent = status.retry_recommended === false ? "Applied" : "Retry Push"; + } + }; + + const loadConfigStatus = async () => { + if (!deviceId) { + return; + } + try { + const status = await api.getDeviceConfigStatus(deviceId); + renderConfigStatus(status); + } catch (error) { + setText(els.configDetail, "Unable to load config status."); + if (els.configRetry) { + els.configRetry.disabled = true; + } + } + }; + const loadBindingDetails = async (device) => { const binding = device?.binding_summary || {}; let merchantName = binding.merchant_id || "Unbound"; @@ -701,6 +823,7 @@ const latestMetric = extractHeartbeatMetrics(latest); setDerivedStatus(device, heartbeats); + renderHealthSummary(device.health_summary); setText( els.firmwareVersion, device.firmware_version || device.fw_version || device.firmware || "-" @@ -710,6 +833,7 @@ renderStream(heartbeats); renderEvents(heartbeats); await loadBindingDetails(device); + await loadConfigStatus(); } catch (error) { console.error("[device detail] failed loading", error); setText(els.title, "Unable to load device"); @@ -717,6 +841,24 @@ }; refreshBtn?.addEventListener("click", loadDevice); + els.configRetry?.addEventListener("click", async () => { + if (!deviceId || !els.configRetry) { + return; + } + els.configRetry.disabled = true; + els.configRetry.textContent = "Retrying..."; + try { + await api.retryDeviceConfigPush(deviceId, {}); + await loadConfigStatus(); + } catch (error) { + try { + await api.retryDeviceConfigPush(deviceId, { force: true }); + await loadConfigStatus(); + } catch (retryError) { + els.configRetry.textContent = "Retry Failed"; + } + } + }); clearBtn?.addEventListener("click", () => { if (stream) { stream.innerHTML = '

--- CONSOLE CLEARED ---

'; diff --git a/ui/shared/admin-api.js b/ui/shared/admin-api.js index 867da61..baf06c4 100644 --- a/ui/shared/admin-api.js +++ b/ui/shared/admin-api.js @@ -135,6 +135,13 @@ window.AdminUIAPI = { getDevice: (id) => adminFetch(`/admin/devices/${id}`), getDeviceHeartbeats: (id, query) => adminFetch(`/admin/devices/${id}/heartbeats`, { query }), + getDeviceConfig: (id) => adminFetch(`/admin/devices/${id}/config`), + getDeviceConfigStatus: (id) => adminFetch(`/admin/devices/${id}/config/status`), + retryDeviceConfigPush: (id, payload) => + adminFetch(`/admin/devices/${id}/config/retry-push`, { + method: "POST", + body: payload || {} + }), listTransactions: (query) => adminFetch("/admin/transactions", { query }), getDashboardSummary: () => adminFetch("/admin/dashboard/summary"), formatMoney,