Implement phase 1 completion and phase 2 dynamic QR
This commit is contained in:
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user