Implement phase 1 completion and phase 2 dynamic QR
This commit is contained in:
339
dist/routes/admin.js
vendored
339
dist/routes/admin.js
vendored
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user