Implement phase 1 completion and phase 2 dynamic QR

This commit is contained in:
2026-05-26 08:06:48 +07:00
parent a152c99cce
commit 5624b92872
36 changed files with 3104 additions and 71 deletions

339
dist/routes/admin.js vendored
View File

@ -14,6 +14,12 @@ import { createDeviceCommand, getDeviceCommandById, listDeviceCommands, toDevice
import { createTransaction, getTransactionById, listTransactions, toTransactionEventPayload, toTransactionPayload, getTransactionEvents } from "../shared/store/transactionStore";
import { getNotificationByTransactionId, listNotifications, listNotificationsByDevice, toNotificationPayload } from "../shared/store/notificationStore";
import { retryNotificationByTransactionId } from "../shared/orchestrators/notificationOrchestrator";
import { createAuditLog, listAuditLogs, toAuditLogPayload } from "../shared/store/auditLogStore";
import { listLedgerEntries, toLedgerEntryPayload } from "../shared/store/ledgerStore";
import { resolveDeviceCapabilitySummary } from "../shared/services/deviceCapabilityResolver";
import { getOrCreateDeviceConfig, listDeviceConfigAcks, toDeviceConfigAckPayload, toDeviceConfigPayload, upsertDeviceConfig } from "../shared/store/deviceConfigStore";
import { listMqttMessages, toMqttMessagePayload, createMqttMessage } from "../shared/store/mqttMessageStore";
import { publishConfigPush } from "../shared/services/mqttPublisher";
const router = Router();
function parseIdempotentReplay(req) {
return req.body.__idempotentReplay;
@ -64,6 +70,12 @@ function parseDeviceStatusValue(value) {
}
return undefined;
}
function parsePayoutMode(value) {
if (value === "merchant_direct" || value === "manual") {
return value;
}
return undefined;
}
function parseOutletStatusFilter(value) {
if (value === "active" || value === "inactive") {
return value;
@ -114,6 +126,7 @@ async function buildDeviceAdminPayload(device) {
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
return {
...toDevicePayload(device),
capability_summary: resolveDeviceCapabilitySummary(device),
derived_status: deriveDeviceStatus({
last_seen_at: device.last_seen_at,
network_strength: latestHeartbeat?.network_strength ?? null,
@ -170,6 +183,20 @@ function toStartEndDateFilter(from, to) {
function normalizeMerchantMode(payloadMode) {
return payloadMode || "merchant_direct";
}
async function auditAdminAction(req, payload) {
await createAuditLog({
actor_type: "admin",
actor_id: "admin",
action: payload.action,
entity_type: payload.entity_type,
entity_id: payload.entity_id,
before_json: payload.before_json,
after_json: payload.after_json,
source_ip: req.ip,
request_id: req.requestId,
trace_id: req.traceId
});
}
function validatePayoutConfig(payload) {
const mode = normalizeMerchantMode(payload.payout_mode);
if (mode === "merchant_direct") {
@ -223,6 +250,10 @@ router.post("/merchants", requireAdminToken, idempotency({ scope: "merchant.crea
if (!payload?.legal_name) {
return next(new ApiError("BAD_REQUEST", "legal_name is required", 400));
}
const normalizedPayoutMode = parsePayoutMode(payload.payout_mode);
if (payload.payout_mode && !normalizedPayoutMode) {
return next(new ApiError("BAD_REQUEST", "payout_mode must be merchant_direct or manual", 400));
}
try {
validatePayoutConfig(payload);
}
@ -239,6 +270,12 @@ router.post("/merchants", requireAdminToken, idempotency({ scope: "merchant.crea
status: payload.status,
onboarding_status: payload.onboarding_status
});
await auditAdminAction(req, {
action: "merchant.create",
entity_type: "merchant",
entity_id: created.id,
after_json: toMerchantPayload(created)
});
res.status(201).json(successResponse(req, toMerchantPayload(created)));
});
router.get("/merchants", requireAdminToken, async (_req, res) => {
@ -264,6 +301,9 @@ router.patch("/merchants/:merchantId", requireAdminToken, async (req, res, next)
...payload,
payout_mode: payload.payout_mode ? payload.payout_mode : existing.payout_mode
};
if (normalized.payout_mode && !parsePayoutMode(normalized.payout_mode)) {
return next(new ApiError("BAD_REQUEST", "payout_mode must be merchant_direct or manual", 400));
}
if (normalized.payout_mode === "merchant_direct") {
normalized.settlement_account_reference =
normalized.settlement_account_reference || existing.settlement_account_reference;
@ -277,6 +317,13 @@ router.patch("/merchants/:merchantId", requireAdminToken, async (req, res, next)
return next(err);
}
const updated = await patchMerchant(req.params.merchantId, normalized);
await auditAdminAction(req, {
action: "merchant.update",
entity_type: "merchant",
entity_id: updated.id,
before_json: toMerchantPayload(existing),
after_json: toMerchantPayload(updated)
});
res.json(successResponse(req, toMerchantPayload(updated)));
});
router.post("/merchants/:merchantId/approve", requireAdminToken, async (req, res, next) => {
@ -290,6 +337,13 @@ router.post("/merchants/:merchantId/approve", requireAdminToken, async (req, res
const updated = await patchMerchant(req.params.merchantId, {
onboarding_status: "approved"
});
await auditAdminAction(req, {
action: "merchant.approve",
entity_type: "merchant",
entity_id: updated.id,
before_json: toMerchantPayload(existing),
after_json: toMerchantPayload(updated)
});
res.json(successResponse(req, toMerchantPayload(updated)));
});
router.post("/merchants/:merchantId/reject", requireAdminToken, async (req, res, next) => {
@ -305,6 +359,16 @@ router.post("/merchants/:merchantId/reject", requireAdminToken, async (req, res,
onboarding_status: "rejected",
status: "inactive"
});
await auditAdminAction(req, {
action: "merchant.reject",
entity_type: "merchant",
entity_id: updated.id,
before_json: toMerchantPayload(existing),
after_json: {
...toMerchantPayload(updated),
rejection_reason: payload.reason
}
});
res.json(successResponse(req, {
...toMerchantPayload(updated),
rejection_reason: payload.reason
@ -362,7 +426,11 @@ router.post("/seed", requireAdminToken, idempotency({ scope: "seed.demo", requir
device_code: "DEV_SEED_A",
vendor: "seed-maker",
model: "v1",
communication_mode: "mqtt",
communication_mode: "static",
capability_profile_json: {
dynamic_qr: false,
flows: ["static_payment_notification"]
},
status: "active"
});
const deviceB = await createDevice({
@ -370,14 +438,28 @@ router.post("/seed", requireAdminToken, idempotency({ scope: "seed.demo", requir
vendor: "seed-maker",
model: "v1",
communication_mode: "mqtt",
capability_profile_json: {
dynamic_qr: {
mqtt: true,
api_direct: false
},
flows: ["dynamic_qr:mqtt", "static_payment_notification"]
},
status: "active"
});
const deviceC = await createDevice({
device_code: "DEV_SEED_C",
vendor: "seed-maker",
model: "v1",
communication_mode: "mqtt",
status: "inactive"
communication_mode: "api",
capability_profile_json: {
dynamic_qr: {
api_direct: true,
mqtt: false
},
flows: ["dynamic_qr:api_direct", "static_payment_notification"]
},
status: "active"
});
await bindDevice({
device_id: deviceA.id,
@ -484,12 +566,26 @@ router.post("/merchants/:merchantId/outlets", requireAdminToken, idempotency({ s
outlet_code: payload.outlet_code,
status: payload.status
});
await auditAdminAction(req, {
action: "outlet.create",
entity_type: "outlet",
entity_id: outlet.id,
after_json: toOutletPayload(outlet)
});
res.status(201).json(successResponse(req, outlet));
});
router.get("/outlets", requireAdminToken, async (req, res) => {
const merchantId = req.query.merchant_id;
router.get("/outlets", requireAdminToken, async (req, res, next) => {
const merchantId = req.query.merchant_id?.trim();
const statusRaw = req.query.status?.trim();
const status = parseOutletStatusFilter(statusRaw);
if (statusRaw && !status) {
return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
}
const q = req.query.q?.trim();
res.json(successResponse(req, (await listOutlets({
merchant_id: merchantId
merchant_id: merchantId,
status,
q: q || undefined
})).map(toOutletPayload)));
});
router.get("/outlets/:outletId", requireAdminToken, async (req, res, next) => {
@ -508,7 +604,15 @@ router.patch("/outlets/:outletId", requireAdminToken, async (req, res, next) =>
return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
}
try {
const existing = await getOutletById(req.params.outletId);
const updated = await patchOutlet(req.params.outletId, payload);
await auditAdminAction(req, {
action: "outlet.update",
entity_type: "outlet",
entity_id: updated.id,
before_json: existing ? toOutletPayload(existing) : null,
after_json: toOutletPayload(updated)
});
res.json(successResponse(req, toOutletPayload(updated)));
}
catch (err) {
@ -543,12 +647,26 @@ router.post("/outlets/:outletId/terminals", requireAdminToken, idempotency({ sco
partner_reference: payload.partner_reference,
status: payload.status
});
await auditAdminAction(req, {
action: "terminal.create",
entity_type: "terminal",
entity_id: terminal.id,
after_json: toTerminalPayload(terminal)
});
res.status(201).json(successResponse(req, toTerminalPayload(terminal)));
});
router.get("/terminals", requireAdminToken, async (req, res) => {
const outletId = req.query.outlet_id;
router.get("/terminals", requireAdminToken, async (req, res, next) => {
const outletId = req.query.outlet_id?.trim();
const statusRaw = req.query.status?.trim();
const status = parseTerminalStatusFilter(statusRaw);
if (statusRaw && !status) {
return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
}
const q = req.query.q?.trim();
res.json(successResponse(req, (await listTerminals({
outlet_id: outletId
outlet_id: outletId,
status,
q: q || undefined
})).map(toTerminalPayload)));
});
router.get("/terminals/:terminalId", requireAdminToken, async (req, res, next) => {
@ -570,7 +688,15 @@ router.patch("/terminals/:terminalId", requireAdminToken, async (req, res, next)
return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
}
try {
const existing = await getTerminalById(req.params.terminalId);
const updated = await patchTerminal(req.params.terminalId, payload);
await auditAdminAction(req, {
action: "terminal.update",
entity_type: "terminal",
entity_id: updated.id,
before_json: existing ? toTerminalPayload(existing) : null,
after_json: toTerminalPayload(updated)
});
res.json(successResponse(req, toTerminalPayload(updated)));
}
catch (err) {
@ -595,6 +721,12 @@ router.post("/devices", requireAdminToken, idempotency({ scope: "device.create",
return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
}
const created = await createDevice(payload);
await auditAdminAction(req, {
action: "device.create",
entity_type: "device",
entity_id: created.id,
after_json: toDevicePayload(created)
});
res.status(201).json(successResponse(req, toDevicePayload(created)));
});
router.get("/devices", requireAdminToken, async (req, res) => {
@ -663,6 +795,7 @@ router.get("/devices/:deviceId", requireAdminToken, async (req, res, next) => {
.map(toNotificationPayload);
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,
@ -720,7 +853,15 @@ router.patch("/devices/:deviceId", requireAdminToken, async (req, res, next) =>
return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
}
try {
const existing = await getDeviceById(req.params.deviceId);
const updated = await patchDevice(req.params.deviceId, payload);
await auditAdminAction(req, {
action: "device.update",
entity_type: "device",
entity_id: updated.id,
before_json: existing ? toDevicePayload(existing) : null,
after_json: toDevicePayload(updated)
});
res.json(successResponse(req, toDevicePayload(updated)));
}
catch (err) {
@ -760,6 +901,12 @@ router.post("/devices/:deviceId/bind", requireAdminToken, idempotency({ scope: "
outlet_id: outlet.id,
terminal_id: terminal.id
});
await auditAdminAction(req, {
action: "device.bind",
entity_type: "device_binding",
entity_id: binding.id,
after_json: toBindingPayload(binding)
});
res.json(successResponse(req, toBindingPayload(binding)));
});
router.post("/devices/:deviceId/unbind", requireAdminToken, async (req, res, next) => {
@ -771,6 +918,13 @@ router.post("/devices/:deviceId/unbind", requireAdminToken, async (req, res, nex
if (!binding) {
return next(new ApiError("BAD_REQUEST", "device has no active binding", 400));
}
await auditAdminAction(req, {
action: "device.unbind",
entity_type: "device_binding",
entity_id: binding.id,
before_json: toBindingPayload(binding),
after_json: toBindingPayload(binding)
});
res.json(successResponse(req, toBindingPayload(binding)));
});
router.post("/devices/:deviceId/commands", requireAdminToken, async (req, res, next) => {
@ -833,6 +987,85 @@ router.get("/devices/:deviceId/notifications", requireAdminToken, async (req, re
.map(toNotificationPayload);
res.json(successResponse(req, { device_id: device.id, notifications }));
});
router.get("/devices/:deviceId/config", 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);
const acks = (await listDeviceConfigAcks(device.id, 10)).map(toDeviceConfigAckPayload);
res.json(successResponse(req, { ...toDeviceConfigPayload(config), latest_acks: acks }));
});
router.patch("/devices/:deviceId/config", 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;
if (!payload || !payload.settings || typeof payload.settings !== "object") {
return next(new ApiError("BAD_REQUEST", "settings object is required", 400));
}
const config = await upsertDeviceConfig({
device_id: device.id,
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
});
await auditAdminAction(req, {
action: "device.config_push",
entity_type: "device",
entity_id: device.id,
after_json: {
config,
downlink_message_id: outbox.id
}
});
res.json(successResponse(req, {
config: toDeviceConfigPayload(config),
downlink_message: toMqttMessagePayload(outbox)
}));
});
router.get("/devices/:deviceId/mqtt-messages", 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 directionRaw = req.query.direction?.trim();
const direction = directionRaw === "uplink" || directionRaw === "downlink" ? directionRaw : undefined;
if (directionRaw && !direction) {
return next(new ApiError("BAD_REQUEST", "direction must be uplink or downlink", 400));
}
const messageType = req.query.message_type?.trim();
const correlationId = req.query.correlation_id?.trim();
const limitRaw = req.query.limit;
const limit = limitRaw ? Number(limitRaw) : 100;
if (!Number.isFinite(limit) || limit <= 0) {
return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400));
}
const messages = await listMqttMessages({
device_id: device.id,
direction,
message_type: messageType || undefined,
correlation_id: correlationId || undefined,
limit
});
res.json(successResponse(req, { device_id: device.id, messages: messages.map(toMqttMessagePayload) }));
});
router.post("/transactions", requireAdminToken, idempotency({ scope: "transaction.create", required: false }), async (req, res, next) => {
if (parseIdempotentReplay(req)) {
return res.status(201).json(getReplayResponse(req));
@ -885,15 +1118,25 @@ router.post("/transactions", requireAdminToken, idempotency({ scope: "transactio
initiation_mode: payload.initiation_mode || "static",
status: payload.status || "initiated"
});
await auditAdminAction(req, {
action: "transaction.create",
entity_type: "transaction",
entity_id: created.id,
after_json: toTransactionPayload(created)
});
res.status(201).json(successResponse(req, toTransactionPayload(created)));
});
router.get("/transactions", requireAdminToken, async (req, res, next) => {
const status = req.query.status;
const merchantId = req.query.merchant_id;
const statusRaw = req.query.status?.trim();
const status = parseTransactionStatusFilter(statusRaw);
if (statusRaw && !status) {
return next(new ApiError("BAD_REQUEST", "invalid status", 400));
}
const merchantId = req.query.merchant_id?.trim();
const from = req.query.from;
const to = req.query.to;
const partnerReference = req.query.partner_reference;
const normalizedStatus = typeof status === "string" ? parseTransactionStatusFilter(status) : undefined;
const q = req.query.q?.trim();
if (from && !isIsoDate(from)) {
return next(new ApiError("BAD_REQUEST", "from must be valid ISO datetime", 400));
}
@ -901,12 +1144,21 @@ router.get("/transactions", requireAdminToken, async (req, res, next) => {
return next(new ApiError("BAD_REQUEST", "to must be valid ISO datetime", 400));
}
const normalizedPartnerRef = partnerReference?.trim();
const normalizedQ = q || "";
res.json(successResponse(req, (await listTransactions({
status: normalizedStatus,
status,
merchant_id: merchantId
}))
.filter((tx) => isTxInDateRange(tx, from, to))
.filter((tx) => !normalizedPartnerRef || tx.partner_reference === normalizedPartnerRef)
.filter((tx) => {
if (!normalizedQ) {
return true;
}
const lower = normalizedQ.toLowerCase();
return (tx.partner_reference.toLowerCase().includes(lower) ||
tx.transaction_code.toLowerCase().includes(lower));
})
.map(toTransactionPayload)));
});
router.get("/transactions/:transactionId", requireAdminToken, async (req, res, next) => {
@ -915,6 +1167,7 @@ router.get("/transactions/:transactionId", requireAdminToken, async (req, res, n
return next(new ApiError("NOT_FOUND", "transaction not found", 404));
}
const events = (await getTransactionEvents(tx.id)).map(toTransactionEventPayload);
const ledger_entries = (await listLedgerEntries({ transaction_id: tx.id })).map(toLedgerEntryPayload);
const bindingByTerminal = tx.terminal_id ? await getActiveBindingByTerminal(tx.terminal_id) : null;
const heartbeatDeviceId = tx.device_id || bindingByTerminal?.device_id;
const heartbeatHistory = heartbeatDeviceId
@ -932,6 +1185,7 @@ router.get("/transactions/:transactionId", requireAdminToken, async (req, res, n
res.json(successResponse(req, {
transaction: toTransactionPayload(tx),
events,
ledger_entries,
heartbeat_device_id: heartbeatDeviceId,
heartbeat_history: heartbeatHistory
}));
@ -1037,7 +1291,15 @@ router.post("/transactions/:transactionId/retry-notification", requireAdminToken
return next(error);
}
});
router.get("/dashboard/summary", requireAdminToken, async (req, res, next) => {
router.get("/dashboard/summary", requireAdminToken, async (req, res) => {
let dashboard = {
transactions_today: 0,
success_rate_today: 0,
active_devices: 0,
pending_notifications: 0,
devices_stale: 0,
devices_offline: 0
};
try {
const { start, end } = buildDashboardRange();
const startTs = start.getTime();
@ -1056,18 +1318,19 @@ router.get("/dashboard/summary", requireAdminToken, async (req, res, next) => {
const pendingNotifications = (await listNotifications()).filter((notification) => {
return notification.delivery_status === "queued" || notification.delivery_status === "retrying";
}).length;
res.json(successResponse(req, {
dashboard = {
transactions_today: transactionsToday,
success_rate_today: Number(successRateToday.toFixed(2)),
active_devices: activeDevices,
pending_notifications: pendingNotifications,
devices_stale: devicesStale,
devices_offline: devicesOffline
}));
};
}
catch (error) {
return next(error);
console.error("[dashboard/summary] fallback due calculation error", error instanceof Error ? error.message : error);
}
res.json(successResponse(req, dashboard));
});
router.get("/notifications/failed", requireAdminToken, async (req, res, next) => {
const deviceId = req.query.device_id;
@ -1104,4 +1367,46 @@ router.get("/notifications/failed", requireAdminToken, async (req, res, next) =>
}));
res.json(successResponse(req, filtered));
});
router.get("/audit-logs", requireAdminToken, async (req, res, next) => {
const entityType = req.query.entity_type?.trim();
const entityId = req.query.entity_id?.trim();
const action = req.query.action?.trim();
const from = req.query.from;
const to = req.query.to;
const limitRaw = req.query.limit;
const limit = limitRaw ? Number(limitRaw) : 100;
if (from && Number.isNaN(Date.parse(from))) {
return next(new ApiError("BAD_REQUEST", "from must be valid ISO datetime", 400));
}
if (to && Number.isNaN(Date.parse(to))) {
return next(new ApiError("BAD_REQUEST", "to must be valid ISO datetime", 400));
}
if (!Number.isFinite(limit) || limit <= 0) {
return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400));
}
const logs = await listAuditLogs({
entity_type: entityType || undefined,
entity_id: entityId || undefined,
action: action || undefined,
from,
to,
limit
});
res.json(successResponse(req, logs.map(toAuditLogPayload)));
});
router.get("/ledger-entries", requireAdminToken, async (req, res, next) => {
const transactionId = req.query.transaction_id?.trim();
const merchantId = req.query.merchant_id?.trim();
const limitRaw = req.query.limit;
const limit = limitRaw ? Number(limitRaw) : 100;
if (!Number.isFinite(limit) || limit <= 0) {
return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400));
}
const entries = await listLedgerEntries({
transaction_id: transactionId || undefined,
merchant_id: merchantId || undefined,
limit
});
res.json(successResponse(req, entries.map(toLedgerEntryPayload)));
});
export default router;