Continue phase 2 device ops and dynamic QR lifecycle
This commit is contained in:
158
dist/routes/admin.js
vendored
158
dist/routes/admin.js
vendored
@ -9,7 +9,7 @@ import { createMerchant, getMerchantById, listMerchants, patchMerchant, toMercha
|
||||
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 { deriveDeviceHealthSummary, 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";
|
||||
@ -20,6 +20,8 @@ import { resolveDeviceCapabilitySummary } from "../shared/services/deviceCapabil
|
||||
import { getOrCreateDeviceConfig, listDeviceConfigAcks, toDeviceConfigAckPayload, toDeviceConfigPayload, upsertDeviceConfig } from "../shared/store/deviceConfigStore";
|
||||
import { listMqttMessages, toMqttMessagePayload, createMqttMessage } from "../shared/store/mqttMessageStore";
|
||||
import { publishConfigPush } from "../shared/services/mqttPublisher";
|
||||
import { buildDeviceConfigStatus } from "../shared/services/deviceConfigStatus";
|
||||
import { expireDueDynamicQrTransactions } from "../shared/services/dynamicQrExpiry";
|
||||
const router = Router();
|
||||
function parseIdempotentReplay(req) {
|
||||
return req.body.__idempotentReplay;
|
||||
@ -124,14 +126,16 @@ function buildBindingSummary(binding) {
|
||||
}
|
||||
async function buildDeviceAdminPayload(device) {
|
||||
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
|
||||
const healthSummary = deriveDeviceHealthSummary({
|
||||
last_seen_at: device.last_seen_at,
|
||||
network_strength: latestHeartbeat?.network_strength ?? null,
|
||||
battery_level: latestHeartbeat?.battery_level ?? null
|
||||
});
|
||||
return {
|
||||
...toDevicePayload(device),
|
||||
capability_summary: resolveDeviceCapabilitySummary(device),
|
||||
derived_status: deriveDeviceStatus({
|
||||
last_seen_at: device.last_seen_at,
|
||||
network_strength: latestHeartbeat?.network_strength ?? null,
|
||||
battery_level: latestHeartbeat?.battery_level ?? null
|
||||
}),
|
||||
derived_status: healthSummary.status,
|
||||
health_summary: healthSummary,
|
||||
heartbeat_count_24h: await getHeartbeatCountForDeviceLastHours(device.id),
|
||||
binding_summary: buildBindingSummary(await getActiveBindingByDevice(device.id)),
|
||||
latest_heartbeat: latestHeartbeat
|
||||
@ -151,13 +155,15 @@ async function deriveDeviceStatusesForDashboard() {
|
||||
const devices = await listDevices();
|
||||
return Promise.all(devices.map(async (device) => {
|
||||
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
|
||||
const healthSummary = deriveDeviceHealthSummary({
|
||||
last_seen_at: device.last_seen_at,
|
||||
network_strength: latestHeartbeat?.network_strength ?? null,
|
||||
battery_level: latestHeartbeat?.battery_level ?? null
|
||||
});
|
||||
return {
|
||||
device,
|
||||
status: deriveDeviceStatus({
|
||||
last_seen_at: device.last_seen_at,
|
||||
network_strength: latestHeartbeat?.network_strength ?? null,
|
||||
battery_level: latestHeartbeat?.battery_level ?? null
|
||||
})
|
||||
status: healthSummary.status,
|
||||
healthSummary
|
||||
};
|
||||
}));
|
||||
}
|
||||
@ -197,6 +203,24 @@ async function auditAdminAction(req, payload) {
|
||||
trace_id: req.traceId
|
||||
});
|
||||
}
|
||||
async function publishDeviceConfigPush(deviceId, config) {
|
||||
const mqttPayload = {
|
||||
message_type: "config_push",
|
||||
config_version: config.config_version,
|
||||
settings: config.settings_json
|
||||
};
|
||||
const publishResult = await publishConfigPush(deviceId, mqttPayload);
|
||||
return createMqttMessage({
|
||||
direction: "downlink",
|
||||
device_id: deviceId,
|
||||
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
|
||||
});
|
||||
}
|
||||
function validatePayoutConfig(payload) {
|
||||
const mode = normalizeMerchantMode(payload.payout_mode);
|
||||
if (mode === "merchant_direct") {
|
||||
@ -739,15 +763,16 @@ router.get("/devices", requireAdminToken, async (req, res) => {
|
||||
const evaluated = await Promise.all(rawDevices.map(async (device) => {
|
||||
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
|
||||
const binding = merchantId ? await getActiveBindingByDevice(device.id) : null;
|
||||
const healthSummary = deriveDeviceHealthSummary({
|
||||
last_seen_at: device.last_seen_at,
|
||||
network_strength: latestHeartbeat?.network_strength ?? null,
|
||||
battery_level: latestHeartbeat?.battery_level ?? 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
|
||||
})
|
||||
derivedStatus: healthSummary.status
|
||||
};
|
||||
}));
|
||||
const data = evaluated
|
||||
@ -789,6 +814,11 @@ router.get("/devices/:deviceId", requireAdminToken, async (req, res, next) => {
|
||||
const activeBinding = await getActiveBindingByDevice(device.id);
|
||||
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
|
||||
const heartbeatCount24h = await getHeartbeatCountForDeviceLastHours(device.id);
|
||||
const healthSummary = deriveDeviceHealthSummary({
|
||||
last_seen_at: device.last_seen_at,
|
||||
network_strength: latestHeartbeat?.network_strength ?? null,
|
||||
battery_level: latestHeartbeat?.battery_level ?? null
|
||||
});
|
||||
const notifications = (await listNotificationsByDevice(device.id))
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||
.slice(0, 10)
|
||||
@ -796,11 +826,8 @@ router.get("/devices/:deviceId", requireAdminToken, async (req, res, next) => {
|
||||
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,
|
||||
battery_level: latestHeartbeat?.battery_level ?? null
|
||||
}),
|
||||
derived_status: healthSummary.status,
|
||||
health_summary: healthSummary,
|
||||
active_binding: activeBinding ? toBindingPayload(activeBinding) : null,
|
||||
latest_heartbeat: latestHeartbeat
|
||||
? {
|
||||
@ -994,7 +1021,16 @@ router.get("/devices/:deviceId/config", requireAdminToken, async (req, res, next
|
||||
}
|
||||
const config = await getOrCreateDeviceConfig(device.id);
|
||||
const acks = (await listDeviceConfigAcks(device.id, 10)).map(toDeviceConfigAckPayload);
|
||||
res.json(successResponse(req, { ...toDeviceConfigPayload(config), latest_acks: acks }));
|
||||
const status = await buildDeviceConfigStatus(config);
|
||||
res.json(successResponse(req, { ...toDeviceConfigPayload(config), latest_acks: acks, config_status: status }));
|
||||
});
|
||||
router.get("/devices/:deviceId/config/status", requireAdminToken, async (req, res, next) => {
|
||||
const device = await getDeviceById(req.params.deviceId);
|
||||
if (!device) {
|
||||
return next(new ApiError("NOT_FOUND", "device not found", 404));
|
||||
}
|
||||
const config = await getOrCreateDeviceConfig(device.id);
|
||||
res.json(successResponse(req, await buildDeviceConfigStatus(config)));
|
||||
});
|
||||
router.patch("/devices/:deviceId/config", requireAdminToken, async (req, res, next) => {
|
||||
const device = await getDeviceById(req.params.deviceId);
|
||||
@ -1010,22 +1046,7 @@ router.patch("/devices/:deviceId/config", requireAdminToken, async (req, res, ne
|
||||
settings_json: payload.settings,
|
||||
config_version: payload.config_version
|
||||
});
|
||||
const mqttPayload = {
|
||||
message_type: "config_push",
|
||||
config_version: config.config_version,
|
||||
settings: config.settings_json
|
||||
};
|
||||
const publishResult = await publishConfigPush(device.id, mqttPayload);
|
||||
const outbox = await createMqttMessage({
|
||||
direction: "downlink",
|
||||
device_id: device.id,
|
||||
topic: publishResult.topic,
|
||||
message_type: "config_push",
|
||||
correlation_id: `config:${config.config_version}`,
|
||||
payload_json: mqttPayload,
|
||||
publish_status: publishResult.ok ? "sent" : "failed",
|
||||
reason: publishResult.reason
|
||||
});
|
||||
const outbox = await publishDeviceConfigPush(device.id, config);
|
||||
await auditAdminAction(req, {
|
||||
action: "device.config_push",
|
||||
entity_type: "device",
|
||||
@ -1040,6 +1061,36 @@ router.patch("/devices/:deviceId/config", requireAdminToken, async (req, res, ne
|
||||
downlink_message: toMqttMessagePayload(outbox)
|
||||
}));
|
||||
});
|
||||
router.post("/devices/:deviceId/config/retry-push", 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 || {});
|
||||
const config = await getOrCreateDeviceConfig(device.id);
|
||||
const beforeStatus = await buildDeviceConfigStatus(config);
|
||||
if (beforeStatus.drift_status === "applied" && !payload.force) {
|
||||
return next(new ApiError("CONFIG_ALREADY_APPLIED", "config already applied; set force=true to push again", 409));
|
||||
}
|
||||
const outbox = await publishDeviceConfigPush(device.id, config);
|
||||
const afterStatus = await buildDeviceConfigStatus(config);
|
||||
await auditAdminAction(req, {
|
||||
action: "device.config_retry_push",
|
||||
entity_type: "device",
|
||||
entity_id: device.id,
|
||||
before_json: beforeStatus,
|
||||
after_json: {
|
||||
...afterStatus,
|
||||
downlink_message_id: outbox.id,
|
||||
force: payload.force === true
|
||||
}
|
||||
});
|
||||
res.json(successResponse(req, {
|
||||
config: toDeviceConfigPayload(config),
|
||||
config_status: afterStatus,
|
||||
downlink_message: toMqttMessagePayload(outbox)
|
||||
}));
|
||||
});
|
||||
router.get("/devices/:deviceId/mqtt-messages", requireAdminToken, async (req, res, next) => {
|
||||
const device = await getDeviceById(req.params.deviceId);
|
||||
if (!device) {
|
||||
@ -1106,6 +1157,9 @@ router.post("/transactions", requireAdminToken, idempotency({ scope: "transactio
|
||||
if (payload.status && !parseTransactionStatusFilter(payload.status)) {
|
||||
return next(new ApiError("BAD_REQUEST", "invalid status", 400));
|
||||
}
|
||||
if (payload.expired_at && Number.isNaN(Date.parse(payload.expired_at))) {
|
||||
return next(new ApiError("BAD_REQUEST", "expired_at must be valid ISO datetime", 400));
|
||||
}
|
||||
const created = await createTransaction({
|
||||
merchant_id: merchant.id,
|
||||
outlet_id: outlet.id,
|
||||
@ -1116,7 +1170,8 @@ router.post("/transactions", requireAdminToken, idempotency({ scope: "transactio
|
||||
currency: payload.currency,
|
||||
qr_mode: payload.qr_mode || "static",
|
||||
initiation_mode: payload.initiation_mode || "static",
|
||||
status: payload.status || "initiated"
|
||||
status: payload.status || "initiated",
|
||||
expired_at: payload.expired_at
|
||||
});
|
||||
await auditAdminAction(req, {
|
||||
action: "transaction.create",
|
||||
@ -1161,6 +1216,31 @@ router.get("/transactions", requireAdminToken, async (req, res, next) => {
|
||||
})
|
||||
.map(toTransactionPayload)));
|
||||
});
|
||||
router.post("/transactions/expire-due", requireAdminToken, async (req, res, next) => {
|
||||
const limitRaw = req.body?.limit ?? req.query.limit;
|
||||
const limit = limitRaw === undefined || limitRaw === "" ? 100 : Number(limitRaw);
|
||||
if (!Number.isFinite(limit) || limit <= 0) {
|
||||
return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400));
|
||||
}
|
||||
const result = await expireDueDynamicQrTransactions({
|
||||
limit,
|
||||
source: "admin",
|
||||
request_id: req.requestId
|
||||
});
|
||||
await auditAdminAction(req, {
|
||||
action: "transaction.expire_due_dynamic_qr",
|
||||
entity_type: "transaction_batch",
|
||||
entity_id: `dynamic_qr_expiry_${result.swept_at}`,
|
||||
after_json: {
|
||||
scanned: result.scanned,
|
||||
expired_count: result.expired_count,
|
||||
skipped_count: result.skipped_count,
|
||||
expired_ids: result.expired.map((tx) => tx.id),
|
||||
skipped: result.skipped
|
||||
}
|
||||
});
|
||||
res.json(successResponse(req, result));
|
||||
});
|
||||
router.get("/transactions/:transactionId", requireAdminToken, async (req, res, next) => {
|
||||
const tx = await getTransactionById(req.params.transactionId);
|
||||
if (!tx) {
|
||||
|
||||
15
dist/routes/device.js
vendored
15
dist/routes/device.js
vendored
@ -340,6 +340,21 @@ router.post("/config/ack", requireDeviceToken, async (req, res, next) => {
|
||||
reason: payload.reason,
|
||||
payload_json: payload.result_payload || {}
|
||||
});
|
||||
await createMqttMessage({
|
||||
direction: "uplink",
|
||||
device_id: device.id,
|
||||
topic: `devices/${device.id}/uplink/config/ack`,
|
||||
message_type: "config_ack",
|
||||
correlation_id: `config:${configVersion}`,
|
||||
payload_json: {
|
||||
message_type: "config_ack",
|
||||
device_id: device.id,
|
||||
config_version: configVersion,
|
||||
status: payload.status,
|
||||
reason: payload.reason,
|
||||
result_payload: payload.result_payload || {}
|
||||
}
|
||||
});
|
||||
res.json(successResponse(req, toDeviceConfigAckPayload(ack)));
|
||||
});
|
||||
export default router;
|
||||
|
||||
37
dist/shared/services/deviceConfigStatus.js
vendored
Normal file
37
dist/shared/services/deviceConfigStatus.js
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
import { getLatestDeviceConfigAck, toDeviceConfigAckPayload, toDeviceConfigPayload } from "../store/deviceConfigStore";
|
||||
import { listMqttMessages, toMqttMessagePayload } from "../store/mqttMessageStore";
|
||||
function deriveConfigDriftStatus(config, latestAck) {
|
||||
if (!latestAck) {
|
||||
return "pending_ack";
|
||||
}
|
||||
if (latestAck.config_version < config.config_version) {
|
||||
return "stale_ack";
|
||||
}
|
||||
if (latestAck.config_version > config.config_version) {
|
||||
return "pending_ack";
|
||||
}
|
||||
if (latestAck.status === "failed") {
|
||||
return "failed_ack";
|
||||
}
|
||||
return "applied";
|
||||
}
|
||||
export async function buildDeviceConfigStatus(config) {
|
||||
const latestAck = await getLatestDeviceConfigAck(config.device_id);
|
||||
const latestPush = (await listMqttMessages({
|
||||
device_id: config.device_id,
|
||||
direction: "downlink",
|
||||
message_type: "config_push",
|
||||
correlation_id: `config:${config.config_version}`,
|
||||
limit: 1
|
||||
}))[0];
|
||||
const driftStatus = latestPush ? deriveConfigDriftStatus(config, latestAck) : "never_pushed";
|
||||
return {
|
||||
device_id: config.device_id,
|
||||
config: toDeviceConfigPayload(config),
|
||||
drift_status: driftStatus,
|
||||
desired_config_version: config.config_version,
|
||||
latest_ack: latestAck ? toDeviceConfigAckPayload(latestAck) : null,
|
||||
latest_push: latestPush ? toMqttMessagePayload(latestPush) : null,
|
||||
retry_recommended: driftStatus !== "applied"
|
||||
};
|
||||
}
|
||||
37
dist/shared/services/dynamicQrExpiry.js
vendored
Normal file
37
dist/shared/services/dynamicQrExpiry.js
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
import { listDueDynamicQrTransactions, toTransactionPayload, updateTransactionStatus } from "../store/transactionStore";
|
||||
export async function expireDueDynamicQrTransactions(input) {
|
||||
const due = await listDueDynamicQrTransactions(input?.limit || 100);
|
||||
const expired = [];
|
||||
const skipped = [];
|
||||
const sweptAt = new Date().toISOString();
|
||||
for (const tx of due) {
|
||||
try {
|
||||
const updated = await updateTransactionStatus(tx.id, "expired", {
|
||||
source: input?.source || "system",
|
||||
expired_at: tx.expired_at || sweptAt,
|
||||
eventContext: {
|
||||
reason: "dynamic_qr_expired",
|
||||
expired_at: tx.expired_at,
|
||||
swept_at: sweptAt,
|
||||
request_id: input?.request_id
|
||||
}
|
||||
});
|
||||
expired.push(toTransactionPayload(updated));
|
||||
}
|
||||
catch (error) {
|
||||
skipped.push({
|
||||
transaction_id: tx.id,
|
||||
partner_reference: tx.partner_reference,
|
||||
reason: error instanceof Error ? error.message : "UNKNOWN_ERROR"
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
scanned: due.length,
|
||||
expired_count: expired.length,
|
||||
skipped_count: skipped.length,
|
||||
swept_at: sweptAt,
|
||||
expired,
|
||||
skipped
|
||||
};
|
||||
}
|
||||
7
dist/shared/store/deviceConfigStore.js
vendored
7
dist/shared/store/deviceConfigStore.js
vendored
@ -81,6 +81,13 @@ export async function listDeviceConfigAcks(deviceId, limit = 50) {
|
||||
LIMIT $2`, [deviceId, Math.min(Math.max(limit, 1), 200)]);
|
||||
return rows.map(mapAck);
|
||||
}
|
||||
export async function getLatestDeviceConfigAck(deviceId) {
|
||||
const { rows } = await getPool().query(`SELECT * FROM device_config_acks
|
||||
WHERE device_id = $1
|
||||
ORDER BY acked_at DESC
|
||||
LIMIT 1`, [deviceId]);
|
||||
return rows[0] ? mapAck(rows[0]) : null;
|
||||
}
|
||||
export function toDeviceConfigPayload(config) {
|
||||
return { ...config };
|
||||
}
|
||||
|
||||
60
dist/shared/store/heartbeatStore.js
vendored
60
dist/shared/store/heartbeatStore.js
vendored
@ -100,3 +100,63 @@ export function deriveDeviceStatus(input) {
|
||||
}
|
||||
return "online";
|
||||
}
|
||||
export function deriveDeviceHealthSummary(input) {
|
||||
const now = Date.now();
|
||||
const lastSeen = Date.parse(input?.last_seen_at || "");
|
||||
const reasons = [];
|
||||
if (!Number.isFinite(lastSeen)) {
|
||||
return {
|
||||
status: "offline",
|
||||
score: 0,
|
||||
age_seconds: null,
|
||||
reasons: ["no_heartbeat"]
|
||||
};
|
||||
}
|
||||
const ageSeconds = Math.max(0, Math.floor((now - lastSeen) / 1000));
|
||||
const networkStrength = typeof input?.network_strength === "number" ? input.network_strength : null;
|
||||
const batteryLevel = typeof input?.battery_level === "number" ? input.battery_level : null;
|
||||
if (ageSeconds > 900) {
|
||||
reasons.push("offline_threshold_exceeded");
|
||||
}
|
||||
else if (ageSeconds > 90) {
|
||||
reasons.push("stale_threshold_exceeded");
|
||||
}
|
||||
if (typeof networkStrength === "number" && networkStrength < 40) {
|
||||
reasons.push("low_signal");
|
||||
}
|
||||
if (typeof batteryLevel === "number" && batteryLevel < 20) {
|
||||
reasons.push("low_battery");
|
||||
}
|
||||
let status = "online";
|
||||
if (reasons.includes("offline_threshold_exceeded")) {
|
||||
status = "offline";
|
||||
}
|
||||
else if (reasons.includes("stale_threshold_exceeded")) {
|
||||
status = "stale";
|
||||
}
|
||||
else if (reasons.includes("low_signal") || reasons.includes("low_battery")) {
|
||||
status = "degraded";
|
||||
}
|
||||
let score = 100;
|
||||
if (ageSeconds > 900) {
|
||||
score -= 80;
|
||||
}
|
||||
else if (ageSeconds > 90) {
|
||||
score -= 35;
|
||||
}
|
||||
else if (ageSeconds > 60) {
|
||||
score -= 10;
|
||||
}
|
||||
if (typeof networkStrength === "number") {
|
||||
score -= Math.max(0, 40 - networkStrength);
|
||||
}
|
||||
if (typeof batteryLevel === "number") {
|
||||
score -= Math.max(0, 20 - batteryLevel);
|
||||
}
|
||||
return {
|
||||
status,
|
||||
score: Math.max(0, Math.min(100, Math.round(score))),
|
||||
age_seconds: ageSeconds,
|
||||
reasons
|
||||
};
|
||||
}
|
||||
|
||||
11
dist/shared/store/transactionStore.js
vendored
11
dist/shared/store/transactionStore.js
vendored
@ -192,6 +192,17 @@ export async function listTransactions(filter) {
|
||||
const { rows } = await getPool().query("SELECT * FROM transactions ORDER BY created_at DESC");
|
||||
return rows.map(mapTransaction);
|
||||
}
|
||||
export async function listDueDynamicQrTransactions(limit = 100) {
|
||||
const safeLimit = Math.min(Math.max(limit, 1), 500);
|
||||
const { rows } = await getPool().query(`SELECT * FROM transactions
|
||||
WHERE qr_mode = 'dynamic'
|
||||
AND status = 'awaiting_payment'
|
||||
AND expired_at IS NOT NULL
|
||||
AND expired_at <= NOW()
|
||||
ORDER BY expired_at ASC
|
||||
LIMIT ${safeLimit}`);
|
||||
return rows.map(mapTransaction);
|
||||
}
|
||||
export async function getTransactionEvents(transactionId) {
|
||||
const { rows } = await getPool().query("SELECT * FROM transaction_events WHERE transaction_id = $1 ORDER BY created_at ASC", [transactionId]);
|
||||
return rows.map(mapEvent);
|
||||
|
||||
Reference in New Issue
Block a user