Files
Qris-Soundbox/src/shared/db/pool.ts

311 lines
11 KiB
TypeScript

import { Pool, type PoolClient } from "pg";
import { env } from "../../config/env";
type PoolConfigWithConnectionString = {
connectionString: string;
};
type PoolConfigWithCredentials = {
host: string;
port: number;
user: string;
password: string;
database: string;
};
let pool: Pool | null = null;
function buildPoolConfig(): PoolConfigWithConnectionString | PoolConfigWithCredentials {
if (env.DATABASE_URL) {
return {
connectionString: env.DATABASE_URL
};
}
return {
host: env.PGHOST,
port: env.PGPORT,
user: env.PGUSER,
password: env.PGPASSWORD,
database: env.PGDATABASE
};
}
export function getPool(): Pool {
if (!pool) {
const config = buildPoolConfig();
pool = new Pool(config);
}
return pool;
}
export async function withClient<T>(work: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await getPool().connect();
try {
return await work(client);
} finally {
client.release();
}
}
export async function ensureSchema() {
const pool = getPool();
await pool.query(MIGRATIONS_SQL);
}
const MIGRATIONS_SQL = `
BEGIN;
CREATE TABLE IF NOT EXISTS merchants (
id TEXT PRIMARY KEY,
merchant_code TEXT NOT NULL UNIQUE,
legal_name TEXT NOT NULL,
brand_name TEXT,
settlement_account_reference TEXT,
settlement_account_type TEXT,
payout_mode TEXT NOT NULL DEFAULT 'merchant_direct' CHECK (payout_mode IN ('merchant_direct', 'manual')),
fee_profile_id TEXT,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
onboarding_status TEXT NOT NULL DEFAULT 'pending' CHECK (onboarding_status IN ('pending', 'approved', 'rejected')),
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS outlets (
id TEXT PRIMARY KEY,
merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE,
outlet_code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
address TEXT,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS terminals (
id TEXT PRIMARY KEY,
outlet_id TEXT NOT NULL REFERENCES outlets (id) ON DELETE CASCADE,
terminal_code TEXT NOT NULL UNIQUE,
qr_mode TEXT NOT NULL DEFAULT 'static' CHECK (qr_mode IN ('static', 'dynamic_mqtt', 'dynamic_api')),
partner_reference TEXT,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS devices (
id TEXT PRIMARY KEY,
device_code TEXT NOT NULL UNIQUE,
serial_number TEXT,
vendor TEXT,
model TEXT,
communication_mode TEXT NOT NULL DEFAULT 'static' CHECK (communication_mode IN ('static', 'mqtt', 'api')),
capability_profile_json JSONB NOT NULL DEFAULT '{}'::jsonb,
auth_method TEXT,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
last_seen_at TIMESTAMPTZ,
firmware_version TEXT,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_devices_status_last_seen ON devices (status, last_seen_at DESC);
CREATE TABLE IF NOT EXISTS device_bindings (
id TEXT PRIMARY KEY,
device_id TEXT NOT NULL REFERENCES devices (id) ON DELETE CASCADE,
merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE,
outlet_id TEXT NOT NULL REFERENCES outlets (id) ON DELETE CASCADE,
terminal_id TEXT NOT NULL REFERENCES terminals (id) ON DELETE CASCADE,
active_flag BOOLEAN NOT NULL DEFAULT false,
bound_at TIMESTAMPTZ NOT NULL,
unbound_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_device_active_binding ON device_bindings (device_id) WHERE active_flag = TRUE;
CREATE INDEX IF NOT EXISTS idx_device_bindings_terminal_active ON device_bindings (terminal_id, active_flag);
CREATE TABLE IF NOT EXISTS device_heartbeats (
id TEXT PRIMARY KEY,
device_id TEXT NOT NULL REFERENCES devices (id) ON DELETE CASCADE,
timestamp TIMESTAMPTZ NOT NULL,
received_at TIMESTAMPTZ NOT NULL,
firmware_version TEXT,
network_strength INTEGER,
battery_level INTEGER,
state TEXT,
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_device_heartbeats_device ON device_heartbeats (device_id, received_at DESC);
CREATE TABLE IF NOT EXISTS device_commands (
id TEXT PRIMARY KEY,
device_id TEXT NOT NULL REFERENCES devices (id) ON DELETE CASCADE,
command TEXT NOT NULL,
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL DEFAULT 'accepted' CHECK (status IN ('accepted', 'delivered', 'failed', 'timeout')),
requested_at TIMESTAMPTZ NOT NULL,
acknowledged_at TIMESTAMPTZ,
result_payload_json JSONB,
reason TEXT
);
CREATE INDEX IF NOT EXISTS idx_device_commands_device_request ON device_commands (device_id, requested_at DESC);
CREATE TABLE IF NOT EXISTS mqtt_messages (
id TEXT PRIMARY KEY,
direction TEXT NOT NULL CHECK (direction IN ('uplink', 'downlink')),
device_id TEXT NOT NULL REFERENCES devices (id) ON DELETE CASCADE,
topic TEXT NOT NULL,
message_type TEXT NOT NULL,
correlation_id TEXT,
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
publish_status TEXT NOT NULL DEFAULT 'recorded' CHECK (publish_status IN ('recorded', 'sent', 'failed')),
reason TEXT,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mqtt_messages_device_created ON mqtt_messages (device_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_mqtt_messages_correlation ON mqtt_messages (correlation_id);
CREATE TABLE IF NOT EXISTS device_configs (
device_id TEXT PRIMARY KEY REFERENCES devices (id) ON DELETE CASCADE,
config_version INT NOT NULL,
settings_json JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS device_config_acks (
id TEXT PRIMARY KEY,
device_id TEXT NOT NULL REFERENCES devices (id) ON DELETE CASCADE,
config_version INT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('applied', 'failed')),
reason TEXT,
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
acked_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_device_config_acks_device ON device_config_acks (device_id, acked_at DESC);
CREATE TABLE IF NOT EXISTS transactions (
id TEXT PRIMARY KEY,
transaction_code TEXT NOT NULL UNIQUE,
merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE,
outlet_id TEXT NOT NULL REFERENCES outlets (id) ON DELETE CASCADE,
terminal_id TEXT NOT NULL REFERENCES terminals (id) ON DELETE CASCADE,
device_id TEXT REFERENCES devices (id) ON DELETE SET NULL,
qr_mode TEXT NOT NULL DEFAULT 'static' CHECK (qr_mode IN ('static', 'dynamic')),
initiation_mode TEXT NOT NULL DEFAULT 'static' CHECK (initiation_mode IN ('static', 'manual', 'dynamic_api', 'dynamic_mqtt')),
partner_reference TEXT NOT NULL UNIQUE,
amount NUMERIC(20,2) NOT NULL,
currency TEXT NOT NULL DEFAULT 'IDR',
status TEXT NOT NULL DEFAULT 'initiated' CHECK (status IN ('initiated', 'awaiting_payment', 'paid', 'failed', 'expired', 'reversed')),
created_at TIMESTAMPTZ NOT NULL,
paid_at TIMESTAMPTZ,
expired_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_transactions_partner_ref ON transactions (partner_reference);
CREATE INDEX IF NOT EXISTS idx_transactions_merchant_created ON transactions (merchant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_transactions_status_created ON transactions (status, created_at DESC);
CREATE TABLE IF NOT EXISTS transaction_events (
id TEXT PRIMARY KEY,
transaction_id TEXT NOT NULL REFERENCES transactions (id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
source TEXT NOT NULL,
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_transaction_events_tx ON transaction_events (transaction_id, created_at DESC);
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY,
transaction_id TEXT NOT NULL REFERENCES transactions (id) ON DELETE CASCADE,
device_id TEXT REFERENCES devices (id) ON DELETE SET NULL,
delivery_channel TEXT NOT NULL DEFAULT 'mqtt' CHECK (delivery_channel IN ('mqtt')),
payload_type TEXT NOT NULL DEFAULT 'payment_success' CHECK (payload_type IN ('payment_success')),
delivery_status TEXT NOT NULL CHECK (delivery_status IN ('queued', 'sent', 'acknowledged', 'failed', 'retrying')),
retry_count INT NOT NULL DEFAULT 0,
ack_status TEXT NOT NULL DEFAULT 'not_needed' CHECK (ack_status IN ('pending', 'received', 'not_supported', 'not_needed')),
event_id TEXT NOT NULL,
reason TEXT,
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
sent_at TIMESTAMPTZ,
ack_at TIMESTAMPTZ,
next_retry_at TIMESTAMPTZ,
CONSTRAINT notifications_unique_tx_event UNIQUE (transaction_id, event_id)
);
CREATE INDEX IF NOT EXISTS idx_notifications_device_status ON notifications (device_id, delivery_status);
CREATE INDEX IF NOT EXISTS idx_notifications_status_created ON notifications (delivery_status, created_at DESC);
CREATE TABLE IF NOT EXISTS ledger_entries (
id TEXT PRIMARY KEY,
transaction_id TEXT NOT NULL REFERENCES transactions (id) ON DELETE CASCADE,
merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE,
entry_type TEXT NOT NULL CHECK (entry_type IN ('gross_income', 'platform_fee', 'merchant_payable')),
amount NUMERIC(20,2) NOT NULL,
currency TEXT NOT NULL DEFAULT 'IDR',
direction TEXT NOT NULL CHECK (direction IN ('credit', 'debit')),
status TEXT NOT NULL DEFAULT 'posted' CHECK (status IN ('posted', 'voided')),
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL,
CONSTRAINT ledger_entries_unique_tx_type UNIQUE (transaction_id, entry_type)
);
CREATE INDEX IF NOT EXISTS idx_ledger_entries_merchant_created ON ledger_entries (merchant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_ledger_entries_tx ON ledger_entries (transaction_id);
CREATE TABLE IF NOT EXISTS roles (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
permissions_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role_id TEXT NOT NULL REFERENCES roles (id),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
created_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS audit_logs (
id TEXT PRIMARY KEY,
actor_type TEXT NOT NULL,
actor_id TEXT,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
before_json JSONB,
after_json JSONB,
source_ip TEXT,
request_id TEXT,
trace_id TEXT,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_audit_logs_entity ON audit_logs (entity_type, entity_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs (action, created_at DESC);
INSERT INTO roles (id, name, permissions_json, created_at)
VALUES ('role_admin', 'admin', '{"admin":"*"}'::jsonb, NOW())
ON CONFLICT (id) DO NOTHING;
INSERT INTO users (id, name, email, password_hash, role_id, status, created_at)
VALUES ('user_admin_seed', 'Admin Seed', 'admin@example.local', 'dev-only-admin-password', 'role_admin', 'active', NOW())
ON CONFLICT (id) DO NOTHING;
COMMIT;
`;