Continue phase 2 device ops and dynamic QR lifecycle
This commit is contained in:
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