Initial commit

This commit is contained in:
Hilmania Zahra 2026-07-20 16:21:52 +07:00
commit f09a282153
124 changed files with 17831 additions and 0 deletions

View File

@ -0,0 +1,623 @@
/*
* ======================================================
* SMART COLLAR FIRMWARE
* ------------------------------------------------------
* Kirim data sensor (suhu, gerak, GPS, sinyal) ke backend
* tiap 1 detik. Prioritas kirim lewat SIM800L (GPRS),
* fallback otomatis ke WiFi kalau SIM800L gagal.
* ======================================================
*/
#define TINY_GSM_MODEM_SIM800
#include <Wire.h>
#include <Adafruit_MLX90614.h>
#include <Adafruit_MPU6050.h>
#include <Adafruit_Sensor.h>
#include <TinyGPS++.h>
#include <HardwareSerial.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <time.h>
#include <sys/time.h>
#include <TinyGsmClient.h>
#include <ArduinoHttpClient.h>
// Install lewat Library Manager: "ArduinoJson" by Benoit Blanchon
#include <ArduinoJson.h>
// ======================================================
// PIN CONFIG
// ======================================================
#define SDA_PIN 21
#define SCL_PIN 22
#define GPS_RX 16
#define GPS_TX 17
#define SIM_RX 13
#define SIM_TX 12
// ======================================================
// WIFI CONFIG
// ======================================================
const char* WIFI_SSID = "oke";
const char* WIFI_PASSWORD = "12345678";
// ======================================================
// GPRS CONFIG
// ======================================================
const char APN[] = "internet";
// ======================================================
// IDENTITAS DEVICE
// Harus sama persis dengan yang terdaftar di backend.
// ======================================================
const char* SERIAL_NUMBER = "SC001";
const char* DEVICE_SECRET = "eefda1ae-ceb8-47da-b89e-c8be0d6543f2";
const char* FIRMWARE_VERSION = "1.0.0";
const char* HARDWARE_VERSION = "1.0.0";
// ======================================================
// BACKEND CONFIG
// ======================================================
const char* WIFI_BASE_URL = "https://api.smartcollar.my.id";
const char* MONITORING_PATH = "/api/v1/monitoring";
// Host SIM800L TANPA "http://"/"https://".
// Domain KHUSUS untuk jalur SIM800L (backend terpisah dari WiFi),
// jadi harus support plain HTTP di port 80 -- SIM800L via
// TinyGsmClient/HttpClient tidak bisa TLS/HTTPS.
const char GSM_SERVER[] = "sim.smartcollar.my.id";
const int GSM_PORT = 80;
// ======================================================
// KALIBRASI SENSOR
// ======================================================
const float TEMP_OFFSET = 2.0;
// ======================================================
// NTP CONFIG (fallback kalau device sedang pakai WiFi)
// ======================================================
const char* NTP_SERVER_1 = "pool.ntp.org";
const char* NTP_SERVER_2 = "time.google.com";
const long GMT_OFFSET_SEC = 7 * 3600; // WIB = UTC+7
const int DAYLIGHT_OFFSET_SEC = 0;
const unsigned long NTP_SYNC_TIMEOUT_MS = 10000;
// ======================================================
// TIMING
// ======================================================
const unsigned long SEND_INTERVAL_MS = 1000; // kirim tiap 1 detik
const unsigned long WIFI_CONNECT_TIMEOUT_MS = 15000;
const unsigned long WIFI_RECONNECT_COOLDOWN_MS = 5000;
// ======================================================
// GLOBAL OBJECTS
// ======================================================
Adafruit_MLX90614 mlx;
Adafruit_MPU6050 mpu;
TinyGPSPlus gps;
HardwareSerial SerialGPS(2);
HardwareSerial SerialSIM(1);
TinyGsm modem(SerialSIM);
TinyGsmClient gsmClient(modem);
HttpClient gsmHttp(gsmClient, GSM_SERVER, GSM_PORT);
const unsigned long WIFI_HTTP_TIMEOUT_MS = 10000;
// ======================================================
// GLOBAL STATE
// ======================================================
float baseAccelX = 0;
bool simConnected = false;
unsigned long lastSendMillis = 0;
unsigned long lastWifiReconnectAttempt = 0;
// ======================================================
// STRUCT DATA SENSOR (biar gampang dibaca & di-passing)
// ======================================================
struct SensorReading {
float temperature;
float accelX;
double latitude;
double longitude;
String linkMaps;
int signalStrength;
};
// ======================================================
// WIFI
// ======================================================
void connectWiFi() {
Serial.println();
Serial.println("CONNECT WIFI");
WiFi.mode(WIFI_STA);
WiFi.setSleep(false); // cegah ESP32 disconnect random akibat modem-sleep
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
unsigned long startAttempt = millis();
while (WiFi.status() != WL_CONNECTED &&
millis() - startAttempt < WIFI_CONNECT_TIMEOUT_MS) {
delay(500);
Serial.print(".");
}
Serial.println();
if (WiFi.status() == WL_CONNECTED) {
Serial.print("WIFI CONNECTED, IP: ");
Serial.println(WiFi.localIP());
} else {
Serial.println("WIFI FAILED");
}
}
// Reconnect non-blocking, dipanggil tiap loop() supaya WiFi yang
// putus di tengah jalan langsung dicoba nyambung lagi di background
// tanpa nge-block pembacaan sensor/GPS.
void reconnectWiFiIfNeeded() {
if (WiFi.status() == WL_CONNECTED) {
return;
}
unsigned long now = millis();
if (now - lastWifiReconnectAttempt < WIFI_RECONNECT_COOLDOWN_MS) {
return;
}
lastWifiReconnectAttempt = now;
Serial.println();
Serial.println("WIFI DISCONNECTED -> RECONNECT (background)");
WiFi.reconnect();
}
// ======================================================
// SIM800L / GPRS
// ======================================================
bool connectSIM800() {
Serial.println();
Serial.println("INIT SIM800L");
SerialSIM.begin(9600, SERIAL_8N1, SIM_RX, SIM_TX);
delay(3000);
if (!modem.restart()) {
Serial.println("SIM800 NOT RESPOND");
return false;
}
Serial.println("SIM800 OK");
if (!modem.waitForNetwork()) {
Serial.println("NETWORK FAILED");
return false;
}
Serial.println("NETWORK OK");
if (!modem.gprsConnect(APN, "", "")) {
Serial.println("GPRS FAILED");
return false;
}
Serial.print("GPRS CONNECTED, IP: ");
Serial.println(modem.localIP());
return true;
}
void reconnectSIM800() {
if (!modem.isNetworkConnected()) {
Serial.println();
Serial.println("NETWORK LOST");
if (modem.waitForNetwork()) {
Serial.println("NETWORK RECONNECTED");
} else {
Serial.println("NETWORK FAILED");
}
}
if (!modem.isGprsConnected()) {
Serial.println();
Serial.println("GPRS LOST");
if (modem.gprsConnect(APN, "", "")) {
Serial.println("GPRS RECONNECTED");
simConnected = true;
} else {
Serial.println("GPRS FAILED");
simConnected = false;
}
}
}
// ======================================================
// SYNC WAKTU (biar field "uptime" isinya Unix timestamp asli,
// sama seperti Date.now() di simulator -- bukan sekadar detik
// sejak boot / millis())
//
// CATATAN: sync waktu dari modem (AT+CCLK) SEMENTARA DINONAKTIFKAN
// karena gak semua provider SIM support & berisiko bikin proses
// nyangkut kalau modem/network gak respon sesuai harapan. Uptime
// akan fallback ke millis() kalau device cuma pakai SIM800L tanpa
// WiFi sama sekali -- gak ideal, tapi jauh lebih aman/stabil dulu
// sampai koneksi SIM800L ke backend beres.
// ======================================================
// Sync lewat NTP (butuh WiFi aktif)
bool syncTimeFromNTP() {
Serial.println();
Serial.println("SYNC WAKTU (NTP)...");
configTime(GMT_OFFSET_SEC, DAYLIGHT_OFFSET_SEC, NTP_SERVER_1, NTP_SERVER_2);
unsigned long startAttempt = millis();
time_t now = time(nullptr);
while (now < 1700000000 && millis() - startAttempt < NTP_SYNC_TIMEOUT_MS) {
delay(300);
Serial.print(".");
now = time(nullptr);
}
Serial.println();
if (now >= 1700000000) {
Serial.print("WAKTU TERSINKRON DARI NTP: ");
Serial.println(now);
return true;
}
Serial.println("NTP SYNC GAGAL");
return false;
}
// Coba sync waktu lewat NTP kalau WiFi aktif. Kalau device murni
// pakai SIM800L tanpa WiFi, uptime fallback ke millis() (lihat
// getUnixTimestamp() di bawah).
void syncTimeIfPossible() {
if (WiFi.status() == WL_CONNECTED) {
syncTimeFromNTP();
} else {
Serial.println();
Serial.println("WIFI TIDAK AKTIF -- uptime pakai fallback millis()");
}
}
// Ambil Unix timestamp (detik sejak epoch), sama format dengan
// Math.floor(Date.now() / 1000) di simulator.
unsigned long getUnixTimestamp() {
time_t now = time(nullptr);
if (now >= 1700000000) {
return (unsigned long)now;
}
// Fallback kalau waktu belum/gagal sync sama sekali
return millis() / 1000;
}
// ======================================================
// SENSOR SETUP
// ======================================================
void setupMPU() {
mpu.setAccelerometerRange(MPU6050_RANGE_2_G);
mpu.setFilterBandwidth(MPU6050_BAND_21_HZ);
}
void calibrateMPU() {
sensors_event_t a, g, t;
mpu.getEvent(&a, &g, &t);
baseAccelX = a.acceleration.x;
}
// ======================================================
// SIGNAL STRENGTH
// ======================================================
int getSignalStrength() {
if (WiFi.status() == WL_CONNECTED) {
return WiFi.RSSI();
}
if (simConnected) {
int csq = modem.getSignalQuality();
return (csq == 99) ? -113 : (-113 + csq * 2);
}
return 0;
}
// ======================================================
// BACA SEMUA SENSOR
// ======================================================
SensorReading readSensors() {
SensorReading reading;
// Suhu
float rawTemp = mlx.readObjectTempC();
reading.temperature = isnan(rawTemp) ? 0 : rawTemp + TEMP_OFFSET;
// Gerak (accelX mentah, status dihitung di backend)
sensors_event_t a, g, t;
mpu.getEvent(&a, &g, &t);
reading.accelX = a.acceleration.x - baseAccelX;
// GPS
reading.latitude = 0;
reading.longitude = 0;
reading.linkMaps = "-";
if (gps.location.isValid()) {
reading.latitude = gps.location.lat();
reading.longitude = gps.location.lng();
reading.linkMaps = "https://www.google.com/maps?q=" +
String(reading.latitude, 6) + "," +
String(reading.longitude, 6);
}
// Sinyal
reading.signalStrength = getSignalStrength();
return reading;
}
// ======================================================
// BANGUN PAYLOAD JSON
// Struktur & nama field disamakan persis dengan simulator.
// ======================================================
String buildPayload(const SensorReading& reading) {
StaticJsonDocument<512> doc;
doc["serialNumber"] = SERIAL_NUMBER;
doc["deviceSecret"] = DEVICE_SECRET;
JsonObject device = doc.createNestedObject("device");
device["signal"] = reading.signalStrength;
device["firmwareVersion"] = FIRMWARE_VERSION;
device["hardwareVersion"] = HARDWARE_VERSION;
device["uptime"] = getUnixTimestamp();
JsonObject sensor = doc.createNestedObject("sensor");
sensor["temperature"] = round(reading.temperature * 100) / 100.0;
JsonObject gpsObj = sensor.createNestedObject("gps");
gpsObj["latitude"] = reading.latitude;
gpsObj["longitude"] = reading.longitude;
gpsObj["linkMaps"] = reading.linkMaps;
JsonObject movement = sensor.createNestedObject("movement");
movement["accelX"] = round(reading.accelX * 100) / 100.0;
String payload;
serializeJson(doc, payload);
if (doc.overflowed()) {
Serial.println();
Serial.println("WARNING: JSON BUFFER OVERFLOW! Payload mungkin kepotong, perbesar StaticJsonDocument.");
}
return payload;
}
// ======================================================
// KIRIM VIA SIM800L
// return true kalau berhasil (statusCode > 0)
// ======================================================
bool sendViaSIM800(const String& payload) {
Serial.println();
Serial.println("---- KIRIM VIA SIM800L ----");
Serial.print("Host : ");
Serial.println(GSM_SERVER);
Serial.print("Path : ");
Serial.println(MONITORING_PATH);
Serial.print("Payload: ");
Serial.println(payload);
gsmHttp.beginRequest();
gsmHttp.post(MONITORING_PATH);
gsmHttp.sendHeader("Content-Type", "application/json");
gsmHttp.sendHeader("Content-Length", payload.length());
gsmHttp.beginBody();
gsmHttp.print(payload);
gsmHttp.endRequest();
int statusCode = gsmHttp.responseStatusCode();
String response = gsmHttp.responseBody();
Serial.print("Status Code : ");
Serial.println(statusCode);
Serial.print("Response : ");
Serial.println(response.length() > 0 ? response : "(kosong)");
return statusCode > 0;
}
// ======================================================
// KIRIM VIA WIFI (HTTPS)
// ======================================================
bool sendViaWiFi(const String& payload) {
if (WiFi.status() != WL_CONNECTED) {
connectWiFi();
}
if (WiFi.status() != WL_CONNECTED) {
Serial.println();
Serial.println("NO INTERNET (WiFi gagal connect)");
return false;
}
String url = String(WIFI_BASE_URL) + String(MONITORING_PATH);
Serial.println();
Serial.println("---- KIRIM VIA WIFI ----");
Serial.print("URL : ");
Serial.println(url);
Serial.print("Payload: ");
Serial.println(payload);
// ESP32 HTTPClient wajib pakai WiFiClientSecure untuk https,
// kalau tidak, request bisa gagal diam-diam (httpCode negatif).
WiFiClientSecure secureClient;
secureClient.setInsecure();
secureClient.setTimeout(WIFI_HTTP_TIMEOUT_MS);
HTTPClient http;
http.setTimeout(WIFI_HTTP_TIMEOUT_MS);
http.setConnectTimeout(WIFI_HTTP_TIMEOUT_MS);
http.begin(secureClient, url);
http.addHeader("Content-Type", "application/json");
int httpCode = http.POST(payload);
Serial.print("HTTP Code : ");
Serial.println(httpCode);
bool success = httpCode > 0 && httpCode < 400;
if (httpCode > 0) {
String body = http.getString();
Serial.print("Response : ");
Serial.println(body.length() > 0 ? body : "(kosong)");
} else {
Serial.print("HTTP Error: ");
Serial.println(http.errorToString(httpCode));
}
http.end();
return success;
}
// ======================================================
// KIRIM DATA — PRIORITAS SIM800L, FALLBACK WIFI
// ======================================================
void sendToBackend(const String& payload) {
bool success = false;
if (simConnected) {
success = sendViaSIM800(payload);
if (!success) {
Serial.println();
Serial.println("SIM800L GAGAL -> COBA WIFI");
}
}
if (!success) {
success = sendViaWiFi(payload);
}
Serial.println();
if (success) {
Serial.println("===== DATA BERHASIL TERKIRIM =====");
} else {
Serial.println("===== DATA GAGAL TERKIRIM (cek koneksi/backend) =====");
}
}
// ======================================================
// SETUP
// ======================================================
void setup() {
Serial.begin(115200);
delay(3000);
Serial.println();
Serial.println("SMART COLLAR START");
Serial.print("Serial Number : ");
Serial.println(SERIAL_NUMBER);
// Koneksi: prioritas SIM800L, fallback WiFi
simConnected = connectSIM800();
if (!simConnected) {
connectWiFi();
}
syncTimeIfPossible();
// I2C + sensor
Wire.begin(SDA_PIN, SCL_PIN);
Serial.println(mlx.begin() ? "MLX OK" : "MLX FAILED");
if (mpu.begin()) {
Serial.println("MPU OK");
setupMPU();
calibrateMPU();
} else {
Serial.println("MPU FAILED");
}
// GPS
SerialGPS.begin(9600, SERIAL_8N1, GPS_RX, GPS_TX);
Serial.println("GPS READY");
}
// ======================================================
// LOOP
// ======================================================
void loop() {
reconnectSIM800();
if (!simConnected) {
reconnectWiFiIfNeeded();
}
// Update data GPS terus-menerus (non-blocking)
while (SerialGPS.available()) {
gps.encode(SerialGPS.read());
}
unsigned long currentMillis = millis();
if (currentMillis - lastSendMillis < SEND_INTERVAL_MS) {
return;
}
lastSendMillis = currentMillis;
SensorReading reading = readSensors();
String payload = buildPayload(reading);
Serial.println();
Serial.println(payload);
sendToBackend(payload);
}

View File

@ -0,0 +1,21 @@
# Server
PORT=3000
# Firebase
FIREBASE_PROJECT_ID=monitoring-7cb63
FIREBASE_CLIENT_EMAIL=your-service-account-email@.iam.gserviceaccount.com
FIREBASE_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nYOUR_PRIVATE_KEY_HERE\n-----END PRIVATE KEY-----
# JWT (still kept for compatibility but Firebase auth is primary)
JWT_SECRET=
# Fonte
FONTE_TOKEN=
# Application
NODE_ENV=development
IDLE_ALERT_THRESHOLD_HOURS :
TEMP_ALERT_COOLDOWN_HOURS :
IDLE_ALERT_COOLDOWN_HOURS :

37
smartcollar-be/.gitignore vendored Normal file
View File

@ -0,0 +1,37 @@
# Dependencies
node_modules/
# Environment
.env
.env.local
.env.development
.env.production
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build
dist/
build/
# Coverage
coverage/
# OS
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
# Firebase
firebase-debug.log
# Temporary
*.tmp
*.temp

View File

@ -0,0 +1,34 @@
# Changelog
All notable changes to this project will be documented in this file.
## v0.1.0
### Added
* Project initialization
* Firebase configuration
* Authentication module
* Cow management module
* Smart Collar management module
## Upcoming
### v0.2.0
* Assignment module
* Monitoring module
* ESP32 integration
### v0.3.0
* WhatsApp notification
* Alert system
* Threshold configuration
### v1.0.0
* Production release
* VPS deployment
* Complete documentation
* Stable REST API

View File

@ -0,0 +1,40 @@
# Contributing Guide
Thank you for your interest in contributing to Smart Collar Backend.
## Branch Strategy
* `main` → Production
* `develop` → Integration branch
* `feature/*` → New features
* `hotfix/*` → Urgent fixes
## Commit Convention
Examples:
```
feat: implement assignment module
fix: resolve authentication issue
refactor: simplify monitoring service
docs: update README
style: format source code
test: add unit tests
chore: update dependencies
```
## Pull Request
* Create a feature branch from `develop`
* Keep pull requests focused on a single feature
* Ensure the application builds successfully
* Update documentation when necessary
## Coding Standards
* Use ES Modules
* Use async/await
* Keep controllers thin
* Business logic belongs in services
* Database access belongs in repositories
* Follow existing project structure

115
smartcollar-be/README.md Normal file
View File

@ -0,0 +1,115 @@
# Smart Collar Backend
Backend API untuk sistem **Smart Collar**, sebuah platform IoT yang digunakan untuk monitoring kesehatan dan aktivitas ternak secara real-time menggunakan perangkat Smart Collar berbasis ESP32.
## Features
* Firebase Authentication
* Cow Management
* Smart Collar Management
* Assignment Management
* Real-time Monitoring
* WhatsApp Notification (Fonte)
* REST API
* Firebase Realtime Database
## Tech Stack
* Node.js
* Express.js
* Firebase Admin SDK
* Firebase Realtime Database
* Firebase Authentication
* Zod
* JWT
* Fonte API
* ES Modules
## Project Structure
```text
src
├── config
├── core
├── middlewares
├── modules
│ ├── auth
│ ├── cow
│ ├── collar
│ ├── assignment
│ ├── monitoring
│ ├── notification
│ └── settings
├── routes
├── shared
├── app.js
└── server.js
```
## Installation
Clone repository.
```bash
git clone <repository-url>
```
Install dependencies.
```bash
npm install
```
Copy environment file.
```bash
cp .env.example .env
```
Run development server.
```bash
npm run dev
```
Run production server.
```bash
npm start
```
## Environment Variables
See `.env.example`.
## API Version
Current Version:
```
v1
```
## Development Workflow
* Create feature branch from `develop`
* Implement feature
* Test locally
* Commit using Conventional Commits
* Open Pull Request into `develop`
* Merge into `main` only after testing
## Roadmap
* Authentication
* Cow Management
* Smart Collar Management
* Assignment Module
* Monitoring Module
* WhatsApp Notification
* Dashboard & Analytics
* OTA Firmware Support
## License
This project is licensed under the MIT License.

4568
smartcollar-be/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,30 @@
{
"name": "smartcollar-be",
"version": "1.0.0",
"description": "Smart Collar Backend API",
"main": "src/server.js",
"scripts": {
"dev": "nodemon src/server.js",
"start": "node src/server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"axios": "^1.18.1",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"express-rate-limit": "^8.5.2",
"firebase": "^12.16.0",
"firebase-admin": "^14.1.0",
"helmet": "^8.2.0",
"morgan": "^1.11.0",
"uuid": "^14.0.1",
"zod": "^4.4.3"
},
"devDependencies": {
"nodemon": "^3.1.14"
}
}

97
smartcollar-be/src/app.js Normal file
View File

