361 lines
15 KiB
JavaScript
361 lines
15 KiB
JavaScript
import { Router } from "express";
|
|
import { ApiError } from "../shared/errors";
|
|
import { requireDeviceToken } from "../shared/middleware/auth";
|
|
import { successResponse } from "../shared/middleware/errorMiddleware";
|
|
import { getDeviceById, patchDevice } from "../shared/store/deviceStore";
|
|
import { createDeviceHeartbeat } from "../shared/store/heartbeatStore";
|
|
import { acknowledgeDeviceCommand } from "../shared/store/deviceCommandStore";
|
|
import { getActiveBindingByDevice } from "../shared/store/bindingStore";
|
|
import { getTerminalById } from "../shared/store/locationStore";
|
|
import { readIdempotency, writeIdempotency } from "../shared/idempotency/idempotencyStore";
|
|
import { env } from "../config/env";
|
|
import { supportsDynamicQrFlow } from "../shared/services/deviceCapabilityResolver";
|
|
import { createDynamicQrTransaction } from "../shared/services/dynamicQrOrchestrator";
|
|
import { createMqttMessage } from "../shared/store/mqttMessageStore";
|
|
import { publishDynamicQrResponse } from "../shared/services/mqttPublisher";
|
|
import { createDeviceConfigAck, getOrCreateDeviceConfig, toDeviceConfigAckPayload, toDeviceConfigPayload } from "../shared/store/deviceConfigStore";
|
|
const router = Router();
|
|
function normalizeNumberOrNull(value) {
|
|
if (typeof value === "string") {
|
|
const parsed = Number(value);
|
|
if (!Number.isNaN(parsed) && Number.isFinite(parsed)) {
|
|
return parsed;
|
|
}
|
|
return null;
|
|
}
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
return value;
|
|
}
|
|
return null;
|
|
}
|
|
function normalizePositiveAmount(value) {
|
|
const normalized = normalizeNumberOrNull(value);
|
|
if (normalized === null || normalized <= 0) {
|
|
return null;
|
|
}
|
|
return normalized;
|
|
}
|
|
function normalizeTtl(value) {
|
|
if (value === undefined || value === null || value === "") {
|
|
return undefined;
|
|
}
|
|
const normalized = normalizeNumberOrNull(value);
|
|
if (normalized === null || normalized <= 0) {
|
|
return undefined;
|
|
}
|
|
return normalized;
|
|
}
|
|
function normalizePositiveInteger(value) {
|
|
const normalized = normalizeNumberOrNull(value);
|
|
if (normalized === null || normalized <= 0 || !Number.isInteger(normalized)) {
|
|
return null;
|
|
}
|
|
return normalized;
|
|
}
|
|
function normalizeSignalStrength(value) {
|
|
const normalized = normalizeNumberOrNull(value);
|
|
if (normalized === null) {
|
|
return null;
|
|
}
|
|
if (normalized < 0 || normalized > 100) {
|
|
throw new Error("NETWORK_STRENGTH_OUT_OF_RANGE");
|
|
}
|
|
return normalized;
|
|
}
|
|
function normalizeBatteryLevel(value) {
|
|
const normalized = normalizeNumberOrNull(value);
|
|
if (normalized === null) {
|
|
return null;
|
|
}
|
|
if (normalized < 0 || normalized > 100) {
|
|
throw new Error("BATTERY_LEVEL_OUT_OF_RANGE");
|
|
}
|
|
return normalized;
|
|
}
|
|
router.post("/heartbeat", requireDeviceToken, async (req, res, next) => {
|
|
const payload = req.body;
|
|
if (!payload || !payload.device_id) {
|
|
return next(new ApiError("BAD_REQUEST", "device_id is required", 400));
|
|
}
|
|
const device = await getDeviceById(payload.device_id);
|
|
if (!device) {
|
|
return next(new ApiError("NOT_FOUND", "device not found", 404));
|
|
}
|
|
const eventTs = payload.timestamp ? new Date(payload.timestamp) : new Date();
|
|
if (Number.isNaN(eventTs.getTime())) {
|
|
return next(new ApiError("BAD_REQUEST", "timestamp must be valid ISO datetime", 400));
|
|
}
|
|
let payloadNetworkStrength;
|
|
let payloadBattery;
|
|
try {
|
|
payloadNetworkStrength = normalizeSignalStrength(payload.network_strength);
|
|
payloadBattery = normalizeBatteryLevel(payload.battery_level);
|
|
}
|
|
catch (error) {
|
|
if (error instanceof Error && error.message === "NETWORK_STRENGTH_OUT_OF_RANGE") {
|
|
return next(new ApiError("BAD_REQUEST", "network_strength must be between 0 and 100", 400));
|
|
}
|
|
if (error instanceof Error && error.message === "BATTERY_LEVEL_OUT_OF_RANGE") {
|
|
return next(new ApiError("BAD_REQUEST", "battery_level must be between 0 and 100", 400));
|
|
}
|
|
return next(error);
|
|
}
|
|
const heartbeat = await createDeviceHeartbeat({
|
|
device_id: payload.device_id,
|
|
timestamp: eventTs.toISOString(),
|
|
firmware_version: payload.firmware_version,
|
|
network_strength: payloadNetworkStrength,
|
|
battery_level: payloadBattery,
|
|
state: payload.state,
|
|
payload_json: {
|
|
network_strength_raw: payload.network_strength,
|
|
battery_level_raw: payload.battery_level,
|
|
state: payload.state,
|
|
firmware_version: payload.firmware_version,
|
|
timestamp: payload.timestamp,
|
|
request_id: req.requestId
|
|
}
|
|
});
|
|
await patchDevice(payload.device_id, {
|
|
last_seen_at: heartbeat.timestamp,
|
|
firmware_version: payload.firmware_version || device.firmware_version
|
|
});
|
|
res.json(successResponse(req, {
|
|
heartbeat_id: heartbeat.id,
|
|
device_id: heartbeat.device_id,
|
|
request_id: req.requestId,
|
|
server_time: heartbeat.received_at
|
|
}));
|
|
});
|
|
router.post("/commands/ack", requireDeviceToken, async (req, res, next) => {
|
|
const payload = req.body;
|
|
if (!payload || !payload.command_id || !payload.device_id || !payload.status) {
|
|
return next(new ApiError("BAD_REQUEST", "command_id, device_id, status are required", 400));
|
|
}
|
|
if (!["delivered", "failed", "timeout"].includes(payload.status)) {
|
|
return next(new ApiError("BAD_REQUEST", "status must be delivered, failed, or timeout", 400));
|
|
}
|
|
const device = await getDeviceById(payload.device_id);
|
|
if (!device) {
|
|
return next(new ApiError("NOT_FOUND", "device not found", 404));
|
|
}
|
|
const updated = await acknowledgeDeviceCommand({
|
|
device_id: device.id,
|
|
command_id: payload.command_id,
|
|
status: payload.status,
|
|
reason: payload.reason,
|
|
result_payload: payload.result_payload
|
|
});
|
|
if (!updated) {
|
|
return next(new ApiError("NOT_FOUND", "command not found", 404));
|
|
}
|
|
res.json(successResponse(req, {
|
|
command_id: updated.id,
|
|
device_id: updated.device_id,
|
|
status: updated.status,
|
|
acknowledged_at: updated.acknowledged_at
|
|
}));
|
|
});
|
|
router.post("/transactions/dynamic-qr", requireDeviceToken, async (req, res, next) => {
|
|
const payload = req.body;
|
|
if (!payload || !payload.device_id || !payload.terminal_id || !payload.request_id) {
|
|
return next(new ApiError("BAD_REQUEST", "device_id, terminal_id, request_id are required", 400));
|
|
}
|
|
const amount = normalizePositiveAmount(payload.amount);
|
|
if (amount === null) {
|
|
return next(new ApiError("INVALID_AMOUNT", "amount must be a positive number", 400));
|
|
}
|
|
const currency = payload.currency && payload.currency.trim() ? payload.currency.trim().toUpperCase() : "IDR";
|
|
if (currency !== "IDR") {
|
|
return next(new ApiError("BAD_REQUEST", "currency must be IDR for QRIS dynamic MVP", 400));
|
|
}
|
|
const idempotencyKey = req.header("Idempotency-Key") || payload.request_id;
|
|
const cached = readIdempotency("device.dynamic_qr.create", idempotencyKey);
|
|
if (cached) {
|
|
return res.json(successResponse(req, cached.data ?? cached));
|
|
}
|
|
const device = await getDeviceById(payload.device_id);
|
|
if (!device) {
|
|
return next(new ApiError("NOT_FOUND", "device not found", 404));
|
|
}
|
|
if (device.status !== "active") {
|
|
return next(new ApiError("DEVICE_CAPABILITY_NOT_SUPPORTED", "inactive device cannot create dynamic QR", 400));
|
|
}
|
|
if (!supportsDynamicQrFlow(device, "api_direct")) {
|
|
return next(new ApiError("DEVICE_CAPABILITY_NOT_SUPPORTED", "device does not support API-direct dynamic QR", 400));
|
|
}
|
|
const terminal = await getTerminalById(payload.terminal_id);
|
|
if (!terminal) {
|
|
return next(new ApiError("NOT_FOUND", "terminal not found", 404));
|
|
}
|
|
if (terminal.qr_mode !== "dynamic_api") {
|
|
return next(new ApiError("DEVICE_CAPABILITY_NOT_SUPPORTED", "terminal is not configured for API dynamic QR", 400));
|
|
}
|
|
const binding = await getActiveBindingByDevice(device.id);
|
|
if (!binding || binding.terminal_id !== terminal.id) {
|
|
return next(new ApiError("DEVICE_NOT_BOUND", "device is not actively bound to requested terminal", 400));
|
|
}
|
|
const created = await createDynamicQrTransaction({
|
|
request_id: payload.request_id,
|
|
device_id: device.id,
|
|
merchant_id: binding.merchant_id,
|
|
outlet_id: binding.outlet_id,
|
|
terminal_id: binding.terminal_id,
|
|
amount,
|
|
currency,
|
|
expires_in_seconds: normalizeTtl(payload.expires_in_seconds)
|
|
});
|
|
const responseData = {
|
|
...created,
|
|
request_id: payload.request_id
|
|
};
|
|
writeIdempotency("device.dynamic_qr.create", idempotencyKey, { data: responseData }, env.IDEMPOTENCY_TTL_MS);
|
|
res.status(201).json(successResponse(req, responseData));
|
|
});
|
|
router.post("/mqtt/uplink/dynamic-qr/request", requireDeviceToken, async (req, res, next) => {
|
|
const payload = req.body;
|
|
if (!payload || !payload.device_id || !payload.terminal_id || !payload.request_id) {
|
|
return next(new ApiError("BAD_REQUEST", "device_id, terminal_id, request_id are required", 400));
|
|
}
|
|
if (payload.message_type && payload.message_type !== "dynamic_qr_request") {
|
|
return next(new ApiError("BAD_REQUEST", "message_type must be dynamic_qr_request", 400));
|
|
}
|
|
const amount = normalizePositiveAmount(payload.amount);
|
|
if (amount === null) {
|
|
return next(new ApiError("INVALID_AMOUNT", "amount must be a positive number", 400));
|
|
}
|
|
const currency = payload.currency && payload.currency.trim() ? payload.currency.trim().toUpperCase() : "IDR";
|
|
if (currency !== "IDR") {
|
|
return next(new ApiError("BAD_REQUEST", "currency must be IDR for QRIS dynamic MVP", 400));
|
|
}
|
|
const cached = readIdempotency("device.dynamic_qr.mqtt", payload.request_id);
|
|
if (cached) {
|
|
return res.json(successResponse(req, cached.data ?? cached));
|
|
}
|
|
const device = await getDeviceById(payload.device_id);
|
|
if (!device) {
|
|
return next(new ApiError("NOT_FOUND", "device not found", 404));
|
|
}
|
|
if (device.status !== "active" || !supportsDynamicQrFlow(device, "mqtt")) {
|
|
return next(new ApiError("DEVICE_CAPABILITY_NOT_SUPPORTED", "device does not support MQTT dynamic QR", 400));
|
|
}
|
|
const terminal = await getTerminalById(payload.terminal_id);
|
|
if (!terminal) {
|
|
return next(new ApiError("NOT_FOUND", "terminal not found", 404));
|
|
}
|
|
if (terminal.qr_mode !== "dynamic_mqtt") {
|
|
return next(new ApiError("DEVICE_CAPABILITY_NOT_SUPPORTED", "terminal is not configured for MQTT dynamic QR", 400));
|
|
}
|
|
const binding = await getActiveBindingByDevice(device.id);
|
|
if (!binding || binding.terminal_id !== terminal.id) {
|
|
return next(new ApiError("DEVICE_NOT_BOUND", "device is not actively bound to requested terminal", 400));
|
|
}
|
|
await createMqttMessage({
|
|
direction: "uplink",
|
|
device_id: device.id,
|
|
topic: `devices/${device.id}/uplink/dynamic-qr/request`,
|
|
message_type: "dynamic_qr_request",
|
|
correlation_id: payload.request_id,
|
|
payload_json: {
|
|
...payload,
|
|
amount,
|
|
currency
|
|
}
|
|
});
|
|
const created = await createDynamicQrTransaction({
|
|
request_id: payload.request_id,
|
|
device_id: device.id,
|
|
merchant_id: binding.merchant_id,
|
|
outlet_id: binding.outlet_id,
|
|
terminal_id: binding.terminal_id,
|
|
amount,
|
|
currency,
|
|
expires_in_seconds: normalizeTtl(payload.expires_in_seconds),
|
|
initiation_mode: "dynamic_mqtt"
|
|
});
|
|
const mqttPayload = {
|
|
message_type: "dynamic_qr_response",
|
|
correlation_id: payload.request_id,
|
|
transaction_id: created.transaction_id,
|
|
status: "success",
|
|
qr_payload: created.qr_payload,
|
|
expires_at: created.expires_at
|
|
};
|
|
const publishResult = await publishDynamicQrResponse(device.id, mqttPayload);
|
|
const outbox = await createMqttMessage({
|
|
direction: "downlink",
|
|
device_id: device.id,
|
|
topic: publishResult.topic,
|
|
message_type: "dynamic_qr_response",
|
|
correlation_id: payload.request_id,
|
|
payload_json: mqttPayload,
|
|
publish_status: publishResult.ok ? "sent" : "failed",
|
|
reason: publishResult.reason
|
|
});
|
|
const responseData = {
|
|
correlation_id: payload.request_id,
|
|
transaction_id: created.transaction_id,
|
|
status: "success",
|
|
qr_payload: created.qr_payload,
|
|
expires_at: created.expires_at,
|
|
downlink_message_id: outbox.id,
|
|
publish_status: outbox.publish_status,
|
|
partner_reference: created.partner_reference
|
|
};
|
|
writeIdempotency("device.dynamic_qr.mqtt", payload.request_id, { data: responseData }, env.IDEMPOTENCY_TTL_MS);
|
|
res.status(201).json(successResponse(req, responseData));
|
|
});
|
|
router.get("/config", requireDeviceToken, async (req, res, next) => {
|
|
const deviceId = req.query.device_id || req.body?.device_id;
|
|
if (!deviceId) {
|
|
return next(new ApiError("BAD_REQUEST", "device_id is required", 400));
|
|
}
|
|
const device = await getDeviceById(deviceId);
|
|
if (!device) {
|
|
return next(new ApiError("NOT_FOUND", "device not found", 404));
|
|
}
|
|
const config = await getOrCreateDeviceConfig(device.id);
|
|
res.json(successResponse(req, toDeviceConfigPayload(config)));
|
|
});
|
|
router.post("/config/ack", requireDeviceToken, async (req, res, next) => {
|
|
const payload = req.body;
|
|
if (!payload || !payload.device_id || !payload.status) {
|
|
return next(new ApiError("BAD_REQUEST", "device_id, status are required", 400));
|
|
}
|
|
if (!["applied", "failed"].includes(payload.status)) {
|
|
return next(new ApiError("BAD_REQUEST", "status must be applied or failed", 400));
|
|
}
|
|
const configVersion = normalizePositiveInteger(payload.config_version);
|
|
if (configVersion === null) {
|
|
return next(new ApiError("BAD_REQUEST", "config_version must be a positive integer", 400));
|
|
}
|
|
const device = await getDeviceById(payload.device_id);
|
|
if (!device) {
|
|
return next(new ApiError("NOT_FOUND", "device not found", 404));
|
|
}
|
|
const ack = await createDeviceConfigAck({
|
|
device_id: device.id,
|
|
config_version: configVersion,
|
|
status: payload.status,
|
|
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;
|