Files
Qris-Soundbox/dist/routes/admin.js
2026-05-25 08:22:12 +07:00

1108 lines
45 KiB
JavaScript

import { Router } from "express";
import { randomUUID } from "node:crypto";
import { ApiError } from "../shared/errors";
import { requireAdminToken } from "../shared/middleware/auth";
import { successResponse } from "../shared/middleware/errorMiddleware";
import { env } from "../config/env";
import { idempotency } from "../shared/middleware/idempotency";
import { createMerchant, getMerchantById, listMerchants, patchMerchant, toMerchantPayload } from "../shared/store/merchantStore";
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 { 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";
import { retryNotificationByTransactionId } from "../shared/orchestrators/notificationOrchestrator";
const router = Router();
function parseIdempotentReplay(req) {
return req.body.__idempotentReplay;
}
function getReplayResponse(req) {
return req.body.__idempotentResponse;
}
function isIsoDate(value) {
if (!value) {
return false;
}
return Number.isFinite(Date.parse(value));
}
function isTxInDateRange(tx, from, to) {
const createdAt = Date.parse(tx.created_at);
if (Number.isNaN(createdAt)) {
return false;
}
if (from && createdAt < Date.parse(from)) {
return false;
}
if (to && createdAt > Date.parse(to)) {
return false;
}
return true;
}
function parseDeviceStatusFilter(value) {
if (value === "online" || value === "offline" || value === "degraded" || value === "stale") {
return value;
}
return undefined;
}
function parseCommunicationModeFilter(value) {
if (value === "static" || value === "mqtt" || value === "api") {
return value;
}
return undefined;
}
function parseDeviceCommunicationMode(value) {
if (value === "static" || value === "mqtt" || value === "api") {
return value;
}
return undefined;
}
function parseDeviceStatusValue(value) {
if (value === "active" || value === "inactive") {
return value;
}
return undefined;
}
function parseOutletStatusFilter(value) {
if (value === "active" || value === "inactive") {
return value;
}
return undefined;
}
function parseTerminalStatusFilter(value) {
if (value === "active" || value === "inactive") {
return value;
}
return undefined;
}
function parseTerminalModeFilter(value) {
if (value === "static" || value === "dynamic_mqtt" || value === "dynamic_api") {
return value;
}
return undefined;
}
function parseTransactionStatusFilter(value) {
if (value === "initiated" ||
value === "awaiting_payment" ||
value === "paid" ||
value === "failed" ||
value === "expired" ||
value === "reversed") {
return value;
}
return undefined;
}
function parseCommandStatusFilter(value) {
if (value === "accepted" || value === "delivered" || value === "failed" || value === "timeout") {
return value;
}
return undefined;
}
function buildBindingSummary(binding) {
if (!binding) {
return null;
}
return {
id: binding.id,
merchant_id: binding.merchant_id,
outlet_id: binding.outlet_id,
terminal_id: binding.terminal_id
};
}
async function buildDeviceAdminPayload(device) {
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
return {
...toDevicePayload(device),
derived_status: deriveDeviceStatus({
last_seen_at: device.last_seen_at,
network_strength: latestHeartbeat?.network_strength ?? null,
battery_level: latestHeartbeat?.battery_level ?? null
}),
heartbeat_count_24h: await getHeartbeatCountForDeviceLastHours(device.id),
binding_summary: buildBindingSummary(await getActiveBindingByDevice(device.id)),
latest_heartbeat: latestHeartbeat
? {
id: latestHeartbeat.id,
timestamp: latestHeartbeat.timestamp,
received_at: latestHeartbeat.received_at,
state: latestHeartbeat.state,
network_strength: latestHeartbeat.network_strength,
battery_level: latestHeartbeat.battery_level,
firmware_version: latestHeartbeat.firmware_version
}
: null
};
}
async function deriveDeviceStatusesForDashboard() {
const devices = await listDevices();
return Promise.all(devices.map(async (device) => {
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
return {
device,
status: deriveDeviceStatus({
last_seen_at: device.last_seen_at,
network_strength: latestHeartbeat?.network_strength ?? null,
battery_level: latestHeartbeat?.battery_level ?? null
})
};
}));
}
function buildDashboardRange() {
const start = new Date();
start.setUTCHours(0, 0, 0, 0);
const end = new Date(start);
end.setUTCDate(start.getUTCDate() + 1);
return { start, end };
}
function toStartEndDateFilter(from, to) {
if (from && Number.isNaN(Date.parse(from))) {
return null;
}
if (to && Number.isNaN(Date.parse(to))) {
return null;
}
return {
fromTs: from ? Date.parse(from) : null,
toTs: to ? Date.parse(to) : null
};
}
function normalizeMerchantMode(payloadMode) {
return payloadMode || "merchant_direct";
}
function validatePayoutConfig(payload) {
const mode = normalizeMerchantMode(payload.payout_mode);
if (mode === "merchant_direct") {
if (!payload.settlement_account_reference || !payload.settlement_account_type) {
throw new ApiError("BAD_REQUEST", "settlement_account_reference and settlement_account_type required when payout_mode=merchant_direct", 400);
}
}
}
async function ensureMerchant(req, next) {
const merchantId = req.params.merchantId;
const merchant = await getMerchantById(merchantId);
if (!merchant) {
return next(new ApiError("NOT_FOUND", "merchant not found", 404));
}
return merchant;
}
router.post("/login", async (req, res, next) => {
const { username, password } = req.body;
if (username !== "admin" || password !== "admin") {
return next(new ApiError("UNAUTHORIZED", "Invalid credentials", 401));
}
const token = env.ADMIN_TOKEN;
res.json(successResponse(req, {
token
}));
});
router.use(async (req, res, next) => {
if (req.path === "/login") {
return next();
}
return requireAdminToken(req, res, next);
});
router.get("/health", requireAdminToken, async (_req, res) => {
res.json(successResponse(_req, {
ok: true,
now: new Date().toISOString()
}));
});
router.post("/sample-idempotent", requireAdminToken, idempotency({ scope: "admin.sample", required: false }), async (req, res) => {
if (parseIdempotentReplay(req)) {
return res.status(200).json(getReplayResponse(req));
}
const id = randomUUID();
res.json(successResponse(req, { id, generated_at: new Date().toISOString() }));
});
router.post("/merchants", requireAdminToken, idempotency({ scope: "merchant.create", required: false }), async (req, res, next) => {
if (parseIdempotentReplay(req)) {
return res.status(200).json(getReplayResponse(req));
}
const payload = req.body;
if (!payload?.legal_name) {
return next(new ApiError("BAD_REQUEST", "legal_name is required", 400));
}
try {
validatePayoutConfig(payload);
}
catch (err) {
return next(err);
}
const created = await createMerchant({
legal_name: payload.legal_name,
brand_name: payload.brand_name,
settlement_account_reference: payload.settlement_account_reference,
settlement_account_type: payload.settlement_account_type,
payout_mode: normalizeMerchantMode(payload.payout_mode),
fee_profile_id: payload.fee_profile_id,
status: payload.status,
onboarding_status: payload.onboarding_status
});
res.status(201).json(successResponse(req, toMerchantPayload(created)));
});
router.get("/merchants", requireAdminToken, async (_req, res) => {
res.json(successResponse(_req, (await listMerchants()).map(toMerchantPayload)));
});
router.get("/merchants/:merchantId", requireAdminToken, async (req, res, next) => {
const merchant = await getMerchantById(req.params.merchantId);
if (!merchant) {
return next(new ApiError("NOT_FOUND", "merchant not found", 404));
}
res.json(successResponse(req, toMerchantPayload(merchant)));
});
router.patch("/merchants/:merchantId", requireAdminToken, async (req, res, next) => {
const payload = req.body;
if (!payload || Object.keys(payload).length === 0) {
return next(new ApiError("BAD_REQUEST", "patch payload required", 400));
}
const existing = await getMerchantById(req.params.merchantId);
if (!existing) {
return next(new ApiError("NOT_FOUND", "merchant not found", 404));
}
const normalized = {
...payload,
payout_mode: payload.payout_mode ? payload.payout_mode : existing.payout_mode
};
if (normalized.payout_mode === "merchant_direct") {
normalized.settlement_account_reference =
normalized.settlement_account_reference || existing.settlement_account_reference;
normalized.settlement_account_type =
normalized.settlement_account_type || existing.settlement_account_type;
}
try {
validatePayoutConfig(normalized);
}
catch (err) {
return next(err);
}
const updated = await patchMerchant(req.params.merchantId, normalized);
res.json(successResponse(req, toMerchantPayload(updated)));
});
router.post("/merchants/:merchantId/approve", requireAdminToken, async (req, res, next) => {
const existing = await getMerchantById(req.params.merchantId);
if (!existing) {
return next(new ApiError("NOT_FOUND", "merchant not found", 404));
}
if (existing.onboarding_status === "approved") {
return res.json(successResponse(req, toMerchantPayload(existing)));
}
const updated = await patchMerchant(req.params.merchantId, {
onboarding_status: "approved"
});
res.json(successResponse(req, toMerchantPayload(updated)));
});
router.post("/merchants/:merchantId/reject", requireAdminToken, async (req, res, next) => {
const payload = req.body;
if (!payload?.reason || payload.reason.trim() === "") {
return next(new ApiError("BAD_REQUEST", "reason is required", 400));
}
const existing = await getMerchantById(req.params.merchantId);
if (!existing) {
return next(new ApiError("NOT_FOUND", "merchant not found", 404));
}
const updated = await patchMerchant(req.params.merchantId, {
onboarding_status: "rejected",
status: "inactive"
});
res.json(successResponse(req, {
...toMerchantPayload(updated),
rejection_reason: payload.reason
}));
});
router.post("/seed", requireAdminToken, idempotency({ scope: "seed.demo", required: false }), async (req, res, next) => {
if (parseIdempotentReplay(req)) {
return res.status(200).json(getReplayResponse(req));
}
const payload = req.body || {};
const includeHeartbeat = payload.include_demo_heartbeat !== false;
const includeTransactions = payload.include_demo_transactions !== false;
if ((await listMerchants()).length > 0 || (await listDevices()).length > 0 || (await listOutlets()).length > 0 || (await listTerminals()).length > 0) {
return next(new ApiError("BAD_REQUEST", "seed requires empty demo environment", 400));
}
const merchantA = await createMerchant({
legal_name: "Seed Merchant A",
brand_name: "Seed A",
settlement_account_reference: "seed-bank:111111111",
settlement_account_type: "merchant_bank_account",
payout_mode: "merchant_direct",
status: "active",
onboarding_status: "approved"
});
const merchantB = await createMerchant({
legal_name: "Seed Merchant B",
brand_name: "Seed B",
settlement_account_reference: "seed-bank:222222222",
settlement_account_type: "merchant_bank_account",
payout_mode: "manual",
status: "active",
onboarding_status: "pending"
});
const outletA = await createOutlet({
merchant_id: merchantA.id,
name: "Outlet Seed A",
address: "Jl. Contoh Nomor 1"
});
const outletB = await createOutlet({
merchant_id: merchantB.id,
name: "Outlet Seed B",
address: "Jl. Contoh Nomor 2"
});
const terminalA = await createTerminal({
outlet_id: outletA.id,
terminal_code: "TERM_SEED_A",
qr_mode: "static"
});
const terminalB = await createTerminal({
outlet_id: outletB.id,
terminal_code: "TERM_SEED_B",
qr_mode: "static"
});
const deviceA = await createDevice({
device_code: "DEV_SEED_A",
vendor: "seed-maker",
model: "v1",
communication_mode: "mqtt",
status: "active"
});
const deviceB = await createDevice({
device_code: "DEV_SEED_B",
vendor: "seed-maker",
model: "v1",
communication_mode: "mqtt",
status: "active"
});
const deviceC = await createDevice({
device_code: "DEV_SEED_C",
vendor: "seed-maker",
model: "v1",
communication_mode: "mqtt",
status: "inactive"
});
await bindDevice({
device_id: deviceA.id,
merchant_id: merchantA.id,
outlet_id: outletA.id,
terminal_id: terminalA.id
});
await bindDevice({
device_id: deviceB.id,
merchant_id: merchantB.id,
outlet_id: outletB.id,
terminal_id: terminalB.id
});
if (includeHeartbeat) {
await createDeviceHeartbeat({
device_id: deviceA.id,
timestamp: new Date().toISOString(),
firmware_version: "1.0.0",
network_strength: 92,
battery_level: 89,
state: "idle"
});
await createDeviceHeartbeat({
device_id: deviceB.id,
timestamp: new Date().toISOString(),
firmware_version: "1.0.0",
network_strength: 83,
battery_level: 76,
state: "idle"
});
}
const transactions = includeTransactions
? [
await createTransaction({
merchant_id: merchantA.id,
outlet_id: outletA.id,
terminal_id: terminalA.id,
device_id: deviceA.id,
partner_reference: "seed-pr-001",
amount: 25000,
currency: "IDR",
qr_mode: "static",
initiation_mode: "static",
status: "initiated"
}),
await createTransaction({
merchant_id: merchantB.id,
outlet_id: outletB.id,
terminal_id: terminalB.id,
device_id: deviceB.id,
partner_reference: "seed-pr-002",
amount: 50000,
currency: "IDR",
qr_mode: "static",
initiation_mode: "static",
status: "awaiting_payment"
})
]
: [];
const seeded = {
merchants: [toMerchantPayload(merchantA), toMerchantPayload(merchantB)],
outlets: [outletA, outletB],
terminals: [terminalA, terminalB],
devices: [deviceA, deviceB, deviceC],
transactions: transactions.map((tx) => toTransactionPayload(tx)),
include_demo_heartbeat: includeHeartbeat,
include_demo_transactions: includeTransactions
};
res.status(201).json(successResponse(req, seeded));
});
router.get("/seed/status", requireAdminToken, async (_req, res) => {
res.json(successResponse(_req, {
merchants: (await listMerchants()).length,
outlets: (await listOutlets()).length,
terminals: (await listTerminals()).length,
devices: (await listDevices()).length,
transactions: (await listTransactions()).length,
heartbeats: (await listHeartbeats()).length,
notifications: (await listNotifications()).length,
seed_eligible: (await listMerchants()).length === 0 && (await listDevices()).length === 0 && (await listOutlets()).length === 0 && (await listTerminals()).length === 0
}));
});
router.post("/merchants/:merchantId/outlets", requireAdminToken, idempotency({ scope: "outlet.create", required: false }), async (req, res, next) => {
if (parseIdempotentReplay(req)) {
return res.status(200).json(getReplayResponse(req));
}
const merchant = await ensureMerchant(req, next);
if (!merchant) {
return;
}
const payload = req.body;
if (!payload?.name) {
return next(new ApiError("BAD_REQUEST", "name is required", 400));
}
if (payload.status) {
if (!parseOutletStatusFilter(payload.status)) {
return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
}
}
const outlet = await createOutlet({
merchant_id: merchant.id,
name: payload.name,
address: payload.address,
outlet_code: payload.outlet_code,
status: payload.status
});
res.status(201).json(successResponse(req, outlet));
});
router.get("/outlets", requireAdminToken, async (req, res) => {
const merchantId = req.query.merchant_id;
res.json(successResponse(req, (await listOutlets({
merchant_id: merchantId
})).map(toOutletPayload)));
});
router.get("/outlets/:outletId", requireAdminToken, async (req, res, next) => {
const outlet = await getOutletById(req.params.outletId);
if (!outlet) {
return next(new ApiError("NOT_FOUND", "outlet not found", 404));
}
res.json(successResponse(req, toOutletPayload(outlet)));
});
router.patch("/outlets/:outletId", requireAdminToken, async (req, res, next) => {
const payload = req.body;
if (!payload || Object.keys(payload).length === 0) {
return next(new ApiError("BAD_REQUEST", "patch payload required", 400));
}
if (payload.status && !parseOutletStatusFilter(payload.status)) {
return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
}
try {
const updated = await patchOutlet(req.params.outletId, payload);
res.json(successResponse(req, toOutletPayload(updated)));
}
catch (err) {
if (err instanceof Error && err.message === "OUTLET_NOT_FOUND") {
return next(new ApiError("NOT_FOUND", "outlet not found", 404));
}
return next(err);
}
});
router.post("/outlets/:outletId/terminals", requireAdminToken, idempotency({ scope: "terminal.create", required: false }), async (req, res, next) => {
if (parseIdempotentReplay(req)) {
return res.status(201).json(getReplayResponse(req));
}
const outlet = await getOutletById(req.params.outletId);
if (!outlet) {
return next(new ApiError("NOT_FOUND", "outlet not found", 404));
}
const payload = req.body;
if (!payload || typeof payload !== "object") {
return next(new ApiError("BAD_REQUEST", "terminal payload required", 400));
}
if (payload.qr_mode && !parseTerminalModeFilter(payload.qr_mode)) {
return next(new ApiError("BAD_REQUEST", "qr_mode must be static|dynamic_mqtt|dynamic_api", 400));
}
if (payload.status && !parseTerminalStatusFilter(payload.status)) {
return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
}
const terminal = await createTerminal({
outlet_id: outlet.id,
terminal_code: payload.terminal_code,
qr_mode: payload.qr_mode,
partner_reference: payload.partner_reference,
status: payload.status
});
res.status(201).json(successResponse(req, toTerminalPayload(terminal)));
});
router.get("/terminals", requireAdminToken, async (req, res) => {
const outletId = req.query.outlet_id;
res.json(successResponse(req, (await listTerminals({
outlet_id: outletId
})).map(toTerminalPayload)));
});
router.get("/terminals/:terminalId", requireAdminToken, async (req, res, next) => {
const terminal = await getTerminalById(req.params.terminalId);
if (!terminal) {
return next(new ApiError("NOT_FOUND", "terminal not found", 404));
}
res.json(successResponse(req, toTerminalPayload(terminal)));
});
router.patch("/terminals/:terminalId", requireAdminToken, async (req, res, next) => {
const payload = req.body;
if (!payload || Object.keys(payload).length === 0) {
return next(new ApiError("BAD_REQUEST", "patch payload required", 400));
}
if (payload.qr_mode && !parseTerminalModeFilter(payload.qr_mode)) {
return next(new ApiError("BAD_REQUEST", "qr_mode must be static|dynamic_mqtt|dynamic_api", 400));
}
if (payload.status && !parseTerminalStatusFilter(payload.status)) {
return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
}
try {
const updated = await patchTerminal(req.params.terminalId, payload);
res.json(successResponse(req, toTerminalPayload(updated)));
}
catch (err) {
if (err instanceof Error && err.message === "TERMINAL_NOT_FOUND") {
return next(new ApiError("NOT_FOUND", "terminal not found", 404));
}
return next(err);
}
});
router.post("/devices", requireAdminToken, idempotency({ scope: "device.create", required: false }), async (req, res, next) => {
if (parseIdempotentReplay(req)) {
return res.status(201).json(getReplayResponse(req));
}
const payload = req.body;
if (!payload) {
return next(new ApiError("BAD_REQUEST", "device payload required", 400));
}
if (payload.communication_mode && !parseDeviceCommunicationMode(payload.communication_mode)) {
return next(new ApiError("BAD_REQUEST", "communication_mode must be static|mqtt|api", 400));
}
if (payload.status && !parseDeviceStatusValue(payload.status)) {
return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
}
const created = await createDevice(payload);
res.status(201).json(successResponse(req, toDevicePayload(created)));
});
router.get("/devices", requireAdminToken, async (req, res) => {
const status = parseDeviceStatusFilter(req.query.status);
const vendor = req.query.vendor?.trim();
const communicationMode = parseCommunicationModeFilter(req.query.communication_mode);
const merchantId = req.query.merchant_id?.trim();
const q = req.query.q?.trim();
const rawDevices = await listDevices();
const evaluated = await Promise.all(rawDevices.map(async (device) => {
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
const binding = merchantId ? await getActiveBindingByDevice(device.id) : 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
})
};
}));
const data = evaluated
.filter((entry) => {
const { device, derivedStatus, binding } = entry;
if (status && derivedStatus !== status) {
return false;
}
if (vendor && device.vendor !== vendor) {
return false;
}
if (communicationMode && device.communication_mode !== communicationMode) {
return false;
}
if (merchantId && (!binding || binding.merchant_id !== merchantId)) {
return false;
}
if (q) {
const text = q.toLowerCase();
const codeMatch = device.device_code.toLowerCase().includes(text);
const serialMatch = device.serial_number?.toLowerCase().includes(text);
if (!codeMatch && !serialMatch) {
return false;
}
}
return true;
})
.map((entry) => entry.device);
const payloads = await Promise.all(data
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.map((device) => buildDeviceAdminPayload(device)));
res.json(successResponse(req, payloads));
});
router.get("/devices/:deviceId", 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 activeBinding = await getActiveBindingByDevice(device.id);
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
const heartbeatCount24h = await getHeartbeatCountForDeviceLastHours(device.id);
const notifications = (await listNotificationsByDevice(device.id))
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.slice(0, 10)
.map(toNotificationPayload);
res.json(successResponse(req, {
...toDevicePayload(device),
derived_status: deriveDeviceStatus({
last_seen_at: device.last_seen_at,
network_strength: latestHeartbeat?.network_strength ?? null,
battery_level: latestHeartbeat?.battery_level ?? null
}),
active_binding: activeBinding ? toBindingPayload(activeBinding) : null,
latest_heartbeat: latestHeartbeat
? {
id: latestHeartbeat.id,
timestamp: latestHeartbeat.timestamp,
received_at: latestHeartbeat.received_at,
state: latestHeartbeat.state,
network_strength: latestHeartbeat.network_strength,
battery_level: latestHeartbeat.battery_level,
firmware_version: latestHeartbeat.firmware_version
}
: null,
heartbeat_count_24h: heartbeatCount24h,
notifications_latest: notifications
}));
});
router.get("/devices/:deviceId/heartbeats", 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 from = req.query.from;
const to = req.query.to;
const state = req.query.state;
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));
}
res.json(successResponse(req, {
device_id: device.id,
heartbeats: await listHeartbeats({
device_id: device.id,
from,
to,
state
})
}));
});
router.patch("/devices/:deviceId", requireAdminToken, async (req, res, next) => {
const payload = req.body;
if (!payload || Object.keys(payload).length === 0) {
return next(new ApiError("BAD_REQUEST", "patch payload required", 400));
}
if (payload.communication_mode && !parseDeviceCommunicationMode(payload.communication_mode)) {
return next(new ApiError("BAD_REQUEST", "communication_mode must be static|mqtt|api", 400));
}
if (payload.status && !parseDeviceStatusValue(payload.status)) {
return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
}
try {
const updated = await patchDevice(req.params.deviceId, payload);
res.json(successResponse(req, toDevicePayload(updated)));
}
catch (err) {
if (err instanceof Error && err.message === "DEVICE_NOT_FOUND") {
return next(new ApiError("NOT_FOUND", "device not found", 404));
}
return next(err);
}
});
router.post("/devices/:deviceId/bind", requireAdminToken, idempotency({ scope: "device.bind", required: false }), async (req, res, next) => {
if (parseIdempotentReplay(req)) {
return res.status(200).json(getReplayResponse(req));
}
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?.merchant_id || !payload.outlet_id || !payload.terminal_id) {
return next(new ApiError("BAD_REQUEST", "merchant_id, outlet_id, terminal_id required", 400));
}
const merchant = await getMerchantById(payload.merchant_id);
const outlet = await getOutletById(payload.outlet_id);
const terminal = await getTerminalById(payload.terminal_id);
if (!merchant || !outlet || !terminal) {
return next(new ApiError("BAD_REQUEST", "merchant/outlet/terminal reference invalid", 400));
}
if (outlet.merchant_id !== merchant.id) {
return next(new ApiError("BAD_REQUEST", "outlet does not belong to merchant", 400));
}
if (terminal.outlet_id !== outlet.id) {
return next(new ApiError("BAD_REQUEST", "terminal does not belong to outlet", 400));
}
const binding = await bindDevice({
device_id: device.id,
merchant_id: merchant.id,
outlet_id: outlet.id,
terminal_id: terminal.id
});
res.json(successResponse(req, toBindingPayload(binding)));
});
router.post("/devices/:deviceId/unbind", 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 binding = await unbindDevice(device.id);
if (!binding) {
return next(new ApiError("BAD_REQUEST", "device has no active binding", 400));
}
res.json(successResponse(req, toBindingPayload(binding)));
});
router.post("/devices/:deviceId/commands", 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 || typeof payload.command !== "string" || payload.command.trim() === "") {
return next(new ApiError("BAD_REQUEST", "command is required", 400));
}
const command = await createDeviceCommand({
device_id: device.id,
command: payload.command.trim(),
payload: payload.payload || {}
});
res.status(201).json(successResponse(req, toDeviceCommandPayload(command)));
});
router.get("/devices/:deviceId/commands", 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 statusFilter = parseCommandStatusFilter(req.query.status);
const limitRaw = req.query.limit;
const limit = limitRaw ? Number(limitRaw) : 50;
if (!Number.isFinite(limit) || limit <= 0) {
return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400));
}
const commands = (await listDeviceCommands(device.id))
.filter((command) => !statusFilter || command.status === statusFilter)
.slice(0, Math.min(limit, 200))
.map(toDeviceCommandPayloadBrief);
res.json(successResponse(req, { device_id: device.id, commands }));
});
router.get("/devices/:deviceId/commands/:commandId", 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 command = await getDeviceCommandById(device.id, req.params.commandId);
if (!command) {
return next(new ApiError("NOT_FOUND", "command not found", 404));
}
res.json(successResponse(req, toDeviceCommandPayload(command)));
});
router.get("/devices/:deviceId/notifications", 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 limitRaw = req.query.limit;
const limit = limitRaw ? Number(limitRaw) : 50;
if (!Number.isFinite(limit) || limit <= 0) {
return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400));
}
const notifications = (await listNotificationsByDevice(device.id))
.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))
.slice(0, Math.min(limit, 200))
.map(toNotificationPayload);
res.json(successResponse(req, { device_id: device.id, notifications }));
});
router.post("/transactions", requireAdminToken, idempotency({ scope: "transaction.create", required: false }), async (req, res, next) => {
if (parseIdempotentReplay(req)) {
return res.status(201).json(getReplayResponse(req));
}
const payload = req.body;
if (!payload ||
!payload.partner_reference ||
!payload.merchant_id ||
!payload.outlet_id ||
!payload.terminal_id) {
return next(new ApiError("BAD_REQUEST", "partner_reference, merchant_id, outlet_id, terminal_id required", 400));
}
const merchant = await getMerchantById(payload.merchant_id);
if (!merchant) {
return next(new ApiError("BAD_REQUEST", "merchant not found", 400));
}
const outlet = await getOutletById(payload.outlet_id);
if (!outlet) {
return next(new ApiError("BAD_REQUEST", "outlet not found", 400));
}
if (outlet.merchant_id !== merchant.id) {
return next(new ApiError("BAD_REQUEST", "outlet does not belong to merchant", 400));
}
const terminal = await getTerminalById(payload.terminal_id);
if (!terminal) {
return next(new ApiError("BAD_REQUEST", "terminal not found", 400));
}
if (terminal.outlet_id !== outlet.id) {
return next(new ApiError("BAD_REQUEST", "terminal does not belong to outlet", 400));
}
if (payload.device_id && !await getDeviceById(payload.device_id)) {
return next(new ApiError("BAD_REQUEST", "device not found", 400));
}
const amount = Number(payload.amount);
if (!Number.isFinite(amount) || amount <= 0) {
return next(new ApiError("BAD_REQUEST", "amount must be a positive number", 400));
}
if (payload.status && !parseTransactionStatusFilter(payload.status)) {
return next(new ApiError("BAD_REQUEST", "invalid status", 400));
}
const created = await createTransaction({
merchant_id: merchant.id,
outlet_id: outlet.id,
terminal_id: terminal.id,
device_id: payload.device_id,
partner_reference: payload.partner_reference,
amount,
currency: payload.currency,
qr_mode: payload.qr_mode || "static",
initiation_mode: payload.initiation_mode || "static",
status: payload.status || "initiated"
});
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 from = req.query.from;
const to = req.query.to;
const partnerReference = req.query.partner_reference;
const normalizedStatus = typeof status === "string" ? parseTransactionStatusFilter(status) : undefined;
if (from && !isIsoDate(from)) {
return next(new ApiError("BAD_REQUEST", "from must be valid ISO datetime", 400));
}
if (to && !isIsoDate(to)) {
return next(new ApiError("BAD_REQUEST", "to must be valid ISO datetime", 400));
}
const normalizedPartnerRef = partnerReference?.trim();
res.json(successResponse(req, (await listTransactions({
status: normalizedStatus,
merchant_id: merchantId
}))
.filter((tx) => isTxInDateRange(tx, from, to))
.filter((tx) => !normalizedPartnerRef || tx.partner_reference === normalizedPartnerRef)
.map(toTransactionPayload)));
});
router.get("/transactions/:transactionId", requireAdminToken, async (req, res, next) => {
const tx = await getTransactionById(req.params.transactionId);
if (!tx) {
return next(new ApiError("NOT_FOUND", "transaction not found", 404));
}
const events = (await getTransactionEvents(tx.id)).map(toTransactionEventPayload);
const bindingByTerminal = tx.terminal_id ? await getActiveBindingByTerminal(tx.terminal_id) : null;
const heartbeatDeviceId = tx.device_id || bindingByTerminal?.device_id;
const heartbeatHistory = heartbeatDeviceId
? (await listHeartbeats({ device_id: heartbeatDeviceId })).map((heartbeat) => ({
id: heartbeat.id,
device_id: heartbeat.device_id,
timestamp: heartbeat.timestamp,
state: heartbeat.state,
network_strength: heartbeat.network_strength,
battery_level: heartbeat.battery_level,
firmware_version: heartbeat.firmware_version,
received_at: heartbeat.received_at
}))
: [];
res.json(successResponse(req, {
transaction: toTransactionPayload(tx),
events,
heartbeat_device_id: heartbeatDeviceId,
heartbeat_history: heartbeatHistory
}));
});
router.get("/transactions/:transactionId/events", requireAdminToken, async (req, res, next) => {
const tx = await getTransactionById(req.params.transactionId);
if (!tx) {
return next(new ApiError("NOT_FOUND", "transaction not found", 404));
}
const events = (await getTransactionEvents(tx.id))
.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))
.map(toTransactionEventPayload);
res.json(successResponse(req, { transaction_id: tx.id, events }));
});
router.get("/transactions/:transactionId/heartbeats", requireAdminToken, async (req, res, next) => {
const tx = await getTransactionById(req.params.transactionId);
if (!tx) {
return next(new ApiError("NOT_FOUND", "transaction not found", 404));
}
const from = req.query.from;
const to = req.query.to;
const state = req.query.state;
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 bindingByTerminal = tx.terminal_id ? await getActiveBindingByTerminal(tx.terminal_id) : null;
const heartbeatDeviceId = tx.device_id || bindingByTerminal?.device_id;
if (!heartbeatDeviceId) {
return res.json(successResponse(req, {
transaction_id: tx.id,
heartbeat_device_id: null,
heartbeats: []
}));
}
const heartbeats = (await listHeartbeats({
device_id: heartbeatDeviceId,
from,
to,
state
}))
.slice(0, Math.min(limit, 500))
.map((heartbeat) => ({
id: heartbeat.id,
device_id: heartbeat.device_id,
timestamp: heartbeat.timestamp,
received_at: heartbeat.received_at,
state: heartbeat.state,
network_strength: heartbeat.network_strength,
battery_level: heartbeat.battery_level,
firmware_version: heartbeat.firmware_version
}));
res.json(successResponse(req, {
transaction_id: tx.id,
heartbeat_device_id: heartbeatDeviceId,
heartbeats
}));
});
router.post("/transactions/:transactionId/retry-notification", requireAdminToken, async (req, res, next) => {
const transactionId = req.params.transactionId;
try {
const tx = await getTransactionById(transactionId);
if (!tx) {
return next(new ApiError("NOT_FOUND", "transaction not found", 404));
}
if (tx.status !== "paid") {
return next(new ApiError("BAD_REQUEST", "notification retry only allowed for paid transactions", 400));
}
const before = await getNotificationByTransactionId(transactionId);
if (!before) {
return next(new ApiError("NOT_FOUND", "notification not found for this transaction", 404));
}
if (before.delivery_status === "acknowledged") {
return res.status(200).json(successResponse(req, {
transaction_id: tx.id,
notification_id: before.id,
delivery_status: before.delivery_status,
next_retry_at: before.next_retry_at || null
}));
}
const updated = await retryNotificationByTransactionId(transactionId);
if (!updated) {
return next(new ApiError("NOTIFICATION_PUBLISH_FAILED", "notification retry could not be executed", 500));
}
res.json(successResponse(req, {
transaction_id: tx.id,
notification_id: updated.id,
delivery_status: updated.delivery_status,
next_retry_at: updated.next_retry_at || null
}));
}
catch (error) {
if (error instanceof Error && error.message === "NOTIFICATION_PUBLISH_CONDITION") {
return next(new ApiError("BAD_REQUEST", "notification retry only allowed for paid transactions", 400));
}
return next(error);
}
});
router.get("/dashboard/summary", requireAdminToken, async (req, res, next) => {
try {
const { start, end } = buildDashboardRange();
const startTs = start.getTime();
const endTs = end.getTime();
const todayTransactions = (await listTransactions()).filter((tx) => {
const createdTs = Date.parse(tx.created_at);
return createdTs >= startTs && createdTs < endTs;
});
const paidToday = todayTransactions.filter((tx) => tx.status === "paid").length;
const transactionsToday = todayTransactions.length;
const successRateToday = transactionsToday > 0 ? (paidToday / transactionsToday) * 100 : 0;
const statuses = await deriveDeviceStatusesForDashboard();
const activeDevices = statuses.filter((row) => row.status !== "offline").length;
const devicesStale = statuses.filter((row) => row.status === "stale").length;
const devicesOffline = statuses.filter((row) => row.status === "offline").length;
const pendingNotifications = (await listNotifications()).filter((notification) => {
return notification.delivery_status === "queued" || notification.delivery_status === "retrying";
}).length;
res.json(successResponse(req, {
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);
}
});
router.get("/notifications/failed", requireAdminToken, async (req, res, next) => {
const deviceId = req.query.device_id;
const from = req.query.from;
const to = req.query.to;
const range = toStartEndDateFilter(from, to);
if ((from || to) && range === null) {
return next(new ApiError("BAD_REQUEST", "from/to must be valid ISO datetime", 400));
}
const filtered = (await listNotifications())
.filter((notification) => notification.delivery_status === "failed")
.filter((notification) => !deviceId || notification.device_id === deviceId)
.filter((notification) => {
if (!range) {
return true;
}
const createdTs = Date.parse(notification.created_at);
if (range.fromTs && createdTs < range.fromTs) {
return false;
}
if (range.toTs && createdTs > range.toTs) {
return false;
}
return true;
})
.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))
.map((notification) => ({
notification_id: notification.id,
transaction_id: notification.transaction_id,
device_id: notification.device_id,
delivery_status: notification.delivery_status,
retry_count: notification.retry_count,
reason: notification.reason
}));
res.json(successResponse(req, filtered));
});
export default router;