@ -0,0 +1,97 @@
import express from "express";
import cors from "cors";
import helmet from "helmet";
import morgan from "morgan";
import env from "./config/env.js";
import router from "./routes/index.js";
import notFoundMiddleware from "./middlewares/notFound.middleware.js";
import errorMiddleware from "./middlewares/error.middleware.js";
const app = express();
/*
|--------------------------------------------------------------------------
| Security
|--------------------------------------------------------------------------
*/
app.use(helmet());
// CORS configuration for development (ports 3000 and 5500)
app.use(cors({
origin: function (origin, callback) {
// Allow requests with no origin (like mobile apps or curl requests)
if (!origin) return callback(null, true);
// Allow localhost ports
const allowedOrigins = [
'http://localhost:3000', // Backend itself
'http://localhost:5500', // Go Live frontend
'http://127.0.0.1:3000',
'http://127.0.0.1:5500',
'http://localhost:8000'
];
if (allowedOrigins.includes(origin)) {
console.log('✅ CORS allowed for origin:', origin);
return callback(null, true);
} else {
console.log('❌ CORS blocked for origin:', origin);
return callback(new Error('Not allowed by CORS'));
}
},
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true
}));
/*
|--------------------------------------------------------------------------
| Body Parser
|--------------------------------------------------------------------------
*/
app.use(express.json());
app.use(
express.urlencoded({
extended: true,
})
);
/*
|--------------------------------------------------------------------------
| Logger
|--------------------------------------------------------------------------
*/
app.use(morgan("dev"));
/*
|--------------------------------------------------------------------------
| API
|--------------------------------------------------------------------------
*/
app.use(env.app.apiPrefix, router);
/*
|--------------------------------------------------------------------------
| 404
|--------------------------------------------------------------------------
*/
app.use(notFoundMiddleware);
/*
|--------------------------------------------------------------------------
| Error
|--------------------------------------------------------------------------
*/
app.use(errorMiddleware);
export default app;

View File

@ -0,0 +1,32 @@
import dotenv from "dotenv";
dotenv.config();
const env = {
app: {
name: process.env.APP_NAME || "Smart Collar API",
env: process.env.NODE_ENV || "development",
port: Number(process.env.PORT) || 3000,
apiPrefix: process.env.API_PREFIX || "/api/v1",
},
firebase: {
projectId: process.env.FIREBASE_PROJECT_ID,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, "\n"),
databaseURL: process.env.FIREBASE_DATABASE_URL,
},
fonnte: {
token: process.env.FONNTE_TOKEN,
},
monitoring: {
temperatureMin: Number(process.env.DEFAULT_TEMPERATURE_MIN) || 37,
temperatureMax: Number(process.env.DEFAULT_TEMPERATURE_MAX) || 39.5,
batteryLow: Number(process.env.DEFAULT_BATTERY_LOW) || 20,
offlineMinute: Number(process.env.DEVICE_TIMEOUT) || 5,
},
};
export default env;

View File

@ -0,0 +1,21 @@
import { initializeApp, cert, getApps } from "firebase-admin/app";
import { getDatabase } from "firebase-admin/database";
import { getAuth } from "firebase-admin/auth";
import env from "./env.js";
if (getApps().length === 0) {
initializeApp({
credential: cert({
projectId: env.firebase.projectId,
clientEmail: env.firebase.clientEmail,
privateKey: env.firebase.privateKey,
}),
databaseURL: env.firebase.databaseURL,
});
}
const db = getDatabase();
const auth = getAuth();
export { db, auth };

View File

@ -0,0 +1,11 @@
import axios from "axios";
import env from "./env.js";
const fonnte = axios.create({
baseURL: "https://api.fonnte.com",
headers: {
Authorization: env.fonnte.token,
},
});
export default fonnte;

View File

@ -0,0 +1,28 @@
export const ROLE = {
ADMIN: "admin",
FARMER: "farmer",
};
export const DEVICE_STATUS = {
ONLINE: "online",
OFFLINE: "offline",
};
export const ASSIGNMENT_STATUS = {
ACTIVE: "active",
INACTIVE: "inactive",
};
export const MOVEMENT = {
IDLE: "idle",
WALKING: "walking",
RUNNING: "running",
UNKNOWN: "unknown",
};
export const NOTIFICATION_TYPE = {
TEMPERATURE: "temperature",
BATTERY: "battery",
OFFLINE: "offline",
MOVEMENT: "movement",
};

View File

@ -0,0 +1,15 @@
class AppError extends Error {
constructor(message, statusCode = 500, errors = null) {
super(message);
this.name = "AppError";
this.statusCode = statusCode;
this.errors = errors;
Error.captureStackTrace(this, this.constructor);
}
}
export default AppError;

View File

@ -0,0 +1,30 @@
const getCurrentTime = () => {
return new Date().toLocaleString("id-ID", {
timeZone: "Asia/Jakarta",
});
};
const logger = {
info(message, data = null) {
console.log(
`[INFO] ${getCurrentTime()} - ${message}`,
data ?? ""
);
},
warn(message, data = null) {
console.warn(
`[WARN] ${getCurrentTime()} - ${message}`,
data ?? ""
);
},
error(message, error = null) {
console.error(
`[ERROR] ${getCurrentTime()} - ${message}`,
error ?? ""
);
},
};
export default logger;

View File

@ -0,0 +1,25 @@
export const successResponse = (
res,
data = null,
message = "Success",
statusCode = 200
) => {
return res.status(statusCode).json({
success: true,
message,
data,
});
};
export const errorResponse = (
res,
message = "Internal Server Error",
statusCode = 500,
errors = null
) => {
return res.status(statusCode).json({
success: false,
message,
errors,
});
};

View File

@ -0,0 +1,18 @@
import AppError from "../exceptions/AppError.js";
export const validate = (schema, data) => {
const result = schema.safeParse(data);
if (!result.success) {
throw new AppError(
"Validation Error",
400,
result.error.issues
);
}
return result.data;
};

View File

View File

@ -0,0 +1,62 @@
import { auth } from "../config/firebase.js";
import AppError from "../core/exceptions/AppError.js";
const authMiddleware = async (
req,
res,
next
) => {
try {
const authorization =
req.headers.authorization;
if (!authorization) {
throw new AppError(
"Unauthorized",
401
);
}
const token =
authorization.replace(
"Bearer ",
""
);
console.log('[AUTH] Token received:', token ? token.substring(0, 20) + '...' : 'NONE');
// Allow debug token for testing
if (token === 'debug-token-for-testing') {
console.log('[AUTH] Debug token accepted for testing');
req.user = {
uid: 'debug-user',
ownerId: 'lRAPbzLWIHMhD70rfP0DBWE79eo1'
};
} else {
// Verify the Firebase ID token
const decoded = await auth.verifyIdToken(token).catch((error) => {
console.error('[AUTH] Firebase token verification failed:', error);
throw new AppError(
"Invalid authentication token: " + error.message,
401
);
});
req.user = decoded;
req.user.ownerId = decoded.ownerId ?? decoded.uid;
console.log('[AUTH] Token verified successfully for uid:', decoded.uid, 'ownerId:', req.user.ownerId);
}
next();
} catch (error) {
console.error('[AUTH] Token verification failed:', error.message);
next(
new AppError(
"Invalid authentication token",
401
)
);
}
};
export default authMiddleware;

View File

@ -0,0 +1,24 @@
import AppError from "../core/exceptions/AppError.js";
import logger from "../core/logger/logger.js";
import { errorResponse } from "../core/response/response.js";
const errorMiddleware = (err, req, res, next) => {
logger.error(err.message, err);
if (err instanceof AppError) {
return errorResponse(
res,
err.message,
err.statusCode,
err.errors
);
}
return errorResponse(
res,
"Internal Server Error",
500
);
};
export default errorMiddleware;

View File

@ -0,0 +1,11 @@
import { errorResponse } from "../core/response/response.js";
const notFoundMiddleware = (req, res) => {
return errorResponse(
res,
`Route ${req.originalUrl} not found`,
404
);
};
export default notFoundMiddleware;

View File

@ -0,0 +1,15 @@
import { validate } from "../core/validator/validator.js";
const validationMiddleware = (schema) => {
return (req, res, next) => {
try {
req.body = validate(schema, req.body);
next();
} catch (error) {
next(error);
}
};
};
export default validationMiddleware;

View File

@ -0,0 +1,96 @@
import { successResponse } from "../../core/response/response.js";
import * as assignmentService from "./assignment.service.js";
/**
* GET /assignments
*/
export const getAll = async (
req,
res,
next
) => {
try {
const data =
await assignmentService.getAll(
req.user.uid
);
return successResponse(
res,
data,
"Assignment retrieved successfully"
);
} catch (error) {
next(error);
}
};
/**
* POST /assignments/attach
*/
export const attach = async (
req,
res,
next
) => {
try {
const data =
await assignmentService.attach(
req.user.uid,
req.body
);
return successResponse(
res,
data,
"Smart Collar attached successfully",
201
);
} catch (error) {
next(error);
}
};
/**
* POST /assignments/detach
*/
export const detach = async (
req,
res,
next
) => {
try {
const data =
await assignmentService.detach(
req.user.uid,
req.body.cowId
);
return successResponse(
res,
data,
"Smart Collar detached successfully"
);
} catch (error) {
next(error);
}
};

View File

@ -0,0 +1,169 @@
import { db } from "../../config/firebase.js";
import {
buildOwnerCow,
buildOwnerCollar,
} from "../../shared/utils/owner-key.js";
const COLLECTION = "assignments";
/**
* Semua assignment
*/
export const findAll = async (
ownerId
) => {
const snapshot = await db
.ref(COLLECTION)
.orderByChild("ownerId")
.equalTo(ownerId)
.once("value");
const data = snapshot.val();
return data
? Object.values(data)
: [];
};
/**
* Detail assignment
*/
export const findById = async (
ownerId,
id
) => {
const snapshot = await db
.ref(`${COLLECTION}/${id}`)
.once("value");
if (!snapshot.exists()) {
return null;
}
const assignment =
snapshot.val();
if (
assignment.ownerId !== ownerId
) {
return null;
}
return assignment;
};
/**
* Assignment aktif berdasarkan sapi
*/
export const findActiveByCow = async (
ownerId,
cowId
) => {
const ownerCow =
buildOwnerCow(
ownerId,
cowId
);
const snapshot = await db
.ref(COLLECTION)
.orderByChild("ownerCow")
.equalTo(ownerCow)
.once("value");
const data =
snapshot.val();
if (!data) {
return null;
}
const assignment =
Object.values(data)
.find(
(item) =>
item.active
);
return assignment || null;
};
/**
* Assignment aktif berdasarkan collar
*/
export const findActiveByCollar = async (
ownerId,
collarId
) => {
const ownerCollar =
buildOwnerCollar(
ownerId,
collarId
);
const snapshot = await db
.ref(COLLECTION)
.orderByChild("ownerCollar")
.equalTo(ownerCollar)
.once("value");
const data =
snapshot.val();
if (!data) {
return null;
}
const assignment =
Object.values(data)
.find(
(item) =>
item.active
);
return assignment || null;
};
/**
* Simpan
*/
export const create = async (
data
) => {
await db
.ref(`${COLLECTION}/${data.id}`)
.set(data);
return data;
};
/**
* Update
*/
export const update = async (
ownerId,
id,
data
) => {
await db
.ref(`${COLLECTION}/${id}`)
.update(data);
return await findById(
ownerId,
id
);
};

View File

@ -0,0 +1,39 @@
import { Router } from "express";
import authMiddleware from "../../middlewares/auth.middleware.js";
import validationMiddleware from "../../middlewares/validation.middleware.js";
import {
attachSchema,
detachSchema
} from "./assignment.schema.js";
import * as controller from "./assignment.controller.js";
const router = Router();
router.use(authMiddleware);
router.get(
"/",
controller.getAll
);
router.post(
"/attach",
validationMiddleware(
attachSchema
),
controller.attach
);
router.post(
"/detach",
validationMiddleware(
detachSchema
),
controller.detach
);
export default router;

View File

@ -0,0 +1,24 @@
import { z } from "zod";
export const attachSchema = z.object({
cowId: z
.string()
.trim()
.min(1),
collarId: z
.string()
.trim()
.min(1)
});
export const detachSchema = z.object({
cowId: z
.string()
.trim()
.min(1)
});

View File

@ -0,0 +1,289 @@
import AppError from "../../core/exceptions/AppError.js";
import { generateId } from "../../shared/id/generateId.js";
import { now } from "../../shared/utils/timestamp.js";
import {
buildOwnerCow,
buildOwnerCollar,
} from "../../shared/utils/owner-key.js";
import * as repository from "./assignment.repository.js";
import * as cowRepository from "../cow/cow.repository.js";
import * as collarRepository from "../collar/collar.repository.js";
/**
* Semua assignment
*/
export const getAll = async (
ownerId
) => {
return await repository.findAll(
ownerId
);
};
/**
* Pasang Smart Collar
*/
export const attach = async (
ownerId,
body
) => {
// ==========================
// VALIDASI SAPI
// ==========================
const cow =
await cowRepository.findById(
ownerId,
body.cowId
);
if (!cow) {
throw new AppError(
"Cow not found",
404
);
}
// ==========================
// VALIDASI COLLAR
// ==========================
const collar =
await collarRepository.findById(
ownerId,
body.collarId
);
if (!collar) {
throw new AppError(
"Smart Collar not found",
404
);
}
// ==========================
// CEK SAPI
// ==========================
const cowAssignment =
await repository.findActiveByCow(
ownerId,
body.cowId
);
if (cowAssignment) {
throw new AppError(
"Cow already has Smart Collar",
400
);
}
// ==========================
// CEK COLLAR
// ==========================
const collarAssignment =
await repository.findActiveByCollar(
ownerId,
body.collarId
);
if (collarAssignment) {
throw new AppError(
"Smart Collar already assigned",
400
);
}
const timestamp =
now();
const assignment = {
id:
generateId(
"assignment"
),
ownerId,
ownerCow:
buildOwnerCow(
ownerId,
cow.id
),
ownerCollar:
buildOwnerCollar(
ownerId,
collar.id
),
cowId:
cow.id,
cowCode:
cow.code,
cowName:
cow.name,
collarId:
collar.id,
serialNumber:
collar.serialNumber,
deviceName:
collar.deviceName,
active:true,
assignedAt:
timestamp,
unassignedAt:null,
createdAt:
timestamp,
updatedAt:
timestamp
};
await repository.create(
assignment
);
await collarRepository.update(
ownerId,
collar.id,
{
assigned:true,
status:"assigned",
currentCowId:
cow.id,
currentCowCode:
cow.code,
currentCowName:
cow.name,
currentAssignmentId:
assignment.id,
updatedAt:timestamp
}
);
return assignment;
};
/**
* Lepas Smart Collar
*/
export const detach = async (
ownerId,
cowId
) => {
const assignment =
await repository.findActiveByCow(
ownerId,
cowId
);
if (!assignment) {
throw new AppError(
"Assignment not found",
404
);
}
const timestamp =
now();
await repository.update(
ownerId,
assignment.id,
{
active:false,
unassignedAt:
timestamp,
updatedAt:
timestamp
}
);
await collarRepository.update(
ownerId,
assignment.collarId,
{
assigned:false,
status:"available",
currentCowId:null,
currentCowCode:null,
currentCowName:null,
currentAssignmentId:null,
updatedAt:timestamp
}
);
return {
message:
"Smart Collar detached successfully"
};
};

View File

@ -0,0 +1,34 @@
import { successResponse } from "../../core/response/response.js";
import {
syncUser,
getProfile,
} from "./auth.service.js";
export const sync = async (req, res, next) => {
try {
const user = await syncUser(req.user, req.body);
return successResponse(
res,
user,
"User synchronized successfully"
);
} catch (error) {
next(error);
}
};
export const me = async (req, res, next) => {
try {
const user = await getProfile(req.user.uid);
return successResponse(
res,
user,
"Profile fetched successfully"
);
} catch (error) {
next(error);
}
};

View File

@ -0,0 +1,27 @@
import { db } from "../../config/firebase.js";
const USER_COLLECTION = "users";
export const findUserById = async (uid) => {
const snapshot = await db
.ref(`${USER_COLLECTION}/${uid}`)
.once("value");
return snapshot.val();
};
export const createUser = async (uid, data) => {
await db.ref(`${USER_COLLECTION}/${uid}`).set(data);
return data;
};
export const updateUser = async (uid, data) => {
await db.ref(`${USER_COLLECTION}/${uid}`).update(data);
const snapshot = await db
.ref(`${USER_COLLECTION}/${uid}`)
.once("value");
return snapshot.val();
};

View File

@ -0,0 +1,28 @@
import { Router } from "express";
import authMiddleware from "../../middlewares/auth.middleware.js";
import validationMiddleware from "../../middlewares/validation.middleware.js";
import { syncUserSchema } from "./auth.schema.js";
import {
sync,
me,
} from "./auth.controller.js";
const router = Router();
router.post(
"/sync",
authMiddleware,
validationMiddleware(syncUserSchema),
sync
);
router.get(
"/me",
authMiddleware,
me
);
export default router;

View File

@ -0,0 +1,8 @@
import { z } from "zod";
export const syncUserSchema = z.object({
name: z.string().min(3),
phone: z.string().min(10),
address: z.string().min(3),
photoUrl: z.string().optional().default(""),
});

View File

@ -0,0 +1,48 @@
import AppError from "../../core/exceptions/AppError.js";
import {
findUserById,
createUser,
updateUser,
} from "./auth.repository.js";
export const syncUser = async (firebaseUser, body) => {
const now = Date.now();
const existingUser = await findUserById(firebaseUser.uid);
if (!existingUser) {
const user = {
uid: firebaseUser.uid,
email: firebaseUser.email,
name: body.name,
phone: body.phone,
address: body.address,
photoUrl: body.photoUrl || "",
role: "farmer",
isActive: true,
createdAt: now,
updatedAt: now,
};
return await createUser(firebaseUser.uid, user);
}
return await updateUser(firebaseUser.uid, {
name: body.name,
phone: body.phone,
address: body.address,
photoUrl: body.photoUrl || "",
updatedAt: now,
});
};
export const getProfile = async (uid) => {
const user = await findUserById(uid);
if (!user) {
throw new AppError("User not found", 404);
}
return user;
};

View File

@ -0,0 +1,104 @@
import { successResponse } from "../../core/response/response.js";
import * as collarService from "./collar.service.js";
/**
* GET /collars
*/
export const getAll = async (req, res, next) => {
try {
const collars = await collarService.getAll(
req.user.uid
);
return successResponse(
res,
collars,
"Smart Collar list retrieved successfully"
);
} catch (error) {
next(error);
}
};
/**
* GET /collars/:id
*/
export const getById = async (req, res, next) => {
try {
const collar = await collarService.getById(
req.user.uid,
req.params.id
);
return successResponse(
res,
collar,
"Smart Collar retrieved successfully"
);
} catch (error) {
next(error);
}
};
/**
* POST /collars
*/
export const create = async (req, res, next) => {
try {
const collar = await collarService.create(
req.user.uid,
req.body
);
return successResponse(
res,
collar,
"Smart Collar created successfully",
201
);
} catch (error) {
next(error);
}
};
/**
* PUT /collars/:id
*/
export const update = async (req, res, next) => {
try {
const collar = await collarService.update(
req.user.uid,
req.params.id,
req.body
);
return successResponse(
res,
collar,
"Smart Collar updated successfully"
);
} catch (error) {
next(error);
}
};
/**
* DELETE /collars/:id
*/
export const deleteById = async (req, res, next) => {
try {
await collarService.deleteById(
req.user.uid,
req.params.id
);
return successResponse(
res,
null,
"Smart Collar deleted successfully"
);
} catch (error) {
next(error);
}
};

View File

@ -0,0 +1,282 @@
import { db } from "../../config/firebase.js";
import {
buildOwnerSerial,
buildSerialSecret,
} from "../../shared/utils/owner-key.js";
const COLLECTION = "collars";
/**
* Ambil semua Smart Collar milik owner
*/
export const findAll = async (ownerId) => {
const snapshot = await db
.ref(COLLECTION)
.orderByChild("ownerId")
.equalTo(ownerId)
.once("value");
const data = snapshot.val();
return data ? Object.values(data) : [];
};
/**
* Cari Smart Collar berdasarkan ID
*/
export const findById = async (
ownerId,
id
) => {
const snapshot = await db
.ref(`${COLLECTION}/${id}`)
.once("value");
if (!snapshot.exists()) {
return null;
}
const collar = snapshot.val();
if (collar.ownerId !== ownerId) {
return null;
}
return collar;
};
/**
* Cari berdasarkan serial number
*/
export const findBySerial = async (
ownerId,
serialNumber
) => {
const ownerSerial =
buildOwnerSerial(
ownerId,
serialNumber
);
const snapshot = await db
.ref(COLLECTION)
.orderByChild("ownerSerial")
.equalTo(ownerSerial)
.once("value");
const data = snapshot.val();
if (!data) {
return null;
}
return Object.values(data)[0];
};
/**
* Digunakan ESP32
*/
export const findByDevice = async (
serialNumber,
deviceSecret
) => {
const serialSecret =
buildSerialSecret(
serialNumber,
deviceSecret
);
const snapshot = await db
.ref(COLLECTION)
.orderByChild("serialSecret")
.equalTo(serialSecret)
.once("value");
const data = snapshot.val();
if (!data) {
return null;
}
return Object.values(data)[0];
};
/**
* Cek apakah Smart Collar ada
*/
export const exists = async (
ownerId,
id
) => {
const collar =
await findById(
ownerId,
id
);
return collar !== null;
};
/**
* Cek apakah sedang digunakan
*/
export const isAssigned = async (
ownerId,
id
) => {
const collar =
await findById(
ownerId,
id
);
if (!collar) {
return false;
}
return collar.assigned;
};
/**
* Tambah Smart Collar
*/
export const create = async (
data
) => {
await db
.ref(`${COLLECTION}/${data.id}`)
.set(data);
return data;
};
/**
* Update data Smart Collar
*/
export const update = async (
ownerId,
id,
data
) => {
await db
.ref(`${COLLECTION}/${id}`)
.update(data);
return await findById(
ownerId,
id
);
};
/**
* Update status device
* Dipakai Monitoring
*/
export const updateStatus = async (
ownerId,
id,
status
) => {
await db
.ref(`${COLLECTION}/${id}`)
.update(status);
return await findById(
ownerId,
id
);
};
/**
* Update heartbeat device
* Dipanggil setiap ESP32 mengirim data
*/
export const touchOnline = async (
ownerId,
id,
payload
) => {
await db
.ref(`${COLLECTION}/${id}`)
.update({
signal:
payload.signal,
firmwareVersion:
payload.firmwareVersion,
hardwareVersion:
payload.hardwareVersion,
online: true,
lastOnline:
payload.lastOnline,
lastSync:
payload.lastSync,
updatedAt:
payload.lastSync,
});
};
/**
* Set offline
* Dipakai scheduler
*/
export const setOffline = async (
ownerId,
id
) => {
await db
.ref(`${COLLECTION}/${id}`)
.update({
online: false,
updatedAt:
Date.now()
});
};
/**
* Hapus Smart Collar
*/
export const deleteById = async (
ownerId,
id
) => {
const exists =
await findById(
ownerId,
id
);
if (!exists) {
return false;
}
await db
.ref(`${COLLECTION}/${id}`)
.remove();
return true;
};

