From f09a2821530a86683ddf8ba53de80df705eb9645 Mon Sep 17 00:00:00 2001 From: Hilmania Zahra Date: Mon, 20 Jul 2026 16:21:52 +0700 Subject: [PATCH] Initial commit --- .../SmartCollar_Mikrokontroller.ino | 623 +++ smartcollar-be/.env.example | 21 + smartcollar-be/.gitignore | 37 + smartcollar-be/CHANGELOG.md | 34 + smartcollar-be/CONTRIBUTING.md | 40 + smartcollar-be/README.md | 115 + smartcollar-be/package-lock.json | 4568 +++++++++++++++++ smartcollar-be/package.json | 30 + smartcollar-be/src/app.js | 97 + smartcollar-be/src/config/env.js | 32 + smartcollar-be/src/config/firebase.js | 21 + smartcollar-be/src/config/fonnte.js | 11 + .../src/core/constants/constants.js | 28 + .../src/core/exceptions/AppError.js | 15 + smartcollar-be/src/core/logger/logger.js | 30 + smartcollar-be/src/core/response/response.js | 25 + .../src/core/validator/validator.js | 18 + smartcollar-be/src/docs/swagger.js | 0 .../src/middlewares/auth.middleware.js | 62 + .../src/middlewares/error.middleware.js | 24 + .../src/middlewares/notFound.middleware.js | 11 + .../src/middlewares/validation.middleware.js | 15 + .../assignment/assignment.controller.js | 96 + .../assignment/assignment.repository.js | 169 + .../modules/assignment/assignment.route.js | 39 + .../modules/assignment/assignment.schema.js | 24 + .../modules/assignment/assignment.service.js | 289 ++ .../src/modules/auth/auth.controller.js | 34 + .../src/modules/auth/auth.repository.js | 27 + smartcollar-be/src/modules/auth/auth.route.js | 28 + .../src/modules/auth/auth.schema.js | 8 + .../src/modules/auth/auth.service.js | 48 + .../src/modules/collar/collar.controller.js | 104 + .../src/modules/collar/collar.repository.js | 282 + .../src/modules/collar/collar.route.js | 38 + .../src/modules/collar/collar.schema.js | 40 + .../src/modules/collar/collar.service.js | 221 + .../src/modules/cow/cow.controller.js | 86 + .../src/modules/cow/cow.repository.js | 152 + smartcollar-be/src/modules/cow/cow.route.js | 38 + smartcollar-be/src/modules/cow/cow.schema.js | 52 + smartcollar-be/src/modules/cow/cow.service.js | 152 + .../monitoring/monitoring.controller.js | 88 + .../monitoring/monitoring.repository.js | 184 + .../modules/monitoring/monitoring.route.js | 61 + .../modules/monitoring/monitoring.schema.js | 54 + .../modules/monitoring/monitoring.service.js | 550 ++ .../notification/notification.controller.js | 16 + .../notification/notification.repository.js | 40 + .../notification/notification.route.js | 27 + .../notification/notification.schema.js | 14 + .../notification/notification.service.js | 101 + .../notification.test.controller.js | 28 + .../src/modules/setting/setting.controller.js | 0 .../src/modules/setting/setting.repository.js | 0 .../src/modules/setting/setting.route.js | 0 .../src/modules/setting/setting.schema.js | 0 .../src/modules/setting/setting.service.js | 0 smartcollar-be/src/routes/index.js | 40 + smartcollar-be/src/server.js | 11 + .../src/shared/builders/monitoring.builder.js | 150 + .../shared/calculators/movement.calculator.js | 36 + .../calculators/temperature.calculator.js | 21 + .../src/shared/constants/collar-status.js | 6 + smartcollar-be/src/shared/date/date.js | 0 smartcollar-be/src/shared/device/device.js | 0 smartcollar-be/src/shared/gps/gps.js | 0 smartcollar-be/src/shared/id/generateId.js | 5 + .../src/shared/message/messageTemplate.js | 0 smartcollar-be/src/shared/utils/device.js | 8 + smartcollar-be/src/shared/utils/owner-key.js | 49 + smartcollar-be/src/shared/utils/timestamp.js | 1 + smartcollar-fe/.env.example | 0 smartcollar-fe/.gitignore | 65 + smartcollar-fe/CHANGELOG.md | 30 + smartcollar-fe/LICENSE | 21 + smartcollar-fe/README.md | 175 + smartcollar-fe/ajax/assignment.php | 49 + smartcollar-fe/ajax/auth_sync.php | 38 + smartcollar-fe/ajax/collar.php | 111 + smartcollar-fe/ajax/cow.php | 187 + smartcollar-fe/ajax/dashboard.php | 22 + smartcollar-fe/ajax/history.php | 35 + smartcollar-fe/ajax/suhu.php | 1 + smartcollar-fe/assets/css/style.css | 1215 +++++ smartcollar-fe/assets/js/aktivitas.js | 379 ++ smartcollar-fe/assets/js/app.js | 46 + smartcollar-fe/assets/js/assignment.js | 131 + smartcollar-fe/assets/js/auth.js | 90 + smartcollar-fe/assets/js/collar.js | 147 + smartcollar-fe/assets/js/cow.js | 249 + smartcollar-fe/assets/js/dashboard.js | 435 ++ smartcollar-fe/assets/js/firebase.js | 14 + smartcollar-fe/assets/js/history.js | 821 +++ smartcollar-fe/assets/js/lokasi.js | 292 ++ smartcollar-fe/assets/js/register.js | 125 + smartcollar-fe/assets/js/suhu.js | 365 ++ smartcollar-fe/composer.json | 5 + smartcollar-fe/composer.lock | 492 ++ smartcollar-fe/config/api.php | 200 + smartcollar-fe/config/app.php | 87 + smartcollar-fe/config/auth.php | 29 + smartcollar-fe/includes/footer.php | 64 + smartcollar-fe/includes/header.php | 68 + smartcollar-fe/includes/navbar.php | 38 + smartcollar-fe/includes/sidebar.php | 103 + smartcollar-fe/index.php | 15 + smartcollar-fe/login.php | 316 ++ smartcollar-fe/logout.php | 9 + smartcollar-fe/pages/aktivitas.php | 145 + smartcollar-fe/pages/assignment.php | 101 + smartcollar-fe/pages/collars.php | 226 + smartcollar-fe/pages/cows.php | 261 + smartcollar-fe/pages/dashboard.php | 216 + smartcollar-fe/pages/history.php | 201 + smartcollar-fe/pages/lokasi.php | 164 + smartcollar-fe/pages/suhu.php | 148 + smartcollar-fe/register.php | 198 + .../services/assignment.service.php | 38 + smartcollar-fe/services/collar.service.php | 110 + smartcollar-fe/services/cow.service.php | 110 + smartcollar-fe/services/dashboard.service.php | 148 + smartcollar-fe/services/suhu.service.php | 0 smartcollar-fe/session.php | 22 + 124 files changed, 17831 insertions(+) create mode 100644 SmartCollar_Mikrokontroller/SmartCollar_Mikrokontroller.ino create mode 100644 smartcollar-be/.env.example create mode 100644 smartcollar-be/.gitignore create mode 100644 smartcollar-be/CHANGELOG.md create mode 100644 smartcollar-be/CONTRIBUTING.md create mode 100644 smartcollar-be/README.md create mode 100644 smartcollar-be/package-lock.json create mode 100644 smartcollar-be/package.json create mode 100644 smartcollar-be/src/app.js create mode 100644 smartcollar-be/src/config/env.js create mode 100644 smartcollar-be/src/config/firebase.js create mode 100644 smartcollar-be/src/config/fonnte.js create mode 100644 smartcollar-be/src/core/constants/constants.js create mode 100644 smartcollar-be/src/core/exceptions/AppError.js create mode 100644 smartcollar-be/src/core/logger/logger.js create mode 100644 smartcollar-be/src/core/response/response.js create mode 100644 smartcollar-be/src/core/validator/validator.js create mode 100644 smartcollar-be/src/docs/swagger.js create mode 100644 smartcollar-be/src/middlewares/auth.middleware.js create mode 100644 smartcollar-be/src/middlewares/error.middleware.js create mode 100644 smartcollar-be/src/middlewares/notFound.middleware.js create mode 100644 smartcollar-be/src/middlewares/validation.middleware.js create mode 100644 smartcollar-be/src/modules/assignment/assignment.controller.js create mode 100644 smartcollar-be/src/modules/assignment/assignment.repository.js create mode 100644 smartcollar-be/src/modules/assignment/assignment.route.js create mode 100644 smartcollar-be/src/modules/assignment/assignment.schema.js create mode 100644 smartcollar-be/src/modules/assignment/assignment.service.js create mode 100644 smartcollar-be/src/modules/auth/auth.controller.js create mode 100644 smartcollar-be/src/modules/auth/auth.repository.js create mode 100644 smartcollar-be/src/modules/auth/auth.route.js create mode 100644 smartcollar-be/src/modules/auth/auth.schema.js create mode 100644 smartcollar-be/src/modules/auth/auth.service.js create mode 100644 smartcollar-be/src/modules/collar/collar.controller.js create mode 100644 smartcollar-be/src/modules/collar/collar.repository.js create mode 100644 smartcollar-be/src/modules/collar/collar.route.js create mode 100644 smartcollar-be/src/modules/collar/collar.schema.js create mode 100644 smartcollar-be/src/modules/collar/collar.service.js create mode 100644 smartcollar-be/src/modules/cow/cow.controller.js create mode 100644 smartcollar-be/src/modules/cow/cow.repository.js create mode 100644 smartcollar-be/src/modules/cow/cow.route.js create mode 100644 smartcollar-be/src/modules/cow/cow.schema.js create mode 100644 smartcollar-be/src/modules/cow/cow.service.js create mode 100644 smartcollar-be/src/modules/monitoring/monitoring.controller.js create mode 100644 smartcollar-be/src/modules/monitoring/monitoring.repository.js create mode 100644 smartcollar-be/src/modules/monitoring/monitoring.route.js create mode 100644 smartcollar-be/src/modules/monitoring/monitoring.schema.js create mode 100644 smartcollar-be/src/modules/monitoring/monitoring.service.js create mode 100644 smartcollar-be/src/modules/notification/notification.controller.js create mode 100644 smartcollar-be/src/modules/notification/notification.repository.js create mode 100644 smartcollar-be/src/modules/notification/notification.route.js create mode 100644 smartcollar-be/src/modules/notification/notification.schema.js create mode 100644 smartcollar-be/src/modules/notification/notification.service.js create mode 100644 smartcollar-be/src/modules/notification/notification.test.controller.js create mode 100644 smartcollar-be/src/modules/setting/setting.controller.js create mode 100644 smartcollar-be/src/modules/setting/setting.repository.js create mode 100644 smartcollar-be/src/modules/setting/setting.route.js create mode 100644 smartcollar-be/src/modules/setting/setting.schema.js create mode 100644 smartcollar-be/src/modules/setting/setting.service.js create mode 100644 smartcollar-be/src/routes/index.js create mode 100644 smartcollar-be/src/server.js create mode 100644 smartcollar-be/src/shared/builders/monitoring.builder.js create mode 100644 smartcollar-be/src/shared/calculators/movement.calculator.js create mode 100644 smartcollar-be/src/shared/calculators/temperature.calculator.js create mode 100644 smartcollar-be/src/shared/constants/collar-status.js create mode 100644 smartcollar-be/src/shared/date/date.js create mode 100644 smartcollar-be/src/shared/device/device.js create mode 100644 smartcollar-be/src/shared/gps/gps.js create mode 100644 smartcollar-be/src/shared/id/generateId.js create mode 100644 smartcollar-be/src/shared/message/messageTemplate.js create mode 100644 smartcollar-be/src/shared/utils/device.js create mode 100644 smartcollar-be/src/shared/utils/owner-key.js create mode 100644 smartcollar-be/src/shared/utils/timestamp.js create mode 100644 smartcollar-fe/.env.example create mode 100644 smartcollar-fe/.gitignore create mode 100644 smartcollar-fe/CHANGELOG.md create mode 100644 smartcollar-fe/LICENSE create mode 100644 smartcollar-fe/README.md create mode 100644 smartcollar-fe/ajax/assignment.php create mode 100644 smartcollar-fe/ajax/auth_sync.php create mode 100644 smartcollar-fe/ajax/collar.php create mode 100644 smartcollar-fe/ajax/cow.php create mode 100644 smartcollar-fe/ajax/dashboard.php create mode 100644 smartcollar-fe/ajax/history.php create mode 100644 smartcollar-fe/ajax/suhu.php create mode 100644 smartcollar-fe/assets/css/style.css create mode 100644 smartcollar-fe/assets/js/aktivitas.js create mode 100644 smartcollar-fe/assets/js/app.js create mode 100644 smartcollar-fe/assets/js/assignment.js create mode 100644 smartcollar-fe/assets/js/auth.js create mode 100644 smartcollar-fe/assets/js/collar.js create mode 100644 smartcollar-fe/assets/js/cow.js create mode 100644 smartcollar-fe/assets/js/dashboard.js create mode 100644 smartcollar-fe/assets/js/firebase.js create mode 100644 smartcollar-fe/assets/js/history.js create mode 100644 smartcollar-fe/assets/js/lokasi.js create mode 100644 smartcollar-fe/assets/js/register.js create mode 100644 smartcollar-fe/assets/js/suhu.js create mode 100644 smartcollar-fe/composer.json create mode 100644 smartcollar-fe/composer.lock create mode 100644 smartcollar-fe/config/api.php create mode 100644 smartcollar-fe/config/app.php create mode 100644 smartcollar-fe/config/auth.php create mode 100644 smartcollar-fe/includes/footer.php create mode 100644 smartcollar-fe/includes/header.php create mode 100644 smartcollar-fe/includes/navbar.php create mode 100644 smartcollar-fe/includes/sidebar.php create mode 100644 smartcollar-fe/index.php create mode 100644 smartcollar-fe/login.php create mode 100644 smartcollar-fe/logout.php create mode 100644 smartcollar-fe/pages/aktivitas.php create mode 100644 smartcollar-fe/pages/assignment.php create mode 100644 smartcollar-fe/pages/collars.php create mode 100644 smartcollar-fe/pages/cows.php create mode 100644 smartcollar-fe/pages/dashboard.php create mode 100644 smartcollar-fe/pages/history.php create mode 100644 smartcollar-fe/pages/lokasi.php create mode 100644 smartcollar-fe/pages/suhu.php create mode 100644 smartcollar-fe/register.php create mode 100644 smartcollar-fe/services/assignment.service.php create mode 100644 smartcollar-fe/services/collar.service.php create mode 100644 smartcollar-fe/services/cow.service.php create mode 100644 smartcollar-fe/services/dashboard.service.php create mode 100644 smartcollar-fe/services/suhu.service.php create mode 100644 smartcollar-fe/session.php diff --git a/SmartCollar_Mikrokontroller/SmartCollar_Mikrokontroller.ino b/SmartCollar_Mikrokontroller/SmartCollar_Mikrokontroller.ino new file mode 100644 index 0000000..f714362 --- /dev/null +++ b/SmartCollar_Mikrokontroller/SmartCollar_Mikrokontroller.ino @@ -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 +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include + +// Install lewat Library Manager: "ArduinoJson" by Benoit Blanchon +#include + +// ====================================================== +// 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); +} \ No newline at end of file diff --git a/smartcollar-be/.env.example b/smartcollar-be/.env.example new file mode 100644 index 0000000..7dd9e86 --- /dev/null +++ b/smartcollar-be/.env.example @@ -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 : \ No newline at end of file diff --git a/smartcollar-be/.gitignore b/smartcollar-be/.gitignore new file mode 100644 index 0000000..714e8a1 --- /dev/null +++ b/smartcollar-be/.gitignore @@ -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 \ No newline at end of file diff --git a/smartcollar-be/CHANGELOG.md b/smartcollar-be/CHANGELOG.md new file mode 100644 index 0000000..452fd0f --- /dev/null +++ b/smartcollar-be/CHANGELOG.md @@ -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 diff --git a/smartcollar-be/CONTRIBUTING.md b/smartcollar-be/CONTRIBUTING.md new file mode 100644 index 0000000..0200e7b --- /dev/null +++ b/smartcollar-be/CONTRIBUTING.md @@ -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 diff --git a/smartcollar-be/README.md b/smartcollar-be/README.md new file mode 100644 index 0000000..1b2d35e --- /dev/null +++ b/smartcollar-be/README.md @@ -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 +``` + +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. diff --git a/smartcollar-be/package-lock.json b/smartcollar-be/package-lock.json new file mode 100644 index 0000000..d664b24 --- /dev/null +++ b/smartcollar-be/package-lock.json @@ -0,0 +1,4568 @@ +{ + "name": "smartcollar-be", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "smartcollar-be", + "version": "1.0.0", + "license": "ISC", + "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" + } + }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, + "node_modules/@firebase/ai": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-2.13.1.tgz", + "integrity": "sha512-RhT/VViTPBSplhQSuEp62HhLvfsV+LowMh8ZUo5MMRDzG7oFtSget4Kmg5oHP50hDVyWQuQj6to9iPFEZk08Tw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.4", + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/analytics": { + "version": "0.10.22", + "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.22.tgz", + "integrity": "sha512-8BSaq/QRGU1+xyi8L2PTLTJU7MH9aMA72RQdIxrbhWFauOZY9OXo8f2YDN/972xA8d588tlnNVEQ2Mo69pT9Ow==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/installations": "0.6.22", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/analytics-compat": { + "version": "0.2.28", + "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.28.tgz", + "integrity": "sha512-lIAlqUUbBu93FJMlQfslryQtBwwzdzvp23ePC6FNgymXk6Ook5v4Uvc0vdutvoIeqmyA3LfP0ZeRFK8+11kOOQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/analytics": "0.10.22", + "@firebase/analytics-types": "0.8.4", + "@firebase/component": "0.7.3", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/analytics-types": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.4.tgz", + "integrity": "sha512-zQ+XTgkwH6CY/eUSHJRP7e4LxM30RCxlCmob5sy2axs25GE3Ny0XdgpDscMTHHQIGqWkxPXad4w2Mw9sCgT8zQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.15.1.tgz", + "integrity": "sha512-iD9+Z5HcPo0Uop5f72/VYMeXwKucBhW7iFrISkJFvQ+lSZikTNgTz0FgAtaaTkAG0pEZSnCymA2Fu49n0rcufQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/app-check": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.12.0.tgz", + "integrity": "sha512-wMeT6HLWRAuW7Cp/5UjWBGKgjPNxWNOoNf4PRIv0weljoGMZVeqbUY7wNBWTI2/31cX1NlXx8gQruDLsUShB3Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/app-check-compat": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.4.5.tgz", + "integrity": "sha512-JI17mVcZs34zO6ZeSCrw4U2iohqy+n6GIzkbmsA+TbVjmvFLkUKt3bs5M+qRBteQm/0IWzqSHYFzEQLzDTQebg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check": "0.12.0", + "@firebase/app-check-types": "0.5.4", + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.4.tgz", + "integrity": "sha512-zz3i6e13B8BfWiLy8MABtTh8aGIACgKbf9UVnyHcWs+yQzJXgQcl8A46b0zfaiJHdQ+niF0ouAfcpuf+3LMPQg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-check-types": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.4.tgz", + "integrity": "sha512-xV7JsIyzVr15aA7f3Pi0rB9gdBuVubs89FGA8VkRYA4g0l78poADgdfrScgf7NndSg9mm7cR7PJyY0+t22KaGw==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-compat": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.15.tgz", + "integrity": "sha512-HaiSM9TwbGIR4b7F6+UncHWlqdH89eeY7VUskaOGOlI2PxHS5Z+6hHsYGvNLy0SHDE6zyXO+3QSA6a4aqQxsqA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@firebase/app": "0.15.1", + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/app-types": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.5.tgz", + "integrity": "sha512-YevqTjvo7Iujsa9Dwowmd6dSoElhzmD63ZSrq6bzjvQ6POjYgNjOFHLmNIgJs48eNO093NCERibuFnxbfOvU7A==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@firebase/logger": "0.5.1" + } + }, + "node_modules/@firebase/auth": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.13.3.tgz", + "integrity": "sha512-bqiq4uubDN2YyQkdvSWPQeJyXAv2O76ImF41En9b6UhV5JuBVYDoHYrrrE3NzIuGkpFMKagfhMRP4Vz6t+yQSQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@react-native-async-storage/async-storage": "^2.2.0 || ^3.0.0" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@firebase/auth-compat": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.6.8.tgz", + "integrity": "sha512-llcBREUC4iSNKZ6rvwud7Oz9Q7aAWU6KuQLa6pdu7Q+QAQsy4JLw6yFgxwtmzabsgznHmmcsX2UjHLLzqUxi3Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth": "1.13.3", + "@firebase/auth-types": "0.13.1", + "@firebase/component": "0.7.3", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.5.tgz", + "integrity": "sha512-1Li/YuBDBAXcKv7BzY4U28gontUmAaw53sYiqbaVOMCFb2lFKK/c3CGMUWqtwe7+TXrl3poWnTCL5umYBg85Eg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-types": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.13.1.tgz", + "integrity": "sha512-0c1Mnid0uMDfGJHeUS4zfvBa4/CedJXotGy/n/NZJnBjwiJawt0ZYU+wH2VAVLiRCEfG2ncCkAX3yd1/2nrB7g==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/component": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.3.tgz", + "integrity": "sha512-wFofIaa2879ogD/WvkjYXJxRmfnL0scen6ORgaC3na1FNOR9ASIUANQdhqQcmWu/h77/pVHY7ch5flewa5Bcew==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/data-connect": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.7.1.tgz", + "integrity": "sha512-2LbUU8mmSA63HknxQMmWHjpzuNLBKflvVwQc2tpoVKg0biWleNEJX031ELks0vzFs+dDjOUkCJR72RP6mQHFOg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth-interop-types": "0.2.5", + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/database": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.3.tgz", + "integrity": "sha512-XwWCa+E4TvNGpGwXrycLRNfdogADwFcvuhyow6wDWma9W54roaQIhe+4PM0KiLsIftBdSCGI7OKCXrdSRHbIhw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.4", + "@firebase/auth-interop-types": "0.2.5", + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.4.tgz", + "integrity": "sha512-3pK35F1MAgmqFJQlf2nhQl44vtAXQO1uaCaQOEUI9kCRtLFqi7N+QRKR7lFZPg+xIZIyubgxQaxY69YgfZRZWg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/database": "1.1.3", + "@firebase/database-types": "1.0.20", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.20.tgz", + "integrity": "sha512-kegbOk/w8iU64pr0q6k2ItyNGjnQBMHFhwS7ohdWI4W+pc0/zhhdGXTdFj6X1oxItRjPoYOsSQmERgBkn/ihxw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.5", + "@firebase/util": "1.15.1" + } + }, + "node_modules/@firebase/firestore": { + "version": "4.16.0", + "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.16.0.tgz", + "integrity": "sha512-qdHMHMvMr0nRMuZyWNR/ArWa0YlPE3C4eAbmxTASJMYXAesKPL0Y54p70moggrNPzaK7MSIIq5RDJJyntQyIYA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "@firebase/webchannel-wrapper": "1.0.6", + "@grpc/grpc-js": "~1.9.0", + "@grpc/proto-loader": "^0.7.8", + "re2js": "^0.4.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/firestore-compat": { + "version": "0.4.11", + "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.4.11.tgz", + "integrity": "sha512-W7o1WdwWq5aABK5Up2ncSvTQs/QGLR/fy7cVpFBNqhsXtxoMtflHf2xBIG6+aoptcuGAobddq4g2Sq27wqHaYw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/firestore": "4.16.0", + "@firebase/firestore-types": "3.0.4", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/firestore-types": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.4.tgz", + "integrity": "sha512-jGn+JSS4X9zZsrfu7Yw66v5YRdOLD1oyQh4USR0xWl4CUqV/DA6bNIXRPpxH/cUl3iVTNiP6MN7g+EL42A4qfA==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/firestore/node_modules/@grpc/grpc-js": { + "version": "1.9.16", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.16.tgz", + "integrity": "sha512-wE4Ut/olIzfKqp631XrG+wbF0v1vWFN4YL9FyXC2LJiG33DsV7PLzURjrCvY/6je2ntdRkeLpPDluzSRGaVltQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.7.8", + "@types/node": ">=12.12.47" + }, + "engines": { + "node": "^8.13.0 || >=10.10.0" + } + }, + "node_modules/@firebase/firestore/node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@firebase/functions": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.13.5.tgz", + "integrity": "sha512-bWCx713f4kE/uFV7gdFOLBS7lDoiZj48MRkbAqe35gkXcCeWF4QjRNO07Jhmve7EJIoQOBczL29y2r8VRuN1kw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.4", + "@firebase/auth-interop-types": "0.2.5", + "@firebase/component": "0.7.3", + "@firebase/messaging-interop-types": "0.2.5", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/functions-compat": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.4.5.tgz", + "integrity": "sha512-10qlUXGY25G5/1g9UihqksPp2po+ZqSE7LEizsrdUP7vrTmkysXxGSZCDyojSEp6mQe/ecRDdDDI+z4XRdb4wQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/functions": "0.13.5", + "@firebase/functions-types": "0.6.4", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/functions-types": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.4.tgz", + "integrity": "sha512-zV6kgqtduR4rUAdC/ilS7kmb93XD7bEZoJDlVBZqlOw2uGGGCNBQBuleww2rr0Ulr3L9o2TDjumEt68/l1f9DQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/installations": { + "version": "0.6.22", + "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.22.tgz", + "integrity": "sha512-ef6nn3GGQTdReCfotRMG77PJZu8CqEbiK5pEoBnM0gTu/Z9v0i/az2p3HABsa/1beQmmyh1OsOjf7P5+pgwdZw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/util": "1.15.1", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/installations-compat": { + "version": "0.2.22", + "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.22.tgz", + "integrity": "sha512-C/zpAuTP5S9OgKSPvXRupw3hoY/JZSlA1wFjD/Sb7LIQE0FNbcMdO8Y4KXVEkjVzma/DDDDIAzxEXqKMAzc88w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/installations": "0.6.22", + "@firebase/installations-types": "0.5.4", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/installations-types": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.4.tgz", + "integrity": "sha512-U2eFapdHwjb43Vx9o+Pmj4dFfvcHEK1IirEFLqMtWrTHvmdrS3gBpBD1kmJk/9HjsOtoHZxJ2Paoe79e+L1ZPg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/logger": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.1.tgz", + "integrity": "sha512-vZKLsqE1ABOy8OjQiE7cUTFn4gvaqlk88yp8N94Pk/sDpq61YqZGqmVFZTvOyflTwuYFcWirBdYGoJgbDaXKYQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/messaging": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.13.0.tgz", + "integrity": "sha512-GZoo0uGRvEbszo83xcgbjJp4FpkmBEr4l8Z4hi8gl+P1Spn/MTK3HapanMzSX4yUHuTEiF5hasWRxOaz+o5sxQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/installations": "0.6.22", + "@firebase/messaging-interop-types": "0.2.5", + "@firebase/util": "1.15.1", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/messaging-compat": { + "version": "0.2.27", + "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.27.tgz", + "integrity": "sha512-JNOiu1PPgdHzEPEtoFiNxQuu0x9bm4bfETSQCpGfcTlgWkhlSK7uh7nlsjC10TQLUNgYetLmuutaYTh8aeYLVA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/messaging": "0.13.0", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/messaging-interop-types": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.5.tgz", + "integrity": "sha512-tUEKnaAP2Y/MNIqgnriPpV6e5l13Vs/+p2yrd6NGlncPJT9O3a8muYZtdnWe+IJ4fgKLHJVC79n/asxk/N5Msw==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/performance": { + "version": "0.7.12", + "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.7.12.tgz", + "integrity": "sha512-fe7nV8teUU3OBHlMUZ9Lw4gLhCW2k4m5Uc3pfWGV+fl8uwJQBGp9Q3lqsJ+HSrFu3Q2pJyLAgrClPGSKyDeYgQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/installations": "0.6.22", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0", + "web-vitals": "^4.2.4" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/performance-compat": { + "version": "0.2.25", + "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.25.tgz", + "integrity": "sha512-q6NjTXpIPoFuUmCmMN/maCdTgzT6aExs9xZo+PxfVLj6uLVGvpyAD6XWjmcrb7jChsFBYbq7E5dyNDF7Zhy9kA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/performance": "0.7.12", + "@firebase/performance-types": "0.2.4", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/performance-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.4.tgz", + "integrity": "sha512-kJSEk7b0uhpcPRyL4SQ/GPujLqk52XNKcXlnsKDbWGAb9vugcLvOU3u6zfEdwd+d8hWJb5S5ZizV1JFFI0nkKg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/remote-config": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.9.0.tgz", + "integrity": "sha512-aNn6/eJhsSC+gXSToiXiYPv3ypLP9lFtzl+/q9kSOBPB7D6rae0Rt2uENZZLXGYbEgHYKQblOhijJAXGbbJjtQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/installations": "0.6.22", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/remote-config-compat": { + "version": "0.2.27", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.27.tgz", + "integrity": "sha512-FYwYWwSbUdza/pRX4NpSBm/Pimntum3jEIBpnDn5Ey1jHNWgjxrE8Z5SB4mCHd5wGCoYd3koJzxARl/VWIEx0Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/remote-config": "0.9.0", + "@firebase/remote-config-types": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/remote-config-types": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.5.1.tgz", + "integrity": "sha512-cX/1LT6KQwkXzck2eSzeKnuvXZCyr8qaPpDcikoJs7jmI+oBOXixpDLeDtWj1U6GNMkIoXrEDNoyT2Ypcyp5/A==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/storage": { + "version": "0.14.3", + "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.14.3.tgz", + "integrity": "sha512-YX4/YL6P6/fufSSeGnVhjWddcIXbFq2cWIhMKFTZo1E/Rtcl2mJj/BYUQTwJfcE1Tl8un1FOya4L05jcSLN/Eg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/storage-compat": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.4.3.tgz", + "integrity": "sha512-gruVqjtUGX8tEoeNbaWXZm0Zfcfcb7fvmDmBxV8yPAbWvExRnZYLO2+qw9idxNE7BvPXt5csyjSYHy//dAizxw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/storage": "0.14.3", + "@firebase/storage-types": "0.8.4", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/storage-types": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.4.tgz", + "integrity": "sha512-BT7cwxJOx8SWwlQfrlC+bD/Sk3Cw+1odCi8UZNFNWTVZoPsBnA5W+mqtZzVnvsdJpXCFGSGQ7R7vOR6dtM/BRA==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/util": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.15.1.tgz", + "integrity": "sha512-LUdM4Wg7YM9Pq/49nGYySJA0CSQEKnGffFzWV8+6gXN7mGxn+FL1IqvFbuZUtAQcfZgHYDwCE1wwlK7rB7gl2g==", + "hasInstallScript": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/webchannel-wrapper": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.6.tgz", + "integrity": "sha512-Vr/Mqu79dMwGRAyGbJ4uN4+BtXB3/mRTdzetD1daWNeG8QaWuzhhbG77GltO5c0yYmYls8i250iX73624GJd7Q==", + "license": "Apache-2.0" + }, + "node_modules/@google-cloud/firestore": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-8.6.0.tgz", + "integrity": "sha512-TdvZHfwQj5B5CSDEgDqyrhdVqtOSupmBXDQPasMAJiC64tjsGvyMooNiC43fdk1TsUHeklyoZ6/vQ1TjWKVMbg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "fast-deep-equal": "^3.1.3", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^5.0.1", + "protobufjs": "^7.5.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/paginator": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", + "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "arrify": "^2.0.0", + "extend": "^3.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/projectify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", + "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/promisify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", + "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.21.0.tgz", + "integrity": "sha512-l+IFTkd+6Y5LoAuXyYCKNAKtw/Ci+rAMqgdTB1jv4iZiLhw0rtq+0qjIRbBizXkNzEFmXiXUW0H7sZQQvk1ffA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@google-cloud/paginator": "^5.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "<4.1.0", + "abort-controller": "^3.0.0", + "async-retry": "^1.3.3", + "duplexify": "^4.1.3", + "fast-xml-parser": "^5.3.4", + "gaxios": "^6.0.2", + "google-auth-library": "^9.6.3", + "html-entities": "^2.5.2", + "mime": "^3.0.0", + "p-limit": "^3.0.1", + "retry-request": "^7.0.0", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@nodable/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@tootallnate/once": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", + "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/caseless": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", + "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/request": { + "version": "2.48.13", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", + "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.5" + } + }, + "node_modules/@types/request/node_modules/form-data": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.6.tgz", + "integrity": "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/@types/request/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@types/request/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "optional": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "license": "MIT", + "optional": true + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "optional": true, + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "optional": true, + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT", + "optional": true + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "optional": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT", + "optional": true + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT", + "optional": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/farmhash-modern": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/farmhash-modern/-/farmhash-modern-1.1.0.tgz", + "integrity": "sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.9.3.tgz", + "integrity": "sha512-brCNCeScma/kqa54J4PIDriSSSLssRkuYaUCpvHJulGc3HGI/xxKUCTDcYkAdqJsyb//ydpbxecjC3hB9+tb/g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@nodable/entities": "^2.2.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^1.0.1", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.4.1", + "xml-naming": "^0.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/firebase": { + "version": "12.16.0", + "resolved": "https://registry.npmjs.org/firebase/-/firebase-12.16.0.tgz", + "integrity": "sha512-CNw6hFBdONkzF8UGLDx/RDRY9gVa5VmJNHd7qi4gdmA3ZuLkuOrhmWefB2l+FN+OxFpN77Itq7aO6zlTi780ag==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/ai": "2.13.1", + "@firebase/analytics": "0.10.22", + "@firebase/analytics-compat": "0.2.28", + "@firebase/app": "0.15.1", + "@firebase/app-check": "0.12.0", + "@firebase/app-check-compat": "0.4.5", + "@firebase/app-compat": "0.5.15", + "@firebase/app-types": "0.9.5", + "@firebase/auth": "1.13.3", + "@firebase/auth-compat": "0.6.8", + "@firebase/data-connect": "0.7.1", + "@firebase/database": "1.1.3", + "@firebase/database-compat": "2.1.4", + "@firebase/firestore": "4.16.0", + "@firebase/firestore-compat": "0.4.11", + "@firebase/functions": "0.13.5", + "@firebase/functions-compat": "0.4.5", + "@firebase/installations": "0.6.22", + "@firebase/installations-compat": "0.2.22", + "@firebase/messaging": "0.13.0", + "@firebase/messaging-compat": "0.2.27", + "@firebase/performance": "0.7.12", + "@firebase/performance-compat": "0.2.25", + "@firebase/remote-config": "0.9.0", + "@firebase/remote-config-compat": "0.2.27", + "@firebase/storage": "0.14.3", + "@firebase/storage-compat": "0.4.3", + "@firebase/util": "1.15.1" + } + }, + "node_modules/firebase-admin": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-14.1.0.tgz", + "integrity": "sha512-GdHh6vHWm9LVRt+3hINWczaA7fPwnN/l4xZdqzn+wNPYErrLI1x6u1mvAJM/5IGgYI9EqQgLt1EyBG8ok/hWCg==", + "license": "Apache-2.0", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@firebase/database-compat": "^2.1.4", + "@firebase/database-types": "^1.0.20", + "farmhash-modern": "^1.1.0", + "fast-deep-equal": "^3.1.1", + "google-auth-library": "^10.6.2", + "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^4.0.1" + }, + "engines": { + "node": ">=22" + }, + "optionalDependencies": { + "@google-cloud/firestore": "^8.6.0", + "@google-cloud/storage": "^7.19.0" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "optional": true, + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "license": "MIT", + "optional": true + }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/gcp-metadata/node_modules/gaxios": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz", + "integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gcp-metadata/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.0.tgz", + "integrity": "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/google-auth-library/node_modules/gaxios": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz", + "integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/google-auth-library/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/google-gax": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-5.0.7.tgz", + "integrity": "sha512-EhiqaWWJ+9h7sCcKJTsoo6tMcjokVHhWsbSuWCnZJT4vIBP3y4mAoFLnt9SzgkVZeq24ZsFaArr06nnYYku2yA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/grpc-js": "^1.12.6", + "@grpc/proto-loader": "^0.8.0", + "duplexify": "^4.1.3", + "google-auth-library": "10.5.0", + "google-logging-utils": "1.1.3", + "node-fetch": "^3.3.2", + "object-hash": "^3.0.0", + "proto3-json-serializer": "3.0.4", + "protobufjs": "^7.5.4", + "retry-request": "^8.0.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/google-gax/node_modules/gaxios": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz", + "integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "optional": true, + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/google-gax/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/google-gax/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "optional": true, + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/google-gax/node_modules/retry-request": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-8.0.3.tgz", + "integrity": "sha512-qqoc4kkGgP9cmQDWELlOpAmfgJOg0Yi7MT82ZjiPWu451ayju4itwomjM4/dBEliify8C1b3tSaeCOldugtwPQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "extend": "^3.0.2", + "teeny-request": "^10.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/teeny-request": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-10.1.3.tgz", + "integrity": "sha512-5yDliI1uWkYPo7W+Zvrxg6YmoWuj5iC5EydewqrRTvc68nyMTZhlPPlLg6cptUGfbQAb+N9XDPDPzF6N081lug==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "stream-events": "^1.0.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "optional": true, + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz", + "integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "license": "ISC" + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unsafe": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-1.0.1.tgz", + "integrity": "sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC", + "optional": true + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "optional": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-4.1.0.tgz", + "integrity": "sha512-sbkByqyATKYJP5F4RXj03N5TUNC0QLTjCAZvwTzC4BwJZ8e0/cWxN8YROnyUth2g1/ONWi4eSFHeu6oYalrc3Q==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^6.1.3", + "limiter": "^1.1.5", + "lru-cache": "^11.0.0", + "lru-memoizer": "^3.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >= 23.0.0" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lru-memoizer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-3.0.0.tgz", + "integrity": "sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "^11.0.1" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "optional": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "optional": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/morgan": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.11.0.tgz", + "integrity": "sha512-zSkVu3t18r39pw4ixfBKvfZi3y2UOqr7d4WYwcj3m8nXpEQK4rPO6GLzs/CExoRgmX3y9EjmmcXqv6jq0SK46g==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.4.1", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/morgan/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/morgan/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nodemon/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nodemon/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0", + "optional": true + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.1.tgz", + "integrity": "sha512-h7bxdzhHk8Knyc4Tj+jMaa7fEEoUJy7p1qtbVgkYg1Uhpe5Np5VuGXCRZnkZvU+Q42M1vStt0ifa3ueykRJPmQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "optional": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC", + "optional": true + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/proto3-json-serializer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-3.0.4.tgz", + "integrity": "sha512-E1sbAYg3aEbXrq0n1ojJkRHQJGE1kaE/O6GLA94y8rnJBfgvOPTOd1b9hOceQK1FFZI9qMh1vBERCyO2ifubcw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "protobufjs": "^7.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/protobufjs": { + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/re2js": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/re2js/-/re2js-0.4.3.tgz", + "integrity": "sha512-EuNmh7jurhHEE8Ge/lBo9JuMLb3qf866Xjjfyovw3wPc7+hlqDkZq4LwhrCQMEI+ARWfrKrHozEndzlpNT0WDg==", + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/retry-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", + "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/request": "^2.48.8", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "optional": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "optional": true, + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT", + "optional": true + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/strnum": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT", + "optional": true + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/teeny-request": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", + "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.9", + "stream-events": "^1.0.5", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/teeny-request/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT", + "optional": true + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT", + "optional": true + }, + "node_modules/uuid": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/web-vitals": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", + "license": "Apache-2.0" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/websocket-driver": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz", + "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "optional": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/smartcollar-be/package.json b/smartcollar-be/package.json new file mode 100644 index 0000000..8b1d5c1 --- /dev/null +++ b/smartcollar-be/package.json @@ -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" + } +} diff --git a/smartcollar-be/src/app.js b/smartcollar-be/src/app.js new file mode 100644 index 0000000..7470305 --- /dev/null +++ b/smartcollar-be/src/app.js @@ -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; diff --git a/smartcollar-be/src/config/env.js b/smartcollar-be/src/config/env.js new file mode 100644 index 0000000..5d3ecf6 --- /dev/null +++ b/smartcollar-be/src/config/env.js @@ -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; \ No newline at end of file diff --git a/smartcollar-be/src/config/firebase.js b/smartcollar-be/src/config/firebase.js new file mode 100644 index 0000000..4c54060 --- /dev/null +++ b/smartcollar-be/src/config/firebase.js @@ -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 }; \ No newline at end of file diff --git a/smartcollar-be/src/config/fonnte.js b/smartcollar-be/src/config/fonnte.js new file mode 100644 index 0000000..760a832 --- /dev/null +++ b/smartcollar-be/src/config/fonnte.js @@ -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; \ No newline at end of file diff --git a/smartcollar-be/src/core/constants/constants.js b/smartcollar-be/src/core/constants/constants.js new file mode 100644 index 0000000..3d70b45 --- /dev/null +++ b/smartcollar-be/src/core/constants/constants.js @@ -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", +}; \ No newline at end of file diff --git a/smartcollar-be/src/core/exceptions/AppError.js b/smartcollar-be/src/core/exceptions/AppError.js new file mode 100644 index 0000000..e9d2a7b --- /dev/null +++ b/smartcollar-be/src/core/exceptions/AppError.js @@ -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; \ No newline at end of file diff --git a/smartcollar-be/src/core/logger/logger.js b/smartcollar-be/src/core/logger/logger.js new file mode 100644 index 0000000..b623fc3 --- /dev/null +++ b/smartcollar-be/src/core/logger/logger.js @@ -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; \ No newline at end of file diff --git a/smartcollar-be/src/core/response/response.js b/smartcollar-be/src/core/response/response.js new file mode 100644 index 0000000..40af8fa --- /dev/null +++ b/smartcollar-be/src/core/response/response.js @@ -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, + }); +}; \ No newline at end of file diff --git a/smartcollar-be/src/core/validator/validator.js b/smartcollar-be/src/core/validator/validator.js new file mode 100644 index 0000000..7c987f0 --- /dev/null +++ b/smartcollar-be/src/core/validator/validator.js @@ -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; +}; \ No newline at end of file diff --git a/smartcollar-be/src/docs/swagger.js b/smartcollar-be/src/docs/swagger.js new file mode 100644 index 0000000..e69de29 diff --git a/smartcollar-be/src/middlewares/auth.middleware.js b/smartcollar-be/src/middlewares/auth.middleware.js new file mode 100644 index 0000000..a0a490e --- /dev/null +++ b/smartcollar-be/src/middlewares/auth.middleware.js @@ -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; \ No newline at end of file diff --git a/smartcollar-be/src/middlewares/error.middleware.js b/smartcollar-be/src/middlewares/error.middleware.js new file mode 100644 index 0000000..1c7b173 --- /dev/null +++ b/smartcollar-be/src/middlewares/error.middleware.js @@ -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; \ No newline at end of file diff --git a/smartcollar-be/src/middlewares/notFound.middleware.js b/smartcollar-be/src/middlewares/notFound.middleware.js new file mode 100644 index 0000000..18e4321 --- /dev/null +++ b/smartcollar-be/src/middlewares/notFound.middleware.js @@ -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; \ No newline at end of file diff --git a/smartcollar-be/src/middlewares/validation.middleware.js b/smartcollar-be/src/middlewares/validation.middleware.js new file mode 100644 index 0000000..03a8b3f --- /dev/null +++ b/smartcollar-be/src/middlewares/validation.middleware.js @@ -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; \ No newline at end of file diff --git a/smartcollar-be/src/modules/assignment/assignment.controller.js b/smartcollar-be/src/modules/assignment/assignment.controller.js new file mode 100644 index 0000000..1dd5cf3 --- /dev/null +++ b/smartcollar-be/src/modules/assignment/assignment.controller.js @@ -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); + + } + +}; \ No newline at end of file diff --git a/smartcollar-be/src/modules/assignment/assignment.repository.js b/smartcollar-be/src/modules/assignment/assignment.repository.js new file mode 100644 index 0000000..24d86bb --- /dev/null +++ b/smartcollar-be/src/modules/assignment/assignment.repository.js @@ -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 + ); + +}; \ No newline at end of file diff --git a/smartcollar-be/src/modules/assignment/assignment.route.js b/smartcollar-be/src/modules/assignment/assignment.route.js new file mode 100644 index 0000000..e8907ae --- /dev/null +++ b/smartcollar-be/src/modules/assignment/assignment.route.js @@ -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; \ No newline at end of file diff --git a/smartcollar-be/src/modules/assignment/assignment.schema.js b/smartcollar-be/src/modules/assignment/assignment.schema.js new file mode 100644 index 0000000..2362764 --- /dev/null +++ b/smartcollar-be/src/modules/assignment/assignment.schema.js @@ -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) + +}); \ No newline at end of file diff --git a/smartcollar-be/src/modules/assignment/assignment.service.js b/smartcollar-be/src/modules/assignment/assignment.service.js new file mode 100644 index 0000000..e676de3 --- /dev/null +++ b/smartcollar-be/src/modules/assignment/assignment.service.js @@ -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" + + }; + +}; \ No newline at end of file diff --git a/smartcollar-be/src/modules/auth/auth.controller.js b/smartcollar-be/src/modules/auth/auth.controller.js new file mode 100644 index 0000000..c78fc27 --- /dev/null +++ b/smartcollar-be/src/modules/auth/auth.controller.js @@ -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); + } +}; \ No newline at end of file diff --git a/smartcollar-be/src/modules/auth/auth.repository.js b/smartcollar-be/src/modules/auth/auth.repository.js new file mode 100644 index 0000000..4143dab --- /dev/null +++ b/smartcollar-be/src/modules/auth/auth.repository.js @@ -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(); +}; \ No newline at end of file diff --git a/smartcollar-be/src/modules/auth/auth.route.js b/smartcollar-be/src/modules/auth/auth.route.js new file mode 100644 index 0000000..4b0a6a4 --- /dev/null +++ b/smartcollar-be/src/modules/auth/auth.route.js @@ -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; \ No newline at end of file diff --git a/smartcollar-be/src/modules/auth/auth.schema.js b/smartcollar-be/src/modules/auth/auth.schema.js new file mode 100644 index 0000000..3bcc4d4 --- /dev/null +++ b/smartcollar-be/src/modules/auth/auth.schema.js @@ -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(""), +}); \ No newline at end of file diff --git a/smartcollar-be/src/modules/auth/auth.service.js b/smartcollar-be/src/modules/auth/auth.service.js new file mode 100644 index 0000000..ba47420 --- /dev/null +++ b/smartcollar-be/src/modules/auth/auth.service.js @@ -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; +}; \ No newline at end of file diff --git a/smartcollar-be/src/modules/collar/collar.controller.js b/smartcollar-be/src/modules/collar/collar.controller.js new file mode 100644 index 0000000..106a09c --- /dev/null +++ b/smartcollar-be/src/modules/collar/collar.controller.js @@ -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); + } +}; \ No newline at end of file diff --git a/smartcollar-be/src/modules/collar/collar.repository.js b/smartcollar-be/src/modules/collar/collar.repository.js new file mode 100644 index 0000000..072622b --- /dev/null +++ b/smartcollar-be/src/modules/collar/collar.repository.js @@ -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; +}; \ No newline at end of file diff --git a/smartcollar-be/src/modules/collar/collar.route.js b/smartcollar-be/src/modules/collar/collar.route.js new file mode 100644 index 0000000..80a2f2f --- /dev/null +++ b/smartcollar-be/src/modules/collar/collar.route.js @@ -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; \ No newline at end of file diff --git a/smartcollar-be/src/modules/collar/collar.schema.js b/smartcollar-be/src/modules/collar/collar.schema.js new file mode 100644 index 0000000..a906692 --- /dev/null +++ b/smartcollar-be/src/modules/collar/collar.schema.js @@ -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(); \ No newline at end of file diff --git a/smartcollar-be/src/modules/collar/collar.service.js b/smartcollar-be/src/modules/collar/collar.service.js new file mode 100644 index 0000000..b8a4b5a --- /dev/null +++ b/smartcollar-be/src/modules/collar/collar.service.js @@ -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; +}; \ No newline at end of file diff --git a/smartcollar-be/src/modules/cow/cow.controller.js b/smartcollar-be/src/modules/cow/cow.controller.js new file mode 100644 index 0000000..99713b7 --- /dev/null +++ b/smartcollar-be/src/modules/cow/cow.controller.js @@ -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); + } +}; \ No newline at end of file diff --git a/smartcollar-be/src/modules/cow/cow.repository.js b/smartcollar-be/src/modules/cow/cow.repository.js new file mode 100644 index 0000000..e986ea4 --- /dev/null +++ b/smartcollar-be/src/modules/cow/cow.repository.js @@ -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; +}; \ No newline at end of file diff --git a/smartcollar-be/src/modules/cow/cow.route.js b/smartcollar-be/src/modules/cow/cow.route.js new file mode 100644 index 0000000..8bb92e7 --- /dev/null +++ b/smartcollar-be/src/modules/cow/cow.route.js @@ -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; \ No newline at end of file diff --git a/smartcollar-be/src/modules/cow/cow.schema.js b/smartcollar-be/src/modules/cow/cow.schema.js new file mode 100644 index 0000000..e23405c --- /dev/null +++ b/smartcollar-be/src/modules/cow/cow.schema.js @@ -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(); \ No newline at end of file diff --git a/smartcollar-be/src/modules/cow/cow.service.js b/smartcollar-be/src/modules/cow/cow.service.js new file mode 100644 index 0000000..6b82199 --- /dev/null +++ b/smartcollar-be/src/modules/cow/cow.service.js @@ -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; +}; \ No newline at end of file diff --git a/smartcollar-be/src/modules/monitoring/monitoring.controller.js b/smartcollar-be/src/modules/monitoring/monitoring.controller.js new file mode 100644 index 0000000..61f5458 --- /dev/null +++ b/smartcollar-be/src/modules/monitoring/monitoring.controller.js @@ -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); + } +}; \ No newline at end of file diff --git a/smartcollar-be/src/modules/monitoring/monitoring.repository.js b/smartcollar-be/src/modules/monitoring/monitoring.repository.js new file mode 100644 index 0000000..7554245 --- /dev/null +++ b/smartcollar-be/src/modules/monitoring/monitoring.repository.js @@ -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(); + +}; \ No newline at end of file diff --git a/smartcollar-be/src/modules/monitoring/monitoring.route.js b/smartcollar-be/src/modules/monitoring/monitoring.route.js new file mode 100644 index 0000000..afbab58 --- /dev/null +++ b/smartcollar-be/src/modules/monitoring/monitoring.route.js @@ -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; \ No newline at end of file diff --git a/smartcollar-be/src/modules/monitoring/monitoring.schema.js b/smartcollar-be/src/modules/monitoring/monitoring.schema.js new file mode 100644 index 0000000..b5d9c71 --- /dev/null +++ b/smartcollar-be/src/modules/monitoring/monitoring.schema.js @@ -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(), + + }), + + }), + +}); \ No newline at end of file diff --git a/smartcollar-be/src/modules/monitoring/monitoring.service.js b/smartcollar-be/src/modules/monitoring/monitoring.service.js new file mode 100644 index 0000000..bf27c87 --- /dev/null +++ b/smartcollar-be/src/modules/monitoring/monitoring.service.js @@ -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 + ); + +}; \ No newline at end of file diff --git a/smartcollar-be/src/modules/notification/notification.controller.js b/smartcollar-be/src/modules/notification/notification.controller.js new file mode 100644 index 0000000..f2d6a19 --- /dev/null +++ b/smartcollar-be/src/modules/notification/notification.controller.js @@ -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, +}; diff --git a/smartcollar-be/src/modules/notification/notification.repository.js b/smartcollar-be/src/modules/notification/notification.repository.js new file mode 100644 index 0000000..f843e4d --- /dev/null +++ b/smartcollar-be/src/modules/notification/notification.repository.js @@ -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, + }); +}; diff --git a/smartcollar-be/src/modules/notification/notification.route.js b/smartcollar-be/src/modules/notification/notification.route.js new file mode 100644 index 0000000..3d04d2f --- /dev/null +++ b/smartcollar-be/src/modules/notification/notification.route.js @@ -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; diff --git a/smartcollar-be/src/modules/notification/notification.schema.js b/smartcollar-be/src/modules/notification/notification.schema.js new file mode 100644 index 0000000..d301acb --- /dev/null +++ b/smartcollar-be/src/modules/notification/notification.schema.js @@ -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; diff --git a/smartcollar-be/src/modules/notification/notification.service.js b/smartcollar-be/src/modules/notification/notification.service.js new file mode 100644 index 0000000..0dd08f8 --- /dev/null +++ b/smartcollar-be/src/modules/notification/notification.service.js @@ -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, +}; diff --git a/smartcollar-be/src/modules/notification/notification.test.controller.js b/smartcollar-be/src/modules/notification/notification.test.controller.js new file mode 100644 index 0000000..8ea1844 --- /dev/null +++ b/smartcollar-be/src/modules/notification/notification.test.controller.js @@ -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, +}; diff --git a/smartcollar-be/src/modules/setting/setting.controller.js b/smartcollar-be/src/modules/setting/setting.controller.js new file mode 100644 index 0000000..e69de29 diff --git a/smartcollar-be/src/modules/setting/setting.repository.js b/smartcollar-be/src/modules/setting/setting.repository.js new file mode 100644 index 0000000..e69de29 diff --git a/smartcollar-be/src/modules/setting/setting.route.js b/smartcollar-be/src/modules/setting/setting.route.js new file mode 100644 index 0000000..e69de29 diff --git a/smartcollar-be/src/modules/setting/setting.schema.js b/smartcollar-be/src/modules/setting/setting.schema.js new file mode 100644 index 0000000..e69de29 diff --git a/smartcollar-be/src/modules/setting/setting.service.js b/smartcollar-be/src/modules/setting/setting.service.js new file mode 100644 index 0000000..e69de29 diff --git a/smartcollar-be/src/routes/index.js b/smartcollar-be/src/routes/index.js new file mode 100644 index 0000000..c6ba978 --- /dev/null +++ b/smartcollar-be/src/routes/index.js @@ -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; \ No newline at end of file diff --git a/smartcollar-be/src/server.js b/smartcollar-be/src/server.js new file mode 100644 index 0000000..df65863 --- /dev/null +++ b/smartcollar-be/src/server.js @@ -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}`); +}); \ No newline at end of file diff --git a/smartcollar-be/src/shared/builders/monitoring.builder.js b/smartcollar-be/src/shared/builders/monitoring.builder.js new file mode 100644 index 0000000..1606c00 --- /dev/null +++ b/smartcollar-be/src/shared/builders/monitoring.builder.js @@ -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, + + }; + +}; \ No newline at end of file diff --git a/smartcollar-be/src/shared/calculators/movement.calculator.js b/smartcollar-be/src/shared/calculators/movement.calculator.js new file mode 100644 index 0000000..9f37c02 --- /dev/null +++ b/smartcollar-be/src/shared/calculators/movement.calculator.js @@ -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; + +}; \ No newline at end of file diff --git a/smartcollar-be/src/shared/calculators/temperature.calculator.js b/smartcollar-be/src/shared/calculators/temperature.calculator.js new file mode 100644 index 0000000..a5f8cec --- /dev/null +++ b/smartcollar-be/src/shared/calculators/temperature.calculator.js @@ -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; + +}; \ No newline at end of file diff --git a/smartcollar-be/src/shared/constants/collar-status.js b/smartcollar-be/src/shared/constants/collar-status.js new file mode 100644 index 0000000..5ddd1df --- /dev/null +++ b/smartcollar-be/src/shared/constants/collar-status.js @@ -0,0 +1,6 @@ +export const COLLAR_STATUS = { + AVAILABLE: "available", + ASSIGNED: "assigned", + OFFLINE: "offline", + MAINTENANCE: "maintenance", +}; \ No newline at end of file diff --git a/smartcollar-be/src/shared/date/date.js b/smartcollar-be/src/shared/date/date.js new file mode 100644 index 0000000..e69de29 diff --git a/smartcollar-be/src/shared/device/device.js b/smartcollar-be/src/shared/device/device.js new file mode 100644 index 0000000..e69de29 diff --git a/smartcollar-be/src/shared/gps/gps.js b/smartcollar-be/src/shared/gps/gps.js new file mode 100644 index 0000000..e69de29 diff --git a/smartcollar-be/src/shared/id/generateId.js b/smartcollar-be/src/shared/id/generateId.js new file mode 100644 index 0000000..c966e19 --- /dev/null +++ b/smartcollar-be/src/shared/id/generateId.js @@ -0,0 +1,5 @@ +import crypto from "crypto"; + +export const generateId = (prefix) => { + return `${prefix}_${crypto.randomUUID()}`; +}; \ No newline at end of file diff --git a/smartcollar-be/src/shared/message/messageTemplate.js b/smartcollar-be/src/shared/message/messageTemplate.js new file mode 100644 index 0000000..e69de29 diff --git a/smartcollar-be/src/shared/utils/device.js b/smartcollar-be/src/shared/utils/device.js new file mode 100644 index 0000000..d064512 --- /dev/null +++ b/smartcollar-be/src/shared/utils/device.js @@ -0,0 +1,8 @@ +import crypto from "crypto"; + +/** + * Generate device secret + */ +export const generateDeviceSecret = () => { + return crypto.randomUUID(); +}; \ No newline at end of file diff --git a/smartcollar-be/src/shared/utils/owner-key.js b/smartcollar-be/src/shared/utils/owner-key.js new file mode 100644 index 0000000..79ec40f --- /dev/null +++ b/smartcollar-be/src/shared/utils/owner-key.js @@ -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}`; +}; \ No newline at end of file diff --git a/smartcollar-be/src/shared/utils/timestamp.js b/smartcollar-be/src/shared/utils/timestamp.js new file mode 100644 index 0000000..14e6d53 --- /dev/null +++ b/smartcollar-be/src/shared/utils/timestamp.js @@ -0,0 +1 @@ +export const now = () => Date.now(); \ No newline at end of file diff --git a/smartcollar-fe/.env.example b/smartcollar-fe/.env.example new file mode 100644 index 0000000..e69de29 diff --git a/smartcollar-fe/.gitignore b/smartcollar-fe/.gitignore new file mode 100644 index 0000000..1b51702 --- /dev/null +++ b/smartcollar-fe/.gitignore @@ -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 \ No newline at end of file diff --git a/smartcollar-fe/CHANGELOG.md b/smartcollar-fe/CHANGELOG.md new file mode 100644 index 0000000..7fd85ac --- /dev/null +++ b/smartcollar-fe/CHANGELOG.md @@ -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 \ No newline at end of file diff --git a/smartcollar-fe/LICENSE b/smartcollar-fe/LICENSE new file mode 100644 index 0000000..b0e7c58 --- /dev/null +++ b/smartcollar-fe/LICENSE @@ -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. \ No newline at end of file diff --git a/smartcollar-fe/README.md b/smartcollar-fe/README.md new file mode 100644 index 0000000..57cde32 --- /dev/null +++ b/smartcollar-fe/README.md @@ -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 diff --git a/smartcollar-fe/ajax/assignment.php b/smartcollar-fe/ajax/assignment.php new file mode 100644 index 0000000..0731086 --- /dev/null +++ b/smartcollar-fe/ajax/assignment.php @@ -0,0 +1,49 @@ + 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; +} diff --git a/smartcollar-fe/ajax/auth_sync.php b/smartcollar-fe/ajax/auth_sync.php new file mode 100644 index 0000000..3193dd2 --- /dev/null +++ b/smartcollar-fe/ajax/auth_sync.php @@ -0,0 +1,38 @@ + 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); diff --git a/smartcollar-fe/ajax/collar.php b/smartcollar-fe/ajax/collar.php new file mode 100644 index 0000000..d72aec0 --- /dev/null +++ b/smartcollar-fe/ajax/collar.php @@ -0,0 +1,111 @@ + 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; +} diff --git a/smartcollar-fe/ajax/cow.php b/smartcollar-fe/ajax/cow.php new file mode 100644 index 0000000..04c30e1 --- /dev/null +++ b/smartcollar-fe/ajax/cow.php @@ -0,0 +1,187 @@ + 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; +} \ No newline at end of file diff --git a/smartcollar-fe/ajax/dashboard.php b/smartcollar-fe/ajax/dashboard.php new file mode 100644 index 0000000..812ce7c --- /dev/null +++ b/smartcollar-fe/ajax/dashboard.php @@ -0,0 +1,22 @@ + 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); diff --git a/smartcollar-fe/ajax/suhu.php b/smartcollar-fe/ajax/suhu.php new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/smartcollar-fe/ajax/suhu.php @@ -0,0 +1 @@ + diff --git a/smartcollar-fe/assets/css/style.css b/smartcollar-fe/assets/css/style.css new file mode 100644 index 0000000..c1b46eb --- /dev/null +++ b/smartcollar-fe/assets/css/style.css @@ -0,0 +1,1215 @@ +:root { + --sidebar-width: 260px; + --primary-color: #4361ee; + --secondary-color: #3f37c9; + --bg-color: #f8f9fa; + --text-dark: #2b2d42; + } + + html { + scroll-behavior: smooth; + } + + body { + font-family: 'Poppins', sans-serif; + overflow-x: hidden; + + background: + radial-gradient(circle at top left, rgba(59,130,246,0.35), transparent 30%), + radial-gradient(circle at bottom right, rgba(56,189,248,0.25), transparent 30%), + linear-gradient(135deg, #06142b, #0a2540, #0f3b68); + + background-size: 400% 400%; + animation: gradientBG 15s ease infinite; + + min-height: 100vh; + color: white; +} + +/* Animasi background */ +@keyframes gradientBG { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} + + /* --- Sidebar Styling --- */ + #wrapper { + display: flex; + width: 100%; + align-items: stretch; + } + + #sidebar { + min-width: var(--sidebar-width); + max-width: var(--sidebar-width); + + background: rgba(15, 23, 42, 0.75); + backdrop-filter: blur(20px); + + border-right: 1px solid rgba(255,255,255,0.08); + + box-shadow: 0 0 30px rgba(0,0,0,0.3); + + color: #fff; + transition: all 0.3s; + min-height: 100vh; + position: fixed; + z-index: 999; +} + + #sidebar.active { + margin-left: calc(var(--sidebar-width) * -1); + } + + .sidebar-header { + padding: 20px; + background: #0f172a; + border-bottom: 1px solid #334155; + } + + .sidebar-brand { + font-weight: 700; + font-size: 1.2rem; + color: #fff; + text-decoration: none; + display: flex; + align-items: center; + } + + .user-profile{ + padding:20px; + text-align:center; + border-bottom:1px solid #334155; +} + + .user-avatar{ + width:60px; + height:60px; + background:var(--primary-color); + border-radius:50%; + display:inline-flex; + align-items:center; + justify-content:center; + font-size:24px; + margin-bottom:10px; + color:white; +} + + + ul.components { + padding: 20px 0; + list-style: none; + padding-left: 0; + } + + ul.components li { + padding: 0; + } + + ul.components li a { + padding: 15px 25px; + display: block; + color: #94a3b8; + text-decoration: none; + transition: 0.3s; + font-size: 0.95rem; + border-left: 4px solid transparent; + position: relative; + overflow: hidden; + } + + ul.components li a:hover, +ul.components li a.active { + color: #fff; + background: linear-gradient(90deg, rgba(67,97,238,0.3), transparent); + border-left: 4px solid #60a5fa; + transform: translateX(8px); +} +ul.components li a { + transition: all 0.35s ease; +} + ul.components li a i { + margin-right: 10px; + width: 20px; + text-align: center; + } + + /* Submenu Label */ + /* Hapus salah satu duplikat .sidebar-label - cukup satu definisi ini saja */ +.sidebar-label { + padding: 10px 25px; + font-size: 0.75rem; + text-transform: uppercase; + color: #64748b; + font-weight: 700; + letter-spacing: 1px; + margin-top: 10px; +} + +/* FIX: sidebar bisa di-scroll sendiri kalau menu terlalu panjang */ +#sidebar { + min-width: var(--sidebar-width); + max-width: var(--sidebar-width); + background: rgba(15, 23, 42, 0.75); + backdrop-filter: blur(20px); + border-right: 1px solid rgba(255,255,255,0.08); + box-shadow: 0 0 30px rgba(0,0,0,0.3); + color: #fff; + transition: all 0.3s; + height: 100vh; + max-height: 100vh; + overflow-y: auto; + position: fixed; + top: 0; + left: 0; + z-index: 999; +} + +/* Scrollbar sidebar tipis biar tetap estetik */ +#sidebar::-webkit-scrollbar { + width: 5px; +} +#sidebar::-webkit-scrollbar-thumb { + background: rgba(96, 165, 250, 0.3); + border-radius: 10px; +} + +/* FIX: Overlay gelap di belakang sidebar saat mobile (klik untuk menutup) */ +#sidebarOverlay { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + z-index: 998; /* di bawah sidebar (999), di atas konten */ + backdrop-filter: blur(2px); +} + +@media (max-width: 768px) { + #sidebarOverlay.show { + display: block; + } +} + +/* Opsional: sembunyikan tombol hamburger di desktop, hanya tampil mobile */ +@media (min-width: 992px) { + .navbar-toggle { + display: none; + } +} + +.text-muted { + color: #cbd5e1 !important; +} + + /* --- Content Styling --- */ + #content{ + width: calc(100% - 260px); + margin-left: 260px; + padding: 25px; + transition: 0.3s; + + background: rgba(255,255,255,0.03); + backdrop-filter: blur(12px); + + min-height: 100vh; +} + #content.active { + margin-left: 0; + } + + /* Navbar Mobile Toggle */ + .navbar-toggle { + background: linear-gradient(135deg, #2563eb, #38bdf8); + + color: white; + + border: 1px solid rgba(255,255,255,0.15); + + padding: 10px 15px; + + border-radius: 14px; + + box-shadow: + 0 0 15px rgba(59,130,246,0.35), + 0 4px 15px rgba(0,0,0,0.3); + + cursor: pointer; + + transition: all 0.3s ease; + + display: inline-block; +} +.navbar-toggle:hover { + transform: translateY(-2px); + box-shadow: + 0 0 25px rgba(96,165,250,0.5), + 0 8px 20px rgba(0,0,0,0.35); +} + + /* Cards */ + .stat-card { + border: 1px solid rgba(96,165,250,0.25); + + border-radius: 22px; + + backdrop-filter: blur(20px); + + background: rgba(255,255,255,0.05); + + box-shadow: + 0 0 25px rgba(59,130,246,0.15), + 0 10px 30px rgba(0,0,0,0.35); + + transition: all 0.4s ease; + + overflow: hidden; + position: relative; + height: 100%; +} +.row.g-4{ + align-items:stretch; +} + + .stat-card:hover { + transform: translateY(-10px) scale(1.02); + box-shadow: + 0 0 25px rgba(96,165,250,0.4), + 0 10px 40px rgba(0,0,0,0.4); +} +.stat-card { + animation: fadeUp 0.8s ease; +} +.glass-card { + background: rgba(255,255,255,0.08); + backdrop-filter: blur(15px); + border: 1px solid rgba(255,255,255,0.08); + box-shadow: 0 8px 30px rgba(0,0,0,0.25); +} +@keyframes fadeUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + + .stat-card .card-body { + z-index: 2; + position: relative; + } + + .stat-icon-bg { + position: absolute; + right: -10px; + bottom: -10px; + font-size: 5rem; + opacity: 0.15; + z-index: 1; + transform: rotate(-15deg); + } + + /* Gradients */ + .bg-gradient-suhu { + background: linear-gradient(135deg, #2563eb, #38bdf8); + color: white; +} + +.bg-gradient-aktivitas { + background: linear-gradient(135deg, #0ea5e9, #06b6d4); + color: white; +} + +.bg-gradient-lokasi { + background: linear-gradient(135deg, #1d4ed8, #60a5fa); + color: white; +} + +.bg-gradient-online { + background: linear-gradient(135deg, #66BB6A, #43A047); +} + +.bg-gradient-offline { + background: linear-gradient(135deg, #c5b0b0, #78909C); +} + + .value-text { + font-size: 2.5rem; + font-weight: 700; + } + + /* Map Section */ + .map-container { + border-radius: 18px; + overflow: hidden; + + background: rgba(15, 23, 42, 0.7); + + border: 1px solid rgba(96,165,250,0.15); + + box-shadow: + 0 0 20px rgba(59,130,246,0.15), + 0 10px 30px rgba(0,0,0,0.3); + + padding: 5px; +} + + iframe { + border-radius: 12px; + width: 100%; + height: 400px; + border: none; + + filter: + brightness(0.8) + contrast(1.2) + saturate(0.8) + hue-rotate(180deg) + invert(0.92); +} + +/* Gradient tambahan untuk card Koordinat GPS - senada dengan tone biru */ +.bg-gradient-koordinat { + background: linear-gradient(135deg, #0f172a, #1e40af); + color: white; +} + +.value-text-sm { + font-size: 1rem; + font-weight: 600; + color: #e0f2fe; +} + +/* Dropdown "Pilih Sapi" - senada dengan tema glass biru gelap */ +.form-label { + color: #cbd5e1; + font-weight: 600; + margin-bottom: 0.5rem; +} + +#cowSelect { + background-color: rgba(15, 23, 42, 0.6); + backdrop-filter: blur(10px); + color: #ffffff; + border: 1px solid rgba(96, 165, 250, 0.35); + border-radius: 14px; + padding: 0.7rem 1.1rem; + font-weight: 500; + box-shadow: + 0 0 15px rgba(59, 130, 246, 0.1), + 0 4px 15px rgba(0, 0, 0, 0.25); + transition: all 0.3s ease; + background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%2360a5fa' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10l-5 5z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 1rem center; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; +} + +#cowSelect:hover { + border-color: rgba(96, 165, 250, 0.6); +} + +#cowSelect:focus { + background-color: rgba(15, 23, 42, 0.75); + color: #ffffff; + border-color: #60a5fa; + box-shadow: 0 0 0 0.2rem rgba(96, 165, 250, 0.25); + outline: none; +} + +#cowSelect option { + background-color: #0f172a; + color: #ffffff; +} + + /* Scroll margin untuk anchor link supaya tidak mepet atas */ + .feature-section { + scroll-margin-top: 20px; + } + ul.components li a::after { + content: ""; + position: absolute; + width: 0; + height: 0; + background: rgba(255,255,255,0.2); + border-radius: 50%; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + transition: width 0.5s, height 0.5s; +} + +ul.components li a:active::after { + width: 300px; + height: 300px; +} + + /* Responsive */ +@media (max-width: 768px) { + + #sidebar { + margin-left: calc(var(--sidebar-width) * -1); + background: rgba(15, 23, 42, 0.85); + backdrop-filter: blur(20px); + border-right: 1px solid rgba(255,255,255,0.08); + box-shadow: 0 0 30px rgba(0,0,0,0.3); + } + + #sidebar.active { + margin-left: 0; + } + + #content { + margin-left: 0; + } + + #content.active{ + margin-left: 260px; + } + + .navbar-toggle { + display: block; + } +} + + /* ============================= */ +/* Page Header */ +/* ============================= */ +.page-title { + color: #fff; + font-weight: 700; +} +.page-subtitle { + color: #94a3b8; +} + +/* ============================= */ +/* Table Card (glass) */ +/* ============================= */ +.table-glass-card { + background: rgba(255, 255, 255, 0.05); + backdrop-filter: blur(20px); + border: 1px solid rgba(96, 165, 250, 0.25); + border-radius: 22px; + box-shadow: + 0 0 25px rgba(59, 130, 246, 0.12), + 0 10px 30px rgba(0, 0, 0, 0.35); +} + +.table-dark-glass { + color: #e2e8f0; + margin-bottom: 0; +} + +.table-dark-glass thead th { + color: #93c5fd; + text-transform: uppercase; + font-size: 0.78rem; + letter-spacing: 0.5px; + border-bottom: 1px solid rgba(96, 165, 250, 0.25); + background: transparent; + padding: 1rem 0.75rem; +} + +.table-dark-glass tbody tr { + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + transition: background 0.25s ease; +} + +.table-dark-glass tbody tr:hover { + background: rgba(96, 165, 250, 0.08); +} + +.table-dark-glass tbody td { + padding: 0.9rem 0.75rem; + vertical-align: middle; +} + +/* ============================= */ +/* Buttons */ +/* ============================= */ +.btn-primary-glow { + background: linear-gradient(135deg, #2563eb, #38bdf8); + border: none; + color: #fff; + font-weight: 600; + border-radius: 12px; + padding: 0.55rem 1.3rem; + box-shadow: 0 0 15px rgba(59, 130, 246, 0.35); + transition: all 0.3s ease; +} +.btn-primary-glow:hover { + transform: translateY(-2px); + box-shadow: 0 0 20px rgba(59, 130, 246, 0.55); + color: #fff; +} + +.btn-edit-glow { + background: rgba(251, 191, 36, 0.15); + border: 1px solid rgba(251, 191, 36, 0.4); + color: #fbbf24; + border-radius: 10px; + font-weight: 600; + padding: 0.35rem 0.85rem; + transition: all 0.25s ease; +} +.btn-edit-glow:hover { + background: rgba(251, 191, 36, 0.25); + color: #fde68a; +} + +.btn-delete-glow { + background: rgba(248, 113, 113, 0.15); + border: 1px solid rgba(248, 113, 113, 0.4); + color: #f87171; + border-radius: 10px; + font-weight: 600; + padding: 0.35rem 0.85rem; + transition: all 0.25s ease; +} +.btn-delete-glow:hover { + background: rgba(248, 113, 113, 0.25); + color: #fecaca; +} + +/* ============================= */ +/* Modal (glass dark) */ +/* ============================= */ +.modal-content-glass { + background: rgba(15, 23, 42, 0.85); + backdrop-filter: blur(20px); + border: 1px solid rgba(96, 165, 250, 0.25); + border-radius: 22px; + box-shadow: + 0 0 25px rgba(59, 130, 246, 0.15), + 0 10px 30px rgba(0, 0, 0, 0.45); + color: #e2e8f0; +} + +.modal-content-glass .modal-header, +.modal-content-glass .modal-footer { + border-color: rgba(255, 255, 255, 0.08); +} + +.modal-content-glass .modal-title, +.modal-content-glass h5 { + color: #fff; + font-weight: 700; +} + +.modal-content-glass label { + color: #93c5fd; + font-weight: 600; + font-size: 0.85rem; + margin-bottom: 0.35rem; +} + +.modal-content-glass .form-control, +.modal-content-glass .form-select { + background-color: rgba(15, 23, 42, 0.6); + border: 1px solid rgba(96, 165, 250, 0.3); + color: #fff; + border-radius: 12px; + padding: 0.6rem 0.9rem; +} + +.modal-content-glass .form-control:focus, +.modal-content-glass .form-select:focus { + background-color: rgba(15, 23, 42, 0.8); + border-color: #60a5fa; + color: #fff; + box-shadow: 0 0 0 0.2rem rgba(96, 165, 250, 0.25); +} + +.modal-content-glass .form-control::placeholder { + color: #64748b; +} + +.modal-content-glass .btn-close { + filter: invert(1) grayscale(100%) brightness(200%); +} + +.modal-content-glass .btn-secondary { + background: rgba(148, 163, 184, 0.15); + border: 1px solid rgba(148, 163, 184, 0.3); + color: #cbd5e1; + border-radius: 10px; +} +.modal-content-glass .btn-secondary:hover { + background: rgba(148, 163, 184, 0.25); + color: #fff; +} + +/* Badge Gender di tabel */ +.badge-gender-male { + background: rgba(59, 130, 246, 0.15); + border: 1px solid rgba(59, 130, 246, 0.4); + color: #93c5fd; + padding: 0.35rem 0.75rem; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 600; +} + +.badge-gender-female { + background: rgba(236, 72, 153, 0.15); + border: 1px solid rgba(236, 72, 153, 0.4); + color: #f9a8d4; + padding: 0.35rem 0.75rem; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 600; +} + +.badge-weight { + background: rgba(56, 189, 248, 0.12); + border: 1px solid rgba(56, 189, 248, 0.3); + color: #7dd3fc; + padding: 0.3rem 0.7rem; + border-radius: 8px; + font-weight: 600; + font-size: 0.85rem; +} + +/* ============================= */ +/* FIX: Toast tidak bisa diklik / menghalangi elemen lain */ +/* ============================= */ +.toast-glass { + position: fixed; + top: 1.5rem; + right: 1.5rem; + z-index: 2000; + min-width: 280px; + max-width: 360px; + padding: 1rem 1.25rem; + border-radius: 14px; + background: rgba(15, 23, 42, 0.9); + backdrop-filter: blur(20px); + border: 1px solid rgba(96, 165, 250, 0.3); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4); + color: #fff; + font-weight: 500; + display: flex; + align-items: center; + gap: 0.6rem; + transform: translateX(120%); + transition: transform 0.35s ease; + pointer-events: none; /* <-- FIX: tidak menghalangi klik saat tersembunyi */ +} + +.toast-glass.show { + transform: translateX(0); + pointer-events: auto; /* <-- aktif hanya saat toast tampil */ +} + +/* ============================= */ +/* FIX: Background tabel masih putih (override Bootstrap variable) */ +/* ============================= */ +.table-dark-glass { + --bs-table-bg: transparent; + --bs-table-striped-bg: rgba(255, 255, 255, 0.03); + --bs-table-hover-bg: rgba(96, 165, 250, 0.08); + --bs-table-hover-color: #fff; + --bs-table-color: #e2e8f0; + --bs-table-border-color: rgba(255, 255, 255, 0.08); + background-color: transparent !important; + color: #e2e8f0; + margin-bottom: 0; +} + +.table-dark-glass thead th { + background-color: transparent !important; + color: #93c5fd; + text-transform: uppercase; + font-size: 0.78rem; + letter-spacing: 0.5px; + border-bottom: 1px solid rgba(96, 165, 250, 0.25); + padding: 1rem 0.75rem; +} + +.table-dark-glass tbody tr { + background-color: transparent !important; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + transition: background 0.25s ease; +} + +.table-dark-glass tbody tr:hover { + background-color: rgba(96, 165, 250, 0.08) !important; +} + +.table-dark-glass tbody td { + background-color: transparent !important; + padding: 0.9rem 0.75rem; + vertical-align: middle; + color: #e2e8f0; +} + +/* Pastikan card pembungkus tabel juga tidak punya bg putih dari Bootstrap */ +.table-glass-card, +.table-glass-card .card-body { + background-color: transparent; +} + +/* ============================= */ +/* Secret Input (Device Secret) */ +/* ============================= */ +.secret-input { + background-color: rgba(15, 23, 42, 0.6) !important; + color: #e2e8f0 !important; + border: 1px solid rgba(96, 165, 250, 0.3) !important; + font-family: monospace; + letter-spacing: 1px; +} + +.secret-input:focus { + background-color: rgba(15, 23, 42, 0.8) !important; + box-shadow: none; + border-color: #60a5fa !important; +} + +.btn-secret-action { + background-color: rgba(96, 165, 250, 0.12); + border: 1px solid rgba(96, 165, 250, 0.3); + color: #93c5fd; + font-weight: 600; + font-size: 0.8rem; + transition: all 0.25s ease; +} + +.btn-secret-action:hover { + background-color: rgba(96, 165, 250, 0.22); + color: #fff; +} + +/* ============================= */ +/* Form Select & Control - Umum (di luar modal) */ +/* ============================= */ +/* ============================= */ +/* Form Select & Control - Umum (di luar modal) */ +/* ============================= */ +.glass-card .form-select, +.glass-card .form-control, +.modal-content-glass .form-select, +.modal-content-glass .form-control, +.form-glass-select { + background-color: rgba(15, 23, 42, 0.6); + backdrop-filter: blur(10px); + color: #ffffff; + border: 1px solid rgba(96, 165, 250, 0.3); + border-radius: 12px; + padding: 0.6rem 0.9rem; + transition: all 0.3s ease; +} + +/* Khusus + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +`; + + 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"); + } + }); + } + +}); \ No newline at end of file diff --git a/smartcollar-fe/assets/js/dashboard.js b/smartcollar-fe/assets/js/dashboard.js new file mode 100644 index 0000000..44d49a3 --- /dev/null +++ b/smartcollar-fe/assets/js/dashboard.js @@ -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 = ` +
+
Suhu Tubuh
+
${temp}
+ ${tempStatus} +
+ `; + + // 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 = ` +
+
Aktivitas
+
${actShort}
+ ${fmt(movementVal !== null ? movementVal : activity)} +
+ `; + + // 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 = ` +
+
Koordinat GPS
+
Lat: ${lat}
+
Lng: ${lng}
+
+ `; + + // Status card + const statusCard = document.createElement('div'); + statusCard.className = 'card text-center shadow-sm device-card'; + statusCard.style.flex = '1 1 20%'; + statusCard.innerHTML = ` +
+
Status
+
${status}
+ ${lastUpdate ? lastUpdate.toLocaleString() : '-'} +
+ `; + + 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 + +); \ No newline at end of file diff --git a/smartcollar-fe/assets/js/firebase.js b/smartcollar-fe/assets/js/firebase.js new file mode 100644 index 0000000..fda25eb --- /dev/null +++ b/smartcollar-fe/assets/js/firebase.js @@ -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); \ No newline at end of file diff --git a/smartcollar-fe/assets/js/history.js b/smartcollar-fe/assets/js/history.js new file mode 100644 index 0000000..7ee10de --- /dev/null +++ b/smartcollar-fe/assets/js/history.js @@ -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 = 'Tidak ada data'; + 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 ` + ${time} + ${temp} + ${cowCode} + ${cowName} + ${cowBreed} + ${cowGender} + ${cowAge} + ${cowWeight} + ${cowCondition} + ${movementLabel} + ${gps} + `; + }).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 = ` + + Memuat data... + + `; + 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); + +}); \ No newline at end of file diff --git a/smartcollar-fe/assets/js/lokasi.js b/smartcollar-fe/assets/js/lokasi.js new file mode 100644 index 0000000..632e709 --- /dev/null +++ b/smartcollar-fe/assets/js/lokasi.js @@ -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 = + '© OpenStreetMap contributors © CARTO'; + +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 = '
Peta tidak dapat dimuat (CDN diblokir). Koordinat tetap tersedia pada card di atas.
'; + } + 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: ` +
+ + + + +
+ `, + 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 + +); \ No newline at end of file diff --git a/smartcollar-fe/assets/js/register.js b/smartcollar-fe/assets/js/register.js new file mode 100644 index 0000000..3548a2a --- /dev/null +++ b/smartcollar-fe/assets/js/register.js @@ -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 = ` +
+ ${message} +
+ `; + } 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); + } + }); +} diff --git a/smartcollar-fe/assets/js/suhu.js b/smartcollar-fe/assets/js/suhu.js new file mode 100644 index 0000000..adeecb3 --- /dev/null +++ b/smartcollar-fe/assets/js/suhu.js @@ -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 = + '

Grafik tidak dapat dimuat (Chart.js gagal diakses). Data suhu tetap diperbarui pada card di atas.

'; + } + } + + loadSuhu(); + + setInterval(loadSuhu, 2000); + +} + +start(); \ No newline at end of file diff --git a/smartcollar-fe/composer.json b/smartcollar-fe/composer.json new file mode 100644 index 0000000..d7c6bb7 --- /dev/null +++ b/smartcollar-fe/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "vlucas/phpdotenv": "^5.6" + } +} diff --git a/smartcollar-fe/composer.lock b/smartcollar-fe/composer.lock new file mode 100644 index 0000000..1a97b91 --- /dev/null +++ b/smartcollar-fe/composer.lock @@ -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" +} diff --git a/smartcollar-fe/config/api.php b/smartcollar-fe/config/api.php new file mode 100644 index 0000000..71afa1f --- /dev/null +++ b/smartcollar-fe/config/api.php @@ -0,0 +1,200 @@ + 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 + ); + +} \ No newline at end of file diff --git a/smartcollar-fe/config/app.php b/smartcollar-fe/config/app.php new file mode 100644 index 0000000..598a320 --- /dev/null +++ b/smartcollar-fe/config/app.php @@ -0,0 +1,87 @@ +safeLoad(); + +/* +|-------------------------------------------------------------------------- +| Application Environment +|-------------------------------------------------------------------------- +*/ + +define( + "APP_NAME", + $_ENV["APP_NAME"] ?? "Smart Collar" +); + +define( + "APP_ENV", + $_ENV["APP_ENV"] ?? "local" +); + +/* +|-------------------------------------------------------------------------- +| API Configuration +|-------------------------------------------------------------------------- +*/ + +define( + "API_URL", + $_ENV["API_URL"] ?? "http://localhost:3000/api/v1" +); + +define( + "API_TIMEOUT", + (int) ($_ENV["API_TIMEOUT"] ?? 10) +); + +define( + "API_CONNECT_TIMEOUT", + (int) ($_ENV["API_CONNECT_TIMEOUT"] ?? 5) +); + +define( + "FIREBASE_API_KEY", + $_ENV["FIREBASE_API_KEY"] +); + +define( + "FIREBASE_AUTH_DOMAIN", + $_ENV["FIREBASE_AUTH_DOMAIN"] +); + +define( + "FIREBASE_DATABASE_URL", + $_ENV["FIREBASE_DATABASE_URL"] +); + +define( + "FIREBASE_PROJECT_ID", + $_ENV["FIREBASE_PROJECT_ID"] +); + +define( + "FIREBASE_STORAGE_BUCKET", + $_ENV["FIREBASE_STORAGE_BUCKET"] +); + +define( + "FIREBASE_MESSAGING_SENDER_ID", + $_ENV["FIREBASE_MESSAGING_SENDER_ID"] +); + +define( + "FIREBASE_APP_ID", + $_ENV["FIREBASE_APP_ID"] +); \ No newline at end of file diff --git a/smartcollar-fe/config/auth.php b/smartcollar-fe/config/auth.php new file mode 100644 index 0000000..478a985 --- /dev/null +++ b/smartcollar-fe/config/auth.php @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/smartcollar-fe/includes/header.php b/smartcollar-fe/includes/header.php new file mode 100644 index 0000000..5b9041e --- /dev/null +++ b/smartcollar-fe/includes/header.php @@ -0,0 +1,68 @@ + + + + + + + + + + + + Smart Collar Dashboard + + + + + + + + + + + + + + + + +
+ +
\ No newline at end of file diff --git a/smartcollar-fe/includes/navbar.php b/smartcollar-fe/includes/navbar.php new file mode 100644 index 0000000..7c329ea --- /dev/null +++ b/smartcollar-fe/includes/navbar.php @@ -0,0 +1,38 @@ + + + \ No newline at end of file diff --git a/smartcollar-fe/includes/sidebar.php b/smartcollar-fe/includes/sidebar.php new file mode 100644 index 0000000..7ffd530 --- /dev/null +++ b/smartcollar-fe/includes/sidebar.php @@ -0,0 +1,103 @@ + + +
+ + + + + + + +
+ + + + +
+ + + \ No newline at end of file diff --git a/smartcollar-fe/index.php b/smartcollar-fe/index.php new file mode 100644 index 0000000..9c00e77 --- /dev/null +++ b/smartcollar-fe/index.php @@ -0,0 +1,15 @@ + + + + + + + + + + Login | Smart Collar System + + + + + + + + + +
+ + + + + + + + + diff --git a/smartcollar-fe/logout.php b/smartcollar-fe/logout.php new file mode 100644 index 0000000..323b0c2 --- /dev/null +++ b/smartcollar-fe/logout.php @@ -0,0 +1,9 @@ + + + +
+
+ + + + + +
+
+ + +
+
+ + + + +
+ +
+
+
+
Aktivitas (accelX)
+
+ - +
+
+ - +
+ +
+
+
+ +
+
+
+
Status Collar
+
+ - +
+ +
+
+
+ +
+
+
+
Update Terakhir
+
+ - +
+ +
+
+
+ +
+ + + + +
+
+
+
+
Grafik Aktivitas Real-time (accelX)
+ 1 titik / data baru · ±30 titik terakhir +
+
+
+ +
+
+
+
+
+ +
+ + + + + + + + + + \ No newline at end of file diff --git a/smartcollar-fe/pages/assignment.php b/smartcollar-fe/pages/assignment.php new file mode 100644 index 0000000..728a37e --- /dev/null +++ b/smartcollar-fe/pages/assignment.php @@ -0,0 +1,101 @@ + + +
+ +
+ +
+

Assignment Management

+

Assign cows to collars.

+
+ +
+ + + + + +
+
+
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+
+
+ + + + + +
+
+
+ + + + + + + + + + + + + + +
IDCowDeviceStatusAssigned At
+
+
+
+ +
+ + \ No newline at end of file diff --git a/smartcollar-fe/pages/collars.php b/smartcollar-fe/pages/collars.php new file mode 100644 index 0000000..67760ec --- /dev/null +++ b/smartcollar-fe/pages/collars.php @@ -0,0 +1,226 @@ + + +
+ +
+ +
+

Smart Collar Management

+

Manage all registered smart collars.

+
+ + + +
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SerialDevice NameDevice SecretSIMAction
No Data
+ +
+ " readonly> + + +
+ + - + +
+ + +
+ +
+ +
+ +
+ +
+ + + + + + + + + + + \ No newline at end of file diff --git a/smartcollar-fe/pages/cows.php b/smartcollar-fe/pages/cows.php new file mode 100644 index 0000000..35efa6f --- /dev/null +++ b/smartcollar-fe/pages/cows.php @@ -0,0 +1,261 @@ + + +
+ + + + + +
+ +
+

Cow Management

+

Manage all registered cows.

+
+ + + +
+ + + + + +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CodeNameBreedGenderAgeWeightColorStatusAction
+ No Data +
+ "> + me-1"> + + + + diff($birth)->y; + echo "$age tahun"; + } else { + echo "-"; + } + ?> + + kg + + + + "> + + + + + +
+ +
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/smartcollar-fe/pages/dashboard.php b/smartcollar-fe/pages/dashboard.php new file mode 100644 index 0000000..8f7bc54 --- /dev/null +++ b/smartcollar-fe/pages/dashboard.php @@ -0,0 +1,216 @@ + + + +
+
+
+

Dashboard Smart Collar

+

+ Pantau kondisi kesehatan dan lokasi hewan ternak secara real-time. +

+
+
+ + + + + +
+
+
+
+
Monitoring Realtime Smart Collar
+
+
+ + + + +
+
+ + +
+
+ + + + +
+ +
+
+
+
Temperature
+
+ - +
+
+ - +
+ +
+
+
+ +
+
+
+
Aktivitas
+
+ - +
+ +
+
+
+ +
+
+
+
Koordinat GPS
+
+
+ Lat + - +
+
+ Long + - +
+
+ +
+
+
+ +
+ + + + + + + + +
+
+ Lokasi Smart Collar +
+
+
+
+
+ + + + + + Lihat di Google Maps + + +
+
+
+
+
+
+
+
+
+ + + + + + \ No newline at end of file diff --git a/smartcollar-fe/pages/history.php b/smartcollar-fe/pages/history.php new file mode 100644 index 0000000..088bc14 --- /dev/null +++ b/smartcollar-fe/pages/history.php @@ -0,0 +1,201 @@ + + +
+ +
+
+

Riwayat Monitoring

+

Lihat data monitoring per hari atau rentang tanggal.

+
+
+ + +
+
+ + + + + +
+
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + +
+ + + +
+
+
+ + + + + +
+
+
+
+
Rata-rata Suhu
+
-
+
+ +
+
+
+
+
+
Suhu Tertinggi
+
-
+
+ +
+
+
+
+
+
Suhu Terendah
+
-
+
+ +
+
+
+
+
+
Total Data
+
-
+
+ +
+
+
+ + + + + +
+
+ Tren Suhu +
+ +
+ + + + + +
+
+ +
+
+ + +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
WaktuSuhuKode SapiNama SapiBreedGenderUmurBeratKondisiAktivitasKoordinat
+ Pilih sapi dan tanggal lalu klik Muat +
+
+ + + +
+
+ +
+ + + + + + + + \ No newline at end of file diff --git a/smartcollar-fe/pages/lokasi.php b/smartcollar-fe/pages/lokasi.php new file mode 100644 index 0000000..f4b9e00 --- /dev/null +++ b/smartcollar-fe/pages/lokasi.php @@ -0,0 +1,164 @@ + + + +
+
+ + + + + +
+
+ + +
+
+ + + + +
+ +
+
+
+
Koordinat GPS
+
+
+ Lat + - +
+
+ Long + - +
+
+ +
+
+
+ +
+
+
+
Status Collar
+
+ - +
+ +
+
+
+ +
+
+
+
Update Terakhir
+
+ - +
+ +
+
+
+ +
+ + + + +
+
+
+
+
Lokasi Smart Collar
+ Update saat ada data baru +
+
+
+
+
+
+

Menghubungkan ke Satelit GPS...

+
+
+
+ + + Lihat di Google Maps + +
+
+
+
+ +
+ + + + + + + + \ No newline at end of file diff --git a/smartcollar-fe/pages/suhu.php b/smartcollar-fe/pages/suhu.php new file mode 100644 index 0000000..88a0462 --- /dev/null +++ b/smartcollar-fe/pages/suhu.php @@ -0,0 +1,148 @@ + + + +
+
+ + + + + + + +
+
+ + +
+
+ + + + +
+ +
+
+
+
Suhu Tubuh
+
+ - + °C +
+
+ - +
+ +
+
+
+ +
+
+
+
Status Collar
+
+ - +
+ +
+
+
+ +
+
+
+
Update Terakhir
+
+ - +
+ +
+
+
+ +
+ + + + +
+
+
+
+
Grafik Suhu Real-time
+ 1 titik / 2 detik · ±1 menit terakhir +
+
+
+ +
+
+
+
+
+ +
+ + + + + + + + + + \ No newline at end of file diff --git a/smartcollar-fe/register.php b/smartcollar-fe/register.php new file mode 100644 index 0000000..1bc85e6 --- /dev/null +++ b/smartcollar-fe/register.php @@ -0,0 +1,198 @@ + + + + + + + + + + + Daftar | Smart Collar System + + + + + + + + + + + + + +
+ +
+
+ +
+ +
+

SMART COLLAR

+

DAFTAR AKUN BARU

+
+ +
+ +
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
Gunakan format +62, misal +6281234567890
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ + +
+ +
+

+ Sudah punya akun? + Masuk disini +

+
+
+ + + + + diff --git a/smartcollar-fe/services/assignment.service.php b/smartcollar-fe/services/assignment.service.php new file mode 100644 index 0000000..ae4c1a7 --- /dev/null +++ b/smartcollar-fe/services/assignment.service.php @@ -0,0 +1,38 @@ + + count($cows), + + "collar" => + count($collars), + + "assignment" => + count($activeAssignments), + + "available" => + $available, + + "online" => + $online, + + "offline" => + $offline, + + "cows" => + $cows + + ]; + +} \ No newline at end of file diff --git a/smartcollar-fe/services/suhu.service.php b/smartcollar-fe/services/suhu.service.php new file mode 100644 index 0000000..e69de29 diff --git a/smartcollar-fe/session.php b/smartcollar-fe/session.php new file mode 100644 index 0000000..4b33ad7 --- /dev/null +++ b/smartcollar-fe/session.php @@ -0,0 +1,22 @@ + true +]); \ No newline at end of file