TKK_E32232188/smartcollar-be/src/modules/monitoring/monitoring.service.js

550 lines
14 KiB
JavaScript

import AppError from "../../core/exceptions/AppError.js";
import * as collarRepository from "../collar/collar.repository.js";
import * as assignmentRepository from "../assignment/assignment.repository.js";
import * as monitoringRepository from "./monitoring.repository.js";
import * as authRepository from "../auth/auth.repository.js";
import notificationService from "../notification/notification.service.js";
import { buildMonitoring } from "../../shared/builders/monitoring.builder.js";
const MOVEMENT_LABELS = {
0: "Diam",
1: "Gerak",
2: "Lari",
};
/**
* Label aktivitas yang aman untuk nilai status apa pun.
* Konsisten dengan logika di frontend (lokasi.js/dashboard.js):
* status 0 = Diam, selain itu = Bergerak (dengan label lebih spesifik kalau dikenal).
*/
const getMovementLabel = (status) => {
if (status === 0 || typeof status === "undefined" || status === null) {
return MOVEMENT_LABELS[0];
}
return MOVEMENT_LABELS[status] ?? "Bergerak";
};
const TEMPERATURE_LABELS = {
0: "Normal",
1: "Peringatan",
2: "Bahaya",
};
/*
|--------------------------------------------------------------------------
| Konfigurasi Alert (bisa di-override lewat ENV, ada default)
|--------------------------------------------------------------------------
| IDLE_ALERT_THRESHOLD_HOURS : lama diam minimal sebelum dianggap tidak wajar
| TEMP_ALERT_COOLDOWN_HOURS : jarak minimal antar notif suhu bahaya
| (selama suhu masih bahaya terus-menerus)
| IDLE_ALERT_COOLDOWN_HOURS : jarak minimal antar notif diam
| (selama sapi masih diam terus-menerus)
*/
const hoursToMs = (hours) => hours * 60 * 60 * 1000;
const IDLE_ALERT_THRESHOLD_MS = hoursToMs(
Number(process.env.IDLE_ALERT_THRESHOLD_HOURS) || 24
);
// Jarak minimal antar penyimpanan ke history (menit), terpisah dari
// saveLatest() yang tetap realtime tiap request masuk dari device.
const HISTORY_INTERVAL_MS =
(Number(process.env.HISTORY_INTERVAL_MINUTES) || 5) * 60 * 1000;
const ALERT_COOLDOWN_MS = {
temperature: hoursToMs(Number(process.env.TEMP_ALERT_COOLDOWN_HOURS) || 3),
immobility: hoursToMs(Number(process.env.IDLE_ALERT_COOLDOWN_HOURS) || 6),
};
const formatTimestamp = (timestamp) => {
try {
return new Date(timestamp).toLocaleString("id-ID", {
timeZone: "Asia/Jakarta",
hour12: false,
dateStyle: "medium",
timeStyle: "short",
});
} catch {
return new Date(timestamp).toISOString();
}
};
const formatDuration = (ms) => {
const totalMinutes = Math.floor(ms / 60000);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours <= 0) {
return `${minutes} menit`;
}
return minutes > 0 ? `${hours} jam ${minutes} menit` : `${hours} jam`;
};
/**
* Hanya 2 kondisi yang bisa memicu notifikasi:
* 1) Suhu tubuh sangat tinggi (status "Bahaya")
* 2) Sapi diam (tidak bergerak) >= IDLE_ALERT_THRESHOLD_MS berturut-turut,
* sementara collar online (request ini datang langsung dari device).
*
* Catatan: fungsi ini hanya mengecek apakah KONDISI-nya terjadi.
* Keputusan apakah notif benar-benar dikirim (biar tidak spam) ada di
* sendMonitoringAlerts lewat pengecekan cooldown per tipe.
*/
const buildMonitoringAlerts = (monitoring, idleDurationMs) => {
const events = [];
if (monitoring.temperatureStatus === 2) {
events.push({
type: "temperature",
level: "Bahaya",
message: `Suhu tubuh sangat tinggi: ${monitoring.temperature}°C. Segera periksa kondisi sapi.`,
});
}
const isIdle = (monitoring.movement?.status ?? 0) === 0;
if (isIdle && typeof idleDurationMs === "number" && idleDurationMs >= IDLE_ALERT_THRESHOLD_MS) {
events.push({
type: "immobility",
level: "Peringatan",
message: `Tidak ada gerakan selama ${formatDuration(idleDurationMs)} berturut-turut, padahal collar dalam status online.`,
});
}
return events;
};
const buildAlertMessage = (owner, monitoring, events) => {
const time = formatTimestamp(monitoring.timestamp);
const location = monitoring.gps?.linkMaps
|| (monitoring.gps?.latitude && monitoring.gps?.longitude
? `https://www.google.com/maps/search/?api=1&query=${monitoring.gps.latitude},${monitoring.gps.longitude}`
: null);
const isDanger = events.some((event) => event.level === "Bahaya");
const severityIcon = isDanger ? "🔴" : "🟠";
const ownerName = owner?.nama || owner?.name || owner?.fullName;
const greeting = ownerName ? `Halo ${ownerName},` : "Halo,";
const eventLines = events
.map((event) => `${event.type === "temperature" ? "🌡️🔥" : "🐄⚠️"} *${event.level}* — ${event.message}`)
.join("\n");
const sensorLines = [
`🌡️ Suhu tubuh: *${monitoring.temperature}°C* (${TEMPERATURE_LABELS[monitoring.temperatureStatus] ?? "Tidak diketahui"})`,
`🏃 Aktivitas: *${getMovementLabel(monitoring.movement?.status)}*`,
`📶 Sinyal Collar: ${monitoring.signal ?? "-"}`,
`🔧 Device: ${monitoring.deviceName ?? "-"} (SN: ${monitoring.serialNumber ?? "-"})`,
].join("\n");
const locationBlock = location
? `📍 *Lokasi Terakhir:*\n${location}`
: `📍 *Lokasi Terakhir:*\nTidak tersedia`;
return (
`${severityIcon} *SMART COLLAR ALERT*\n` +
`${greeting} ada kondisi yang perlu perhatianmu.\n\n` +
`🐄 *Sapi:* ${monitoring.cowName} (${monitoring.cowCode ?? "-"})\n` +
`🆔 *Collar ID:* ${monitoring.collarId ?? "-"}\n\n` +
`*Peringatan Terdeteksi:*\n${eventLines}\n\n` +
`*Ringkasan Sensor Saat Ini:*\n${sensorLines}\n\n` +
`${locationBlock}\n\n` +
`⏰ *Waktu Kejadian:* ${time}\n\n` +
`Mohon segera dicek langsung ke lapangan untuk memastikan keamanan sapi. 🙏`
);
};
/**
* Kirim notifikasi WA kalau ada event yang lolos cooldown.
*
* Anti-spam: memanfaatkan notificationService.shouldSendAlert() yang SUDAH
* menyimpan waktu alert terakhir per (ownerId, cowId, alertType) di
* notificationRepository — cukup kirim cooldownMs khusus per tipe
* (ALERT_COOLDOWN_MS), bukan default 10 menit bawaannya.
*/
const sendMonitoringAlerts = async (ownerId, monitoring, idleDurationMs) => {
const user = await authRepository.findUserById(ownerId);
if (!user || !user.phone) {
return null;
}
const events = buildMonitoringAlerts(monitoring, idleDurationMs);
if (events.length === 0) {
return null;
}
const allowedEvents = [];
const suppressedEvents = [];
for (const event of events) {
const cooldownMs = ALERT_COOLDOWN_MS[event.type] ?? undefined;
const canSend = await notificationService.shouldSendAlert(
ownerId,
monitoring.cowId,
event.type,
cooldownMs
);
if (canSend) {
allowedEvents.push(event);
} else {
suppressedEvents.push(event);
}
}
if (allowedEvents.length === 0) {
for (const event of suppressedEvents) {
await notificationService.logNotification(
ownerId,
monitoring.cowId,
event.type,
user.phone,
buildAlertMessage(user, monitoring, [event]),
false,
null,
true
);
}
return null;
}
const message = buildAlertMessage(user, monitoring, allowedEvents);
try {
const result = await notificationService.sendWhatsApp(user.phone, message);
for (const event of allowedEvents) {
await notificationService.saveAlertSent(
ownerId,
monitoring.cowId,
event.type,
buildAlertMessage(user, monitoring, [event])
);
await notificationService.logNotification(
ownerId,
monitoring.cowId,
event.type,
user.phone,
buildAlertMessage(user, monitoring, [event]),
true,
null,
false
);
}
for (const event of suppressedEvents) {
await notificationService.logNotification(
ownerId,
monitoring.cowId,
event.type,
user.phone,
buildAlertMessage(user, monitoring, [event]),
false,
null,
true
);
}
return result;
} catch (err) {
console.error("[NOTIFICATION] Failed to send WhatsApp alert:", err.message || err);
for (const event of allowedEvents) {
await notificationService.logNotification(
ownerId,
monitoring.cowId,
event.type,
user.phone,
buildAlertMessage(user, monitoring, [event]),
false,
err?.message || err,
false
);
}
return null;
}
};
/**
* Monitoring dari ESP32
*/
export const receiveMonitoring = async (body) => {
const {
serialNumber,
deviceSecret,
device,
sensor,
} = body;
/*
|--------------------------------------------------------------------------
| Device Authentication
|--------------------------------------------------------------------------
*/
const collar = await collarRepository.findByDevice(
serialNumber,
deviceSecret
);
if (!collar) {
throw new AppError(
"Smart Collar not found",
401
);
}
/*
|--------------------------------------------------------------------------
| Resolve cow assignment
|--------------------------------------------------------------------------
*/
let currentCowId = collar.currentCowId;
let currentCowCode = collar.currentCowCode;
let currentCowName = collar.currentCowName;
let currentAssignmentId = collar.currentAssignmentId;
if (!currentCowId) {
const assignment = await assignmentRepository.findActiveByCollar(
collar.ownerId,
collar.id
);
if (!assignment) {
throw new AppError(
"Smart Collar is not assigned",
400
);
}
currentCowId = assignment.cowId;
currentCowCode = assignment.cowCode;
currentCowName = assignment.cowName;
currentAssignmentId = assignment.id;
}
/*
|--------------------------------------------------------------------------
| Timestamp
|--------------------------------------------------------------------------
*/
const timestamp = Date.now();
/*
|--------------------------------------------------------------------------
| Update Device Status
|--------------------------------------------------------------------------
*/
await collarRepository.touchOnline(
collar.ownerId,
collar.id,
{
signal: device.signal,
firmwareVersion:
device.firmwareVersion ?? null,
hardwareVersion:
device.hardwareVersion ?? null,
lastOnline: timestamp,
lastSync: timestamp,
}
);
/*
|--------------------------------------------------------------------------
| Build Monitoring Snapshot
|--------------------------------------------------------------------------
*/
const monitoring = buildMonitoring(
{
...collar,
currentCowId,
currentCowCode,
currentCowName,
currentAssignmentId,
},
sensor,
device,
timestamp
);
/*
|--------------------------------------------------------------------------
| Track Idle Duration (untuk alert "diam 24 jam")
|--------------------------------------------------------------------------
| movement.status: 0 = Diam, 1 = Gerak
| lastMovementAt di-update ke waktu sekarang setiap kali sapi bergerak.
| Kalau belum pernah tercatat (collar baru), anggap baru saja bergerak
| supaya tidak langsung false-alert saat pertama kali online.
*/
const isMovingNow = (monitoring.movement?.status ?? 0) !== 0;
const previousLastMovementAt = collar.lastMovementAt ?? timestamp;
const lastMovementAt = isMovingNow ? timestamp : previousLastMovementAt;
const idleDurationMs = timestamp - lastMovementAt;
/*
|--------------------------------------------------------------------------
| Save Latest (REALTIME — selalu update tiap request masuk dari device)
|--------------------------------------------------------------------------
*/
await monitoringRepository.saveLatest(
collar.ownerId,
currentCowId,
monitoring
);
/*
|--------------------------------------------------------------------------
| Save History (DI-THROTTLE — hanya disimpan tiap HISTORY_INTERVAL_MS,
| supaya tabel history tidak membanjir tiap 5 detik dari ESP32)
|--------------------------------------------------------------------------
*/
const previousLastHistoryAt = collar.lastHistoryAt ?? 0;
const shouldSaveHistory = (timestamp - previousLastHistoryAt) >= HISTORY_INTERVAL_MS;
if (shouldSaveHistory) {
await monitoringRepository.saveHistory(
collar.ownerId,
currentCowId,
monitoring
);
}
const lastHistoryAt = shouldSaveHistory ? timestamp : previousLastHistoryAt;
/*
|--------------------------------------------------------------------------
| Persist state collar (assignment, lastMovementAt)
|--------------------------------------------------------------------------
*/
await collarRepository.update(
collar.ownerId,
collar.id,
{
currentCowId,
currentCowCode,
currentCowName,
currentAssignmentId,
lastMovementAt,
lastHistoryAt,
}
);
/*
|--------------------------------------------------------------------------
| Alert (anti-spam per tipe ditangani oleh notificationService, lihat
| cooldownMs khusus per tipe di ALERT_COOLDOWN_MS)
|--------------------------------------------------------------------------
*/
await sendMonitoringAlerts(
collar.ownerId,
monitoring,
idleDurationMs
);
return monitoring;
};
/**
* Monitoring realtime semua sapi
*/
export const getLatest = async (ownerId) => {
const latest =
await monitoringRepository.getLatest(ownerId);
if (!latest) {
return {};
}
return latest;
};
/**
* Monitoring realtime satu sapi
*/
export const getLatestByCow = async (
ownerId,
cowId
) => {
let monitoring =
await monitoringRepository.getLatestByCow(
ownerId,
cowId
);
if (!monitoring) {
const latest =
await monitoringRepository.getLatest(ownerId);
if (latest && typeof latest === "object") {
monitoring = Object.values(latest).find(
(item) => item && item.cowId === cowId
);
}
}
if (!monitoring) {
throw new AppError(
"Monitoring not found",
404
);
}
return monitoring;
};
/**
* History Monitoring
*/
export const getHistory = async (
ownerId,
cowId,
query
) => {
const {
year,
month,
day,
} = query;
return await monitoringRepository.getHistoryByDay(
ownerId,
cowId,
year,
month,
day
);
};