View File

@ -0,0 +1,38 @@
import { Router } from "express";
import authMiddleware from "../../middlewares/auth.middleware.js";
import validationMiddleware from "../../middlewares/validation.middleware.js";
import {
createCollarSchema,
updateCollarSchema,
} from "./collar.schema.js";
import * as controller from "./collar.controller.js";
const router = Router();
router.use(authMiddleware);
router.get("/", controller.getAll);
router.get("/:id", controller.getById);
router.post(
"/",
validationMiddleware(createCollarSchema),
controller.create
);
router.put(
"/:id",
validationMiddleware(updateCollarSchema),
controller.update
);
router.delete(
"/:id",
controller.deleteById
);
export default router;

View File

@ -0,0 +1,40 @@
import { z } from "zod";
export const createCollarSchema = z.object({
serialNumber: z
.string()
.trim()
.min(3)
.max(50)
.transform((v) => v.toUpperCase()),
deviceName: z
.string()
.trim()
.min(2)
.max(100),
hardwareVersion: z
.string()
.trim()
.min(1),
firmwareVersion: z
.string()
.trim()
.min(1),
macAddress: z
.string()
.trim()
.min(1),
simNumber: z
.string()
.trim()
.optional()
.default("")
});
export const updateCollarSchema =
createCollarSchema.partial();

View File

@ -0,0 +1,221 @@
import AppError from "../../core/exceptions/AppError.js";
import { generateId } from "../../shared/id/generateId.js";
import { generateDeviceSecret } from "../../shared/utils/device.js";
import {
buildOwnerSerial,
buildSerialSecret,
} from "../../shared/utils/owner-key.js";
import { now } from "../../shared/utils/timestamp.js";
import { COLLAR_STATUS as collarStatus } from "../../shared/constants/collar-status.js";
import * as repository from "./collar.repository.js";
/**
* Ambil semua Smart Collar
*/
export const getAll = async (ownerId) => {
return await repository.findAll(ownerId);
};
/**
* Detail Smart Collar
*/
export const getById = async (
ownerId,
id
) => {
const collar = await repository.findById(
ownerId,
id
);
if (!collar) {
throw new AppError(
"Smart Collar not found",
404
);
}
return collar;
};
/**
* Tambah Smart Collar
*/
export const create = async (
ownerId,
body
) => {
const duplicate =
await repository.findBySerial(
ownerId,
body.serialNumber
);
if (duplicate) {
throw new AppError(
"Serial number already exists",
400
);
}
const serial =
body.serialNumber.toUpperCase();
const secret =
generateDeviceSecret();
const timestamp = now();
const collar = {
id: generateId("collar"),
ownerId,
ownerSerial: buildOwnerSerial(
ownerId,
serial
),
serialNumber: serial,
deviceSecret: secret,
serialSecret: buildSerialSecret(
serial,
secret
),
deviceName:
body.deviceName?.trim(),
hardwareVersion:
body.hardwareVersion ?? null,
firmwareVersion:
body.firmwareVersion ?? null,
macAddress:
body.macAddress?.toUpperCase() ?? null,
simNumber:
body.simNumber ?? null,
signal: null,
online: false,
assigned: false,
currentCowId: null,
currentCowCode: null,
currentCowName: null,
currentAssignmentId: null,
status: collarStatus.AVAILABLE,
lastOnline: null,
lastSync: null,
createdAt: timestamp,
updatedAt: timestamp,
};
return await repository.create(
collar
);
};
/**
* Update Smart Collar
*/
export const update = async (
ownerId,
id,
body
) => {
const collar =
await getById(
ownerId,
id
);
if (body.serialNumber) {
const duplicate =
await repository.findBySerial(
ownerId,
body.serialNumber
);
if (
duplicate &&
duplicate.id !== id
) {
throw new AppError(
"Serial number already exists",
400
);
}
body.serialNumber =
body.serialNumber.toUpperCase();
body.ownerSerial =
buildOwnerSerial(
ownerId,
body.serialNumber
);
body.serialSecret =
buildSerialSecret(
body.serialNumber,
collar.deviceSecret
);
}
const updateData = {
...body,
updatedAt: now(),
};
return await repository.update(
ownerId,
id,
updateData
);
};
/**
* Hapus Smart Collar
*/
export const deleteById = async (
ownerId,
id
) => {
const collar =
await getById(
ownerId,
id
);
await repository.deleteById(
ownerId,
id
);
return collar;
};

View File

@ -0,0 +1,86 @@
import { successResponse } from "../../core/response/response.js";
import * as cowService from "./cow.service.js";
export const getAll = async (req, res, next) => {
try {
const cows = await cowService.getAll(req.user.uid);
return successResponse(
res,
cows,
"Cow list retrieved successfully"
);
} catch (error) {
next(error);
}
};
export const getById = async (req, res, next) => {
try {
const cow = await cowService.getById(
req.user.uid,
req.params.id
);
return successResponse(
res,
cow,
"Cow retrieved successfully"
);
} catch (error) {
next(error);
}
};
export const create = async (req, res, next) => {
try {
const cow = await cowService.create(
req.user.uid,
req.body
);
return successResponse(
res,
cow,
"Cow created successfully",
201
);
} catch (error) {
next(error);
}
};
export const update = async (req, res, next) => {
try {
const cow = await cowService.update(
req.user.uid,
req.params.id,
req.body
);
return successResponse(
res,
cow,
"Cow updated successfully"
);
} catch (error) {
next(error);
}
};
export const remove = async (req, res, next) => {
try {
await cowService.deleteById(
req.user.uid,
req.params.id
);
return successResponse(
res,
null,
"Cow deleted successfully"
);
} catch (error) {
next(error);
}
};

View File

@ -0,0 +1,152 @@
import { db } from "../../config/firebase.js";
const COLLECTION = "cows";
/**
* Ambil seluruh sapi milik owner
*/
export const findAll = async (ownerId) => {
const snapshot = await db
.ref(COLLECTION)
.orderByChild("ownerId")
.equalTo(ownerId)
.once("value");
const data = snapshot.val();
return data ? Object.values(data) : [];
};
/**
* Cari sapi berdasarkan owner + id
*/
export const findById = async (
ownerId,
id
) => {
const snapshot = await db
.ref(`${COLLECTION}/${id}`)
.once("value");
if (!snapshot.exists()) {
return null;
}
const cow = snapshot.val();
if (cow.ownerId !== ownerId) {
return null;
}
return cow;
};
/**
* Cari sapi berdasarkan owner + code
*/
export const findByOwnerCode = async (
ownerId,
code
) => {
const ownerCode =
`${ownerId}_${code.toUpperCase()}`;
const snapshot = await db
.ref(COLLECTION)
.orderByChild("ownerCode")
.equalTo(ownerCode)
.once("value");
const data = snapshot.val();
if (!data) {
return null;
}
return Object.values(data)[0];
};
/**
* Cek apakah sapi ada
*/
export const exists = async (
ownerId,
id
) => {
const cow = await findById(
ownerId,
id
);
return cow !== null;
};
/**
* Tambah sapi
*/
export const create = async (
data
) => {
await db
.ref(`${COLLECTION}/${data.id}`)
.set(data);
return data;
};
/**
* Update sapi
*/
export const update = async (
ownerId,
id,
data
) => {
console.log("================================");
console.log("UPDATE DATA");
console.log("================================");
console.log(data);
console.log(Array.isArray(data));
const cow = await findById(ownerId, id);
if (!cow) {
return null;
}
await db
.ref(`${COLLECTION}/${id}`)
.update(data);
return await findById(ownerId, id);
};
/**
* Hapus sapi
*/
export const deleteById = async (
ownerId,
id
) => {
const cow =
await findById(
ownerId,
id
);
if (!cow) {
return false;
}
await db
.ref(`${COLLECTION}/${id}`)
.remove();
return true;
};

View File

@ -0,0 +1,38 @@
import { Router } from "express";
import authMiddleware from "../../middlewares/auth.middleware.js";
import validationMiddleware from "../../middlewares/validation.middleware.js";
import {
createCowSchema,
updateCowSchema,
} from "./cow.schema.js";
import * as controller from "./cow.controller.js";
const router = Router();
router.use(authMiddleware);
router.get("/", controller.getAll);
router.get("/:id", controller.getById);
router.post(
"/",
validationMiddleware(createCowSchema),
controller.create
);
router.put(
"/:id",
validationMiddleware(updateCowSchema),
controller.update
);
router.delete(
"/:id",
controller.remove
);
export default router;

View File

@ -0,0 +1,52 @@
import { z } from "zod";
export const createCowSchema = z.object({
code: z
.string()
.trim()
.min(2, "Code minimal 2 karakter")
.max(20),
name: z
.string()
.trim()
.min(2, "Nama minimal 2 karakter")
.max(100),
breed: z
.string()
.trim()
.min(2, "Breed wajib diisi"),
gender: z.enum(["male", "female"]),
birthDate: z.string(),
weight: z
.number()
.positive(),
color: z
.string()
.trim()
.optional()
.default(""),
photoUrl: z
.string()
.optional()
.default(""),
note: z
.string()
.optional()
.default(""),
status: z.enum([
"healthy",
"sick",
"pregnant"
])
});
export const updateCowSchema = createCowSchema.partial();

View File

@ -0,0 +1,152 @@
import AppError from "../../core/exceptions/AppError.js";
import { generateId } from "../../shared/id/generateId.js";
import {
buildOwnerCode,
} from "../../shared/utils/owner-key.js";
import {
now,
} from "../../shared/utils/timestamp.js";
import * as repository from "./cow.repository.js";
/**
* Ambil semua sapi milik user
*/
export const getAll = async (ownerId) => {
return await repository.findAll(ownerId);
};
/**
* Detail sapi
*/
export const getById = async (
ownerId,
id
) => {
const cow = await repository.findById(
ownerId,
id
);
if (!cow) {
throw new AppError(
"Cow not found",
404
);
}
return cow;
};
/**
* Tambah sapi
*/
export const create = async (
ownerId,
body
) => {
const duplicate =
await repository.findByOwnerCode(
ownerId,
body.code
);
if (duplicate) {
throw new AppError(
"Cow code already exists",
400
);
}
const timestamp = now();
const cow = {
id: generateId("cow"),
ownerId,
ownerCode: buildOwnerCode(
ownerId,
body.code
),
...body,
code: body.code.toUpperCase(),
createdAt: timestamp,
updatedAt: timestamp,
};
return await repository.create(cow);
};
/**
* Update sapi
*/
export const update = async (
ownerId,
id,
body
) => {
await getById(ownerId, id);
if (body.code) {
const duplicate =
await repository.findByOwnerCode(
ownerId,
body.code
);
if (
duplicate &&
duplicate.id !== id
) {
throw new AppError(
"Cow code already exists",
400
);
}
body.code =
body.code.toUpperCase();
body.ownerCode =
buildOwnerCode(
ownerId,
body.code
);
}
body.updatedAt = now();
return await repository.update(
ownerId,
id,
body
);
};
/**
* Hapus sapi
*/
export const deleteById = async (
ownerId,
id
) => {
const cow = await getById(
ownerId,
id
);
await repository.deleteById(
ownerId,
id
);
return cow;
};

View File

@ -0,0 +1,88 @@
import { successResponse } from "../../core/response/response.js";
import * as monitoringService from "./monitoring.service.js";
/*
|--------------------------------------------------------------------------
| POST /api/v1/monitoring
| Digunakan oleh ESP32
|--------------------------------------------------------------------------
*/
export const receive = async (req, res, next) => {
try {
const monitoring = await monitoringService.receiveMonitoring(req.body);
return successResponse(res, monitoring, "Monitoring received successfully");
} catch (error) {
next(error);
}
};
/*
|--------------------------------------------------------------------------
| GET /api/v1/monitoring/latest
|--------------------------------------------------------------------------
*/
export const getLatest = async (req, res, next) => {
try {
const monitoring = await monitoringService.getLatest(req.user.ownerId);
res.set({
"Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate",
Pragma: "no-cache",
Expires: "0",
"Surrogate-Control": "no-store"
});
return successResponse(res, monitoring, "Latest monitoring retrieved successfully");
} catch (error) {
next(error);
}
};
/*
|--------------------------------------------------------------------------
| GET /api/v1/monitoring/latest/:cowId
|--------------------------------------------------------------------------
*/
export const getLatestByCow = async (req, res, next) => {
try {
const monitoring = await monitoringService.getLatestByCow(req.user.ownerId, req.params.cowId);
return successResponse(res, monitoring, "Monitoring retrieved successfully");
} catch (error) {
next(error);
}
};
/*
|--------------------------------------------------------------------------
| GET /api/v1/monitoring/history/:cowId
|--------------------------------------------------------------------------
*/
export const getHistory = async (req, res, next) => {
try {
const history = await monitoringService.getHistory(req.user.ownerId, req.params.cowId, req.query);
return successResponse(res, history, "Monitoring history retrieved successfully");
} catch (error) {
next(error);
}
};
/*
|--------------------------------------------------------------------------
| GET /api/v1/monitoring/debug/latest
| Debug endpoint - no authentication required
|--------------------------------------------------------------------------
*/
export const getLatestDebug = async (req, res, next) => {
try {
console.log('[DEBUG] Debug endpoint called - skipping authentication');
// Use the same owner ID as the test data
const monitoring = await monitoringService.getLatest('lRAPbzLWIHMhD70rfP0DBWE79eo1');
return successResponse(res, monitoring, "Debug monitoring data retrieved successfully");
} catch (error) {
console.error('[DEBUG] Error in debug endpoint:', error);
next(error);
}
};

View File

@ -0,0 +1,184 @@
import { db } from "../../config/firebase.js";
/*
|--------------------------------------------------------------------------
| SAVE LATEST
|--------------------------------------------------------------------------
*/
export const saveLatest = async (
ownerId,
cowId,
data
) => {
await db
.ref(
`monitoring/latest/${ownerId}/${cowId}`
)
.set(data);
};
/*
|--------------------------------------------------------------------------
| SAVE HISTORY
|--------------------------------------------------------------------------
*/
/**
* Simpan history monitoring
*/
export const saveHistory = async (
ownerId,
cowId,
data
) => {
const date = new Date(data.timestamp);
const year = String(date.getFullYear());
const month = String(
date.getMonth() + 1
).padStart(2, "0");
const day = String(
date.getDate()
).padStart(2, "0");
await db
.ref(
`monitoring/history/${ownerId}/${cowId}/${year}/${month}/${day}`
)
.push(data);
};
/*
|--------------------------------------------------------------------------
| GET LATEST
|--------------------------------------------------------------------------
*/
export const getLatest = async (
ownerId
) => {
const snapshot =
await db
.ref(
`monitoring/latest/${ownerId}`
)
.once("value");
return snapshot.val();
};
/*
|--------------------------------------------------------------------------
| GET LATEST BY COW
|--------------------------------------------------------------------------
*/
export const getLatestByCow =
async (
ownerId,
cowId
) => {
const snapshot =
await db
.ref(
`monitoring/latest/${ownerId}/${cowId}`
)
.once("value");
return snapshot.exists()
? snapshot.val()
: null;
};
/*
|--------------------------------------------------------------------------
| GET HISTORY DAY
|--------------------------------------------------------------------------
*/
export const getHistoryByDay = async (
ownerId,
cowId,
year,
month,
day
) => {
const snapshot = await db
.ref(
`monitoring/history/${ownerId}/${cowId}/${year}/${month}/${day}`
)
.once("value");
const data = snapshot.val();
return data
? Object.values(data)
: [];
};
/*
|--------------------------------------------------------------------------
| GET HISTORY MONTH
|--------------------------------------------------------------------------
*/
export const getHistoryByMonth = async (
ownerId,
cowId,
year,
month
) => {
const snapshot = await db
.ref(
`monitoring/history/${ownerId}/${cowId}/${year}/${month}`
)
.once("value");
return snapshot.val();
};
/*
|--------------------------------------------------------------------------
| DELETE HISTORY
|--------------------------------------------------------------------------
*/
export const deleteHistory = async (
ownerId,
cowId,
year,
month,
day
) => {
await db
.ref(
`monitoring/history/${ownerId}/${cowId}/${year}/${month}/${day}`
)
.remove();
};

View File

@ -0,0 +1,61 @@
import { Router } from "express";
import * as controller from "./monitoring.controller.js";
import authMiddleware from "../../middlewares/auth.middleware.js";
import validationMiddleware from "../../middlewares/validation.middleware.js";
import {
createMonitoringSchema
} from "./monitoring.schema.js";
const router=Router();
/*
|--------------------------------------------------------------------------
| ESP32
|--------------------------------------------------------------------------
*/
router.post(
"/",
validationMiddleware(createMonitoringSchema),
controller.receive
);
/*
|--------------------------------------------------------------------------
| Mobile
|--------------------------------------------------------------------------
*/
router.get(
"/latest",
authMiddleware,
controller.getLatest
);
router.get(
"/latest/:cowId",
authMiddleware,
controller.getLatestByCow
);
router.get(
"/history/:cowId",
authMiddleware,
controller.getHistory
);
/*
|--------------------------------------------------------------------------
| Debug endpoint - no authentication required
|--------------------------------------------------------------------------
*/
router.get(
"/debug/latest",
controller.getLatestDebug
);
export default router;

View File

@ -0,0 +1,54 @@
import { z } from "zod";
export const createMonitoringSchema = z.object({
serialNumber:
z.string(),
deviceSecret:
z.string(),
device: z.object({
signal:
z.number(),
firmwareVersion:
z.string().optional(),
hardwareVersion:
z.string().optional(),
uptime:
z.number().optional(),
}),
sensor: z.object({
temperature:
z.number(),
gps: z.object({
latitude:
z.number(),
longitude:
z.number(),
linkMaps:
z.string(),
}),
movement: z.object({
accelX:
z.number(),
}),
}),
});

View File

@ -0,0 +1,550 @@
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
);
};

View File

@ -0,0 +1,16 @@
import notificationService from "./notification.service.js";
export const sendWhatsApp = async (req, res, next) => {
try {
const { phone, message, template } = req.body;
const result = await notificationService.sendWhatsApp(phone, message, { template });
return res.status(200).json({ success: true, message: "Pesan terkirim", data: result });
} catch (err) {
next(err);
}
};
export default {
sendWhatsApp,
};

View File

@ -0,0 +1,40 @@
import { db } from "../../config/firebase.js";
const COLLECTION = "notifications";
export const getLastAlert = async (
ownerId,
cowId,
alertType
) => {
const snapshot = await db
.ref(`${COLLECTION}/last/${ownerId}/${cowId}/${alertType}`)
.once("value");
return snapshot.val();
};
export const saveLastAlert = async (
ownerId,
cowId,
alertType,
data
) => {
await db
.ref(`${COLLECTION}/last/${ownerId}/${cowId}/${alertType}`)
.set(data);
};
export const saveNotificationLog = async (
ownerId,
cowId,
data
) => {
const timestamp = Date.now();
await db
.ref(`${COLLECTION}/logs/${ownerId}/${cowId}/${timestamp}`)
.set({
...data,
createdAt: timestamp,
});
};

View File

@ -0,0 +1,27 @@
import { Router } from "express";
import * as controller from "./notification.controller.js";
import testController from "./notification.test.controller.js";
import authMiddleware from "../../middlewares/auth.middleware.js";
import validationMiddleware from "../../middlewares/validation.middleware.js";
import {
sendWhatsAppSchema,
sendTestWhatsAppSchema,
} from "./notification.schema.js";
const router = Router();
router.post(
"/whatsapp",
authMiddleware,
validationMiddleware(sendWhatsAppSchema),
controller.sendWhatsApp
);
router.post(
"/whatsapp/test",
authMiddleware,
validationMiddleware(sendTestWhatsAppSchema),
testController.sendTestWhatsApp
);
export default router;

View File

@ -0,0 +1,14 @@
import { z } from "zod";
export const sendWhatsAppSchema = z.object({
phone: z.string().min(5, "Nomor telepon wajib diisi"),
message: z.string().min(1, "Pesan wajib diisi"),
template: z.string().optional(),
});
export const sendTestWhatsAppSchema = z.object({
phone: z.string().min(5, "Nomor telepon wajib diisi"),
message: z.string().min(1, "Pesan wajib diisi"),
});
export default sendWhatsAppSchema;

View File

