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

View File

@ -56,6 +56,18 @@ import {
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();
@ -104,6 +116,11 @@ type DeviceCommandInput = {
payload?: Record<string, unknown>;
};
type DeviceConfigInput = {
settings?: Record<string, unknown>;
config_version?: number;
};
type BindingInput = {
merchant_id?: string;
outlet_id?: string;
@ -262,6 +279,7 @@ async function buildDeviceAdminPayload(device: DeviceEntity) {
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,
@ -327,6 +345,30 @@ function normalizeMerchantMode(payloadMode: MerchantCreateInput["payout_mode"]):
return payloadMode || "merchant_direct";
}
async function auditAdminAction(
req: Request,
payload: {
action: string;
entity_type: string;
entity_id: string;
before_json?: unknown;
after_json?: unknown;
}
) {
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: MerchantCreateInput) {
const mode = normalizeMerchantMode(payload.payout_mode);
if (mode === "merchant_direct") {
@ -430,6 +472,13 @@ router.post(
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)));
}
);
@ -481,6 +530,13 @@ router.patch("/merchants/:merchantId", requireAdminToken, async (req: Request, r
}
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)));
});
@ -498,6 +554,14 @@ router.post("/merchants/:merchantId/approve", requireAdminToken, async (req: Req
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)));
});
@ -520,6 +584,17 @@ router.post(
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),
@ -591,7 +666,11 @@ router.post(
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({
@ -599,14 +678,28 @@ router.post(
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({
@ -732,6 +825,13 @@ router.post(
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));
}
);
@ -775,7 +875,15 @@ router.patch("/outlets/:outletId", requireAdminToken, async (req: Request, res:
}
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) {
if (err instanceof Error && err.message === "OUTLET_NOT_FOUND") {
@ -819,6 +927,13 @@ router.post(
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)));
}
);
@ -865,7 +980,15 @@ router.patch("/terminals/:terminalId", requireAdminToken, async (req: Request, r
}
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) {
if (err instanceof Error && err.message === "TERMINAL_NOT_FOUND") {
@ -894,6 +1017,12 @@ router.post("/devices", requireAdminToken, idempotency({ scope: "device.create",
}
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)));
});
@ -969,7 +1098,7 @@ 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 notifications = (await listNotificationsByDevice(device.id))
@ -980,6 +1109,7 @@ router.get("/devices/:deviceId", requireAdminToken, async (req: Request, res: Re
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,
@ -1047,7 +1177,15 @@ router.patch("/devices/:deviceId", requireAdminToken, async (req: Request, res:
}
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) {
if (err instanceof Error && err.message === "DEVICE_NOT_FOUND") {
@ -1094,6 +1232,13 @@ router.post("/devices/:deviceId/bind", requireAdminToken, idempotency({ scope: "
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)));
});
@ -1108,6 +1253,14 @@ router.post("/devices/:deviceId/unbind", requireAdminToken, async (req: Request,
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)));
});
@ -1199,6 +1352,99 @@ router.get("/devices/:deviceId/notifications", requireAdminToken, async (req: Re
res.json(successResponse(req, { device_id: device.id, notifications }));
});
router.get("/devices/:deviceId/config", 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);
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: 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 DeviceConfigInput;
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" 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
});
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: 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 directionRaw = (req.query.direction as string | undefined)?.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 as string | undefined)?.trim();
const correlationId = (req.query.correlation_id as string | undefined)?.trim();
const limitRaw = req.query.limit as string | undefined;
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,
@ -1268,6 +1514,13 @@ router.post(
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)));
}
);
@ -1331,6 +1584,7 @@ router.get(
}
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
@ -1350,6 +1604,7 @@ router.get(
successResponse(req, {
transaction: toTransactionPayload(tx),
events,
ledger_entries,
heartbeat_device_id: heartbeatDeviceId,
heartbeat_history: heartbeatHistory
})
@ -1588,4 +1843,56 @@ router.get("/notifications/failed", requireAdminToken, async (req: Request, res:
res.json(successResponse(req, filtered));
});
router.get("/audit-logs", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
const entityType = (req.query.entity_type as string | undefined)?.trim();
const entityId = (req.query.entity_id as string | undefined)?.trim();
const action = (req.query.action as string | undefined)?.trim();
const from = req.query.from as string | undefined;
const to = req.query.to as string | undefined;
const limitRaw = req.query.limit as string | undefined;
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: Request, res: Response, next: NextFunction) => {
const transactionId = (req.query.transaction_id as string | undefined)?.trim();
const merchantId = (req.query.merchant_id as string | undefined)?.trim();
const limitRaw = req.query.limit as string | undefined;
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;