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

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

View File

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

View File

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