@ -0,0 +1,101 @@
import fonnte from "../../config/fonnte.js";
import * as notificationRepository from "./notification.repository.js";
const DEFAULT_COOLDOWN_MS = 1000 * 60 * 10; // 10 menit
const sendWhatsApp = async (phone, message, opts = {}) => {
const payload = {
target: phone,
message,
...(opts.template ? { template: opts.template } : {}),
};
const res = await fonnte.post("/send", payload);
return res.data;
};
const shouldSendAlert = async (
ownerId,
cowId,
alertType,
cooldownMs = DEFAULT_COOLDOWN_MS
) => {
const lastAlert = await notificationRepository.getLastAlert(
ownerId,
cowId,
alertType
);
if (!lastAlert || !lastAlert.timestamp) {
return true;
}
const elapsed = Date.now() - lastAlert.timestamp;
return elapsed > cooldownMs;
};
const saveAlertSent = async (
ownerId,
cowId,
alertType,
message
) => {
await notificationRepository.saveLastAlert(ownerId, cowId, alertType, {
timestamp: Date.now(),
message,
});
};
const logNotification = async (
ownerId,
cowId,
alertType,
phone,
message,
sent,
error = null,
suppressed = false
) => {
await notificationRepository.saveNotificationLog(ownerId, cowId, {
alertType,
phone,
message,
sent,
suppressed,
error,
});
};
const sendAlertIfAllowed = async (
ownerId,
cowId,
alertType,
phone,
message,
cooldownMs = DEFAULT_COOLDOWN_MS
) => {
const canSend = await shouldSendAlert(ownerId, cowId, alertType, cooldownMs);
if (!canSend) {
await logNotification(ownerId, cowId, alertType, phone, message, false, null, true);
return null;
}
try {
const result = await sendWhatsApp(phone, message);
await saveAlertSent(ownerId, cowId, alertType, message);
await logNotification(ownerId, cowId, alertType, phone, message, true, null, false);
return result;
} catch (error) {
await logNotification(ownerId, cowId, alertType, phone, message, false, error?.message || error, false);
throw error;
}
};
export default {
sendWhatsApp,
shouldSendAlert,
saveAlertSent,
logNotification,
sendAlertIfAllowed,
};

View File

@ -0,0 +1,28 @@
import notificationService from "./notification.service.js";
export const sendTestWhatsApp = async (req, res, next) => {
try {
const { phone, message } = req.body;
if (!phone || !message) {
return res.status(400).json({
success: false,
message: "phone dan message wajib diisi untuk testing",
});
}
const result = await notificationService.sendWhatsApp(phone, message);
return res.status(200).json({
success: true,
message: "Test pesan WhatsApp terkirim",
data: result,
});
} catch (err) {
next(err);
}
};
export default {
sendTestWhatsApp,
};

View File

@ -0,0 +1,40 @@
import { Router } from "express";
import authRoute from "../modules/auth/auth.route.js";
import cowRoute from "../modules/cow/cow.route.js";
import collarRoute from "../modules/collar/collar.route.js";
import assignmentRoute from "../modules/assignment/assignment.route.js";
import monitoringRoute from "../modules/monitoring/monitoring.route.js";
import notificationRoute from "../modules/notification/notification.route.js";
const router = Router();
/*
|--------------------------------------------------------------------------
| Health Check
|--------------------------------------------------------------------------
*/
router.get("/", (req, res) => {
return res.status(200).json({
success: true,
message: "Smart Collar API v1",
data: null,
});
});
/*
|--------------------------------------------------------------------------
| Modules
|--------------------------------------------------------------------------
*/
router.use("/auth", authRoute);
router.use("/cows", cowRoute);
router.use("/collars", collarRoute);
router.use("/assignments", assignmentRoute);
router.use("/monitoring", monitoringRoute);
router.use("/notifications", notificationRoute);
// router.use("/settings", settingRoute);
export default router;

View File

@ -0,0 +1,11 @@
import app from "./app.js";
import env from "./config/env.js";
import logger from "./core/logger/logger.js";
const PORT = env.app.port;
app.listen(PORT, () => {
logger.info(`${env.app.name} running on port ${PORT}`);
});

View File

@ -0,0 +1,150 @@
import {
calculateTemperatureStatus,
} from "../calculators/temperature.calculator.js";
import {
calculateMovementStatus,
} from "../calculators/movement.calculator.js";
export const buildMonitoring = (
collar,
sensor,
device,
timestamp
) => {
const date = new Date(timestamp);
return {
/*
|--------------------------------------------------------------------------
| Owner
|--------------------------------------------------------------------------
*/
ownerId: collar.ownerId,
/*
|--------------------------------------------------------------------------
| Cow
|--------------------------------------------------------------------------
*/
cowId: collar.currentCowId,
cowCode: collar.currentCowCode,
cowName: collar.currentCowName,
assignmentId:
collar.currentAssignmentId,
/*
|--------------------------------------------------------------------------
| Collar
|--------------------------------------------------------------------------
*/
collarId: collar.id,
serialNumber:
collar.serialNumber,
deviceName:
collar.deviceName,
/*
|--------------------------------------------------------------------------
| Device
|--------------------------------------------------------------------------
*/
signal:
device.signal,
firmwareVersion:
device.firmwareVersion ?? null,
hardwareVersion:
device.hardwareVersion ?? null,
uptime:
device.uptime ?? null,
/*
|--------------------------------------------------------------------------
| Sensor
|--------------------------------------------------------------------------
*/
temperature:
sensor.temperature,
temperatureStatus:
calculateTemperatureStatus(
sensor.temperature
),
gps: {
latitude:
sensor.gps.latitude,
longitude:
sensor.gps.longitude,
linkMaps:
sensor.gps.linkMaps,
},
movement: {
accelX:
sensor.movement.accelX,
status:
calculateMovementStatus(
sensor.movement.accelX
),
},
/*
|--------------------------------------------------------------------------
| Time
|--------------------------------------------------------------------------
*/
year:
date.getFullYear(),
month:
String(
date.getMonth() + 1
).padStart(2, "0"),
day:
String(
date.getDate()
).padStart(2, "0"),
hour:
String(
date.getHours()
).padStart(2, "0"),
minute:
String(
date.getMinutes()
).padStart(2, "0"),
second:
String(
date.getSeconds()
).padStart(2, "0"),
timestamp,
};
};

View File

@ -0,0 +1,36 @@
export const MOVEMENT_STATUS = {
REST: 0,
WALK: 1,
RUN: 2,
};
// Range "diam" berdasarkan observasi nyata di lapangan -- device yang
// benar-benar diam tetap menghasilkan noise sensor di kisaran ini
// (asimetris karena sedikit gravitasi/kemiringan bocor ke sumbu X).
const ACCEL_REST_MIN = -0.5;
const ACCEL_REST_MAX = 0.2;
// Ambang batas RUN, dihitung dari besaran (magnitude) di luar range REST.
const ACCEL_RUN_THRESHOLD = 1.0;
export const calculateMovementStatus = (
accelX
) => {
const isResting =
accelX >= ACCEL_REST_MIN &&
accelX <= ACCEL_REST_MAX;
if (isResting) {
return MOVEMENT_STATUS.REST;
}
const magnitude = Math.abs(accelX);
if (magnitude < ACCEL_RUN_THRESHOLD) {
return MOVEMENT_STATUS.WALK;
}
return MOVEMENT_STATUS.RUN;
};

View File

@ -0,0 +1,21 @@
export const TEMPERATURE_STATUS = {
NORMAL: 0,
WARNING: 1,
DANGER: 2,
};
export const calculateTemperatureStatus = (
temperature
) => {
if (temperature <= 39.5) {
return TEMPERATURE_STATUS.NORMAL;
}
if (temperature <= 40.5) {
return TEMPERATURE_STATUS.WARNING;
}
return TEMPERATURE_STATUS.DANGER;
};

View File

@ -0,0 +1,6 @@
export const COLLAR_STATUS = {
AVAILABLE: "available",
ASSIGNED: "assigned",
OFFLINE: "offline",
MAINTENANCE: "maintenance",
};

View File

View File

View File

@ -0,0 +1,5 @@
import crypto from "crypto";
export const generateId = (prefix) => {
return `${prefix}_${crypto.randomUUID()}`;
};

View File

@ -0,0 +1,8 @@
import crypto from "crypto";
/**
* Generate device secret
*/
export const generateDeviceSecret = () => {
return crypto.randomUUID();
};

View File

@ -0,0 +1,49 @@
/**
* Generate ownerCode
*/
export const buildOwnerCode = (
ownerId,
code
) => {
return `${ownerId}_${code.toUpperCase()}`;
};
/**
* Generate ownerSerial
*/
export const buildOwnerSerial = (
ownerId,
serialNumber
) => {
return `${ownerId}_${serialNumber.toUpperCase()}`;
};
/**
* Generate serialSecret
*/
export const buildSerialSecret = (
serialNumber,
secret
) => {
return `${serialNumber.toUpperCase()}_${secret}`;
};
/**
* Generate ownerCow
*/
export const buildOwnerCow = (
ownerId,
cowId
) => {
return `${ownerId}_${cowId}`;
};
/**
* Generate ownerCollar
*/
export const buildOwnerCollar = (
ownerId,
collarId
) => {
return `${ownerId}_${collarId}`;
};

View File

@ -0,0 +1 @@
export const now = () => Date.now();

View File

65
smartcollar-fe/.gitignore vendored Normal file
View File

@ -0,0 +1,65 @@
#########################################
# macOS
#########################################
.DS_Store
#########################################
# VSCode
#########################################
.vscode/
#########################################
# JetBrains
#########################################
.idea/
#########################################
# Composer
#########################################
vendor/
#########################################
# Environment
#########################################
.env
.env.local
#########################################
# Logs
#########################################
logs/
*.log
#########################################
# Cache
#########################################
.cache/
tmp/
#########################################
# Upload
#########################################
uploads/
#########################################
# Node
#########################################
node_modules/
#########################################
# OS
#########################################
Thumbs.db

View File

@ -0,0 +1,30 @@
# Changelog
All notable changes to this project will be documented in this file.
---
## [1.0.0] - 2026-07-12
### Added
- Firebase Authentication
- PHP Session Login
- Dashboard
- Cow Management
- Smart Collar Management
- Assignment Module
- Monitoring Module
- REST API Integration
---
## Upcoming
- Monitoring History
- WhatsApp Notification
- Export PDF
- Export Excel
- Device Analytics
- OTA Update
- Geofence

21
smartcollar-fe/LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Muhammad Iqbal
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

175
smartcollar-fe/README.md Normal file
View File

@ -0,0 +1,175 @@
# 🐄 Smart Collar Web
Smart Collar Web adalah aplikasi berbasis **PHP Native** yang digunakan untuk memonitor sapi secara realtime menggunakan perangkat IoT Smart Collar.
Project ini terhubung dengan **Smart Collar Backend (Express.js)** melalui REST API dan menggunakan Firebase Authentication serta Firebase Realtime Database.
---
## Features
- Firebase Authentication
- Dashboard Monitoring
- Cow Management
- Smart Collar Management
- Assignment Management
- Realtime Monitoring
- Google Maps
- Monitoring History
- WhatsApp Notification (Planned)
---
## Tech Stack
### Frontend
- PHP Native
- Bootstrap 5
- JavaScript
- Fetch API
### Backend
- Node.js
- Express.js
### Database
- Firebase Realtime Database
### Authentication
- Firebase Authentication
---
## Project Structure
```
smartcollar-web/
├── ajax/
├── assets/
│ ├── css/
│ ├── img/
│ └── js/
├── config/
├── includes/
├── pages/
├── services/
├── index.php
├── login.php
├── README.md
├── LICENSE
├── CHANGELOG.md
└── .gitignore
```
---
## Installation
Clone repository
```bash
git clone https://github.com/yourusername/smartcollar-web.git
```
Masuk ke project
```bash
cd smartcollar-web
```
Install dependency
```bash
composer install
```
---
## Configuration
Edit
```
config/api.php
```
```php
define(
"API_URL",
"http://localhost:3000/api/v1"
);
```
---
## Run
PHP Built-in Server
```bash
php -S localhost:8080
```
atau menggunakan XAMPP
```
http://localhost/smartcollar-web
```
---
## Monitoring Flow
```
ESP32
REST API
Express Backend
Firebase
PHP Dashboard
```
---
## Development Status
| Module | Status |
|----------|--------|
| Authentication | ✅ |
| Dashboard | ✅ |
| Cow Management | ✅ |
| Smart Collar | ✅ |
| Assignment | ✅ |
| Monitoring | ✅|
| History | ✅ |
| WhatsApp Notification | ✅ |
---
## License
MIT License
---
## Author
Muhammad Iqbal
Software Engineer
DevGo

View File

@ -0,0 +1,49 @@
<?php
require_once "../config/auth.php";
require_once "../services/assignment.service.php";
require_once "../services/cow.service.php";
require_once "../services/collar.service.php";
requireLogin();
header("Content-Type: application/json");
$action = $_POST["action"] ?? "";
switch ($action) {
case "list":
$assignments = getAllAssignment();
echo json_encode(["success" => true, "data" => $assignments]);
break;
case "attach":
$cowId = $_POST["cowId"] ?? null;
$collarId = $_POST["collarId"] ?? null;
if (!$cowId || !$collarId) {
echo json_encode(["success" => false, "message" => "cowId and collarId are required"]);
exit;
}
$response = attachAssignment(["cowId" => $cowId, "collarId" => $collarId]);
echo json_encode($response);
break;
case "detach":
$cowId = $_POST["cowId"] ?? null;
if (!$cowId) {
echo json_encode(["success" => false, "message" => "cowId is required"]);
exit;
}
$response = detachAssignment(["cowId" => $cowId]);
echo json_encode($response);
break;
default:
echo json_encode(["success" => false, "message" => "Invalid action"]);
break;
}

View File

@ -0,0 +1,38 @@
<?php
require_once "../config/auth.php";
require_once "../config/api.php";
requireLogin();
header("Content-Type: application/json");
$token = getToken();
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
echo json_encode(["success" => false, "message" => "Invalid request payload"]);
exit;
}
$name = trim($input['name'] ?? '');
$phone = trim($input['phone'] ?? '');
$address = trim($input['address'] ?? '');
$photoUrl = trim($input['photoUrl'] ?? '');
if (!$name || !$phone || !$address) {
echo json_encode(["success" => false, "message" => "Semua field user wajib diisi"]);
exit;
}
$data = [
"name" => $name,
"phone" => $phone,
"address" => $address,
"photoUrl" => $photoUrl,
];
$result = apiPost("/auth/sync", $data, $token);
echo json_encode($result);

View File

@ -0,0 +1,111 @@
<?php
require_once "../config/auth.php";
require_once "../services/collar.service.php";
requireLogin();
header("Content-Type: application/json");
$action = $_POST["action"] ?? "";
switch ($action) {
case "get":
$id = $_POST["id"] ?? null;
if (!$id) {
echo json_encode([
"success" => false,
"message" => "Collar ID is required"
]);
exit;
}
$collar = getCollarById($id);
echo json_encode([
"success" => true,
"data" => $collar
]);
break;
case "create":
$data = [
"serialNumber" => trim($_POST["serialNumber"] ?? ""),
"deviceName" => trim($_POST["deviceName"] ?? ""),
"hardwareVersion" => trim($_POST["hardwareVersion"] ?? ""),
"firmwareVersion" => trim($_POST["firmwareVersion"] ?? ""),
"macAddress" => trim($_POST["macAddress"] ?? ""),
"simNumber" => trim($_POST["simNumber"] ?? "")
];
$response = createCollar($data);
echo json_encode($response);
break;
case "update":
$id = $_POST["id"] ?? null;
if (!$id) {
echo json_encode([
"success" => false,
"message" => "Collar ID is required"
]);
exit;
}
$data = [
"serialNumber" => trim($_POST["serialNumber"] ?? ""),
"deviceName" => trim($_POST["deviceName"] ?? ""),
"hardwareVersion" => trim($_POST["hardwareVersion"] ?? ""),
"firmwareVersion" => trim($_POST["firmwareVersion"] ?? ""),
"macAddress" => trim($_POST["macAddress"] ?? ""),
"simNumber" => trim($_POST["simNumber"] ?? "")
];
$response = updateCollar($id, $data);
echo json_encode($response);
break;
case "delete":
$id = $_POST["id"] ?? null;
if (!$id) {
echo json_encode([
"success" => false,
"message" => "Collar ID is required"
]);
exit;
}
$response = deleteCollar($id);
echo json_encode($response);
break;
default:
echo json_encode([
"success" => false,
"message" => "Invalid action"
]);
break;
}

187
smartcollar-fe/ajax/cow.php Normal file
View File

@ -0,0 +1,187 @@
<?php
require_once "../config/auth.php";
require_once "../services/cow.service.php";
requireLogin();
header("Content-Type: application/json");
/*
|--------------------------------------------------------------------------
| Action
|--------------------------------------------------------------------------
*/
$action = $_POST["action"] ?? "";
/*
|--------------------------------------------------------------------------
| Switch Action
|--------------------------------------------------------------------------
*/
switch ($action) {
/*
|--------------------------------------------------------------------------
| Get Cow By ID
|--------------------------------------------------------------------------
*/
case "get":
$id = $_POST["id"] ?? null;
if (!$id) {
echo json_encode([
"success" => false,
"message" => "Cow ID is required"
]);
exit;
}
$cow = getCowById($id);
// Map backend field names to frontend expectations
if (is_array($cow)) {
$cow["description"] = $cow["note"] ?? "";
}
echo json_encode([
"success" => true,
"data" => $cow
]);
break;
/*
|--------------------------------------------------------------------------
| Create Cow
|--------------------------------------------------------------------------
*/
case "create":
// Map frontend fields to backend API schema
$gender = strtolower(trim($_POST["gender"] ?? ""));
$weight = (float) ($_POST["weight"] ?? 0);
if ($weight <= 0) {
$weight = 1; // default to 1 to satisfy positive validation
}
$data = [
"code" => trim($_POST["code"] ?? ""),
"name" => trim($_POST["name"] ?? ""),
"breed" => trim($_POST["breed"] ?? ""),
"gender" => $gender,
"birthDate" => trim($_POST["birthDate"] ?? ""),
"weight" => $weight,
"note" => trim($_POST["description"] ?? ""),
"color" => trim($_POST["color"] ?? ""),
"photoUrl" => trim($_POST["photoUrl"] ?? ""),
"status" => trim($_POST["status"] ?? "healthy")
];
$response = createCow($data);
echo json_encode($response);
break;
/*
|--------------------------------------------------------------------------
| Update Cow
|--------------------------------------------------------------------------
*/
case "update":
$id = $_POST["id"] ?? null;
if (!$id) {
echo json_encode([
"success" => false,
"message" => "Cow ID is required"
]);
exit;
}
$gender = strtolower(trim($_POST["gender"] ?? ""));
$weight = (float) ($_POST["weight"] ?? 0);
if ($weight <= 0) {
$weight = 1;
}
$data = [
"code" => trim($_POST["code"] ?? ""),
"name" => trim($_POST["name"] ?? ""),
"breed" => trim($_POST["breed"] ?? ""),
"gender" => $gender,
"birthDate" => trim($_POST["birthDate"] ?? ""),
"weight" => $weight,
"note" => trim($_POST["description"] ?? ""),
"color" => trim($_POST["color"] ?? ""),
"photoUrl" => trim($_POST["photoUrl"] ?? ""),
"status" => trim($_POST["status"] ?? "healthy")
];
$response = updateCow(
$id,
$data
);
echo json_encode($response);
break;
/*
|--------------------------------------------------------------------------
| Delete Cow
|--------------------------------------------------------------------------
*/
case "delete":
$id = $_POST["id"] ?? null;
if (!$id) {
echo json_encode([
"success" => false,
"message" => "Cow ID is required"
]);
exit;
}
$response = deleteCow($id);
echo json_encode($response);
break;
/*
|--------------------------------------------------------------------------
| Default
|--------------------------------------------------------------------------
*/
default:
echo json_encode([
"success" => false,
"message" => "Invalid action"
]);
break;
}

View File

@ -0,0 +1,22 @@
<?php
require_once "../config/auth.php";
require_once "../config/api.php";
requireLogin();
header("Content-Type: application/json");
$token =
getToken();
$cowId =
$_GET["cowId"] ?? "";
$data =
apiGet(
"/monitoring/latest/" . $cowId,
$token
);
echo json_encode($data);

View File

@ -0,0 +1,35 @@
<?php
require_once "../config/auth.php";
require_once "../config/api.php";
requireLogin();
header("Content-Type: application/json");
$token = getToken();
$cowId = $_GET["cowId"] ?? "";
$date = $_GET["date"] ?? null; // expected YYYY-MM-DD
if (!$cowId) {
echo json_encode(["success" => false, "message" => "cowId required"]);
exit;
}
$year = $month = $day = null;
if ($date) {
$parts = explode('-', $date);
if (count($parts) === 3) {
$year = $parts[0]; $month = $parts[1]; $day = $parts[2];
}
}
$endpoint = "/monitoring/history/" . $cowId;
if ($year && $month && $day) {
$endpoint .= "?year={$year}&month={$month}&day={$day}";
}
$result = apiGet($endpoint, $token);
echo json_encode($result);

View File

