backend Aplikasi

This commit is contained in:
Naufal 2026-07-08 22:09:18 +07:00
parent 520ad74e8c
commit 0efe07bb45
14 changed files with 2943 additions and 0 deletions

18
backend-Sigasti/.gitignore vendored Normal file
View File

@ -0,0 +1,18 @@
# Dependencies
node_modules/
# Environment variables
.env
# Logs
logs
*.log
npm-debug.log*
# OS files
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/

View File

@ -0,0 +1,166 @@
# Panduan Deploy Backend ke Railway
## Persiapan
### 1. Setup MongoDB Atlas (Database Cloud)
1. Buka [MongoDB Atlas](https://www.mongodb.com/cloud/atlas)
2. Buat akun gratis (jika belum punya)
3. Buat cluster baru (pilih FREE tier)
4. Tunggu cluster selesai dibuat (±5 menit)
5. Klik "Connect" → "Connect your application"
6. Copy connection string, contoh:
```
mongodb+srv://username:password@cluster0.xxxxx.mongodb.net/autoclave?retryWrites=true&w=majority
```
7. Ganti `<password>` dengan password database Anda
8. Simpan connection string ini untuk nanti
### 2. Push Code ke GitHub
#### A. Inisialisasi Git (jika belum)
```bash
cd Back-end
git init
```
#### B. Buat repository di GitHub
1. Buka [GitHub](https://github.com)
2. Klik tombol "+" → "New repository"
3. Nama repository: `autoclave-backend` (atau nama lain)
4. Pilih "Public" atau "Private"
5. **JANGAN** centang "Initialize with README" (karena sudah ada)
6. Klik "Create repository"
#### C. Push code ke GitHub
```bash
# Tambahkan semua file
git add .
# Commit
git commit -m "Initial commit: Backend autoclave system"
# Tambahkan remote (ganti dengan URL repository Anda)
git remote add origin https://github.com/username/autoclave-backend.git
# Push ke GitHub
git branch -M main
git push -u origin main
```
### 3. Deploy ke Railway
#### A. Buat akun Railway
1. Buka [Railway.app](https://railway.app)
2. Klik "Login" → pilih "Login with GitHub"
3. Authorize Railway untuk akses GitHub Anda
#### B. Deploy dari GitHub
1. Di Railway dashboard, klik "New Project"
2. Pilih "Deploy from GitHub repo"
3. Pilih repository `autoclave-backend` yang baru dibuat
4. Railway akan otomatis detect Node.js project
5. Klik "Deploy Now"
#### C. Set Environment Variables
1. Setelah deploy, klik project Anda
2. Klik tab "Variables"
3. Tambahkan variabel berikut:
```
PORT=5000
MONGODB_URI=mongodb+srv://username:password@cluster0.xxxxx.mongodb.net/autoclave?retryWrites=true&w=majority
```
4. Klik "Add" untuk setiap variabel
#### D. Dapatkan URL Production
1. Klik tab "Settings"
2. Scroll ke "Domains"
3. Klik "Generate Domain"
4. Copy URL yang digenerate, contoh:
```
https://autoclave-backend-production.up.railway.app
```
### 4. Update Frontend
Setelah mendapat URL production, update file `.env` di frontend:
```env
BACKEND_URL=https://autoclave-backend-production.up.railway.app
```
Atau update `Front-end/src/config.ts`:
```typescript
export const BACKEND_URL = 'https://autoclave-backend-production.up.railway.app';
```
## Testing
Test endpoint dengan curl atau Postman:
```bash
# Test root endpoint
curl https://your-app.up.railway.app/
# Test API endpoint
curl https://your-app.up.railway.app/sterilisasi/running/last
```
## Monitoring
1. Di Railway dashboard, klik project Anda
2. Klik tab "Deployments" untuk melihat status deploy
3. Klik tab "Logs" untuk melihat log real-time
4. Klik tab "Metrics" untuk melihat usage
## Troubleshooting
### Deploy Gagal
1. Cek logs di Railway dashboard
2. Pastikan `package.json` memiliki script `"start": "node server.js"`
3. Pastikan semua dependencies sudah di-install
### Database Connection Error
1. Pastikan MongoDB Atlas cluster sudah running
2. Pastikan IP whitelist di MongoDB Atlas diset ke `0.0.0.0/0` (allow all)
3. Pastikan connection string benar (username, password, database name)
### MQTT Connection Error
1. MQTT broker (HiveMQ) harus bisa diakses dari Railway
2. Cek logs untuk error connection
3. Pastikan tidak ada firewall yang blocking
## Update Code
Setiap kali ada perubahan code:
```bash
git add .
git commit -m "Update: deskripsi perubahan"
git push
```
Railway akan otomatis re-deploy setelah push ke GitHub.
## Free Tier Limits
Railway free tier:
- $5 credit per bulan
- 500 jam execution time
- 1GB RAM
- 1GB disk
Cukup untuk development dan testing!

59
backend-Sigasti/README.md Normal file
View File

@ -0,0 +1,59 @@
# Backend Autoclave Sterilization System
Backend API untuk sistem monitoring dan kontrol autoclave sterilization menggunakan MQTT.
## Tech Stack
- Node.js + Express
- MongoDB (Mongoose)
- MQTT (HiveMQ)
- CORS
## Environment Variables
Buat file `.env` dengan variabel berikut:
```env
PORT=5000
MONGODB_URI=your_mongodb_connection_string
```
## Installation
```bash
npm install
```
## Running Locally
```bash
npm start
```
Server akan berjalan di `http://localhost:5000`
## API Endpoints
### Sterilization Set
- `POST /sterilisasi/set` - Kirim perintah start ke alat
- `GET /sterilisasi/set` - Ambil semua data set
- `GET /sterilisasi/set/last` - Ambil data set terakhir
### Sterilization Running
- `POST /sterilisasi/running` - Kirim perintah stop ke alat
- `GET /sterilisasi/running` - Ambil semua data running
- `GET /sterilisasi/running/last` - Ambil data running terakhir
### Sterilization Finish
- `GET /sterilisasi/finish` - Ambil semua data finish
- `GET /sterilisasi/finish/last` - Ambil data finish terakhir (consumed)
### History
- `GET /sterilisasi/history` - Ambil history lengkap (gabungan finish + set)
## Deployment
Deploy ke Railway:
1. Push code ke GitHub
2. Connect repository di Railway
3. Set environment variables di Railway dashboard
4. Deploy otomatis akan berjalan

View File

@ -0,0 +1,23 @@
const mongoose = require("mongoose");
const connectDB = async () => {
try {
// Support both MONGODB_URI (Railway default) and MONGO_URI (local .env)
const mongoUri = process.env.MONGODB_URI || process.env.MONGO_URI;
if (!mongoUri) {
console.error("❌ MONGODB_URI atau MONGO_URI tidak ditemukan!");
console.log("Available env vars:", Object.keys(process.env).filter(k => k.includes('MONGO')));
process.exit(1);
}
console.log("🔗 Connecting to MongoDB...");
await mongoose.connect(mongoUri);
console.log("MongoDB Connected ✅");
} catch (error) {
console.error("MongoDB Error ❌:", error.message);
process.exit(1);
}
};
module.exports = connectDB;

View File

@ -0,0 +1,108 @@
const mongoose = require("mongoose");
// ── Koleksi: data dari topik sterilisasi/set ──────────────
// Menyimpan perintah yang dikirim dari aplikasi ke alat
const setSchema = new mongoose.Schema({
action: { type: String },
suhu: { type: Number },
tekanan: { type: Number },
waktu: { type: mongoose.Schema.Types.Mixed },
device: { type: String },
namaAlat: { type: String, default: "" },
status: { type: String, default: "unknown" },
batch_id: { type: String }, // ID unik untuk membedakan proses
createdAt:{ type: Date, default: Date.now },
});
// ── Koleksi: data dari topik sterilisasi/running ──────────
// Menyimpan status proses yang dikirim alat ke backend
const runningSchema = new mongoose.Schema({
action: { type: String },
suhu: { type: Number },
tekanan: { type: Number },
waktu: { type: mongoose.Schema.Types.Mixed },
timer: { type: String }, // Timer dari alat (format: "00:00:00")
device: { type: String },
sesi: { type: String },
status: { type: String },
percobaan: { type: Number }, // Jumlah percobaan ignition (untuk ignition_failed)
batch_id: { type: String }, // ID unik untuk membedakan proses
createdAt: { type: Date, default: Date.now },
});
// ── Koleksi: data dari topik sterilisasi/finish ───────────
// Menyimpan data saat proses selesai atau dihentikan
const finishSchema = new mongoose.Schema({
action: { type: String, default: "finish" }, // "finish" atau "stop"
suhu: { type: Number },
tekanan: { type: Number },
waktu: { type: mongoose.Schema.Types.Mixed },
device: { type: String },
notes: { type: String, default: "" }, // Catatan user untuk riwayat
batch_id: { type: String }, // ID unik untuk membedakan proses
createdAt: { type: Date, default: Date.now },
});
// ── Koleksi: data dari topik sterilisasi/manual ───────────
// Menyimpan perintah manual control (valve, gas, starter) dan data real-time (suhu, tekanan)
const manualSchema = new mongoose.Schema({
valve: { type: String }, // "OPEN" atau "CLOSE"
gas: { type: String }, // "TUTUP", "KECIL", "SEDANG", "BESAR"
starter: { type: String }, // "ON" atau "OFF"
suhureal: { type: Number }, // Suhu real-time dari alat
tekananreal: { type: Number }, // Tekanan real-time dari alat
device: { type: String },
source: { type: String, default: "device" }, // "device" atau "backend" (anti-loop)
createdAt: { type: Date, default: Date.now },
});
// ── Koleksi BARU: histories ────────────────────────────────
// Menyimpan semua data proses dalam satu document berdasarkan batch_id
const historySchema = new mongoose.Schema({
batch_id: { type: String, required: true, unique: true, index: true }, // Primary key
device: { type: String, required: true },
namaAlat: { type: String, default: "" },
// Data dari SET (awal proses)
set: {
suhu: { type: Number },
tekanan: { type: Number },
waktu: { type: String },
startedAt: { type: Date },
},
// Data RUNNING (array untuk grafik)
runningData: [{
suhu: { type: Number },
tekanan: { type: Number },
timer: { type: String },
timestamp: { type: Date, default: Date.now },
_id: false // Disable _id untuk subdocument
}],
// Data FINISH
finish: {
action: { type: String }, // "finish" atau "stop"
suhu: { type: Number },
tekanan: { type: Number },
finishedAt: { type: Date },
},
status: {
type: String,
enum: ['running', 'completed', 'stopped'],
default: 'running'
},
notes: { type: String, default: "" },
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now },
});
const Set = mongoose.model("Set", setSchema);
const Running = mongoose.model("Running", runningSchema);
const Finish = mongoose.model("Finish", finishSchema);
const Manual = mongoose.model("Manual", manualSchema);
const History = mongoose.model("History", historySchema);
module.exports = { Set, Running, Finish, Manual, History };

View File

@ -0,0 +1,133 @@
const { History } = require("../models/sterilisasi");
/**
* Helper functions untuk mengelola collection History
*/
/**
* Buat history baru saat menerima SET (start)
*/
async function createHistory(data) {
const batchId = data.batch_id;
if (!batchId) {
console.log("[History] Tidak ada batch_id, skip create history");
return null;
}
try {
const history = await History.create({
batch_id: batchId,
device: data.Device ?? data.device,
namaAlat: data.namaAlat ?? "",
set: {
suhu: data.suhu ?? null,
tekanan: data.tekanan ?? null,
waktu: data.waktu ?? null,
startedAt: new Date(),
},
runningData: [],
status: 'running',
});
console.log(`[History] ✅ Created: batch_id=${batchId}`);
return history;
} catch (error) {
if (error.code === 11000) {
console.log(`[History] Sudah ada: batch_id=${batchId}`);
} else {
console.error("[History] Error create:", error.message);
}
return null;
}
}
/**
* Tambah data running ke history
*/
async function addRunningData(data) {
const batchId = data.batch_id;
if (!batchId) {
return null;
}
try {
const history = await History.findOneAndUpdate(
{ batch_id: batchId },
{
$push: {
runningData: {
suhu: data.suhu ?? null,
tekanan: data.tekanan ?? null,
timer: data.timer ?? null,
timestamp: new Date(),
}
},
$set: { updatedAt: new Date() }
},
{ new: true }
);
if (history) {
console.log(`[History] ✅ Running data added: batch_id=${batchId}, total=${history.runningData.length}`);
} else {
console.log(`[History] ⚠️ History not found for batch_id=${batchId}`);
}
return history;
} catch (error) {
console.error("[History] Error add running:", error.message);
return null;
}
}
/**
* Update history dengan data finish
*/
async function finishHistory(data) {
const batchId = data.batch_id;
if (!batchId) {
return null;
}
const action = data.action ?? "finish";
const newStatus = action === "stop" ? "stopped" : "completed";
try {
const history = await History.findOneAndUpdate(
{ batch_id: batchId },
{
$set: {
finish: {
action: action,
suhu: data.suhu ?? null,
tekanan: data.tekanan ?? null,
finishedAt: new Date(),
},
status: newStatus,
updatedAt: new Date(),
}
},
{ new: true }
);
if (history) {
console.log(`[History] ✅ Finished: batch_id=${batchId}, status=${newStatus}`);
} else {
console.log(`[History] ⚠️ History not found for batch_id=${batchId}`);
}
return history;
} catch (error) {
console.error("[History] Error finish:", error.message);
return null;
}
}
module.exports = {
createHistory,
addRunningData,
finishHistory,
};

View File

@ -0,0 +1,321 @@
require("dotenv").config();
const mqtt = require("mqtt");
const broker = "mqtt://broker.hivemq.com";
const { Set, Running, Finish, Manual, History } = require("../models/sterilisasi");
const { createHistory, addRunningData, finishHistory } = require("./historyHelper");
/**
* TOPIK MQTT:
*
* SUBSCRIBE (menerima dari perangkat):
* sterilisasi/running respon proses (countdown, running, ignition)
* sterilisasi/finish sinyal selesai dari perangkat
* sterilisasi/set logging perintah yang dikirim ke perangkat
* sterilisasi/manual logging perintah manual control
*
* PUBLISH (mengirim ke perangkat):
* sterilisasi/set perintah start dari aplikasi
* sterilisasi/finish perintah stop dari aplikasi
* sterilisasi/manual perintah manual control (valve, gas, starter)
*/
const SUBSCRIBE_TOPIC = "sterilisasi/running";
const FINISH_TOPIC = "sterilisasi/finish";
const SET_TOPIC = "sterilisasi/set";
const MANUAL_TOPIC = "sterilisasi/manual";
const PUBLISH_TOPIC = "sterilisasi/set";
const client = mqtt.connect(broker);
let lastData = null;
let lastFinishData = null;
let lastManualData = null; // Data manual control terbaru
let finishConsumed = false;
let finishTimestamp = null; // Timestamp kapan finish diterima
const FINISH_LOCK_DURATION = 5000; // 5 detik setelah finish, abaikan running
client.on("connect", () => {
console.log("MQTT Terhubung ✅ →", broker);
client.subscribe(SUBSCRIBE_TOPIC, (err) => {
if (!err) console.log("Subscribe ke topic:", SUBSCRIBE_TOPIC);
else console.error("Gagal subscribe:", err.message);
});
client.subscribe(FINISH_TOPIC, (err) => {
if (!err) console.log("Subscribe ke topic:", FINISH_TOPIC);
else console.error("Gagal subscribe:", err.message);
});
client.subscribe(SET_TOPIC, (err) => {
if (!err) console.log("Subscribe ke topic:", SET_TOPIC);
else console.error("Gagal subscribe:", err.message);
});
client.subscribe(MANUAL_TOPIC, (err) => {
if (!err) console.log("Subscribe ke topic:", MANUAL_TOPIC);
else console.error("Gagal subscribe:", err.message);
});
});
client.on("message", async (receivedTopic, message) => {
const raw = message.toString();
console.log(`[${receivedTopic}] Data masuk:`, raw);
let data = {};
try {
data = JSON.parse(raw);
} catch {
console.warn("Payload bukan JSON, diabaikan");
return;
}
// ── Topik sterilisasi/set ─────────────────────────────────
if (receivedTopic === SET_TOPIC) {
const action = data.action ?? null;
// ANTI-LOOP: Abaikan message yang dikirim oleh backend sendiri
if (data.source === "backend") {
console.log("[sterilisasi/set] Message dari backend sendiri, diabaikan (anti-loop)");
return;
}
console.log(`[sterilisasi/set] Menerima perintah: ${action}, batch_id: ${data.batch_id ?? 'tidak ada'}`);
try {
const setData = {
action,
device: data.Device ?? data.device ?? "unknown",
namaAlat: data.namaAlat ?? "",
status: action === "start" ? "running" : (action === "stop" ? "dihentikan" : "unknown"),
batch_id: data.batch_id ?? null, // Simpan batch_id
};
if (data.suhu != null) setData.suhu = data.suhu;
if (data.tekanan != null) setData.tekanan = data.tekanan;
if (data.waktu != null) setData.waktu = data.waktu;
await new Set(setData).save();
console.log(`[sterilisasi/set] Disimpan: action=${action}, batch_id=${setData.batch_id}`);
// ── Simpan ke History (jika action=start dan ada batch_id) ──
if (action === "start" && setData.batch_id) {
await createHistory(data);
}
} catch (error) {
console.error("[sterilisasi/set] Gagal simpan:", error.message);
}
return;
}
// ── Topik sterilisasi/finish ──────────────────────────────
if (receivedTopic === FINISH_TOPIC) {
const action = data.action ?? "finish"; // Default action adalah "finish"
// ANTI-LOOP: Abaikan message yang dikirim oleh backend sendiri
if (data.source === "backend") {
console.log("[sterilisasi/finish] Message dari backend sendiri, diabaikan (anti-loop)");
return;
}
lastFinishData = {
action: action, // Bisa "finish" atau "stop"
suhu: data.suhu ?? null,
tekanan: data.tekanan ?? null,
waktu: data.waktu ?? null,
device: data.Device ?? data.device ?? null,
batch_id: data.batch_id ?? null, // Simpan batch_id
};
finishConsumed = false;
finishTimestamp = Date.now(); // Catat waktu finish diterima
console.log(`[sterilisasi/finish] lastFinishData diperbarui (action=${action}, batch_id=${lastFinishData.batch_id}):`, lastFinishData);
// Update lastData juga agar frontend bisa detect action finish/stop
lastData = {
action: action,
suhu: data.suhu ?? null,
tekanan: data.tekanan ?? null,
waktu: data.waktu ?? null,
timer: null,
device: data.Device ?? data.device ?? null,
sesi: null,
status: null,
percobaan: null,
batch_id: data.batch_id ?? null, // Simpan batch_id
};
console.log(`[sterilisasi/finish] lastData diperbarui dengan action ${action}, batch_id=${lastData.batch_id}:`, lastData);
try {
await new Finish(lastFinishData).save();
console.log(`[sterilisasi/finish] Disimpan ke collection Finish (action=${action}, batch_id=${lastFinishData.batch_id})`);
// ── Update History dengan data finish (jika ada batch_id) ──
if (lastFinishData.batch_id) {
await finishHistory(lastFinishData);
}
} catch (error) {
console.error("[sterilisasi/finish] Gagal simpan:", error.message);
}
return;
}
// ── Topik sterilisasi/manual ──────────────────────────────
if (receivedTopic === MANUAL_TOPIC) {
// ANTI-LOOP: Abaikan message yang dikirim oleh backend sendiri
if (data.source === "backend") {
console.log("[sterilisasi/manual] Message dari backend sendiri, diabaikan (anti-loop)");
return;
}
lastManualData = {
valve: data.valve ?? null,
gas: data.gas ?? null,
starter: data.starter ?? null,
suhureal: data.suhureal ?? null,
tekananreal: data.tekananreal ?? null,
device: data.device ?? data.Device ?? null,
source: data.source ?? "device", // Track source
};
console.log("[sterilisasi/manual] lastManualData diperbarui (source: device, TIDAK disimpan ke DB):", lastManualData);
// TIDAK DISIMPAN KE DATABASE - hanya di memory untuk real-time access
// try {
// await new Manual(lastManualData).save();
// console.log("[sterilisasi/manual] Disimpan ke collection Manual");
// } catch (error) {
// console.error("[sterilisasi/manual] Gagal simpan:", error.message);
// }
return;
}
// ── Topik sterilisasi/running ─────────────────────────────
const action = data.action ?? null;
if (!action) {
console.warn("Tidak ada field action, diabaikan");
return;
}
// ANTI-LOOP: Abaikan message yang dikirim oleh backend sendiri
if (data.source === "backend") {
console.log("[sterilisasi/running] Message dari backend sendiri, diabaikan (anti-loop)");
return;
}
const validActions = ["countdown", "running", "ignition", "ignition_failed", "stop"];
if (!validActions.includes(action)) {
console.warn("Action tidak dikenali:", action);
return;
}
// WORKAROUND: Abaikan data running jika baru saja menerima finish (dalam 5 detik)
if (action === "running" && finishTimestamp && (Date.now() - finishTimestamp < FINISH_LOCK_DURATION)) {
console.warn(`[WORKAROUND] Data running diabaikan karena finish baru diterima ${Date.now() - finishTimestamp}ms yang lalu`);
return;
}
lastData = {
action,
suhu: data.suhu ?? null,
tekanan: data.tekanan ?? null,
waktu: data.waktu ?? null, // Tidak menggunakan fallback, biarkan null jika tidak ada
timer: data.timer ?? null, // Timer dari alat (format: "00:00:00")
device: data.Device ?? data.device ?? null,
sesi: data.sesi ?? null,
status: data.status ?? null,
percobaan: data.percobaan ?? null, // Jumlah percobaan ignition (untuk ignition_failed)
batch_id: data.batch_id ?? null, // Simpan batch_id
};
console.log("lastData diperbarui:", lastData);
try {
await new Running(lastData).save();
console.log(`[sterilisasi/running] Disimpan: action=${action}, batch_id=${lastData.batch_id}`);
// ── Tambah ke History.runningData (jika ada batch_id) ──
if (lastData.batch_id && action === "running") {
await addRunningData(lastData);
}
} catch (error) {
console.error("[sterilisasi/running] Gagal simpan:", error.message);
}
});
module.exports = {
client,
getLastData: () => lastData,
consumeAction: () => { if (lastData) lastData.action = null; },
getLastFinishData: () => lastFinishData,
consumeFinish: () => { lastFinishData = null; finishConsumed = true; },
isFinishConsumed: () => finishConsumed,
getLastManualData: () => lastManualData,
PUBLISH_TOPIC,
MANUAL_TOPIC,
updateLastDataWithStop: (device, batch_id = null) => {
lastData = {
action: "stop",
suhu: null,
tekanan: null,
waktu: null,
timer: null,
device: device ?? null,
sesi: null,
status: null,
percobaan: null,
batch_id: batch_id,
};
console.log(`[MQTT] lastData diperbarui dengan action stop (dari frontend), batch_id=${batch_id}:`, lastData);
},
publishSet: (payload) => {
return new Promise((resolve, reject) => {
// Tambahkan field source: "backend" untuk anti-loop
const payloadWithSource = {
...payload,
source: "backend"
};
client.publish(PUBLISH_TOPIC, JSON.stringify(payloadWithSource), (err) => {
if (err) reject(err);
else { console.log(`[PUBLISH] ${PUBLISH_TOPIC}:`, JSON.stringify(payloadWithSource)); resolve(); }
});
});
},
publishStop: (payload) => {
return new Promise((resolve, reject) => {
// Tambahkan field source: "backend" untuk anti-loop
const payloadWithSource = {
...payload,
source: "backend"
};
client.publish(FINISH_TOPIC, JSON.stringify(payloadWithSource), (err) => {
if (err) reject(err);
else { console.log(`[PUBLISH] ${FINISH_TOPIC}:`, JSON.stringify(payloadWithSource)); resolve(); }
});
});
},
publishManual: (payload) => {
return new Promise((resolve, reject) => {
// Tambahkan field source: "backend" untuk anti-loop
const payloadWithSource = {
...payload,
source: "backend"
};
client.publish(MANUAL_TOPIC, JSON.stringify(payloadWithSource), (err) => {
if (err) reject(err);
else { console.log(`[PUBLISH] ${MANUAL_TOPIC}:`, JSON.stringify(payloadWithSource)); resolve(); }
});
});
},
publishRunning: (payload) => {
return new Promise((resolve, reject) => {
// Tambahkan field source: "backend" untuk anti-loop
const payloadWithSource = {
...payload,
source: "backend"
};
client.publish(SUBSCRIBE_TOPIC, JSON.stringify(payloadWithSource), (err) => {
if (err) reject(err);
else { console.log(`[PUBLISH] ${SUBSCRIBE_TOPIC}:`, JSON.stringify(payloadWithSource)); resolve(); }
});
});
},
};

1437
backend-Sigasti/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,20 @@
{
"name": "back-end",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"cors": "^2.8.6",
"dotenv": "^17.4.1",
"express": "^5.2.1",
"mongoose": "^9.4.1",
"mqtt": "^5.15.1"
}
}

View File

@ -0,0 +1,7 @@
[build]
builder = "NIXPACKS"
[deploy]
startCommand = "node server.js"
restartPolicyType = "ON_FAILURE"
restartPolicyMaxRetries = 10

View File

@ -0,0 +1,433 @@
const express = require("express");
const router = express.Router();
const { Set, Running, Finish, Manual, History } = require("../models/sterilisasi");
const mqttClient = require("../mqtt/mqttClient");
// ── POST /sterilisasi/set ─────────────────────────────────────
// Frontend kirim parameter start → publish ke MQTT sterilisasi/set
router.post("/set", async (req, res) => {
try {
const { action, suhu, tekanan, waktu, device, batch_id } = req.body;
if (!device) {
return res.status(400).json({ status: "error", message: "Field device wajib diisi" });
}
const mqttPayload = {
action: action || "start",
suhu: suhu != null ? Number(suhu) : null,
tekanan: tekanan != null ? Number(tekanan) : null,
waktu,
Device: device,
batch_id: batch_id ?? null, // Include batch_id jika ada
};
await mqttClient.publishSet(mqttPayload);
res.json({ status: "success", message: `Perintah ${action || "start"} berhasil dikirim`, batch_id: batch_id });
} catch (error) {
res.status(500).json({ status: "error", message: error.message });
}
});
// ── GET /sterilisasi/set ──────────────────────────────────────
router.get("/set", async (_req, res) => {
try {
const data = await Set.find().sort({ createdAt: -1 }).limit(50);
res.json({ status: "success", data });
} catch (error) {
res.status(500).json({ status: "error", message: error.message });
}
});
// ── GET /sterilisasi/set/last ─────────────────────────────────
router.get("/set/last", async (_req, res) => {
try {
const last = await Set.findOne().sort({ createdAt: -1 });
res.json({ status: "success", data: last });
} catch (error) {
res.status(500).json({ status: "error", message: error.message });
}
});
// ── GET /sterilisasi/running ──────────────────────────────────
router.get("/running", async (_req, res) => {
try {
const data = await Running.find().sort({ createdAt: -1 }).limit(50);
res.json({ status: "success", data });
} catch (error) {
res.status(500).json({ status: "error", message: error.message });
}
});
// ── POST /sterilisasi/running ─────────────────────────────────
// Frontend kirim perintah stop → publish ke topik sterilisasi/running
router.post("/running", async (req, res) => {
try {
const { action, device, suhu, tekanan, batch_id } = req.body;
if (!device) {
return res.status(400).json({ status: "error", message: "Field device wajib diisi" });
}
// Jika action adalah stop, kirim ke topic sterilisasi/finish
if (action === "stop" || !action) {
const mqttPayload = {
action: "stop",
suhu: suhu != null ? Number(suhu) : 0,
tekanan: tekanan != null ? Number(tekanan) : 0,
device: device,
batch_id: batch_id ?? null, // Include batch_id jika ada
};
await mqttClient.publishStop(mqttPayload);
// Update lastData langsung agar frontend bisa detect stop
mqttClient.updateLastDataWithStop(device, batch_id);
return res.json({
status: "success",
message: `Perintah stop berhasil dikirim ke topik sterilisasi/finish`,
batch_id: batch_id
});
}
// Untuk action lain, kirim ke topic sterilisasi/running
const mqttPayload = {
action: action,
Device: device,
batch_id: batch_id ?? null, // Include batch_id jika ada
};
await mqttClient.publishRunning(mqttPayload);
res.json({ status: "success", message: `Perintah ${action} berhasil dikirim ke topik running`, batch_id: batch_id });
} catch (error) {
res.status(500).json({ status: "error", message: error.message });
}
});
// ── GET /sterilisasi/running/last ─────────────────────────────
router.get("/running/last", async (_req, res) => {
try {
const last = await Running.findOne().sort({ createdAt: -1 });
res.json({ status: "success", data: last });
} catch (error) {
res.status(500).json({ status: "error", message: error.message });
}
});
// ── GET /sterilisasi/running/batch/:batch_id ──────────────────
// Ambil semua data running berdasarkan batch_id untuk grafik riwayat
router.get("/running/batch/:batch_id", async (req, res) => {
try {
const { batch_id } = req.params;
if (!batch_id) {
return res.status(400).json({ status: "error", message: "batch_id tidak valid" });
}
// Ambil semua data running dengan batch_id yang sama, diurutkan berdasarkan waktu
const runningData = await Running.find({ batch_id }).sort({ createdAt: 1 });
res.json({
status: "success",
data: runningData,
count: runningData.length
});
} catch (error) {
res.status(500).json({ status: "error", message: error.message });
}
});
// ── GET /sterilisasi/finish ───────────────────────────────────
router.get("/finish", async (_req, res) => {
try {
const data = await Finish.find().sort({ createdAt: -1 }).limit(100);
res.json({ status: "success", data });
} catch (error) {
res.status(500).json({ status: "error", message: error.message });
}
});
// ── GET /sterilisasi/histories ────────────────────────────────
// Ambil semua data dari collection histories (yang punya batch_id)
router.get("/histories", async (_req, res) => {
try {
const histories = await History.find()
.sort({ createdAt: -1 })
.limit(100);
// Transform data untuk frontend
const data = histories.map(h => ({
_id: h._id,
batch_id: h.batch_id,
device: h.device,
namaAlat: h.namaAlat,
suhu: h.set?.suhu ?? 0,
tekanan: h.set?.tekanan ?? 0,
waktu: h.set?.waktu ?? "00:00",
status: h.status,
action: h.finish?.action ?? null,
createdAt: h.createdAt,
notes: h.notes ?? "",
runningDataCount: h.runningData?.length ?? 0,
}));
res.json({ status: "success", data });
} catch (error) {
res.status(500).json({ status: "error", message: error.message });
}
});
// ── GET /sterilisasi/histories/:batch_id ──────────────────────
// Ambil satu history lengkap dengan runningData untuk grafik
router.get("/histories/:batch_id", async (req, res) => {
try {
const { batch_id } = req.params;
const history = await History.findOne({ batch_id });
if (!history) {
return res.status(404).json({
status: "error",
message: "History not found"
});
}
res.json({ status: "success", data: history });
} catch (error) {
res.status(500).json({ status: "error", message: error.message });
}
});
// ── GET /sterilisasi/history ──────────────────────────────────
// DEPRECATED: Masih ada untuk backward compatibility
// Menggabungkan data finish dengan set untuk history lengkap
router.get("/history", async (_req, res) => {
try {
const finishData = await Finish.find().sort({ createdAt: -1 }).limit(100);
// Untuk setiap finish, cari set yang sesuai berdasarkan batch_id atau device+waktu
const history = await Promise.all(
finishData.map(async (finish) => {
let matchingSet = null;
let batchIdToUse = finish.batch_id; // Default: gunakan batch_id dari finish
// Prioritas 1: Cari berdasarkan batch_id jika ada
if (finish.batch_id) {
matchingSet = await Set.findOne({
batch_id: finish.batch_id,
action: "start"
}).sort({ createdAt: -1 });
if (matchingSet) {
console.log(`[History] Match berdasarkan batch_id: ${finish.batch_id}`);
}
}
// Prioritas 2: Fallback ke metode lama (device + waktu) jika tidak ada batch_id atau tidak ketemu
if (!matchingSet) {
const timeWindow = new Date(finish.createdAt.getTime() - 2 * 60 * 60 * 1000);
matchingSet = await Set.findOne({
device: finish.device,
action: "start",
createdAt: { $gte: timeWindow, $lte: finish.createdAt }
}).sort({ createdAt: -1 });
if (matchingSet) {
console.log(`[History] Match berdasarkan device+waktu untuk device: ${finish.device}`);
// FALLBACK: Jika finish tidak punya batch_id tapi set punya, gunakan dari set
if (!batchIdToUse && matchingSet.batch_id) {
batchIdToUse = matchingSet.batch_id;
console.log(`[History] Menggunakan batch_id dari Set: ${batchIdToUse}`);
}
}
}
// FALLBACK FINAL: Jika masih tidak ada batch_id, coba cari dari running terakhir
if (!batchIdToUse) {
const timeWindow = new Date(finish.createdAt.getTime() - 2 * 60 * 60 * 1000);
const lastRunning = await Running.findOne({
device: finish.device,
batch_id: { $ne: null }, // Hanya yang ada batch_id
createdAt: { $gte: timeWindow, $lte: finish.createdAt }
}).sort({ createdAt: -1 });
if (lastRunning && lastRunning.batch_id) {
batchIdToUse = lastRunning.batch_id;
console.log(`[History] Menggunakan batch_id dari Running: ${batchIdToUse}`);
}
}
return {
_id: finish._id,
action: finish.action || "finish", // Include action dari finish ("finish" atau "stop")
device: finish.device,
suhu: matchingSet?.suhu ?? finish.suhu ?? 0,
tekanan: matchingSet?.tekanan ?? finish.tekanan ?? 0,
waktu: matchingSet?.waktu ?? finish.waktu ?? "00:00",
finishSuhu: finish.suhu,
finishTekanan: finish.tekanan,
createdAt: finish.createdAt,
notes: finish.notes || "",
batch_id: batchIdToUse, // Include batch_id (dari finish, set, atau running)
};
})
);
res.json({ status: "success", data: history });
} catch (error) {
res.status(500).json({ status: "error", message: error.message });
}
});
// ── GET /sterilisasi/finish/last ──────────────────────────────
// Ambil data finish dari memory dan CONSUME setelah dibaca
router.get("/finish/last", async (_req, res) => {
try {
const lastFinish = mqttClient.getLastFinishData();
if (!lastFinish) {
return res.json({ status: "success", data: null });
}
// Copy dulu sebelum di-consume agar tidak hilang karena race condition
const dataToSend = { ...lastFinish };
mqttClient.consumeFinish();
console.log("[GET /sterilisasi/finish/last] Data finish di-consume:", JSON.stringify(dataToSend));
res.json({ status: "success", data: dataToSend });
} catch (error) {
res.status(500).json({ status: "error", message: error.message });
}
});
// ── DELETE /sterilisasi/history/:id ───────────────────────────
// Hapus riwayat berdasarkan ID
router.delete("/history/:id", async (req, res) => {
try {
const { id } = req.params;
if (!id) {
return res.status(400).json({ status: "error", message: "ID tidak valid" });
}
const deleted = await Finish.findByIdAndDelete(id);
if (!deleted) {
return res.status(404).json({ status: "error", message: "Data tidak ditemukan" });
}
res.json({ status: "success", message: "Riwayat berhasil dihapus" });
} catch (error) {
res.status(500).json({ status: "error", message: error.message });
}
});
// ── PATCH /sterilisasi/history/:id ────────────────────────────
// Update catatan riwayat berdasarkan ID
router.patch("/history/:id", async (req, res) => {
try {
const { id } = req.params;
const { notes } = req.body;
if (!id) {
return res.status(400).json({ status: "error", message: "ID tidak valid" });
}
const updated = await Finish.findByIdAndUpdate(
id,
{ notes: notes || "" },
{ returnDocument: 'after' } // Updated: use returnDocument instead of new
);
if (!updated) {
return res.status(404).json({ status: "error", message: "Data tidak ditemukan" });
}
res.json({ status: "success", message: "Catatan berhasil diupdate", data: updated });
} catch (error) {
res.status(500).json({ status: "error", message: error.message });
}
});
// ── POST /sterilisasi/manual ──────────────────────────────────
// Frontend kirim perintah manual control → publish ke MQTT sterilisasi/manual
router.post("/manual", async (req, res) => {
try {
const { valve, gas, starter, suhureal, tekananreal, device } = req.body;
if (!device) {
return res.status(400).json({ status: "error", message: "Field device wajib diisi" });
}
// Validasi nilai valve
if (valve && !["OPEN", "CLOSE"].includes(valve)) {
return res.status(400).json({
status: "error",
message: "Nilai valve harus OPEN atau CLOSE"
});
}
// Validasi nilai gas
if (gas && !["TUTUP", "KECIL", "SEDANG", "BESAR"].includes(gas)) {
return res.status(400).json({
status: "error",
message: "Nilai gas harus TUTUP, KECIL, SEDANG, atau BESAR"
});
}
// Validasi nilai starter
if (starter && !["ON", "OFF"].includes(starter)) {
return res.status(400).json({
status: "error",
message: "Nilai starter harus ON atau OFF"
});
}
const mqttPayload = {
valve: valve ?? null,
gas: gas ?? null,
starter: starter ?? null,
suhureal: suhureal != null ? Number(suhureal) : null,
tekananreal: tekananreal != null ? Number(tekananreal) : null,
device: device,
};
await mqttClient.publishManual(mqttPayload);
res.json({
status: "success",
message: "Perintah manual control berhasil dikirim",
data: mqttPayload
});
} catch (error) {
res.status(500).json({ status: "error", message: error.message });
}
});
// ── GET /sterilisasi/manual/last ──────────────────────────────
// Ambil data manual control terbaru dari memory
router.get("/manual/last", async (_req, res) => {
try {
const lastManual = mqttClient.getLastManualData();
res.json({ status: "success", data: lastManual });
} catch (error) {
res.status(500).json({ status: "error", message: error.message });
}
});
// ── GET /sterilisasi/manual ───────────────────────────────────
// History manual control - DISABLED (data tidak disimpan ke database)
router.get("/manual", async (_req, res) => {
res.json({
status: "info",
message: "History manual control tidak tersedia. Data hanya disimpan di memory untuk real-time access.",
data: []
});
});
module.exports = router;

25
backend-Sigasti/server.js Normal file
View File

@ -0,0 +1,25 @@
require("dotenv").config(); // Load env vars PERTAMA KALI
const express = require("express");
const cors = require("cors");
const connectDB = require("./config/database");
// Inisialisasi MQTT client (subscribe otomatis saat require)
require("./mqtt/mqttClient");
const sterilisasiRouter = require("./routes/sterilisasi");
const app = express();
app.use(cors());
app.use(express.json());
connectDB();
// ── Routes ────────────────────────────────────────────────
app.use("/sterilisasi", sterilisasiRouter);
app.get("/", (_req, res) => res.send("Backend MQTT Aktif 🚀"));
// ─────────────────────────────────────────────────────────
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => console.log(`Server jalan di http://localhost:${PORT}`));

