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

+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;
+74
View File
@@ -0,0 +1,74 @@
const express = require('express');
const router = express.Router();
const Cycle = require('../models/Cycle');
router.get('/', async (req, res) => {
try {
const cycles = await Cycle.getAll();
res.json({ success: true, data: cycles });
} catch (error) {
console.error('Error in GET /api/cycles:', error);
res.status(500).json({ success: false, error: error.message });
}
});
router.get('/active', async (req, res) => {
try {
const cycle = await Cycle.getActive();
if (!cycle) return res.status(404).json({ success: false, error: 'No active cycle found' });
res.json({ success: true, data: cycle });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
router.get('/:id', async (req, res) => {
try {
const cycle = await Cycle.getById(req.params.id);
if (!cycle) return res.status(404).json({ success: false, error: 'Cycle not found' });
res.json({ success: true, data: cycle });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
router.post('/', async (req, res) => {
try {
const { id, totalDays, currentDay, startDate, status } = req.body;
if (!id || !totalDays || currentDay === undefined || !startDate || !status) {
return res.status(400).json({ success: false, error: 'Missing required fields' });
}
// CRITICAL: If creating a new "Active" cycle, automatically mark all previous "Active" cycles as "Completed"
if (status === 'Active') {
await Cycle.markAllActiveAsCompleted();
}
const cycle = await Cycle.create(req.body);
res.status(201).json({ success: true, data: cycle });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
router.put('/:id', async (req, res) => {
try {
const cycle = await Cycle.update(req.params.id, req.body);
if (!cycle) return res.status(404).json({ success: false, error: 'Cycle not found' });
res.json({ success: true, data: cycle });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
router.delete('/:id', async (req, res) => {
try {
const deleted = await Cycle.delete(req.params.id);
if (!deleted) return res.status(404).json({ success: false, error: 'Cycle not found' });
res.json({ success: true, message: 'Cycle deleted successfully' });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
module.exports = router;
+29
View File
@@ -0,0 +1,29 @@
const express = require('express');
const router = express.Router();
const FeedSackColumnConfig = require('../models/FeedSackColumnConfig');
// GET /api/feed-sack-column-config - Get column configuration
router.get('/', async (req, res) => {
try {
const config = await FeedSackColumnConfig.get();
res.json({ success: true, data: config || [] });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// PUT /api/feed-sack-column-config - Save column configuration
router.put('/', async (req, res) => {
try {
const { config } = req.body;
if (!Array.isArray(config)) {
return res.status(400).json({ success: false, error: 'config must be an array' });
}
const saved = await FeedSackColumnConfig.save(config);
res.json({ success: true, data: saved });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
module.exports = router;
+133
View File
@@ -0,0 +1,133 @@
const express = require('express');
const router = express.Router();
const Kandang = require('../models/Kandang');
// GET /api/kandangs - List all kandangs
router.get('/', async (req, res) => {
try {
const kandangs = await Kandang.getAll();
res.json({ success: true, data: kandangs });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// GET /api/kandangs/by-cycle/:cycleId - Get kandangs with doc_in_count for a cycle
router.get('/by-cycle/:cycleId', async (req, res) => {
try {
const kandangs = await Kandang.getByCycle(req.params.cycleId);
res.json({ success: true, data: kandangs });
} catch (error) {
console.error('Error fetching kandangs by cycle:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// GET /api/kandangs/:id - Get single kandang
router.get('/:id', async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) return res.status(400).json({ success: false, error: 'Invalid ID' });
const kandang = await Kandang.getById(id);
if (!kandang) return res.status(404).json({ success: false, error: 'Kandang not found' });
res.json({ success: true, data: kandang });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// POST /api/kandangs - Create new kandang
router.post('/', async (req, res) => {
try {
const { name } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({ success: false, error: 'Name is required' });
}
const kandang = await Kandang.create(name.trim());
res.status(201).json({ success: true, data: kandang });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// PUT /api/kandangs/:id - Rename kandang
router.put('/:id', async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) return res.status(400).json({ success: false, error: 'Invalid ID' });
const { name } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({ success: false, error: 'Name is required' });
}
const kandang = await Kandang.update(id, name.trim());
if (!kandang) return res.status(404).json({ success: false, error: 'Kandang not found' });
res.json({ success: true, data: kandang });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// DELETE /api/kandangs/:id - Delete kandang
router.delete('/:id', async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) return res.status(400).json({ success: false, error: 'Invalid ID' });
const deleted = await Kandang.delete(id);
if (!deleted) return res.status(404).json({ success: false, error: 'Kandang not found' });
res.json({ success: true, message: 'Kandang deleted successfully' });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// PUT /api/kandangs/:id/cycle/:cycleId - Set doc_in_count for kandang + cycle
router.put('/:id/cycle/:cycleId', async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) return res.status(400).json({ success: false, error: 'Invalid ID' });
const { docInCount } = req.body;
if (docInCount === undefined || docInCount < 0) {
return res.status(400).json({ success: false, error: 'Valid docInCount is required' });
}
await Kandang.setDocInCount(id, req.params.cycleId, docInCount);
res.json({ success: true, message: 'doc_in_count updated' });
} catch (error) {
console.error('Error setting DOC count:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// PUT /api/kandangs/:id/cage-uuid - Update cage UUID for a kandang
router.put('/:id/cage-uuid', async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) return res.status(400).json({ success: false, error: 'Invalid ID' });
const { cageUuid } = req.body;
// Validate UUID format if provided (allow null/empty to clear)
if (cageUuid && cageUuid.trim()) {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(cageUuid.trim())) {
return res.status(400).json({ success: false, error: 'Invalid UUID format' });
}
}
const kandang = await Kandang.updateCageUuid(id, cageUuid?.trim() || null);
if (!kandang) {
return res.status(404).json({ success: false, error: 'Kandang not found' });
}
res.json({ success: true, data: kandang });
} catch (error) {
// Handle unique constraint violation
if (error.code === '23505') {
return res
.status(409)
.json({ success: false, error: 'Cage UUID already assigned to another kandang' });
}
res.status(500).json({ success: false, error: error.message });
}
});
module.exports = router;
+128
View File
@@ -0,0 +1,128 @@
const express = require('express');
const router = express.Router();
const ManualFeedSack = require('../models/ManualFeedSack');
const { convertKeysToCamelCase } = require('../utils/caseConverter');
const {
isBeforeCutoffTime,
getPreviousDate,
createResponseMetadata
} = require('../utils/dateUtils');
/**
* GET /api/manual-feed-sack?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD
* Get all manual entries for a date range
*/
router.get('/', async (req, res) => {
try {
let { startDate, endDate } = req.query;
if (!startDate || !endDate) {
return res.status(400).json({
success: false,
error: 'Missing required query parameters: startDate, endDate'
});
}
// 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 entries = await ManualFeedSack.getByDateRange(startDate, endDate);
// Transform to camelCase and ensure date is a string
const transformedEntries = entries.map(entry => ({
...convertKeysToCamelCase(entry),
date: entry.date instanceof Date
? entry.date.toISOString().split('T')[0]
: typeof entry.date === 'string'
? entry.date.split('T')[0]
: entry.date
}));
res.json({
success: true,
data: transformedEntries,
meta: createResponseMetadata(isD1Applied)
});
} catch (error) {
console.error('Error fetching manual feed sack entries:', error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* PUT /api/manual-feed-sack
* Upsert manual entry
*/
router.put('/', async (req, res) => {
try {
const { date, cameraName, count, createdBy, keterangan } = req.body;
// Validation
if (!date || !cameraName || count === undefined) {
return res.status(400).json({
success: false,
error: 'Missing required fields: date, cameraName, count'
});
}
if (count < 0) {
return res.status(400).json({
success: false,
error: 'Count cannot be negative'
});
}
// Validate keterangan length
if (keterangan && keterangan.length > 200) {
return res.status(400).json({
success: false,
error: 'Keterangan cannot exceed 200 characters'
});
}
const entry = await ManualFeedSack.upsert(
date,
cameraName,
count,
createdBy,
keterangan || null
);
// Transform to camelCase and ensure date is a string
const transformedEntry = {
...convertKeysToCamelCase(entry),
date: entry.date instanceof Date
? entry.date.toISOString().split('T')[0]
: typeof entry.date === 'string'
? entry.date.split('T')[0]
: entry.date
};
res.json({ success: true, data: transformedEntry });
} catch (error) {
console.error('Error saving manual feed sack entry:', error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* DELETE /api/manual-feed-sack/:date/:cameraName
* Delete manual entry
*/
router.delete('/:date/:cameraName', async (req, res) => {
try {
const { date, cameraName } = req.params;
await ManualFeedSack.delete(date, cameraName);
res.json({ success: true });
} catch (error) {
console.error('Error deleting manual feed sack entry:', error);
res.status(500).json({ success: false, error: error.message });
}
});
module.exports = router;
+135
View File
@@ -0,0 +1,135 @@
const express = require('express');
const router = express.Router();
const Mortality = require('../models/Mortality');
const { captureAuditLog, filterAuditFields } = require('../middleware/auditLogger');
router.get('/:cycleId', async (req, res) => {
try {
const kandangId = req.query.kandangId ? parseInt(req.query.kandangId, 10) : null;
const records = await Mortality.getByCycle(req.params.cycleId, kandangId);
res.json({ success: true, data: records });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// GET aggregated totals across all kandangs
router.get('/:cycleId/totals', async (req, res) => {
try {
const totals = await Mortality.getTotalsByCycle(req.params.cycleId);
res.json({ success: true, data: totals });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
router.get('/:cycleId/:day', async (req, res) => {
try {
const day = parseInt(req.params.day, 10);
if (isNaN(day)) return res.status(400).json({ success: false, error: 'Day must be a number' });
const kandangId = req.query.kandangId ? parseInt(req.query.kandangId, 10) : null;
const record = await Mortality.getByDay(req.params.cycleId, day, kandangId);
if (!record) return res.status(404).json({ success: false, error: 'Mortality record not found' });
res.json({ success: true, data: record });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
router.put('/:cycleId/:day', async (req, res) => {
try {
const day = parseInt(req.params.day, 10);
const { mortalityCount, chickenCount, kandangId, panen, keterangan, afkir, beratPanen } = req.body;
// Validation
if (isNaN(day) || mortalityCount === undefined || mortalityCount < 0) {
return res.status(400).json({ success: false, error: 'Invalid mortality count' });
}
if (afkir !== undefined && afkir < 0) {
return res.status(400).json({ success: false, error: 'Afkir must be non-negative' });
}
if (panen !== undefined && panen < 0) {
return res.status(400).json({ success: false, error: 'Panen must be non-negative' });
}
if (beratPanen !== undefined && beratPanen < 0) {
return res.status(400).json({ success: false, error: 'Berat panen must be non-negative' });
}
if (keterangan && keterangan.length > 200) {
return res.status(400).json({ success: false, error: 'Keterangan must be 200 characters or less' });
}
const parsedKandangId = kandangId ? parseInt(kandangId, 10) : null;
const parsedAfkir = afkir !== undefined ? parseInt(afkir, 10) : 0;
const parsedPanen = panen !== undefined ? parseInt(panen, 10) : 0;
const parsedBeratPanen = beratPanen !== undefined ? parseFloat(beratPanen) : 0;
const parsedKeterangan = keterangan || null;
if (beratPanen !== undefined && Number.isNaN(parsedBeratPanen)) {
return res.status(400).json({ success: false, error: 'Berat panen must be a number' });
}
// Fetch old record for audit logging
const oldRecord = await Mortality.getByDay(req.params.cycleId, day, parsedKandangId);
const isCreate = !oldRecord;
// Perform update/insert
const record = await Mortality.upsert(
req.params.cycleId,
day,
mortalityCount,
true,
chickenCount,
parsedKandangId,
parsedPanen,
parsedKeterangan,
parsedAfkir,
parsedBeratPanen
);
// Capture audit log
const recordId = `${req.params.cycleId}-${day}${parsedKandangId ? `-${parsedKandangId}` : ''}`;
await captureAuditLog(
'mortality_records',
recordId,
isCreate ? 'CREATE' : 'UPDATE',
isCreate ? null : filterAuditFields(oldRecord),
filterAuditFields(record)
);
res.json({ success: true, data: record });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
router.delete('/:cycleId/:day', async (req, res) => {
try {
const day = parseInt(req.params.day, 10);
if (isNaN(day)) return res.status(400).json({ success: false, error: 'Day must be a number' });
const kandangId = req.query.kandangId ? parseInt(req.query.kandangId, 10) : null;
// Fetch record before deleting for audit log
const oldRecord = await Mortality.getByDay(req.params.cycleId, day, kandangId);
const deleted = await Mortality.delete(req.params.cycleId, day, kandangId);
if (!deleted) return res.status(404).json({ success: false, error: 'Mortality record not found' });
// Capture audit log
if (oldRecord) {
const recordId = `${req.params.cycleId}-${day}${kandangId ? `-${kandangId}` : ''}`;
await captureAuditLog(
'mortality_records',
recordId,
'DELETE',
filterAuditFields(oldRecord),
null
);
}
res.json({ success: true, message: 'Mortality record deleted successfully' });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
module.exports = router;