@ -0,0 +1 @@

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,379 @@
/*
|--------------------------------------------------------------------------
| Monitoring Aktivitas Real-time (accelX)
|--------------------------------------------------------------------------
*/
const cowSelect = document.getElementById("cowSelect");
let currentCow = cowSelect && cowSelect.value ? cowSelect.value : "";
const MAX_POINTS = 30; // jumlah titik chart yang disimpan
const CHART_JS_URL = "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.4/chart.umd.min.js";
let activityChart;
let historyData = []; // { timestamp, accelX }[]
let lastPlottedTimestamp = null; // hanya push ke chart kalau timestamp data berubah (data baru masuk)
let chartReady = null; // promise, biar renderChart() tidak dipanggil sebelum library siap
let resizeTimer = null;
/*
|--------------------------------------------------------------------------
| Load Chart.js (dynamic, hindari race condition)
|--------------------------------------------------------------------------
*/
function loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = src;
script.async = true;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
function ensureChartJs() {
if (chartReady) {
return chartReady;
}
if (typeof Chart !== "undefined") {
chartReady = Promise.resolve();
return chartReady;
}
chartReady = loadScript(CHART_JS_URL).catch((error) => {
console.error("Gagal memuat Chart.js dari CDN. Periksa koneksi internet / ad-blocker.", error);
throw error;
});
return chartReady;
}
/*
|--------------------------------------------------------------------------
| Helper: deteksi layar mobile
|--------------------------------------------------------------------------
*/
function isMobileScreen() {
return window.innerWidth < 576;
}
function isTabletScreen() {
return window.innerWidth < 768;
}
/*
|--------------------------------------------------------------------------
| Render Chart
|--------------------------------------------------------------------------
*/
function renderChart(data) {
const ctx = document.getElementById("activityChart");
if (!ctx || typeof Chart === "undefined") {
return;
}
// Urutkan lama -> baru khusus untuk chart biar tren terbaca alami
const sorted = [...data].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
const mobile = isMobileScreen();
const tablet = isTabletScreen();
const labels = sorted.map(d => {
const t = new Date(d.timestamp);
if (isNaN(t.getTime())) return "";
return mobile
? t.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })
: t.toLocaleString(undefined, { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
});
const values = sorted.map(d => parseFloat(d.accelX) || 0);
if (activityChart) activityChart.destroy();
const tickFont = mobile ? 10 : 12;
const legendFont = mobile ? 11 : 13;
const maxTicks = mobile ? 4 : (tablet ? 6 : 8);
const pointRadius = mobile ? 1.5 : 2;
activityChart = new Chart(ctx, {
type: "line",
data: {
labels: labels,
datasets: [{
label: "Aktivitas - accelX",
data: values,
borderColor: "#34d399",
backgroundColor: "rgba(52, 211, 153, 0.15)",
fill: true,
tension: 0.35,
borderWidth: mobile ? 1.5 : 2,
pointRadius: pointRadius,
pointBackgroundColor: "#10b981"
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
layout: {
padding: mobile ? 4 : 8
},
plugins: {
legend: {
display: !mobile,
labels: { color: "#e2e8f0", font: { size: legendFont } }
}
},
scales: {
x: {
ticks: { color: "#94a3b8", maxTicksLimit: maxTicks, font: { size: tickFont } },
grid: { color: "rgba(255,255,255,0.05)" }
},
y: {
ticks: { color: "#94a3b8", font: { size: tickFont } },
grid: { color: "rgba(255,255,255,0.05)" }
}
}
}
});
}
function resetChart() {
historyData = [];
lastPlottedTimestamp = null;
if (activityChart) {
activityChart.destroy();
activityChart = null;
}
}
/*
|--------------------------------------------------------------------------
| Resize handling (debounced)
|--------------------------------------------------------------------------
*/
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (historyData.length > 0) {
renderChart(historyData);
}
}, 300);
});
/*
|--------------------------------------------------------------------------
| Change Cow
|--------------------------------------------------------------------------
*/
if (cowSelect) {
cowSelect.addEventListener(
"change",
function () {
currentCow = this.value;
resetChart();
loadAktivitas();
}
);
}
/*
|--------------------------------------------------------------------------
| Load Data Aktivitas
|--------------------------------------------------------------------------
*/
async function loadAktivitas() {
if (!currentCow) {
return;
}
try {
const response =
await fetch(
"../ajax/dashboard.php?cowId=" +
currentCow
);
const result =
await response.json();
if (!result.success) {
console.log(result.message);
return;
}
const data = result.data;
if (!data || Object.keys(data).length === 0) {
console.log("Data aktivitas tidak tersedia untuk sapi ini.");
return;
}
updateAktivitas(data);
}
catch (error) {
console.log(error);
}
}
/*
|--------------------------------------------------------------------------
| Ambil Nilai accelX
|--------------------------------------------------------------------------
| Sesuai skema BE (createMonitoringSchema): sensor.movement hanya berisi
| field accelX (number). Tidak ada accelY di data yang dikirim device.
*/
function extractAccelX(movement) {
if (!movement || typeof movement !== "object") {
return null;
}
return (typeof movement.accelX !== "undefined" && movement.accelX !== null)
? movement.accelX
: null;
}
/*
|--------------------------------------------------------------------------
| Update Card & Chart
|--------------------------------------------------------------------------
*/
function updateAktivitas(data) {
if (!data) {
return;
}
// Ambil nilai sensor accelX dari data.movement (sesuai skema BE).
const movement = (typeof data.movement === "object" && data.movement !== null) ? data.movement : null;
const accelX = extractAccelX(movement);
const movementStatus = movement && typeof movement.status !== "undefined" ? movement.status : null;
// Card nilai aktivitas (accelX)
const valAktivitas = document.getElementById("val-aktivitas");
if (valAktivitas) {
valAktivitas.innerText = (accelX !== null && typeof accelX !== "undefined") ? accelX : "-";
}
// Badge Diam / Bergerak (berdasarkan movement.status: 0 = Diam, selain itu Bergerak)
const valGerak = document.getElementById("val-gerak");
if (valGerak && movementStatus !== null && typeof movementStatus !== "undefined") {
const status = Number(movementStatus);
if (status === 0) {
valGerak.className = "badge bg-light text-dark px-3 py-1 rounded-pill";
valGerak.innerText = "DIAM";
} else {
valGerak.className = "badge bg-success px-3 py-1 rounded-pill";
valGerak.innerText = "BERGERAK";
}
}
// Status online/offline collar
const lastUpdate = data.timestamp ? new Date(data.timestamp) : null;
const age = lastUpdate ? (Date.now() - lastUpdate.getTime()) : Infinity;
const isOnline = age < 30000;
const status = document.getElementById("status");
if (status) {
status.innerText = isOnline ? "ONLINE" : "OFFLINE";
status.className = "value-text " + (isOnline ? "text-success" : "text-danger");
}
const elLast = document.getElementById("lastUpdate");
if (elLast && lastUpdate) {
elLast.innerText = lastUpdate.toLocaleString();
}
// Chart: hanya tambah titik & render ulang kalau ada data BARU dari
// device (timestamp berbeda dari titik terakhir yang sudah diplot).
if (accelX !== null && typeof accelX !== "undefined" && data.timestamp) {
const isNewData = data.timestamp !== lastPlottedTimestamp;
if (isNewData) {
lastPlottedTimestamp = data.timestamp;
historyData.push({ timestamp: data.timestamp, accelX: accelX });
if (historyData.length > MAX_POINTS) {
historyData.shift();
}
renderChart(historyData);
}
}
}
/*
|--------------------------------------------------------------------------
| Init & Refresh
|--------------------------------------------------------------------------
*/
async function start() {
try {
await ensureChartJs();
} catch (error) {
// Chart.js gagal dimuat (mis. offline / CDN diblokir).
// Card aktivitas tetap jalan normal, hanya grafik yang tidak tampil.
const cardChart = document.getElementById("activityChart");
if (cardChart && cardChart.parentElement) {
cardChart.parentElement.innerHTML =
'<p class="text-muted mb-0">Grafik tidak dapat dimuat (Chart.js gagal diakses). Data aktivitas tetap diperbarui pada card di atas.</p>';
}
}
loadAktivitas();
setInterval(loadAktivitas, 2000);
}
start();

View File

@ -0,0 +1,46 @@
import { auth } from "./firebase.js";
const refreshSessionToken = async () => {
try {
const user = auth.currentUser;
if (!user) {
return;
}
const token = await user.getIdToken(true);
await fetch("/session.php", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ token, uid: user.uid, email: user.email }),
});
} catch (error) {
console.error("[AUTH] Failed to refresh token:", error);
}
};
const scheduleTokenRefresh = () => {
setInterval(() => {
refreshSessionToken();
}, 1000 * 60 * 4); // refresh setiap 4 menit
};
window.addEventListener("DOMContentLoaded", () => {
auth.onAuthStateChanged((user) => {
if (user) {
refreshSessionToken();
scheduleTokenRefresh();
}
});
window.addEventListener("focus", () => {
refreshSessionToken();
});
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
refreshSessionToken();
}
});
});

View File

@ -0,0 +1,131 @@
document.addEventListener('DOMContentLoaded', () => {
const assignForm = document.getElementById('assignForm');
const assignTableBody = document.getElementById('assignmentTableBody');
const cowSelect = document.querySelector('select[name="cowId"]');
const collarSelect = document.querySelector('select[name="collarId"]');
const initialCowOptions = cowSelect
? Array.from(cowSelect.options)
.filter(option => option.value)
.map(option => ({ value: option.value, text: option.text }))
: [];
const initialCollarOptions = collarSelect
? Array.from(collarSelect.options)
.filter(option => option.value)
.map(option => ({ value: option.value, text: option.text }))
: [];
function refreshDropdowns(assignedCowIds, assignedCollarIds) {
if (cowSelect) {
cowSelect.innerHTML = '';
cowSelect.appendChild(new Option('-- Select Cow --', ''));
for (const option of initialCowOptions) {
if (assignedCowIds.has(option.value)) continue;
cowSelect.appendChild(new Option(option.text, option.value));
}
}
if (collarSelect) {
collarSelect.innerHTML = '';
collarSelect.appendChild(new Option('-- Select Collar --', ''));
for (const option of initialCollarOptions) {
if (assignedCollarIds.has(option.value)) continue;
collarSelect.appendChild(new Option(option.text, option.value));
}
}
}
async function loadAssignments() {
if (!assignTableBody) {
console.error('Assignment table body element not found');
return;
}
const form = new FormData();
form.append('action', 'list');
const res = await fetch('../ajax/assignment.php', { method: 'POST', body: form });
const json = await res.json();
if (!json.success) {
alert(json.message);
return;
}
const activeAssignments = Array.isArray(json.data)
? json.data.filter(a => a.active)
: [];
const assignedCowIds = new Set(activeAssignments.map(a => a.cowId).filter(Boolean));
const assignedCollarIds = new Set(activeAssignments.map(a => a.collarId).filter(Boolean));
refreshDropdowns(assignedCowIds, assignedCollarIds);
assignTableBody.innerHTML = '';
if (activeAssignments.length === 0) {
const emptyRow = document.createElement('tr');
emptyRow.innerHTML = '<td colspan="6" class="text-center">No active assignments</td>';
assignTableBody.appendChild(emptyRow);
} else {
for (const a of activeAssignments) {
const status = a.active ? 'Assigned' : 'Inactive';
const cowLabel = a.cowName ? a.cowName : a.cowId ?? '';
const deviceLabel = a.deviceName ? a.deviceName : a.collarId ?? '';
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${a.id}</td>
<td>${cowLabel}</td>
<td>${deviceLabel}</td>
<td>${status}</td>
<td>${a.assignedAt ?? ''}</td>
<td><button class="btn btn-sm btn-danger detachBtn" data-cowid="${a.cowId}">Detach</button></td>
`;
assignTableBody.appendChild(tr);
}
}
document.querySelectorAll('.detachBtn').forEach(btn => {
btn.addEventListener('click', async function () {
const cowId = this.dataset.cowid;
if (!confirm('Detach this assignment?')) return;
const form = new FormData();
form.append('action', 'detach');
form.append('cowId', cowId);
const res = await fetch('../ajax/assignment.php', { method: 'POST', body: form });
const j = await res.json();
if (j.success) {
alert('Detached');
loadAssignments();
} else {
alert(j.message);
}
});
});
}
if (assignForm) {
assignForm.addEventListener('submit', async function (e) {
e.preventDefault();
const formData = new FormData(this);
const cowId = formData.get('cowId');
const collarId = formData.get('collarId');
if (!cowId || !collarId) {
alert('Select cow and collar');
return;
}
const form = new FormData();
form.append('action', 'attach');
form.append('cowId', cowId);
form.append('collarId', collarId);
const res = await fetch('../ajax/assignment.php', { method: 'POST', body: form });
const j = await res.json();
if (j.success) {
alert('Assigned');
loadAssignments();
} else {
alert(j.message);
}
});
}
loadAssignments();
});

View File

@ -0,0 +1,90 @@
import {
auth
} from "./firebase.js";
import {
signInWithEmailAndPassword
} from "https://www.gstatic.com/firebasejs/11.10.0/firebase-auth.js";
const form =
document.getElementById("loginForm");
if (form) {
form.addEventListener(
"submit",
async function (e) {
e.preventDefault();
const email =
document.getElementById("email").value;
const password =
document.getElementById("password").value;
try {
const credential =
await signInWithEmailAndPassword(
auth,
email,
password
);
const token =
await credential.user.getIdToken();
const response =
await fetch(
"/session.php",
{
method: "POST",
headers: {
"Content-Type":
"application/json"
},
body: JSON.stringify({
token,
uid:
credential.user.uid,
email:
credential.user.email
})
}
);
const result =
await response.json();
if (result.success) {
window.location =
"/pages/dashboard.php";
}
}
catch (error) {
alert(error.message);
}
}
);
}

View File

@ -0,0 +1,147 @@
document.addEventListener("DOMContentLoaded", () => {
if (typeof bootstrap === "undefined") {
console.error("Bootstrap JS belum dimuat.");
return;
}
const createModalEl = document.getElementById("createCollarModal");
const editModalEl = document.getElementById("editCollarModal");
const deleteModalEl = document.getElementById("deleteCollarModal");
const createModal = createModalEl ? new bootstrap.Modal(createModalEl) : null;
const editModal = editModalEl ? new bootstrap.Modal(editModalEl) : null;
const deleteModal = deleteModalEl ? new bootstrap.Modal(deleteModalEl) : null;
/* CREATE */
const createForm = document.getElementById("createCollarForm");
if (createForm) {
createForm.addEventListener("submit", async function (e) {
e.preventDefault();
const formData = new FormData(this);
const data = {};
formData.forEach((v, k) => data[k] = v);
const form = new FormData();
for (const k in data) form.append(k, data[k]);
form.append("action", "create");
const response = await fetch("../ajax/collar.php", { method: "POST", body: form });
const result = await response.json();
if (result.success) {
alert("Collar berhasil ditambahkan.");
createModal.hide();
location.reload();
} else {
alert(result.message);
}
});
}
/* EDIT BUTTON */
document.querySelectorAll('.editCollarBtn').forEach(btn => {
btn.addEventListener('click', async function () {
const id = this.dataset.id;
const form = new FormData();
form.append('action', 'get');
form.append('id', id);
const response = await fetch('../ajax/collar.php', { method: 'POST', body: form });
const result = await response.json();
if (!result.success) { alert(result.message); return; }
const collar = result.data;
document.querySelector('#editCollarForm input[name=id]').value = collar.id;
document.querySelector('#editCollarForm input[name=serialNumber]').value = collar.serialNumber ?? '';
document.querySelector('#editCollarForm input[name=deviceName]').value = collar.deviceName ?? '';
document.querySelector('#editCollarForm input[name=hardwareVersion]').value = collar.hardwareVersion ?? '';
document.querySelector('#editCollarForm input[name=firmwareVersion]').value = collar.firmwareVersion ?? '';
document.querySelector('#editCollarForm input[name=macAddress]').value = collar.macAddress ?? '';
document.querySelector('#editCollarForm input[name=simNumber]').value = collar.simNumber ?? '';
editModal.show();
});
});
/* UPDATE */
const editForm = document.getElementById('editCollarForm');
if (editForm) {
editForm.addEventListener('submit', async function (e) {
e.preventDefault();
const formData = new FormData(this);
const data = {};
formData.forEach((v,k)=> data[k]=v);
const form = new FormData();
for (const k in data) form.append(k, data[k]);
form.append('action','update');
const response = await fetch('../ajax/collar.php', { method: 'POST', body: form });
const result = await response.json();
if (result.success) {
alert('Collar berhasil diupdate.');
editModal.hide();
location.reload();
} else { alert(result.message); }
});
}
/* SECRET ACTIONS */
document.querySelectorAll('.toggle-secret-btn').forEach(btn => {
btn.addEventListener('click', function () {
const group = this.closest('.input-group');
const input = group.querySelector('.secret-input');
if (!input) return;
const isPassword = input.type === 'password';
input.type = isPassword ? 'text' : 'password';
this.textContent = isPassword ? 'Hide' : 'Show';
});
});
document.querySelectorAll('.copy-secret-btn').forEach(btn => {
btn.addEventListener('click', async function () {
const group = this.closest('.input-group');
const input = group.querySelector('.secret-input');
if (!input) return;
try {
await navigator.clipboard.writeText(input.value);
alert('Device secret copied to clipboard.');
} catch (error) {
console.error(error);
alert('Unable to copy device secret.');
}
});
});
/* DELETE */
document.querySelectorAll('.deleteCollarBtn').forEach(btn => {
btn.addEventListener('click', function () {
const id = this.dataset.id;
const form = document.querySelector('#deleteCollarForm');
form.querySelector('input[name=id]').value = id;
});
});
const deleteForm = document.getElementById('deleteCollarForm');
if (deleteForm) {
deleteForm.addEventListener('submit', async function (e) {
e.preventDefault();
const formData = new FormData(this);
formData.append('action','delete');
const response = await fetch('../ajax/collar.php', { method: 'POST', body: formData });
const result = await response.json();
if (result.success) {
alert('Collar berhasil dihapus.');
deleteModal.hide();
location.reload();
} else { alert(result.message); }
});
}
});

View File

@ -0,0 +1,249 @@
document.addEventListener("DOMContentLoaded", () => {
/*
|--------------------------------------------------------------------------
| Bootstrap Check
|--------------------------------------------------------------------------
*/
if (typeof bootstrap === "undefined") {
console.error("Bootstrap JS belum dimuat.");
return;
}
/*
|--------------------------------------------------------------------------
| Toast Notification (pengganti alert())
|--------------------------------------------------------------------------
*/
function showToast(message, type = "success") {
const toast = document.createElement("div");
toast.className = `toast-glass ${type}`;
const icon = type === "success" ? "fa-circle-check" : "fa-circle-exclamation";
toast.innerHTML = `<i class="fas ${icon}"></i><span>${message}</span>`;
document.body.appendChild(toast);
requestAnimationFrame(() => toast.classList.add("show"));
setTimeout(() => {
toast.classList.remove("show");
setTimeout(() => toast.remove(), 350);
}, 2500);
}
/*
|--------------------------------------------------------------------------
| Modal
|--------------------------------------------------------------------------
*/
const createCowModalElement = document.getElementById("createCowModal");
const editCowModalElement = document.getElementById("editCowModal");
const deleteCowModalElement = document.getElementById("deleteCowModal");
const createModal = createCowModalElement ? new bootstrap.Modal(createCowModalElement) : null;
const editModal = editCowModalElement ? new bootstrap.Modal(editCowModalElement) : null;
const deleteModal = deleteCowModalElement ? new bootstrap.Modal(deleteCowModalElement) : null;
/*
|--------------------------------------------------------------------------
| CREATE
|--------------------------------------------------------------------------
*/
const createForm = document.getElementById("createCowForm");
if (createForm) {
createForm.addEventListener("submit", async function (e) {
e.preventDefault();
const formData = new FormData(this);
formData.append("action", "create");
const response = await fetch("../ajax/cow.php", {
method: "POST",
body: formData
});
const result = await response.json();
if (result.success) {
showToast("Cow berhasil ditambahkan.", "success");
createModal.hide();
setTimeout(() => location.reload(), 800);
} else {
showToast(result.message || "Gagal menambahkan cow.", "error");
}
});
}
/*
|--------------------------------------------------------------------------
| EDIT BUTTON
|--------------------------------------------------------------------------
*/
document.querySelectorAll(".editBtn").forEach(button => {
button.addEventListener("click", async function () {
const id = this.dataset.id;
const form = new FormData();
form.append("action", "get");
form.append("id", id);
const response = await fetch("../ajax/cow.php", {
method: "POST",
body: form
});
const result = await response.json();
if (!result.success) {
showToast(result.message || "Gagal memuat data cow.", "error");
return;
}
const cow = result.data;
document.querySelector("#editCowForm input[name=id]").value = cow.id;
document.getElementById("editBody").innerHTML = `
<div class="mb-3">
<label class="form-label">Code</label>
<input class="form-control" name="code" value="${cow.code}" required>
</div>
<div class="mb-3">
<label class="form-label">Name</label>
<input class="form-control" name="name" value="${cow.name}" required>
</div>
<div class="mb-3">
<label class="form-label">Breed</label>
<input class="form-control" name="breed" value="${cow.breed ?? ""}">
</div>
<div class="mb-3">
<label class="form-label">Gender</label>
<select class="form-select" name="gender">
<option value="male" ${cow.gender == "male" ? "selected" : ""}>Male</option>
<option value="female" ${cow.gender == "female" ? "selected" : ""}>Female</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Weight</label>
<input type="number" class="form-control" name="weight" value="${cow.weight ?? 0}">
</div>
<div class="mb-3">
<label class="form-label">Birth Date</label>
<input type="date" class="form-control" name="birthDate" value="${cow.birthDate ?? ""}">
</div>
<div class="mb-3">
<label class="form-label">Color</label>
<input type="text" class="form-control" name="color" value="${cow.color ?? ""}">
</div>
<div class="mb-3">
<label class="form-label">Status</label>
<select class="form-select" name="status">
<option value="healthy" ${cow.status == "healthy" ? "selected" : ""}>Healthy</option>
<option value="sick" ${cow.status == "sick" ? "selected" : ""}>Sick</option>
<option value="pregnant" ${cow.status == "pregnant" ? "selected" : ""}>Pregnant</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea class="form-control" name="description">${cow.note ?? ""}</textarea>
</div>
`;
editModal.show();
});
});
/*
|--------------------------------------------------------------------------
| UPDATE
|--------------------------------------------------------------------------
*/
const editForm = document.getElementById("editCowForm");
if (editForm) {
editForm.addEventListener("submit", async function (e) {
e.preventDefault();
const formData = new FormData(this);
formData.append("action", "update");
const response = await fetch("../ajax/cow.php", {
method: "POST",
body: formData
});
const result = await response.json();
if (result.success) {
showToast("Cow berhasil diupdate.", "success");
editModal.hide();
setTimeout(() => location.reload(), 800);
} else {
showToast(result.message || "Gagal update cow.", "error");
}
});
}
/*
|--------------------------------------------------------------------------
| DELETE BUTTON (buka modal konfirmasi, TIDAK langsung hapus)
|--------------------------------------------------------------------------
*/
document.querySelectorAll(".deleteBtn").forEach(button => {
button.addEventListener("click", function () {
const id = this.dataset.id;
document.querySelector("#deleteCowForm input[name=id]").value = id;
deleteModal.show();
});
});
/*
|--------------------------------------------------------------------------
| DELETE (dieksekusi setelah user konfirmasi di modal)
|--------------------------------------------------------------------------
*/
const deleteForm = document.getElementById("deleteCowForm");
if (deleteForm) {
deleteForm.addEventListener("submit", async function (e) {
e.preventDefault();
const formData = new FormData(this);
formData.append("action", "delete");
const response = await fetch("../ajax/cow.php", {
method: "POST",
body: formData
});
const result = await response.json();
if (result.success) {
showToast("Cow berhasil dihapus.", "success");
deleteModal.hide();
setTimeout(() => location.reload(), 800);
} else {
showToast(result.message || "Gagal menghapus cow.", "error");
}
});
}
});

View File

@ -0,0 +1,435 @@
/*
|--------------------------------------------------------------------------
| Dashboard Monitoring
|--------------------------------------------------------------------------
*/
/*
|--------------------------------------------------------------------------
| MAP
|--------------------------------------------------------------------------
*/
let map;
let marker;
const cowSelect =
document.getElementById("cowSelect");
let currentCow =
cowSelect && cowSelect.value ? cowSelect.value : "";
const googleMapsApiKey = typeof GOOGLE_MAPS_API_KEY !== "undefined" ? GOOGLE_MAPS_API_KEY : "YOUR_GOOGLE_MAPS_API_KEY";
const useGoogleMaps = googleMapsApiKey && googleMapsApiKey !== "YOUR_GOOGLE_MAPS_API_KEY";
let mapProvider = useGoogleMaps ? "google" : "leaflet";
let mapAssetsLoaded = false;
function loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = src;
script.async = true;
script.defer = true;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
function loadCss(href) {
if (document.querySelector(`link[href="${href}"]`)) {
return;
}
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
document.head.appendChild(link);
}
async function loadMapAssets() {
if (mapAssetsLoaded) {
return;
}
if (mapProvider === "google") {
try {
await loadScript(`https://maps.googleapis.com/maps/api/js?key=${googleMapsApiKey}`);
} catch (error) {
console.error("Google Maps failed to load, falling back to Leaflet.", error);
mapProvider = "leaflet";
}
}
if (mapProvider === "leaflet") {
loadCss("https://unpkg.com/leaflet/dist/leaflet.css");
await loadScript("https://unpkg.com/leaflet/dist/leaflet.js");
}
mapAssetsLoaded = true;
}
function initGoogleMap(position) {
if (!window.google || !window.google.maps) {
console.error("Google Maps API not loaded");
return;
}
map = new google.maps.Map(document.getElementById("map"), {
center: position,
zoom: 18,
disableDefaultUI: false,
streetViewControl: false,
mapTypeControl: false,
});
}
function initLeafletMap(position) {
if (typeof L === "undefined") {
console.error("Leaflet not loaded");
return;
}
map = L.map("map").setView([position.lat, position.lng], 18);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
}).addTo(map);
}
async function ensureMap(position) {
await loadMapAssets();
if (!map) {
if (mapProvider === "google") {
initGoogleMap(position);
} else {
initLeafletMap(position);
}
}
}
/*
|--------------------------------------------------------------------------
| Change Cow
|--------------------------------------------------------------------------
*/
if (cowSelect) {
cowSelect.addEventListener(
"change",
function () {
currentCow = this.value;
loadMonitoring();
}
);
}
/*
|--------------------------------------------------------------------------
| Load Monitoring
|--------------------------------------------------------------------------
*/
async function loadMonitoring() {
if (!currentCow) {
return;
}
try {
const response =
await fetch(
"../ajax/dashboard.php?cowId=" +
currentCow
);
const result =
await response.json();
if (!result.success) {
console.log(result.message);
return;
}
const data = result.data;
if (!data || Object.keys(data).length === 0) {
console.log('No monitoring data available for selected cow.');
return;
}
await updateDashboard(data);
}
catch (error) {
console.log(error);
}
}
/*
|--------------------------------------------------------------------------
| Update Dashboard
|--------------------------------------------------------------------------
*/
async function updateDashboard(data) {
if (!data) {
return;
}
const status = document.getElementById("status");
const lastUpdate = new Date(data.timestamp);
const age = Date.now() - lastUpdate.getTime();
const isOnline = age < 30000;
if (data.gps && typeof data.gps.latitude !== "undefined" && typeof data.gps.longitude !== "undefined") {
const lat = data.gps.latitude;
const lng = data.gps.longitude;
const position = { lat, lng };
await ensureMap(position);
if (mapProvider === "google") {
if (!marker) {
marker = new google.maps.Marker({
position,
map,
title: "Smart Collar"
});
} else {
marker.setPosition(position);
}
map.panTo(position);
} else if (mapProvider === "leaflet") {
if (!marker) {
marker = L.marker([position.lat, position.lng]).addTo(map);
} else {
marker.setLatLng([position.lat, position.lng]);
}
map.panTo([position.lat, position.lng]);
}
}
// Render device cards (support single object or array)
const cardsContainer = document.getElementById("deviceCards");
if (cardsContainer) {
const devices = Array.isArray(data) ? data : [data];
renderDeviceCards(devices, cardsContainer);
}
// Safely update any legacy elements if present (page may have removed them)
const elTemp = document.getElementById("temperature");
if (elTemp && typeof data.temperature !== 'undefined') elTemp.innerHTML = (typeof data.temperature === 'object' && data.temperature.value ? data.temperature.value : data.temperature) + " °C";
// Update new dashboard stat fields if present
const valSuhu = document.getElementById('val-suhu');
if (valSuhu && typeof data.temperature !== 'undefined') {
const t = (typeof data.temperature === 'object' && data.temperature.value ? data.temperature.value : data.temperature);
valSuhu.innerText = t !== null && typeof t !== 'undefined' ? t : '-';
}
const elSignal = document.getElementById("signal");
if (elSignal && typeof data.signal !== 'undefined') elSignal.innerHTML = data.signal + " dBm";
const valAkt = document.getElementById('val-aktivitas');
if (valAkt) {
// prefer movement status from backend (Diam/Bergerak)
if (data.movement && typeof data.movement.status !== 'undefined') {
const status = data.movement.status;
// Map numeric status to Indonesian labels: 0=Diam, 1-2=Bergerak
const statusLabel = status === 0 ? 'DIAM' : 'BERGERAK';
valAkt.innerText = statusLabel;
} else if (typeof data.activity !== 'undefined') {
valAkt.innerText = data.activity;
} else {
valAkt.innerText = '-';
}
}
if (status) { status.innerHTML = isOnline ? "ONLINE" : "OFFLINE"; status.className = isOnline ? "text-success" : "text-danger"; }
const elLat = document.getElementById("latitude");
if (elLat && data.gps && typeof data.gps.latitude !== 'undefined') elLat.innerHTML = data.gps.latitude;
const elLng = document.getElementById("longitude");
if (elLng && data.gps && typeof data.gps.longitude !== 'undefined') elLng.innerHTML = data.gps.longitude;
const vLat = document.getElementById('val-lat');
const vLng = document.getElementById('val-long');
if (vLat && data.gps && typeof data.gps.latitude !== 'undefined') vLat.innerText = data.gps.latitude;
if (vLng && data.gps && typeof data.gps.longitude !== 'undefined') vLng.innerText = data.gps.longitude;
const elLast = document.getElementById("lastUpdate");
if (elLast && lastUpdate) elLast.innerHTML = lastUpdate.toLocaleString();
// Update register badge condition if present
const valKond = document.getElementById('val-kondisi');
if (valKond && typeof data.temperature !== 'undefined') {
const t = (typeof data.temperature === 'object' && data.temperature.value ? data.temperature.value : data.temperature);
if (t !== null && typeof t !== 'undefined') {
if (t >= 40) { valKond.className = 'badge bg-danger px-3 py-1 rounded-pill'; valKond.innerText = 'BAHAYA'; }
else if (t >= 39.5) { valKond.className = 'badge bg-warning text-dark px-3 py-1 rounded-pill'; valKond.innerText = 'WASPADA'; }
else { valKond.className = 'badge bg-light text-dark px-3 py-1 rounded-pill'; valKond.innerText = 'NORMAL'; }
}
}
/*
|--------------------------------------------------------------------------
| Google Maps
|--------------------------------------------------------------------------
*/
document
.getElementById("maps")
.href =
data.gps.linkMaps;
}
function renderDeviceCards(devices, container) {
container.innerHTML = "";
devices.forEach((d) => {
const id = d.deviceId || d.collarId || d.id || currentCow || "-";
const tempVal = (typeof d.temperature !== 'undefined') ? d.temperature : null;
const temp = tempVal !== null ? tempVal + ' °C' : '-';
const rawMovement = (typeof d.movement !== 'undefined') ? d.movement : (typeof d.movementValue !== 'undefined' ? d.movementValue : null);
let movementVal = null;
// backend provides movement as object { accelX, status }
if (rawMovement !== null && typeof rawMovement === 'number') movementVal = rawMovement;
else if (rawMovement !== null && typeof rawMovement === 'object') {
if (typeof rawMovement.accelX === 'number') movementVal = rawMovement.accelX;
else if (typeof rawMovement.value === 'number') movementVal = rawMovement.value;
}
// prefer movement status string for display when available
const movementStatusStr = (rawMovement && typeof rawMovement === 'object' && rawMovement.status) ? rawMovement.status : null;
const activity = movementVal !== null ? movementVal : (movementStatusStr !== null ? movementStatusStr : (d.activity || d.motion || d.activityState || '-'));
const lat = d.gps && typeof d.gps.latitude !== 'undefined' ? d.gps.latitude : '-';
const lng = d.gps && typeof d.gps.longitude !== 'undefined' ? d.gps.longitude : '-';
const lastUpdate = d.timestamp ? new Date(d.timestamp) : null;
const age = lastUpdate ? (Date.now() - lastUpdate.getTime()) : Infinity;
const status = age < 30000 ? 'ONLINE' : 'OFFLINE';
const statusClass = age < 30000 ? 'text-success' : 'text-danger';
// Temperature status: threshold — adjust as needed
let tempStatus = 'Normal';
let tempStatusClass = 'text-success';
if (tempVal !== null) {
if (tempVal >= 40) { tempStatus = 'Bahaya'; tempStatusClass = 'text-danger'; }
else if (tempVal >= 39.5) { tempStatus = 'Waspada'; tempStatusClass = 'text-warning'; }
else { tempStatus = 'Normal'; tempStatusClass = 'text-success'; }
} else {
tempStatus = '-'; tempStatusClass = 'text-muted';
}
// Use movement status from backend (Diam/Bergerak)
let actShort = '-';
if (movementStatusStr !== null && typeof movementStatusStr === 'number') {
const status = Number(movementStatusStr);
// Map numeric status to Indonesian labels: 0=Diam, 1-2=Bergerak
actShort = status === 0 ? 'Diam' : 'Bergerak';
} else if (activity) {
// Fallback to activity string if status not available
const actLower = ('' + activity).toLowerCase();
actShort = (actLower.includes('move') || actLower.includes('moving') || actLower.includes('walk') || actLower.includes('run')) ? 'Bergerak' : (actLower.includes('sleep') || actLower.includes('rest') || actLower.includes('idle') ? 'Diam' : (activity === '-' ? '-' : activity));
}
const row = document.createElement('div');
row.className = 'd-flex flex-row gap-3 mb-3 align-items-stretch';
// Temp card
const tempCard = document.createElement('div');
tempCard.className = 'card text-center shadow-sm device-card bg-gradient-suhu';
tempCard.style.flex = '1 1 32%';
tempCard.innerHTML = `
<div class="card-body">
<h6 class="card-title">Suhu Tubuh</h6>
<div class="h2 mb-1">${temp}</div>
<small class="${tempStatusClass}">${tempStatus}</small>
</div>
`;
// Activity card
const actCard = document.createElement('div');
actCard.className = 'card text-center shadow-sm device-card bg-gradient-aktivitas';
actCard.style.flex = '1 1 32%';
actCard.innerHTML = `
<div class="card-body">
<h6 class="card-title">Aktivitas</h6>
<div class="h3 mb-1">${actShort}</div>
<small class="text-muted">${fmt(movementVal !== null ? movementVal : activity)}</small>
</div>
`;
// GPS card
const gpsCard = document.createElement('div');
gpsCard.className = 'card text-center shadow-sm device-card bg-gradient-lokasi';
gpsCard.style.flex = '1 1 32%';
const mapsHref = d.gps && d.gps.linkMaps ? d.gps.linkMaps : `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`;
gpsCard.innerHTML = `
<div class="card-body">
<h6 class="card-title">Koordinat GPS</h6>
<div class="mb-1">Lat: ${lat}</div>
<div class="mb-2">Lng: ${lng}</div>
</div>
`;
// Status card
const statusCard = document.createElement('div');
statusCard.className = 'card text-center shadow-sm device-card';
statusCard.style.flex = '1 1 20%';
statusCard.innerHTML = `
<div class="card-body">
<h6 class="card-title">Status</h6>
<div class="h5 mb-1 ${statusClass}">${status}</div>
<small class="text-muted">${lastUpdate ? lastUpdate.toLocaleString() : '-'}</small>
</div>
`;
row.appendChild(tempCard);
row.appendChild(actCard);
row.appendChild(gpsCard);
row.appendChild(statusCard);
container.appendChild(row);
});
}
// small utility to format objects safely
function fmt(v) {
if (v === null || typeof v === 'undefined') return '-';
if (typeof v === 'object') {
if (typeof v.value !== 'undefined') return v.value;
if (typeof v.movement !== 'undefined') return v.movement;
if (typeof v.type !== 'undefined') return v.type;
try { return JSON.stringify(v); } catch (e) { return String(v); }
}
return v;
}
/*
|--------------------------------------------------------------------------
| Refresh
|--------------------------------------------------------------------------
*/
loadMonitoring();
setInterval(
loadMonitoring,
2000
);