View File

@ -0,0 +1,105 @@
/**
* Script untuk test endpoint batch_id
*
* Usage: node test-batch-id.js
*/
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:5000';
async function testHistoryEndpoint() {
console.log('\n=== Test GET /sterilisasi/history ===\n');
try {
const response = await fetch(`${BACKEND_URL}/sterilisasi/history`);
const data = await response.json();
if (data.status === 'success') {
console.log(`✅ Success! Found ${data.data.length} history entries\n`);
// Tampilkan 3 entry pertama dengan batch_id
console.log('Sample data (first 3):');
data.data.slice(0, 3).forEach((item, index) => {
console.log(`\n${index + 1}. ID: ${item._id}`);
console.log(` Device: ${item.device}`);
console.log(` Batch ID: ${item.batch_id || '❌ NULL'}`);
console.log(` Suhu: ${item.suhu}°C`);
console.log(` Tekanan: ${item.tekanan} bar`);
console.log(` Created: ${item.createdAt}`);
});
// Cek berapa yang punya batch_id
const withBatchId = data.data.filter(item => item.batch_id);
const withoutBatchId = data.data.filter(item => !item.batch_id);
console.log(`\n📊 Statistics:`);
console.log(` Total entries: ${data.data.length}`);
console.log(` With batch_id: ${withBatchId.length}`);
console.log(` Without batch_id: ${withoutBatchId.length} ⚠️`);
return withBatchId.length > 0 ? withBatchId[0].batch_id : null;
} else {
console.log('❌ Failed:', data);
return null;
}
} catch (error) {
console.error('❌ Error:', error.message);
return null;
}
}
async function testRunningBatchEndpoint(batchId) {
if (!batchId) {
console.log('\n⚠ Skipping running/batch test - no batch_id available\n');
return;
}
console.log(`\n=== Test GET /sterilisasi/running/batch/${batchId} ===\n`);
try {
const response = await fetch(`${BACKEND_URL}/sterilisasi/running/batch/${batchId}`);
const data = await response.json();
if (data.status === 'success') {
console.log(`✅ Success! Found ${data.count} running data points\n`);
if (data.data && data.data.length > 0) {
console.log('Sample data (first 5):');
data.data.slice(0, 5).forEach((item, index) => {
console.log(`\n${index + 1}. Action: ${item.action}`);
console.log(` Suhu: ${item.suhu ?? 'null'}°C`);
console.log(` Tekanan: ${item.tekanan ?? 'null'} bar`);
console.log(` Timer: ${item.timer ?? 'null'}`);
console.log(` Created: ${item.createdAt}`);
});
// Statistik data
const suhuData = data.data.map(item => item.suhu ?? 0);
const tekananData = data.data.map(item => item.tekanan ?? 0);
console.log(`\n📊 Data Statistics:`);
console.log(` Total points: ${data.data.length}`);
console.log(` Suhu range: ${Math.min(...suhuData)} - ${Math.max(...suhuData)}°C`);
console.log(` Tekanan range: ${Math.min(...tekananData)} - ${Math.max(...tekananData)} bar`);
console.log(` First timer: ${data.data[0].timer}`);
console.log(` Last timer: ${data.data[data.data.length - 1].timer}`);
} else {
console.log('⚠️ No data found for this batch_id');
}
} else {
console.log('❌ Failed:', data);
}
} catch (error) {
console.error('❌ Error:', error.message);
}
}
// Main execution
(async () => {
console.log('🧪 Testing batch_id endpoints...\n');
console.log(`Backend URL: ${BACKEND_URL}\n`);
const batchId = await testHistoryEndpoint();
await testRunningBatchEndpoint(batchId);
console.log('\n✅ Test complete!\n');
})();

