1108 lines
45 KiB
JavaScript
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;
|