View File

@ -0,0 +1,14 @@
import {
initializeApp
} from "https://www.gstatic.com/firebasejs/11.10.0/firebase-app.js";
import {
getAuth
} from "https://www.gstatic.com/firebasejs/11.10.0/firebase-auth.js";
console.log("APP =", window.APP);
console.log("Firebase =", window.APP?.firebase);
const app = initializeApp(window.APP.firebase);
export const auth = getAuth(app);

View File

@ -0,0 +1,821 @@
/* History page script */
document.addEventListener('DOMContentLoaded', () => {
const loadBtn = document.getElementById('loadHistory');
const cowSelect = document.getElementById('cowSelect');
const dateStart = document.getElementById('dateStart');
const dateEnd = document.getElementById('dateEnd');
const historyBody = document.getElementById('historyBody');
const searchInput = document.getElementById('searchInput');
const paginationControls = document.getElementById('paginationControls');
const paginationInfo = document.getElementById('paginationInfo');
const exportCsvBtn = document.getElementById('exportCsvBtn');
const exportPdfBtn = document.getElementById('exportPdfBtn');
let allData = [];
let filteredData = [];
let currentPage = 1;
const perPage = 10;
let tempChart = null;
let cowDetails = null; // Store cow details
/*
|--------------------------------------------------------------------------
| Quick Date Presets
|--------------------------------------------------------------------------
*/
document.querySelectorAll('.btn-preset').forEach(btn => {
btn.addEventListener('click', function () {
document.querySelectorAll('.btn-preset').forEach(b => b.classList.remove('active'));
this.classList.add('active');
const today = new Date();
const range = this.dataset.range;
if (range === 'today') {
dateStart.value = formatDate(today);
dateEnd.value = formatDate(today);
} else {
const days = parseInt(range, 10);
const past = new Date();
past.setDate(today.getDate() - (days - 1));
dateStart.value = formatDate(past);
dateEnd.value = formatDate(today);
}
loadHistory();
});
});
function formatDate(d) {
return d.toISOString().split('T')[0];
}
/*
|--------------------------------------------------------------------------
| Bikin daftar tanggal antara start - end (inklusif)
|--------------------------------------------------------------------------
*/
function getDateRange(start, end) {
const dates = [];
let current = new Date(start);
const last = new Date(end);
while (current <= last) {
dates.push(formatDate(current));
current.setDate(current.getDate() + 1);
}
return dates;
}
/*
|--------------------------------------------------------------------------
| Ambil satu hari data dari API asli (GET ?cowId=..&date=..)
|--------------------------------------------------------------------------
*/
async function fetchCowDetails(cowId) {
try {
const formData = new FormData();
formData.append("action", "get");
formData.append("id", cowId);
const resp = await fetch("../ajax/cow.php", {
method: "POST",
body: formData
});
const result = await resp.json();
if (result.success && result.data) {
return result.data;
}
return null;
} catch (err) {
console.error('Gagal memuat detail sapi:', err);
return null;
}
}
async function fetchOneDay(cowId, date) {
try {
const resp = await fetch(`${BASE_URL_HISTORY}?cowId=${encodeURIComponent(cowId)}&date=${encodeURIComponent(date)}`);
const result = await resp.json();
if (!result.success || !Array.isArray(result.data)) return [];
return result.data;
} catch (err) {
console.error(`Gagal memuat data tanggal ${date}:`, err);
return [];
}
}
/*
|--------------------------------------------------------------------------
| Load Data (loop tiap tanggal dalam rentang, lalu digabung)
|--------------------------------------------------------------------------
*/
async function loadHistory() {
const cowId = cowSelect.value;
if (!cowId) {
alert('Pilih sapi terlebih dahulu');
return;
}
if (!dateStart.value || !dateEnd.value) {
alert('Pilih rentang tanggal terlebih dahulu');
return;
}
if (dateStart.value > dateEnd.value) {
alert('Tanggal awal tidak boleh lebih besar dari tanggal akhir');
return;
}
renderLoadingState();
const dates = getDateRange(dateStart.value, dateEnd.value);
// Ambil detail sapi terlebih dahulu
cowDetails = await fetchCowDetails(cowId);
// Ambil data tiap tanggal secara paralel
const results = await Promise.all(dates.map(date => fetchOneDay(cowId, date)));
allData = results.flat();
// Urutkan berdasarkan waktu terbaru dulu
allData.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
currentPage = 1;
applySearch();
updateSummary(allData);
renderChart(allData);
}
/*
|--------------------------------------------------------------------------
| Helper: ambil label movement dari struktur asli (object/string)
|--------------------------------------------------------------------------
*/
function getMovementLabel(movement) {
// Simplified logic: only show "Diam" or "Aktif"
if (!movement || !movement.status) return 'Diam';
return movement.status === 0 ? 'Diam' : 'Gerak';
}
function isMovementActive(movement) {
if (!movement || !movement.status) return false;
return movement.status !== 0;
}
/*
|--------------------------------------------------------------------------
| Search / Filter Client-side
|--------------------------------------------------------------------------
*/
function calculateAge(birthDate) {
if (!birthDate) return '-';
const today = new Date();
const birth = new Date(birthDate);
let age = today.getFullYear() - birth.getFullYear();
const monthDiff = today.getMonth() - birth.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
age--;
}
return age + ' tahun';
}
function applySearch() {
const keyword = (searchInput.value || '').toLowerCase().trim();
if (!keyword) {
filteredData = [...allData];
} else {
filteredData = allData.filter(item => {
const movementLabel = getMovementLabel(item.movement);
const movementText = isMovementActive(item.movement) ? 'Gerak' : 'Diam';
const cowInfo = cowDetails ? [
cowDetails.name ?? '',
cowDetails.code ?? '',
cowDetails.breed ?? '',
cowDetails.gender ?? '',
cowDetails.weight ?? '',
'normal',
movementText
].join(' ') : '';
const haystack = [
item.timestamp ?? '',
item.temperature ?? '',
cowInfo,
item.gps?.latitude ?? '',
item.gps?.longitude ?? ''
].join(' ').toLowerCase();
return haystack.includes(keyword);
});
}
renderTable();
}
searchInput.addEventListener('input', () => {
currentPage = 1;
applySearch();
});
/*
|--------------------------------------------------------------------------
| Render Table + Pagination
|--------------------------------------------------------------------------
*/
function renderTable() {
if (filteredData.length === 0) {
historyBody.innerHTML = '<tr><td colspan="10" class="text-center text-muted py-4">Tidak ada data</td></tr>';
paginationControls.innerHTML = '';
paginationInfo.textContent = '';
return;
}
const totalPages = Math.ceil(filteredData.length / perPage);
if (currentPage > totalPages) currentPage = totalPages;
const startIdx = (currentPage - 1) * perPage;
const pageData = filteredData.slice(startIdx, startIdx + perPage);
const rows = pageData.map(item => {
const t = new Date(item.timestamp);
const time = isNaN(t.getTime()) ? '-' : t.toLocaleString();
const temp = (item.temperature !== undefined && item.temperature !== null) ? item.temperature + ' °C' : '-';
const movementLabel = getMovementLabel(item.movement);
const movementClass = isMovementActive(item.movement) ? 'badge-movement-active' : 'badge-movement-idle';
const gps = item.gps ? `${item.gps.latitude || '-'}, ${item.gps.longitude || '-'}` : '-';
// Cow details
const cowName = cowDetails ? cowDetails.name : '-';
const cowCode = cowDetails ? cowDetails.code : '-';
const cowBreed = cowDetails ? cowDetails.breed : '-';
const cowGender = cowDetails ? cowDetails.gender : '-';
const cowWeight = cowDetails ? cowDetails.weight + ' kg' : '-';
const cowAge = cowDetails ? calculateAge(cowDetails.birthDate) : '-';
const cowCondition = 'Normal';
return `<tr>
<td>${time}</td>
<td>${temp}</td>
<td>${cowCode}</td>
<td>${cowName}</td>
<td>${cowBreed}</td>
<td>${cowGender}</td>
<td>${cowAge}</td>
<td>${cowWeight}</td>
<td>${cowCondition}</td>
<td><span class="${movementClass}">${movementLabel}</span></td>
<td>${gps}</td>
</tr>`;
}).join('');
historyBody.innerHTML = rows;
paginationInfo.textContent = `Menampilkan ${startIdx + 1}-${Math.min(startIdx + perPage, filteredData.length)} dari ${filteredData.length} data`;
renderPagination(totalPages);
}
function renderPagination(totalPages) {
paginationControls.innerHTML = '';
if (totalPages <= 1) return;
const createPageItem = (label, page, disabled = false, active = false) => {
const li = document.createElement('li');
li.className = `page-item ${disabled ? 'disabled' : ''} ${active ? 'active' : ''}`;
const a = document.createElement('a');
a.className = 'page-link';
a.href = '#';
a.textContent = label;
a.addEventListener('click', (e) => {
e.preventDefault();
if (disabled) return;
currentPage = page;
renderTable();
});
li.appendChild(a);
return li;
};
paginationControls.appendChild(createPageItem('«', currentPage - 1, currentPage === 1));
for (let i = 1; i <= totalPages; i++) {
paginationControls.appendChild(createPageItem(i, i, false, i === currentPage));
}
paginationControls.appendChild(createPageItem('»', currentPage + 1, currentPage === totalPages));
}
function renderLoadingState() {
historyBody.innerHTML = `
<tr><td colspan="11" class="table-loading">
<i class="fas fa-spinner fa-spin me-2"></i>Memuat data...
</td></tr>
`;
paginationControls.innerHTML = '';
paginationInfo.textContent = '';
}
/*
|--------------------------------------------------------------------------
| Summary Cards
|--------------------------------------------------------------------------
*/
function updateSummary(data) {
const temps = data.map(d => parseFloat(d.temperature)).filter(t => !isNaN(t));
document.getElementById('totalRecords').textContent = data.length;
if (temps.length === 0) {
document.getElementById('avgTemp').textContent = '-';
document.getElementById('maxTemp').textContent = '-';
document.getElementById('minTemp').textContent = '-';
return;
}
const avg = (temps.reduce((a, b) => a + b, 0) / temps.length).toFixed(1);
const max = Math.max(...temps).toFixed(1);
const min = Math.min(...temps).toFixed(1);
document.getElementById('avgTemp').textContent = avg + '°C';
document.getElementById('maxTemp').textContent = max + '°C';
document.getElementById('minTemp').textContent = min + '°C';
}
/*
|--------------------------------------------------------------------------
| Chart Tren Suhu (Chart.js)
|--------------------------------------------------------------------------
*/
function renderChart(data) {
const ctx = document.getElementById('tempChart');
if (!ctx) return;
// Urutkan lama -> baru khusus untuk chart biar tren terbaca alami
const sorted = [...data].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
const labels = sorted.map(d => {
const t = new Date(d.timestamp);
return isNaN(t.getTime()) ? '' : t.toLocaleString(undefined, { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
});
const temps = sorted.map(d => parseFloat(d.temperature) || null);
if (tempChart) tempChart.destroy();
tempChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Suhu (°C)',
data: temps,
borderColor: '#60a5fa',
backgroundColor: 'rgba(96, 165, 250, 0.15)',
fill: true,
tension: 0.35,
pointRadius: 2,
pointBackgroundColor: '#38bdf8'
}]
},
options: {
responsive: true,
plugins: {
legend: { labels: { color: '#e2e8f0' } }
},
scales: {
x: {
ticks: { color: '#94a3b8', maxTicksLimit: 8 },
grid: { color: 'rgba(255,255,255,0.05)' }
},
y: {
ticks: { color: '#94a3b8' },
grid: { color: 'rgba(255,255,255,0.05)' }
}
}
}
});
}
/*
|--------------------------------------------------------------------------
| Helper: statistik ringkas untuk kop laporan (CSV & PDF)
|--------------------------------------------------------------------------
*/
function calculateStats(data) {
const temps = data.map(d => parseFloat(d.temperature)).filter(t => !isNaN(t));
const activeCount = data.filter(d => isMovementActive(d.movement)).length;
return {
total: data.length,
avg: temps.length ? (temps.reduce((a, b) => a + b, 0) / temps.length).toFixed(1) : '-',
max: temps.length ? Math.max(...temps).toFixed(1) : '-',
min: temps.length ? Math.min(...temps).toFixed(1) : '-',
activeCount,
idleCount: data.length - activeCount
};
}
function generateReportNumber() {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
const rand = String(now.getHours()).padStart(2, '0') + String(now.getMinutes()).padStart(2, '0');
return `SC/RPT/${y}${m}${d}/${rand}`;
}
function formatLongDate(dateStr) {
if (!dateStr) return '-';
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
return d.toLocaleDateString('id-ID', { day: '2-digit', month: 'long', year: 'numeric' });
}
/*
|--------------------------------------------------------------------------
| Export CSV format surat laporan terstruktur
|--------------------------------------------------------------------------
*/
exportCsvBtn.addEventListener('click', () => {
if (filteredData.length === 0) {
alert('Tidak ada data untuk diexport.');
return;
}
const stats = calculateStats(filteredData);
const reportNo = generateReportNumber();
const now = new Date();
const rows = [];
const addRow = (...cols) => rows.push(cols);
const csvEscape = (val) => `"${String(val ?? '').replace(/"/g, '""')}"`;
const blankRow = () => rows.push(['']);
// ---------- KOP LAPORAN ----------
addRow('SMART COLLAR - LAPORAN RIWAYAT MONITORING TERNAK SAPI');
addRow('Sistem Monitoring Kesehatan Ternak Sapi');
blankRow();
addRow('Nomor Laporan', ':', reportNo);
addRow('Tanggal Dicetak', ':', now.toLocaleDateString('id-ID', { day: '2-digit', month: 'long', year: 'numeric' }) + ' ' + now.toLocaleTimeString('id-ID'));
addRow('Periode Monitoring', ':', `${formatLongDate(dateStart.value)} s/d ${formatLongDate(dateEnd.value)}`);
blankRow();
// ---------- INFORMASI SAPI ----------
addRow('DATA IDENTITAS SAPI');
if (cowDetails) {
addRow('Kode Sapi', ':', cowDetails.code ?? '-');
addRow('Nama Sapi', ':', cowDetails.name ?? '-');
addRow('Breed', ':', cowDetails.breed ?? '-');
addRow('Gender', ':', cowDetails.gender ?? '-');
addRow('Umur', ':', calculateAge(cowDetails.birthDate));
addRow('Berat', ':', cowDetails.weight ? cowDetails.weight + ' kg' : '-');
} else {
addRow('Data sapi tidak tersedia', '', '');
}
blankRow();
// ---------- RINGKASAN STATISTIK ----------
addRow('RINGKASAN HASIL MONITORING');
addRow('Total Catatan Data', ':', stats.total);
addRow('Suhu Rata-rata', ':', stats.avg !== '-' ? stats.avg + ' °C' : '-');
addRow('Suhu Tertinggi', ':', stats.max !== '-' ? stats.max + ' °C' : '-');
addRow('Suhu Terendah', ':', stats.min !== '-' ? stats.min + ' °C' : '-');
addRow('Jumlah Catatan Aktif Bergerak', ':', stats.activeCount);
addRow('Jumlah Catatan Diam', ':', stats.idleCount);
blankRow();
// ---------- TABEL DATA ----------
// Kolom identitas sapi tidak diulang di sini karena sudah tercantum sekali
// pada bagian "Data Identitas Sapi" di atas (satu laporan = satu sapi).
addRow('DETAIL DATA MONITORING');
addRow('Waktu', 'Suhu', 'Kondisi', 'Aktivitas', 'Latitude', 'Longitude');
filteredData.forEach(item => {
const t = new Date(item.timestamp);
const time = isNaN(t.getTime()) ? '-' : t.toLocaleString('id-ID');
const movementText = isMovementActive(item.movement) ? 'Gerak' : 'Diam';
addRow(
time,
item.temperature ?? '',
'Normal',
movementText,
item.gps?.latitude ?? '',
item.gps?.longitude ?? ''
);
});
blankRow();
addRow(`Laporan ini dibuat secara otomatis oleh Smart Collar System pada ${now.toLocaleString('id-ID')}.`);
let csvContent = rows.map(row => row.map(csvEscape).join(',')).join('\n');
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `laporan-riwayat-monitoring-${dateStart.value}_${dateEnd.value}.csv`;
link.click();
URL.revokeObjectURL(url);
});
exportPdfBtn.addEventListener('click', () => {
if (filteredData.length === 0) {
alert('Tidak ada data untuk diexport.');
return;
}
const { jsPDF } = window.jspdf;
const doc = new jsPDF('p', 'mm', 'a4');
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
const margin = 18;
const contentWidth = pageWidth - (2 * margin);
const stats = calculateStats(filteredData);
const reportNo = generateReportNumber();
const now = new Date();
const COLOR_PRIMARY = [37, 99, 153]; // biru kop/aksen
const COLOR_DARK = [30, 30, 30];
const COLOR_MUTED = [110, 110, 110];
const COLOR_LINE = [190, 190, 190];
const COLOR_ROW_ALT = [244, 247, 250];
/* ---------------------------------------------------------------- *
* KOP SURAT / LETTERHEAD (dicetak di setiap halaman)
* ---------------------------------------------------------------- */
function drawLetterhead() {
doc.setFillColor(...COLOR_PRIMARY);
doc.rect(0, 0, pageWidth, 24, 'F');
doc.setTextColor(255, 255, 255);
doc.setFont(undefined, 'bold');
doc.setFontSize(14);
doc.text('SMART COLLAR MONITORING SYSTEM', margin, 11);
doc.setFont(undefined, 'normal');
doc.setFontSize(9);
doc.text('Sistem Monitoring Kesehatan & Aktivitas Ternak Sapi', margin, 17);
doc.setFontSize(8);
doc.text(`No. Laporan: ${reportNo}`, pageWidth - margin, 11, { align: 'right' });
doc.text(now.toLocaleDateString('id-ID', { day: '2-digit', month: 'long', year: 'numeric' }), pageWidth - margin, 17, { align: 'right' });
doc.setDrawColor(...COLOR_PRIMARY);
doc.setLineWidth(0.8);
doc.line(0, 24, pageWidth, 24);
}
function drawFooter(pageNum, totalPages) {
const footerY = pageHeight - 12;
doc.setDrawColor(...COLOR_LINE);
doc.setLineWidth(0.2);
doc.line(margin, footerY - 5, pageWidth - margin, footerY - 5);
doc.setFontSize(7.5);
doc.setFont(undefined, 'normal');
doc.setTextColor(...COLOR_MUTED);
doc.text('Dokumen ini dihasilkan otomatis oleh Smart Collar System dan sah tanpa tanda tangan basah.', margin, footerY);
doc.text(`Halaman ${pageNum} dari ${totalPages}`, pageWidth - margin, footerY, { align: 'right' });
}
drawLetterhead();
let y = 34;
/* ---------------------------------------------------------------- *
* JUDUL LAPORAN & PERIHAL (gaya surat resmi)
* ---------------------------------------------------------------- */
doc.setTextColor(...COLOR_DARK);
doc.setFontSize(13);
doc.setFont(undefined, 'bold');
doc.text('LAPORAN RIWAYAT MONITORING TERNAK SAPI', pageWidth / 2, y, { align: 'center' });
y += 7;
doc.setFontSize(9);
doc.setFont(undefined, 'normal');
doc.text(`Perihal : Rekapitulasi data suhu tubuh dan aktivitas gerak ternak`, margin, y);
y += 5;
doc.text(`Periode : ${formatLongDate(dateStart.value)} s/d ${formatLongDate(dateEnd.value)}`, margin, y);
y += 8;
/* ---------------------------------------------------------------- *
* KOTAK INFORMASI IDENTITAS SAPI
* ---------------------------------------------------------------- */
const infoBoxHeight = 28;
doc.setDrawColor(...COLOR_LINE);
doc.setFillColor(250, 250, 251);
doc.roundedRect(margin, y, contentWidth, infoBoxHeight, 1.5, 1.5, 'FD');
doc.setFont(undefined, 'bold');
doc.setFontSize(9.5);
doc.setTextColor(...COLOR_PRIMARY);
doc.text('DATA IDENTITAS SAPI', margin + 4, y + 6);
doc.setFont(undefined, 'normal');
doc.setFontSize(9);
doc.setTextColor(...COLOR_DARK);
const colA = margin + 4;
const colB = margin + contentWidth / 2 + 2;
const labelValue = (label, value, x, yy) => {
doc.setFont(undefined, 'bold');
doc.text(label, x, yy);
doc.setFont(undefined, 'normal');
doc.text(String(value ?? '-'), x + 24, yy);
};
if (cowDetails) {
labelValue('Kode Sapi', cowDetails.code, colA, y + 13);
labelValue('Nama Sapi', cowDetails.name, colB, y + 13);
labelValue('Breed', cowDetails.breed, colA, y + 19);
labelValue('Gender', cowDetails.gender, colB, y + 19);
labelValue('Umur', calculateAge(cowDetails.birthDate), colA, y + 25);
labelValue('Berat', (cowDetails.weight ? cowDetails.weight + ' kg' : '-'), colB, y + 25);
} else {
doc.text('Data identitas sapi tidak tersedia.', colA, y + 15);
}
y += infoBoxHeight + 6;
/* ---------------------------------------------------------------- *
* KOTAK RINGKASAN STATISTIK
* ---------------------------------------------------------------- */
const summaryBoxHeight = 22;
doc.setDrawColor(...COLOR_LINE);
doc.setFillColor(250, 250, 251);
doc.roundedRect(margin, y, contentWidth, summaryBoxHeight, 1.5, 1.5, 'FD');
doc.setFont(undefined, 'bold');
doc.setFontSize(9.5);
doc.setTextColor(...COLOR_PRIMARY);
doc.text('RINGKASAN HASIL MONITORING', margin + 4, y + 6);
const summaryItems = [
['Total Data', stats.total],
['Suhu Rata-rata', stats.avg !== '-' ? stats.avg + ' °C' : '-'],
['Suhu Tertinggi', stats.max !== '-' ? stats.max + ' °C' : '-'],
['Suhu Terendah', stats.min !== '-' ? stats.min + ' °C' : '-'],
['Aktif Bergerak', stats.activeCount],
['Diam', stats.idleCount]
];
const cellW = contentWidth / summaryItems.length;
summaryItems.forEach((sitem, i) => {
const cx = margin + cellW * i + cellW / 2;
doc.setFont(undefined, 'normal');
doc.setFontSize(7.5);
doc.setTextColor(...COLOR_MUTED);
doc.text(sitem[0], cx, y + 13, { align: 'center' });
doc.setFont(undefined, 'bold');
doc.setFontSize(10.5);
doc.setTextColor(...COLOR_DARK);
doc.text(String(sitem[1]), cx, y + 19, { align: 'center' });
});
y += summaryBoxHeight + 8;
/* ---------------------------------------------------------------- *
* TABEL DETAIL DATA
* ---------------------------------------------------------------- */
doc.setFont(undefined, 'bold');
doc.setFontSize(9.5);
doc.setTextColor(...COLOR_PRIMARY);
doc.text('DETAIL DATA MONITORING', margin, y);
y += 4;
// Kolom identitas sapi (kode/nama/breed/gender/berat) tidak diulang di sini
// karena sudah ditampilkan sekali di kotak "Data Identitas Sapi" di atas
// (satu laporan = satu sapi, nilainya sama untuk semua baris).
// Lebar kolom dijumlah persis = contentWidth agar tidak meluber ke samping.
const colWidths = [40, 24, 30, 30, 50]; // total = 174 = contentWidth
const headers = ['Waktu', 'Suhu', 'Kondisi', 'Aktivitas', 'Lokasi GPS'];
const xPos = colWidths.map((w, i) => margin + colWidths.slice(0, i).reduce((a, b) => a + b, 0));
const rowHeight = 6.5;
function drawTableHeader(yy) {
doc.setFillColor(...COLOR_PRIMARY);
doc.rect(margin, yy, contentWidth, rowHeight, 'F');
doc.setFont(undefined, 'bold');
doc.setFontSize(7.8);
doc.setTextColor(255, 255, 255);
headers.forEach((h, i) => doc.text(h, xPos[i] + 1.5, yy + rowHeight - 2));
// garis vertikal pemisah kolom pada header
doc.setDrawColor(255, 255, 255);
doc.setLineWidth(0.2);
xPos.forEach(x => doc.line(x, yy, x, yy + rowHeight));
return yy + rowHeight;
}
let tableTop = y;
let cursorY = drawTableHeader(tableTop);
const bottomLimit = pageHeight - 22;
function newPage() {
doc.addPage();
drawLetterhead();
cursorY = drawTableHeader(34);
}
filteredData.forEach((item) => {
if (cursorY + rowHeight > bottomLimit) {
newPage();
}
const t = new Date(item.timestamp);
const time = isNaN(t.getTime()) ? '-' : t.toLocaleString('id-ID', { dateStyle: 'short', timeStyle: 'short' });
const gpsText = item.gps ? `${item.gps.latitude ?? '-'}, ${item.gps.longitude ?? '-'}` : '-';
const rowData = [
time,
item.temperature ? item.temperature + '°C' : '-',
'Normal',
getMovementLabel(item.movement),
gpsText
];
const isEven = Math.round((cursorY - tableTop) / rowHeight) % 2 === 0;
if (isEven) {
doc.setFillColor(...COLOR_ROW_ALT);
doc.rect(margin, cursorY, contentWidth, rowHeight, 'F');
}
doc.setFont(undefined, 'normal');
doc.setFontSize(7.5);
doc.setTextColor(...COLOR_DARK);
rowData.forEach((val, i) => {
doc.text(String(val), xPos[i] + 1.5, cursorY + rowHeight - 2, { maxWidth: colWidths[i] - 2 });
});
// garis horizontal & vertikal (grid tabel penuh)
doc.setDrawColor(...COLOR_LINE);
doc.setLineWidth(0.15);
doc.line(margin, cursorY, margin + contentWidth, cursorY);
xPos.forEach(x => doc.line(x, cursorY, x, cursorY + rowHeight));
doc.line(margin + contentWidth, cursorY, margin + contentWidth, cursorY + rowHeight);
cursorY += rowHeight;
});
// garis penutup bawah tabel
doc.setDrawColor(...COLOR_LINE);
doc.line(margin, cursorY, margin + contentWidth, cursorY);
/* ---------------------------------------------------------------- *
* BLOK PENUTUP & TANDA TANGAN
* ---------------------------------------------------------------- */
const signatureBlockHeight = 38;
if (cursorY + signatureBlockHeight > bottomLimit) {
newPage();
}
cursorY += 10;
doc.setFont(undefined, 'normal');
doc.setFontSize(8.5);
doc.setTextColor(...COLOR_DARK);
doc.text(
'Demikian laporan riwayat monitoring ini dibuat berdasarkan data yang tercatat pada sistem Smart Collar',
margin, cursorY
);
doc.text('untuk digunakan sebagaimana mestinya.', margin, cursorY + 5);
cursorY += 16;
const signCol = pageWidth - margin - 60;
doc.text(`Padalarang, ${formatLongDate(now.toISOString().split('T')[0])}`, signCol, cursorY);
doc.text('Petugas Monitoring,', signCol, cursorY + 5);
cursorY += 28;
doc.setFont(undefined, 'bold');
doc.text('( ......................................... )', signCol, cursorY);
// Cetak footer + nomor halaman pada setiap halaman setelah semua konten selesai
const totalPageCount = doc.internal.getNumberOfPages();
for (let p = 1; p <= totalPageCount; p++) {
doc.setPage(p);
drawFooter(p, totalPageCount);
}
const fileName = `laporan-riwayat-monitoring-${dateStart.value}_${dateEnd.value}.pdf`;
doc.save(fileName);
});
/*
|--------------------------------------------------------------------------
| Event Listener Utama
|--------------------------------------------------------------------------
*/
loadBtn.addEventListener('click', loadHistory);
});

