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

View File

@ -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

View File

@ -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

View File

@ -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.

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);

View File

@ -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}`);
})();

View File

@ -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<ReturnType<typeof getOrCreateDeviceConfig>>) {
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,

View File

@ -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)));
});

View File

@ -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;

View File

@ -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"
};
}

View File

@ -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
};
}

View File

@ -131,6 +131,18 @@ export async function listDeviceConfigAcks(deviceId: string, limit = 50): Promis
return rows.map(mapAck);
}
export async function getLatestDeviceConfigAck(deviceId: string): Promise<DeviceConfigAckEntity | null> {
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 };
}

View File

@ -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
};
}

View File

@ -320,6 +320,21 @@ export async function listTransactions(filter?: {
return rows.map(mapTransaction);
}
export async function listDueDynamicQrTransactions(limit = 100): Promise<TransactionEntity[]> {
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<TransactionEventEntity[]> {
const { rows } = await getPool().query(
"SELECT * FROM transaction_events WHERE transaction_id = $1 ORDER BY created_at ASC",

View File

@ -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 `
<tr class="hover:bg-slate-50 transition-colors group">
@ -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 @@
<span class="text-on-surface-variant">Health</span>
<span class="${health.className}">${health.value}</span>
</div>
<div class="mt-3 pt-3 border-t border-slate-200">
<div class="flex justify-between mb-2">
<span class="text-on-surface-variant">Heartbeat Age</span>
<span class="font-bold">${typeof summary.age_seconds === "number" ? `${summary.age_seconds}s` : "-"}</span>
</div>
<div class="flex justify-between gap-4">
<span class="text-on-surface-variant">Reason</span>
<span class="text-right font-bold">${reasons}</span>
</div>
</div>
</div>
</section>
<section id="device-config-section">
<h5 class="text-label-md font-label-md text-slate-500 uppercase mb-3">Config Delivery</h5>
<div class="bg-slate-50 p-4 rounded-xl border border-slate-100" id="device-config-status-box">
<p class="text-slate-500">Loading config status...</p>
</div>
</section>
<section>
@ -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 = `
<div class="flex items-center justify-between gap-3 mb-3">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold border ${meta.className}">
<span class="material-symbols-outlined text-[16px]">${meta.icon}</span>
${meta.label}
</span>
<span class="font-mono text-slate-500">v${configStatus.desired_config_version || "-"}</span>
</div>
<div class="grid grid-cols-2 gap-3 text-sm mb-4">
<div>
<p class="text-slate-500">Latest Push</p>
<p class="font-bold">${latestPush ? formatLastSeen(latestPush.created_at) : "-"}</p>
</div>
<div>
<p class="text-slate-500">Latest ACK</p>
<p class="font-bold">${latestAck ? latestAck.status : "-"}</p>
</div>
</div>
<button id="device-config-retry" class="w-full px-3 py-2 rounded-lg text-sm font-bold border ${canRetry ? "bg-primary text-white border-primary" : "bg-white text-slate-500 border-slate-200"}">
${canRetry ? "Retry Config Push" : "Config Applied"}
</button>
`;
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 = '<p class="text-danger">Unable to load config status.</p>';
}
};
const closeDrawer = () => {

View File

@ -287,6 +287,38 @@
</p>
</div>
</div>
<!-- Fase 2 Ops Summary -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-gutter">
<div class="bg-surface-container-lowest border border-slate-200 p-card-padding rounded-xl shadow-sm">
<div class="flex items-start justify-between gap-4 mb-4">
<div>
<p class="text-label-md text-on-surface-variant mb-1">Health Summary</p>
<h3 class="font-headline-lg text-headline-lg text-on-surface" id="device-health-score">-</h3>
</div>
<span id="device-health-status" class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold border bg-slate-100 text-slate-600 border-slate-200">
<span class="material-symbols-outlined text-[16px]">monitor_heart</span>
Unknown
</span>
</div>
<p class="text-body-md text-on-surface-variant" id="device-health-reasons">No health summary yet.</p>
</div>
<div class="bg-surface-container-lowest border border-slate-200 p-card-padding rounded-xl shadow-sm">
<div class="flex items-start justify-between gap-4 mb-4">
<div>
<p class="text-label-md text-on-surface-variant mb-1">Config Delivery</p>
<h3 class="font-headline-md text-headline-md text-on-surface" id="device-config-version">Config -</h3>
</div>
<span id="device-config-status" class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold border bg-slate-100 text-slate-600 border-slate-200">
<span class="material-symbols-outlined text-[16px]">pending</span>
Loading
</span>
</div>
<div class="flex items-center justify-between gap-3">
<p class="text-body-md text-on-surface-variant" id="device-config-detail">Waiting for config status.</p>
<button id="device-config-retry" class="px-3 py-2 bg-primary text-white rounded-lg text-label-md font-bold hover:opacity-90 disabled:opacity-50">Retry Push</button>
</div>
</div>
</div>
<!-- Merchant Binding Info -->
<div class="bg-surface-container-lowest border border-slate-200 rounded-xl overflow-hidden">
<div class="px-card-padding py-4 border-b border-slate-100 flex justify-between items-center bg-surface-container-low">
@ -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 = `<span class="material-symbols-outlined text-[16px]">monitor_heart</span>${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 = `<span class="material-symbols-outlined text-[16px]">${icon}</span>${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 = '<p class="text-slate-500">--- CONSOLE CLEARED ---</p>';

View File

@ -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,