550 lines
14 KiB
JavaScript
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
|
|
);
|
|
|
|
}; |