View File

@ -0,0 +1,292 @@
/*
|--------------------------------------------------------------------------
| Monitoring Lokasi (GPS) Real-time Leaflet (tampilan ala Google Maps)
|--------------------------------------------------------------------------
*/
const cowSelect = document.getElementById("cowSelect");
let currentCow = cowSelect && cowSelect.value ? cowSelect.value : "";
let lastPlottedTimestamp = null; // hanya update peta kalau timestamp data berubah (data baru masuk)
/*
|--------------------------------------------------------------------------
| Map setup (Leaflet + tile bergaya Google Maps)
|--------------------------------------------------------------------------
*/
let map;
let marker;
let mapAssetsLoaded = false;
// Tile "Voyager" dari CARTO — palet warna paling mendekati Google Maps
// (jalan putih/kuning, air biru muda, area hijau lembut), gratis & tanpa API key.
const TILE_URL = "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png";
const TILE_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>';
function loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = src;
script.async = true;
script.defer = true;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
function loadCss(href) {
if (document.querySelector(`link[href="${href}"]`)) {
return;
}
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
document.head.appendChild(link);
}
async function loadMapAssets() {
if (mapAssetsLoaded) {
return;
}
loadCss("https://unpkg.com/leaflet/dist/leaflet.css");
try {
await loadScript("https://unpkg.com/leaflet/dist/leaflet.js");
} catch (error) {
console.error("Leaflet gagal dimuat dari CDN.", error);
const mapEl = document.getElementById("map");
if (mapEl) {
mapEl.innerHTML = '<div class="d-flex align-items-center justify-content-center h-100 text-muted">Peta tidak dapat dimuat (CDN diblokir). Koordinat tetap tersedia pada card di atas.</div>';
}
throw error;
}
mapAssetsLoaded = true;
}
function getGoogleStylePin() {
// Icon marker ala pin merah Google Maps.
// Dibuat setelah Leaflet siap karena butuh L.divIcon.
return L.divIcon({
className: "gmap-style-pin",
html: `
<div style="
width: 30px; height: 42px;
display:flex; align-items:flex-start; justify-content:center;
">
<svg width="30" height="42" viewBox="0 0 30 42" xmlns="http://www.w3.org/2000/svg">
<path d="M15 0C6.7 0 0 6.7 0 15c0 10.5 12.4 24.6 14 26.4a1.3 1.3 0 0 0 2 0C17.6 39.6 30 25.5 30 15 30 6.7 23.3 0 15 0z" fill="#EA4335"/>
<circle cx="15" cy="15" r="6.5" fill="#ffffff"/>
</svg>
</div>
`,
iconSize: [30, 42],
iconAnchor: [15, 42],
});
}
function initLeafletMap(position) {
if (typeof L === "undefined") {
console.error("Leaflet belum siap");
return;
}
map = L.map("map", {
zoomControl: true,
attributionControl: true,
}).setView([position.lat, position.lng], 17);
L.tileLayer(TILE_URL, {
maxZoom: 20,
attribution: TILE_ATTRIBUTION,
}).addTo(map);
// Posisi kontrol zoom dipindah ke kanan bawah, seperti Google Maps
map.zoomControl.setPosition("bottomright");
}
async function ensureMap(position) {
await loadMapAssets();
if (!map) {
initLeafletMap(position);
}
}
function updateMarker(position) {
if (!marker) {
marker = L.marker([position.lat, position.lng], { icon: getGoogleStylePin() }).addTo(map);
} else {
marker.setLatLng([position.lat, position.lng]);
}
map.panTo([position.lat, position.lng]);
}
/*
|--------------------------------------------------------------------------
| Change Cow
|--------------------------------------------------------------------------
*/
if (cowSelect) {
cowSelect.addEventListener(
"change",
function () {
currentCow = this.value;
lastPlottedTimestamp = null;
loadLokasi();
}
);
}
/*
|--------------------------------------------------------------------------
| Load Data Lokasi
|--------------------------------------------------------------------------
*/
async function loadLokasi() {
if (!currentCow) {
return;
}
try {
const response =
await fetch(
"../ajax/dashboard.php?cowId=" +
currentCow
);
const result =
await response.json();
if (!result.success) {
console.log(result.message);
return;
}
const data = result.data;
if (!data || Object.keys(data).length === 0) {
console.log("Data lokasi tidak tersedia untuk sapi ini.");
return;
}
await updateLokasi(data);
}
catch (error) {
console.log(error);
}
}
/*
|--------------------------------------------------------------------------
| Update Card & Peta
|--------------------------------------------------------------------------
*/
async function updateLokasi(data) {
if (!data) {
return;
}
// Status online/offline collar
const lastUpdate = data.timestamp ? new Date(data.timestamp) : null;
const age = lastUpdate ? (Date.now() - lastUpdate.getTime()) : Infinity;
const isOnline = age < 30000;
const status = document.getElementById("status");
if (status) {
status.innerText = isOnline ? "ONLINE" : "OFFLINE";
status.className = "value-text " + (isOnline ? "text-success" : "text-danger");
}
const elLast = document.getElementById("lastUpdate");
if (elLast && lastUpdate) {
elLast.innerText = lastUpdate.toLocaleString();
}
if (!data.gps || typeof data.gps.latitude === "undefined" || typeof data.gps.longitude === "undefined") {
return;
}
// Card koordinat selalu ditampilkan sesuai data terkini
const elLat = document.getElementById("latitude");
if (elLat) elLat.innerText = data.gps.latitude;
const elLng = document.getElementById("longitude");
if (elLng) elLng.innerText = data.gps.longitude;
const mapsLink = document.getElementById("maps");
if (mapsLink) {
mapsLink.href = data.gps.linkMaps || `https://www.google.com/maps?q=${data.gps.latitude},${data.gps.longitude}`;
}
// Peta hanya di-update (marker digeser) kalau ada data BARU dari
// device (timestamp berbeda dari titik terakhir).
const isNewData = data.timestamp !== lastPlottedTimestamp;
if (!isNewData) {
return;
}
lastPlottedTimestamp = data.timestamp;
const position = { lat: data.gps.latitude, lng: data.gps.longitude };
try {
await ensureMap(position);
updateMarker(position);
} catch (error) {
// sudah ditangani pesan fallback di loadMapAssets()
}
}
/*
|--------------------------------------------------------------------------
| Init & Refresh
|--------------------------------------------------------------------------
*/
loadLokasi();
setInterval(
loadLokasi,
2000
);

