Spaces:
Running
Running
| import Database from 'better-sqlite3'; | |
| import { drizzle } from 'drizzle-orm/better-sqlite3'; | |
| import { config } from '../config/env.js'; | |
| import * as schema from './schema.js'; | |
| const sqlite = new Database(config.DATABASE_URL); | |
| sqlite.pragma('journal_mode = WAL'); | |
| // Create tables if they don't exist | |
| sqlite.exec(` | |
| CREATE TABLE IF NOT EXISTS users ( | |
| id TEXT PRIMARY KEY, | |
| google_id TEXT UNIQUE, | |
| email TEXT UNIQUE NOT NULL, | |
| name TEXT, | |
| avatar_url TEXT, | |
| access_token TEXT, | |
| refresh_token TEXT, | |
| token_expires TEXT, | |
| role TEXT DEFAULT 'viewer', | |
| created_at TEXT, | |
| updated_at TEXT | |
| ); | |
| CREATE TABLE IF NOT EXISTS transactions ( | |
| id TEXT PRIMARY KEY, | |
| email_id TEXT UNIQUE NOT NULL, | |
| user_id TEXT, | |
| date TEXT NOT NULL, | |
| sender TEXT NOT NULL, | |
| amount REAL NOT NULL, | |
| currency TEXT DEFAULT 'CAD', | |
| reference TEXT, | |
| message TEXT, | |
| recipient_email TEXT, | |
| branch TEXT, | |
| status TEXT, | |
| raw_email TEXT, | |
| raw_email_html TEXT, | |
| screenshot_original TEXT, | |
| screenshot_preview TEXT, | |
| parsed_at TEXT, | |
| reviewed INTEGER DEFAULT 0, | |
| reviewed_by TEXT, | |
| created_at TEXT | |
| ); | |
| CREATE TABLE IF NOT EXISTS branch_config ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| email TEXT UNIQUE NOT NULL, | |
| branch TEXT NOT NULL, | |
| active INTEGER DEFAULT 1, | |
| updated_at TEXT | |
| ); | |
| CREATE TABLE IF NOT EXISTS scan_logs ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| user_id TEXT, | |
| scan_preset TEXT, | |
| scan_start_date TEXT NOT NULL, | |
| scan_end_date TEXT NOT NULL, | |
| force_rescan INTEGER DEFAULT 0, | |
| started_at TEXT NOT NULL, | |
| finished_at TEXT, | |
| emails_found INTEGER DEFAULT 0, | |
| emails_parsed INTEGER DEFAULT 0, | |
| emails_skipped INTEGER DEFAULT 0, | |
| errors INTEGER DEFAULT 0, | |
| error_details TEXT, | |
| ai_provider TEXT, | |
| ai_model TEXT, | |
| created_at TEXT | |
| ); | |
| CREATE TABLE IF NOT EXISTS ai_settings ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| user_id TEXT, | |
| provider TEXT NOT NULL, | |
| model TEXT NOT NULL, | |
| api_key TEXT, | |
| base_url TEXT, | |
| is_active INTEGER DEFAULT 1, | |
| created_at TEXT | |
| ); | |
| CREATE TABLE IF NOT EXISTS envelopes ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| envelope_number TEXT NOT NULL, | |
| business_name TEXT, | |
| name TEXT NOT NULL, | |
| email TEXT | |
| ); | |
| `); | |
| // Authoritative branch list — only these 20 branches should exist | |
| const now = new Date().toISOString(); | |
| const branchSeeds: [string, string][] = [ | |
| ['finances@iccameriques.org', 'Montreal'], | |
| ['gatineauottawa.finances@iccameriques.org', 'Gatineau'], | |
| ['moncton.finances@iccameriques.org', 'Moncton'], | |
| ['chibougamau.finances@iccameriques.org', 'Chibougamau'], | |
| ['trois-rivieres.finances@iccameriques.org', 'Trois-Rivieres'], | |
| ['sherbrooke.finances@iccameriques.org', 'Sherbrooke'], | |
| ['toronto.finances@iccameriques.org', 'Toronto'], | |
| ['quebec.finances@iccameriques.org', 'Quebec'], | |
| ['lvl.finances@iccameriques.org', 'Laval'], | |
| ['lgl.finances@iccameriques.org', 'Longueuil'], | |
| ['victoriaville.finances@iccameriques.org', 'Victoriaville'], | |
| ['valleyfield.finances@iccameriques.org', 'Valleyfield'], | |
| ['finances.rdl@iccameriques.org', 'Riviere-du-Loup'], | |
| ['ottawa.finances@iccameriques.org', 'Ottawa'], | |
| ['kingston.finances@iccameriques.org', 'Kingston'], | |
| ['hamilton.finances@iccameriques.org', 'Hamilton'], | |
| ['edmonton.finances@iccameriques.org', 'Edmonton'], | |
| ['shawinigan.finances@iccameriques.org', 'Shawinigan'], | |
| ['timmins.finances@iccameriques.org', 'Timmins'], | |
| ['sudbury.finances@iccameriques.org', 'Sudbury'], | |
| ['sthy.finances@iccameriques.org', 'Saint-Hyacinthe'], | |
| ]; | |
| const seedEmails = new Set(branchSeeds.map(([e]) => e)); | |
| // Remove any branches not in the authoritative list | |
| const allBranches = sqlite.prepare('SELECT id, email FROM branch_config').all() as { id: number; email: string }[]; | |
| const deleteBranch = sqlite.prepare('DELETE FROM branch_config WHERE id = ?'); | |
| const insertBranch = sqlite.prepare( | |
| 'INSERT OR IGNORE INTO branch_config (email, branch, active, updated_at) VALUES (?, ?, 1, ?)' | |
| ); | |
| const seedTx = sqlite.transaction(() => { | |
| for (const row of allBranches) { | |
| if (!seedEmails.has(row.email)) { | |
| deleteBranch.run(row.id); | |
| } | |
| } | |
| for (const [email, branch] of branchSeeds) { | |
| insertBranch.run(email, branch, now); | |
| } | |
| }); | |
| seedTx(); | |
| export const db = drizzle(sqlite, { schema }); | |
| export { sqlite, schema }; | |
| // Safe migration — add columns that may not exist yet | |
| try { sqlite.exec('ALTER TABLE transactions ADD COLUMN raw_email_html TEXT'); } catch {} | |
| try { sqlite.exec('ALTER TABLE transactions ADD COLUMN screenshot_original TEXT'); } catch {} | |
| try { sqlite.exec('ALTER TABLE transactions ADD COLUMN screenshot_preview TEXT'); } catch {} | |
| try { sqlite.exec('ALTER TABLE transactions ADD COLUMN envelope_number TEXT'); } catch {} | |
| // Seed envelopes from Google Sheets (async — runs after module load) | |
| const GSHEET_CSV_URL = 'https://docs.google.com/spreadsheets/d/1dOxxBASqevE7Kwnb_xl9x_BX7DLKOHy225s-3lQq3aQ/export?format=csv'; | |
| function parseCsvLine(line: string): string[] { | |
| const cols: string[] = []; | |
| let current = ''; | |
| let inQuotes = false; | |
| for (let i = 0; i < line.length; i++) { | |
| const ch = line[i]; | |
| if (ch === '"') { inQuotes = !inQuotes; continue; } | |
| if (ch === ',' && !inQuotes) { cols.push(current.trim()); current = ''; continue; } | |
| current += ch; | |
| } | |
| cols.push(current.trim()); | |
| return cols; | |
| } | |
| function seedEnvelopesFromCsv(csvContent: string): number { | |
| const allLines = csvContent.split('\n'); | |
| if (allLines.length < 2) return 0; | |
| // Detect column indices from header row | |
| const headers = parseCsvLine(allLines[0]).map(h => h.toLowerCase().trim()); | |
| const envIdx = headers.findIndex(h => h.includes('envelope')); | |
| const nameIdx = headers.findIndex(h => h === 'name' || h === 'nom'); | |
| const bizIdx = headers.findIndex(h => h.includes('business')); | |
| const emailIdx = headers.findIndex(h => h.includes('email')); | |
| if (envIdx === -1 || nameIdx === -1) { | |
| console.error('[DB] CSV missing required columns. Headers:', headers); | |
| return 0; | |
| } | |
| const lines = allLines.slice(1); // skip header | |
| const insertEnvelope = sqlite.prepare( | |
| 'INSERT INTO envelopes (envelope_number, business_name, name, email) VALUES (?, ?, ?, ?)' | |
| ); | |
| let count = 0; | |
| sqlite.exec('DELETE FROM envelopes'); | |
| const seedTxn = sqlite.transaction(() => { | |
| for (const line of lines) { | |
| const cols = parseCsvLine(line); | |
| const envNum = cols[envIdx] || ''; | |
| const name = cols[nameIdx] || ''; | |
| const bizName = bizIdx >= 0 ? cols[bizIdx] || null : null; | |
| const email = emailIdx >= 0 ? cols[emailIdx] || null : null; | |
| if (!name || !envNum) continue; | |
| insertEnvelope.run(envNum, bizName, name, email); | |
| count++; | |
| } | |
| }); | |
| seedTxn(); | |
| return count; | |
| } | |
| export async function syncEnvelopesFromSheet(): Promise<number> { | |
| try { | |
| const res = await fetch(GSHEET_CSV_URL); | |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); | |
| const csvContent = await res.text(); | |
| const count = seedEnvelopesFromCsv(csvContent); | |
| console.log(`[DB] Synced ${count} envelopes from Google Sheets`); | |
| // Clear the envelope cache so lookups use fresh data | |
| try { | |
| const { clearEnvelopeCache } = await import('../services/envelopeService.js'); | |
| clearEnvelopeCache(); | |
| } catch {} | |
| return count; | |
| } catch (err) { | |
| console.error(`[DB] Failed to sync envelopes from Google Sheets:`, err); | |
| return -1; | |
| } | |
| } | |
| // Initial sync on startup (non-blocking) | |
| const envelopeCount = (sqlite.prepare('SELECT COUNT(*) as cnt FROM envelopes').get() as { cnt: number }).cnt; | |
| if (envelopeCount === 0) { | |
| syncEnvelopesFromSheet(); | |
| } else { | |
| // Already have data — refresh in background after 5s to pick up sheet changes | |
| setTimeout(() => syncEnvelopesFromSheet(), 5000); | |
| } | |