View File

@ -0,0 +1,88 @@
/**
* Quick test untuk endpoint history
* Usage: node test-history-api.js
*/
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:5000';
async function testHistoryAPI() {
console.log('🧪 Testing GET /sterilisasi/history\n');
console.log(`Backend URL: ${BACKEND_URL}\n`);
try {
const response = await fetch(`${BACKEND_URL}/sterilisasi/history`);
const result = await response.json();
if (result.status === 'success' && result.data) {
console.log(`✅ Success! Found ${result.data.length} entries\n`);
// Tampilkan 5 entry pertama
console.log('═══════════════════════════════════════════════════════\n');
result.data.slice(0, 5).forEach((item, index) => {
console.log(`${index + 1}. Entry ID: ${item._id}`);
console.log(` Device: ${item.device}`);
console.log(` Batch ID: ${item.batch_id ? '✅ ' + item.batch_id : '❌ NULL'}`);
console.log(` Suhu: ${item.suhu}°C`);
console.log(` Tekanan: ${item.tekanan} bar`);
console.log(` Waktu: ${item.waktu}`);
console.log(` Created: ${new Date(item.createdAt).toLocaleString()}`);
console.log('');
});
console.log('═══════════════════════════════════════════════════════\n');
// Statistik
const withBatchId = result.data.filter(item => item.batch_id);
const withoutBatchId = result.data.filter(item => !item.batch_id);
console.log('📊 Statistics:');
console.log(` Total entries: ${result.data.length}`);
console.log(` With batch_id: ${withBatchId.length}`);
console.log(` Without batch_id: ${withoutBatchId.length} ⚠️`);
console.log('');
// Test endpoint batch untuk yang ada batch_id
if (withBatchId.length > 0) {
const testBatchId = withBatchId[0].batch_id;
console.log(`\n🔍 Testing /sterilisasi/running/batch/${testBatchId}\n`);
const batchResponse = await fetch(`${BACKEND_URL}/sterilisasi/running/batch/${testBatchId}`);
const batchResult = await batchResponse.json();
if (batchResult.status === 'success') {
console.log(`✅ Found ${batchResult.count} running data points`);
if (batchResult.data && batchResult.data.length > 0) {
console.log('\nSample data (first 3):');
batchResult.data.slice(0, 3).forEach((item, index) => {
console.log(`\n${index + 1}. Timer: ${item.timer}`);
console.log(` Suhu: ${item.suhu ?? 'null'}°C`);
console.log(` Tekanan: ${item.tekanan ?? 'null'} bar`);
});
// Data untuk grafik
const suhuData = batchResult.data.map(d => d.suhu ?? 0);
const tekananData = batchResult.data.map(d => d.tekanan ?? 0);
console.log(`\n📊 Grafik Data:`);
console.log(` Suhu points: ${suhuData.length}`);
console.log(` Suhu range: ${Math.min(...suhuData)} - ${Math.max(...suhuData)}°C`);
console.log(` Tekanan points: ${tekananData.length}`);
console.log(` Tekanan range: ${Math.min(...tekananData)} - ${Math.max(...tekananData)} bar`);
}
} else {
console.log('❌ Failed to get running data:', batchResult);
}
} else {
console.log('⚠️ No entries with batch_id found for testing');
}
} else {
console.log('❌ API returned error:', result);
}
} catch (error) {
console.error('❌ Error:', error.message);
}
}
// Run
testHistoryAPI();