You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/src/migrations-pg.ts

385 lines
17 KiB
TypeScript

/**
* Postgres migration set (D19). Numbered, applied in order by db-pg.ts's runner,
* each inside its own transaction, recorded in schema_migrations. Inline strings
* (not .sql files) so esbuild bundles them into dist/server.cjs with no file I/O.
*
* Translation choices vs the SQLite SCHEMA (deliberate, recorded in the D19 spec):
* - every *_paise / money column is BIGINT (int4 overflows at ₹2.15 crore in paise);
* - 0/1 flags stay INTEGER — repos say `active=1` and that SQL must be identical on
* both engines (boolean would break it);
* - JSON payloads stay TEXT — node-pg auto-parses json/jsonb columns into objects,
* which would break the repos' JSON.parse at the edge. A jsonb ALTER is a later,
* pg-only optimisation if we ever index into payloads;
* - dates stay TEXT ISO (house convention, index-friendly, no timezone surprises);
* - the reminder.rule_kind CHECK is born in its final widened form (SQLite reaches
* it via the guarded table rebuild; Postgres starts correct).
*/
export interface PgMigration { id: string; sql: string }
export const PG_MIGRATIONS: PgMigration[] = [
{
id: '001-init',
sql: `
CREATE TABLE IF NOT EXISTS staff_user (
id text PRIMARY KEY, email text NOT NULL UNIQUE, display_name text NOT NULL,
role text NOT NULL CHECK (role IN ('owner','manager','staff')),
phone text, title text,
pw_salt text NOT NULL, pw_hash text NOT NULL, active integer NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS session (
token text PRIMARY KEY, staff_id text NOT NULL, expires_at text NOT NULL
);
CREATE TABLE IF NOT EXISTS client (
id text PRIMARY KEY, code text NOT NULL UNIQUE, name text NOT NULL,
gstin text, state_code text NOT NULL DEFAULT '32', address text NOT NULL DEFAULT '',
contacts text NOT NULL DEFAULT '[]',
status text NOT NULL DEFAULT 'active' CHECK (status IN ('lead','active','dormant','lost')),
owner_id text,
anydesk text, os text, district text, sector text, db_password_enc text,
notes text NOT NULL DEFAULT '', source text NOT NULL DEFAULT 'hq', created_at text NOT NULL
);
CREATE TABLE IF NOT EXISTS module (
id text PRIMARY KEY, code text NOT NULL UNIQUE, name text NOT NULL,
sac text NOT NULL DEFAULT '998313',
allowed_kinds text NOT NULL DEFAULT '["one_time","monthly","yearly","usage"]',
multi_subscription integer NOT NULL DEFAULT 0, active integer NOT NULL DEFAULT 1,
quote_content text NOT NULL DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS module_price_book (
id text PRIMARY KEY, module_id text NOT NULL, edition text NOT NULL DEFAULT 'standard',
kind text NOT NULL, price_paise bigint NOT NULL, effective_from text NOT NULL
);
CREATE TABLE IF NOT EXISTS client_module (
id text PRIMARY KEY, client_id text NOT NULL, module_id text NOT NULL,
status text NOT NULL DEFAULT 'quoted' CHECK (status IN
('quoted','ordered','installing','installed','trained','live','expired','cancelled')),
kind text NOT NULL, edition text NOT NULL DEFAULT 'standard',
installed_on text, completed_on text, trained_on text,
next_renewal text, active integer NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS tax_class (
class_code text NOT NULL, rate_pct_bp integer NOT NULL,
cess_pct_bp integer NOT NULL DEFAULT 0, effective_from text NOT NULL, effective_to text
);
CREATE TABLE IF NOT EXISTS doc_series (
doc_type text NOT NULL, fy text NOT NULL, prefix text NOT NULL, next_seq integer NOT NULL,
PRIMARY KEY (doc_type, fy)
);
CREATE TABLE IF NOT EXISTS document (
id text PRIMARY KEY, doc_type text NOT NULL CHECK (doc_type IN
('QUOTATION','PROFORMA','INVOICE','RECEIPT','CREDIT_NOTE')),
doc_no text, fy text NOT NULL, client_id text NOT NULL, doc_date text NOT NULL,
due_date text,
status text NOT NULL DEFAULT 'draft' CHECK (status IN
('draft','sent','accepted','invoiced','part_paid','paid','lost','cancelled')),
ref_doc_id text,
taxable_paise bigint NOT NULL, cgst_paise bigint NOT NULL, sgst_paise bigint NOT NULL,
igst_paise bigint NOT NULL, round_off_paise bigint NOT NULL, payable_paise bigint NOT NULL,
payload text NOT NULL,
source text NOT NULL DEFAULT 'hq',
created_by text NOT NULL, created_at text NOT NULL,
UNIQUE (doc_no)
);
CREATE TABLE IF NOT EXISTS document_event (
id text PRIMARY KEY, document_id text NOT NULL, at_wall text NOT NULL,
kind text NOT NULL, meta text NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS document_share (
id text PRIMARY KEY, document_id text NOT NULL, token text NOT NULL UNIQUE,
created_by text NOT NULL, created_at text NOT NULL,
expires_at text,
revoked integer NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS payment (
id text PRIMARY KEY, client_id text NOT NULL, received_on text NOT NULL,
mode text NOT NULL CHECK (mode IN ('bank','upi','cheque','cash','other')),
reference text NOT NULL DEFAULT '', amount_paise bigint NOT NULL,
tds_paise bigint NOT NULL DEFAULT 0, created_by text NOT NULL, created_at text NOT NULL
);
CREATE TABLE IF NOT EXISTS payment_allocation (
id text PRIMARY KEY, payment_id text NOT NULL, document_id text NOT NULL,
amount_paise bigint NOT NULL
);
CREATE TABLE IF NOT EXISTS email_account (
id text PRIMARY KEY, address text NOT NULL, refresh_token_enc text NOT NULL,
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','dead')), updated_at text NOT NULL
);
CREATE TABLE IF NOT EXISTS email_log (
id text PRIMARY KEY, document_id text, to_addr text NOT NULL, subject text NOT NULL,
status text NOT NULL CHECK (status IN ('sent','failed')), gmail_message_id text,
error text, at_wall text NOT NULL,
bounced integer NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS setting (
key text PRIMARY KEY, value text NOT NULL
);
CREATE TABLE IF NOT EXISTS audit_log (
id text PRIMARY KEY, at_wall text NOT NULL, user_id text NOT NULL, action text NOT NULL,
entity text NOT NULL, entity_id text NOT NULL, before_json text, after_json text
);
CREATE TABLE IF NOT EXISTS stg_client (
row_no integer PRIMARY KEY, code text, name text, gstin text, state_code text,
address text, phone text, email text, status text,
anydesk text, os text, district text, sector text,
problems text NOT NULL DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS stg_invoice (
row_no integer PRIMARY KEY, client_code text, doc_no text, doc_date text,
taxable_paise bigint, tax_paise bigint, total_paise bigint, paid integer,
problems text NOT NULL DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS recurring_plan (
id text PRIMARY KEY, client_id text NOT NULL,
client_module_id text,
cadence text NOT NULL CHECK (cadence IN ('monthly','yearly')),
amount_paise bigint,
next_run text NOT NULL,
policy text NOT NULL DEFAULT 'manual' CHECK (policy IN ('auto','manual')),
active integer NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS amc_contract (
id text PRIMARY KEY, client_id text NOT NULL, coverage text NOT NULL DEFAULT '',
period_from text NOT NULL, period_to text NOT NULL, amount_paise bigint NOT NULL,
renewal_reminder_days integer NOT NULL DEFAULT 30,
legacy_paid integer,
invoice_doc_id text, active integer NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS interaction_type (
code text PRIMARY KEY, label text NOT NULL
);
CREATE TABLE IF NOT EXISTS interaction (
id text PRIMARY KEY, client_id text NOT NULL, type_code text NOT NULL,
on_date text NOT NULL, staff_id text NOT NULL, notes text NOT NULL DEFAULT '',
outcome text CHECK (outcome IN ('positive','neutral','negative')),
follow_up_on text, created_at text NOT NULL
);
CREATE TABLE IF NOT EXISTS reminder (
id text PRIMARY KEY,
rule_kind text NOT NULL CHECK (rule_kind IN
('invoice_overdue','renewal_due','amc_expiring','follow_up','recurring_generated','email_bounced','quote_followup')),
subject_id text NOT NULL, due_period text NOT NULL, client_id text NOT NULL,
doc_id text,
status text NOT NULL DEFAULT 'queued' CHECK (status IN ('queued','sent','failed','dismissed')),
policy_applied text NOT NULL DEFAULT 'manual' CHECK (policy_applied IN ('auto','manual')),
error text, created_at text NOT NULL, sent_at text,
UNIQUE (rule_kind, subject_id, due_period)
);
CREATE TABLE IF NOT EXISTS reminder_schedule (
id text PRIMARY KEY, rule_kind text NOT NULL,
effective_from text NOT NULL, effective_to text,
day_offsets text NOT NULL,
subject text, body text
);
CREATE TABLE IF NOT EXISTS aws_usage (
id text PRIMARY KEY, client_id text NOT NULL,
month text NOT NULL,
storage_gb double precision NOT NULL DEFAULT 0,
transfer_gb double precision NOT NULL DEFAULT 0,
cost_paise bigint NOT NULL DEFAULT 0,
source text NOT NULL DEFAULT 'auto' CHECK (source IN ('auto','manual')),
updated_at text NOT NULL,
UNIQUE (client_id, month)
);
`,
},
{
id: '002-apex-parity',
sql: `
ALTER TABLE client_module ADD COLUMN IF NOT EXISTS provider text;
ALTER TABLE client_module ADD COLUMN IF NOT EXISTS username text;
ALTER TABLE client_module ADD COLUMN IF NOT EXISTS password_enc text;
ALTER TABLE client_module ADD COLUMN IF NOT EXISTS details text NOT NULL DEFAULT '[]';
ALTER TABLE client_module ADD COLUMN IF NOT EXISTS remark text;
CREATE TABLE IF NOT EXISTS client_branch (
id text PRIMARY KEY, client_id text NOT NULL, name text NOT NULL,
code text, active integer NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS ticket (
id text PRIMARY KEY, client_id text NOT NULL, branch_id text,
module_code text,
kind text NOT NULL DEFAULT '',
description text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'open' CHECK (status IN ('open','in_progress','waiting','closed','dropped')),
assigned_to text,
online_offline text,
opened_on text NOT NULL, closed_on text,
created_by text NOT NULL, created_at text NOT NULL,
source text NOT NULL DEFAULT 'hq'
);
`,
},
{
id: '003-module-field-schema',
sql: `
ALTER TABLE module ADD COLUMN IF NOT EXISTS field_spec text NOT NULL DEFAULT '[]';
ALTER TABLE client_module ADD COLUMN IF NOT EXISTS field_values text NOT NULL DEFAULT '{}';
ALTER TABLE client_module ADD COLUMN IF NOT EXISTS secrets_enc text;
`,
},
{
id: '004-usage-rate-band',
sql: `
CREATE TABLE IF NOT EXISTS usage_rate_band (
id text PRIMARY KEY, module_id text NOT NULL, effective_from text NOT NULL,
min_qty bigint NOT NULL, rate_paise bigint NOT NULL
);
`,
},
{
id: '005-project-milestone',
sql: `
CREATE TABLE IF NOT EXISTS project_milestone (
id text PRIMARY KEY, client_module_id text NOT NULL,
key text NOT NULL, label text NOT NULL,
done integer NOT NULL DEFAULT 0, done_on text, sort integer NOT NULL DEFAULT 0,
UNIQUE (client_module_id, key)
);
`,
},
{
id: '006-login-lockout',
sql: `
ALTER TABLE staff_user ADD COLUMN IF NOT EXISTS failed_count integer NOT NULL DEFAULT 0;
ALTER TABLE staff_user ADD COLUMN IF NOT EXISTS locked_until text;
`,
},
{
// D24 (red-team): indexes for the queries that run over the real data. Same DDL as the
// SQLite SCHEMA's INDEX_DDL (CREATE INDEX IF NOT EXISTS is valid on both engines).
id: '007-indexes',
sql: `
CREATE INDEX IF NOT EXISTS ix_document_client ON document (client_id);
CREATE INDEX IF NOT EXISTS ix_document_type_status ON document (doc_type, status);
CREATE INDEX IF NOT EXISTS ix_document_ref ON document (ref_doc_id);
CREATE INDEX IF NOT EXISTS ix_document_created_by ON document (created_by);
CREATE INDEX IF NOT EXISTS ix_document_event_doc ON document_event (document_id);
CREATE INDEX IF NOT EXISTS ix_document_share_doc ON document_share (document_id);
CREATE INDEX IF NOT EXISTS ix_payment_client ON payment (client_id);
CREATE INDEX IF NOT EXISTS ix_alloc_doc ON payment_allocation (document_id);
CREATE INDEX IF NOT EXISTS ix_alloc_payment ON payment_allocation (payment_id);
CREATE INDEX IF NOT EXISTS ix_client_module_client ON client_module (client_id);
CREATE INDEX IF NOT EXISTS ix_client_module_module ON client_module (module_id, active);
CREATE INDEX IF NOT EXISTS ix_client_module_renewal ON client_module (next_renewal);
CREATE INDEX IF NOT EXISTS ix_client_owner ON client (owner_id);
CREATE INDEX IF NOT EXISTS ix_client_branch_client ON client_branch (client_id);
CREATE INDEX IF NOT EXISTS ix_ticket_client ON ticket (client_id);
CREATE INDEX IF NOT EXISTS ix_ticket_status ON ticket (status);
CREATE INDEX IF NOT EXISTS ix_ticket_assigned ON ticket (assigned_to);
CREATE INDEX IF NOT EXISTS ix_interaction_client ON interaction (client_id);
CREATE INDEX IF NOT EXISTS ix_interaction_followup ON interaction (follow_up_on);
CREATE INDEX IF NOT EXISTS ix_reminder_status ON reminder (status);
CREATE INDEX IF NOT EXISTS ix_reminder_client ON reminder (client_id);
CREATE INDEX IF NOT EXISTS ix_reminder_doc ON reminder (doc_id);
CREATE INDEX IF NOT EXISTS ix_price_book_module ON module_price_book (module_id);
CREATE INDEX IF NOT EXISTS ix_rate_band_module ON usage_rate_band (module_id, effective_from);
CREATE INDEX IF NOT EXISTS ix_recurring_active_run ON recurring_plan (active, next_run);
CREATE INDEX IF NOT EXISTS ix_amc_client ON amc_contract (client_id);
CREATE INDEX IF NOT EXISTS ix_aws_client ON aws_usage (client_id);
CREATE INDEX IF NOT EXISTS ix_session_staff ON session (staff_id);
CREATE INDEX IF NOT EXISTS ix_audit_entity ON audit_log (entity, entity_id);
CREATE INDEX IF NOT EXISTS ix_milestone_cm ON project_milestone (client_module_id);
`,
},
{
id: '008-module-sort-order',
sql: `ALTER TABLE module ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 100;`,
},
{
// D25: login by username. Backfill from email so existing accounts keep working.
id: '009-staff-username',
sql: `
ALTER TABLE staff_user ADD COLUMN IF NOT EXISTS username text;
UPDATE staff_user SET username=email WHERE username IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS ix_staff_username ON staff_user (username);
`,
},
{
// D28: SMS gateway balance-check telemetry (operational sync log, not audited).
id: '010-sms-balance-check',
sql: `
CREATE TABLE IF NOT EXISTS sms_balance_check (
client_module_id text PRIMARY KEY,
status text NOT NULL,
balance integer,
message text,
checked_at text NOT NULL
);
`,
},
{
// D34: invited-employee flag (default password to change on first login).
id: '011-staff-must-change-password',
sql: `ALTER TABLE staff_user ADD COLUMN IF NOT EXISTS must_change_password integer NOT NULL DEFAULT 0;`,
},
{
// D35: client type (PACS vs Store), backfilled from the name.
id: '012-client-type',
sql: `
ALTER TABLE client ADD COLUMN IF NOT EXISTS client_type text NOT NULL DEFAULT 'PACS';
UPDATE client SET client_type='Store'
WHERE UPPER(TRIM(name)) LIKE '%STORE' OR UPPER(TRIM(name)) LIKE '%STORES';
`,
},
{
// D36: login attempt log (IP + user-agent + outcome).
id: '013-login-event',
sql: `
CREATE TABLE IF NOT EXISTS login_event (
id text PRIMARY KEY, at_wall text NOT NULL,
staff_id text, username_tried text NOT NULL, outcome text NOT NULL,
ip text NOT NULL DEFAULT '', user_agent text NOT NULL DEFAULT ''
);
`,
},
{
// D37: society category (directory taxonomy), backfilled from the name.
id: '014-client-category',
sql: `
ALTER TABLE client ADD COLUMN IF NOT EXISTS category text NOT NULL DEFAULT 'Other';
UPDATE client SET category = CASE
WHEN client_type = 'Store' THEN 'Store'
WHEN UPPER(name) LIKE '%VANITHA%' THEN 'Vanitha'
WHEN UPPER(name) LIKE '%HOUSING%' THEN 'Housing'
WHEN UPPER(name) LIKE '%URBAN%' THEN 'Urban Bank'
WHEN UPPER(name) LIKE '%EMPLOYEE%' OR UPPER(name) LIKE '%TEACHER%' OR UPPER(name) LIKE '%STAFF%' THEN 'Employees'
WHEN UPPER(name) LIKE '%MARKETING%' THEN 'Marketing'
WHEN UPPER(name) LIKE '%AGRICULTUR%' OR UPPER(name) LIKE '%RURAL DEVELOPMENT%' OR UPPER(name) LIKE '%RUBBER%' THEN 'Agricultural'
WHEN UPPER(name) LIKE '%SERVICE CO-OPERATIVE%' OR UPPER(name) LIKE '%SERVICE BANK%' OR UPPER(name) LIKE '%SCB%' THEN 'Service Bank'
WHEN UPPER(name) LIKE '%BANK%' THEN 'Bank (other)'
WHEN UPPER(name) LIKE '%SOCIET%' OR UPPER(name) LIKE '%CO-OPERATIVE%' OR UPPER(name) LIKE '%AICOS%' THEN 'Society (other)'
ELSE 'Other'
END;
`,
},
{
// Client Detail redesign task 4: link an advance/balance payment-step tick to the real payment.
id: '015-milestone-payment-id',
sql: `ALTER TABLE project_milestone ADD COLUMN IF NOT EXISTS payment_id text;`,
},
{
// Client Detail redesign task 7: link the quotation step to the quotation/proforma sent.
id: '016-milestone-document-id',
sql: `ALTER TABLE project_milestone ADD COLUMN IF NOT EXISTS document_id text;`,
},
{
// Client Detail redesign task 6: ticket classification (key from the `ticket.types` setting).
id: '017-ticket-type',
sql: `ALTER TABLE ticket ADD COLUMN IF NOT EXISTS type text NOT NULL DEFAULT 'service';`,
},
{
// Pre-ship audit FIX D: real creation date for the "no-tick project" stalled-onboarding
// measurement (nullable — existing rows keep an unknown/NULL date, never fabricated).
id: '018-client-module-created-at',
sql: `ALTER TABLE client_module ADD COLUMN IF NOT EXISTS created_at text;`,
},
{
// FIX 4 (regression): the APEX importer inserted client_module rows WITHOUT created_at
// (only assignModule stamped it going forward), so every one of the ~300 imported clients'
// projects permanently NULL'd out of the stalled-onboarding age measurement. Backfill from
// installed_on where known; a row with neither stays NULL (never fabricated).
id: '019-client-module-created-at-backfill',
sql: `UPDATE client_module SET created_at = installed_on WHERE created_at IS NULL AND installed_on IS NOT NULL;`,
},
]