Continue phase 2 device ops and dynamic QR lifecycle

This commit is contained in:
2026-05-26 21:25:07 +07:00
parent 5624b92872
commit e0b8f9af9a
22 changed files with 1050 additions and 92 deletions

View File

@ -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,

View File

@ -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)));
});

View File

@ -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;

View 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"
};
}

View 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
};
}

View File

@ -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 };
}

View File

@ -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
};
}

View File

@ -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",