View File

@ -0,0 +1,125 @@
import {
auth
} from "./firebase.js";
import {
createUserWithEmailAndPassword
} from "https://www.gstatic.com/firebasejs/11.10.0/firebase-auth.js";
const form = document.getElementById("registerForm");
if (form) {
form.addEventListener("submit", async function (e) {
e.preventDefault();
const name = document.getElementById("name").value.trim();
const email = document.getElementById("email").value.trim();
const rawPhone = document.getElementById("phone").value.trim();
const address = document.getElementById("address").value.trim();
const password = document.getElementById("password").value;
const alertCont = document.getElementById("alert-container");
const showError = (message) => {
if (alertCont) {
alertCont.innerHTML = `
<div class="alert alert-danger animate__animated animate__shakeX">
<i class="fas fa-circle-exclamation me-2"></i>${message}
</div>
`;
} else {
alert(message);
}
};
if (!name || !email || !rawPhone || !address || !password) {
showError("Semua field user wajib diisi.");
return;
}
const formatPhone = (value) => {
if (!value) return "";
if (value.startsWith("+62")) return value;
if (value.startsWith("62")) return "+" + value;
if (value.startsWith("08")) return "+62" + value.slice(1);
return value;
};
const phone = formatPhone(rawPhone);
if (!phone.startsWith("+62")) {
alert("Nomor WA harus diawali dengan +62, misal +628123...");
return;
}
try {
const credential = await createUserWithEmailAndPassword(
auth,
email,
password
);
const token = await credential.user.getIdToken();
const response = await fetch("/session.php", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
token,
uid: credential.user.uid,
email: credential.user.email,
name,
phone,
address
})
});
const result = await response.json();
if (!result.success) {
throw new Error(result.message || "Gagal menyimpan session");
}
const syncResponse = await fetch("/ajax/auth_sync.php", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
name,
phone,
address,
photoUrl: ""
})
});
const syncResult = await syncResponse.json();
if (!syncResult.success) {
throw new Error(syncResult.message || "Gagal sinkron user");
}
window.location = "/pages/dashboard.php";
} catch (error) {
let message = error.message || "Terjadi kesalahan, silakan coba lagi.";
if (error.code) {
switch (error.code) {
case "auth/email-already-in-use":
message = "Email sudah terdaftar. Silakan gunakan email lain atau login.";
break;
case "auth/weak-password":
message = "Password terlalu lemah. Gunakan minimal 6 karakter.";
break;
case "auth/invalid-email":
message = "Format email tidak valid.";
break;
case "auth/network-request-failed":
message = "Koneksi internet bermasalah. Coba lagi.";
break;
}
}
showError(message);
}
});
}

View File

@ -0,0 +1,365 @@
/*
|--------------------------------------------------------------------------
| Monitoring Suhu Real-time
|--------------------------------------------------------------------------
*/
const cowSelect = document.getElementById("cowSelect");
let currentCow = cowSelect && cowSelect.value ? cowSelect.value : "";
const MAX_POINTS = 30; // jumlah titik chart yang disimpan (± 1 menit @ interval 2 detik)
const CHART_JS_URL = "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.4/chart.umd.min.js";
let tempChart;
let historyData = []; // { timestamp, temperature }[]
let lastPlottedTimestamp = null; // hanya push ke chart kalau timestamp data berubah (data baru masuk)
let chartReady = null; // promise, biar renderChart() tidak dipanggil sebelum library siap
let resizeTimer = null;
/*
|--------------------------------------------------------------------------
| Load Chart.js (dynamic, hindari race condition)
|--------------------------------------------------------------------------
*/
function loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = src;
script.async = true;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
function ensureChartJs() {
if (chartReady) {
return chartReady;
}
if (typeof Chart !== "undefined") {
chartReady = Promise.resolve();
return chartReady;
}
chartReady = loadScript(CHART_JS_URL).catch((error) => {
console.error("Gagal memuat Chart.js dari CDN. Periksa koneksi internet / ad-blocker.", error);
throw error;
});
return chartReady;
}
/*
|--------------------------------------------------------------------------
| Helper: deteksi layar mobile
|--------------------------------------------------------------------------
*/
function isMobileScreen() {
return window.innerWidth < 576;
}
function isTabletScreen() {
return window.innerWidth < 768;
}
/*
|--------------------------------------------------------------------------
| Render Chart
|--------------------------------------------------------------------------
*/
function renderChart(data) {
const ctx = document.getElementById("tempChart");
if (!ctx || typeof Chart === "undefined") {
return;
}
// Urutkan lama -> baru khusus untuk chart biar tren terbaca alami
const sorted = [...data].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
const mobile = isMobileScreen();
const tablet = isTabletScreen();
const labels = sorted.map(d => {
const t = new Date(d.timestamp);
if (isNaN(t.getTime())) return "";
// Di layar sempit tampilkan jam:menit saja biar tidak penuh
return mobile
? t.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })
: t.toLocaleString(undefined, { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
});
const temps = sorted.map(d => parseFloat(d.temperature) || null);
if (tempChart) tempChart.destroy();
const tickFont = mobile ? 10 : 12;
const legendFont = mobile ? 11 : 13;
const maxTicks = mobile ? 4 : (tablet ? 6 : 8);
const pointRadius = mobile ? 1.5 : 2;
tempChart = new Chart(ctx, {
type: "line",
data: {
labels: labels,
datasets: [{
label: "Suhu (°C)",
data: temps,
borderColor: "#60a5fa",
backgroundColor: "rgba(96, 165, 250, 0.15)",
fill: true,
tension: 0.35,
borderWidth: mobile ? 1.5 : 2,
pointRadius: pointRadius,
pointBackgroundColor: "#38bdf8"
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
layout: {
padding: mobile ? 4 : 8
},
plugins: {
legend: {
display: !mobile, // sembunyikan legend di HP biar hemat ruang
labels: { color: "#e2e8f0", font: { size: legendFont } }
}
},
scales: {
x: {
ticks: { color: "#94a3b8", maxTicksLimit: maxTicks, font: { size: tickFont } },
grid: { color: "rgba(255,255,255,0.05)" }
},
y: {
ticks: { color: "#94a3b8", font: { size: tickFont } },
grid: { color: "rgba(255,255,255,0.05)" }
}
}
}
});
}
function resetChart() {
historyData = [];
lastPlottedTimestamp = null;
if (tempChart) {
tempChart.destroy();
tempChart = null;
}
}
/*
|--------------------------------------------------------------------------
| Resize handling
|--------------------------------------------------------------------------
| Chart.js sendiri sudah responsive (auto resize saat container berubah),
| tapi label/font/ticks perlu di-render ulang penuh saat breakpoint mobile
| <-> desktop berubah (mis. rotasi layar HP). Di-debounce biar tidak
| render ulang berkali-kali saat drag resize.
*/
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (historyData.length > 0) {
renderChart(historyData);
}
}, 300);
});
/*
|--------------------------------------------------------------------------
| Change Cow
|--------------------------------------------------------------------------
*/
if (cowSelect) {
cowSelect.addEventListener(
"change",
function () {
currentCow = this.value;
resetChart();
loadSuhu();
}
);
}
/*
|--------------------------------------------------------------------------
| Load Data Suhu
|--------------------------------------------------------------------------
*/
async function loadSuhu() {
if (!currentCow) {
return;
}
try {
const response =
await fetch(
"../ajax/dashboard.php?cowId=" +
currentCow
);
const result =
await response.json();
if (!result.success) {
console.log(result.message);
return;
}
const data = result.data;
if (!data || Object.keys(data).length === 0) {
console.log("Data suhu tidak tersedia untuk sapi ini.");
return;
}
updateSuhu(data);
}
catch (error) {
console.log(error);
}
}
/*
|--------------------------------------------------------------------------
| Update Card & Chart
|--------------------------------------------------------------------------
*/
function updateSuhu(data) {
if (!data) {
return;
}
const t = (typeof data.temperature === "object" && data.temperature !== null)
? data.temperature.value
: data.temperature;
// Card nilai suhu
const valSuhu = document.getElementById("val-suhu");
if (valSuhu) {
valSuhu.innerText = (t !== null && typeof t !== "undefined") ? t : "-";
}
// Badge kondisi
const valKondisi = document.getElementById("val-kondisi");
if (valKondisi && t !== null && typeof t !== "undefined") {
if (t >= 40) {
valKondisi.className = "badge bg-danger px-3 py-1 rounded-pill";
valKondisi.innerText = "BAHAYA";
} else if (t >= 39.5) {
valKondisi.className = "badge bg-warning text-dark px-3 py-1 rounded-pill";
valKondisi.innerText = "WASPADA";
} else {
valKondisi.className = "badge bg-light text-dark px-3 py-1 rounded-pill";
valKondisi.innerText = "NORMAL";
}
}
// Status online/offline collar
const lastUpdate = data.timestamp ? new Date(data.timestamp) : null;
const age = lastUpdate ? (Date.now() - lastUpdate.getTime()) : Infinity;
const isOnline = age < 30000;
const status = document.getElementById("status");
if (status) {
status.innerText = isOnline ? "ONLINE" : "OFFLINE";
status.className = "value-text " + (isOnline ? "text-success" : "text-danger");
}
const elLast = document.getElementById("lastUpdate");
if (elLast && lastUpdate) {
elLast.innerText = lastUpdate.toLocaleString();
}
// Chart: hanya tambah titik & render ulang kalau ada data BARU dari
// device (timestamp berbeda dari titik terakhir yang sudah diplot).
// Bukan setiap kali polling 2 detik meskipun datanya masih sama.
if (t !== null && typeof t !== "undefined" && data.timestamp) {
const isNewData = data.timestamp !== lastPlottedTimestamp;
if (isNewData) {
lastPlottedTimestamp = data.timestamp;
historyData.push({ timestamp: data.timestamp, temperature: t });
if (historyData.length > MAX_POINTS) {
historyData.shift();
}
renderChart(historyData);
}
}
}
/*
|--------------------------------------------------------------------------
| Init & Refresh
|--------------------------------------------------------------------------
*/
async function start() {
try {
await ensureChartJs();
} catch (error) {
// Chart.js gagal dimuat (mis. offline / CDN diblokir).
// Card suhu tetap jalan normal, hanya grafik yang tidak tampil.
const cardChart = document.getElementById("tempChart");
if (cardChart && cardChart.parentElement) {
cardChart.parentElement.innerHTML =
'<p class="text-muted mb-0">Grafik tidak dapat dimuat (Chart.js gagal diakses). Data suhu tetap diperbarui pada card di atas.</p>';
}
}
loadSuhu();
setInterval(loadSuhu, 2000);
}
start();

View File

@ -0,0 +1,5 @@
{
"require": {
"vlucas/phpdotenv": "^5.6"
}
}

492
smartcollar-fe/composer.lock generated Normal file
View File

@ -0,0 +1,492 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "108be68e4e2b97fed51d36a10eed0849",
"packages": [
{
"name": "graham-campbell/result-type",
"version": "v1.1.4",
"source": {
"type": "git",
"url": "https://github.com/GrahamCampbell/Result-Type.git",
"reference": "e01f4a821471308ba86aa202fed6698b6b695e3b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b",
"reference": "e01f4a821471308ba86aa202fed6698b6b695e3b",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0",
"phpoption/phpoption": "^1.9.5"
},
"require-dev": {
"phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7"
},
"type": "library",
"autoload": {
"psr-4": {
"GrahamCampbell\\ResultType\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
}
],
"description": "An Implementation Of The Result Type",
"keywords": [
"Graham Campbell",
"GrahamCampbell",
"Result Type",
"Result-Type",
"result"
],
"support": {
"issues": "https://github.com/GrahamCampbell/Result-Type/issues",
"source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type",
"type": "tidelift"
}
],
"time": "2025-12-27T19:43:20+00:00"
},
{
"name": "phpoption/phpoption",
"version": "1.9.5",
"source": {
"type": "git",
"url": "https://github.com/schmittjoh/php-option.git",
"reference": "75365b91986c2405cf5e1e012c5595cd487a98be"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be",
"reference": "75365b91986c2405cf5e1e012c5595cd487a98be",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34"
},
"type": "library",
"extra": {
"bamarni-bin": {
"bin-links": true,
"forward-command": false
},
"branch-alias": {
"dev-master": "1.9-dev"
}
},
"autoload": {
"psr-4": {
"PhpOption\\": "src/PhpOption/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Johannes M. Schmitt",
"email": "schmittjoh@gmail.com",
"homepage": "https://github.com/schmittjoh"
},
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
}
],
"description": "Option Type for PHP",
"keywords": [
"language",
"option",
"php",
"type"
],
"support": {
"issues": "https://github.com/schmittjoh/php-option/issues",
"source": "https://github.com/schmittjoh/php-option/tree/1.9.5"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption",
"type": "tidelift"
}
],
"time": "2025-12-27T19:41:33+00:00"
},
{
"name": "symfony/polyfill-ctype",
"version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
"reference": "141046a8f9477948ff284fa65be2095baafb94f2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2",
"reference": "141046a8f9477948ff284fa65be2095baafb94f2",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"provide": {
"ext-ctype": "*"
},
"suggest": {
"ext-ctype": "For best performance"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Ctype\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Gert de Pagter",
"email": "BackEndTea@gmail.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for ctype functions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"ctype",
"polyfill",
"portable"
],
"support": {
"source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-04-10T16:19:22+00:00"
},
{
"name": "symfony/polyfill-mbstring",
"version": "v1.38.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
"reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
"reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
"shasum": ""
},
"require": {
"ext-iconv": "*",
"php": ">=7.2"
},
"provide": {
"ext-mbstring": "*"
},
"suggest": {
"ext-mbstring": "For best performance"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Mbstring\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for the Mbstring extension",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"mbstring",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-27T06:59:30+00:00"
},
{
"name": "symfony/polyfill-php80",
"version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php80.git",
"reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
"reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Php80\\": ""
},
"classmap": [
"Resources/stubs"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ion Bazan",
"email": "ion.bazan@gmail.com"
},
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-04-10T16:19:22+00:00"
},
{
"name": "vlucas/phpdotenv",
"version": "v5.6.4",
"source": {
"type": "git",
"url": "https://github.com/vlucas/phpdotenv.git",
"reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/416df702837983f8d5ff48c9c3fee4f5f57b980b",
"reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b",
"shasum": ""
},
"require": {
"ext-pcre": "*",
"graham-campbell/result-type": "^1.1.4",
"php": "^7.2.5 || ^8.0",
"phpoption/phpoption": "^1.9.5",
"symfony/polyfill-ctype": "^1.26",
"symfony/polyfill-mbstring": "^1.26",
"symfony/polyfill-php80": "^1.26"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"ext-filter": "*",
"phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2"
},
"suggest": {
"ext-filter": "Required to use the boolean validator."
},
"type": "library",
"extra": {
"bamarni-bin": {
"bin-links": true,
"forward-command": false
},
"branch-alias": {
"dev-master": "5.6-dev"
}
},
"autoload": {
"psr-4": {
"Dotenv\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Vance Lucas",
"email": "vance@vancelucas.com",
"homepage": "https://github.com/vlucas"
}
],
"description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.",
"keywords": [
"dotenv",
"env",
"environment"
],
"support": {
"issues": "https://github.com/vlucas/phpdotenv/issues",
"source": "https://github.com/vlucas/phpdotenv/tree/v5.6.4"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv",
"type": "tidelift"
}
],
"time": "2026-07-06T19:11:50+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {},
"platform-dev": {},
"plugin-api-version": "2.6.0"
}

View File

@ -0,0 +1,200 @@
<?php
/*
|--------------------------------------------------------------------------
| Configuration
|--------------------------------------------------------------------------
*/
require_once __DIR__ . "/../config/app.php";
/*
|--------------------------------------------------------------------------
| API Request
|--------------------------------------------------------------------------
*/
function apiRequest(
$method,
$endpoint,
$data = null,
$token = null
) {
$url = API_URL . $endpoint;
$curl = curl_init($url);
$headers = [
"Content-Type: application/json"
];
if ($token) {
$headers[] = "Authorization: Bearer {$token}";
}
curl_setopt_array(
$curl,
[
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => strtoupper($method),
CURLOPT_HTTPHEADER => $headers,
CURLOPT_CONNECTTIMEOUT => API_CONNECT_TIMEOUT,
CURLOPT_TIMEOUT => API_TIMEOUT,
]
);
if ($data !== null) {
curl_setopt(
$curl,
CURLOPT_POSTFIELDS,
json_encode($data)
);
}
$response = curl_exec($curl);
/*
|--------------------------------------------------------------------------
| CURL Error
|--------------------------------------------------------------------------
*/
if (curl_errno($curl)) {
$message = curl_error($curl);
curl_close($curl);
return [
"success" => false,
"status" => 500,
"message" => $message
];
}
/*
|--------------------------------------------------------------------------
| HTTP Status
|--------------------------------------------------------------------------
*/
$status = curl_getinfo(
$curl,
CURLINFO_HTTP_CODE
);
curl_close($curl);
/*
|--------------------------------------------------------------------------
| Decode JSON
|--------------------------------------------------------------------------
*/
$result = json_decode(
$response,
true
);
if (json_last_error() !== JSON_ERROR_NONE) {
return [
"success" => false,
"status" => $status,
"message" => "Invalid JSON response",
"raw" => $response
];
}
$result["status"] = $status;
return $result;
}
/*
|--------------------------------------------------------------------------
| GET
|--------------------------------------------------------------------------
*/
function apiGet(
$endpoint,
$token = null
) {
return apiRequest(
"GET",
$endpoint,
null,
$token
);
}
/*
|--------------------------------------------------------------------------
| POST
|--------------------------------------------------------------------------
*/
function apiPost(
$endpoint,
$data,
$token = null
) {
return apiRequest(
"POST",
$endpoint,
$data,
$token
);
}
/*
|--------------------------------------------------------------------------
| PUT
|--------------------------------------------------------------------------
*/
function apiPut(
$endpoint,
$data,
$token = null
) {
return apiRequest(
"PUT",
$endpoint,
$data,
$token
);
}
/*
|--------------------------------------------------------------------------
| DELETE
|--------------------------------------------------------------------------
*/
function apiDelete(
$endpoint,
$token = null
) {
return apiRequest(
"DELETE",
$endpoint,
null,
$token
);
}

Some files were not shown because too many files have changed in this diff Show More