Continue phase 2 device ops and dynamic QR lifecycle
This commit is contained in:
@ -28,7 +28,7 @@ import {
|
||||
toDevicePayload
|
||||
} from "../shared/store/deviceStore";
|
||||
import {
|
||||
deriveDeviceStatus,
|
||||
deriveDeviceHealthSummary,
|
||||
getHeartbeatCountForDeviceLastHours,
|
||||
getLatestHeartbeatByDeviceId,
|
||||
listHeartbeats,
|
||||
@ -68,6 +68,8 @@ import {
|
||||
} 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();
|
||||
|
||||
@ -121,6 +123,10 @@ type DeviceConfigInput = {
|
||||
config_version?: number;
|
||||
};
|
||||
|
||||
type DeviceConfigRetryInput = {
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
type BindingInput = {
|
||||
merchant_id?: string;
|
||||
outlet_id?: string;
|
||||
@ -147,6 +153,7 @@ type TransactionCreateInput = {
|
||||
qr_mode?: "static" | "dynamic";
|
||||
initiation_mode?: "manual" | "dynamic_api" | "dynamic_mqtt" | "static";
|
||||
status?: "initiated" | "awaiting_payment";
|
||||
expired_at?: string;
|
||||
};
|
||||
|
||||
function parseIdempotentReplay(req: Request) {
|
||||
@ -277,14 +284,16 @@ function buildBindingSummary(
|
||||
|
||||
async function buildDeviceAdminPayload(device: DeviceEntity) {
|
||||
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
|
||||
@ -307,13 +316,15 @@ async function deriveDeviceStatusesForDashboard() {
|
||||
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
|
||||
};
|
||||
})
|
||||
);
|
||||
@ -369,6 +380,25 @@ async function auditAdminAction(
|
||||
});
|
||||
}
|
||||
|
||||
async function publishDeviceConfigPush(deviceId: string, config: Awaited<ReturnType<typeof getOrCreateDeviceConfig>>) {
|
||||
const mqttPayload = {
|
||||
message_type: "config_push" as const,
|
||||
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: MerchantCreateInput) {
|
||||
const mode = normalizeMerchantMode(payload.payout_mode);
|
||||
if (mode === "merchant_direct") {
|
||||
@ -1038,15 +1068,16 @@ router.get("/devices", requireAdminToken, async (req: Request, res: Response) =>
|
||||
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
|
||||
};
|
||||
})
|
||||
);
|
||||
@ -1098,9 +1129,14 @@ router.get("/devices/:deviceId", requireAdminToken, async (req: Request, res: Re
|
||||
return next(new ApiError("NOT_FOUND", "device not found", 404));
|
||||
}
|
||||
|
||||
const activeBinding = await getActiveBindingByDevice(device.id);
|
||||
const activeBinding = await getActiveBindingByDevice(device.id);
|
||||
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
|
||||
const heartbeatCount24h = await getHeartbeatCountForDeviceLastHours(device.id);
|
||||
const 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)
|
||||
@ -1110,11 +1146,8 @@ router.get("/devices/:deviceId", requireAdminToken, async (req: Request, res: Re
|
||||
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
|
||||
? {
|
||||
@ -1360,7 +1393,18 @@ router.get("/devices/:deviceId/config", requireAdminToken, async (req: Request,
|
||||
|
||||
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: Request, res: Response, next: NextFunction) => {
|
||||
const device = await getDeviceById(req.params.deviceId);
|
||||
if (!device) {
|
||||
return next(new ApiError("NOT_FOUND", "device not found", 404));
|
||||
}
|
||||
|
||||
const config = await getOrCreateDeviceConfig(device.id);
|
||||
res.json(successResponse(req, await buildDeviceConfigStatus(config)));
|
||||
});
|
||||
|
||||
router.patch("/devices/:deviceId/config", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
||||
@ -1379,22 +1423,7 @@ router.patch("/devices/:deviceId/config", requireAdminToken, async (req: Request
|
||||
settings_json: payload.settings,
|
||||
config_version: payload.config_version
|
||||
});
|
||||
const mqttPayload = {
|
||||
message_type: "config_push" as const,
|
||||
config_version: config.config_version,
|
||||
settings: config.settings_json
|
||||
};
|
||||
const publishResult = await publishConfigPush(device.id, mqttPayload);
|
||||
const outbox = await createMqttMessage({
|
||||
direction: "downlink",
|
||||
device_id: device.id,
|
||||
topic: publishResult.topic,
|
||||
message_type: "config_push",
|
||||
correlation_id: `config:${config.config_version}`,
|
||||
payload_json: mqttPayload,
|
||||
publish_status: publishResult.ok ? "sent" : "failed",
|
||||
reason: publishResult.reason
|
||||
});
|
||||
const outbox = await publishDeviceConfigPush(device.id, config);
|
||||
|
||||
await auditAdminAction(req, {
|
||||
action: "device.config_push",
|
||||
@ -1414,6 +1443,43 @@ router.patch("/devices/:deviceId/config", requireAdminToken, async (req: Request
|
||||
);
|
||||
});
|
||||
|
||||
router.post("/devices/:deviceId/config/retry-push", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
||||
const device = await getDeviceById(req.params.deviceId);
|
||||
if (!device) {
|
||||
return next(new ApiError("NOT_FOUND", "device not found", 404));
|
||||
}
|
||||
|
||||
const payload = (req.body || {}) as DeviceConfigRetryInput;
|
||||
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: Request, res: Response, next: NextFunction) => {
|
||||
const device = await getDeviceById(req.params.deviceId);
|
||||
if (!device) {
|
||||
@ -1501,6 +1567,10 @@ router.post(
|
||||
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,
|
||||
@ -1511,7 +1581,8 @@ router.post(
|
||||
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, {
|
||||
@ -1574,6 +1645,35 @@ router.get("/transactions", requireAdminToken, async (req: Request, res: Respons
|
||||
);
|
||||
});
|
||||
|
||||
router.post("/transactions/expire-due", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
||||
const limitRaw = (req.body as { limit?: unknown } | undefined)?.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,
|
||||
|
||||
@ -455,6 +455,21 @@ router.post("/config/ack", requireDeviceToken, async (req: Request, res: Respons
|
||||
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)));
|
||||
});
|
||||
|
||||
@ -20,7 +20,8 @@ export type ErrorCode =
|
||||
| "NOTIFICATION_RETRY_EXHAUSTED"
|
||||
| "INVALID_AMOUNT"
|
||||
| "DEVICE_NOT_BOUND"
|
||||
| "DEVICE_CAPABILITY_NOT_SUPPORTED";
|
||||
| "DEVICE_CAPABILITY_NOT_SUPPORTED"
|
||||
| "CONFIG_ALREADY_APPLIED";
|
||||
|
||||
export interface ApiErrorShape {
|
||||
code: ErrorCode;
|
||||
|
||||
55
src/shared/services/deviceConfigStatus.ts
Normal file
55
src/shared/services/deviceConfigStatus.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import {
|
||||
DeviceConfigAckEntity,
|
||||
DeviceConfigEntity,
|
||||
getLatestDeviceConfigAck,
|
||||
toDeviceConfigAckPayload,
|
||||
toDeviceConfigPayload
|
||||
} from "../store/deviceConfigStore";
|
||||
import { listMqttMessages, toMqttMessagePayload } from "../store/mqttMessageStore";
|
||||
|
||||
type ConfigDriftStatus = "applied" | "pending_ack" | "failed_ack" | "stale_ack" | "never_pushed";
|
||||
|
||||
function deriveConfigDriftStatus(config: DeviceConfigEntity, latestAck: DeviceConfigAckEntity | null): ConfigDriftStatus {
|
||||
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: DeviceConfigEntity) {
|
||||
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"
|
||||
};
|
||||
}
|
||||
47
src/shared/services/dynamicQrExpiry.ts
Normal file
47
src/shared/services/dynamicQrExpiry.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import {
|
||||
listDueDynamicQrTransactions,
|
||||
toTransactionPayload,
|
||||
updateTransactionStatus
|
||||
} from "../store/transactionStore";
|
||||
|
||||
export async function expireDueDynamicQrTransactions(input?: {
|
||||
limit?: number;
|
||||
source?: "system" | "admin";
|
||||
request_id?: string;
|
||||
}) {
|
||||
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
|
||||
};
|
||||
}
|
||||
@ -131,6 +131,18 @@ export async function listDeviceConfigAcks(deviceId: string, limit = 50): Promis
|
||||
return rows.map(mapAck);
|
||||
}
|
||||
|
||||
export async function getLatestDeviceConfigAck(deviceId: string): Promise<DeviceConfigAckEntity | null> {
|
||||
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: DeviceConfigEntity) {
|
||||
return { ...config };
|
||||
}
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
export type DeviceHealthStatus = "online" | "offline" | "degraded" | "stale";
|
||||
|
||||
export type DeviceHealthReason =
|
||||
| "no_heartbeat"
|
||||
| "offline_threshold_exceeded"
|
||||
| "stale_threshold_exceeded"
|
||||
| "low_signal"
|
||||
| "low_battery";
|
||||
|
||||
export interface DeviceHeartbeatEntity {
|
||||
id: string;
|
||||
device_id: string;
|
||||
@ -171,3 +178,75 @@ export function deriveDeviceStatus(
|
||||
|
||||
return "online";
|
||||
}
|
||||
|
||||
export function deriveDeviceHealthSummary(
|
||||
input?: {
|
||||
last_seen_at?: string | null;
|
||||
network_strength?: number | null;
|
||||
battery_level?: number | null;
|
||||
}
|
||||
) {
|
||||
const now = Date.now();
|
||||
const lastSeen = Date.parse(input?.last_seen_at || "");
|
||||
const reasons: DeviceHealthReason[] = [];
|
||||
|
||||
if (!Number.isFinite(lastSeen)) {
|
||||
return {
|
||||
status: "offline" as DeviceHealthStatus,
|
||||
score: 0,
|
||||
age_seconds: null,
|
||||
reasons: ["no_heartbeat"] as DeviceHealthReason[]
|
||||
};
|
||||
}
|
||||
|
||||
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: DeviceHealthStatus = "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
|
||||
};
|
||||
}
|
||||
|
||||
@ -320,6 +320,21 @@ export async function listTransactions(filter?: {
|
||||
return rows.map(mapTransaction);
|
||||
}
|
||||
|
||||
export async function listDueDynamicQrTransactions(limit = 100): Promise<TransactionEntity[]> {
|
||||
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: string): Promise<TransactionEventEntity[]> {
|
||||
const { rows } = await getPool().query(
|
||||
"SELECT * FROM transaction_events WHERE transaction_id = $1 ORDER BY created_at ASC",
|
||||
|
||||
Reference in New Issue
Block a user