const fs = require('node:fs'); const path = require('node:path'); const postgres = require('postgres'); type SqlClient = ReturnType; type SeedOption = { code: string; label: string; value: string; sortOrder: number; }; type BranchSeedRow = { id: string; category: string; code: string; label: string; }; function loadEnvFile(filePath: string) { if (!fs.existsSync(filePath)) { return; } const content = fs.readFileSync(filePath, 'utf8'); for (const rawLine of content.split(/\r?\n/)) { const line = rawLine.trim(); if (!line || line.startsWith('#')) { continue; } const separatorIndex = line.indexOf('='); if (separatorIndex === -1) { continue; } const key = line.slice(0, separatorIndex).trim(); let value = line.slice(separatorIndex + 1).trim(); if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); } if (!(key in process.env)) { process.env[key] = value; } } } function loadLocalEnv() { loadEnvFile(path.resolve(process.cwd(), '.env.local')); loadEnvFile(path.resolve(process.cwd(), '.env')); } function requireEnv(name: string) { const value = process.env[name]?.trim(); if (!value) { throw new Error(`Missing required environment variable: ${name}`); } return value; } function getCurrentPeriod(date = new Date()) { const year = String(date.getFullYear()).slice(-2); const month = String(date.getMonth() + 1).padStart(2, '0'); return `${year}${month}`; } const FOUNDATION_OPTIONS = { crm_branch: [ { code: 'head_office', label: 'สำนักงานใหญ่', value: 'head_office', sortOrder: 1 }, { code: 'factory', label: 'Factory', value: 'factory', sortOrder: 2 }, { code: 'service', label: 'Service', value: 'service', sortOrder: 3 } ], crm_customer_status: [ { code: 'active', label: 'Active', value: 'active', sortOrder: 1 }, { code: 'inactive', label: 'Inactive', value: 'inactive', sortOrder: 2 }, { code: 'suspended', label: 'Suspended', value: 'suspended', sortOrder: 3 } ], crm_customer_type: [ { code: 'company', label: 'Company', value: 'company', sortOrder: 1 }, { code: 'individual', label: 'Individual', value: 'individual', sortOrder: 2 } ], crm_customer_group: [ { code: 'strategic', label: 'Strategic', value: 'strategic', sortOrder: 1 }, { code: 'standard', label: 'Standard', value: 'standard', sortOrder: 2 }, { code: 'project', label: 'Project', value: 'project', sortOrder: 3 } ], crm_enquiry_status: [ { code: 'new', label: 'New', value: 'new', sortOrder: 1 }, { code: 'qualifying', label: 'Qualifying', value: 'qualifying', sortOrder: 2 }, { code: 'requirement', label: 'Requirement', value: 'requirement', sortOrder: 3 }, { code: 'follow_up', label: 'Follow Up', value: 'follow_up', sortOrder: 4 }, { code: 'converted', label: 'Converted', value: 'converted', sortOrder: 5 }, { code: 'closed_lost', label: 'Closed Lost', value: 'closed_lost', sortOrder: 6 }, { code: 'cancelled', label: 'Cancelled', value: 'cancelled', sortOrder: 7 } ], crm_quotation_status: [ { code: 'draft', label: 'Draft', value: 'draft', sortOrder: 1 }, { code: 'pending_approval', label: 'Pending Approval', value: 'pending_approval', sortOrder: 2 }, { code: 'approved', label: 'Approved', value: 'approved', sortOrder: 3 }, { code: 'rejected', label: 'Rejected', value: 'rejected', sortOrder: 4 }, { code: 'sent', label: 'Sent', value: 'sent', sortOrder: 5 }, { code: 'accepted', label: 'Accepted', value: 'accepted', sortOrder: 6 }, { code: 'lost', label: 'Lost', value: 'lost', sortOrder: 7 }, { code: 'cancelled', label: 'Cancelled', value: 'cancelled', sortOrder: 8 }, { code: 'revised', label: 'Revised', value: 'revised', sortOrder: 9 } ], crm_quotation_type: [ { code: 'crane', label: 'Crane', value: 'crane', sortOrder: 1 }, { code: 'dockdoor', label: 'Dock Door', value: 'dockdoor', sortOrder: 2 }, { code: 'solarcell', label: 'Solar Cell', value: 'solarcell', sortOrder: 3 }, { code: 'service', label: 'Service', value: 'service', sortOrder: 4 }, { code: 'other', label: 'Other', value: 'other', sortOrder: 5 } ], crm_product_type: [ { code: 'crane', label: 'Crane', value: 'crane', sortOrder: 1 }, { code: 'dockdoor', label: 'Dock Door', value: 'dockdoor', sortOrder: 2 }, { code: 'solarcell', label: 'Solar Cell', value: 'solarcell', sortOrder: 3 } ], crm_currency: [ { code: 'THB', label: 'THB', value: 'THB', sortOrder: 1 }, { code: 'USD', label: 'USD', value: 'USD', sortOrder: 2 }, { code: 'EUR', label: 'EUR', value: 'EUR', sortOrder: 3 } ], crm_discount_type: [ { code: 'fixed', label: 'Fixed', value: 'fixed', sortOrder: 1 }, { code: 'percentage', label: 'Percentage', value: 'percentage', sortOrder: 2 } ], crm_unit: [ { code: 'pcs', label: 'PCS', value: 'pcs', sortOrder: 1 }, { code: 'set', label: 'Set', value: 'set', sortOrder: 2 }, { code: 'lot', label: 'Lot', value: 'lot', sortOrder: 3 }, { code: 'job', label: 'Job', value: 'job', sortOrder: 4 } ], crm_sent_via: [ { code: 'email', label: 'Email', value: 'email', sortOrder: 1 }, { code: 'manual', label: 'Manual', value: 'manual', sortOrder: 2 }, { code: 'system', label: 'System', value: 'system', sortOrder: 3 } ], crm_quotation_customer_role: [ { code: 'owner', label: 'Owner', value: 'owner', sortOrder: 1 }, { code: 'consultant', label: 'Consultant', value: 'consultant', sortOrder: 2 }, { code: 'contractor', label: 'Contractor', value: 'contractor', sortOrder: 3 }, { code: 'billing', label: 'Billing', value: 'billing', sortOrder: 4 } ], crm_quotation_topic_type: [ { code: 'scope', label: 'Scope', value: 'scope', sortOrder: 1 }, { code: 'exclusion', label: 'Exclusion', value: 'exclusion', sortOrder: 2 }, { code: 'payment', label: 'Payment', value: 'payment', sortOrder: 3 } ], crm_payment_term: [ { code: 'cash', label: 'Cash', value: 'cash', sortOrder: 1 }, { code: 'credit_30', label: 'Credit 30', value: 'credit_30', sortOrder: 2 }, { code: 'credit_60', label: 'Credit 60', value: 'credit_60', sortOrder: 3 } ], crm_priority: [ { code: 'low', label: 'Low', value: 'low', sortOrder: 1 }, { code: 'normal', label: 'Normal', value: 'normal', sortOrder: 2 }, { code: 'high', label: 'High', value: 'high', sortOrder: 3 }, { code: 'urgent', label: 'Urgent', value: 'urgent', sortOrder: 4 } ], crm_followup_type: [ { code: 'call', label: 'Call', value: 'call', sortOrder: 1 }, { code: 'meeting', label: 'Meeting', value: 'meeting', sortOrder: 2 }, { code: 'site_visit', label: 'Site Visit', value: 'site_visit', sortOrder: 3 }, { code: 'email', label: 'Email', value: 'email', sortOrder: 4 }, { code: 'line', label: 'LINE', value: 'line', sortOrder: 5 } ], crm_lead_channel: [ { code: 'website', label: 'Website', value: 'website', sortOrder: 1 }, { code: 'referral', label: 'Referral', value: 'referral', sortOrder: 2 }, { code: 'sales', label: 'Sales', value: 'sales', sortOrder: 3 }, { code: 'facebook', label: 'Facebook', value: 'facebook', sortOrder: 4 }, { code: 'line', label: 'LINE', value: 'line', sortOrder: 5 }, { code: 'other', label: 'Other', value: 'other', sortOrder: 6 } ] }; const DOCUMENT_SEQUENCES = [ { documentType: 'customer', prefix: 'CUS' }, { documentType: 'contact', prefix: 'CON' }, { documentType: 'enquiry', prefix: 'ENQ' }, { documentType: 'quotation', prefix: 'QT' }, { documentType: 'approval', prefix: 'APV' } ]; async function upsertMasterOptionsForOrganization( sql: SqlClient, organizationId: string ): Promise { const branchRows: BranchSeedRow[] = []; for (const [category, options] of Object.entries(FOUNDATION_OPTIONS) as Array< [string, SeedOption[]] >) { for (const option of options) { const [row] = await sql` insert into ms_options ( id, organization_id, category, code, label, value, parent_id, sort_order, is_active, metadata, deleted_at ) values ( ${crypto.randomUUID()}, ${organizationId}, ${category}, ${option.code}, ${option.label}, ${option.value}, ${null}, ${option.sortOrder}, ${true}, ${null}, ${null} ) on conflict (organization_id, category, code) do update set label = excluded.label, value = excluded.value, parent_id = excluded.parent_id, sort_order = excluded.sort_order, is_active = excluded.is_active, metadata = excluded.metadata, deleted_at = excluded.deleted_at, updated_at = now() returning id, category, code, label `; if (category === 'crm_branch') { branchRows.push(row); } } } return branchRows; } async function upsertDocumentSequencesForOrganization( sql: SqlClient, organizationId: string, branchRows: BranchSeedRow[] ) { if (!branchRows.length) { throw new Error( `No crm_branch options were found for organization ${organizationId}. Foundation branch seed must run first.` ); } const period = getCurrentPeriod(); for (const branch of branchRows) { for (const sequence of DOCUMENT_SEQUENCES) { await sql` insert into document_sequences ( id, organization_id, branch_id, document_type, prefix, period, current_number, padding_length, is_active ) values ( ${crypto.randomUUID()}, ${organizationId}, ${branch.id}, ${sequence.documentType}, ${sequence.prefix}, ${period}, ${0}, ${3}, ${true} ) on conflict (organization_id, document_type, period, branch_id) do update set prefix = excluded.prefix, padding_length = excluded.padding_length, is_active = excluded.is_active, updated_at = now() `; } } } async function main() { loadLocalEnv(); const databaseUrl = requireEnv('DATABASE_URL'); const sql = postgres(databaseUrl, { prepare: false }); try { const organizations = await sql` select id, name from organizations order by name asc `; if (organizations.length === 0) { throw new Error( 'No organizations found. Create at least one organization before running foundation seed.' ); } for (const organization of organizations) { const branchRows = await upsertMasterOptionsForOrganization(sql, organization.id); await upsertDocumentSequencesForOrganization(sql, organization.id, branchRows); console.log(`[seed-foundation] Seeded foundation data for ${organization.name}`); } } finally { await sql.end(); } } if (require.main === module) { main().catch((error) => { console.error('[seed-foundation] Failed:', error.message); process.exitCode = 1; }); }