first commit
CI / lint-and-test (push) Canceled after 0s

This commit is contained in:
Alberto-Audrix committed 2026-07-28 08:55:05 +07:00
commit 32a36cceff
444 files changed
+67186

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
node_modules
npm-debug.log
.env.local
.env.*.local
dist
*.md
.git
.gitignore
backend/
QUICKSTART.md
+19
View File
@@ -0,0 +1,19 @@
# Environment Variables for Docker Deployment
# Copy this file to .env and set your values
# PostgreSQL Database Credentials
# IMPORTANT: Change these to strong, unique values in production!
DB_USER=dashboard_user
DB_PASSWORD=change_this_to_a_strong_password
DB_NAME=dashboard_db
# API URL - Leave empty for local development (uses http://localhost:5001/api)
# For Docker deployment, this is set automatically to /api
VITE_API_URL=
# LLM Deployment Settings (Ollama / LM Studio)
# Default model name to download and pull automatically
LLM_MODEL_NAME=deepseek-r1:8b
# LLM engine connection URL (defaults to http://llm:11434 inside docker)
LM_STUDIO_BASE_URL=http://localhost:11434
+42
View File
@@ -0,0 +1,42 @@
{
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"prettier"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
}
},
"plugins": ["@typescript-eslint", "react", "react-hooks", "prettier"],
"rules": {
"prettier/prettier": "error",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}
],
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
"react-hooks/exhaustive-deps": "warn",
"react-hooks/static-components": "off",
"react-hooks/purity": "off",
"react-hooks/set-state-in-effect": "off",
"react/no-unescaped-entities": "off"
},
"settings": {
"react": {
"version": "detect"
}
},
"ignorePatterns": ["dist", "node_modules", "*.config.js", "*.config.ts"]
}
+77
View File
@@ -0,0 +1,77 @@
# GitHub Actions CI/CD
This directory contains automated workflows for the chicken farm dashboard.
## Workflows
### CI Pipeline (`ci.yml`)
**Triggers:**
- Push to `main` or `master` branch
- Pull requests to `main` or `master` branch
**What it does:**
1. ✅ Checks out code
2. ✅ Sets up Node.js 18
3. ✅ Installs frontend dependencies
4. ✅ Runs ESLint (code quality check)
5. ✅ Runs TypeScript type check (strict mode)
6. ✅ Runs frontend tests
7. ✅ Builds frontend (verifies build works)
8. ✅ Installs backend dependencies
9. ✅ Runs backend tests
**Duration:** ~3-5 minutes
**Status:** Tests run with `continue-on-error: true` for gradual adoption
## How to View Results
1. Go to your repository on GitHub
2. Click "Actions" tab
3. See all workflow runs and their status
## Adding Status Badge to README
Add this to your main README.md:
```markdown
![CI](https://github.com/YOUR-USERNAME/YOUR-REPO/workflows/CI/badge.svg)
```
Replace `YOUR-USERNAME` and `YOUR-REPO` with your actual GitHub details.
## Troubleshooting
**If CI fails:**
- Check the "Actions" tab for detailed logs
- Most common issues:
- TypeScript errors (run `npm run type-check` locally)
- Linting errors (run `npm run lint` locally)
- Build failures (run `npm run build` locally)
- Test failures (run `npm test` locally)
**Local testing before push:**
```bash
# Run the same checks CI will run
npm run lint
npm run type-check
npm run build
npm test
cd backend
npm test
```
## Future Improvements
Optional enhancements you can add later:
- Deploy to production on successful build
- Run tests against test database
- Add code coverage reports
- Slack/Discord notifications on failures
+54
View File
@@ -0,0 +1,54 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install frontend dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
continue-on-error: true
- name: Run TypeScript type check
run: npm run type-check
- name: Run frontend tests
run: npm run test:run
continue-on-error: true
- name: Build frontend
run: npm run build
- name: Install backend dependencies
working-directory: ./backend
run: npm ci
- name: Run backend tests
working-directory: ./backend
run: npm run test:run
continue-on-error: true
- name: Test Summary
if: always()
run: |
echo "✅ CI Pipeline Complete"
echo "📦 Frontend built successfully"
echo "🧪 Tests executed (check logs for results)"
+68
View File
@@ -0,0 +1,68 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Local Docker Compose overrides (ports for dev machine)
docker-compose.override.yml
# Database dumps (sensitive; keep off git)
backups/
backup_*.sql
*.sql.gz
*.dump
# Environment variables (sensitive data)
.env
.env.local
.env.*.local
backend/.env
backend/.env.local
**/.env.local
# Backend
backend/node_modules/
backend/data/*.db
backend/data/*.db-*
# Keep structure but ignore database files
!backend/data/.gitkeep
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Testing
coverage/
.nyc_output/
*.lcov
# Temporary files
*.tmp
*.temp
.cache/
.parcel-cache/
# OS
Thumbs.db
*~
# Docker volumes
docker-compose.override.yml
.docker/
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged
+9
View File
@@ -0,0 +1,9 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"printWidth": 100,
"trailingComma": "es5",
"arrowParens": "always",
"endOfLine": "lf"
}
+24
View File
@@ -0,0 +1,24 @@
FROM python:3.9-slim
WORKDIR /app
# Copy dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Pre-download the Hugging Face embedding model during Docker build phase
# so it is baked into the image and does not need internet to start
RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2')"
# Copy source code and vector DB
COPY . .
# Set offline environment variables
ENV HF_HUB_OFFLINE=1
ENV TRANSFORMERS_OFFLINE=1
ENV RAG_PORT=5002
EXPOSE 5002
CMD ["python", "rag_service.py"]
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Binary file not shown.
Whitespace-only changes.
+120
View File
@@ -0,0 +1,120 @@
import os
import glob
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
INPUT_DIR = os.getenv("INPUT_DIR", "documents")
OUTPUT_TXT_DIR = os.getenv("OUTPUT_TXT_DIR", "extracted_txt")
def extract_docx(file_path):
"""Mengekstrak teks dari file .docx dengan menjaga urutan asli paragraf dan tabel."""
import docx
from docx.oxml import OxmlElement
from docx.text.paragraph import Paragraph
from docx.table import Table
doc = docx.Document(file_path)
full_text = []
# Iterasi semua elemen anak di dalam body document untuk menjaga urutan
for element in doc.element.body:
tag = element.tag
if tag.endswith('p'):
para = Paragraph(element, doc)
if para.text.strip():
full_text.append(para.text)
elif tag.endswith('tbl'):
table = Table(element, doc)
table_text = []
for row in table.rows:
row_text = []
for cell in row.cells:
text = cell.text.strip()
# Hindari duplikasi text sel gabungan (merged cells) secara berturut-turut
if not row_text or row_text[-1] != text:
row_text.append(text)
if row_text:
table_text.append(" | ".join(row_text))
if table_text:
full_text.append("\n".join(table_text))
return "\n\n".join(full_text)
def extract_pdf(file_path):
"""Mengekstrak teks dari file .pdf halaman demi halaman."""
from pypdf import PdfReader
reader = PdfReader(file_path)
full_text = []
for i, page in enumerate(reader.pages):
text = page.extract_text()
if text and text.strip():
full_text.append(text)
return "\n".join(full_text)
def extract_txt(file_path):
"""Membaca file teks dengan encoding UTF-8."""
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
return f.read()
def main():
# Pastikan folder input ada
if not os.path.exists(INPUT_DIR):
print(f"Folder input '{INPUT_DIR}' tidak ditemukan. Membuat folder...")
os.makedirs(INPUT_DIR)
print(f"Silakan letakkan file dokumen Anda di folder '{INPUT_DIR}' lalu jalankan kembali script ini.")
return
# Buat folder output jika belum ada
os.makedirs(OUTPUT_TXT_DIR, exist_ok=True)
# Cari semua dokumen pendukung
supported_extensions = ["*.docx", "*.pdf", "*.txt"]
files_to_process = []
for ext in supported_extensions:
# Cari case-insensitive atau kombinasikan lowercase/uppercase
files_to_process.extend(glob.glob(os.path.join(INPUT_DIR, ext)))
files_to_process.extend(glob.glob(os.path.join(INPUT_DIR, ext.upper())))
# Hapus duplikasi jika ada (karena pencarian case-sensitive pada OS tertentu)
files_to_process = list(set(files_to_process))
if not files_to_process:
print(f"Tidak ada file .docx, .pdf, atau .txt yang ditemukan di folder '{INPUT_DIR}'.")
return
print(f"Menemukan {len(files_to_process)} file dokumen untuk diekstrak.")
for file_path in files_to_process:
filename = os.path.basename(file_path)
base_name, ext = os.path.splitext(filename)
output_file_path = os.path.join(OUTPUT_TXT_DIR, f"{base_name}.txt")
print(f"Mengekstrak: {filename} ... ", end="", flush=True)
try:
ext_lower = ext.lower()
if ext_lower == ".docx":
text = extract_docx(file_path)
elif ext_lower == ".pdf":
text = extract_pdf(file_path)
elif ext_lower == ".txt":
text = extract_txt(file_path)
else:
print("Format tidak didukung (dilewati)")
continue
# Simpan hasil teks ke file .txt di folder output
with open(output_file_path, "w", encoding="utf-8") as f:
f.write(text)
print(f"Selesai! Disimpan ke: {output_file_path}")
except Exception as e:
print(f"GAGAL! Error: {str(e)}")
print("\nProses ekstraksi selesai seluruhnya!")
if __name__ == "__main__":
main()
File diff suppressed because it is too large. Load diff
+162
View File
@@ -0,0 +1,162 @@
import os
import glob
from dotenv import load_dotenv
import chromadb
from sentence_transformers import SentenceTransformer
# Set offline mode agar sentence-transformers tidak mencoba menghubungi Hugging Face di jaringan on-premise
os.environ["HF_HUB_OFFLINE"] = "1"
# Load environment variables
load_dotenv()
OUTPUT_TXT_DIR = os.getenv("OUTPUT_TXT_DIR", "extracted_txt")
CHROMA_DB_DIR = os.getenv("CHROMA_DB_DIR", "chroma_db")
EMBEDDING_MODEL_NAME = os.getenv("EMBEDDING_MODEL_NAME", "all-MiniLM-L6-v2")
def chunk_text(text, chunk_size=800, chunk_overlap=150):
"""Memecah teks menjadi chunk berdasarkan paragraf, baris, atau kata."""
paragraphs = text.split("\n\n")
chunks = []
current_chunk = ""
for para in paragraphs:
para = para.strip()
if not para:
continue
# Jika paragraf itu sendiri lebih besar dari chunk_size, bagi berdasarkan baris
if len(para) > chunk_size:
if current_chunk:
chunks.append(current_chunk)
current_chunk = ""
lines = para.split("\n")
for line in lines:
line = line.strip()
if not line:
continue
if len(line) > chunk_size:
# Bagi baris panjang berdasarkan kata
words = line.split(" ")
temp_chunk = ""
for word in words:
if len(temp_chunk) + len(word) + 1 > chunk_size:
if temp_chunk:
chunks.append(temp_chunk)
overlap_start = max(0, len(temp_chunk) - chunk_overlap)
temp_chunk = temp_chunk[overlap_start:].strip()
if temp_chunk:
temp_chunk += " " + word
else:
temp_chunk = word
else:
if temp_chunk:
temp_chunk += " " + word
else:
temp_chunk = word
if temp_chunk:
current_chunk = temp_chunk
else:
if len(current_chunk) + len(line) + 1 > chunk_size:
chunks.append(current_chunk)
overlap_start = max(0, len(current_chunk) - chunk_overlap)
current_chunk = current_chunk[overlap_start:].strip()
if current_chunk:
current_chunk += "\n" + line
else:
current_chunk = line
else:
if current_chunk:
current_chunk += "\n" + line
else:
current_chunk = line
else:
# Pengelompokan paragraf standar
if len(current_chunk) + len(para) + 2 > chunk_size:
chunks.append(current_chunk)
overlap_start = max(0, len(current_chunk) - chunk_overlap)
current_chunk = current_chunk[overlap_start:].strip()
if current_chunk:
current_chunk += "\n\n" + para
else:
current_chunk = para
else:
if current_chunk:
current_chunk += "\n\n" + para
else:
current_chunk = para
if current_chunk:
chunks.append(current_chunk)
return chunks
def main():
if not os.path.exists(OUTPUT_TXT_DIR):
print(f"Folder teks terekstrak '{OUTPUT_TXT_DIR}' tidak ditemukan. Jalankan extract_text.py terlebih dahulu.")
return
txt_files = glob.glob(os.path.join(OUTPUT_TXT_DIR, "*.txt"))
if not txt_files:
print(f"Tidak ada file .txt ditemukan di '{OUTPUT_TXT_DIR}'. Jalankan extract_text.py terlebih dahulu.")
return
# Inisialisasi Model Embedding lokal
print(f"Memuat model embedding lokal '{EMBEDDING_MODEL_NAME}'...")
model = SentenceTransformer(EMBEDDING_MODEL_NAME)
print("Model embedding berhasil dimuat.")
# Inisialisasi Chroma DB client
print(f"Menginisialisasi Chroma DB di folder '{CHROMA_DB_DIR}'...")
client = chromadb.PersistentClient(path=CHROMA_DB_DIR)
# Hapus koleksi lama jika ada untuk menghindari duplikasi data lama saat indeks ulang
try:
client.delete_collection(name="company_sop")
print("Koleksi lama 'company_sop' berhasil dihapus untuk indeks ulang.")
except Exception:
pass
collection = client.create_collection(name="company_sop")
total_chunks = 0
for file_path in txt_files:
filename = os.path.basename(file_path)
print(f"\nMemproses chunking & embedding untuk file: {filename}...")
with open(file_path, "r", encoding="utf-8") as f:
text = f.read()
chunks = chunk_text(text, chunk_size=800, chunk_overlap=150)
if not chunks:
print(f"File {filename} kosong atau tidak menghasilkan chunk.")
continue
print(f"Menghasilkan {len(chunks)} chunks dari {filename}. Membuat embedding...")
# Hitung embeddings
embeddings = model.encode(chunks)
embeddings_list = [emb.tolist() for emb in embeddings]
# Siapkan metadata dan ID unik untuk Chroma DB
metadatas = [{"source": filename, "chunk_index": i} for i in range(len(chunks))]
ids = [f"{filename}_chunk_{i}" for i in range(len(chunks))]
# Tambahkan ke Chroma DB
collection.add(
documents=chunks,
embeddings=embeddings_list,
metadatas=metadatas,
ids=ids
)
total_chunks += len(chunks)
print(f"Berhasil menyimpan {len(chunks)} chunks ke Chroma DB.")
print(f"\nProses pembuatan database selesai! Total {total_chunks} chunks berhasil disimpan di Chroma DB.")
if __name__ == "__main__":
main()
+157
View File
@@ -0,0 +1,157 @@
import os
import sys
import requests
from dotenv import load_dotenv
import chromadb
from sentence_transformers import SentenceTransformer
# Set offline mode agar sentence-transformers tidak mencoba menghubungi Hugging Face di jaringan on-premise
os.environ["HF_HUB_OFFLINE"] = "1"
# Load environment variables
load_dotenv()
CHROMA_DB_DIR = os.getenv("CHROMA_DB_DIR", "chroma_db")
EMBEDDING_MODEL_NAME = os.getenv("EMBEDDING_MODEL_NAME", "all-MiniLM-L6-v2")
OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL", "http://localhost:1234/v1")
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "lm-studio")
LLM_MODEL_NAME = os.getenv("LLM_MODEL_NAME", "deepseek-r1-distill-qwen-7b")
def main():
# 1. Inisialisasi Database Vektor
if not os.path.exists(CHROMA_DB_DIR):
print(f"Database Chroma DB di '{CHROMA_DB_DIR}' tidak ditemukan. Silakan jalankan extract_text.py dan populate_db.py terlebih dahulu.")
return
print("Menghubungkan ke Chroma DB...")
chroma_client = chromadb.PersistentClient(path=CHROMA_DB_DIR)
try:
collection = chroma_client.get_collection(name="company_sop")
except Exception as e:
print(f"Koleksi 'company_sop' tidak ditemukan di database. Pastikan populate_db.py sudah dijalankan dengan sukses. Error: {e}")
return
# 2. Inisialisasi Model Embedding lokal
print(f"Memuat model embedding lokal '{EMBEDDING_MODEL_NAME}' untuk kueri...")
embedding_model = SentenceTransformer(EMBEDDING_MODEL_NAME)
print("Model embedding berhasil dimuat.")
# 3. Konfigurasi koneksi LM Studio native v1 API
LM_STUDIO_API_URL = os.getenv("LM_STUDIO_API_URL")
if not LM_STUDIO_API_URL:
openai_base = os.getenv("OPENAI_BASE_URL", "http://localhost:1234/v1")
if openai_base.endswith("/v1"):
LM_STUDIO_API_URL = openai_base.replace("/v1", "/api/v1/chat")
else:
LM_STUDIO_API_URL = f"{openai_base.rstrip('/')}/api/v1/chat"
print(f"Mengonfigurasi koneksi LLM ke native API: {LM_STUDIO_API_URL} (Model: {LLM_MODEL_NAME})...")
print("\n" + "="*60)
print(" PIPELINE RAG LOKAL - ASISTEN SOP PERUSAHAAN (DEEPSEEK)")
print(" Ketik 'keluar' atau 'exit' untuk menyudahi percakapan.")
print("="*60 + "\n")
while True:
try:
query = input("\nPertanyaan Anda: ").strip()
if not query:
continue
if query.lower() in ["keluar", "exit", "q", "quit"]:
print("Sampai jumpa!")
break
print("\n[1/3] Mencari dokumen referensi relevan di database lokal...", end="", flush=True)
# Buat embedding kueri
query_embedding = embedding_model.encode([query])[0].tolist()
# Cari kueri di Chroma DB (ambil 6 chunk teratas)
results = collection.query(
query_embeddings=[query_embedding],
n_results=6
)
print(" Selesai!")
retrieved_chunks = results['documents'][0]
retrieved_metadatas = results['metadatas'][0]
if not retrieved_chunks or len(retrieved_chunks) == 0:
print("⚠️ Tidak ditemukan referensi dokumen yang cocok dengan pertanyaan Anda.")
continue
# Tampilkan referensi yang ditemukan
print("\n[Referensi yang Ditemukan]:")
for idx, meta in enumerate(retrieved_metadatas):
print(f" - [{idx+1}] File: {meta['source']} (Chunk: {meta['chunk_index']})")
# 4. Susun Prompt dengan Konteks SOP
context = "\n\n---\n\n".join(retrieved_chunks)
system_prompt = (
"Anda adalah asisten AI perusahaan yang profesional. Tugas Anda adalah memberikan jawaban "
"yang valid, akurat, dan sesuai dengan Standar Operasional Prosedur (SOP) atau dokumen acuan perusahaan "
"yang disediakan di bawah ini.\n"
"Patuhi aturan berikut:\n"
"1. Jawablah HANYA berdasarkan informasi yang ada dalam dokumen acuan di bawah.\n"
"2. Jika jawaban tidak dapat ditemukan di dalam dokumen tersebut secara eksplisit atau logis, katakan dengan sopan "
"bahwa 'Maaf, informasi tersebut tidak ditemukan dalam dokumen SOP/acuan perusahaan kami.' Jangan mengarang informasi.\n"
"3. Sajikan data dengan valid dan rapi."
)
user_prompt = f"""Dokumen SOP / Acuan Perusahaan:
=========================================
{context}
=========================================
Pertanyaan Pengguna: {query}
Jawaban berdasarkan Dokumen Acuan:"""
print(f"\n[2/3] Menghubungi LLM DeepSeek lokal di {LM_STUDIO_API_URL}...", end="", flush=True)
# Panggil LM Studio native v1 API
headers = {
"Content-Type": "application/json"
}
if OPENAI_API_KEY and OPENAI_API_KEY != "lm-studio":
headers["Authorization"] = f"Bearer {OPENAI_API_KEY}"
payload = {
"model": LLM_MODEL_NAME,
"input": user_prompt,
"system_prompt": system_prompt,
"temperature": 0.1,
"max_output_tokens": 32000
}
response = requests.post(LM_STUDIO_API_URL, json=payload, headers=headers)
response.raise_for_status()
print(" Selesai!")
answer = response.json()["response"]
# Pisahkan proses berpikir (<think>) jika ada (khusus model reasoning seperti DeepSeek R1)
import re
think_match = re.search(r'<think>(.*?)</think>', answer, re.DOTALL)
clean_answer = re.sub(r'<think>.*?</think>', '', answer, flags=re.DOTALL).strip()
if think_match and think_match.group(1).strip():
print(" Selesai!")
print("\n[Proses Berpikir DeepSeek]:")
print("." * 50)
print(think_match.group(1).strip())
print("." * 50)
else:
print(" Selesai!")
print("\n[3/3] Respon Asisten SOP:")
print("-"*50)
print(clean_answer)
print("-"*50)
except Exception as e:
print(f"\n❌ Terjadi kesalahan: {e}")
print("Harap pastikan server LLM lokal Anda (LM Studio/vLLM/llama.cpp) sedang berjalan dan dapat diakses.")
if __name__ == "__main__":
main()
+148
View File
@@ -0,0 +1,148 @@
"""
RAG Microservice untuk CP 707 Knowledge Base
Berjalan di port 5002, dipanggil oleh backend Node.js
Menggunakan ChromaDB + SentenceTransformers (offline mode)
"""
import os
import sys
from pathlib import Path
from dotenv import load_dotenv
# Set offline agar tidak download dari HuggingFace
os.environ["HF_HUB_OFFLINE"] = "1"
os.environ["TRANSFORMERS_OFFLINE"] = "1"
# Load .env dari direktori yang sama dengan script ini
script_dir = Path(__file__).parent
load_dotenv(script_dir / ".env")
CHROMA_DB_DIR = str(script_dir / os.getenv("CHROMA_DB_DIR", "chroma_db"))
EMBEDDING_MODEL_NAME = os.getenv("EMBEDDING_MODEL_NAME", "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
RAG_PORT = int(os.getenv("RAG_PORT", "5002"))
COLLECTION_NAME = "company_sop"
print(f"[RAG Service] ChromaDB path: {CHROMA_DB_DIR}")
print(f"[RAG Service] Embedding model: {EMBEDDING_MODEL_NAME}")
# Import setelah env set
import chromadb
from sentence_transformers import SentenceTransformer
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
import uvicorn
# ─── Init ChromaDB & Embedding Model ─────────────────────────────────────────
print("[RAG Service] Memuat ChromaDB...")
try:
chroma_client = chromadb.PersistentClient(path=CHROMA_DB_DIR)
collection = chroma_client.get_collection(name=COLLECTION_NAME)
total_chunks = collection.count()
print(f"[RAG Service] ChromaDB loaded. Total chunks: {total_chunks}")
except Exception as e:
print(f"[RAG Service] ERROR: Gagal load ChromaDB: {e}")
sys.exit(1)
print(f"[RAG Service] Memuat embedding model '{EMBEDDING_MODEL_NAME}'...")
try:
embedding_model = SentenceTransformer(EMBEDDING_MODEL_NAME)
print("[RAG Service] Embedding model berhasil dimuat.")
except Exception as e:
print(f"[RAG Service] ERROR: Gagal load embedding model: {e}")
print("[RAG Service] Pastikan model sudah didownload. Jalankan populate_db.py terlebih dahulu.")
sys.exit(1)
# ─── FastAPI App ──────────────────────────────────────────────────────────────
app = FastAPI(
title="CP707 RAG Service",
description="Retrieval-Augmented Generation service untuk buku Manajemen Broiler CP 707",
version="1.0.0"
)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://localhost:5001", "http://127.0.0.1:5001"],
allow_methods=["GET", "POST"],
allow_headers=["*"],
)
# ─── Request/Response Models ─────────────────────────────────────────────────
class QueryRequest(BaseModel):
query: str = Field(..., min_length=1, description="Query text untuk mencari chunk CP707 relevan")
n_results: int = Field(default=4, ge=1, le=10, description="Jumlah chunk yang dikembalikan")
topic: str = Field(default="", description="Topic insight: berat_ayam, fcr, iot_panel, dll")
class QueryResponse(BaseModel):
success: bool
chunks: list
sources: list
total_found: int
query_used: str
class HealthResponse(BaseModel):
status: str
total_chunks: int
embedding_model: str
# ─── Topic → Query enhancement mapping ──────────────────────────────────────
# Tambahkan keyword relevan per topic agar embedding search lebih tepat sasaran
TOPIC_QUERY_HINTS = {
"berat_ayam": "berat badan target bobot ADG pertumbuhan standar mingguan ayam broiler",
"fcr": "FCR feed conversion ratio konsumsi pakan efisiensi standar broiler",
"iot_panel": "suhu kandang kelembapan amonia CO2 ventilasi lingkungan pemeliharaan broiler",
"eef": "EEF indeks performa IP efisiensi produksi siklus broiler",
"hitung_ayam": "mortalitas deplesi kematian afkir populasi standar toleransi broiler",
"hitung_karung": "pakan karung konsumsi harian feed intake standar broiler",
}
# ─── Endpoints ───────────────────────────────────────────────────────────────
@app.get("/health", response_model=HealthResponse)
def health_check():
return HealthResponse(
status="ok",
total_chunks=collection.count(),
embedding_model=EMBEDDING_MODEL_NAME,
)
@app.post("/query", response_model=QueryResponse)
def query_cp707(req: QueryRequest):
"""
Cari chunk CP707 yang relevan berdasarkan query.
Jika topic disediakan, tambahkan hint keyword agar hasil lebih relevan.
"""
enhanced_query = req.query
if req.topic and req.topic in TOPIC_QUERY_HINTS:
enhanced_query = f"{req.query} {TOPIC_QUERY_HINTS[req.topic]}"
try:
query_embedding = embedding_model.encode([enhanced_query])[0].tolist()
results = collection.query(
query_embeddings=[query_embedding],
n_results=min(req.n_results, collection.count()),
)
chunks = results["documents"][0] if results["documents"] else []
metadatas = results["metadatas"][0] if results["metadatas"] else []
sources = [
f"{m.get('source', 'unknown')} (chunk {m.get('chunk_index', '?')})"
for m in metadatas
]
return QueryResponse(
success=True,
chunks=chunks,
sources=sources,
total_found=len(chunks),
query_used=enhanced_query,
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"RAG query error: {str(e)}")
if __name__ == "__main__":
print(f"[RAG Service] Starting on http://0.0.0.0:{RAG_PORT}")
uvicorn.run(app, host="0.0.0.0", port=RAG_PORT, log_level="warning")
+8
View File
@@ -0,0 +1,8 @@
chromadb
sentence-transformers
python-docx
pypdf
python-dotenv
openai
fastapi
uvicorn
+1203
View File
File diff suppressed because it is too large. Load diff
+176
View File
@@ -0,0 +1,176 @@
# Chicken Counting API Documentation
**Version:** 2.0
**Last Updated:** March 5, 2026
**Base URL:** `http://103.215.13.55:5001/api`
---
## Overview
The Chicken Counting API allows external systems to submit chicken population counts per kandang (coop) per date. The API uses a simple upsert mechanism — calling the endpoint multiple times with the same date and kandang will replace the previous record with the latest data.
---
## Authentication
All requests require an API key via the `X-API-Key` header.
```http
X-API-Key: cpa_e8cfeeabaa6997a1ecd4239cee6a9cc59cf43f96cfd58463
```
| Header | Value |
| ----------- | ------------------------------------------------------ |
| `X-API-Key` | `cpa_e8cfeeabaa6997a1ecd4239cee6a9cc59cf43f96cfd58463` |
---
## API Endpoint
### Submit / Update Chicken Count
**Endpoint:** `POST /api/chicken-counting`
**Headers:**
```http
Content-Type: application/json
X-API-Key: cpa_e8cfeeabaa6997a1ecd4239cee6a9cc59cf43f96cfd58463
```
**Request Body:**
```json
{
"date": "2026-03-01",
"kandang": "kandang-atas",
"filename": "farm_sukawarna_5b_ch1_main_20260301145938_20260301151559.mp4",
"total_count": 5612
}
```
**Required Fields:**
| Field | Type | Description |
| ------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `date` | string | Date in `YYYY-MM-DD` format |
| `kandang` | string | Slug of the kandang name (e.g. `kandang-atas`, `kandang-bawah`). Matched against the kandangs table by normalizing the name: lowercase + spaces replaced with dashes. |
| `total_count` | integer | Chicken count (must be >= 0) |
**Optional Fields:**
| Field | Type | Description |
| ---------- | ------ | --------------------------- |
| `filename` | string | Source video/image filename |
**Response (200 OK):**
```json
{
"success": true,
"data": {
"id": 1,
"date": "2026-03-01",
"kandangId": 1,
"kandangName": "Kandang Atas",
"filename": "farm_sukawarna_5b_ch1_main_20260301145938_20260301151559.mp4",
"totalCount": 5612,
"createdAt": "2026-03-01T14:59:38.000Z",
"updatedAt": "2026-03-01T14:59:38.000Z"
}
}
```
**Upsert Behavior:**
If a record with the same `date` + `kandang` already exists, the `total_count` and `filename` will be updated. The `createdAt` remains unchanged while `updatedAt` reflects the latest call.
---
## Error Handling
All error responses follow this format:
```json
{
"success": false,
"error": "Human-readable error message"
}
```
**Error Responses:**
| Status | Error | Description |
| ------ | ----------------------- | ----------------------------------------------------------------- |
| 401 | API key required | `X-API-Key` header not provided |
| 401 | Invalid API key | API key not found or incorrect |
| 400 | Missing required fields | `date`, `kandang`, or `total_count` not provided |
| 400 | Kandang not found | The `kandang` slug doesn't match any kandang name in the database |
| 400 | Invalid date format | Must be `YYYY-MM-DD` |
| 400 | Invalid total_count | Must be a non-negative number |
| 500 | Internal server error | Server error |
---
## Code Examples
### cURL
```bash
curl -X POST http://103.215.13.55:5001/api/chicken-counting \
-H "Content-Type: application/json" \
-H "X-API-Key: cpa_e8cfeeabaa6997a1ecd4239cee6a9cc59cf43f96cfd58463" \
-d '{
"date": "2026-03-01",
"kandang": "kandang-atas",
"filename": "farm_sukawarna_5b_ch1_main_20260301145938_20260301151559.mp4",
"total_count": 5612
}'
```
### Python
```python
import requests
BASE_URL = "http://103.215.13.55:5001/api"
API_KEY = "cpa_e8cfeeabaa6997a1ecd4239cee6a9cc59cf43f96cfd58463"
response = requests.post(
f"{BASE_URL}/chicken-counting",
headers={"X-API-Key": API_KEY},
json={
"date": "2026-03-01",
"kandang": "kandang-atas",
"filename": "farm_sukawarna_5b_ch1_main_20260301145938_20260301151559.mp4",
"total_count": 5612
}
)
print(response.json())
```
### JavaScript
```javascript
const API_KEY = 'cpa_e8cfeeabaa6997a1ecd4239cee6a9cc59cf43f96cfd58463';
const response = await fetch('http://103.215.13.55:5001/api/chicken-counting', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
},
body: JSON.stringify({
date: '2026-03-01',
kandang: 'kandang-atas',
filename: 'farm_sukawarna_5b_ch1_main_20260301145938_20260301151559.mp4',
total_count: 5612,
}),
});
const result = await response.json();
// Use `result` here.
```
+24
View File
@@ -0,0 +1,24 @@
import React, { useState } from 'react';
import AppContent from './components/AppContent.tsx';
import { AppContextProvider } from './context/AppContext.tsx';
import { Kandang } from './types/counting.ts';
const App: React.FC = () => {
const [selectedKandangId, setSelectedKandangId] = useState<number | null>(null);
const [kandangs, setKandangs] = useState<Kandang[]>([]);
// This will be populated by AppContent once cycle data is available
// For now, we'll pass the state and setter to AppContent
return (
<AppContextProvider selectedKandangId={selectedKandangId}>
<AppContent
selectedKandangId={selectedKandangId}
setSelectedKandangId={setSelectedKandangId}
kandangs={kandangs}
setKandangs={setKandangs}
/>
</AppContextProvider>
);
};
export default App;
+996
View File
@@ -0,0 +1,996 @@
# Data Source Mapping - Dashboard Solusi AI Peternakan Ayam
This document maps every page, section, graph, and box to their respective data sources (internal backend vs external API).
## Base URLs
- **External API (CPSP)**: `https://dashboard.cpsp.id`
- **Internal Backend**: `http://localhost:5001/api` (or `VITE_API_URL` env variable)
---
## 1. Main Dashboard (`/`)
### Page Components
#### 1.1 Filter Dropdowns Section
- **Component**: `FilterDropdown` (Area & Kandang)
- **Data Source**: Internal Backend
- **API**: `GET /api/kandangs/by-cycle/{cycleId}`
- **Usage**: Load kandang options for dropdown filter
#### 1.2 KPI Cards (6 cards)
- **Component**: `KpiCard`
- **Data Source**: External API (CPSP)
- **API**: `GET https://dashboard.cpsp.id/api/scales/cage/9f556d2a-af5c-41b0-bcd0-6cea0dfa0fed`
- **Data Path**: `response.data.kpi`
- **KPIs**:
1. Rata-rata Bobot (Average Weight)
2. Total Populasi (Total Population)
3. FCR (Feed Conversion Ratio)
4. Deplesi (Depletion/Mortality Rate)
5. Mortalitas (Mortality Count)
6. Umur (Age/Days)
#### 1.3 Chicken Counting Dashboard
- **Component**: `ChickenCountingDashboard`
- **Data Sources**: Multiple
##### 1.3.1 Counting Stats Cards
- **Component**: `CountingStats`
- **Data Source**: Internal Backend
- **API**: `GET /api/chicken-counting?startDate={start}&endDate={end}&kandangId={id}`
- **Metrics**:
- Total Count (today)
- Average Count (period)
- Change from yesterday
- Data points count
##### 1.3.2 Camera Count Cards (Multiple cameras)
- **Component**: `CameraCountCard`
- **Data Source**: Internal Backend
- **API**: `GET /api/chicken-counting?startDate={start}&endDate={end}&kandangId={id}`
- **Filtered by**: Camera name
- **Displays**: Latest count per camera
##### 1.3.3 Population Trend Chart
- **Component**: `SimplePopulationChart`
- **Data Source**: Internal Backend
- **API**: `GET /api/chicken-counting?startDate={start}&endDate={end}&kandangId={id}`
- **Chart Type**: Line chart showing population over time
- **Aggregation**: Sum of all cameras per day
##### 1.3.4 Location Count Breakdown
- **Component**: `LocationCountBreakdown`
- **Data Source**: Internal Backend
- **API**: `GET /api/chicken-counting?startDate={start}&endDate={end}`
- **Displays**: Breakdown by kandang location
#### 1.4 Daily Weight Gain Chart
- **Component**: `DailyWeightGainChart`
- **Data Source**: Multiple (External API + Internal Backend)
- **Weight Data API**: `GET https://dashboard.cpsp.id/api/iot/flocks/distribusi_bobot/?mode=range&start_date={start}&end_date={end}`
- **FCR Data API**: `GET https://dashboard.cpsp.id/api/data-support/fcrtable/{dow}/{population}/{day}/`
- **Mortality Data**: Internal Backend - `GET /api/mortality/{cycleId}`
- **Chart Metrics**:
- Actual Weight (kg)
- Standard Weight (kg)
- Daily Gain (g)
- ADG (Average Daily Gain)
- FCR
- IP (Performance Index)
- **Caching**: 1-hour cache for weight distribution, 24-hour cache for FCR
---
## 2. Weight Monitoring Page (`/weight-monitoring`)
### Page Components
#### 2.1 Weight Stats Cards
- **Component**: `WeightStats`
- **Data Source**: External API (CPSP)
- **API**: `GET https://dashboard.cpsp.id/api/iot/flocks/distribusi_bobot/?mode=yesterday`
- **Metrics**:
- Average Weight
- Uniformity %
- Standard Deviation
- Min/Max Weight
- Total Predictions
- Chicken Count
#### 2.2 Weight Distribution Chart
- **Component**: `WeightDistributionChart`
- **Data Source**: External API (CPSP)
- **API**: `GET https://dashboard.cpsp.id/api/iot/flocks/distribusi_bobot/?mode=range&start_date={start}&end_date={end}`
- **Chart Type**: Histogram/Bar chart
- **Displays**: Weight range distribution with frequency
- **Caching**: 1-hour cache grouped by 14-day periods
#### 2.3 Weight Trend Chart
- **Component**: `WeightTrendChart`
- **Data Source**: External API (CPSP)
- **API**: `GET https://dashboard.cpsp.id/api/iot/flocks/distribusi_bobot/?mode=range&start_date={start}&end_date={end}`
- **Chart Type**: Line chart
- **Metrics**: Average weight over time with standard weight comparison
#### 2.4 Combined Weight Trend Chart
- **Component**: `CombinedWeightTrendChart`
- **Data Source**: Multiple (External + Internal)
- **Weight API**: `GET https://dashboard.cpsp.id/api/iot/flocks/distribusi_bobot/`
- **Mortality API**: Internal Backend - `GET /api/mortality/{cycleId}`
- **Chart Type**: Multi-line chart with dual Y-axis
- **Metrics**: Weight trend + mortality overlay
#### 2.5 Weight Analysis
- **Component**: `WeightAnalysis`
- **Data Source**: External API (CPSP)
- **API**: `GET https://dashboard.cpsp.id/api/iot/flocks/distribusi_bobot/?mode=yesterday`
- **Analysis Includes**:
- Growth rate calculation
- Uniformity assessment
- Performance indicators
- Alerts and recommendations
#### 2.6 Sensor Weight Summary
- **Component**: `SensorWeightSummary`
- **Data Source**: External API (CPSP)
- **API**: `GET https://dashboard.cpsp.id/api/scales/predictions/summary?start_date={date}&end_date={date}`
- **Displays**: Per-device weight summary table
- **Metrics per device**:
- Device name
- Total weight (prediction & actual)
- Average weight (prediction & actual)
- Chicken count
#### 2.7 Live Scale View
- **Component**: `LiveScaleView`
- **Data Source**: External API (CPSP)
- **API**: `GET https://dashboard.cpsp.id/api/scales/predictions/range?start_date={start}&end_date={end}`
- **Displays**: Real-time scale readings
- **Updates**: Periodic polling
---
## 3. Multi-Coop Weight Comparison Page (`/multi-coop-weight`)
### Page Components
#### 3.1 Multi-Coop Analysis
- **Component**: `MultiCoopAnalysis`
- **Data Source**: External API (CPSP)
- **API**: `GET https://dashboard.cpsp.id/api/scales/cage/9f556d2a-af5c-41b0-bcd0-6cea0dfa0fed`
- **Data Path**: `response.data.kpi` (multi-coop structure)
- **Metrics per coop**:
- Average Weight
- Population
- FCR
- Mortality
- Growth rate
- **Chart Type**: Comparison bar charts
#### 3.2 Comparison Analysis
- **Component**: `ComparisonAnalysis`
- **Data Source**: External API (CPSP)
- **Same API as above**
- **Analysis**: Statistical comparison between coops
- **Displays**:
- Best/worst performers
- Performance gaps
- Recommendations
#### 3.3 Device Weight Comparison Chart
- **Component**: `DeviceWeightComparisonChart`
- **Data Source**: External API (CPSP)
- **API**: `GET https://dashboard.cpsp.id/api/scales/predictions/summary`
- **Chart Type**: Bar chart comparing devices
- **Metrics**: Average weight per device/coop
---
## 4. Feed Sack Counting Page (`/feed-sack-counting`)
### Page Components
#### 4.1 Feed Saldo Card (Balance Summary)
- **Component**: `FeedSaldoCard`
- **Data Sources**: Multiple (External + Internal)
- **Feed Sack API**: `GET https://dashboard.cpsp.id/api/counting/sacks/{siteId}/{cameraName}/`
- **Manual Entries API**: Internal Backend - `GET /api/manual-feed-sack?startDate={start}&endDate={end}`
- **Metrics**:
- Opening balance (Saldo Awal)
- Inbound sacks (Karung Masuk)
- Usage (Pemakaian)
- Closing balance (Saldo Akhir)
#### 4.2 Feed Stats Cards
- **Component**: `FeedStats`
- **Data Source**: External API + Internal Backend
- **External API**: `GET https://dashboard.cpsp.id/api/counting/sacks/{siteId}/`
- **Internal API**: `GET /api/manual-feed-sack`
- **Metrics**:
- Total inbound (today/period)
- Total usage (today/period)
- Average daily usage
- Days of supply remaining
#### 4.3 Feed Trend Chart
- **Component**: `FeedTrendChart`
- **Data Source**: External API + Internal Backend (merged)
- **External API**: `GET https://dashboard.cpsp.id/api/counting/sacks/{siteId}/`
- **Internal API**: `GET /api/manual-feed-sack`
- **Chart Type**: Multi-line chart
- **Lines**: Inbound, Usage, Balance over time
#### 4.4 Feed Activity Log
- **Component**: `FeedActivityLog`
- **Data Source**: External API + Internal Backend (merged)
- **External API**: `GET https://dashboard.cpsp.id/api/counting/sacks/{siteId}/`
- **Internal API**: `GET /api/manual-feed-sack`
- **Displays**: Timestamped feed sack transactions
- **Types**: Inbound, Usage, Outbound, Manual entries
#### 4.5 Feed Daily Summary Table
- **Component**: `FeedDailySummaryTable`
- **Data Source**: External API + Internal Backend
- **Aggregation**: Daily totals by camera/location
- **Editable**: Manual entry corrections via Internal Backend
- **API for edits**: `PUT /api/manual-feed-sack`
#### 4.6 Feed Comparison Charts
- **Component**: `FeedSackComparisonCharts`
- **Data Source**: External API + Internal Backend
- **Chart Types**:
- Camera comparison bar chart
- Location comparison bar chart
- Time-series comparison
#### 4.7 Live Feed View
- **Component**: `LiveFeedView`
- **Data Source**: External API (CPSP)
- **API**: `GET https://dashboard.cpsp.id/api/counting/sacks/{siteId}/`
- **Updates**: Real-time polling
- **Displays**: Latest camera readings
#### 4.8 Column Configuration
- **Component**: `ColumnConfigModal`
- **Data Source**: Internal Backend
- **API GET**: `GET /api/feed-sack-column-config`
- **API PUT**: `PUT /api/feed-sack-column-config`
- **Purpose**: Save user's column visibility preferences
---
## 5. Chicken Counting Page (`/chicken-counting`)
### Page Components
#### 5.1 Counting Stats
- **Component**: `CountingStats`
- **Data Source**: Internal Backend
- **API**: `GET /api/chicken-counting?startDate={start}&endDate={end}&kandangId={id}`
- **Metrics**: Same as dashboard section
#### 5.2 Chicken Count Trend Chart
- **Component**: `ChickenCountTrendChart`
- **Data Source**: Internal Backend
- **API**: `GET /api/chicken-counting?startDate={start}&endDate={end}&kandangId={id}`
- **Chart Type**: Line chart with area fill
- **Metrics**: Daily population trend
#### 5.3 Combined Trend Chart (Count + Mortality)
- **Component**: `CombinedTrendChart`
- **Data Source**: Multiple Internal Backend
- **Counting API**: `GET /api/chicken-counting`
- **Mortality API**: `GET /api/mortality/{cycleId}`
- **Chart Type**: Multi-line with dual Y-axis
- **Displays**: Population vs mortality correlation
#### 5.4 Population Trend Chart
- **Component**: `PopulationTrendChart`
- **Data Source**: Internal Backend
- **API**: `GET /api/chicken-counting`
- **Chart Type**: Area chart
- **Shows**: Population change over cycle
#### 5.5 Live Count View
- **Component**: `LiveCountView`
- **Data Source**: Internal Backend
- **API**: `GET /api/chicken-counting`
- **Updates**: Periodic polling for latest counts
#### 5.6 Kandang Management
- **Component**: `KandangManagementPage`
- **Data Source**: Internal Backend
- **API GET**: `GET /api/kandangs`
- **API POST**: `POST /api/kandangs`
- **API PUT**: `PUT /api/kandangs/{id}`
- **API DELETE**: `DELETE /api/kandangs/{id}`
- **DOC In Count**: `PUT /api/kandangs/{kandangId}/cycle/{cycleId}`
#### 5.7 Data Management
- **Component**: `DataManagementPage`
- **Data Source**: Internal Backend
- **Submit Count**: `POST /api/chicken-counting?source=manual`
- **Requires**: X-API-Key header
---
## 6. IoT Panel / Environment Monitoring (`/iot-panel`)
### Page Components
#### 6.1 IoT Panel Environment Column
- **Component**: `IotPanelEnvironmentColumn`
- **Data Source**: External API (CPSP)
- **Latest Sensor API**: `GET https://dashboard.cpsp.id/api/iot/flocks/sensors/latest/`
- **Range API**: `GET https://dashboard.cpsp.id/api/iot/flocks/sensors/range/?start_date={start}&end_date={end}`
- **Metrics**:
- Temperature (Suhu)
- Humidity (Kelembapan)
- Ammonia (Amonia)
- **Aggregation**: 5-minute intervals, last 12 hours
- **Filter**: Removes sensor failures (zero values)
#### 6.2 Environment Chart
- **Component**: `EnvironmentChart`
- **Data Source**: External API (CPSP)
- **API**: `GET https://dashboard.cpsp.id/api/iot/flocks/sensors/range/`
- **Chart Type**: Multi-line chart
- **Lines**: Temperature, Humidity, Ammonia
- **Time Range**: Last 12 hours (filtered)
#### 6.3 IoT Panel Devices Column
- **Component**: `IotPanelDevicesColumn`
- **Data Source**: External API (CPSP)
- **API**: `GET https://dashboard.cpsp.id/api/cage_sites/get_all/`
- **Displays**:
- Device list (scales, sensors)
- Device status
- Last reading timestamp
#### 6.4 Activity Log Panel
- **Component**: `ActivityLogPanel`
- **Data Source**: External API (CPSP)
- **API**: `GET https://dashboard.cpsp.id/api/iot/log-act/last/?limit={limit}`
- **Displays**: Recent IoT system activities
- **Log Types**: Device actions, alerts, status changes
#### 6.5 IoT Sensor History Modal
- **Component**: `IotSensorHistoryModal`
- **Data Source**: External API (CPSP)
- **API**: `GET https://dashboard.cpsp.id/api/iot/flocks/sensors/range/`
- **Chart Type**: Historical line chart
- **Date Range**: User-selectable
---
## 7. Manual Weighing Page (`/manual-weighing`)
### Page Components
#### 7.1 Manual Weighing Stats
- **Component**: `ManualWeighingStats`
- **Data Source**: External API (CPSP)
- **API**: `GET https://dashboard.cpsp.id/api/scales/predictions/range`
- **Metrics**:
- Total manual entries
- Average weight
- Sample size
- Last weighing date
#### 7.2 Manual Weighing Trends
- **Component**: `ManualWeighingTrends`
- **Data Source**: External API (CPSP)
- **API**: `GET https://dashboard.cpsp.id/api/scales/predictions/range`
- **Chart Type**: Line chart
- **Displays**: Manual weighing data trend
#### 7.3 Manual Weighing Analysis
- **Component**: `ManualWeighingAnalysis`
- **Data Source**: External API (CPSP)
- **API**: `GET https://dashboard.cpsp.id/api/scales/predictions/range`
- **Analysis**:
- Comparison with sensor data
- Variance analysis
- Accuracy metrics
#### 7.4 Weighing Record Cards
- **Component**: `WeighingRecordCard`
- **Data Source**: External API (CPSP)
- **API**: `GET https://dashboard.cpsp.id/api/scales/predictions/range`
- **Displays**: Individual manual weighing records
#### 7.5 AI Weighing Analysis Modal
- **Component**: `AiWeighingAnalysisModal`
- **Data Source**: Mock/Client-side AI
- **Note**: AI analysis is currently client-side processing
---
## 8. Cycle Management (`/cycle-timeline`)
### Page Components
#### 8.1 Cycle Timeline
- **Component**: `CycleTimeline`
- **Data Source**: Internal Backend
- **API**: `GET /api/cycles`
- **Displays**:
- Cycle start/end dates
- Current day
- Total days
- Progress bar
#### 8.2 Cycle Create Modal
- **Component**: `CycleCreateModal`
- **Data Source**: Internal Backend
- **API POST**: `POST /api/cycles`
- **Fields**:
- Start date
- End date
- Total days
- Chick-in weight
- DOC in count
- Status
#### 8.3 Active Cycle Info
- **Data Source**: Internal Backend
- **API**: `GET /api/cycles/active`
- **Used Throughout App**: Context provider
#### 8.4 Cycle Update
- **Data Source**: Internal Backend
- **API PUT**: `PUT /api/cycles/{id}`
---
## 9. Mortality Tracking (Embedded in multiple pages)
### Components
#### 9.1 Mortality Records Table
- **Data Source**: Internal Backend
- **API GET**: `GET /api/mortality/{cycleId}`
- **API GET (by Kandang)**: `GET /api/mortality/{cycleId}?kandangId={id}`
- **API GET (Totals)**: `GET /api/mortality/{cycleId}/totals`
- **Displays**:
- Daily mortality count
- Chicken count
- Panen (harvest)
- Afkir (culling)
- Keterangan (notes)
#### 9.2 Mortality Update
- **Data Source**: Internal Backend
- **API PUT**: `PUT /api/mortality/{cycleId}/{day}`
- **Body**: mortalityCount, chickenCount, kandangId, panen, keterangan, afkir
#### 9.3 Mortality Delete
- **Data Source**: Internal Backend
- **API DELETE**: `DELETE /api/mortality/{cycleId}/{day}`
---
## 10. FCR Calculation (Multiple Pages)
### Component
#### 10.1 FCR Comparison Chart
- **Component**: `FcrComparisonChart`
- **Data Source**: External API (CPSP)
- **API**: `GET https://dashboard.cpsp.id/api/data-support/fcrtable/{dow}/{population}/{day}/`
- **Parameters**:
- `dow`: Day of week (strain type)
- `population`: Initial population
- `day`: Current day in cycle
- **Caching**: 24-hour localStorage cache
- **Batch Fetch**: Can fetch multiple days at once
- **Metrics**:
- Weight
- ADG (Average Daily Gain)
- Daily Gain
- Daily Feed
- Feeds Use
- FCR
- Total Feed
- Bags Feed
---
## 11. Cobb Standard Reference (`/cobb-standard`)
### Page Components
#### 11.1 Weight Standard Table
- **Data Source**: External API (CPSP)
- **API**: `GET https://dashboard.cpsp.id/api/data-support/fcrtable/{dow}/{population}/{day}/`
- **Display**: Cobb standard weight by day
- **Comparison**: Actual vs standard
#### 11.2 Performance Index
- **Data Source**: Multiple (External + Internal)
- **Weight**: External API
- **Mortality**: Internal Backend
- **Calculation**: Client-side based on fetched data
---
## 12. Location/Cage Management (`/location-management`)
### Page Components
#### 12.1 Location Structure
- **Component**: `StructureColumn`
- **Data Source**: External API (CPSP)
- **API**: `GET https://dashboard.cpsp.id/api/cage_sites/get_all/`
- **Structure**: Site → Scales → Devices
- **Transforms To**: Location → Coop → Floor
#### 12.2 Floor Details
- **Component**: `FloorDetails`
- **Data Source**: External API (CPSP)
- **API**: Same as above
- **Displays**: Device-level details
---
## 13. Vet Visit Page (`/vet-visit`)
### Page Components
#### 13.1 Vet Visit Stats
- **Component**: `VetVisitStats`
- **Data Source**: Mock Data (to be implemented)
- **Future API**: Internal Backend endpoint needed
#### 13.2 Upcoming Visits
- **Component**: `UpcomingVisits`
- **Data Source**: Mock Data (to be implemented)
#### 13.3 Visit Log Table
- **Component**: `VisitLogTable`
- **Data Source**: Mock Data (to be implemented)
#### 13.4 Visit Notes Modal
- **Component**: `VisitNotesModal`
- **Data Source**: Mock Data (to be implemented)
---
## 14. Behavior Analysis (`/behavior-analysis`)
### Page Components
#### 14.1 Behavior Summary
- **Component**: `BehaviorSummary`
- **Data Source**: Mock Data (to be implemented)
- **Future API**: Computer vision API needed
#### 14.2 Behavior Trend Chart
- **Component**: `BehaviorTrendChart`
- **Data Source**: Mock Data (to be implemented)
#### 14.3 Live Behavior View
- **Component**: `LiveBehaviorView`
- **Data Source**: Mock Data (to be implemented)
- **Future**: Real-time video stream analysis
#### 14.4 Behavior Anomaly Log
- **Component**: `BehaviorAnomalyLog`
- **Data Source**: Mock Data (to be implemented)
---
## 15. Feed Scheduling (`/feed-scheduling`)
### Page Components
#### 15.1 Schedule Overview
- **Component**: `ScheduleOverview`
- **Data Source**: Mock Data (to be implemented)
- **Future API**: Internal Backend scheduling service
#### 15.2 Daily Schedule Timeline
- **Component**: `DailyScheduleTimeline`
- **Data Source**: Mock Data (to be implemented)
#### 15.3 Consumption Pattern Chart
- **Component**: `ConsumptionPatternChart`
- **Data Source**: External API (Feed Sack) + Mock
- **Historical Data**: `GET https://dashboard.cpsp.id/api/counting/sacks/`
- **Schedule Data**: Mock (to be implemented)
#### 15.4 Influencing Factors
- **Component**: `InfluencingFactors`
- **Data Source**: Multiple (Environment + Weight + Mock)
- **Environment**: External API
- **Weight**: External API
- **AI Factors**: Mock
#### 15.5 Daily Events List
- **Component**: `DailyEventsList`
- **Data Source**: Mock Data (to be implemented)
---
## 16. Feed Wastage (`/feed-wastage`)
### Page Components
#### 16.1 Wastage Stats
- **Component**: `WastageStats`
- **Data Source**: Calculated from Feed Sack API
- **Feed Data**: External API - Feed Sack Counting
- **Calculation**: Expected vs Actual usage
#### 16.2 Live Wastage View
- **Component**: `LiveWastageView`
- **Data Source**: Mock Data (to be implemented)
- **Future**: Computer vision API for wastage detection
#### 16.3 Wastage Hotspot Map
- **Component**: `WastageHotspotMap`
- **Data Source**: Mock Data (to be implemented)
#### 16.4 Wastage Alert Log
- **Component**: `WastageAlertLog`
- **Data Source**: Mock Data (to be implemented)
---
## 17. Alerts & Notifications (`/alerts`)
### Page Components
#### 17.1 Alert Feed
- **Component**: `AlertFeed`
- **Data Source**: Multiple sources aggregated
- **Weight Alerts**: External API (Weight Distribution)
- **Environment Alerts**: External API (IoT Sensors)
- **Mortality Alerts**: Internal Backend
- **Feed Alerts**: External API (Feed Sack)
- **System Alerts**: External API (IoT Activity Logs)
#### 17.2 Weight Alerts Log
- **Component**: `WeightAlertsLog`
- **Data Source**: External API (CPSP)
- **Based on**: Weight distribution statistics
- **Triggers**: Uniformity < 80%, Std Dev > threshold
---
## 18. IoT Device Management (`/iot-devices`)
### Page Components
#### 18.1 IoT Device Dashboard
- **Component**: `IotDeviceDashboard`
- **Data Source**: External API (CPSP)
- **API**: `GET https://dashboard.cpsp.id/api/cage_sites/get_all/`
- **Displays**: All IoT devices with status
#### 18.2 IoT Device Stats
- **Component**: `IotDeviceStats`
- **Data Source**: External API (CPSP)
- **Metrics**:
- Total devices
- Active devices
- Offline devices
- Last sync time
#### 18.3 IoT Device Card
- **Component**: `IotDeviceCard`
- **Data Source**: External API (CPSP)
- **API**: Same as above
- **Displays per device**:
- Device name
- Status
- Last reading
- Battery level (if applicable)
---
## 19. Camera Management (`/cameras`)
### Page Components
#### 19.1 Camera Dashboard
- **Component**: `CameraDashboard`
- **Data Source**: External API (CPSP)
- **Feed Sack Cameras**: `GET https://dashboard.cpsp.id/api/counting/sacks/`
- **Available Cameras**: Extracted from API response
#### 19.2 Camera Card
- **Component**: `CameraCard`
- **Data Source**: External API (CPSP)
- **Displays per camera**:
- Camera name
- Location
- Status
- Last capture time
- Latest count
---
## 20. Network Management (`/network`)
### Page Components
#### 20.1 Network Stats
- **Component**: `NetworkStats`
- **Data Source**: Mock Data (to be implemented)
- **Future API**: Network monitoring service
#### 20.2 Network Device Dashboard
- **Component**: `NetworkDeviceDashboard`
- **Data Source**: Mock Data (to be implemented)
#### 20.3 Bandwidth Chart
- **Component**: `BandwidthChart`
- **Data Source**: Mock Data (to be implemented)
---
## 21. Batch Processing (`/batch-process`)
### Page Components
#### 21.1 Batch Stats
- **Component**: `BatchStats`
- **Data Source**: Mock Data (to be implemented)
- **Future API**: Batch processing service
#### 21.2 Service Monitor
- **Component**: `ServiceMonitor`
- **Data Source**: Mock Data (to be implemented)
---
## 22. OCR Processing (`/ocr`)
### Page Components
#### 22.1 OCR Stats
- **Component**: `OcrStats`
- **Data Source**: Mock Data (to be implemented)
- **Future API**: OCR service
#### 22.2 OCR Upload
- **Component**: `OcrUpload`
- **Data Source**: Mock Data (to be implemented)
#### 22.3 OCR Documents Table
- **Component**: `OcrDocumentsTable`
- **Data Source**: Mock Data (to be implemented)
---
## 23. User Management (`/users`)
### Page Components
#### 23.1 User Tab
- **Component**: `UserTab`
- **Data Source**: Mock Data (to be implemented)
- **Future API**: Internal Backend user management
#### 23.2 Role Tab
- **Component**: `RoleTab`
- **Data Source**: Mock Data (to be implemented)
#### 23.3 Permission Manager
- **Component**: `PermissionManager`
- **Data Source**: Mock Data (to be implemented)
---
## 24. Authentication Pages (`/login`, `/forgot-password`)
### Page Components
#### 24.1 Login Form
- **Component**: `LoginForm`
- **Data Source**: Mock Data (to be implemented)
- **Future API**: Internal Backend auth service
#### 24.2 Forgot Password Form
- **Component**: `ForgotPasswordForm`
- **Data Source**: Mock Data (to be implemented)
---
## 25. Audit Logs (Embedded)
### Component
#### 25.1 Audit Log Viewer
- **Data Source**: Internal Backend
- **API**: `GET /api/audit-logs?table={table}&recordId={id}&startDate={start}&endDate={end}&limit={limit}&offset={offset}`
- **Count API**: `GET /api/audit-logs/count?table={table}`
- **Tracks**: All database changes
- **Fields**:
- Table name
- Record ID
- Action (INSERT, UPDATE, DELETE)
- Old values
- New values
- Timestamp
- User (if applicable)
---
## Summary Table
| Category | Internal Backend | External API (CPSP) | Mock/TBD |
| ----------------------------- | ------------------ | ------------------- | -------- |
| **Chicken Counting** | ✓ | | |
| **Weight Monitoring** | | ✓ | |
| **Weight Distribution** | | ✓ (with cache) | |
| **FCR Calculation** | | ✓ (with cache) | |
| **Feed Sack Counting** | ✓ (manual entries) | ✓ (camera counts) | |
| **IoT Sensors (Environment)** | | ✓ | |
| **IoT Devices/Scales** | | ✓ | |
| **Cycle Management** | ✓ | | |
| **Mortality Tracking** | ✓ | | |
| **Kandang Management** | ✓ | | |
| **Location/Cage Sites** | | ✓ | |
| **Manual Feed Sack** | ✓ | | |
| **Audit Logs** | ✓ | | |
| **Column Config** | ✓ | | |
| **Behavior Analysis** | | | ✓ |
| **Vet Visits** | | | ✓ |
| **Feed Scheduling** | | | ✓ |
| **Feed Wastage** | Partial | Partial | ✓ |
| **User Management** | | | ✓ |
| **Authentication** | | | ✓ |
| **Network Monitoring** | | | ✓ |
| **Batch Processing** | | | ✓ |
| **OCR Processing** | | | ✓ |
---
## Caching Strategy
### Weight Distribution Cache
- **Type**: 1-hour cache
- **Storage**: localStorage
- **Key Pattern**: `weight-dist-1hour-{groupNumber}`
- **Grouping**: 14-day periods aligned to cycle start
- **Clear Function**: `apiClient.clearWeightDistributionCache()`
### FCR Cache
- **Type**: 24-hour cache
- **Storage**: localStorage
- **Key Pattern**: `fcr-cache-{dow}-{population}-{day}`
- **Purpose**: Reduce API calls for standard reference data
---
## API Mode Configuration
The application supports two modes:
- **LIVE Mode**: Fetches from real APIs
- **DEMO Mode**: Uses mock data
Mode is set in `AppContext` and passed to all `apiClient` functions.
---
## Environment Variables
```env
VITE_API_URL=http://localhost:5001/api # Internal backend base URL
VITE_CHICKEN_COUNTING_API_KEY=<key> # API key for chicken counting submission
```
---
## Notes
1. **External API (CPSP)** is hardcoded to `https://dashboard.cpsp.id` in `apiService.ts`
2. **Internal Backend** uses `VITE_API_URL` environment variable with fallback to `localhost:5001`
3. **Caching** is implemented for high-frequency endpoints (Weight Distribution, FCR)
4. **Manual overrides** are stored in Internal Backend and merged with External API data (Feed Sack)
5. **Mock data** is used for features under development (Behavior, Vet, Scheduling, etc.)
6. **Real-time updates** use periodic polling, not WebSockets (yet)
---
**Last Updated**: 2026-05-14
+602
View File
@@ -0,0 +1,602 @@
# Docker Deployment Guide
Complete guide for deploying Dashboard Solusi AI Peternakan Ayam using Docker containers.
## 📋 Table of Contents
- [Architecture](#architecture)
- [Prerequisites](#prerequisites)
- [Quick Start](#quick-start)
- [Configuration](#configuration)
- [Deployment](#deployment)
- [Management](#management)
- [Troubleshooting](#troubleshooting)
## 🏗️ Architecture
The application runs in 3 Docker containers:
```
┌─────────────────────────────────────────────────┐
│ Host Server │
│ │
│ ┌────────────────┐ ┌──────────────────┐ │
│ │ Frontend │ │ Backend │ │
│ │ (Nginx) │◄────►│ (Node.js) │ │
│ │ Port 80 │ │ Port 5001 │ │
│ └────────────────┘ └──────────────────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┌──────────────────┐ │
│ │ │ Database │ │
│ └──────────────►│ (PostgreSQL) │ │
│ │ Port 5432 │ │
│ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────┘
```
### Container Details
| Container | Image | Port | Description |
| ------------ | ------------------ | ---------- | -------------------------- |
| **frontend** | nginx:alpine | 80 | React app served by Nginx |
| **backend** | node:18-alpine | 5001 | Express API server |
| **database** | postgres:16-alpine | 15432→5432 | PostgreSQL database server |
### Network Communication
- Frontend → Backend: Internal Docker network (`dashboard-network`)
- Backend → Database: Internal Docker network (`dashboard-network`)
- Client → Frontend: HTTP port 80 (or 5002 in dev)
- Host → Database: TCP port 15432 (for DBeaver, psql, backups)
## 📦 Prerequisites
### Required Software
- **Docker**: v20.10+ ([Install](https://docs.docker.com/get-docker/))
- **Docker Compose**: v2.0+ ([Install](https://docs.docker.com/compose/install/))
Check versions:
```bash
docker --version
docker-compose --version
```
### Server Requirements
- **OS**: Linux (Ubuntu 20.04+, Debian 11+, CentOS 8+)
- **RAM**: Minimum 2GB, Recommended 4GB+
- **Disk**: Minimum 10GB free space
- **CPU**: 2+ cores recommended
### Ports
Ensure these ports are available:
- **80** or **5002**: Frontend (HTTP)
- **5001**: Backend API
- **15432**: PostgreSQL (for local access, DBeaver, backups)
Check port availability:
```bash
sudo lsof -i :80
sudo lsof -i :5001
sudo lsof -i :15432
```
## 🚀 Quick Start
### 1. Clone Repository
```bash
git clone <repository-url>
cd dashboard-solusi-ai-peternakan-ayam
```
### 2. Configure Environment (Optional)
```bash
# Copy and edit if you need custom settings
cp .env.example .env
```
### 3. Build and Start Containers
```bash
docker-compose up -d --build
```
This will:
- ✅ Build frontend and backend images
- ✅ Create containers
- ✅ Initialize database
- ✅ Start all services
### 4. Verify Deployment
```bash
# Check container status
docker-compose ps
# Check logs
docker-compose logs -f
# Test frontend
curl http://localhost
# Test backend
curl http://localhost/api/health
```
### 5. Access Application
Open browser: **http://your-server-ip**
## ⚙️ Configuration
### Environment Variables
#### Frontend (.env.example)
```env
# API URL - Uses nginx proxy in Docker
VITE_API_URL=/api
```
#### Backend Environment Variables
These are set in docker-compose.yml:
```yaml
environment:
- NODE_ENV=production
- PORT=5001
- DB_HOST=database
- DB_PORT=5432
- DB_USER=${DB_USER:-dashboard_user}
- DB_PASSWORD=${DB_PASSWORD:-change_this_password}
- DB_NAME=${DB_NAME:-dashboard_db}
- DB_SSL=false
```
### Custom Configuration
**1. Create environment file:**
```bash
cp .env.example .env
cp backend/.env.example backend/.env
```
**2. Edit docker-compose.yml:**
Change ports:
```yaml
services:
frontend:
ports:
- '8080:80' # Change from 80 to 8080
```
Change database credentials:
```yaml
# Create .env file in project root:
DB_USER=my_user
DB_PASSWORD=my_secure_password
DB_NAME=my_database
```
## 🚢 Deployment
### Development Deployment
```bash
# Build and start with logs
docker-compose up --build
# Press Ctrl+C to stop
```
### Production Deployment
```bash
# Build and start in detached mode
docker-compose up -d --build
# View logs
docker-compose logs -f
# Stop viewing logs (Ctrl+C, containers keep running)
```
### Update Deployment
When code changes:
```bash
# Pull latest code
git pull origin main
# Rebuild and restart
docker-compose down
docker-compose up -d --build
```
### Zero-Downtime Update
```bash
# Build new images
docker-compose build
# Restart services one by one
docker-compose up -d --no-deps --build backend
docker-compose up -d --no-deps --build frontend
```
## 🔧 Management
### Container Management
```bash
# Start all containers
docker-compose start
# Stop all containers
docker-compose stop
# Restart all containers
docker-compose restart
# Stop and remove containers
docker-compose down
# Stop and remove containers + volumes
docker-compose down -v
```
### View Logs
```bash
# All containers
docker-compose logs -f
# Specific container
docker-compose logs -f backend
docker-compose logs -f frontend
# Last 100 lines
docker-compose logs --tail=100
# Since timestamp
docker-compose logs --since 2025-12-22T10:00:00
```
### Execute Commands in Container
```bash
# Backend shell
docker-compose exec backend sh
# Frontend shell
docker-compose exec frontend sh
# Database shell (PostgreSQL)
docker-compose exec database psql -U dashboard_user -d dashboard_db
# Run seed in backend
docker-compose exec backend node database/seed-postgres.js
```
### Database Management
```bash
# Backup database (recommended)
./scripts/backup-postgres.sh
# Manual backup
docker-compose exec database sh -c \
'PGPASSWORD="$POSTGRES_PASSWORD" pg_dump -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
| gzip > backup-$(date +%Y%m%d).sql.gz
# Restore database
./scripts/restore-postgres-local.sh --reset backups/dashboard_db-YYYYMMDD.sql.gz
# Query database
docker-compose exec database psql -U dashboard_user -d dashboard_db -c "SELECT * FROM cycles;"
# Connect with psql from host
psql -h localhost -p 15432 -U dashboard_user -d dashboard_db
```
### Health Checks
```bash
# Check container health
docker-compose ps
# Expected output:
# NAME STATUS
# dashboard-database Up (healthy)
# dashboard-backend Up (healthy)
# dashboard-frontend Up (healthy)
# Manual health checks
curl http://localhost:5002/health # Backend
curl http://localhost:5002/api/cycles # API test
# Database health
docker-compose exec database pg_isready -U dashboard_user
```
## 🐛 Troubleshooting
### Container Won't Start
**Problem:** Container exits immediately
```bash
# Check logs
docker-compose logs backend
docker-compose logs frontend
# Common causes:
# - Port already in use
# - Missing dependencies
# - Configuration error
```
**Solution:**
```bash
# Remove and rebuild
docker-compose down
docker-compose up --build
```
### Port Conflicts
**Problem:** Port 80 or 5001 already in use
```bash
# Find process using port
sudo lsof -i :80
sudo lsof -i :5001
# Kill process
sudo kill -9 <PID>
# Or change port in docker-compose.yml
```
### Database Not Persisting
**Problem:** Data lost after restart
```bash
# Check volume
docker volume ls | grep db-data
docker volume inspect dashboard-solusi-ai-peternakan-ayam_db-data
# Check database is accessible
docker-compose exec database psql -U dashboard_user -d dashboard_db -c "SELECT COUNT(*) FROM cycles;"
```
**Solution:**
```bash
# Make sure you're not using -v flag when stopping
docker-compose down # Preserves volumes
# NOT: docker-compose down -v # This deletes volumes!
# Restart containers
docker-compose up -d
```
### Frontend Can't Connect to Backend
**Problem:** API requests fail with CORS or connection errors
**Check:**
```bash
# Test backend directly
curl http://localhost:5001/health
# Check nginx proxy config
docker-compose exec frontend cat /etc/nginx/conf.d/default.conf
# Check network
docker network ls
docker network inspect peternakan-network
```
**Solution:**
```bash
# Verify both containers are on same network
docker-compose down
docker-compose up -d
```
### Out of Disk Space
**Problem:** Build fails with "no space left on device"
```bash
# Check disk usage
df -h
# Clean Docker
docker system prune -a
docker volume prune
# Remove unused images
docker image prune -a
```
### Database Connection Errors
**Problem:** Backend can't connect to database
```bash
# Check database container is running and healthy
docker-compose ps database
# Check logs
docker-compose logs database
docker-compose logs backend
# Verify connection from backend
docker-compose exec backend sh -c 'psql -h database -U $DB_USER -d $DB_NAME -c "SELECT 1"'
# Restart services
docker-compose restart database backend
```
### Container Memory Issues
**Problem:** Container crashes with OOM
```bash
# Check memory usage
docker stats
# Add memory limits in docker-compose.yml:
services:
backend:
mem_limit: 512m
mem_reservation: 256m
```
## 📊 Monitoring
### Resource Usage
```bash
# Real-time stats
docker stats
# Container processes
docker-compose top
```
### Logs Analysis
```bash
# Search logs
docker-compose logs | grep ERROR
docker-compose logs | grep "Failed to"
# Export logs
docker-compose logs > application.log
```
## 🔒 Security
### Production Recommendations
1. **Use HTTPS:**
- Add reverse proxy (Nginx/Apache) with SSL
- Get certificate from Let's Encrypt
2. **Firewall:**
```bash
# Allow only necessary ports
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
```
3. **Update regularly:**
```bash
# Update base images
docker-compose pull
docker-compose up -d
```
4. **Secure database:**
```bash
# Use strong passwords in production .env
DB_PASSWORD=your_very_strong_password_here
# Enable SSL for production
DB_SSL=true
# Block external database access (only allow from Docker network)
# Edit docker-compose.yml: remove or restrict ports section for database
```
## 🔄 Backup & Restore
### Backup
Use the provided script:
```bash
./scripts/backup-postgres.sh
```
This creates a compressed SQL dump in `./backups/dashboard_db-YYYYMMDD-HHMMSS.sql.gz`
**Manual backup:**
```bash
#!/bin/bash
# Manual backup example
BACKUP_DIR="./backups"
DATE=$(date +%Y%m%d_%H%M%S)
mkdir -p $BACKUP_DIR
docker-compose exec -T database sh -c \
'PGPASSWORD="$POSTGRES_PASSWORD" pg_dump -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" --no-owner --no-acl' \
| gzip -c > "$BACKUP_DIR/dashboard_db-$DATE.sql.gz"
echo "Backup completed: $BACKUP_DIR/dashboard_db-$DATE.sql.gz"
```
### Restore
Use the provided script:
```bash
./scripts/restore-postgres-local.sh --reset backups/dashboard_db-YYYYMMDD-HHMMSS.sql.gz
```
This will:
1. Stop and remove local Docker volumes
2. Start fresh database
3. Restore from backup file
**Note:** The `--reset` flag is required to prevent mixing old and new data.
## 📚 Additional Resources
- [Docker Documentation](https://docs.docker.com/)
- [Docker Compose Documentation](https://docs.docker.com/compose/)
- [Nginx Documentation](https://nginx.org/en/docs/)
## 🆘 Support
For issues specific to Docker deployment:
1. Check logs: `docker-compose logs -f`
2. Verify configuration: `docker-compose config`
3. Check GitHub Issues
4. Contact support@cpsp.id
---
**Docker deployment guide by PT Cipta Pola Solusi Prima - 2025**
+43
View File
@@ -0,0 +1,43 @@
# Frontend Dockerfile - Multi-stage build
FROM node:18-alpine AS builder
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy application files
COPY . .
# Set API URL for Docker deployment (nginx will proxy to backend)
ENV VITE_API_URL=/api
# Set Chicken Counting API key
ARG CHICKEN_COUNTING_API_KEY
ENV VITE_CHICKEN_COUNTING_API_KEY=${CHICKEN_COUNTING_API_KEY}
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built files from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy custom nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost:80 || exit 1
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
+84
View File
@@ -0,0 +1,84 @@
# Product Requirements Document: Solusi AI Peternakan Ayam
**Versi:** 1.0
**Tanggal:** 4 Agustus 2025
**Pemilik Produk:** Divisi Digitalisasi Pertanian, Charoen Pokphand
---
## 1. Pendahuluan
### 1.1 Visi Produk
Menjadi platform analitik prediktif terdepan di Asia Tenggara untuk industri peternakan ayam, memberdayakan peternak dengan data untuk meningkatkan efisiensi, kesejahteraan hewan, dan profitabilitas secara berkelanjutan.
### 1.2 Misi Produk
Membangun solusi AI terintegrasi yang menyediakan wawasan real-time, dapat ditindaklanjuti, dan mudah diakses dari data operasional kandang, mulai dari pemantauan visual hingga manajemen sumber daya.
### 1.3 Audiens Target
- **Manajer Peternakan:** Membutuhkan ringkasan performa, analitik tren, dan notifikasi kritis untuk pengambilan keputusan strategis.
- **Operator Kandang:** Membutuhkan panduan tugas harian yang jelas, notifikasi real-time, dan alat bantu untuk penanganan masalah di lapangan.
- **Dokter Hewan:** Membutuhkan data kesehatan agregat, deteksi dini penyakit melalui perilaku, dan riwayat kesehatan kandang.
- **Manajemen Eksekutif:** Membutuhkan dashboard tingkat tinggi mengenai KPI utama (FCR, profitabilitas, populasi) di seluruh aset peternakan.
---
## 2. Sasaran & Tujuan
### 2.1 Sasaran Bisnis
- Mengurangi FCR (Feed Conversion Ratio) sebesar 5% dalam 1 tahun pertama.
- Menurunkan tingkat mortalitas sebesar 10% melalui deteksi dini.
- Meningkatkan efisiensi tenaga kerja operasional sebesar 15%.
- Mengurangi pemborosan pakan sebesar 20%.
### 2.2 Sasaran Pengguna
- **Manajer:** Mengurangi waktu pembuatan laporan manual dari 4 jam/minggu menjadi <15 menit/minggu.
- **Operator:** Menyelesaikan 95% tugas kritis yang dihasilkan AI dalam waktu 1 jam setelah notifikasi.
- **Dokter Hewan:** Mendapatkan akses terpusat ke data kesehatan dan perilaku untuk 100% kandang yang terhubung.
---
## 3. Fitur Utama
### 3.1 Dashboard & Analitik Terpusat
- Tampilan ringkas KPI utama dengan tren historis.
- Peta panas (heatmap) untuk kepadatan populasi dan hotspot masalah.
- Laporan AI generatif untuk ringkasan harian.
### 3.2 Analisis Visual Berbasis AI
- **Penghitungan Ayam:** Penghitungan populasi secara otomatis dan akurat.
- **Deteksi Mortalitas:** Notifikasi instan saat ada ayam mati.
- **Analisis Perilaku:** Deteksi stres, agresi, dan penyakit melalui pola perilaku.
- **Pemantauan Pakan:** Estimasi konsumsi, deteksi pemborosan, dan penghitungan karung.
### 3.3 Pemantauan Lingkungan & IoT
- Integrasi dengan sensor suhu, kelembapan, amonia, dll.
- Visualisasi data sensor historis.
- Notifikasi jika parameter lingkungan keluar dari ambang batas aman.
### 3.4 Aplikasi Mobile Pendamping
- Tampilan yang disesuaikan untuk setiap peran (Manajer, Operator, Dokter Hewan).
- Notifikasi push untuk peringatan kritis.
- Alur kerja untuk menangani tugas yang dihasilkan oleh AI (misalnya, menangani mortalitas).
---
## 4. User Stories (Contoh)
- **Sebagai Manajer Peternakan,** saya ingin melihat perbandingan FCR dan ADG (Average Daily Gain) antar kandang agar saya bisa mengidentifikasi kandang yang berkinerja buruk dan baik.
- **Sebagai Operator Kandang,** saya ingin menerima notifikasi di ponsel saya ketika ada ayam yang terdeteksi mati, lengkap dengan lokasinya, agar saya dapat segera menanganinya.
- **Sebagai Dokter Hewan,** saya ingin melihat tren tingkat stres dan aktivitas di semua kandang untuk mendeteksi potensi wabah penyakit lebih awal.
---
## 5. Metrik Keberhasilan
- Tingkat adopsi pengguna (Daily Active Users / Monthly Active Users).
- Jumlah notifikasi kritis yang ditindaklanjuti dalam SLA yang ditentukan.
- Korelasi antara penggunaan fitur AI dengan peningkatan KPI bisnis (FCR, mortalitas).
- Skor kepuasan pengguna (diukur melalui survei internal).
---
## 6. Di Luar Cakupan (V1)
- Kontrol aktuator secara otomatis (misalnya, menyalakan kipas).
- Integrasi dengan sistem keuangan untuk perhitungan profitabilitas real-time.
- Analisis kualitas kotoran (litter quality).
+431
View File
@@ -0,0 +1,431 @@
# 🚀 Quick Start Guide
Panduan cepat untuk menjalankan Dashboard Solusi AI Peternakan Ayam.
## ⚡ TL;DR - Super Quick Start
### Opsi 1: Docker (Recommended for Production) 🐳
```bash
# 1. Install Docker & Docker Compose
# 2. Clone repository
git clone <repo-url>
cd dashboard-solusi-ai-peternakan-ayam
# 3. Deploy
docker-compose up -d --build
# 4. Access
# http://localhost
```
### Opsi 2: Manual (For Development) 💻
```bash
# 1. Install dependencies
npm install
cd backend && npm install && cd ..
# 2. Terminal 1 - Start Backend
cd backend
npm run dev
# 3. Terminal 2 - Start Frontend
npm run dev
# 4. Open browser
# Frontend: http://localhost:3001
# Backend API: http://localhost:5001
```
---
## 🐳 Docker Deployment (Recommended)
### Prerequisites
- ✅ Docker v20.10+ ([Download](https://docs.docker.com/get-docker/))
- ✅ Docker Compose v2.0+ ([Download](https://docs.docker.com/compose/install/))
### Quick Deploy
```bash
# Clone repository
git clone <repo-url>
cd dashboard-solusi-ai-peternakan-ayam
# Create data directory
mkdir -p data
# Deploy
docker-compose up -d --build
# Check status
docker-compose ps
# View logs
docker-compose logs -f
```
**Access:** http://localhost or http://your-server-ip
**📚 Complete Docker Guide:** [DOCKER.md](DOCKER.md)
---
## 💻 Manual Installation
## 📋 Prerequisites
Pastikan sudah terinstall:
- ✅ Node.js v18+ ([Download](https://nodejs.org/))
- ✅ npm v9+
- ✅ Port 3001 dan 5001 tersedia
Cek versi:
```bash
node --version # Should be v18.0.0 or higher
npm --version # Should be v9.0.0 or higher
```
---
## 📦 Step 1: Install Dependencies
### Frontend Dependencies
```bash
npm install
```
### Backend Dependencies
```bash
cd backend
npm install
cd ..
```
**Troubleshooting:**
- Jika error `EACCES`: Gunakan `sudo npm install` atau fix npm permissions
- Jika error `node-gyp`: Install build tools untuk OS Anda
---
## ▶️ Step 2: Start Backend Server
**Buka Terminal 1:**
```bash
cd backend
npm run dev
```
**✅ Output yang benar:**
```
========================================
🚀 Server running on http://localhost:5001
========================================
API Endpoints:
GET /health
GET /api/cycles
GET /api/cycles/active
...
✓ Database initialized successfully
```
**❌ Jika gagal:**
**Problem:** Port 5001 sudah terpakai
```bash
# Check what's using port 5001
lsof -i :5001
# Solution: Change port in backend/.env
PORT=5002
```
**Problem:** Database error
```bash
# Make sure PostgreSQL is running
# For local development, check backend/.env for DB credentials
cd backend
node database/seed-postgres.js
```
---
## ▶️ Step 3: Start Frontend Server
**Buka Terminal 2 (baru):**
```bash
npm run dev
```
**✅ Output yang benar:**
```
VITE v6.4.1 ready in 155 ms
➜ Local: http://localhost:3001/
➜ Network: http://127.0.2.2:3001/
```
**Note:** Jika port 3001 terpakai, Vite akan otomatis menggunakan port lain (misalnya 3002).
---
## 🌐 Step 4: Open in Browser
Buka browser dan akses:
**Frontend Dashboard:**
```
http://localhost:3001
```
**Backend API (optional):**
```
http://localhost:5001/health
```
---
## ✅ Verify Installation
### Test 1: Backend Health Check
```bash
curl http://localhost:5001/health
```
**Expected response:**
```json
{ "status": "ok", "timestamp": "2025-12-22T03:02:20.793Z" }
```
### Test 2: Get Cycles Data
```bash
curl http://localhost:5001/api/cycles
```
**Expected response:**
```json
{
"success": true,
"data": [
{
"id": "CYCLE-JBW-2025-12-10",
"totalDays": 42,
"currentDay": 7,
"startDate": "2025-12-10",
"endDate": "2026-01-20",
"docInCount": 20000,
"status": "Active",
...
}
]
}
```
### Test 3: Frontend Loads
1. Open http://localhost:3001
2. You should see the dashboard with:
- ✅ KPI cards (Population, Mortality, etc.)
- ✅ Linimasa Siklus sidebar menu
- ✅ No console errors (press F12)
### Test 4: Database Integration
1. Navigate to "Linimasa Siklus Produksi"
2. Click "Edit" on active cycle
3. Change DOC In count: 20000 → 21000
4. Click "Simpan"
5. Refresh page (F5)
6. **✅ Data should persist** - DOC count still shows 21000
---
## 📚 Next Steps
### Explore Features
1. **Dashboard Utama** - View KPI and real-time monitoring
2. **Linimasa Siklus** - Manage production cycles
3. **Hitung Ayam > Manajemen Data** - Edit mortality data
4. **Monitoring Berat** - View weight distribution
5. **Standar Performa** - Compare with Cobb standards
### Test Database Persistence
**Test Mortality Data:**
1. Go to "Hitung Ayam" > "Manajemen Data"
2. Click "Edit" on any day
3. Change mortality value
4. Click save (✓)
5. Refresh page - data should persist
---
## 🔧 Common Issues
### Issue: "Cannot connect to backend"
**Symptoms:**
- Frontend shows no data
- Console shows: `Failed to load cycles from database`
**Solution:**
1. Check backend is running: `curl http://localhost:5001/health`
2. Check CORS settings in `backend/server.js`
3. Check frontend API URL in `services/apiService.ts`:
```typescript
const DB_API_BASE_URL = 'http://localhost:5001/api';
```
### Issue: "Port already in use"
**Backend (5001):**
```bash
# Find process using port 5001
lsof -i :5001
# Kill the process (replace PID)
kill -9 <PID>
# Or change port in backend/.env
```
**Frontend (3001):**
- Vite will auto-select another port
- Check terminal output for actual port
### Issue: "Database not found" or "Connection refused"
```bash
# Make sure PostgreSQL is running locally
# Check connection settings in backend/.env:
# DB_HOST=localhost
# DB_PORT=5432
# DB_USER=dashboard_user
# DB_PASSWORD=your_password
# DB_NAME=dashboard_db
cd backend
node database/seed-postgres.js
npm run dev
```
### Issue: "Module not found"
```bash
# Reinstall dependencies
rm -rf node_modules package-lock.json
npm install
# Backend
cd backend
rm -rf node_modules package-lock.json
npm install
```
---
## 🛑 Stopping Servers
**Stop Backend:**
```bash
# In backend terminal, press:
Ctrl + C
```
**Stop Frontend:**
```bash
# In frontend terminal, press:
Ctrl + C
```
**Kill All Node Processes (if stuck):**
```bash
# macOS/Linux
killall node
# Windows
taskkill /F /IM node.exe
```
---
## 📖 Full Documentation
For detailed documentation, see:
- **[README.md](README.md)** - Complete project documentation
- **[backend/README.md](backend/README.md)** - Backend API documentation
- **API Endpoints** - http://localhost:5001 (when server is running)
---
## 💡 Tips
1. **Keep both terminals open** - Don't close backend and frontend terminals
2. **Check logs** - Backend logs show all API requests and SQL queries
3. **Use browser DevTools** - F12 to see network requests and errors
4. **Database is PostgreSQL** - Connection details in `backend/.env`
5. **Auto-reload** - Both frontend and backend support hot reload
6. **Local database guide** - See [docs/LOCAL_DATABASE_GUIDE.md](docs/LOCAL_DATABASE_GUIDE.md) for syncing with production
---
## ✨ Features Quick Reference
| Feature | Location | Description |
| -------------- | ---------------------------- | ------------------------- |
| View Cycles | Linimasa Siklus | See all production cycles |
| Edit Cycle | Linimasa > Edit | Modify dates, DOC count |
| Mortality Data | Hitung Ayam > Manajemen Data | CRUD mortality records |
| Weight Stats | Monitoring Berat | View weight distribution |
| Cobb Standards | Standar Performa | Compare with standards |
---
## 🎯 Success Checklist
- [ ] Backend running on port 5001
- [ ] Frontend running on port 3001
- [ ] `/health` endpoint returns `{"status":"ok"}`
- [ ] `/api/cycles` returns 4 cycles
- [ ] Dashboard loads in browser
- [ ] Can edit cycle data and it persists
- [ ] Can edit mortality data and it persists
- [ ] No errors in browser console
- [ ] No errors in backend logs
---
**Ready to go! 🚀**
If you encounter any issues not covered here, check the full [README.md](README.md) or [backend/README.md](backend/README.md).
+319
View File
@@ -0,0 +1,319 @@
# Quick Reference Guide
**For:** Daily development with quality improvements
---
## 🚀 Common Commands
### Development
```bash
npm run dev # Start dev server
```
### Code Quality
```bash
npm run lint # Check code quality
npm run lint:fix # Auto-fix linting issues
npm run format # Format all files
npm run type-check # Check TypeScript errors
```
### Testing
```bash
npm test # Run tests (watch mode)
npm run test:run # Run tests once
npm run test:ui # Visual test UI
npm run test:coverage # Coverage report
# Backend tests
cd backend
npm test
```
### Building
```bash
npm run build # Build for production
npm run preview # Preview production build
```
### Git Hooks
```bash
git commit # Automatically lints changed files
git commit --no-verify # Skip hooks (emergency only)
```
---
## 🔧 Quick Fixes
### "Pre-commit hook failed"
```bash
npm run lint:fix # Auto-fix issues
git add .
git commit -m "your message"
```
### "TypeScript errors"
```bash
npm run type-check # See all errors
# Fix the errors in your editor
```
### "Tests failing"
```bash
npm test # See which tests fail
# Fix the code or update tests
```
### "ESLint errors"
```bash
npm run lint # See errors
npm run lint:fix # Auto-fix what's possible
# Manually fix remaining issues
```
---
## 📋 Pre-Push Checklist
Before pushing to GitHub:
- [ ] `npm run lint` ✅
- [ ] `npm run type-check` ✅
- [ ] `npm run build` ✅
- [ ] `npm test` ✅
**One-liner:**
```bash
npm run lint && npm run type-check && npm run build && npm test -- --run
```
---
## 🎯 When Things Go Wrong
### CI Failed on GitHub
1. Go to repository → Actions tab
2. Click the failed workflow
3. Read the error logs
4. Fix locally, test, then push again
### Can't Commit
- Pre-commit hook is blocking
- Run `npm run lint:fix`
- Or bypass (emergency): `git commit --no-verify`
### TypeScript Strict Mode Complaints
- Fix the type errors (recommended)
- Or add type assertions (not recommended)
```typescript
const data = apiResponse as MyType; // Last resort
```
---
## 📁 Important Files
| File | Purpose |
| ------------------------------------- | --------------------------------------- |
| `.eslintrc.json` | ESLint config (frontend) |
| `backend/.eslintrc.json` | ESLint config (backend) |
| `.prettierrc` | Code formatting rules |
| `tsconfig.json` | TypeScript config (strict mode enabled) |
| `.husky/pre-commit` | Git pre-commit hook |
| `.github/workflows/ci.yml` | CI/CD pipeline |
| `components/common/ErrorBoundary.tsx` | Error boundary component |
| `backend/utils/errors.js` | Custom error classes |
---
## 🧪 Writing Tests
### Backend Model Test
```javascript
// backend/models/__tests__/MyModel.test.js
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import MyModel from '../MyModel.js';
import { query } from '../../database/db.js';
describe('MyModel', () => {
beforeEach(async () => {
await query('DELETE FROM my_table WHERE id LIKE $1', ['TEST-%']);
});
it('should do something', async () => {
const result = await MyModel.doSomething();
expect(result).toBeDefined();
});
afterEach(async () => {
await query('DELETE FROM my_table WHERE id LIKE $1', ['TEST-%']);
});
});
```
### Frontend Component Test
```typescript
// components/__tests__/MyComponent.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import MyComponent from '../MyComponent';
describe('MyComponent', () => {
it('renders correctly', () => {
render(<MyComponent title="Hello" />);
expect(screen.getByText('Hello')).toBeInTheDocument();
});
});
```
---
## 🔐 Using Error Classes
### Backend Route with Error Handling
```javascript
const { ValidationError, NotFoundError } = require('./utils/errors');
router.get('/api/cycles/:id', async (req, res, next) => {
try {
const { id } = req.params;
if (!id) {
throw new ValidationError('Cycle ID is required');
}
const cycle = await Cycle.getById(id);
if (!cycle) {
throw new NotFoundError('Cycle');
}
res.json({ success: true, data: cycle });
} catch (error) {
next(error); // Error handler sanitizes for client
}
});
```
---
## 🛡️ Error Boundary Usage
### Wrap Risky Components
```tsx
import ErrorBoundary from '@/components/common/ErrorBoundary';
function MyPage() {
return (
<ErrorBoundary>
<RiskyComponent />
</ErrorBoundary>
);
}
// Custom fallback
<ErrorBoundary fallback={<div>Oops, data loading failed</div>}>
<DataTable />
</ErrorBoundary>;
```
---
## 📊 Viewing Test Coverage
```bash
npm run test:coverage
# Opens HTML report in coverage/index.html
```
**Coverage Goals:**
- Critical models: >80%
- Utility functions: >80%
- Components: >50%
---
## 🎨 Code Style Tips
### Auto-format on save (VS Code)
Add to `.vscode/settings.json`:
```json
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
}
}
```
### Disable ESLint for one line
```typescript
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data: any = legacyApi();
```
### Disable TypeScript for one line
```typescript
// @ts-ignore
const result = oldCode();
```
---
## 🚨 Emergency Procedures
### Disable Pre-commit Hooks Temporarily
```bash
git commit --no-verify -m "emergency fix"
```
### Skip TypeScript Errors Temporarily
Comment out in `tsconfig.json`:
```json
{
"compilerOptions": {
// "strict": true, // Commented temporarily
}
}
```
**Remember to re-enable!**
---
## 📞 Getting Help
**ESLint issues:** Check `.eslintrc.json` rules
**TypeScript errors:** Check `tsconfig.json` compiler options
**Test failures:** Check test files in `__tests__/` directories
**CI failures:** Check GitHub Actions logs
**Full documentation:** See `QUALITY_IMPROVEMENTS.md`
+665
View File
@@ -0,0 +1,665 @@
# Dashboard Solusi AI Peternakan Ayam
Dashboard monitoring dan manajemen peternakan ayam berbasis web dengan integrasi IoT, AI, dan database PostgreSQL untuk penyimpanan data siklus produksi.
## 📋 Daftar Isi
- [Fitur Utama](#fitur-utama)
- [Teknologi](#teknologi)
- [Deployment](#deployment)
- [Persyaratan Sistem](#persyaratan-sistem)
- [Instalasi](#instalasi)
- [Cara Menjalankan](#cara-menjalankan)
- [Struktur Proyek](#struktur-proyek)
- [API Documentation](#api-documentation)
- [Database Schema](#database-schema)
- [Troubleshooting](#troubleshooting)
## 🚀 Fitur Utama
### Frontend
- **Dashboard Utama**: Monitoring real-time KPI, alert, dan kondisi kandang
- **Linimasa Siklus**: Manajemen siklus produksi dengan timeline visual
- **Hitung Ayam**:
- Dashboard perhitungan populasi
- **Manajemen Data**: CRUD data mortalitas dengan penyimpanan database
- **Monitoring Berat**: Tracking berat ayam dengan distribusi dan statistik
- **Standar Performa**: Perbandingan dengan standar Cobb
- **Penjadwalan Pakan**: Rekomendasi jadwal pemberian pakan
- **Feed Wastage**: Monitoring pemborosan pakan
- **Behavior Analysis**: Analisis perilaku ayam dengan AI
- **Network Monitoring**: Status koneksi IoT devices
- **ERP Integration**: Sinkronisasi dengan sistem ERP
### Backend (PostgreSQL Database)
- **Database**: PostgreSQL untuk penyimpanan data siklus dan mortalitas
- **RESTful API**: Endpoint untuk CRUD operations
- **Auto-seeding**: Database otomatis terisi data awal saat pertama kali dijalankan
- **Persistent Storage**: Semua perubahan data tersimpan permanen
## 🛠 Teknologi
### Frontend
- **React 18** dengan TypeScript
- **Vite** - Build tool & dev server
- **Recharts** - Data visualization
- **Tailwind CSS** - Styling
- **Context API** - State management
### Backend
- **Node.js** dengan Express
- **PostgreSQL** (pg) - Database
- **CORS** - Cross-origin support
### Deployment
- **Docker** - Containerization
- **Docker Compose** - Multi-container orchestration (3 containers: frontend, backend, database)
- **Nginx** - Reverse proxy & static file serving
- **PostgreSQL** - Database container
## 🐳 Deployment
### Docker Deployment (Recommended for Production)
Deploy dengan 3 container (frontend + backend + PostgreSQL database) menggunakan Docker Compose:
```bash
# Quick start
docker-compose up -d --build
# Access application
http://your-server-ip
```
**Keuntungan Docker:**
- ✅ Easy deployment dan scaling
- ✅ Isolated environment
- ✅ Consistent across different servers
- ✅ Automatic restarts
- ✅ Built-in health checks
**📚 Complete Docker Guide:** [DOCKER.md](DOCKER.md)
**Local Postgres / sync from server / DBeaver:** [docs/LOCAL_DATABASE_GUIDE.md](docs/LOCAL_DATABASE_GUIDE.md)
### Manual Deployment
Untuk development atau testing lokal, lihat [Instalasi](#instalasi) dan [Cara Menjalankan](#cara-menjalankan) di bawah.
## 📦 Persyaratan Sistem
### Untuk Docker Deployment
- **Docker**: v20.10+
- **Docker Compose**: v2.0+
- **RAM**: Minimum 2GB
- **Disk**: 10GB free space
- **Port**: 80 (frontend)
### Untuk Manual Installation
- **Node.js**: v18.0.0 atau lebih tinggi
- **npm**: v9.0.0 atau lebih tinggi
- **Port yang tersedia**:
- Frontend: 3001 (atau port lain jika 3001 terpakai)
- Backend: 5001
## 💿 Instalasi
### 1. Clone Repository
```bash
git clone <repository-url>
cd dashboard-solusi-ai-peternakan-ayam
```
### 2. Install Dependencies Frontend
```bash
npm install
```
### 3. Install Dependencies Backend
```bash
cd backend
npm install
cd ..
```
## ▶️ Cara Menjalankan
### Opsi 1: Menjalankan Secara Manual (Recommended untuk Development)
#### Terminal 1 - Backend Server
```bash
cd backend
npm run dev
```
Backend akan berjalan di: **http://localhost:5001**
Output yang diharapkan:
```
========================================
🚀 Server running on http://localhost:5001
========================================
API Endpoints:
GET /health
GET /api/cycles
GET /api/cycles/active
...
```
#### Terminal 2 - Frontend Server
```bash
npm run dev
```
Frontend akan berjalan di: **http://localhost:3001** (atau port lain jika terpakai)
Output yang diharapkan:
```
VITE v6.4.1 ready in 155 ms
➜ Local: http://localhost:3001/
```
### Opsi 2: Menjalankan dengan satu perintah
Buka dua terminal secara terpisah:
**Terminal 1:**
```bash
cd backend && npm run dev
```
**Terminal 2:**
```bash
npm run dev
```
Atau gunakan terminal multiplexer seperti `tmux` atau `screen`.
## 📁 Struktur Proyek
```
dashboard-solusi-ai-peternakan-ayam/
├── backend/ # Backend Node.js + PostgreSQL
│ ├── database/
│ │ ├── db.js # PostgreSQL connection & pool setup
│ │ ├── schema.sql # Database schema
│ │ ├── seed-postgres.js # Data seeding script
│ │ └── migrations/ # Database migrations
│ ├── models/
│ │ ├── Cycle.js # Cycle data model
│ │ └── Mortality.js # Mortality data model
│ ├── routes/
│ │ ├── cycles.js # Cycle endpoints
│ │ └── mortality.js # Mortality endpoints
│ ├── server.js # Express server
│ ├── package.json
│ └── .env # Environment variables
├── components/ # React components
│ ├── counting/
│ │ └── DataManagementPage.tsx # Mortality data management
│ ├── CycleTimeline.tsx # Cycle timeline component
│ └── ...
├── context/ # React context & stores
│ ├── AppContext.tsx
│ └── stores/
│ └── useStaticDataStore.ts # Store with DB integration
├── services/
│ └── apiService.ts # API client with DB methods
├── scripts/ # Database & deployment scripts
│ ├── backup-postgres.sh # Backup database
│ ├── restore-postgres-local.sh # Restore from backup
│ └── fetch-prod-db.sh # Fetch production data
├── mockData/ # Mock data for demo mode
├── types/ # TypeScript type definitions
├── public/ # Static assets
├── docker-compose.yml # 3-container orchestration
├── package.json
└── README.md
```
## 📡 API Documentation
Base URL: `http://localhost:5001/api`
### Health Check
```http
GET /health
```
**Response:**
```json
{
"status": "ok",
"timestamp": "2025-12-22T03:02:20.793Z"
}
```
### Cycles Endpoints
#### Get All Cycles
```http
GET /api/cycles
```
**Response:**
```json
{
"success": true,
"data": [
{
"id": "CYCLE-JBW-2025-12-10",
"totalDays": 42,
"currentDay": 7,
"startDate": "2025-12-10",
"endDate": "2026-01-20",
"chickInWeight": null,
"docInCount": 20000,
"status": "Active",
"createdAt": "2025-12-22 02:59:17",
"updatedAt": "2025-12-22 02:59:17"
}
]
}
```
#### Get Active Cycle
```http
GET /api/cycles/active
```
#### Get Cycle by ID
```http
GET /api/cycles/:id
```
#### Update Cycle
```http
PUT /api/cycles/:id
Content-Type: application/json
{
"totalDays": 42,
"currentDay": 7,
"startDate": "2025-12-10",
"endDate": "2026-01-20",
"chickInWeight": 42,
"docInCount": 20000,
"status": "Active"
}
```
#### Delete Cycle
```http
DELETE /api/cycles/:id
```
### Mortality Endpoints
#### Get All Mortality Records for Cycle
```http
GET /api/mortality/:cycleId
```
**Response:**
```json
{
"success": true,
"data": [
{
"id": 1,
"cycleId": "CYCLE-JBW-2025-12-10",
"day": 0,
"mortalityCount": 25,
"isEdited": true,
"createdAt": "2025-12-22 03:00:00",
"updatedAt": "2025-12-22 03:00:00"
}
]
}
```
#### Get Mortality Record for Specific Day
```http
GET /api/mortality/:cycleId/:day
```
#### Update/Create Mortality Record
```http
PUT /api/mortality/:cycleId/:day
Content-Type: application/json
{
"mortalityCount": 30
}
```
#### Delete Mortality Record (Reset to Default)
```http
DELETE /api/mortality/:cycleId/:day
```
## 🗄️ Database Schema
### Table: cycles
Menyimpan informasi siklus produksi.
| Column | Type | Description |
| --------------- | ------- | ----------------------------------------- |
| id | TEXT | Primary key, format: CYCLE-JBW-YYYY-MM-DD |
| total_days | INTEGER | Total hari dalam siklus (biasanya 42) |
| current_day | INTEGER | Hari saat ini dalam siklus |
| start_date | TEXT | Tanggal mulai (ISO format: YYYY-MM-DD) |
| end_date | TEXT | Tanggal akhir (ISO format: YYYY-MM-DD) |
| chick_in_weight | INTEGER | Berat DOC in (gram) |
| doc_in_count | INTEGER | Jumlah DOC in (ekor) |
| status | TEXT | Status: 'Active', 'Completed', 'Upcoming' |
| created_at | TEXT | Timestamp pembuatan |
| updated_at | TEXT | Timestamp update terakhir |
### Table: mortality_records
Menyimpan data mortalitas harian.
| Column | Type | Description |
| --------------- | ------- | ------------------------------------ |
| id | INTEGER | Primary key (auto increment) |
| cycle_id | TEXT | Foreign key ke cycles.id |
| day | INTEGER | Hari ke berapa dalam siklus |
| mortality_count | INTEGER | Jumlah mortalitas hari ini |
| is_edited | BOOLEAN | Flag apakah data sudah diedit manual |
| created_at | TEXT | Timestamp pembuatan |
| updated_at | TEXT | Timestamp update terakhir |
**Constraints:**
- UNIQUE(cycle_id, day) - Satu cycle hanya bisa punya satu record per hari
- FOREIGN KEY cascade delete - Jika cycle dihapus, mortality records ikut terhapus
### Indexes
- `idx_mortality_cycle_id` - Index pada cycle_id untuk query cepat
- `idx_mortality_day` - Index pada day
- `idx_cycles_status` - Index pada status untuk filter cycle aktif
- `idx_cycles_start_date` - Index pada start_date untuk sorting
## 🔧 Troubleshooting
### Port 5001 sudah terpakai
Jika port 5001 sudah digunakan oleh aplikasi lain (contoh: AirPlay on macOS):
1. Edit file `backend/.env`:
```env
PORT=5002 # Ganti ke port lain yang tersedia
```
2. Update URL di `services/apiService.ts`:
```typescript
const DB_API_BASE_URL = 'http://localhost:5002/api';
```
3. Restart backend server
### Port 3001 sudah terpakai
Vite akan otomatis mencari port lain yang tersedia. Perhatikan output di terminal untuk melihat port yang digunakan.
### Database tidak terbuat
Jika database tidak terbuat otomatis:
```bash
cd backend
node database/seed-postgres.js
```
### CORS Error
Jika frontend tidak bisa mengakses backend, pastikan:
1. Backend server sudah berjalan
2. Port di `backend/server.js` sesuai dengan konfigurasi CORS:
```javascript
origin: ['http://localhost:3001', 'http://localhost:5173'];
```
### Frontend tidak load data dari database
1. Buka browser console (F12)
2. Cek apakah ada error dari API calls
3. Pastikan backend server berjalan: `curl http://localhost:5001/health`
4. Cek network tab untuk melihat request/response
### Error "Failed to load cycles from database"
1. Pastikan backend server sudah berjalan
2. Test backend endpoint: `curl http://localhost:5001/api/cycles`
3. Cek backend logs untuk melihat error detail
### Reset Database ke Data Awal
**For Local PostgreSQL Development:**
```bash
# Stop backend if running, then:
./scripts/restore-postgres-local.sh --reset
# Or manually run seed:
cd backend
node database/seed-postgres.js
```
**For Docker:**
```bash
# Reset database by removing volume and restarting:
docker-compose down -v
docker-compose up -d
```
## 📝 Fitur Database
### Auto-Seeding
Database akan otomatis terisi dengan 4 siklus produksi saat pertama kali server backend dijalankan:
1. **CYCLE-JBW-2025-05-20** - Completed (20 Mei - 30 Juni 2025)
2. **CYCLE-JBW-2025-07-11** - Completed (11 Juli - 21 Agustus 2025)
3. **CYCLE-JBW-2025-10-22** - Completed (22 Oktober - 2 Desember 2025)
4. **CYCLE-JBW-2025-12-10** - Active (10 Desember 2025 - 20 Januari 2026) ✓
### Data Persistence
Semua perubahan data akan tersimpan permanen di PostgreSQL database:
- ✅ Perubahan tanggal siklus di Linimasa
- ✅ Update DOC In dan Berat DOC di pengaturan siklus
- ✅ Edit data mortalitas di halaman Manajemen Data
- ✅ Reset data mortalitas ke nilai default
### Backup Database
**Production/Docker:**
```bash
./scripts/backup-postgres.sh
```
Backups are saved in `./backups/` as `dashboard_db-YYYYMMDD-HHMMSS.sql.gz`
**Fetch from production server:**
```bash
REMOTE_USER=your-user \
REMOTE_HOST=your-host \
REMOTE_PROJECT_DIR=/path/to/project \
./scripts/fetch-prod-db.sh
```
## 🔐 Environment Variables
### Backend (.env)
File: `backend/.env`
```env
PORT=5001
NODE_ENV=development
# PostgreSQL Configuration
DB_HOST=localhost
DB_PORT=5432
DB_USER=dashboard_user
DB_PASSWORD=your_password
DB_NAME=dashboard_db
DB_SSL=false
```
### Frontend (.env.local) - Optional
File: `.env.local` (create if needed)
```env
VITE_API_URL=http://localhost:5001/api
```
## 🎯 Testing
### Test Backend API
```bash
# Health check
curl http://localhost:5001/health
# Get all cycles
curl http://localhost:5001/api/cycles
# Get active cycle
curl http://localhost:5001/api/cycles/active
# Get mortality records
curl http://localhost:5001/api/mortality/CYCLE-JBW-2025-12-10
```
### Test Frontend
1. Buka http://localhost:3001 di browser
2. Navigasi ke "Linimasa Siklus Produksi"
3. Klik "Edit" pada siklus aktif
4. Ubah tanggal atau DOC count
5. Save dan refresh - data harus tetap tersimpan
6. Navigasi ke "Hitung Ayam" > "Manajemen Data"
7. Edit nilai mortalitas
8. Save dan refresh - data harus tetap tersimpan
## 📚 Cara Menggunakan Aplikasi
### 1. Mengelola Siklus Produksi
**Linimasa Siklus:**
- Lihat semua siklus produksi (completed, active, upcoming)
- Edit tanggal mulai dan akhir
- Set DOC In count dan berat DOC
- Data tersimpan otomatis ke database
### 2. Manajemen Data Mortalitas
**Hitung Ayam > Manajemen Data:**
- Lihat data mortalitas per hari
- Edit nilai mortalitas dengan klik "Edit"
- Reset ke nilai default dengan klik tombol reset
- Lihat statistik: populasi awal, populasi saat ini, total mortalitas, tingkat mortalitas
- Filter data: cari per hari atau tampilkan hanya data yang diedit
- Data tersimpan permanen di database
### 3. Monitoring Standar Performa
**Standar Performa > Cobb Standard:**
- Kapasitas kandang otomatis sync dengan DOC In count dari linimasa
- Lihat grafik perbandingan berat aktual vs standar Cobb
- Analisis FCR (Feed Conversion Ratio)
## 🚦 Status Proyek
- ✅ Frontend Dashboard
- ✅ Backend API dengan PostgreSQL
- ✅ Database Schema & Models
- ✅ CRUD Cycles
- ✅ CRUD Mortality Records
- ✅ Frontend-Backend Integration
- ✅ Auto-seeding Data
- ✅ Data Persistence
- ✅ Docker Deployment (3-container architecture)
- ✅ Database Backup & Restore Scripts
- ⏳ User Authentication (coming soon)
- ⏳ Real-time WebSocket (coming soon)
- ⏳ Data Export (CSV/Excel) (coming soon)
## 🤝 Kontribusi
Untuk berkontribusi pada proyek ini:
1. Fork repository
2. Buat branch feature (`git checkout -b feature/AmazingFeature`)
3. Commit perubahan (`git commit -m 'Add some AmazingFeature'`)
4. Push ke branch (`git push origin feature/AmazingFeature`)
5. Buat Pull Request
## 📄 License
Proyek ini menggunakan lisensi MIT.
## 👥 Tim Pengembang
- Development Team - PT Cipta Pola Solusi Prima
- Dashboard & AI Integration - 2025
## 📞 Kontak
Untuk pertanyaan atau dukungan, silakan hubungi:
- Email: support@cpsp.id
- Website: https://dashboard.cpsp.id
---
**Dibuat dengan ❤️ untuk kemajuan peternakan ayam Indonesia**
+106
View File
@@ -0,0 +1,106 @@
# Daftar Tugas Proyek: Solusi AI Peternakan Ayam
Dokumen ini melacak tugas-tugas pengembangan untuk proyek ini.
## Legenda
- [x] Selesai
- [ ] Belum Selesai
- [-] Sedang Dikerjakan
- [!] Diblokir
---
## Milestone 1: MVP Core Dashboard & Data Display
### Backend
- [x] Setup server & database schema dasar.
- [x] Endpoint untuk data KPI statis.
- [x] Endpoint untuk data lingkungan (mock).
- [x] Endpoint untuk data notifikasi (mock).
- [ ] Endpoint untuk otentikasi pengguna.
- [ ] Integrasi awal dengan API untuk Laporan Harian.
### Frontend
- [x] Struktur proyek dasar dengan React & TypeScript.
- [x] Halaman Login.
- [x] Layout utama (Sidebar & Header).
- [x] Komponen KPI Card.
- [x] Komponen Grafik Lingkungan.
- [x] Komponen Peta Kepadatan Kandang.
- [x] Komponen Notifikasi.
- [x] State management dasar dengan React Context.
- [x] Halaman Dashboard Utama.
- [-] Manajemen Pengguna & Akses (RBAC).
---
## Milestone 2: Fitur Analisis Visual (AI)
### Backend
- [x] Service untuk ingesti stream video dari kamera RTSP.
- [x] Model AI: Penghitungan Ayam (integrasi).
- [ ] Model AI: Deteksi Mortalitas (integrasi).
- [-] Model AI: Penghitungan Karung Pakan (integrasi).
- [ ] Model AI: Analisis Perilaku (pengembangan).
- [-] Endpoint real-time untuk hasil analisis (WebSockets/SSE).
### Frontend
- [x] Halaman Dashboard: Hitung Ayam.
- [x] Halaman Dashboard: Hitung Karung Pakan.
- [x] Halaman Dashboard: Analisis Perilaku.
- [x] Halaman Dashboard: Pemantauan Berat.
- [ ] Visualisasi bounding box pada stream video (mock).
- [ ] Update data analitik secara real-time di UI.
---
## Milestone 3: Manajemen & Aplikasi Mobile
### Backend
- [x] CRUD API untuk Lokasi, Kandang, Lantai.
- [x] CRUD API untuk Kamera.
- [x] CRUD API untuk Perangkat IoT.
- [x] CRUD API untuk Pengguna & Peran.
- [ ] Endpoint yang dioptimalkan untuk aplikasi mobile.
- [ ] Sistem notifikasi push (Firebase/APNS).
### Frontend
- [x] Halaman Manajemen Struktur (Lokasi/Kandang/Lantai).
- [x] Halaman Manajemen Kamera.
- [x] Halaman Manajemen Perangkat IoT.
- [x] Halaman Manajemen Pengguna & Hak Akses.
- [x] Halaman Prototipe Aplikasi Mobile.
---
## Milestone 4: Integrasi Rekomendasi AI Lanjutan
### Backend
- [ ] Kembangkan service untuk menghasilkan rekomendasi dinamis berdasarkan data agregat.
- [ ] Buat endpoint API untuk menyajikan rekomendasi ke frontend.
- [ ] Integrasikan model prediksi (misal: prediksi bobot, prediksi stok pakan).
### Frontend
- [ ] Desain komponen UI untuk menampilkan rekomendasi AI di setiap modul terkait.
- [ ] **Dashboard:** Tampilkan "AI Insight of the Day" secara proaktif.
- [ ] **Hitung Ayam:** Tampilkan rekomendasi terkait kepadatan & distribusi populasi.
- [ ] **Berat Ayam:** Integrasikan rekomendasi prediksi bobot dan keseragaman.
- [ ] **Manajemen Pakan:**
- [ ] **Hitung Karung:** Tampilkan rekomendasi prediksi inventori dan pemesanan.
- [ ] **Pemborosan Pakan:** Tampilkan rekomendasi akar masalah (root cause) dari hotspot pemborosan.
- [ ] **Penjadwalan Pakan:** Tampilkan rekomendasi penyesuaian jadwal dinamis berdasarkan perilaku/lingkungan.
- [ ] **Analisis Perilaku:** Tampilkan rekomendasi deteksi dini penyakit berdasarkan anomali perilaku.
- [ ] **Kunjungan Dokter:** Tampilkan rekomendasi penjadwalan kunjungan preventif berbasis data.
- [ ] **Proses OCR:** Tampilkan rekomendasi jika ada anomali data antar dokumen atau dengan data sistem.
- [ ] **Manajemen Jaringan:** Tampilkan rekomendasi untuk optimasi perangkat jaringan yang bermasalah.
---
## Lain-lain
- [ ] Dokumentasi API (Swagger/OpenAPI).
- [ ] Setup CI/CD pipeline.
- [ ] Penulisan Unit Test & E2E Test.
- [ ] Optimasi performa & Core Web Vitals.
- [x] Dokumen SRS.
- [x] Dokumen PRD.
- [x] Daftar Tugas (TODO).
+10
View File
@@ -0,0 +1,10 @@
node_modules
npm-debug.log
.env.local
.env.*.local
data/*.db
data/*.db-shm
data/*.db-wal
*.md
.git
.gitignore
+20
View File
@@ -0,0 +1,20 @@
# Backend Environment Variables
# Copy this file to .env for local development
# Server port
PORT=5001
# PostgreSQL Database Configuration
DB_HOST=localhost
DB_PORT=5432
DB_USER=dashboard_user
DB_PASSWORD=your_secure_password_here
DB_NAME=dashboard_db
DB_SSL=false
# Connection pool settings (optional)
DB_POOL_MIN=2
DB_POOL_MAX=10
# Node environment
NODE_ENV=development
+22
View File
@@ -0,0 +1,22 @@
{
"extends": ["eslint:recommended"],
"env": {
"node": true,
"es2022": true
},
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module"
},
"rules": {
"no-console": "off",
"no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}
]
},
"ignorePatterns": ["node_modules"]
}
+27
View File
@@ -0,0 +1,27 @@
# Backend Dockerfile
FROM node:18-alpine
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies (using install instead of ci for flexibility)
RUN npm install --omit=dev
# Copy application files
COPY . .
# Make startup script executable
RUN chmod +x startup.sh
# Expose port
EXPOSE 5001
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:5001/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# Start the application using startup script
CMD ["sh", "startup.sh"]
+635
View File
@@ -0,0 +1,635 @@
# Backend API - Dashboard Solusi AI Peternakan Ayam
Backend server dengan Express.js dan PostgreSQL database untuk menyimpan data siklus produksi dan mortalitas ayam.
## 📋 Daftar Isi
- [Teknologi](#teknologi)
- [Instalasi](#instalasi)
- [Cara Menjalankan](#cara-menjalankan)
- [Struktur File](#struktur-file)
- [Database](#database)
- [API Endpoints](#api-endpoints)
- [Environment Variables](#environment-variables)
- [Development](#development)
## 🛠 Teknologi
- **Node.js** v18+
- **Express.js** - Web framework
- **pg** - PostgreSQL database driver
- **cors** - Cross-origin resource sharing
- **dotenv** - Environment variables
## 💿 Instalasi
```bash
cd backend
npm install
```
## ▶️ Cara Menjalankan
### Development Mode
```bash
npm run dev
```
Server akan berjalan di http://localhost:5001
### Production Mode
```bash
npm start
```
### Seeding Database
```bash
npm run seed
```
Akan mengisi database dengan 4 siklus produksi awal.
## 📁 Struktur File
```
backend/
├── database/
│ ├── db.js # PostgreSQL connection pool & initialization
│ ├── schema.sql # PostgreSQL schema definition
│ ├── seed-postgres.js # Data seeding script
│ ├── run-migrations.js # Migration runner
│ └── migrations/ # Database migration files
├── models/
│ ├── Cycle.js # Cycle CRUD operations
│ └── Mortality.js # Mortality CRUD operations
├── routes/
│ ├── cycles.js # Cycle API routes
│ └── mortality.js # Mortality API routes
├── server.js # Express app entry point
├── startup.sh # Startup script for Docker
├── package.json
├── .env # Environment configuration
└── README.md
```
## 🗄️ Database
### Schema
#### Table: cycles
```sql
CREATE TABLE cycles (
id VARCHAR(50) PRIMARY KEY,
total_days INTEGER NOT NULL,
current_day INTEGER NOT NULL,
start_date DATE NOT NULL,
end_date DATE,
chick_in_weight INTEGER,
doc_in_count INTEGER,
status VARCHAR(20) NOT NULL CHECK(status IN ('Completed', 'Active', 'Upcoming')),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
#### Table: mortality_records
```sql
CREATE TABLE mortality_records (
id SERIAL PRIMARY KEY,
cycle_id VARCHAR(50) NOT NULL,
day INTEGER NOT NULL,
mortality_count INTEGER NOT NULL DEFAULT 0,
is_edited BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (cycle_id) REFERENCES cycles(id) ON DELETE CASCADE,
UNIQUE(cycle_id, day)
);
```
### Indexes
```sql
CREATE INDEX idx_mortality_cycle_id ON mortality_records(cycle_id);
CREATE INDEX idx_mortality_day ON mortality_records(day);
CREATE INDEX idx_cycles_status ON cycles(status);
CREATE INDEX idx_cycles_start_date ON cycles(start_date);
```
See complete schema in [database/schema.sql](database/schema.sql)
### Initial Data
Database akan terisi otomatis dengan 4 siklus:
| Cycle ID | Status | Start Date | End Date | DOC Count |
| -------------------- | --------- | ---------- | ---------- | --------- |
| CYCLE-JBW-2025-05-20 | Completed | 2025-05-20 | 2025-06-30 | 20,000 |
| CYCLE-JBW-2025-07-11 | Completed | 2025-07-11 | 2025-08-21 | 20,000 |
| CYCLE-JBW-2025-10-22 | Completed | 2025-10-22 | 2025-12-02 | 20,000 |
| CYCLE-JBW-2025-12-10 | Active | 2025-12-10 | 2026-01-20 | 20,000 |
## 📡 API Endpoints
Base URL: `http://localhost:5001`
### Health Check
```http
GET /health
```
Response:
```json
{
"status": "ok",
"timestamp": "2025-12-22T03:02:20.793Z"
}
```
### Cycles API
#### Get All Cycles
```http
GET /api/cycles
```
Response:
```json
{
"success": true,
"data": [
{
"id": "CYCLE-JBW-2025-12-10",
"totalDays": 42,
"currentDay": 7,
"startDate": "2025-12-10",
"endDate": "2026-01-20",
"chickInWeight": null,
"docInCount": 20000,
"status": "Active",
"createdAt": "2025-12-22 02:59:17",
"updatedAt": "2025-12-22 02:59:17"
}
]
}
```
#### Get Active Cycle
```http
GET /api/cycles/active
```
#### Get Cycle by ID
```http
GET /api/cycles/:id
```
Example: `GET /api/cycles/CYCLE-JBW-2025-12-10`
#### Create Cycle
```http
POST /api/cycles
Content-Type: application/json
{
"id": "CYCLE-JBW-2025-12-10",
"totalDays": 42,
"currentDay": 0,
"startDate": "2025-12-10",
"endDate": "2026-01-20",
"chickInWeight": 42,
"docInCount": 20000,
"status": "Active"
}
```
#### Update Cycle
```http
PUT /api/cycles/:id
Content-Type: application/json
{
"totalDays": 42,
"currentDay": 7,
"startDate": "2025-12-10",
"endDate": "2026-01-20",
"chickInWeight": 42,
"docInCount": 20000,
"status": "Active"
}
```
#### Delete Cycle
```http
DELETE /api/cycles/:id
```
### Mortality API
#### Get All Mortality Records for Cycle
```http
GET /api/mortality/:cycleId
```
Example: `GET /api/mortality/CYCLE-JBW-2025-12-10`
Response:
```json
{
"success": true,
"data": [
{
"id": 1,
"cycleId": "CYCLE-JBW-2025-12-10",
"day": 0,
"mortalityCount": 25,
"isEdited": true,
"createdAt": "2025-12-22 03:00:00",
"updatedAt": "2025-12-22 03:00:00"
}
]
}
```
#### Get Mortality Record for Specific Day
```http
GET /api/mortality/:cycleId/:day
```
Example: `GET /api/mortality/CYCLE-JBW-2025-12-10/5`
#### Update/Create Mortality Record
```http
PUT /api/mortality/:cycleId/:day
Content-Type: application/json
{
"mortalityCount": 30
}
```
- Creates new record if doesn't exist
- Updates existing record if exists
- Sets `is_edited` flag to true
#### Delete Mortality Record
```http
DELETE /api/mortality/:cycleId/:day
```
Removes the mortality record, effectively resetting it to default value.
## 🔐 Environment Variables
File: `.env`
```env
PORT=5001
NODE_ENV=development
# PostgreSQL Database Configuration
DB_HOST=localhost
DB_PORT=5432
DB_USER=dashboard_user
DB_PASSWORD=your_secure_password_here
DB_NAME=dashboard_db
DB_SSL=false
# Connection pool settings (optional)
DB_POOL_MIN=2
DB_POOL_MAX=10
```
### Variables
| Variable | Description | Default |
| ----------- | ------------------------------------ | -------------- |
| PORT | Server port | 5001 |
| NODE_ENV | Environment (development/production) | development |
| DB_HOST | PostgreSQL host | localhost |
| DB_PORT | PostgreSQL port | 5432 |
| DB_USER | PostgreSQL user | dashboard_user |
| DB_PASSWORD | PostgreSQL password | (required) |
| DB_NAME | Database name | dashboard_db |
| DB_SSL | Enable SSL connection | false |
| DB_POOL_MIN | Minimum pool connections | 2 |
| DB_POOL_MAX | Maximum pool connections | 10 |
## 🔧 Development
### Database Helper Functions
File: `database/db.js`
```javascript
// Get connection pool and helpers
const { pool, query, transaction, toISODate, parseISODate } = require('./database/db');
// Execute query
const result = await query('SELECT * FROM cycles WHERE status = $1', ['Active']);
// Run transaction
await transaction(async (client) => {
await client.query('UPDATE cycles SET current_day = $1 WHERE id = $2', [
7,
'CYCLE-JBW-2025-12-10',
]);
await client.query('INSERT INTO mortality_records ...');
});
// Date helpers
toISODate(new Date()); // Converts Date to YYYY-MM-DD
parseISODate('2025-12-10'); // Converts ISO string to Date
```
### Models
#### Cycle Model
File: `models/Cycle.js`
```javascript
const Cycle = require('./models/Cycle');
// Get all cycles
Cycle.getAll();
// Get cycle by ID
Cycle.getById('CYCLE-JBW-2025-12-10');
// Get active cycle
Cycle.getActive();
// Create new cycle
Cycle.create({
id: 'CYCLE-JBW-2025-12-10',
totalDays: 42,
currentDay: 0,
startDate: '2025-12-10',
endDate: '2026-01-20',
docInCount: 20000,
status: 'Active',
});
// Update cycle
Cycle.update('CYCLE-JBW-2025-12-10', {
currentDay: 7,
// ... other fields
});
// Delete cycle
Cycle.delete('CYCLE-JBW-2025-12-10');
```
#### Mortality Model
File: `models/Mortality.js`
```javascript
const Mortality = require('./models/Mortality');
// Get all mortality records for a cycle
Mortality.getByCycle('CYCLE-JBW-2025-12-10');
// Get mortality record for specific day
Mortality.getByDay('CYCLE-JBW-2025-12-10', 5);
// Insert or update mortality record
Mortality.upsert('CYCLE-JBW-2025-12-10', 5, 30, true);
// Delete mortality record
Mortality.delete('CYCLE-JBW-2025-12-10', 5);
```
### Request Logging
All requests are logged with timestamp, method, and path:
```
2025-12-22T03:02:20.793Z - GET /health
2025-12-22T03:02:22.884Z - GET /api/cycles
2025-12-22T03:02:24.985Z - GET /api/cycles/active
```
SQL queries are also logged in development mode.
### CORS Configuration
File: `server.js`
```javascript
const corsOptions = {
origin: ['http://localhost:3001', 'http://localhost:5173'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
credentials: true,
};
```
Add more origins as needed for different environments.
## 🧪 Testing API
### Using curl
```bash
# Health check
curl http://localhost:5001/health
# Get all cycles
curl http://localhost:5001/api/cycles
# Get active cycle
curl http://localhost:5001/api/cycles/active
# Get mortality records
curl http://localhost:5001/api/mortality/CYCLE-JBW-2025-12-10
# Create mortality record
curl -X PUT http://localhost:5001/api/mortality/CYCLE-JBW-2025-12-10/5 \
-H "Content-Type: application/json" \
-d '{"mortalityCount": 30}'
# Delete mortality record
curl -X DELETE http://localhost:5001/api/mortality/CYCLE-JBW-2025-12-10/5
```
### Using Postman or Thunder Client
Import the following collection:
```json
{
"info": {
"name": "Dashboard Peternakan API",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "Health Check",
"request": {
"method": "GET",
"url": "http://localhost:5001/health"
}
},
{
"name": "Get All Cycles",
"request": {
"method": "GET",
"url": "http://localhost:5001/api/cycles"
}
}
]
}
```
## 📝 Database Maintenance
### Backup Database
**From Docker (Production):**
```bash
# Run backup script (saves to ./backups/)
./scripts/backup-postgres.sh
```
**Fetch from Production Server:**
```bash
REMOTE_USER=your-user \
REMOTE_HOST=your-host \
REMOTE_PROJECT_DIR=/path/to/project \
./scripts/fetch-prod-db.sh
```
### Restore Database
**Restore to Local:**
```bash
# This will reset local database and restore from backup
./scripts/restore-postgres-local.sh --reset backups/dashboard_db-YYYYMMDD-HHMMSS.sql.gz
```
### Reset Database
**Local Development:**
```bash
npm run seed # Runs seed-postgres.js
```
**Docker:**
```bash
docker-compose down -v # Remove volumes
docker-compose up -d # Recreate with fresh data
```
### View Database
**Using psql CLI:**
```bash
# Connect to local database
psql -h localhost -p 5432 -U dashboard_user -d dashboard_db
# SQL commands
\dt # Show all tables
\d cycles # Show table schema
SELECT * FROM cycles; # Query data
SELECT * FROM mortality_records;
\q # Exit
```
**Using Docker:**
```bash
docker-compose exec database psql -U dashboard_user -d dashboard_db
```
**Using DBeaver (GUI):**
- Download from https://dbeaver.io/
- Connect to: localhost:5432 (or 15432 for Docker)
- Database: dashboard_db
- User/Password: from .env file
## 🚨 Error Handling
All endpoints return consistent error format:
```json
{
"success": false,
"error": "Error message here"
}
```
HTTP Status Codes:
- `200` - Success
- `201` - Created
- `400` - Bad Request (invalid input)
- `404` - Not Found
- `500` - Internal Server Error
## 🔒 Security Notes
- Database credentials should be kept in .env (gitignored)
- Foreign keys enforce referential integrity
- SQL injection is prevented by using parameterized queries ($1, $2, etc.)
- CORS is configured for specific origins only
- Input validation on all endpoints
- Connection pooling manages database connections efficiently
- SSL can be enabled for production (set DB_SSL=true)
## 📚 Additional Resources
- [Express.js Documentation](https://expressjs.com/)
- [node-postgres (pg) Documentation](https://node-postgres.com/)
- [PostgreSQL Documentation](https://www.postgresql.org/docs/)
- [Local Database Setup Guide](../docs/LOCAL_DATABASE_GUIDE.md)
## 🤝 Contributing
When contributing to the backend:
1. Follow existing code structure
2. Add error handling for new endpoints
3. Update this README if adding new features
4. Test all endpoints before committing
5. Keep models thin - business logic in models, HTTP in routes
## 📞 Support
For backend-specific issues:
- Check server logs in console
- Verify PostgreSQL is running: `pg_isready` or `docker-compose ps`
- Test database connection: `npm run seed`
- Check port availability: `lsof -i :5001`
- Verify .env configuration (DB_HOST, DB_PORT, credentials)
---
**Backend developed by PT Cipta Pola Solusi Prima - 2025**
View File
Whitespace-only changes.
+121
View File
@@ -0,0 +1,121 @@
const { Pool } = require('pg');
const fs = require('fs');
const path = require('path');
require('dotenv').config();
const SCHEMA_PATH = path.join(__dirname, 'schema.sql');
// Database configuration
const config = {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432'),
user: process.env.DB_USER || 'dashboard_user',
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME || 'dashboard_db',
// Connection pool settings
min: parseInt(process.env.DB_POOL_MIN || '2'),
max: parseInt(process.env.DB_POOL_MAX || '10'),
// Connection timeout
connectionTimeoutMillis: 5000,
// Idle timeout (30 seconds)
idleTimeoutMillis: 30000,
// SSL configuration
ssl:
process.env.DB_SSL === 'true'
? {
rejectUnauthorized: false,
}
: false,
};
// Create connection pool
const pool = new Pool(config);
// Handle pool errors
pool.on('error', (err) => {
console.error('Unexpected error on idle PostgreSQL client', err);
process.exit(-1);
});
// Initialize database with schema
async function initializeDatabase() {
const client = await pool.connect();
try {
const schema = fs.readFileSync(SCHEMA_PATH, 'utf8');
await client.query(schema);
} catch (error) {
console.error('✗ Error initializing database:', error.message);
throw error;
} finally {
client.release();
}
}
// Helper function to convert date to ISO string (YYYY-MM-DD for DATE columns)
function toISODate(date) {
if (!date) return null;
if (date instanceof Date) {
return date.toISOString().split('T')[0];
}
return date;
}
// Helper function to parse ISO date
function parseISODate(dateString) {
if (!dateString) return null;
return new Date(dateString);
}
// Query helper with parameter binding
async function query(text, params) {
const start = Date.now();
try {
const res = await pool.query(text, params);
const duration = Date.now() - start;
if (process.env.NODE_ENV === 'development') {
}
return res;
} catch (error) {
console.error('Query error:', error.message);
console.error('Query:', text);
console.error('Params:', params);
throw error;
}
}
// Transaction helper
async function transaction(callback) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
// Graceful shutdown
async function closePool() {
await pool.end();
}
// Initialize on first load - store promise so callers can await it
const dbReady = initializeDatabase().catch((error) => {
console.error('Failed to initialize database:', error);
process.exit(1);
});
module.exports = {
pool,
query,
transaction,
toISODate,
parseISODate,
closePool,
dbReady,
};
@@ -0,0 +1,15 @@
-- Migration: Add chicken_count and population columns to mortality_records table
-- Date: 2025-12-30
-- Description: Adds chicken_count (from AI counting) and population (manual override) columns
-- Add chicken_count column (nullable)
ALTER TABLE mortality_records
ADD COLUMN IF NOT EXISTS chicken_count INTEGER;
-- Add population column (nullable)
ALTER TABLE mortality_records
ADD COLUMN IF NOT EXISTS population INTEGER;
-- Add comments for documentation
COMMENT ON COLUMN mortality_records.chicken_count IS 'Chicken count from AI camera counting system';
COMMENT ON COLUMN mortality_records.population IS 'Manual population override value';
@@ -0,0 +1,10 @@
-- Migration tracking table
-- This table keeps track of which migrations have been applied
CREATE TABLE IF NOT EXISTS schema_migrations (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) UNIQUE NOT NULL,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create index for faster lookups
CREATE INDEX IF NOT EXISTS idx_schema_migrations_filename ON schema_migrations(filename);
@@ -0,0 +1,74 @@
-- Migration: Add kandang management
-- Date: 2026-02-26
-- Description: Adds kandangs table, kandang_cycles junction table, and kandang_id to mortality_records
-- Also auto-assigns existing mortality records to a default "Kandang 1"
-- 1. Create kandangs table
CREATE TABLE IF NOT EXISTS kandangs (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- 2. Create kandang_cycles junction table (doc_in_count per kandang per cycle)
CREATE TABLE IF NOT EXISTS kandang_cycles (
id SERIAL PRIMARY KEY,
kandang_id INTEGER NOT NULL,
cycle_id VARCHAR(255) NOT NULL,
doc_in_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (kandang_id) REFERENCES kandangs(id) ON DELETE CASCADE,
FOREIGN KEY (cycle_id) REFERENCES cycles(id) ON DELETE CASCADE,
UNIQUE(kandang_id, cycle_id)
);
-- 3. Add kandang_id column to mortality_records (nullable for backward compat)
ALTER TABLE mortality_records
ADD COLUMN IF NOT EXISTS kandang_id INTEGER REFERENCES kandangs(id) ON DELETE SET NULL;
-- 4. Add indexes
CREATE INDEX IF NOT EXISTS idx_mortality_kandang_id ON mortality_records(kandang_id);
CREATE INDEX IF NOT EXISTS idx_kandang_cycles_kandang ON kandang_cycles(kandang_id);
CREATE INDEX IF NOT EXISTS idx_kandang_cycles_cycle ON kandang_cycles(cycle_id);
-- 5. Add updated_at triggers
DROP TRIGGER IF EXISTS update_kandangs_updated_at ON kandangs;
CREATE TRIGGER update_kandangs_updated_at BEFORE UPDATE ON kandangs
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
DROP TRIGGER IF EXISTS update_kandang_cycles_updated_at ON kandang_cycles;
CREATE TRIGGER update_kandang_cycles_updated_at BEFORE UPDATE ON kandang_cycles
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- 6. Auto-assign existing data: Create default "Kandang 1" if not exists and assign all unassigned mortality records
-- Insert default kandang (only if no kandangs exist yet)
INSERT INTO kandangs (name)
SELECT 'Kandang 1'
WHERE NOT EXISTS (SELECT 1 FROM kandangs LIMIT 1);
-- Assign existing mortality records to the first kandang (only records with NULL kandang_id)
UPDATE mortality_records
SET kandang_id = (SELECT id FROM kandangs ORDER BY id LIMIT 1)
WHERE kandang_id IS NULL
AND EXISTS (SELECT 1 FROM kandangs LIMIT 1);
-- Create kandang_cycles entries for the default kandang for all cycles that have doc_in_count
INSERT INTO kandang_cycles (kandang_id, cycle_id, doc_in_count)
SELECT k.id, c.id, COALESCE(c.doc_in_count, 0)
FROM kandangs k, cycles c
WHERE k.id = (SELECT id FROM kandangs ORDER BY id LIMIT 1)
AND NOT EXISTS (
SELECT 1 FROM kandang_cycles kc
WHERE kc.kandang_id = k.id AND kc.cycle_id = c.id
);
-- 7. Drop old unique constraint and add new one that includes kandang_id
-- Note: The constraint name follows PostgreSQL naming convention
ALTER TABLE mortality_records DROP CONSTRAINT IF EXISTS mortality_records_cycle_id_day_key;
-- Create new unique index that handles kandang_id (using COALESCE for NULL safety)
DROP INDEX IF EXISTS idx_mortality_cycle_day_kandang;
CREATE UNIQUE INDEX idx_mortality_cycle_day_kandang
ON mortality_records(cycle_id, day, COALESCE(kandang_id, -1));
@@ -0,0 +1,22 @@
-- Migration: Simplify chicken counting to a single upsert table
-- Date: 2026-03-05
CREATE TABLE IF NOT EXISTS chicken_counting (
id SERIAL PRIMARY KEY,
date DATE NOT NULL,
kandang_id INTEGER NOT NULL REFERENCES kandangs(id) ON DELETE CASCADE,
filename TEXT,
total_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(date, kandang_id)
);
CREATE INDEX IF NOT EXISTS idx_chicken_counting_date ON chicken_counting(date);
CREATE INDEX IF NOT EXISTS idx_chicken_counting_kandang_id ON chicken_counting(kandang_id);
-- Auto-update updated_at trigger
CREATE TRIGGER update_chicken_counting_updated_at
BEFORE UPDATE ON chicken_counting
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
@@ -0,0 +1,12 @@
-- Insert API key for chicken counting endpoint
-- Key: cpa_e8cfeeabaa6997a1ecd4239cee6a9cc59cf43f96cfd58463
INSERT INTO api_keys (id, key_hash, name, description, is_active, rate_limit)
VALUES (
'apk_chicken_counting',
'7a98758e7e6e4dea9f69a06b98d1f56f9f9838dabb2e56c23214676d17a34cad',
'Chicken Counting Service',
'API key for chicken counting program to submit count data',
true,
1000
)
ON CONFLICT (id) DO NOTHING;
@@ -0,0 +1,12 @@
-- Migration: Add deplesi tracking columns
-- Date: 2026-04-19
-- Description: Adds panen and keterangan columns to mortality_records for comprehensive daily tracking
-- Add new columns (safe: idempotent, non-destructive)
ALTER TABLE mortality_records
ADD COLUMN IF NOT EXISTS panen INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS keterangan TEXT;
-- Optional: Add comment for documentation
COMMENT ON COLUMN mortality_records.panen IS 'Daily harvest count (chickens sold/removed)';
COMMENT ON COLUMN mortality_records.keterangan IS 'Daily notes or remarks about significant events';
@@ -0,0 +1,10 @@
-- Migration: Add afkir column
-- Date: 2026-04-19
-- Description: Adds afkir (culled chickens) column to mortality_records
-- Add new column (safe: idempotent, non-destructive)
ALTER TABLE mortality_records
ADD COLUMN IF NOT EXISTS afkir INTEGER DEFAULT 0;
-- Optional: Add comment for documentation
COMMENT ON COLUMN mortality_records.afkir IS 'Daily culled chickens count (removed due to poor quality)';
@@ -0,0 +1,29 @@
-- Migration: Add manual_feed_sack_entries table
-- Description: Store manual feed sack count entries separate from IOT camera data
-- Date: 2026-04-19
CREATE TABLE IF NOT EXISTS manual_feed_sack_entries (
id SERIAL PRIMARY KEY,
cycle_id VARCHAR(255) NOT NULL,
day INTEGER NOT NULL,
date DATE NOT NULL,
camera_name VARCHAR(255) NOT NULL,
count INTEGER NOT NULL CHECK(count >= 0),
created_by VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (cycle_id) REFERENCES cycles(id) ON DELETE CASCADE,
UNIQUE(cycle_id, day, camera_name)
);
-- Create indexes for faster lookups
CREATE INDEX IF NOT EXISTS idx_manual_feed_sack_cycle ON manual_feed_sack_entries(cycle_id);
CREATE INDEX IF NOT EXISTS idx_manual_feed_sack_day ON manual_feed_sack_entries(day);
CREATE INDEX IF NOT EXISTS idx_manual_feed_sack_camera ON manual_feed_sack_entries(camera_name);
-- Trigger for automatic updated_at timestamp
DROP TRIGGER IF EXISTS update_manual_feed_sack_entries_updated_at ON manual_feed_sack_entries;
CREATE TRIGGER update_manual_feed_sack_entries_updated_at
BEFORE UPDATE ON manual_feed_sack_entries
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
@@ -0,0 +1,32 @@
-- Migration: Update manual_feed_sack_entries to use date-based keys instead of cycle-based
-- Description: Remove cycle_id and day dependencies, use date + camera_name as unique key
-- Date: 2026-04-19
-- Drop old unique constraint
ALTER TABLE manual_feed_sack_entries DROP CONSTRAINT IF EXISTS manual_feed_sack_entries_cycle_id_day_camera_name_key;
-- Drop foreign key constraint
ALTER TABLE manual_feed_sack_entries DROP CONSTRAINT IF EXISTS manual_feed_sack_entries_cycle_id_fkey;
-- Drop cycle_id and day columns
ALTER TABLE manual_feed_sack_entries DROP COLUMN IF EXISTS cycle_id;
ALTER TABLE manual_feed_sack_entries DROP COLUMN IF EXISTS day;
-- Add new unique constraint on date + camera_name (only if it doesn't exist)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'manual_feed_sack_entries_date_camera_key'
) THEN
ALTER TABLE manual_feed_sack_entries
ADD CONSTRAINT manual_feed_sack_entries_date_camera_key UNIQUE (date, camera_name);
END IF;
END $$;
-- Drop old index on cycle_id
DROP INDEX IF EXISTS idx_manual_feed_sack_cycle;
DROP INDEX IF EXISTS idx_manual_feed_sack_day;
-- Add index on date for faster lookups
CREATE INDEX IF NOT EXISTS idx_manual_feed_sack_date ON manual_feed_sack_entries(date);
@@ -0,0 +1,23 @@
-- Migration: Add type field to feed_sack_column_config
-- Description: Add 'type' field to distinguish inbound vs usage columns
-- Date: 2026-05-08
-- Author: Claude Code
-- Update existing configs to add type field
-- This uses PostgreSQL's JSONB operators to modify each array element
UPDATE feed_sack_column_config
SET config = (
SELECT jsonb_agg(
CASE
-- Infer type from cameraName for backward compatibility
WHEN elem->>'cameraName' LIKE '%karung_masuk%'
THEN elem || '{"type": "inbound"}'
ELSE elem || '{"type": "usage"}'
END
)
FROM jsonb_array_elements(config) AS elem
)
WHERE config IS NOT NULL AND config != '[]'::jsonb;
-- Note: This migration is safe because it adds a new field with defaults
-- All existing column configs will automatically get 'type' based on camera name pattern
@@ -0,0 +1,20 @@
-- Migration: Add keterangan column to manual_feed_sack_entries
-- Description: Support per-camera notes/remarks for feed sack counting
-- Date: 2026-05-14
-- Add keterangan column (nullable for backward compatibility)
ALTER TABLE manual_feed_sack_entries
ADD COLUMN IF NOT EXISTS keterangan TEXT;
-- Add CHECK constraint for max 200 characters (only if not exists)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'keterangan_max_length'
AND conrelid = 'manual_feed_sack_entries'::regclass
) THEN
ALTER TABLE manual_feed_sack_entries
ADD CONSTRAINT keterangan_max_length CHECK (char_length(keterangan) <= 200);
END IF;
END $$;
@@ -0,0 +1,23 @@
-- Migration: Add cycle_feed_initial_balance table
-- Description: Store initial feed sack balance (saldo karung awal) per cycle
-- Date: 2026-05-26
CREATE TABLE IF NOT EXISTS cycle_feed_initial_balance (
id SERIAL PRIMARY KEY,
cycle_id VARCHAR(255) NOT NULL UNIQUE,
saldo_awal INTEGER NOT NULL DEFAULT 0 CHECK(saldo_awal >= 0),
created_by VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (cycle_id) REFERENCES cycles(id) ON DELETE CASCADE
);
-- Create index for faster lookups by cycle
CREATE INDEX IF NOT EXISTS idx_cycle_feed_initial_balance_cycle ON cycle_feed_initial_balance(cycle_id);
-- Trigger for automatic updated_at timestamp
DROP TRIGGER IF EXISTS update_cycle_feed_initial_balance_updated_at ON cycle_feed_initial_balance;
CREATE TRIGGER update_cycle_feed_initial_balance_updated_at
BEFORE UPDATE ON cycle_feed_initial_balance
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
@@ -0,0 +1,19 @@
-- Create AI Insights table for caching AI-generated insights
CREATE TABLE IF NOT EXISTS ai_insights (
id SERIAL PRIMARY KEY,
cycle_id VARCHAR(255) NOT NULL,
kandang_id VARCHAR(255), -- NULL means "all kandangs"
insight_text TEXT NOT NULL,
version VARCHAR(50) NOT NULL DEFAULT 'v1',
generated_at TIMESTAMP NOT NULL DEFAULT NOW(),
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Create indexes for faster lookups
CREATE INDEX IF NOT EXISTS idx_ai_insights_cycle_kandang ON ai_insights (cycle_id, kandang_id);
CREATE INDEX IF NOT EXISTS idx_ai_insights_expires_at ON ai_insights (expires_at);
-- Add comment to table
COMMENT ON TABLE ai_insights IS 'Stores AI-generated insights with 6-hour TTL for cross-device caching';
@@ -0,0 +1,18 @@
-- Migration 012: Add cage_uuid to kandangs table
-- Add cage_uuid field for external scales API integration
-- Add cage_uuid column (nullable, unique)
ALTER TABLE kandangs
ADD COLUMN IF NOT EXISTS cage_uuid VARCHAR(36) UNIQUE;
-- Add comment explaining the field
COMMENT ON COLUMN kandangs.cage_uuid IS 'External cage UUID for scales API (dashboard.cpsp.id/api/scales/cage/)';
-- Seed initial data for existing kandangs
-- Kandang Atas gets the existing hardcoded UUID
UPDATE kandangs
SET cage_uuid = '9f556d2a-af5c-41b0-bcd0-6cea0dfa0fed'
WHERE LOWER(name) LIKE '%atas%';
-- Kandang Bawah and others remain null (to be configured via settings UI)
-- This is intentional - admins will configure via IoT Panel settings
@@ -0,0 +1,8 @@
-- Migration: Add harvest weight tracking to mortality records
-- Date: 2026-06-19
-- Description: Adds berat_panen to mortality_records so harvest weight persists across reloads
ALTER TABLE mortality_records
ADD COLUMN IF NOT EXISTS berat_panen NUMERIC(10,2) DEFAULT 0;
COMMENT ON COLUMN mortality_records.berat_panen IS 'Daily harvest weight in kilograms';
+29
View File
@@ -0,0 +1,29 @@
const fs = require('fs');
const path = require('path');
const { query } = require('./db');
async function runMigration() {
try {
const migrationPath = path.join(
__dirname,
'migrations',
'add_chicken_count_and_population.sql'
);
const sql = fs.readFileSync(migrationPath, 'utf8');
// Split by semicolon and execute each statement
const statements = sql.split(';').filter((stmt) => stmt.trim());
for (const statement of statements) {
if (statement.trim()) {
await query(statement);
}
}
process.exit(0);
} catch (error) {
console.error('Migration failed:', error);
process.exit(1);
}
}
runMigration();
+67
View File
@@ -0,0 +1,67 @@
const fs = require('fs');
const path = require('path');
const { query, closePool, dbReady } = require('./db');
const MIGRATIONS_DIR = path.join(__dirname, 'migrations');
async function runMigrations() {
try {
// Wait for schema to be initialized before running migrations
await dbReady;
// Create migrations tracking table if it doesn't exist
await query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) NOT NULL UNIQUE,
applied_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
)
`);
// Get list of already applied migrations
const applied = await query('SELECT filename FROM schema_migrations ORDER BY filename');
const appliedSet = new Set(applied.rows.map((r) => r.filename));
// Read all .sql files from migrations directory, sorted alphabetically
const files = fs
.readdirSync(MIGRATIONS_DIR)
.filter((f) => f.endsWith('.sql'))
.sort();
let migrationsRun = 0;
for (const file of files) {
if (appliedSet.has(file)) {
continue;
}
const filePath = path.join(MIGRATIONS_DIR, file);
const sql = fs.readFileSync(filePath, 'utf8');
// Execute the entire migration as a single statement block
await query(sql);
// Record the migration
await query('INSERT INTO schema_migrations (filename) VALUES ($1)', [file]);
migrationsRun++;
}
if (migrationsRun === 0) {
} else {
}
} catch (error) {
console.error('Migration error:', error.message);
throw error;
}
}
// Run if executed directly
if (require.main === module) {
runMigrations()
.then(() => closePool())
.then(() => process.exit(0))
.catch(() => {
closePool().then(() => process.exit(1));
});
}
module.exports = { runMigrations };
+77
View File
@@ -0,0 +1,77 @@
const fs = require('fs');
const path = require('path');
const { query } = require('./db');
async function runMigrations() {
try {
await query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) UNIQUE NOT NULL,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
await query(`
CREATE INDEX IF NOT EXISTS idx_schema_migrations_filename
ON schema_migrations(filename)
`);
// Get all migration files
const migrationsDir = path.join(__dirname, 'migrations');
if (!fs.existsSync(migrationsDir)) {
return;
}
const files = fs
.readdirSync(migrationsDir)
.filter((f) => f.endsWith('.sql'))
.sort(); // Run in alphabetical order (001_, 002_, etc.)
if (files.length === 0) {
return;
}
let appliedCount = 0;
let skippedCount = 0;
// Process each migration file
for (const file of files) {
// Check if migration has already been applied
const checkResult = await query('SELECT * FROM schema_migrations WHERE filename = $1', [
file,
]);
if (checkResult.rows.length > 0) {
skippedCount++;
continue;
}
// Apply new migration
try {
const filePath = path.join(migrationsDir, file);
const sql = fs.readFileSync(filePath, 'utf8');
// Execute migration SQL
await query(sql);
// Record migration as applied
await query('INSERT INTO schema_migrations (filename) VALUES ($1)', [file]);
appliedCount++;
} catch (error) {
console.error(` ❌ ${file} - FAILED`);
console.error(` Error: ${error.message}`);
throw error; // Stop on first error
}
}
} catch (error) {
console.error('\n' + '='.repeat(50));
console.error('❌ Migration failed!');
console.error('='.repeat(50));
console.error(error);
console.error('\n⚠️ Server startup aborted due to migration failure\n');
throw error; // Re-throw to prevent server from starting
}
}
module.exports = { runMigrations };
+323
View File
@@ -0,0 +1,323 @@
-- PostgreSQL Schema for Chicken Farm Management System
-- This file is automatically executed on database initialization
-- =============================================================================
-- CORE TABLES
-- =============================================================================
-- Cycles table
CREATE TABLE IF NOT EXISTS cycles (
id VARCHAR(255) PRIMARY KEY,
total_days INTEGER NOT NULL,
current_day INTEGER NOT NULL,
start_date DATE NOT NULL,
end_date DATE,
chick_in_weight INTEGER,
doc_in_count INTEGER,
status VARCHAR(50) NOT NULL CHECK(status IN ('Completed', 'Active', 'Upcoming')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Kandangs table (chicken houses for mortality/population tracking)
CREATE TABLE IF NOT EXISTS kandangs (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
cage_uuid VARCHAR(36) UNIQUE, -- External cage UUID for scales API integration
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Kandang-Cycles junction table (doc_in_count per kandang per cycle)
CREATE TABLE IF NOT EXISTS kandang_cycles (
id SERIAL PRIMARY KEY,
kandang_id INTEGER NOT NULL,
cycle_id VARCHAR(255) NOT NULL,
doc_in_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (kandang_id) REFERENCES kandangs(id) ON DELETE CASCADE,
FOREIGN KEY (cycle_id) REFERENCES cycles(id) ON DELETE CASCADE,
UNIQUE(kandang_id, cycle_id)
);
CREATE INDEX IF NOT EXISTS idx_kandang_cycles_kandang ON kandang_cycles(kandang_id);
CREATE INDEX IF NOT EXISTS idx_kandang_cycles_cycle ON kandang_cycles(cycle_id);
-- Mortality records table
CREATE TABLE IF NOT EXISTS mortality_records (
id SERIAL PRIMARY KEY,
cycle_id VARCHAR(255) NOT NULL,
day INTEGER NOT NULL,
mortality_count INTEGER NOT NULL DEFAULT 0,
chicken_count INTEGER,
population INTEGER,
kandang_id INTEGER REFERENCES kandangs(id) ON DELETE SET NULL,
is_edited BOOLEAN DEFAULT FALSE,
afkir INTEGER DEFAULT 0,
panen INTEGER DEFAULT 0,
berat_panen NUMERIC(10,2) DEFAULT 0,
keterangan TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (cycle_id) REFERENCES cycles(id) ON DELETE CASCADE
);
-- Unique index for mortality records (supports per-kandang tracking)
-- Wrapped in DO block: on existing DBs, kandang_id may not exist yet (added by migration 002)
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'mortality_records' AND column_name = 'kandang_id'
) THEN
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_mortality_cycle_day_kandang') THEN
CREATE UNIQUE INDEX idx_mortality_cycle_day_kandang
ON mortality_records(cycle_id, day, COALESCE(kandang_id, -1));
END IF;
ELSE
-- Fallback: ensure old unique constraint exists for pre-migration state
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'mortality_records_cycle_id_day_key'
) AND NOT EXISTS (
SELECT 1 FROM pg_indexes WHERE indexname = 'idx_mortality_cycle_day_kandang'
) THEN
ALTER TABLE mortality_records ADD CONSTRAINT mortality_records_cycle_id_day_key UNIQUE (cycle_id, day);
END IF;
END IF;
END
$$;
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_mortality_cycle_id ON mortality_records(cycle_id);
CREATE INDEX IF NOT EXISTS idx_mortality_day ON mortality_records(day);
CREATE INDEX IF NOT EXISTS idx_cycles_status ON cycles(status);
CREATE INDEX IF NOT EXISTS idx_cycles_start_date ON cycles(start_date);
-- kandang_id index (only if column exists - added by migration 002 on existing DBs)
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'mortality_records' AND column_name = 'kandang_id'
) THEN
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_mortality_kandang_id') THEN
CREATE INDEX idx_mortality_kandang_id ON mortality_records(kandang_id);
END IF;
END IF;
END
$$;
-- =============================================================================
-- CHICKEN COUNTING API TABLES
-- =============================================================================
-- API Keys for authentication
CREATE TABLE IF NOT EXISTS api_keys (
id VARCHAR(255) PRIMARY KEY,
key_hash VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
description TEXT,
created_by VARCHAR(255),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP WITH TIME ZONE,
expires_at TIMESTAMP WITH TIME ZONE,
rate_limit INTEGER DEFAULT 1000,
allowed_ips TEXT
);
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(is_active);
-- Location hierarchy: Locations (top level)
CREATE TABLE IF NOT EXISTS locations (
id VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
address TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Location hierarchy: Coops (buildings within locations)
CREATE TABLE IF NOT EXISTS coops (
id VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
location_id VARCHAR(255) NOT NULL,
capacity INTEGER,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_coops_location ON coops(location_id);
-- Location hierarchy: Floors (levels within coops)
CREATE TABLE IF NOT EXISTS floors (
id VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
coop_id VARCHAR(255) NOT NULL,
floor_number INTEGER,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (coop_id) REFERENCES coops(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_floors_coop ON floors(coop_id);
-- Location hierarchy: Cameras (AI/CV cameras on floors)
CREATE TABLE IF NOT EXISTS cameras (
id VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
camera_type VARCHAR(50) NOT NULL CHECK(camera_type IN ('STANDARD', 'INFRARED', 'THERMAL', 'THREE_D')) DEFAULT 'STANDARD',
rtsp_url TEXT,
floor_id VARCHAR(255),
model VARCHAR(255),
status VARCHAR(50) NOT NULL CHECK(status IN ('online', 'offline', 'error')) DEFAULT 'offline',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (floor_id) REFERENCES floors(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_cameras_floor ON cameras(floor_id);
CREATE INDEX IF NOT EXISTS idx_cameras_status ON cameras(status);
-- Chicken count records from AI cameras
CREATE TABLE IF NOT EXISTS chicken_count_records (
id SERIAL PRIMARY KEY,
camera_id VARCHAR(255) NOT NULL,
cycle_id VARCHAR(255) NOT NULL,
day INTEGER NOT NULL,
count INTEGER NOT NULL CHECK(count >= 0),
confidence_score REAL CHECK(confidence_score >= 0 AND confidence_score <= 1),
processing_status VARCHAR(50) NOT NULL CHECK(processing_status IN ('success', 'processing', 'failed')) DEFAULT 'success',
source_type VARCHAR(50) NOT NULL CHECK(source_type IN ('ai_camera', 'manual', 'estimated')) DEFAULT 'ai_camera',
image_url TEXT,
recorded_at TIMESTAMP WITH TIME ZONE NOT NULL,
is_verified BOOLEAN DEFAULT FALSE,
verified_by VARCHAR(255),
verified_at TIMESTAMP WITH TIME ZONE,
notes TEXT,
metadata JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (camera_id) REFERENCES cameras(id) ON DELETE CASCADE,
FOREIGN KEY (cycle_id) REFERENCES cycles(id) ON DELETE CASCADE,
UNIQUE(camera_id, cycle_id, day, recorded_at)
);
CREATE INDEX IF NOT EXISTS idx_count_camera ON chicken_count_records(camera_id);
CREATE INDEX IF NOT EXISTS idx_count_cycle ON chicken_count_records(cycle_id);
CREATE INDEX IF NOT EXISTS idx_count_day ON chicken_count_records(day);
CREATE INDEX IF NOT EXISTS idx_count_recorded_at ON chicken_count_records(recorded_at);
CREATE INDEX IF NOT EXISTS idx_count_cycle_day ON chicken_count_records(cycle_id, day);
CREATE INDEX IF NOT EXISTS idx_count_status ON chicken_count_records(processing_status);
CREATE INDEX IF NOT EXISTS idx_count_verified ON chicken_count_records(is_verified);
CREATE INDEX IF NOT EXISTS idx_count_metadata ON chicken_count_records USING GIN(metadata);
-- Daily aggregated counts for performance
CREATE TABLE IF NOT EXISTS daily_aggregated_counts (
id SERIAL PRIMARY KEY,
cycle_id VARCHAR(255) NOT NULL,
day INTEGER NOT NULL,
total_count INTEGER NOT NULL,
average_confidence REAL,
camera_count INTEGER,
recorded_date DATE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (cycle_id) REFERENCES cycles(id) ON DELETE CASCADE,
UNIQUE(cycle_id, day, recorded_date)
);
CREATE INDEX IF NOT EXISTS idx_daily_agg_cycle ON daily_aggregated_counts(cycle_id);
CREATE INDEX IF NOT EXISTS idx_daily_agg_day ON daily_aggregated_counts(day);
CREATE INDEX IF NOT EXISTS idx_daily_agg_date ON daily_aggregated_counts(recorded_date);
-- =============================================================================
-- FEED SACK COLUMN CONFIGURATION
-- =============================================================================
-- Single-row table storing the column config as JSONB
CREATE TABLE IF NOT EXISTS feed_sack_column_config (
id SERIAL PRIMARY KEY,
config JSONB NOT NULL DEFAULT '[]',
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- =============================================================================
-- FUNCTIONS AND TRIGGERS
-- =============================================================================
-- Function to automatically update the updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
-- Apply update_updated_at trigger to all tables with updated_at column
DROP TRIGGER IF EXISTS update_cycles_updated_at ON cycles;
CREATE TRIGGER update_cycles_updated_at BEFORE UPDATE ON cycles
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
DROP TRIGGER IF EXISTS update_mortality_records_updated_at ON mortality_records;
CREATE TRIGGER update_mortality_records_updated_at BEFORE UPDATE ON mortality_records
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
DROP TRIGGER IF EXISTS update_kandangs_updated_at ON kandangs;
CREATE TRIGGER update_kandangs_updated_at BEFORE UPDATE ON kandangs
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
DROP TRIGGER IF EXISTS update_kandang_cycles_updated_at ON kandang_cycles;
CREATE TRIGGER update_kandang_cycles_updated_at BEFORE UPDATE ON kandang_cycles
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
DROP TRIGGER IF EXISTS update_locations_updated_at ON locations;
CREATE TRIGGER update_locations_updated_at BEFORE UPDATE ON locations
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
DROP TRIGGER IF EXISTS update_coops_updated_at ON coops;
CREATE TRIGGER update_coops_updated_at BEFORE UPDATE ON coops
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
DROP TRIGGER IF EXISTS update_floors_updated_at ON floors;
CREATE TRIGGER update_floors_updated_at BEFORE UPDATE ON floors
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
DROP TRIGGER IF EXISTS update_cameras_updated_at ON cameras;
CREATE TRIGGER update_cameras_updated_at BEFORE UPDATE ON cameras
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
DROP TRIGGER IF EXISTS update_chicken_count_records_updated_at ON chicken_count_records;
CREATE TRIGGER update_chicken_count_records_updated_at BEFORE UPDATE ON chicken_count_records
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
DROP TRIGGER IF EXISTS update_daily_aggregated_counts_updated_at ON daily_aggregated_counts;
CREATE TRIGGER update_daily_aggregated_counts_updated_at BEFORE UPDATE ON daily_aggregated_counts
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- =============================================================================
-- AUDIT LOG TABLE
-- =============================================================================
-- Audit logs table for tracking all data changes
-- Note: Timestamps are stored in UTC (TIMESTAMP WITH TIME ZONE)
-- Frontend converts to WIB (UTC+7) for display
CREATE TABLE IF NOT EXISTS audit_logs (
id SERIAL PRIMARY KEY,
table_name VARCHAR(50) NOT NULL,
record_id VARCHAR(50) NOT NULL,
action VARCHAR(20) NOT NULL CHECK(action IN ('CREATE', 'UPDATE', 'DELETE')),
old_values JSONB,
new_values JSONB,
changed_fields TEXT[],
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP -- Stored in UTC
);
-- Indexes for efficient querying
CREATE INDEX IF NOT EXISTS idx_audit_logs_table_record ON audit_logs(table_name, record_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_logs_table_created ON audit_logs(table_name, created_at DESC);
+260
View File
@@ -0,0 +1,260 @@
const { query, toISODate, closePool, dbReady } = require('./db');
const Cycle = require('../models/Cycle');
const Camera = require('../models/Camera');
const ApiKey = require('../models/ApiKey');
const crypto = require('crypto');
// Helper function to hash API keys
function hashApiKey(key) {
return crypto.createHash('sha256').update(key).digest('hex');
}
// Helper function to generate API key
function generateApiKey() {
const randomBytes = crypto.randomBytes(24);
return 'cpa_' + randomBytes.toString('hex');
}
// Seed data - minimal cycles with no assumed data
const cycles = [
{
id: 'CYCLE-JBW-2025-05-20',
totalDays: 42,
currentDay: 42,
startDate: new Date('2025-05-20'),
status: 'Completed',
docInCount: null,
chickInWeight: null,
},
{
id: 'CYCLE-JBW-2025-07-11',
totalDays: 42,
currentDay: 42,
startDate: new Date('2025-07-11'),
status: 'Completed',
docInCount: null,
chickInWeight: null,
},
{
id: 'CYCLE-JBW-2025-10-22',
totalDays: 42,
currentDay: 42,
startDate: new Date('2025-10-22'),
status: 'Completed',
docInCount: null,
chickInWeight: null,
},
{
id: 'CYCLE-JBW-2025-12-10',
totalDays: 42,
currentDay: 42,
startDate: new Date('2025-12-10'),
status: 'Completed',
docInCount: null,
chickInWeight: null,
},
{
id: 'CYCLE-JBW-2026-02-09',
totalDays: 42,
currentDay: 2,
startDate: new Date('2026-02-09'),
status: 'Active',
docInCount: 20000,
chickInWeight: null,
},
];
// Location hierarchy seed data
const locations = [
{
id: 'loc1',
name: 'Peternakan Pusat, Jawa Barat',
address: 'Jl. Peternakan No. 123, Bandung, Jawa Barat',
},
];
const coops = [
{
id: 'coop1',
name: 'Kandang A (Broiler)',
locationId: 'loc1',
capacity: 15000,
},
{
id: 'coop2',
name: 'Kandang B (Layer)',
locationId: 'loc1',
capacity: 12000,
},
];
const floors = [
{
id: 'floor1',
name: 'Lantai 1',
coopId: 'coop1',
floorNumber: 1,
},
{
id: 'floor2',
name: 'Lantai 2',
coopId: 'coop1',
floorNumber: 2,
},
{
id: 'floor3',
name: 'Lantai 1',
coopId: 'coop2',
floorNumber: 1,
},
];
const cameras = [
{
id: 'cam1',
name: 'Kamera Sudut Kanan Zona A',
cameraType: 'STANDARD',
floorId: 'floor1',
model: 'HIK-2MP-IR',
status: 'online',
},
{
id: 'cam2',
name: 'Kamera Sudut Kiri Zona A',
cameraType: 'STANDARD',
floorId: 'floor1',
model: 'HIK-2MP-IR',
status: 'online',
},
{
id: 'cam3',
name: 'Kamera Tengah Zona B',
cameraType: 'INFRARED',
floorId: 'floor2',
model: 'HIK-4MP-IR',
status: 'online',
},
{
id: 'cam4',
name: 'Kamera Pintu Masuk',
cameraType: 'STANDARD',
floorId: 'floor2',
model: 'HIK-2MP',
status: 'online',
},
{
id: 'cam5',
name: 'Kamera Zona Layer 1',
cameraType: 'STANDARD',
floorId: 'floor3',
model: 'HIK-2MP-IR',
status: 'online',
},
];
async function seedDatabase() {
try {
// Wait for schema to be initialized before querying
await dbReady;
// Check if cycles already exist
const existingCycles = await query('SELECT COUNT(*) as count FROM cycles', []);
const cycleCount = parseInt(existingCycles.rows[0].count);
if (cycleCount > 0) {
return;
}
// Insert locations (skip if already exist)
for (const loc of locations) {
await query(
'INSERT INTO locations (id, name, address) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING',
[loc.id, loc.name, loc.address || null]
);
}
// Insert coops (skip if already exist)
for (const coop of coops) {
await query(
'INSERT INTO coops (id, name, location_id, capacity) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING',
[coop.id, coop.name, coop.locationId, coop.capacity || null]
);
}
// Insert floors (skip if already exist)
for (const floor of floors) {
await query(
'INSERT INTO floors (id, name, coop_id, floor_number) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING',
[floor.id, floor.name, floor.coopId, floor.floorNumber || null]
);
}
for (const cam of cameras) {
await Camera.create({
id: cam.id,
name: cam.name,
cameraType: cam.cameraType,
floorId: cam.floorId || null,
model: cam.model || null,
status: cam.status,
});
}
for (const cycle of cycles) {
const endDate = new Date(cycle.startDate);
endDate.setDate(endDate.getDate() + cycle.totalDays - 1);
await Cycle.create({
id: cycle.id,
totalDays: cycle.totalDays,
currentDay: cycle.currentDay,
startDate: cycle.startDate,
endDate: endDate,
chickInWeight: cycle.chickInWeight,
docInCount: cycle.docInCount,
status: cycle.status,
});
}
// Display seeded cycles
const seededCycles = await Cycle.getAll();
seededCycles.forEach((c) => {});
const devKey = generateApiKey();
const keyHash = hashApiKey(devKey);
const keyId = 'apk_dev_' + Date.now();
await query(
`INSERT INTO api_keys (id, key_hash, name, description, created_by, is_active)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
keyId,
keyHash,
'Development Key',
'Auto-generated key for testing and development',
'seed-script',
1,
]
);
} catch (error) {
console.error('✗ Error seeding database:', error.message);
console.error(error.stack);
throw error;
}
}
// Run seed if this file is executed directly
if (require.main === module) {
seedDatabase()
.then(() => {
return closePool();
})
.then(() => {
process.exit(0);
})
.catch((error) => {
console.error('Fatal error:', error);
closePool().then(() => {
process.exit(1);
});
});
}
module.exports = { seedDatabase };
+262
View File
@@ -0,0 +1,262 @@
const { db, toISODate } = require('./db');
const crypto = require('crypto');
// Helper function to hash API keys
function hashApiKey(key) {
return crypto.createHash('sha256').update(key).digest('hex');
}
// Helper function to generate API key
function generateApiKey() {
const randomBytes = crypto.randomBytes(24);
return 'cpa_' + randomBytes.toString('hex');
}
// Seed data from mockData/cycleData.ts
const cycles = [
{
id: 'CYCLE-JBW-2025-05-20',
totalDays: 42,
currentDay: 42,
startDate: new Date('2025-05-20'),
status: 'Completed',
docInCount: 20000,
chickInWeight: null,
},
{
id: 'CYCLE-JBW-2025-07-11',
totalDays: 42,
currentDay: 42,
startDate: new Date('2025-07-11'),
status: 'Completed',
docInCount: 20000,
chickInWeight: null,
},
{
id: 'CYCLE-JBW-2025-10-22',
totalDays: 42,
currentDay: 42,
startDate: new Date('2025-10-22'),
status: 'Completed',
docInCount: 20000,
chickInWeight: null,
},
{
id: 'CYCLE-JBW-2025-12-10',
totalDays: 42,
currentDay: 7,
startDate: new Date('2025-12-10'),
status: 'Active',
docInCount: 20000,
chickInWeight: null,
},
];
// Location hierarchy seed data
const locations = [
{
id: 'loc1',
name: 'Peternakan Pusat, Jawa Barat',
address: 'Jl. Peternakan No. 123, Bandung, Jawa Barat',
},
];
const coops = [
{
id: 'coop1',
name: 'Kandang A (Broiler)',
locationId: 'loc1',
capacity: 15000,
},
{
id: 'coop2',
name: 'Kandang B (Layer)',
locationId: 'loc1',
capacity: 12000,
},
];
const floors = [
{
id: 'floor1',
name: 'Lantai 1',
coopId: 'coop1',
floorNumber: 1,
},
{
id: 'floor2',
name: 'Lantai 2',
coopId: 'coop1',
floorNumber: 2,
},
{
id: 'floor3',
name: 'Lantai 1',
coopId: 'coop2',
floorNumber: 1,
},
];
const cameras = [
{
id: 'cam1',
name: 'Kamera Sudut Kanan Zona A',
cameraType: 'STANDARD',
floorId: 'floor1',
model: 'HIK-2MP-IR',
status: 'online',
},
{
id: 'cam2',
name: 'Kamera Sudut Kiri Zona A',
cameraType: 'STANDARD',
floorId: 'floor1',
model: 'HIK-2MP-IR',
status: 'online',
},
{
id: 'cam3',
name: 'Kamera Tengah Zona B',
cameraType: 'INFRARED',
floorId: 'floor2',
model: 'HIK-4MP-IR',
status: 'online',
},
{
id: 'cam4',
name: 'Kamera Pintu Masuk',
cameraType: 'STANDARD',
floorId: 'floor2',
model: 'HIK-2MP',
status: 'online',
},
{
id: 'cam5',
name: 'Kamera Zona Layer 1',
cameraType: 'STANDARD',
floorId: 'floor3',
model: 'HIK-2MP-IR',
status: 'online',
},
];
function seedDatabase() {
try {
// Check if cycles already exist
const existingCycles = db.prepare('SELECT COUNT(*) as count FROM cycles').get();
if (existingCycles.count > 0) {
return;
}
// Insert cycles
const insertCycle = db.prepare(`
INSERT INTO cycles (id, total_days, current_day, start_date, end_date, chick_in_weight, doc_in_count, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const insertMany = db.transaction((cycles) => {
for (const cycle of cycles) {
const endDate = new Date(cycle.startDate);
endDate.setDate(endDate.getDate() + cycle.totalDays - 1);
insertCycle.run(
cycle.id,
cycle.totalDays,
cycle.currentDay,
toISODate(cycle.startDate),
toISODate(endDate),
cycle.chickInWeight,
cycle.docInCount,
cycle.status
);
}
});
insertMany(cycles);
// Display seeded data
const seededCycles = db.prepare('SELECT * FROM cycles ORDER BY start_date').all();
seededCycles.forEach((c) => {});
// Insert locations
const insertLocation = db.prepare(`
INSERT INTO locations (id, name, address) VALUES (?, ?, ?)
`);
const seedLocations = db.transaction((locs) => {
for (const loc of locs) {
insertLocation.run(loc.id, loc.name, loc.address || null);
}
});
seedLocations(locations);
// Insert coops
const insertCoop = db.prepare(`
INSERT INTO coops (id, name, location_id, capacity) VALUES (?, ?, ?, ?)
`);
const seedCoops = db.transaction((coopList) => {
for (const coop of coopList) {
insertCoop.run(coop.id, coop.name, coop.locationId, coop.capacity || null);
}
});
seedCoops(coops);
// Insert floors
const insertFloor = db.prepare(`
INSERT INTO floors (id, name, coop_id, floor_number) VALUES (?, ?, ?, ?)
`);
const seedFloors = db.transaction((floorList) => {
for (const floor of floorList) {
insertFloor.run(floor.id, floor.name, floor.coopId, floor.floorNumber || null);
}
});
seedFloors(floors);
// Insert cameras
const insertCamera = db.prepare(`
INSERT INTO cameras (id, name, camera_type, floor_id, model, status)
VALUES (?, ?, ?, ?, ?, ?)
`);
const seedCameras = db.transaction((camList) => {
for (const cam of camList) {
insertCamera.run(
cam.id,
cam.name,
cam.cameraType,
cam.floorId || null,
cam.model || null,
cam.status
);
}
});
seedCameras(cameras);
const devKey = generateApiKey();
const keyHash = hashApiKey(devKey);
const keyId = 'apk_dev_' + Date.now();
db.prepare(
`
INSERT INTO api_keys (id, key_hash, name, description, created_by, is_active)
VALUES (?, ?, ?, ?, ?, ?)
`
).run(
keyId,
keyHash,
'Development Key',
'Auto-generated key for testing and development',
'seed-script',
1
);
} catch (error) {
console.error('✗ Error seeding database:', error.message);
throw error;
}
}
// Run seed if this file is executed directly
if (require.main === module) {
seedDatabase();
process.exit(0);
}
module.exports = { seedDatabase };
+93
View File
@@ -0,0 +1,93 @@
const AuditLog = require('../models/AuditLog');
/**
* Middleware to log data changes to audit_logs table
*
* Usage:
* const { captureAuditLog } = require('../middleware/auditLogger');
*
* router.put('/endpoint/:id', async (req, res) => {
* const oldData = await Model.getById(id); // Fetch current data before update
*
* // Perform update
* const newData = await Model.update(id, req.body);
*
* // Log the change
* await captureAuditLog('table_name', id, 'UPDATE', oldData, newData);
*
* res.json({ success: true, data: newData });
* });
*/
/**
* Capture audit log for a data change
* @param {string} tableName - Name of the table being modified
* @param {string|number} recordId - ID of the record being modified
* @param {string} action - Action type: CREATE, UPDATE, DELETE
* @param {Object} oldValues - Previous values (null for CREATE)
* @param {Object} newValues - New values (null for DELETE)
* @returns {Promise<Object>} Created audit log entry
*/
async function captureAuditLog(tableName, recordId, action, oldValues = null, newValues = null) {
try {
// Determine which fields changed
const changedFields = [];
if (action === 'UPDATE' && oldValues && newValues) {
// Compare old and new values to find changed fields
for (const key in newValues) {
if (oldValues.hasOwnProperty(key)) {
// Handle different types of values
const oldVal = oldValues[key];
const newVal = newValues[key];
// Deep comparison for objects/arrays, simple comparison otherwise
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
changedFields.push(key);
}
}
}
}
// Only log if there are actual changes (for UPDATE) or always for CREATE/DELETE
if (action !== 'UPDATE' || changedFields.length > 0) {
return await AuditLog.create(
tableName,
recordId,
action,
oldValues,
newValues,
changedFields
);
}
return null; // No changes to log
} catch (error) {
// Log error but don't fail the main operation
console.error('Error capturing audit log:', error);
return null;
}
}
/**
* Helper to extract only relevant fields for logging
* (excludes timestamps and internal fields)
* @param {Object} data - Data object
* @param {string[]} excludeFields - Fields to exclude (default: created_at, updated_at)
* @returns {Object} Filtered data object
*/
function filterAuditFields(data, excludeFields = ['created_at', 'updated_at']) {
if (!data) return null;
const filtered = { ...data };
excludeFields.forEach(field => {
delete filtered[field];
});
return filtered;
}
module.exports = {
captureAuditLog,
filterAuditFields
};
+109
View File
@@ -0,0 +1,109 @@
const crypto = require('crypto');
const { query } = require('../database/db');
/**
* Hash an API key using SHA-256
*/
function hashApiKey(key) {
return crypto.createHash('sha256').update(key).digest('hex');
}
/**
* Generate a new API key with 'cpa_' prefix
*/
function generateApiKey() {
const randomBytes = crypto.randomBytes(24);
return 'cpa_' + randomBytes.toString('hex');
}
/**
* Middleware to validate API key from X-API-Key header
*/
async function validateApiKey(req, res, next) {
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
return res.status(401).json({
success: false,
error: 'API key is required. Provide X-API-Key header.'
});
}
if (!apiKey.startsWith('cpa_')) {
return res.status(401).json({
success: false,
error: 'Invalid API key format'
});
}
try {
const keyHash = hashApiKey(apiKey);
const result = await query(
`SELECT id, name, is_active, expires_at, rate_limit, allowed_ips
FROM api_keys
WHERE key_hash = $1`,
[keyHash]
);
const keyRecord = result.rows[0];
if (!keyRecord) {
return res.status(401).json({
success: false,
error: 'Invalid API key'
});
}
if (!keyRecord.is_active) {
return res.status(401).json({
success: false,
error: 'API key is inactive'
});
}
if (keyRecord.expires_at) {
const expiryDate = new Date(keyRecord.expires_at);
if (expiryDate < new Date()) {
return res.status(401).json({
success: false,
error: 'API key has expired'
});
}
}
if (keyRecord.allowed_ips) {
const allowedIps = keyRecord.allowed_ips.split(',').map(ip => ip.trim());
const clientIp = req.ip || req.connection.remoteAddress;
if (!allowedIps.includes(clientIp)) {
return res.status(403).json({
success: false,
error: 'IP address not allowed for this API key'
});
}
}
// Update last_used_at
await query('UPDATE api_keys SET last_used_at = CURRENT_TIMESTAMP WHERE id = $1', [keyRecord.id]);
req.apiKey = {
id: keyRecord.id,
name: keyRecord.name,
rateLimit: keyRecord.rate_limit
};
next();
} catch (error) {
console.error('API key validation error:', error);
return res.status(500).json({
success: false,
error: 'Authentication error'
});
}
}
module.exports = {
validateApiKey,
generateApiKey,
hashApiKey
};
+30
View File
@@ -0,0 +1,30 @@
/**
* Error Handler Middleware
* Sanitizes errors before sending to client (security best practice)
*/
function errorHandler(err, req, res, _next) {
// Log full error internally (with stack trace)
console.error('[ERROR]', {
timestamp: new Date().toISOString(),
path: req.path,
method: req.method,
error: err.message,
code: err.code,
stack: process.env.NODE_ENV !== 'production' ? err.stack : undefined,
});
// Determine error type
const statusCode = err.statusCode || 500;
const isClientError = statusCode >= 400 && statusCode < 500;
// Send sanitized response to client
// IMPORTANT: Never expose internal error messages in production
res.status(statusCode).json({
success: false,
error: isClientError ? err.message : 'Internal server error',
code: err.code || 'INTERNAL_ERROR',
});
}
module.exports = { errorHandler };
+113
View File
@@ -0,0 +1,113 @@
const { query } = require('../database/db');
class AiInsight {
/**
* Get AI insight by cycle and kandang
* @param {string} cycleId - Cycle ID
* @param {string|null} kandangId - Kandang ID (null for all kandangs)
* @returns {Promise<Object|null>} Insight object or null if not found or expired
*/
static async getByCycleAndKandang(cycleId, kandangId = null) {
try {
const result = await query(
`SELECT * FROM ai_insights
WHERE cycle_id = $1
AND ($2::text IS NULL AND kandang_id IS NULL OR kandang_id = $2)
AND expires_at > NOW()
ORDER BY generated_at DESC
LIMIT 1`,
[cycleId, kandangId]
);
if (result.rows.length === 0) return null;
return this.formatInsight(result.rows[0]);
} catch (error) {
console.error('Error getting AI insight:', error);
throw error;
}
}
/**
* Create or update AI insight
* @param {Object} insightData - Insight data
* @returns {Promise<Object>} Created/updated insight
*/
static async upsert(insightData) {
try {
const { cycleId, kandangId, insightText, version = 'v1' } = insightData;
// Calculate expiry date (6 hours from now)
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 6);
// Check if insight already exists
const existing = await this.getByCycleAndKandang(cycleId, kandangId);
if (existing) {
// Update existing
const result = await query(
`UPDATE ai_insights
SET insight_text = $1, version = $2, generated_at = NOW(), expires_at = $3, updated_at = NOW()
WHERE id = $4
RETURNING *`,
[insightText, version, expiresAt, existing.id]
);
return this.formatInsight(result.rows[0]);
} else {
// Create new
const result = await query(
`INSERT INTO ai_insights (cycle_id, kandang_id, insight_text, version, generated_at, expires_at)
VALUES ($1, $2, $3, $4, NOW(), $5)
RETURNING *`,
[cycleId, kandangId, insightText, version, expiresAt]
);
return this.formatInsight(result.rows[0]);
}
} catch (error) {
console.error('Error upserting AI insight:', error);
throw error;
}
}
/**
* Delete expired insights
* @returns {Promise<number>} Number of deleted rows
*/
static async cleanupExpired() {
try {
const result = await query(
'DELETE FROM ai_insights WHERE expires_at < NOW()',
[]
);
return result.rowCount;
} catch (error) {
console.error('Error cleaning up expired insights:', error);
throw error;
}
}
/**
* Format database row to API response format
* @param {Object} row - Database row
* @returns {Object} Formatted insight
*/
static formatInsight(row) {
return {
id: row.id,
cycleId: row.cycle_id,
kandangId: row.kandang_id,
insightText: row.insight_text,
version: row.version,
generatedAt: row.generated_at,
expiresAt: row.expires_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
}
module.exports = AiInsight;
+139
View File
@@ -0,0 +1,139 @@
const { query } = require('../database/db');
const { hashApiKey, generateApiKey } = require('../middleware/auth');
class ApiKey {
/**
* Create a new API key
* @param {object} keyData - Key data
* @returns {object} - Created key with plain key (shown once)
*/
static async create(keyData) {
try {
const { name, description, createdBy, expiresAt, rateLimit, allowedIps } = keyData;
const key = generateApiKey();
const keyHash = hashApiKey(key);
const id = 'apk_' + Date.now();
await query(`
INSERT INTO api_keys (
id, key_hash, name, description, created_by,
expires_at, rate_limit, allowed_ips
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, [
id,
keyHash,
name,
description || null,
createdBy || null,
expiresAt || null,
rateLimit || 1000,
allowedIps || null
]);
// Return the plain key ONCE (never stored)
return {
id,
key, // Plain key - show only once
name,
createdAt: new Date().toISOString()
};
} catch (error) {
console.error('Error creating API key:', error);
throw error;
}
}
/**
* Get all API keys (without plain keys)
* @returns {array} - All API keys
*/
static async getAll() {
try {
const result = await query(`
SELECT id, name, description, created_by, is_active,
created_at, last_used_at, expires_at, rate_limit
FROM api_keys
ORDER BY created_at DESC
`, []);
return result.rows.map(this.formatRecord);
} catch (error) {
console.error('Error getting all API keys:', error);
throw error;
}
}
/**
* Get API key by ID
* @param {string} id - Key ID
* @returns {object|null} - API key or null
*/
static async getById(id) {
try {
const result = await query(`
SELECT id, name, description, created_by, is_active,
created_at, last_used_at, expires_at, rate_limit, allowed_ips
FROM api_keys
WHERE id = $1
`, [id]);
if (result.rows.length === 0) return null;
return this.formatRecord(result.rows[0]);
} catch (error) {
console.error('Error getting API key by ID:', error);
throw error;
}
}
/**
* Update API key status (activate/deactivate)
* @param {string} id - Key ID
* @param {boolean} isActive - Active status
* @returns {object|null} - Updated key or null
*/
static async updateStatus(id, isActive) {
try {
const result = await query('UPDATE api_keys SET is_active = $1 WHERE id = $2', [isActive ? 1 : 0, id]);
if (result.rowCount === 0) return null;
return this.getById(id);
} catch (error) {
console.error('Error updating API key status:', error);
throw error;
}
}
/**
* Delete an API key
* @param {string} id - Key ID
* @returns {boolean} - True if deleted
*/
static async delete(id) {
try {
const result = await query('DELETE FROM api_keys WHERE id = $1', [id]);
return result.rowCount > 0;
} catch (error) {
console.error('Error deleting API key:', error);
throw error;
}
}
/**
* Format database record to camelCase
* @param {object} record - Database record
* @returns {object} - Formatted record
*/
static formatRecord(record) {
return {
id: record.id,
name: record.name,
description: record.description,
createdBy: record.created_by,
isActive: Boolean(record.is_active),
createdAt: record.created_at,
lastUsedAt: record.last_used_at,
expiresAt: record.expires_at,
rateLimit: record.rate_limit,
allowedIps: record.allowed_ips
};
}
}
module.exports = ApiKey;
+127
View File
@@ -0,0 +1,127 @@
const { query } = require('../database/db');
class AuditLog {
/**
* Create a new audit log entry
* @param {string} tableName - Name of the table that was modified
* @param {string} recordId - ID of the record that was modified
* @param {string} action - Type of action: CREATE, UPDATE, DELETE
* @param {Object} oldValues - Previous values (null for CREATE)
* @param {Object} newValues - New values (null for DELETE)
* @param {string[]} changedFields - Array of field names that changed
* @returns {Promise<Object>} Created audit log entry
*/
static async create(tableName, recordId, action, oldValues, newValues, changedFields) {
const result = await query(
`INSERT INTO audit_logs (table_name, record_id, action, old_values, new_values, changed_fields)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[
tableName,
recordId.toString(),
action,
oldValues ? JSON.stringify(oldValues) : null,
newValues ? JSON.stringify(newValues) : null,
changedFields || []
]
);
return result.rows[0];
}
/**
* Get audit logs for a specific table
* @param {string} tableName - Name of the table
* @param {number} limit - Maximum number of logs to return (default: 50)
* @param {number} offset - Number of logs to skip (for pagination)
* @returns {Promise<Array>} Array of audit log entries
*/
static async getByTable(tableName, limit = 50, offset = 0) {
const result = await query(
`SELECT * FROM audit_logs
WHERE table_name = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3`,
[tableName, limit, offset]
);
return result.rows;
}
/**
* Get audit logs for a specific record
* @param {string} tableName - Name of the table
* @param {string} recordId - ID of the record
* @returns {Promise<Array>} Array of audit log entries for this record
*/
static async getByRecord(tableName, recordId) {
const result = await query(
`SELECT * FROM audit_logs
WHERE table_name = $1 AND record_id = $2
ORDER BY created_at DESC`,
[tableName, recordId.toString()]
);
return result.rows;
}
/**
* Get recent audit logs across all tables
* @param {number} limit - Maximum number of logs to return
* @returns {Promise<Array>} Array of recent audit log entries
*/
static async getRecent(limit = 50) {
const result = await query(
`SELECT * FROM audit_logs
ORDER BY created_at DESC
LIMIT $1`,
[limit]
);
return result.rows;
}
/**
* Get audit logs by date range
* @param {string} tableName - Name of the table (optional)
* @param {string} startDate - Start date (YYYY-MM-DD)
* @param {string} endDate - End date (YYYY-MM-DD)
* @param {number} limit - Maximum number of logs to return
* @returns {Promise<Array>} Array of audit log entries
*/
static async getByDateRange(tableName, startDate, endDate, limit = 50) {
let queryStr;
let params;
if (tableName) {
queryStr = `SELECT * FROM audit_logs
WHERE table_name = $1
AND created_at >= $2
AND created_at <= $3
ORDER BY created_at DESC
LIMIT $4`;
params = [tableName, startDate, endDate, limit];
} else {
queryStr = `SELECT * FROM audit_logs
WHERE created_at >= $1
AND created_at <= $2
ORDER BY created_at DESC
LIMIT $3`;
params = [startDate, endDate, limit];
}
const result = await query(queryStr, params);
return result.rows;
}
/**
* Get count of audit logs for a table
* @param {string} tableName - Name of the table
* @returns {Promise<number>} Count of audit logs
*/
static async getCount(tableName) {
const result = await query(
`SELECT COUNT(*) as count FROM audit_logs WHERE table_name = $1`,
[tableName]
);
return parseInt(result.rows[0].count, 10);
}
}
module.exports = AuditLog;
+132
View File
@@ -0,0 +1,132 @@
const { query } = require('../database/db');
class Camera {
/**
* Get all cameras
* @returns {array} - All cameras
*/
static async getAll() {
try {
const result = await query('SELECT * FROM cameras ORDER BY name', []);
return result.rows.map(this.formatRecord);
} catch (error) {
console.error('Error getting all cameras:', error);
throw error;
}
}
/**
* Get camera by ID
* @param {string} id - Camera ID
* @returns {object|null} - Camera or null
*/
static async getById(id) {
try {
const result = await query('SELECT * FROM cameras WHERE id = $1', [id]);
if (result.rows.length === 0) return null;
return this.formatRecord(result.rows[0]);
} catch (error) {
console.error('Error getting camera by ID:', error);
throw error;
}
}
/**
* Get cameras by floor
* @param {string} floorId - Floor ID
* @returns {array} - Cameras on floor
*/
static async getByFloor(floorId) {
try {
const result = await query('SELECT * FROM cameras WHERE floor_id = $1', [floorId]);
return result.rows.map(this.formatRecord);
} catch (error) {
console.error('Error getting cameras by floor:', error);
throw error;
}
}
/**
* Create a new camera
* @param {object} cameraData - Camera data
* @returns {object} - Created camera
*/
static async create(cameraData) {
try {
const { id, name, cameraType, rtspUrl, floorId, model, status } = cameraData;
await query(`
INSERT INTO cameras (id, name, camera_type, rtsp_url, floor_id, model, status)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`, [
id,
name,
cameraType,
rtspUrl || null,
floorId || null,
model || null,
status || 'offline'
]);
return this.getById(id);
} catch (error) {
console.error('Error creating camera:', error);
throw error;
}
}
/**
* Update camera status
* @param {string} id - Camera ID
* @param {string} status - New status (online, offline, error)
* @returns {object|null} - Updated camera or null
*/
static async updateStatus(id, status) {
try {
const result = await query(`
UPDATE cameras
SET status = $1, updated_at = CURRENT_TIMESTAMP
WHERE id = $2
`, [status, id]);
if (result.rowCount === 0) return null;
return this.getById(id);
} catch (error) {
console.error('Error updating camera status:', error);
throw error;
}
}
/**
* Delete a camera
* @param {string} id - Camera ID
* @returns {boolean} - True if deleted
*/
static async delete(id) {
try {
const result = await query('DELETE FROM cameras WHERE id = $1', [id]);
return result.rowCount > 0;
} catch (error) {
console.error('Error deleting camera:', error);
throw error;
}
}
/**
* Format database record to camelCase
* @param {object} record - Database record
* @returns {object} - Formatted record
*/
static formatRecord(record) {
return {
id: record.id,
name: record.name,
cameraType: record.camera_type,
rtspUrl: record.rtsp_url,
floorId: record.floor_id,
model: record.model,
status: record.status,
createdAt: record.created_at,
updatedAt: record.updated_at
};
}
}
module.exports = Camera;
+99
View File
@@ -0,0 +1,99 @@
const { query } = require('../database/db');
class ChickenCount {
static async upsert(date, kandangId, filename, totalCount) {
const result = await query(`
INSERT INTO chicken_counting (date, kandang_id, filename, total_count)
VALUES ($1, $2, $3, $4)
ON CONFLICT (date, kandang_id)
DO UPDATE SET
filename = EXCLUDED.filename,
total_count = EXCLUDED.total_count,
updated_at = NOW()
RETURNING *
`, [date, kandangId, filename, totalCount]);
// Fetch with kandang name
const record = await query(`
SELECT cc.*, k.name as kandang_name
FROM chicken_counting cc
JOIN kandangs k ON cc.kandang_id = k.id
WHERE cc.id = $1
`, [result.rows[0].id]);
return this.formatRecord(record.rows[0]);
}
static async insertIfNotExists(date, kandangId, filename, totalCount) {
// Check if record already exists
const existing = await query(`
SELECT cc.*, k.name as kandang_name
FROM chicken_counting cc
JOIN kandangs k ON cc.kandang_id = k.id
WHERE cc.date = $1 AND cc.kandang_id = $2
`, [date, kandangId]);
if (existing.rows.length > 0) {
// Record exists, return existing data without updating
return {
...this.formatRecord(existing.rows[0]),
skipped: true
};
}
// Record doesn't exist, insert new
const result = await query(`
INSERT INTO chicken_counting (date, kandang_id, filename, total_count)
VALUES ($1, $2, $3, $4)
RETURNING *
`, [date, kandangId, filename, totalCount]);
// Fetch with kandang name
const record = await query(`
SELECT cc.*, k.name as kandang_name
FROM chicken_counting cc
JOIN kandangs k ON cc.kandang_id = k.id
WHERE cc.id = $1
`, [result.rows[0].id]);
return {
...this.formatRecord(record.rows[0]),
skipped: false
};
}
static async getByDateRange(startDate, endDate, kandangId) {
let sql = `
SELECT cc.*, k.name as kandang_name
FROM chicken_counting cc
JOIN kandangs k ON cc.kandang_id = k.id
WHERE cc.date >= $1 AND cc.date <= $2
`;
const params = [startDate, endDate];
if (kandangId) {
sql += ` AND cc.kandang_id = $3`;
params.push(kandangId);
}
sql += ` ORDER BY cc.date ASC`;
const result = await query(sql, params);
return result.rows.map(row => this.formatRecord(row));
}
static formatRecord(record) {
return {
id: record.id,
date: record.date,
kandangId: record.kandang_id,
kandangName: record.kandang_name,
filename: record.filename,
totalCount: record.total_count,
createdAt: record.created_at,
updatedAt: record.updated_at
};
}
}
module.exports = ChickenCount;
+117
View File
@@ -0,0 +1,117 @@
const { query, toISODate, parseISODate } = require('../database/db');
class Cycle {
static async getAll() {
try {
const result = await query('SELECT * FROM cycles ORDER BY start_date DESC', []);
return result.rows.map(this.formatCycle);
} catch (error) {
console.error('Error getting all cycles:', error);
throw error;
}
}
static async getById(id) {
try {
const result = await query('SELECT * FROM cycles WHERE id = $1', [id]);
if (result.rows.length === 0) return null;
return this.formatCycle(result.rows[0]);
} catch (error) {
console.error('Error getting cycle by ID:', error);
throw error;
}
}
static async create(cycleData) {
try {
const { id, totalDays, currentDay, startDate, endDate, chickInWeight, docInCount, status } = cycleData;
await query('INSERT INTO cycles (id, total_days, current_day, start_date, end_date, chick_in_weight, doc_in_count, status) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)', [id, totalDays, currentDay, toISODate(startDate), toISODate(endDate), chickInWeight || null, docInCount || null, status]);
return this.getById(id);
} catch (error) {
console.error('Error creating cycle:', error);
throw error;
}
}
static async update(id, cycleData) {
try {
const { totalDays, currentDay, startDate, endDate, chickInWeight, docInCount, status } = cycleData;
const result = await query('UPDATE cycles SET total_days = $1, current_day = $2, start_date = $3, end_date = $4, chick_in_weight = $5, doc_in_count = $6, status = $7, updated_at = CURRENT_TIMESTAMP WHERE id = $8', [totalDays, currentDay, toISODate(startDate), toISODate(endDate), chickInWeight || null, docInCount || null, status, id]);
if (result.rowCount === 0) return null;
return this.getById(id);
} catch (error) {
console.error('Error updating cycle:', error);
throw error;
}
}
static async delete(id) {
try {
const result = await query('DELETE FROM cycles WHERE id = $1', [id]);
return result.rowCount > 0;
} catch (error) {
console.error('Error deleting cycle:', error);
throw error;
}
}
static async getActive() {
try {
const result = await query("SELECT * FROM cycles WHERE status = 'Active' LIMIT 1", []);
if (result.rows.length === 0) return null;
return this.formatCycle(result.rows[0]);
} catch (error) {
console.error('Error getting active cycle:', error);
throw error;
}
}
static async markAllActiveAsCompleted() {
try {
const result = await query(
"UPDATE cycles SET status = 'Completed', updated_at = CURRENT_TIMESTAMP WHERE status = 'Active'",
[]
);
return result;
} catch (error) {
console.error('Error marking active cycles as completed:', error);
throw error;
}
}
static formatCycle(cycle) {
// Calculate current day dynamically based on start_date
let calculatedCurrentDay = cycle.current_day;
if (cycle.status === 'Active' && cycle.start_date) {
const startDate = new Date(cycle.start_date);
const today = new Date();
// Reset time to midnight for accurate day calculation
startDate.setHours(0, 0, 0, 0);
today.setHours(0, 0, 0, 0);
// Calculate days difference
const diffMs = today - startDate;
const daysDiff = Math.floor(diffMs / (1000 * 60 * 60 * 24));
// Use calculated day, but don't exceed totalDays
calculatedCurrentDay = Math.min(daysDiff, cycle.total_days);
}
return {
id: cycle.id,
totalDays: cycle.total_days,
currentDay: calculatedCurrentDay, // Use dynamically calculated day for active cycles
startDate: cycle.start_date,
endDate: cycle.end_date,
chickInWeight: cycle.chick_in_weight,
docInCount: cycle.doc_in_count,
status: cycle.status,
createdAt: cycle.created_at,
updatedAt: cycle.updated_at
};
}
}
module.exports = Cycle;
+51
View File
@@ -0,0 +1,51 @@
const { query } = require('../database/db');
class CycleFeedInitialBalance {
/**
* Get initial balance for a specific cycle
* @param {string} cycleId - Cycle ID
* @returns {Promise<Object|null>} Initial balance record or null if not found
*/
static async getByCycleId(cycleId) {
const result = await query(
'SELECT * FROM cycle_feed_initial_balance WHERE cycle_id = $1',
[cycleId]
);
return result.rows[0] || null;
}
/**
* Insert or update initial balance for a cycle (upsert)
* @param {string} cycleId - Cycle ID
* @param {number} saldoAwal - Initial balance count (must be non-negative)
* @param {string} createdBy - Username of creator
* @returns {Promise<Object>} Created/updated initial balance record
*/
static async upsert(cycleId, saldoAwal, createdBy) {
const result = await query(
`INSERT INTO cycle_feed_initial_balance (cycle_id, saldo_awal, created_by)
VALUES ($1, $2, $3)
ON CONFLICT (cycle_id)
DO UPDATE SET
saldo_awal = $2,
updated_at = CURRENT_TIMESTAMP
RETURNING *`,
[cycleId, saldoAwal, createdBy]
);
return result.rows[0];
}
/**
* Delete initial balance for a cycle
* @param {string} cycleId - Cycle ID
* @returns {Promise<void>}
*/
static async delete(cycleId) {
await query(
'DELETE FROM cycle_feed_initial_balance WHERE cycle_id = $1',
[cycleId]
);
}
}
module.exports = CycleFeedInitialBalance;
+31
View File
@@ -0,0 +1,31 @@
const { query } = require('../database/db');
class FeedSackColumnConfig {
static async get() {
const result = await query('SELECT config FROM feed_sack_column_config ORDER BY id LIMIT 1');
if (result.rows.length === 0) return null;
return result.rows[0].config;
}
static async save(config) {
// Check if a row exists
const existing = await query('SELECT id FROM feed_sack_column_config LIMIT 1');
if (existing.rows.length === 0) {
// Insert
const result = await query(
'INSERT INTO feed_sack_column_config (config) VALUES ($1) RETURNING config',
[JSON.stringify(config)]
);
return result.rows[0].config;
} else {
// Update the existing row
const result = await query(
'UPDATE feed_sack_column_config SET config = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING config',
[JSON.stringify(config), existing.rows[0].id]
);
return result.rows[0].config;
}
}
}
module.exports = FeedSackColumnConfig;
+103
View File
@@ -0,0 +1,103 @@
const { query } = require('../database/db');
class Kandang {
static async getAll() {
const result = await query('SELECT * FROM kandangs ORDER BY id');
return result.rows.map(this.formatKandang);
}
static async getById(id) {
const result = await query('SELECT * FROM kandangs WHERE id = $1', [id]);
if (result.rows.length === 0) return null;
return this.formatKandang(result.rows[0]);
}
static async create(name) {
const result = await query(
'INSERT INTO kandangs (name) VALUES ($1) RETURNING *',
[name]
);
return this.formatKandang(result.rows[0]);
}
static async update(id, name) {
const result = await query(
'UPDATE kandangs SET name = $1 WHERE id = $2 RETURNING *',
[name, id]
);
if (result.rows.length === 0) return null;
return this.formatKandang(result.rows[0]);
}
static async delete(id) {
const result = await query('DELETE FROM kandangs WHERE id = $1', [id]);
return result.rowCount > 0;
}
// Get kandangs with their doc_in_count for a specific cycle
static async getByCycle(cycleId) {
const result = await query(`
SELECT k.*, kc.doc_in_count
FROM kandangs k
LEFT JOIN kandang_cycles kc ON k.id = kc.kandang_id AND kc.cycle_id = $1
ORDER BY k.id
`, [cycleId]);
return result.rows.map(row => ({
...this.formatKandang(row),
docInCount: parseInt(row.doc_in_count) || 0
}));
}
// Set doc_in_count for a kandang in a specific cycle (upsert)
static async setDocInCount(kandangId, cycleId, docInCount) {
await query(`
INSERT INTO kandang_cycles (kandang_id, cycle_id, doc_in_count)
VALUES ($1, $2, $3)
ON CONFLICT (kandang_id, cycle_id)
DO UPDATE SET doc_in_count = EXCLUDED.doc_in_count
`, [kandangId, cycleId, docInCount]);
}
// Copy DOC counts from one cycle to another (useful when starting new cycle)
static async copyDocCountsToCycle(fromCycleId, toCycleId) {
await query(`
INSERT INTO kandang_cycles (kandang_id, cycle_id, doc_in_count)
SELECT kandang_id, $2, doc_in_count
FROM kandang_cycles
WHERE cycle_id = $1
ON CONFLICT (kandang_id, cycle_id) DO NOTHING
`, [fromCycleId, toCycleId]);
}
// Get kandang by cage UUID
static async getByCageUuid(cageUuid) {
const result = await query(
'SELECT * FROM kandangs WHERE cage_uuid = $1',
[cageUuid]
);
if (result.rows.length === 0) return null;
return this.formatKandang(result.rows[0]);
}
// Update cage UUID for a kandang
static async updateCageUuid(id, cageUuid) {
const result = await query(
'UPDATE kandangs SET cage_uuid = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING *',
[cageUuid, id]
);
if (result.rows.length === 0) return null;
return this.formatKandang(result.rows[0]);
}
static formatKandang(record) {
return {
id: record.id,
name: record.name,
cageUuid: record.cage_uuid || null,
createdAt: record.created_at,
updatedAt: record.updated_at
};
}
}
module.exports = Kandang;
+70
View File
@@ -0,0 +1,70 @@
const { query } = require('../database/db');
class ManualFeedSack {
/**
* Get all manual entries for a date range
* @param {string} startDate - Start date (YYYY-MM-DD)
* @param {string} endDate - End date (YYYY-MM-DD)
* @returns {Promise<Array>} Array of manual feed sack entries
*/
static async getByDateRange(startDate, endDate) {
const result = await query(
'SELECT * FROM manual_feed_sack_entries WHERE date >= $1 AND date <= $2 ORDER BY date ASC',
[startDate, endDate]
);
return result.rows;
}
/**
* Get manual entry for specific date and camera
* @param {string} date - Date (YYYY-MM-DD)
* @param {string} cameraName - Camera name
* @returns {Promise<Object|null>} Manual entry or null if not found
*/
static async getByDateAndCamera(date, cameraName) {
const result = await query(
'SELECT * FROM manual_feed_sack_entries WHERE date = $1 AND camera_name = $2',
[date, cameraName]
);
return result.rows[0] || null;
}
/**
* Insert or update manual entry (upsert)
* @param {string} date - Date (YYYY-MM-DD)
* @param {string} cameraName - Camera name
* @param {number} count - Sack count
* @param {string} createdBy - Username of creator
* @param {string} keterangan - Optional notes/remarks (max 200 chars)
* @returns {Promise<Object>} Created/updated entry
*/
static async upsert(date, cameraName, count, createdBy, keterangan = null) {
const result = await query(
`INSERT INTO manual_feed_sack_entries (date, camera_name, count, created_by, keterangan)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (date, camera_name)
DO UPDATE SET
count = $3,
keterangan = $5,
updated_at = CURRENT_TIMESTAMP
RETURNING *`,
[date, cameraName, count, createdBy, keterangan]
);
return result.rows[0];
}
/**
* Delete manual entry
* @param {string} date - Date (YYYY-MM-DD)
* @param {string} cameraName - Camera name
* @returns {Promise<void>}
*/
static async delete(date, cameraName) {
await query(
'DELETE FROM manual_feed_sack_entries WHERE date = $1 AND camera_name = $2',
[date, cameraName]
);
}
}
module.exports = ManualFeedSack;
+143
View File
@@ -0,0 +1,143 @@
const { query } = require('../database/db');
class Mortality {
static async getByCycle(cycleId, kandangId = null) {
let sql = 'SELECT * FROM mortality_records WHERE cycle_id = $1';
const params = [cycleId];
if (kandangId !== null && kandangId !== undefined) {
sql += ' AND kandang_id = $2';
params.push(kandangId);
}
sql += ' ORDER BY day';
const result = await query(sql, params);
return result.rows.map(this.formatRecord);
}
static async getByDay(cycleId, day, kandangId = null) {
let sql = 'SELECT * FROM mortality_records WHERE cycle_id = $1 AND day = $2';
const params = [cycleId, day];
if (kandangId !== null && kandangId !== undefined) {
sql += ' AND kandang_id = $3';
params.push(kandangId);
} else {
sql += ' AND kandang_id IS NULL';
}
const result = await query(sql, params);
if (result.rows.length === 0) return null;
return this.formatRecord(result.rows[0]);
}
static async upsert(
cycleId,
day,
mortalityCount,
isEdited = true,
chickenCount = null,
kandangId = null,
panen = 0,
keterangan = null,
afkir = 0,
beratPanen = 0
) {
try {
const sql = `
INSERT INTO mortality_records (cycle_id, day, mortality_count, chicken_count, kandang_id, is_edited, afkir, panen, berat_panen, keterangan, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, CURRENT_TIMESTAMP)
ON CONFLICT (cycle_id, day, COALESCE(kandang_id, -1))
DO UPDATE SET mortality_count = EXCLUDED.mortality_count,
chicken_count = EXCLUDED.chicken_count,
is_edited = EXCLUDED.is_edited,
afkir = EXCLUDED.afkir,
panen = EXCLUDED.panen,
berat_panen = EXCLUDED.berat_panen,
keterangan = EXCLUDED.keterangan,
updated_at = CURRENT_TIMESTAMP
`;
await query(sql, [
cycleId,
day,
mortalityCount,
chickenCount,
kandangId,
isEdited ? 1 : 0,
afkir,
panen,
beratPanen,
keterangan
]);
return this.getByDay(cycleId, day, kandangId);
} catch (error) {
console.error('Error upserting mortality record:', error);
throw error;
}
}
static async delete(cycleId, day, kandangId = null) {
try {
let sql = 'DELETE FROM mortality_records WHERE cycle_id = $1 AND day = $2';
const params = [cycleId, day];
if (kandangId !== null && kandangId !== undefined) {
sql += ' AND kandang_id = $3';
params.push(kandangId);
} else {
sql += ' AND kandang_id IS NULL';
}
const result = await query(sql, params);
return result.rowCount > 0;
} catch (error) {
console.error('Error deleting mortality record:', error);
throw error;
}
}
// Get totals aggregated across all kandangs for a cycle
static async getTotalsByCycle(cycleId) {
const result = await query(`
SELECT day,
SUM(mortality_count) as total_mortality,
SUM(afkir) as total_afkir,
SUM(chicken_count) as total_chicken_count,
SUM(panen) as total_panen,
SUM(berat_panen) as total_berat_panen
FROM mortality_records
WHERE cycle_id = $1
GROUP BY day
ORDER BY day
`, [cycleId]);
return result.rows.map(row => ({
day: row.day,
mortalityCount: parseInt(row.total_mortality) || 0,
afkir: parseInt(row.total_afkir) || 0,
chickenCount: row.total_chicken_count ? parseInt(row.total_chicken_count) : null,
panen: parseInt(row.total_panen) || 0,
beratPanen: row.total_berat_panen ? parseFloat(row.total_berat_panen) : 0
}));
}
static formatRecord(record) {
return {
id: record.id,
cycleId: record.cycle_id,
day: record.day,
mortalityCount: record.mortality_count,
chickenCount: record.chicken_count,
population: record.population,
kandangId: record.kandang_id,
isEdited: Boolean(record.is_edited),
afkir: record.afkir || 0,
panen: record.panen || 0,
beratPanen: record.berat_panen ? parseFloat(record.berat_panen) : 0,
keterangan: record.keterangan || '',
createdAt: record.created_at,
updatedAt: record.updated_at
};
}
}
module.exports = Mortality;
+239
View File
@@ -0,0 +1,239 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Cycle from '../Cycle.js';
import { query } from '../../database/db.js';
describe('Cycle Model', () => {
// Clean up test data before and after each test
beforeEach(async () => {
await query('DELETE FROM cycles WHERE id LIKE $1', ['TEST-%']);
});
afterEach(async () => {
await query('DELETE FROM cycles WHERE id LIKE $1', ['TEST-%']);
});
describe('create', () => {
it('should create a new cycle with all fields', async () => {
const cycleData = {
id: 'TEST-CYCLE-001',
totalDays: 35,
currentDay: 1,
startDate: '2026-04-01',
chickInWeight: 40,
docInCount: 10000,
status: 'Active',
};
const cycle = await Cycle.create(cycleData);
expect(cycle).toBeDefined();
expect(cycle.id).toBe('TEST-CYCLE-001');
expect(cycle.totalDays).toBe(35);
expect(cycle.currentDay).toBeGreaterThanOrEqual(1);
expect(cycle.docInCount).toBe(10000);
expect(cycle.status).toBe('Active');
});
it('should create a cycle with optional fields as null', async () => {
const cycleData = {
id: 'TEST-CYCLE-002',
totalDays: 35,
currentDay: 0,
startDate: '2026-05-01',
status: 'Upcoming',
};
const cycle = await Cycle.create(cycleData);
expect(cycle).toBeDefined();
expect(cycle.id).toBe('TEST-CYCLE-002');
expect(cycle.chickInWeight).toBeNull();
expect(cycle.docInCount).toBeNull();
expect(cycle.endDate).toBeNull();
});
});
describe('getById', () => {
it('should retrieve a cycle by ID', async () => {
// Create test cycle
await Cycle.create({
id: 'TEST-CYCLE-003',
totalDays: 35,
currentDay: 5,
startDate: '2026-04-01',
status: 'Active',
});
const cycle = await Cycle.getById('TEST-CYCLE-003');
expect(cycle).toBeDefined();
expect(cycle.id).toBe('TEST-CYCLE-003');
expect(cycle.totalDays).toBe(35);
});
it('should return null for non-existent cycle', async () => {
const cycle = await Cycle.getById('DOES-NOT-EXIST');
expect(cycle).toBeNull();
});
});
describe('getAll', () => {
it('should retrieve all cycles ordered by start date', async () => {
// Create multiple test cycles
await Cycle.create({
id: 'TEST-CYCLE-004',
totalDays: 35,
currentDay: 1,
startDate: '2026-04-01',
status: 'Active',
});
await Cycle.create({
id: 'TEST-CYCLE-005',
totalDays: 35,
currentDay: 0,
startDate: '2026-05-01',
status: 'Upcoming',
});
const cycles = await Cycle.getAll();
const testCycles = cycles.filter((c) => c.id.startsWith('TEST-'));
expect(testCycles.length).toBeGreaterThanOrEqual(2);
// Verify ordering (most recent start date first)
const testCycle005 = testCycles.find((c) => c.id === 'TEST-CYCLE-005');
const testCycle004 = testCycles.find((c) => c.id === 'TEST-CYCLE-004');
expect(testCycles.indexOf(testCycle005)).toBeLessThan(testCycles.indexOf(testCycle004));
});
});
describe('update', () => {
it('should update an existing cycle', async () => {
// Create initial cycle
await Cycle.create({
id: 'TEST-CYCLE-006',
totalDays: 35,
currentDay: 5,
startDate: '2026-04-01',
status: 'Active',
docInCount: 10000,
});
// Update the cycle
const updated = await Cycle.update('TEST-CYCLE-006', {
totalDays: 40,
currentDay: 10,
startDate: '2026-04-01',
status: 'Active',
docInCount: 9500,
});
expect(updated).toBeDefined();
expect(updated.totalDays).toBe(40);
expect(updated.docInCount).toBe(9500);
});
it('should return null when updating non-existent cycle', async () => {
const result = await Cycle.update('DOES-NOT-EXIST', {
totalDays: 35,
currentDay: 1,
startDate: '2026-04-01',
status: 'Active',
});
expect(result).toBeNull();
});
});
describe('delete', () => {
it('should delete an existing cycle', async () => {
// Create cycle to delete
await Cycle.create({
id: 'TEST-CYCLE-007',
totalDays: 35,
currentDay: 1,
startDate: '2026-04-01',
status: 'Active',
});
const deleted = await Cycle.delete('TEST-CYCLE-007');
expect(deleted).toBe(true);
// Verify it's gone
const cycle = await Cycle.getById('TEST-CYCLE-007');
expect(cycle).toBeNull();
});
it('should return false when deleting non-existent cycle', async () => {
const deleted = await Cycle.delete('DOES-NOT-EXIST');
expect(deleted).toBe(false);
});
});
describe('getActive', () => {
it('should retrieve the active cycle', async () => {
// Create an active cycle
await Cycle.create({
id: 'TEST-CYCLE-008',
totalDays: 35,
currentDay: 10,
startDate: '2026-04-01',
status: 'Active',
});
const activeCycle = await Cycle.getActive();
// Should return either our test cycle or another active cycle
expect(activeCycle).toBeDefined();
expect(activeCycle.status).toBe('Active');
});
});
describe('formatCycle', () => {
it('should calculate current day dynamically for active cycles', () => {
const today = new Date();
const startDate = new Date(today);
startDate.setDate(startDate.getDate() - 10); // 10 days ago
const rawCycle = {
id: 'TEST-FORMAT',
total_days: 35,
current_day: 1,
start_date: startDate.toISOString().split('T')[0],
end_date: null,
chick_in_weight: 40,
doc_in_count: 10000,
status: 'Active',
created_at: new Date(),
updated_at: new Date(),
};
const formatted = Cycle.formatCycle(rawCycle);
expect(formatted.currentDay).toBe(10);
expect(formatted.id).toBe('TEST-FORMAT');
expect(formatted.status).toBe('Active');
});
it('should not calculate current day for completed cycles', () => {
const rawCycle = {
id: 'TEST-FORMAT-2',
total_days: 35,
current_day: 35,
start_date: '2026-03-01',
end_date: '2026-04-05',
chick_in_weight: 40,
doc_in_count: 10000,
status: 'Completed',
created_at: new Date(),
updated_at: new Date(),
};
const formatted = Cycle.formatCycle(rawCycle);
expect(formatted.currentDay).toBe(35);
expect(formatted.status).toBe('Completed');
});
});
});
+233
View File
@@ -0,0 +1,233 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Mortality from '../Mortality.js';
import Cycle from '../Cycle.js';
import { query } from '../../database/db.js';
describe('Mortality Model', () => {
const TEST_CYCLE_ID = 'TEST-MORTALITY-CYCLE';
beforeEach(async () => {
// Clean up test data
await query('DELETE FROM mortality_records WHERE cycle_id = $1', [TEST_CYCLE_ID]);
await query('DELETE FROM cycles WHERE id = $1', [TEST_CYCLE_ID]);
// Create test cycle
await Cycle.create({
id: TEST_CYCLE_ID,
totalDays: 35,
currentDay: 1,
startDate: '2026-04-01',
status: 'Active',
docInCount: 10000,
});
});
afterEach(async () => {
await query('DELETE FROM mortality_records WHERE cycle_id = $1', [TEST_CYCLE_ID]);
await query('DELETE FROM cycles WHERE id = $1', [TEST_CYCLE_ID]);
});
describe('upsert', () => {
it('should insert a new mortality record', async () => {
const record = await Mortality.upsert(TEST_CYCLE_ID, 1, 50, true, 9950);
expect(record).toBeDefined();
expect(record.cycleId).toBe(TEST_CYCLE_ID);
expect(record.day).toBe(1);
expect(record.mortalityCount).toBe(50);
expect(record.chickenCount).toBe(9950);
expect(record.isEdited).toBe(true);
});
it('should update an existing mortality record', async () => {
// Insert initial record
await Mortality.upsert(TEST_CYCLE_ID, 1, 50, true, 9950);
// Update the same record
const updated = await Mortality.upsert(TEST_CYCLE_ID, 1, 55, true, 9945);
expect(updated).toBeDefined();
expect(updated.mortalityCount).toBe(55);
expect(updated.chickenCount).toBe(9945);
});
it('should handle afkir and panen fields', async () => {
const record = await Mortality.upsert(
TEST_CYCLE_ID,
10,
20,
true,
9500,
null,
100,
'Culled due to disease',
30
);
expect(record).toBeDefined();
expect(record.afkir).toBe(30);
expect(record.panen).toBe(100);
expect(record.beratPanen).toBe(0);
expect(record.keterangan).toBe('Culled due to disease');
});
it('should persist harvest weight', async () => {
const record = await Mortality.upsert(
TEST_CYCLE_ID,
12,
15,
true,
9985,
null,
80,
'Harvest day',
5,
123.45
);
expect(record).toBeDefined();
expect(record.beratPanen).toBe(123.45);
});
});
describe('getByDay', () => {
it('should retrieve a mortality record by cycle and day', async () => {
await Mortality.upsert(TEST_CYCLE_ID, 5, 45, true, 9500);
const record = await Mortality.getByDay(TEST_CYCLE_ID, 5);
expect(record).toBeDefined();
expect(record.day).toBe(5);
expect(record.mortalityCount).toBe(45);
});
it('should return null for non-existent record', async () => {
const record = await Mortality.getByDay(TEST_CYCLE_ID, 999);
expect(record).toBeNull();
});
it('should handle kandang-specific records', async () => {
// Create records for different kandangs
await Mortality.upsert(TEST_CYCLE_ID, 1, 20, true, 4980, 1);
await Mortality.upsert(TEST_CYCLE_ID, 1, 30, true, 4970, 2);
const recordKandang1 = await Mortality.getByDay(TEST_CYCLE_ID, 1, 1);
const recordKandang2 = await Mortality.getByDay(TEST_CYCLE_ID, 1, 2);
expect(recordKandang1.mortalityCount).toBe(20);
expect(recordKandang2.mortalityCount).toBe(30);
expect(recordKandang1.kandangId).toBe(1);
expect(recordKandang2.kandangId).toBe(2);
});
});
describe('getByCycle', () => {
it('should retrieve all mortality records for a cycle', async () => {
// Create multiple records
await Mortality.upsert(TEST_CYCLE_ID, 1, 50, true, 9950);
await Mortality.upsert(TEST_CYCLE_ID, 2, 45, true, 9905);
await Mortality.upsert(TEST_CYCLE_ID, 3, 40, true, 9865);
const records = await Mortality.getByCycle(TEST_CYCLE_ID);
expect(records).toHaveLength(3);
expect(records[0].day).toBe(1);
expect(records[1].day).toBe(2);
expect(records[2].day).toBe(3);
});
it('should filter by kandang when provided', async () => {
await Mortality.upsert(TEST_CYCLE_ID, 1, 20, true, 4980, 1);
await Mortality.upsert(TEST_CYCLE_ID, 2, 25, true, 4955, 1);
await Mortality.upsert(TEST_CYCLE_ID, 1, 30, true, 4970, 2);
const recordsKandang1 = await Mortality.getByCycle(TEST_CYCLE_ID, 1);
const recordsKandang2 = await Mortality.getByCycle(TEST_CYCLE_ID, 2);
expect(recordsKandang1).toHaveLength(2);
expect(recordsKandang2).toHaveLength(1);
expect(recordsKandang1.every((r) => r.kandangId === 1)).toBe(true);
expect(recordsKandang2.every((r) => r.kandangId === 2)).toBe(true);
});
});
describe('getTotalsByCycle', () => {
it('should aggregate mati, afkir, and panen across kandangs', async () => {
await Mortality.upsert(TEST_CYCLE_ID, 1, 20, true, 4980, 1, 5, 'Kandang 1', 3, 11.25);
await Mortality.upsert(TEST_CYCLE_ID, 1, 30, true, 4970, 2, 7, 'Kandang 2', 4, 8.75);
const totals = await Mortality.getTotalsByCycle(TEST_CYCLE_ID);
expect(totals).toHaveLength(1);
expect(totals[0].day).toBe(1);
expect(totals[0].mortalityCount).toBe(50);
expect(totals[0].afkir).toBe(7);
expect(totals[0].panen).toBe(12);
expect(totals[0].beratPanen).toBe(20);
});
});
describe('delete', () => {
it('should delete a mortality record', async () => {
await Mortality.upsert(TEST_CYCLE_ID, 10, 50, true, 9500);
const deleted = await Mortality.delete(TEST_CYCLE_ID, 10);
expect(deleted).toBe(true);
const record = await Mortality.getByDay(TEST_CYCLE_ID, 10);
expect(record).toBeNull();
});
it('should return false when deleting non-existent record', async () => {
const deleted = await Mortality.delete(TEST_CYCLE_ID, 999);
expect(deleted).toBe(false);
});
it('should delete kandang-specific records', async () => {
await Mortality.upsert(TEST_CYCLE_ID, 1, 20, true, 4980, 1);
await Mortality.upsert(TEST_CYCLE_ID, 1, 30, true, 4970, 2);
const deleted = await Mortality.delete(TEST_CYCLE_ID, 1, 1);
expect(deleted).toBe(true);
// Verify only kandang 1 was deleted
const record1 = await Mortality.getByDay(TEST_CYCLE_ID, 1, 1);
const record2 = await Mortality.getByDay(TEST_CYCLE_ID, 1, 2);
expect(record1).toBeNull();
expect(record2).toBeDefined();
});
});
describe('formatRecord', () => {
it('should format database record to camelCase', () => {
const dbRecord = {
id: 1,
cycle_id: TEST_CYCLE_ID,
day: 5,
mortality_count: 50,
chicken_count: 9500,
population: 9500,
kandang_id: 1,
is_edited: true,
afkir: 10,
panen: 100,
berat_panen: 12.5,
keterangan: 'Test note',
created_at: new Date(),
updated_at: new Date(),
};
const formatted = Mortality.formatRecord(dbRecord);
expect(formatted.cycleId).toBe(TEST_CYCLE_ID);
expect(formatted.mortalityCount).toBe(50);
expect(formatted.chickenCount).toBe(9500);
expect(formatted.kandangId).toBe(1);
expect(formatted.isEdited).toBe(true);
expect(formatted.afkir).toBe(10);
expect(formatted.panen).toBe(100);
expect(formatted.beratPanen).toBe(12.5);
expect(formatted.keterangan).toBe('Test note');
});
});
});
+2358
View File
File diff suppressed because it is too large. Load diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "chicken-farm-backend",
"version": "2.0.0",
"description": "Backend API for chicken farm dashboard with PostgreSQL database",
"main": "server.js",
"scripts": {
"dev": "node server.js",
"seed": "node database/seed-postgres.js",
"start": "node server.js",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"test:run": "vitest run"
},
"keywords": [
"postgresql",
"express",
"chicken-farm"
],
"author": "",
"license": "ISC",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"pg": "^8.13.1"
},
"devDependencies": {
"@vitest/ui": "^4.1.5",
"vitest": "^4.1.5"
}
}
+299
View File
@@ -0,0 +1,299 @@
const express = require('express');
const router = express.Router();
const AiInsight = require('../models/AiInsight.js');
const {
generateDashboardInsight,
INSIGHT_VERSION,
} = require('../services/dashboardInsightService.js');
// GET /api/ai-insights/:cycleId
// Optional query params: kandangId
router.get('/:cycleId', async (req, res) => {
try {
const { cycleId } = req.params;
const { kandangId } = req.query;
// Find the most recent non-expired insight
const insight = await AiInsight.getByCycleAndKandang(cycleId, kandangId || null);
if (!insight) {
return res.status(404).json({
success: false,
message: 'No insight found or insight expired',
});
}
res.json({
success: true,
data: insight,
});
} catch (error) {
console.error('Error fetching AI insight:', error);
res.status(500).json({
success: false,
error: error.message,
});
}
});
// POST /api/ai-insights
// Body: { cycleId, kandangId, insightText, version }
router.post('/', async (req, res) => {
try {
const { cycleId, kandangId, insightText, version = 'v1' } = req.body;
if (!cycleId || !insightText) {
return res.status(400).json({
success: false,
error: 'cycleId and insightText are required',
});
}
// Upsert (create or update)
const insight = await AiInsight.upsert({
cycleId,
kandangId: kandangId || null,
insightText,
version,
});
res.json({
success: true,
data: insight,
});
} catch (error) {
console.error('Error saving AI insight:', error);
res.status(500).json({
success: false,
error: error.message,
});
}
});
// DELETE /api/ai-insights/cleanup
// Cleanup expired insights (can be called by a cron job)
router.delete('/cleanup', async (req, res) => {
try {
const deleted = await AiInsight.cleanupExpired();
res.json({
success: true,
deleted,
});
} catch (error) {
console.error('Error cleaning up insights:', error);
res.status(500).json({
success: false,
error: error.message,
});
}
});
// POST /api/ai-insights/dashboard
// Body: { cycleId, kandangId, contextPack, version, forceRefresh }
router.post('/dashboard', async (req, res) => {
try {
const {
cycleId,
kandangId = null,
contextPack,
version = INSIGHT_VERSION,
forceRefresh = false,
} = req.body;
if (!cycleId || !contextPack) {
return res.status(400).json({
success: false,
error: 'cycleId and contextPack are required',
});
}
const result = await generateDashboardInsight({
cycleId,
kandangId: kandangId || null,
contextPack,
version,
forceRefresh: Boolean(forceRefresh),
});
res.json({
success: true,
source: result.source,
data: result.insight,
});
} catch (error) {
console.error('Error generating AI dashboard insight:', error);
res.status(500).json({
success: false,
error: error.message,
});
}
});
// ─── RAG: Fetch relevant CP707 chunks from Python RAG service ────────────────
const RAG_SERVICE_URL = process.env.RAG_SERVICE_URL || 'http://localhost:5002';
async function fetchCp707Chunks(query, topic, nResults = 4) {
try {
const response = await fetch(`${RAG_SERVICE_URL}/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, topic, n_results: nResults }),
signal: AbortSignal.timeout(5000) // max 5 detik untuk RAG query
});
if (!response.ok) return [];
const json = await response.json();
return json.chunks || [];
} catch (err) {
// RAG service tidak wajib — graceful fallback jika tidak aktif
console.warn('[RAG] Service tidak tersedia, lanjut tanpa RAG context:', err.message);
return [];
}
}
// POST /api/ai-insights/proxy
// Proxies chat requests to LM Studio, auto-detecting the loaded model
router.post('/proxy', async (req, res) => {
try {
let { input, system_prompt, temperature = 0.2, topic = '', context_query = '', contextData = null } = req.body;
let responseText = '';
let success = false;
// Format contextData cleanly as JSON if present
if (contextData && typeof contextData === 'object') {
const dataStr = JSON.stringify(contextData, null, 2);
// Append formatted context data to user input if not already present
if (!input.includes(dataStr)) {
input = `${input}\n\n[Data Halaman (JSON)]:\n${dataStr}`;
}
}
// 1. Ambil chunk CP707 yang relevan dari RAG service
const ragQuery = context_query || input.slice(0, 300); // gunakan konteks query atau 300 char pertama dari input
const cp707Chunks = await fetchCp707Chunks(ragQuery, topic, 4);
// 2. Inject CP707 chunks ke dalam system_prompt
let enrichedSystemPrompt = system_prompt || 'You are a helpful assistant.';
if (cp707Chunks.length > 0) {
const chunksText = cp707Chunks
.map((chunk, i) => `[Referensi CP707 #${i + 1}]\n${chunk}`)
.join('\n\n');
enrichedSystemPrompt = `${enrichedSystemPrompt}
═══════════════════════════════════════════════
REFERENSI BUKU: Manajemen Broiler CP 707
(PT Charoen Pokphand Indonesia, Tbk - Edisi Juli 2023)
GUNAKAN informasi ini sebagai acuan utama analisis Anda.
═══════════════════════════════════════════════
${chunksText}
═══════════════════════════════════════════════`;
console.log(`[RAG] Injected ${cp707Chunks.length} CP707 chunks into system_prompt`);
}
let isOllama = false;
// 3. Try LM Studio / Ollama
try {
const lmStudioBaseUrl = process.env.LM_STUDIO_BASE_URL || process.env.OLLAMA_BASE_URL ||
(process.env.DOCKER_ENV === 'true' ? 'http://host.docker.internal:11434' : 'http://127.0.0.1:11434');
isOllama = lmStudioBaseUrl.includes('11434') || lmStudioBaseUrl.includes('llm');
let modelName = process.env.LLM_MODEL_NAME || 'deepseek-r1:8b';
// Only auto-detect if LLM_MODEL_NAME is not explicitly configured
if (!process.env.LLM_MODEL_NAME) {
try {
const modelsResponse = await fetch(`${lmStudioBaseUrl}/api/v1/models`, { signal: AbortSignal.timeout(2000) });
if (modelsResponse.ok) {
const modelsJson = await modelsResponse.json();
if (modelsJson && modelsJson.data && modelsJson.data.length > 0) {
modelName = modelsJson.data[0].id;
} else if (modelsJson && Array.isArray(modelsJson.models) && modelsJson.models.length > 0) {
modelName = modelsJson.models[0].key;
}
}
} catch (err) {
console.warn('[LM Studio Proxy] Failed to fetch loaded models:', err.message);
}
}
const endpoint = isOllama ? '/api/chat' : '/v1/chat/completions';
const payload = isOllama ? {
model: modelName,
messages: [
{ role: 'system', content: enrichedSystemPrompt },
{ role: 'user', content: input }
],
stream: false,
options: {
num_ctx: 8192,
num_predict: 8000,
temperature
}
} : {
model: modelName,
messages: [
{ role: 'system', content: enrichedSystemPrompt },
{ role: 'user', content: input }
],
temperature,
max_tokens: 8000
};
const response = await fetch(`${lmStudioBaseUrl}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(540000)
});
if (response.ok) {
const json = await response.json();
const msg = isOllama ? json.message : json.choices?.[0]?.message;
const finishReason = isOllama ? json.done_reason : json.choices?.[0]?.finish_reason;
console.log(`[LM Studio Proxy] finish_reason=${finishReason}, content_len=${msg?.content?.length || 0}`);
if (msg?.content && typeof msg.content === 'string' && msg.content.trim()) {
responseText = msg.content.trim();
} else if (msg?.reasoning_content && typeof msg.reasoning_content === 'string' && msg.reasoning_content.trim()) {
responseText = msg.reasoning_content.trim();
console.log('[LM Studio Proxy] content empty, using reasoning_content as fallback');
} else if (!isOllama && json.content && typeof json.content === 'string') {
responseText = json.content;
} else if (!isOllama && json.response) {
responseText = json.response;
} else if (!isOllama && Array.isArray(json.output)) {
const parts = json.output
.map(item => (typeof item.content === 'string' ? item.content : ''))
.filter(Boolean);
responseText = parts.join('\n').trim();
}
success = !!responseText;
} else {
const errBody = await response.text().catch(() => '');
console.warn(`[LM Studio Proxy] Returned status ${response.status}: ${errBody.slice(0, 200)}`);
}
} catch (lmStudioError) {
console.error('[LM Studio Proxy] Failed/not running:', lmStudioError.message);
}
if (!success) {
const errMsg = isOllama ? 'Gagal menghubungi Ollama lokal. Pastikan Ollama aktif di port 11434.' : 'Gagal menghubungi LM Studio lokal. Pastikan server LM Studio aktif di port 1234.';
throw new Error(errMsg);
}
const cleanedResponseText = responseText
.replace(/<think>[\s\S]*?<\/think>/g, '')
.replace(/```json\s*/gi, '')
.replace(/```\s*/g, '')
.trim();
res.json({ success: true, response: cleanedResponseText });
} catch (error) {
console.error('Error in proxy route:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
module.exports = router;
+144
View File
@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const express = require('express');
// Mock dependencies of backend/routes/aiInsights.js before loading it
vi.mock('../models/AiInsight.js', () => {
return {
getByCycleAndKandang: vi.fn(),
upsert: vi.fn(),
cleanupExpired: vi.fn(),
};
});
vi.mock('../services/dashboardInsightService.js', () => {
return {
generateDashboardInsight: vi.fn(),
INSIGHT_VERSION: 'v1',
};
});
// Mock database module so it doesn't attempt real connection during test
vi.mock('../database/db', () => ({}));
// Import express router
const router = require('./aiInsights.js');
describe('AI Insights Proxy Route', () => {
let app;
beforeEach(() => {
vi.resetAllMocks();
app = express();
app.use(express.json());
app.use('/api/ai-insights', router);
});
it('should accept contextData and send it to LM Studio proxy with CP707 chunks', async () => {
// 1. Mock fetch globally
const mockResponseText = JSON.stringify({
choices: [
{
message: {
content: JSON.stringify({
headline: 'Analisis Test FCR',
status: 'ok',
summary: ['FCR normal'],
risks: [],
actions: ['Pertahankan'],
}),
},
finish_reason: 'stop',
},
],
});
const mockFetch = vi.fn().mockImplementation((url, options) => {
// Mock /api/v1/models (checking loaded models)
if (url.includes('/api/v1/models')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ data: [{ id: 'mock-model' }] }),
});
}
// Mock RAG Service on port 5002
if (url.includes(':5002/query')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
chunks: ['Ini adalah chunk standar FCR broiler CP 707 dari buku.'],
sources: ['page 1'],
}),
});
}
// Mock LM Studio chat completions
if (url.includes('/v1/chat/completions')) {
const body = JSON.parse(options.body);
// Assert that body.messages contains system prompt with RAG chunks
expect(body.messages[0].role).toBe('system');
expect(body.messages[0].content).toContain('REFERENSI BUKU: Manajemen Broiler CP 707');
expect(body.messages[0].content).toContain('Ini adalah chunk standar FCR broiler CP 707 dari buku.');
// Assert that body.messages contains user message with contextData
expect(body.messages[1].role).toBe('user');
expect(body.messages[1].content).toContain('"FCR_aktual": 1.25');
return Promise.resolve({
ok: true,
json: () => Promise.resolve(JSON.parse(mockResponseText)),
});
}
return Promise.reject(new Error(`Unhandled fetch url: ${url}`));
});
global.fetch = mockFetch;
// Simulate calling the endpoint using standard express request-response handling
const req = {
method: 'POST',
url: '/api/ai-insights/proxy',
headers: { 'content-type': 'application/json' },
body: {
topic: 'fcr',
context_query: 'FCR standar broiler CP707',
input: 'Data: FCR_aktual 1.25',
contextData: { FCR_aktual: 1.25 },
system_prompt: 'Anda adalah analis CP707',
},
};
// We can run a small integration check by calling the router directly
let resObj;
await new Promise((resolve) => {
const res = {
statusCode: 200,
headers: {},
setHeader(name, val) {
this.headers[name.toLowerCase()] = val;
},
status(code) {
this.statusCode = code;
return this;
},
json(data) {
this.body = data;
resObj = this;
resolve();
},
};
app(req, res, (err) => {
if (err) console.error('Router error:', err);
resObj = res;
resolve();
});
});
expect(resObj.statusCode).toBe(200);
expect(resObj.body.success).toBe(true);
expect(JSON.parse(resObj.body.response).headline).toBe('Analisis Test FCR');
});
});
+142
View File
@@ -0,0 +1,142 @@
const express = require('express');
const router = express.Router();
const ApiKey = require('../models/ApiKey');
// Note: In production, these routes should be protected by admin authentication
// For now, they are unprotected for initial setup
// POST /api/admin/api-keys - Create new API key
router.post('/', async (req, res) => {
try {
const { name, description, createdBy, expiresAt, rateLimit, allowedIps } = req.body;
if (!name) {
return res.status(400).json({
success: false,
error: 'name is required'
});
}
const apiKey = await ApiKey.create({
name,
description,
createdBy,
expiresAt,
rateLimit,
allowedIps
});
res.status(201).json({
success: true,
data: apiKey,
warning: 'Save this key securely. It will not be shown again.'
});
} catch (error) {
console.error('Error creating API key:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// GET /api/admin/api-keys - List all API keys
router.get('/', async (req, res) => {
try {
const keys = await ApiKey.getAll();
res.json({
success: true,
data: keys
});
} catch (error) {
console.error('Error getting API keys:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// GET /api/admin/api-keys/:id - Get specific API key
router.get('/:id', async (req, res) => {
try {
const key = await ApiKey.getById(req.params.id);
if (!key) {
return res.status(404).json({
success: false,
error: 'API key not found'
});
}
res.json({
success: true,
data: key
});
} catch (error) {
console.error('Error getting API key:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// PUT /api/admin/api-keys/:id - Update API key status
router.put('/:id', async (req, res) => {
try {
const { isActive } = req.body;
if (isActive === undefined) {
return res.status(400).json({
success: false,
error: 'isActive is required'
});
}
const key = await ApiKey.updateStatus(req.params.id, isActive);
if (!key) {
return res.status(404).json({
success: false,
error: 'API key not found'
});
}
res.json({
success: true,
data: key
});
} catch (error) {
console.error('Error updating API key:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// DELETE /api/admin/api-keys/:id - Delete API key
router.delete('/:id', async (req, res) => {
try {
const deleted = await ApiKey.delete(req.params.id);
if (!deleted) {
return res.status(404).json({
success: false,
error: 'API key not found'
});
}
res.json({
success: true,
message: 'API key deleted successfully'
});
} catch (error) {
console.error('Error deleting API key:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
module.exports = router;
+107
View File
@@ -0,0 +1,107 @@
const express = require('express');
const router = express.Router();
const AuditLog = require('../models/AuditLog');
/**
* GET /api/audit-logs
* Get audit logs with optional filtering
*
* Query parameters:
* - table: Filter by table name (e.g., 'mortality_records')
* - recordId: Filter by specific record ID
* - startDate: Filter by start date (YYYY-MM-DD)
* - endDate: Filter by end date (YYYY-MM-DD)
* - limit: Maximum number of logs to return (default: 50)
* - offset: Number of logs to skip for pagination (default: 0)
*/
router.get('/', async (req, res) => {
try {
const { table, recordId, startDate, endDate, limit = 50, offset = 0 } = req.query;
let logs;
// Filter by specific record
if (table && recordId) {
logs = await AuditLog.getByRecord(table, recordId);
}
// Filter by date range
else if (startDate && endDate) {
logs = await AuditLog.getByDateRange(
table || null,
startDate,
endDate,
parseInt(limit, 10)
);
}
// Filter by table
else if (table) {
logs = await AuditLog.getByTable(
table,
parseInt(limit, 10),
parseInt(offset, 10)
);
}
// Get recent logs across all tables
else {
logs = await AuditLog.getRecent(parseInt(limit, 10));
}
// Get total count for pagination
let totalCount = logs.length;
if (table && !recordId) {
totalCount = await AuditLog.getCount(table);
}
res.json({
success: true,
data: logs,
pagination: {
total: totalCount,
limit: parseInt(limit, 10),
offset: parseInt(offset, 10),
hasMore: totalCount > (parseInt(offset, 10) + logs.length)
}
});
} catch (error) {
console.error('Error fetching audit logs:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
/**
* GET /api/audit-logs/count
* Get count of audit logs for a table
*
* Query parameters:
* - table: Table name (required)
*/
router.get('/count', async (req, res) => {
try {
const { table } = req.query;
if (!table) {
return res.status(400).json({
success: false,
error: 'table parameter is required'
});
}
const count = await AuditLog.getCount(table);
res.json({
success: true,
count
});
} catch (error) {
console.error('Error counting audit logs:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
module.exports = router;
+250
View File
@@ -0,0 +1,250 @@
const express = require('express');
const router = express.Router();
const ChickenCount = require('../models/ChickenCount');
const Kandang = require('../models/Kandang');
const { validateApiKey } = require('../middleware/auth');
const {
isBeforeCutoffTime,
getPreviousDate,
createResponseMetadata,
} = require('../utils/dateUtils');
/**
* Normalize a kandang name to a slug: "Kandang Atas" -> "kandang-atas"
*/
function nameToSlug(name) {
return name.toLowerCase().replace(/\s+/g, '-');
}
// POST /api/chicken-counting (requires API key)
router.post('/', validateApiKey, async (req, res) => {
const timestamp = new Date().toISOString();
try {
// Check if body is empty or not JSON
if (!req.body || Object.keys(req.body).length === 0) {
return res.status(400).json({
success: false,
error:
'Request body is empty or not valid JSON. Make sure to set Content-Type: application/json',
});
}
const { date, kandang, filename, total_count } = req.body;
// Check missing fields individually for clear error messages
const missingFields = [];
if (!date) missingFields.push('date');
if (!kandang) missingFields.push('kandang');
if (total_count === undefined || total_count === null) missingFields.push('total_count');
if (missingFields.length > 0) {
return res.status(400).json({
success: false,
error: `Missing required fields: ${missingFields.join(', ')}`,
details: {
missing: missingFields,
received: {
date: date || null,
kandang: kandang || null,
total_count: total_count !== undefined ? total_count : null,
filename: filename || null,
},
},
});
}
// Validate date format
if (typeof date !== 'string') {
return res.status(400).json({
success: false,
error: `Invalid date type. Expected string, got ${typeof date}`,
details: { received: date, expected: 'YYYY-MM-DD' },
});
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return res.status(400).json({
success: false,
error: `Invalid date format: "${date}". Use YYYY-MM-DD (e.g. "2026-03-01")`,
details: { received: date, expected: 'YYYY-MM-DD' },
});
}
// Validate date is a real date
const parsedDate = new Date(date + 'T00:00:00Z');
if (isNaN(parsedDate.getTime())) {
return res.status(400).json({
success: false,
error: `Invalid date value: "${date}". The date does not exist.`,
details: { received: date },
});
}
// Validate kandang type
if (typeof kandang !== 'string') {
return res.status(400).json({
success: false,
error: `Invalid kandang type. Expected string, got ${typeof kandang}`,
details: { received: kandang, expected: 'string (e.g. "kandang-atas")' },
});
}
// Validate total_count type and value
if (typeof total_count !== 'number') {
return res.status(400).json({
success: false,
error: `Invalid total_count type. Expected number, got ${typeof total_count}`,
details: { received: total_count, expected: 'number (e.g. 5612)' },
});
}
if (!Number.isInteger(total_count)) {
return res.status(400).json({
success: false,
error: `total_count must be an integer, got ${total_count}`,
details: { received: total_count },
});
}
if (total_count < 0) {
return res.status(400).json({
success: false,
error: `total_count cannot be negative`,
details: { received: total_count },
});
}
// Validate filename type if provided
if (filename !== undefined && filename !== null && typeof filename !== 'string') {
return res.status(400).json({
success: false,
error: `Invalid filename type. Expected string, got ${typeof filename}`,
details: { received: filename },
});
}
let allKandangs;
try {
allKandangs = await Kandang.getAll();
} catch (dbError) {
console.error(`[${timestamp}] ERROR: Failed to query kandangs table:`, dbError.message);
return res.status(500).json({
success: false,
error: 'Database error: Failed to query kandangs table',
details: { message: dbError.message },
});
}
if (allKandangs.length === 0) {
return res.status(500).json({
success: false,
error:
'No kandangs configured in the system. Please create kandangs first via /api/kandangs',
});
}
const matched = allKandangs.find((k) => nameToSlug(k.name) === kandang);
const validSlugs = allKandangs.map((k) => ({
slug: nameToSlug(k.name),
name: k.name,
id: k.id,
}));
if (!matched) {
return res.status(400).json({
success: false,
error: `Kandang '${kandang}' not found`,
details: {
received: kandang,
available: validSlugs,
},
});
}
// Check if this is a manual update from UI (source=manual query param)
const isManualUpdate = req.query.source === 'manual';
const operation = isManualUpdate ? 'Upserting (manual)' : 'Inserting (API, skip if exists)';
// Use upsert for manual updates, insertIfNotExists for API calls
let record;
try {
if (isManualUpdate) {
record = await ChickenCount.upsert(date, matched.id, filename || null, total_count);
} else {
record = await ChickenCount.insertIfNotExists(
date,
matched.id,
filename || null,
total_count
);
}
} catch (dbError) {
console.error(`[${timestamp}] ERROR: Failed to save chicken count:`, dbError.message);
console.error(`[${timestamp}] Stack:`, dbError.stack);
return res.status(500).json({
success: false,
error: 'Database error: Failed to save chicken count',
details: { message: dbError.message },
});
}
if (record.skipped) {
} else {
}
res.status(200).json({
success: true,
data: record,
});
} catch (error) {
console.error(`[${timestamp}] UNEXPECTED ERROR:`, error.message);
console.error(`[${timestamp}] Stack:`, error.stack);
res.status(500).json({
success: false,
error: 'Internal server error',
details: { message: error.message },
});
}
});
// GET /api/chicken-counting?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD&kandangId=1
router.get('/', async (req, res) => {
try {
let { startDate, endDate, kandangId } = req.query;
if (!startDate || !endDate) {
return res.status(400).json({
success: false,
error: 'startDate and endDate query parameters are required (YYYY-MM-DD)',
});
}
// Apply D-1 filter if querying single day and before cutoff (17:00 WIB)
let isD1Applied = false;
if (startDate === endDate && isBeforeCutoffTime()) {
startDate = getPreviousDate(startDate);
endDate = startDate;
isD1Applied = true;
}
const records = await ChickenCount.getByDateRange(
startDate,
endDate,
kandangId ? parseInt(kandangId) : null
);
res.json({
success: true,
data: records,
meta: createResponseMetadata(isD1Applied),
});
} catch (error) {
console.error('Error querying chicken counting:', error);
res.status(500).json({
success: false,
error: error.message,
});
}
});
module.exports = router;
+84
View File
@@ -0,0 +1,84 @@
const express = require('express');
const router = express.Router();
const CycleFeedInitialBalance = require('../models/CycleFeedInitialBalance');
const { convertKeysToCamelCase } = require('../utils/caseConverter');
/**
* GET /api/cycle-feed-initial-balance/:cycleId
* Get initial balance for a specific cycle
*/
router.get('/:cycleId', async (req, res) => {
try {
const { cycleId } = req.params;
const balance = await CycleFeedInitialBalance.getByCycleId(cycleId);
if (!balance) {
return res.json({ success: true, data: null });
}
res.json({ success: true, data: convertKeysToCamelCase(balance) });
} catch (error) {
console.error('Error fetching cycle feed initial balance:', error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* PUT /api/cycle-feed-initial-balance
* Upsert initial balance for a cycle
*/
router.put('/', async (req, res) => {
try {
const { cycleId, saldoAwal, createdBy } = req.body;
// Validation
if (!cycleId || saldoAwal === undefined) {
return res.status(400).json({
success: false,
error: 'Missing required fields: cycleId, saldoAwal'
});
}
if (saldoAwal < 0) {
return res.status(400).json({
success: false,
error: 'saldoAwal cannot be negative'
});
}
if (!Number.isInteger(saldoAwal)) {
return res.status(400).json({
success: false,
error: 'saldoAwal must be an integer'
});
}
const balance = await CycleFeedInitialBalance.upsert(
cycleId,
saldoAwal,
createdBy || 'system'
);
res.json({ success: true, data: convertKeysToCamelCase(balance) });
} catch (error) {
console.error('Error saving cycle feed initial balance:', error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* DELETE /api/cycle-feed-initial-balance/:cycleId
* Delete initial balance for a cycle
*/
router.delete('/:cycleId', async (req, res) => {
try {
const { cycleId } = req.params;
await CycleFeedInitialBalance.delete(cycleId);
res.json({ success: true });
} catch (error) {
console.error('Error deleting cycle feed initial balance:', error);
res.status(500).json({ success: false, error: error.message });
}
});
module.exports = router;
Loaded 100 of 444 files, more files were not shown because too many files have changed in this diff. Show more