diff --git a/.env.example b/.env.example index b7c2cd5..a5af73f 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,11 @@ PORT=3000 NODE_ENV=production -# Database & Data Persistence Path +# PostgreSQL Connection String (Coolify / Managed Postgres / Docker) +DATABASE_URL=postgresql://postgres:postgres_secure_pass_2026@postgres:5432/balance_sheet_db +POSTGRES_PASSWORD=postgres_secure_pass_2026 + +# Fallback File Storage Path (Used if DATABASE_URL is omitted or offline) DATA_PATH=/app/data/portal-data.json # Optional SMTP Email Configuration (If using real SMTP server) diff --git a/COOLIFY.md b/COOLIFY.md index 16460ad..0a7e1fe 100644 --- a/COOLIFY.md +++ b/COOLIFY.md @@ -1,69 +1,53 @@ -# Deployment Guide for Coolify +# Coolify & PostgreSQL Deployment Guide -This repository is optimized for deployment on **Coolify** using Docker or Docker Compose. +This application consists of: +1. **Server Side**: Node.js + Express backend (`server.ts`) serving REST APIs, managing authentication, ledger reconciliation, audit logs, FX rates, and financial reports. +2. **Database**: PostgreSQL (via `pg` connection pool) with automatic JSON fall-back backup. +3. **Frontend**: React SPA served directly via Express in production. --- -## Quick Deployment Options in Coolify +## 1. Quick Coolify Deployment Options -### Option A: Deployment via Git Repository (Recommended) +### Option A: Docker Compose Deployment (Application + PostgreSQL in One Stack) -1. **Push Code to Git**: Push this repository to GitHub, GitLab, or your self-hosted Git service. -2. **Add New Resource in Coolify**: - - Go to your Coolify dashboard. - - Click **+ Add Resource** -> **Public Repository** or **Private Repository**. - - Paste your repository URL and select the `main` branch. -3. **Select Build Pack**: - - Select **Dockerfile**. Coolify will automatically detect the `Dockerfile` in the root directory. -4. **Configure Port & Network**: - - **Port**: Set `3000`. -5. **Configure Persistent Volume**: - - Under **Storage / Volumes**, add a persistent volume mapping to prevent data loss on container redeployments: - - **Destination Path**: `/app/data` -6. **Deploy**: - - Click **Deploy**. Coolify will build the multi-stage Docker image and start your application. - ---- - -### Option B: Deployment via Docker Compose - -1. **Add New Resource in Coolify**: +1. **In Coolify Dashboard**: - Click **+ Add Resource** -> **Docker Compose**. -2. **Source Code**: - - Select your Git Repository or paste the contents of `docker-compose.yml`. -3. **Environment Variables**: - - Add the following environment variables in Coolify UI: + - Point to your Git Repository or paste the contents of `docker-compose.yml`. +2. **Environment Variables**: + Add the following in Coolify: + ```env + NODE_ENV=production + PORT=3000 + POSTGRES_PASSWORD=your_custom_secure_password + ``` +3. **Deploy**: + - Coolify will build the app container and spin up a dedicated `postgres:16-alpine` database container with health checks and persistent volume storage (`balance_sheet_postgres_data`). + +--- + +### Option B: Coolify Managed PostgreSQL + Standalone App Container + +If you prefer using Coolify's built-in managed PostgreSQL database resource: + +1. **Create Database in Coolify**: + - Click **+ Add Resource** -> **PostgreSQL**. + - Coolify will provide an internal database connection URL (e.g., `postgresql://postgres:pass@coolify-postgres-host:5432/balance_sheet_db`). +2. **Deploy App Service**: + - Add your Git repo as a **Dockerfile** application. + - Set environment variable in Coolify UI: ```env - NODE_ENV=production + DATABASE_URL=postgresql://postgres:pass@coolify-postgres-host:5432/balance_sheet_db PORT=3000 - DATA_PATH=/app/data/portal-data.json ``` -4. **Deploy**: - - Click **Deploy**. Coolify will spin up the container and attach the `portal_data` volume automatically. +3. **Deploy**: + - The Express server will automatically connect to the PostgreSQL database, create the necessary table structures on boot, and store all portal state in PostgreSQL. --- -## Local Testing with Docker +## 2. Server-Side Architecture Details -To build and test locally before deploying to Coolify: - -### Using Docker Compose: -```bash -docker-compose up --build -d -``` -Access the app at: `http://localhost:3000` - -### Using Docker directly: -```bash -docker build -t balance-sheet-portal . -docker run -p 3000:3000 -v portal_data:/app/data balance-sheet-portal -``` - ---- - -## Health Check Endpoint - -The container includes a built-in health check targeting: -`GET /api/health` - -It returns `{"status": "ok"}` when the Express server and Vite static engine are healthy. +- **Automatic Table Creation**: On server boot, the backend runs `initPgDatabase()` to verify/create `app_portal_state`. +- **Automatic State Recovery**: If PostgreSQL contains existing data, the portal state is loaded directly from PostgreSQL. If empty, baseline seed data is written to PostgreSQL. +- **Resilient Fallback**: If `DATABASE_URL` is omitted, the app gracefully operates using persistent JSON storage (`/app/data/portal-data.json`). +- **Health Check**: Express exposes `GET /api/health` for Docker container monitoring. diff --git a/bun.lock b/bun.lock index dda6f66..59f4809 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@google/genai": "^2.4.0", "@tailwindcss/vite": "^4.1.14", + "@types/pg": "^8.20.4", "@types/three": "^0.185.4", "@vitejs/plugin-react": "^5.0.4", "dotenv": "^17.2.3", @@ -14,6 +15,7 @@ "lucide-react": "^0.546.0", "motion": "^12.23.24", "nodemailer": "^9.0.4", + "pg": "^8.22.0", "react": "^19.0.1", "react-dom": "^19.0.1", "recharts": "^3.10.1", @@ -292,6 +294,8 @@ "@types/nodemailer": ["@types/nodemailer@8.0.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw=="], + "@types/pg": ["@types/pg@8.20.4", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-Jz7UDOlIiFJuacC0TlBoLyNtmwlA/wpIyPDd3tvUqlRM+HzkWy2xUgpFpaXtbfTAFF6sIGq5lsCDBdJnhky1Xg=="], + "@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="], "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], @@ -572,6 +576,22 @@ "path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="], + "pg": ["pg@8.22.0", "", { "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.15.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA=="], + + "pg-cloudflare": ["pg-cloudflare@1.4.0", "", {}, "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A=="], + + "pg-connection-string": ["pg-connection-string@2.14.0", "", {}, "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="], + + "pg-protocol": ["pg-protocol@1.15.0", "", {}, "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], @@ -580,6 +600,14 @@ "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + "protobufjs": ["protobufjs@7.6.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], @@ -636,6 +664,8 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], @@ -678,6 +708,8 @@ "ws": ["ws@8.21.2", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "@babel/core/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], diff --git a/docker-compose.yml b/docker-compose.yml index 2cc4f12..bf4f2f4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,26 @@ version: '3.8' services: + # PostgreSQL Database Service + postgres: + image: postgres:16-alpine + container_name: balance-sheet-postgres + restart: unless-stopped + environment: + POSTGRES_DB: balance_sheet_db + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres_secure_pass_2026} + ports: + - "5432:5432" + volumes: + - postgres_db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d balance_sheet_db"] + interval: 10s + timeout: 5s + retries: 5 + + # Express + React Application Web Service balance-sheet-portal: build: context: . @@ -8,11 +28,15 @@ services: image: balance-sheet-portal:latest container_name: balance-sheet-portal restart: unless-stopped - expose: - - "3000" + depends_on: + postgres: + condition: service_healthy + ports: + - "${PORT:-3000}:3000" environment: - NODE_ENV=production - PORT=3000 + - DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD:-postgres_secure_pass_2026}@postgres:5432/balance_sheet_db - DATA_PATH=/app/data/portal-data.json volumes: - portal_data:/app/data @@ -27,3 +51,6 @@ volumes: portal_data: name: balance_sheet_portal_data driver: local + postgres_db_data: + name: balance_sheet_postgres_data + driver: local diff --git a/package.json b/package.json index 52a8683..705cfe6 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dependencies": { "@google/genai": "^2.4.0", "@tailwindcss/vite": "^4.1.14", + "@types/pg": "^8.20.4", "@types/three": "^0.185.4", "@vitejs/plugin-react": "^5.0.4", "dotenv": "^17.2.3", @@ -21,6 +22,7 @@ "lucide-react": "^0.546.0", "motion": "^12.23.24", "nodemailer": "^9.0.4", + "pg": "^8.22.0", "react": "^19.0.1", "react-dom": "^19.0.1", "recharts": "^3.10.1", diff --git a/server.ts b/server.ts index f1c7fc8..1fdb4bb 100644 --- a/server.ts +++ b/server.ts @@ -38,6 +38,7 @@ import { MakerCheckerStatus } from './src/types.js'; import { AccountingEngine, DEFAULT_FX_RATES } from './src/services/accountingEngine.js'; +import { initPgDatabase, loadStateFromPg, saveStateToPg } from './src/db/postgres.js'; const app = express(); const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000; @@ -45,7 +46,7 @@ const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000; app.use(express.json()); // In-Memory Database / Persistent File Storage -interface DB { +export interface DB { users: User[]; submissions: Record>; // period -> branchId -> submission smtpConfig: SmtpConfig; @@ -186,10 +187,30 @@ function saveDB(data: DB) { } catch (err) { console.error('Error saving portal-data.json:', err); } + + // Asynchronously push state to PostgreSQL if configured + saveStateToPg(data).catch((err) => { + console.error('Error syncing state to PostgreSQL:', err); + }); } let db = loadDB(); +// Initialize PostgreSQL if DATABASE_URL or POSTGRES_HOST is present +(async () => { + const pgReady = await initPgDatabase(); + if (pgReady) { + const pgState = await loadStateFromPg(); + if (pgState) { + db = pgState; + console.log('[PostgreSQL] DB state successfully synced from PostgreSQL database.'); + } else { + console.log('[PostgreSQL] Initializing PostgreSQL database with baseline state...'); + await saveStateToPg(db); + } + } +})(); + // ------------------------------------------------------------- // AUTH ENDPOINTS // ------------------------------------------------------------- diff --git a/src/db/postgres.ts b/src/db/postgres.ts new file mode 100644 index 0000000..d2f66dc --- /dev/null +++ b/src/db/postgres.ts @@ -0,0 +1,122 @@ +import pg from 'pg'; +import type { DB } from '../../server'; + +const { Pool } = pg; + +let pool: pg.Pool | null = null; + +export function getPgPool(): pg.Pool | null { + if (pool) return pool; + + const connectionString = + process.env.DATABASE_URL || + process.env.POSTGRES_URL || + process.env.DATABASE_PRIVATE_URL; + + const host = process.env.POSTGRES_HOST || process.env.SQL_HOST; + const user = process.env.POSTGRES_USER || process.env.SQL_USER; + const password = process.env.POSTGRES_PASSWORD || process.env.SQL_PASSWORD; + const database = process.env.POSTGRES_DB || process.env.SQL_DB_NAME || 'balance_sheet_db'; + const port = parseInt(process.env.POSTGRES_PORT || '5432', 10); + + if (connectionString) { + console.log('[PostgreSQL] Initializing connection pool via DATABASE_URL'); + pool = new Pool({ + connectionString, + max: 10, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, + }); + } else if (host && user) { + console.log(`[PostgreSQL] Initializing connection pool via host ${host}:${port}, db: ${database}`); + pool = new Pool({ + host, + port, + user, + password, + database, + max: 10, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, + }); + } else { + console.log('[PostgreSQL] No DATABASE_URL or POSTGRES_HOST provided. Operating in file-backed mode.'); + return null; + } + + pool.on('error', (err) => { + console.error('[PostgreSQL] Unexpected idle client error:', err); + }); + + return pool; +} + +/** + * Initializes PostgreSQL schema table if needed + */ +export async function initPgDatabase(): Promise { + const p = getPgPool(); + if (!p) return false; + + try { + const client = await p.connect(); + try { + await client.query(` + CREATE TABLE IF NOT EXISTS app_portal_state ( + id VARCHAR(50) PRIMARY KEY, + data JSONB NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ); + `); + console.log('[PostgreSQL] Database table app_portal_state ensured.'); + return true; + } finally { + client.release(); + } + } catch (err) { + console.error('[PostgreSQL] Initialization failed:', err); + return false; + } +} + +/** + * Load state from PostgreSQL + */ +export async function loadStateFromPg(): Promise { + const p = getPgPool(); + if (!p) return null; + + try { + const res = await p.query(`SELECT data FROM app_portal_state WHERE id = $1`, ['main_state']); + if (res.rows.length > 0 && res.rows[0].data) { + console.log('[PostgreSQL] Successfully restored portal state from PostgreSQL database.'); + return res.rows[0].data as DB; + } + return null; + } catch (err) { + console.error('[PostgreSQL] Error loading state:', err); + return null; + } +} + +/** + * Save state to PostgreSQL asynchronously + */ +export async function saveStateToPg(state: DB): Promise { + const p = getPgPool(); + if (!p) return; + + try { + await p.query( + ` + INSERT INTO app_portal_state (id, data, updated_at) + VALUES ($1, $2, NOW()) + ON CONFLICT (id) DO UPDATE + SET data = EXCLUDED.data, updated_at = NOW(); + `, + ['main_state', JSON.stringify(state)] + ); + } catch (err) { + console.error('[PostgreSQL] Error saving state:', err); + } +}