commit
32a36cceff
444 files changed
+67186
No files matched your search
@@ -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';
|
||||
@@ -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();
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in new issue
Block a user