diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 0b1feee..6fe0052 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -36,3 +36,36 @@ Examples: ## Worker - `worker/src/worker.js` is a placeholder loop. - Later it will pull from connectors, OCR, classify, and run rule assignment. + +## Local runbook +Preferred path: +```bash +cd /root/clawd/mastermind-mvp +cp .env.example .env +``` + +Set at least: +```bash +SESSION_SECRET=replace-with-a-long-random-string +BASE_URL=http://localhost:3005 +BOOTSTRAP_OWNER_EMAIL=owner@local +BOOTSTRAP_OWNER_PASSWORD=ChangeMe12345 +``` + +Start the full stack with Postgres: +```bash +docker compose up -d --build +``` + +Alternative direct Node.js mode with the in-memory DB: +```bash +npm ci --prefix web +npm ci --prefix worker +DATABASE_URL=memory://local SESSION_SECRET=replace-with-a-long-random-string BASE_URL=http://localhost:3005 BOOTSTRAP_OWNER_EMAIL=owner@local BOOTSTRAP_OWNER_PASSWORD=ChangeMe12345 npm run start:web +DATABASE_URL=memory://local npm run start:worker +``` + +Run tests: +```bash +npm test +``` diff --git a/INSTALL.md b/INSTALL.md index ef4df14..61dd2b6 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,7 +1,7 @@ # INSTALL — Mastermind MVP ## Prerequisites -- Docker + Docker Compose +- Docker + Docker Compose (recommended) - A stable hostname/IP you can reach (LAN or Tailscale) ## Configure @@ -13,13 +13,14 @@ cp .env.example .env Edit `.env`: - `SESSION_SECRET` — set to a long random value - `BASE_URL` — the URL you use to reach the app (Tailscale IP recommended) -- `BOOTSTRAP_OWNER_EMAIL` / `BOOTSTRAP_OWNER_PASSWORD` — defaults are set for local MVP use (`owner@local` / `owner`) +- `BOOTSTRAP_OWNER_EMAIL` / `BOOTSTRAP_OWNER_PASSWORD` — one-time local owner login created only when no local identities exist + - Defaults in `.env.example` are set for local MVP use: `owner@local` / `owner` OAuth is optional for now: - `GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET` - `MICROSOFT_CLIENT_ID/MICROSOFT_CLIENT_SECRET` -## Start +## Start (Docker Compose) ```bash docker compose up -d --build ``` @@ -29,10 +30,11 @@ App: - With the default `.env.example`, that is `http://localhost:3005/login` ## First login (important) -On first run, if no local identities exist, the app creates: -- `owner@local` / `owner` by default from `.env.example` +On first run, if no local identities exist, the app creates the bootstrap local owner from: +- `BOOTSTRAP_OWNER_EMAIL` +- `BOOTSTRAP_OWNER_PASSWORD` -Immediately change it: +Immediately change that password: - http://:3005/account/password ## Create your first project @@ -58,3 +60,28 @@ Set `BASE_URL` correctly before authorizing. ```bash npm test ``` + +## Direct Node.js run (no Docker, memory DB) +Install dependencies: +```bash +npm ci --prefix web +npm ci --prefix worker +``` + +Terminal 1: +```bash +cd /path/to/mastermind-mvp +export DATABASE_URL=memory://local +export SESSION_SECRET='replace-with-a-long-random-string' +export BASE_URL='http://localhost:3005' +export BOOTSTRAP_OWNER_EMAIL='owner@local' +export BOOTSTRAP_OWNER_PASSWORD='ChangeMe12345' +npm run start:web +``` + +Terminal 2: +```bash +cd /path/to/mastermind-mvp +export DATABASE_URL=memory://local +npm run start:worker +``` diff --git a/README.md b/README.md index 36babab..ad89d99 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Common local commands: - `data/` — persisted data volume (Postgres + uploads) ## Security notes (MVP) -- First run auto-creates: `owner@local / owner` — change immediately. +- First run can bootstrap a local owner account from `BOOTSTRAP_OWNER_EMAIL` and `BOOTSTRAP_OWNER_PASSWORD`. - OAuth secrets live in `.env` (do not commit) - This MVP is intended to be run privately (LAN/Tailscale) until hardened. diff --git a/docker-compose.yml b/docker-compose.yml index b71cce3..6bdeeeb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,12 +17,17 @@ services: web: build: ./web + command: npm run start environment: NODE_ENV: development PORT: 3005 DATABASE_URL: postgres://mastermind:${DB_PASSWORD:-mastermind}@db:5432/mastermind SESSION_SECRET: ${SESSION_SECRET:-change-me} BASE_URL: ${BASE_URL:-http://localhost:3005} + TRUST_PROXY: ${TRUST_PROXY:-false} + COOKIE_SECURE: ${COOKIE_SECURE:-false} + BOOTSTRAP_OWNER_EMAIL: ${BOOTSTRAP_OWNER_EMAIL:-} + BOOTSTRAP_OWNER_PASSWORD: ${BOOTSTRAP_OWNER_PASSWORD:-} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-} @@ -44,6 +49,7 @@ services: worker: build: ./worker + command: npm run start environment: NODE_ENV: development DATABASE_URL: postgres://mastermind:${DB_PASSWORD:-mastermind}@db:5432/mastermind diff --git a/package.json b/package.json index 0f6b819..cf31586 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,8 @@ "description": "", "main": "index.js", "scripts": { + "start:web": "node web/src/index.js", + "start:worker": "node worker/src/worker.js", "test": "node --test", "test:ci": "CI=1 node --test --test-reporter=spec", "up": "docker compose up --build", diff --git a/test/smoke.test.js b/test/smoke.test.js new file mode 100644 index 0000000..fedaa9c --- /dev/null +++ b/test/smoke.test.js @@ -0,0 +1,139 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const rootDir = process.cwd(); + +test('web and worker boot cleanly in direct node mode', (t) => { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mastermind-startup-')); + const env = { + ...process.env, + DATA_DIR: dataDir, + DATABASE_URL: 'memory://smoke', + SESSION_SECRET: 'smoke-test-secret', + BOOTSTRAP_OWNER_EMAIL: 'owner@local', + BOOTSTRAP_OWNER_PASSWORD: 'owner', + MASTERMIND_DISABLE_LISTEN: '1', + NODE_ENV: 'test', + WORKER_TICK_MS: '100', + WORKER_RUN_ONCE: '1' + }; + + t.after(() => { + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + const web = spawnSync(process.execPath, ['web/src/index.js'], { + cwd: rootDir, + env, + encoding: 'utf8' + }); + const worker = spawnSync(process.execPath, ['worker/src/worker.js'], { + cwd: rootDir, + env, + encoding: 'utf8' + }); + + assert.equal(web.status, 0, web.stderr || web.stdout); + assert.equal(worker.status, 0, worker.stderr || worker.stdout); +}); + +test('memory-backed flow supports project creation, email sorting, and draft generation', async () => { + const [{ createMemoryPool }, { applyRulesToEmail }, { buildPCOBody }] = await Promise.all([ + import('../web/src/lib/memory-db.js'), + import('../web/src/lib/email-rules.js'), + import('../web/src/lib/utils.js') + ]); + + const pool = createMemoryPool(); + + const ownerInsert = await pool.query( + "insert into users(email, display_name, role) values ($1,'Owner','owner') returning id", + ['owner@local'] + ); + const ownerId = ownerInsert.rows[0].id; + await pool.query( + "insert into identities(user_id, provider, email, password_hash) values ($1,'local',$2,$3)", + [ownerId, 'owner@local', 'hash'] + ); + + const projectInsert = await pool.query( + `insert into projects(created_by_user_id,name,job_number,role_mode,gc_name,city,state,keywords) + values ($1,$2,$3,$4,$5,$6,$7,$8) + returning id`, + [ownerId, 'Smoke Test Project', '0222600001', 'ec', 'Example GC', 'Durham', 'NC', 'smoke'] + ); + const projectId = projectInsert.rows[0].id; + await pool.query( + `insert into project_members(project_id,user_id,project_role) values ($1,$2,'owner') + on conflict do nothing`, + [projectId, ownerId] + ); + + await pool.query( + `insert into email_rules(created_by_user_id, project_id, match_type, match_value, priority) + values ($1,$2,$3,$4,$5) + returning id`, + [ownerId, projectId, 'subject_contains', '0222600001', 10] + ); + + const emailInsert = await pool.query( + `insert into ingested_emails(created_by_user_id, source, from_addr, to_addr, cc_addr, subject, date, body_text, body_html, has_attachments, raw_path, sha256, status) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'unsorted') + returning *`, + [ + ownerId, + 'upload', + 'pm@example.com', + 'team@example.com', + '', + 'RE: 0222600001 coordination', + new Date('2026-03-01T12:00:00.000Z'), + 'Need pricing and schedule impact.', + '', + false, + '/tmp/sample.eml', + 'abc123' + ] + ); + const email = emailInsert.rows[0]; + + const rules = (await pool.query( + `select * from email_rules where enabled=true order by priority asc, created_at asc` + )).rows; + const match = await applyRulesToEmail(email, rules); + assert.deepEqual( + { projectId: match.projectId, confidence: match.confidence }, + { projectId, confidence: 0.9 } + ); + + await pool.query( + `update ingested_emails set project_id=$1, status=$2, confidence=$3 where id=$4`, + [projectId, 'assigned', match.confidence, email.id] + ); + + const assignedEmails = await pool.query( + `select id, from_addr, subject, date, source + from ingested_emails + where project_id=$1 + order by date desc nulls last, created_at desc + limit 300`, + [projectId] + ); + assert.equal(assignedEmails.rows.length, 1); + assert.equal(assignedEmails.rows[0].subject, 'RE: 0222600001 coordination'); + + const body = buildPCOBody(email, { name: 'Smoke Test Project', job_number: '0222600001' }); + const draftInsert = await pool.query( + `insert into pco_drafts(created_by_user_id, project_id, source_email_id, title, body) + values ($1,$2,$3,$4,$5) returning id`, + [ownerId, projectId, email.id, 'PCO: RE: 0222600001 coordination', body] + ); + + const draft = await pool.query('select * from pco_drafts where id=$1', [draftInsert.rows[0].id]); + assert.equal(draft.rows[0].source_email_id, email.id); + assert.match(draft.rows[0].body, /Need pricing and schedule impact\./); +}); diff --git a/test/utils.test.js b/test/utils.test.js new file mode 100644 index 0000000..0607e64 --- /dev/null +++ b/test/utils.test.js @@ -0,0 +1,47 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +let utilsPromise; + +async function loadUtils() { + utilsPromise ||= import('../web/src/lib/utils.js'); + return utilsPromise; +} + +test('extractDomain returns lower-cased sender domain', async () => { + const { extractDomain } = await loadUtils(); + assert.equal(extractDomain('Jane Doe '), 'example.com'); + assert.equal(extractDomain('no-at-symbol'), ''); +}); + +test('extractFirstJobNumber finds the first long numeric token', async () => { + const { extractFirstJobNumber } = await loadUtils(); + assert.equal(extractFirstJobNumber('RE: Job 0222600001 addendum'), '0222600001'); + assert.equal(extractFirstJobNumber('no job here'), ''); +}); + +test('validatePassword enforces the expected complexity rules', async () => { + const { validatePassword } = await loadUtils(); + assert.equal(validatePassword('short'), 'Password must be at least 12 characters.'); + assert.equal(validatePassword('alllowercase123'), 'Password must include an uppercase letter.'); + assert.equal(validatePassword('ALLUPPERCASE123'), 'Password must include a lowercase letter.'); + assert.equal(validatePassword('MissingNumber!'), 'Password must include a number.'); + assert.equal(validatePassword('ValidPassword123'), null); +}); + +test('draft builders include source email context', async () => { + const { buildPCOBody, buildRFIBody } = await loadUtils(); + const email = { + id: 'email-1', + subject: 'Coordination needed', + from_addr: 'pm@example.com', + date: '2026-03-01T00:00:00.000Z', + body_text: 'Need a price and schedule impact.' + }; + const project = { name: 'Example Project', job_number: '0222600001' }; + + assert.match(buildPCOBody(email, project), /Potential Change Order/); + assert.match(buildPCOBody(email, project), /Coordination needed/); + assert.match(buildRFIBody(email, project), /RFI - Draft/); + assert.match(buildRFIBody(email, project), /Need a price and schedule impact\./); +}); diff --git a/web/src/index.js b/web/src/index.js index 553ecc2..bf4064b 100644 --- a/web/src/index.js +++ b/web/src/index.js @@ -18,14 +18,15 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { marked } from 'marked'; import MarkdownIt from 'markdown-it'; +import { createMemoryPool } from './lib/memory-db.js'; +import { applyRulesToEmail } from './lib/email-rules.js'; import { - applyRulesToEmail, buildPCOBody, buildRFIBody, extractDomain, extractFirstJobNumber, validatePassword -} from './lib/helpers.js'; +} from './lib/utils.js'; const { Pool } = pg; const PgStore = connectPgSimple(session); @@ -42,6 +43,9 @@ const PORT = process.env.PORT || 3005; const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`; const isProd = process.env.NODE_ENV === 'production'; const trustProxy = process.env.TRUST_PROXY === '1' || process.env.TRUST_PROXY === 'true'; +const isMemoryDb = !process.env.DATABASE_URL || process.env.DATABASE_URL.startsWith('memory://'); +const appRoot = process.env.APP_ROOT || fileURLToPath(new URL('../../', import.meta.url)); +const dataRoot = process.env.DATA_DIR || path.join(appRoot, 'data'); if (trustProxy) app.set('trust proxy', 1); if (isProd && (!process.env.SESSION_SECRET || process.env.SESSION_SECRET.length < 24)) { @@ -56,8 +60,7 @@ function baseHost() { } } -const pool = new Pool({ connectionString: process.env.DATABASE_URL }); -const useMemorySessionStore = process.env.SESSION_STORE === 'memory' || (!process.env.DATABASE_URL && process.env.NODE_ENV === 'test'); +const pool = isMemoryDb ? createMemoryPool() : new Pool({ connectionString: process.env.DATABASE_URL }); app.use( helmet({ @@ -103,11 +106,6 @@ app.use((req, res, next) => { return res.status(403).send('Blocked (CSRF)'); }); -function firstExistingPath(...paths) { - return paths.find((candidate) => candidate && fs.existsSync(candidate)) || paths[0]; -} - -const dataRoot = firstExistingPath('/app/data', path.resolve(__dirname, '../../data')); const uploadDir = path.join(dataRoot, 'uploads'); const attachmentDir = path.join(dataRoot, 'attachments'); fs.mkdirSync(uploadDir, { recursive: true }); @@ -119,22 +117,25 @@ app.use(rateLimit({ windowMs: 60_000, limit: 120 })); const loginLimiter = rateLimit({ windowMs: 60_000, limit: 10 }); const uploadLimiter = rateLimit({ windowMs: 60_000, limit: 20 }); -app.use( - session({ - name: 'mm.sid', - store: useMemorySessionStore ? undefined : new PgStore({ pool, createTableIfMissing: true }), - secret: process.env.SESSION_SECRET || 'change-me', - resave: false, - saveUninitialized: false, - rolling: true, - cookie: { - httpOnly: true, - sameSite: 'lax', - secure: (process.env.COOKIE_SECURE === 'true') || BASE_URL.startsWith('https://'), - maxAge: 1000 * 60 * 60 * 12 // 12 hours - } - }) -); +const sessionConfig = { + name: 'mm.sid', + secret: process.env.SESSION_SECRET || 'change-me', + resave: false, + saveUninitialized: false, + rolling: true, + cookie: { + httpOnly: true, + sameSite: 'lax', + secure: (process.env.COOKIE_SECURE === 'true') || BASE_URL.startsWith('https://'), + maxAge: 1000 * 60 * 60 * 12 + } +}; + +if (!isMemoryDb) { + sessionConfig.store = new PgStore({ pool, createTableIfMissing: true }); +} + +app.use(session(sessionConfig)); app.use(passport.initialize()); app.use(passport.session()); @@ -785,6 +786,48 @@ app.post('/setup/base-url', requireAuth, async (req, res) => { res.redirect('/setup'); }); +let initialized = false; + +export async function initializeApp() { + if (initialized) return; + + await ensureSchema(); + + // Backfill schema changes (safe alters) + await pool.query(`alter table users add column if not exists disabled boolean not null default false;`); + try { + await pool.query(`alter table identities add constraint identities_provider_email_unique unique (provider, email);`); + } catch (_) { + // constraint may already exist + } + + // Create an initial local owner account if none exists (bootstrap) + // For safety, we do NOT create a default owner/owner in production. + const { rows: existing } = await pool.query("select count(*)::int as c from identities where provider='local'"); + if (existing[0].c === 0) { + const email = (process.env.BOOTSTRAP_OWNER_EMAIL || '').trim().toLowerCase(); + const password = (process.env.BOOTSTRAP_OWNER_PASSWORD || '').trim(); + + if (!email || !password) { + console.warn('No local identities exist. Set BOOTSTRAP_OWNER_EMAIL and BOOTSTRAP_OWNER_PASSWORD to create the initial owner account.'); + } else { + const hash = await bcrypt.hash(password, 12); + const { rows } = await pool.query( + "insert into users(email, display_name, role) values ($1,'Owner','owner') returning id", + [email] + ); + await pool.query( + "insert into identities(user_id, provider, email, password_hash) values ($1,'local',$2,$3)", + [rows[0].id, email, hash] + ); + console.log(`Created bootstrap local owner: ${email} (change password ASAP)`); + } + } + + initialized = true; +} + +export { app }; // Admin: user management app.get('/admin/users', requireOwner, async (_req, res) => { const { rows } = await pool.query(` @@ -878,6 +921,9 @@ async function upsertConnector(provider) { ); } +await upsertConnector('gmail'); +await upsertConnector('microsoft'); + app.get('/account/password', requireAuth, (req, res) => { res.render('account_password'); }); @@ -931,13 +977,12 @@ app.post('/admin/email-connectors/:provider/toggle', requireOwner, async (req, r // Admin: email rules // Docs (in-app) -const docRoot = path.resolve(__dirname, '../../'); const DOCS = [ - { slug: 'readme', title: 'README', path: firstExistingPath('/app/README.md', path.join(docRoot, 'README.md')) }, - { slug: 'install', title: 'INSTALL', path: firstExistingPath('/app/INSTALL.md', path.join(docRoot, 'INSTALL.md')) }, - { slug: 'operations', title: 'OPERATIONS', path: firstExistingPath('/app/OPERATIONS.md', path.join(docRoot, 'OPERATIONS.md')) }, - { slug: 'development', title: 'DEVELOPMENT', path: firstExistingPath('/app/DEVELOPMENT.md', path.join(docRoot, 'DEVELOPMENT.md')) }, - { slug: 'changelog', title: 'CHANGELOG', path: firstExistingPath('/app/CHANGELOG.md', path.join(docRoot, 'CHANGELOG.md')) } + { slug: 'readme', title: 'README', path: path.join(appRoot, 'README.md') }, + { slug: 'install', title: 'INSTALL', path: path.join(appRoot, 'INSTALL.md') }, + { slug: 'operations', title: 'OPERATIONS', path: path.join(appRoot, 'OPERATIONS.md') }, + { slug: 'development', title: 'DEVELOPMENT', path: path.join(appRoot, 'DEVELOPMENT.md') }, + { slug: 'changelog', title: 'CHANGELOG', path: path.join(appRoot, 'CHANGELOG.md') } ]; app.get('/docs', requireAuth, (req, res) => res.redirect('/docs/readme')); @@ -1157,53 +1202,14 @@ app.get('/admin/audit', requireOwner, async (_req, res) => { res.render('admin_audit', { logs: rows }); }); -let initialized = false; - -export async function initializeApp() { - if (initialized) return; - - await ensureSchema(); - - await pool.query(`alter table users add column if not exists disabled boolean not null default false;`); - try { - await pool.query(`alter table identities add constraint identities_provider_email_unique unique (provider, email);`); - } catch (_) { - // constraint may already exist - } - - const { rows: existing } = await pool.query("select count(*)::int as c from identities where provider='local'"); - if (existing[0].c === 0) { - const email = (process.env.BOOTSTRAP_OWNER_EMAIL || '').trim().toLowerCase(); - const password = (process.env.BOOTSTRAP_OWNER_PASSWORD || '').trim(); - - if (!email || !password) { - console.warn('No local identities exist. Set BOOTSTRAP_OWNER_EMAIL and BOOTSTRAP_OWNER_PASSWORD to create the initial owner account.'); - } else { - const hash = await bcrypt.hash(password, 12); - const { rows } = await pool.query( - "insert into users(email, display_name, role) values ($1,'Owner','owner') returning id", - [email] - ); - await pool.query( - "insert into identities(user_id, provider, email, password_hash) values ($1,'local',$2,$3)", - [rows[0].id, email, hash] - ); - console.log(`Created bootstrap local owner: ${email} (change password ASAP)`); - } - } - - await upsertConnector('gmail'); - await upsertConnector('microsoft'); - - initialized = true; -} - -export { app }; - if (isMainModule) { await initializeApp(); - app.listen(PORT, () => { - // eslint-disable-next-line no-console - console.log(`Mastermind web listening on ${BASE_URL}`); - }); + + if (process.env.MASTERMIND_DISABLE_LISTEN === '1') { + console.log(`Mastermind web ready (${isMemoryDb ? 'memory' : 'postgres'})`); + } else { + app.listen(PORT, () => { + console.log(`Mastermind web listening on ${BASE_URL}`); + }); + } } diff --git a/web/src/lib/email-rules.js b/web/src/lib/email-rules.js new file mode 100644 index 0000000..3ca9e59 --- /dev/null +++ b/web/src/lib/email-rules.js @@ -0,0 +1,29 @@ +export async function applyRulesToEmail(emailRow, rules) { + const from = (emailRow.from_addr || '').toLowerCase(); + const subj = (emailRow.subject || '').toLowerCase(); + const body = (emailRow.body_text || '').toLowerCase(); + const thread = (emailRow.thread_key || '').toLowerCase(); + + for (const rule of rules) { + if (!rule.enabled) continue; + const value = (rule.match_value || '').toLowerCase(); + let hit = false; + if (rule.match_type === 'from_domain') { + hit = value && from.includes('@') && from.split('@').pop()?.includes(value.replace(/^@/, '')); + } else if (rule.match_type === 'from_contains') { + hit = value && from.includes(value); + } else if (rule.match_type === 'subject_contains') { + hit = value && subj.includes(value); + } else if (rule.match_type === 'body_contains') { + hit = value && body.includes(value); + } else if (rule.match_type === 'thread_key') { + hit = value && thread && thread === value; + } + + if (hit) { + return { projectId: rule.project_id, confidence: 0.9, ruleId: rule.id }; + } + } + + return null; +} diff --git a/web/src/lib/memory-db.js b/web/src/lib/memory-db.js new file mode 100644 index 0000000..e1f247e --- /dev/null +++ b/web/src/lib/memory-db.js @@ -0,0 +1,672 @@ +import crypto from 'crypto'; + +function now() { + return new Date().toISOString(); +} + +function uuid() { + return crypto.randomUUID(); +} + +function normalizeSql(sql) { + return String(sql || '').trim().replace(/\s+/g, ' ').toLowerCase(); +} + +function cloneRow(row) { + return row ? structuredClone(row) : row; +} + +function cloneRows(rows) { + return rows.map((row) => cloneRow(row)); +} + +function cmpEmail(a, b) { + return String(a || '').toLowerCase() === String(b || '').toLowerCase(); +} + +function sortByCreatedDesc(items) { + return [...items].sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || ''))); +} + +export function createMemoryPool() { + const db = { + users: [], + identities: [], + app_settings: [], + audit_logs: [], + projects: [], + project_members: [], + ingested_emails: [], + email_attachments: [], + email_connectors: [], + email_rules: [], + pco_drafts: [], + rfi_drafts: [] + }; + + function getUserById(id) { + return db.users.find((user) => user.id === id) || null; + } + + function getProjectById(id) { + return db.projects.find((project) => project.id === id) || null; + } + + function getEmailById(id) { + return db.ingested_emails.find((email) => email.id === id) || null; + } + + function upsertUser({ email, displayName, role = 'owner' }) { + let user = db.users.find((candidate) => cmpEmail(candidate.email, email)); + if (!user) { + user = { + id: uuid(), + email: email || null, + display_name: displayName || email || null, + role, + disabled: false, + created_at: now() + }; + db.users.push(user); + } else { + user.display_name = displayName || user.display_name; + user.role = role || user.role; + } + return user; + } + + async function query(sql, params = []) { + const normalized = normalizeSql(sql); + + if ( + normalized.startsWith('create table if not exists') || + normalized.startsWith('create index if not exists') || + normalized.startsWith('alter table ') || + normalized.startsWith('select 1') + ) { + return { rows: [], rowCount: 0 }; + } + + if (normalized === 'select * from users where id=$1') { + const user = getUserById(params[0]); + return { rows: user ? [cloneRow(user)] : [], rowCount: user ? 1 : 0 }; + } + + if (normalized.includes("select u.*, i.password_hash from users u join identities i on i.user_id=u.id where i.provider='local'")) { + const identity = db.identities.find((item) => item.provider === 'local' && cmpEmail(item.email, params[0])); + const user = identity ? getUserById(identity.user_id) : null; + if (!identity || !user) return { rows: [], rowCount: 0 }; + return { rows: [{ ...cloneRow(user), password_hash: identity.password_hash }], rowCount: 1 }; + } + + if (normalized === "select user_id from identities where provider='google' and provider_user_id=$1 limit 1") { + const identity = db.identities.find((item) => item.provider === 'google' && item.provider_user_id === params[0]); + return { rows: identity ? [{ user_id: identity.user_id }] : [], rowCount: identity ? 1 : 0 }; + } + + if (normalized === "select user_id from identities where provider='microsoft' and provider_user_id=$1 limit 1") { + const identity = db.identities.find((item) => item.provider === 'microsoft' && item.provider_user_id === params[0]); + return { rows: identity ? [{ user_id: identity.user_id }] : [], rowCount: identity ? 1 : 0 }; + } + + if (normalized === 'insert into users(email, display_name) values ($1,$2) on conflict(email) do update set display_name=excluded.display_name returning id') { + const user = upsertUser({ email: params[0], displayName: params[1] }); + return { rows: [{ id: user.id }], rowCount: 1 }; + } + + if (normalized === "insert into identities(user_id, provider, provider_user_id, email) values ($1,'google',$2,$3) on conflict(provider, provider_user_id) do nothing") { + const existing = db.identities.find((item) => item.provider === 'google' && item.provider_user_id === params[1]); + if (!existing) { + db.identities.push({ + id: uuid(), + user_id: params[0], + provider: 'google', + provider_user_id: params[1], + email: params[2] || null, + password_hash: null, + created_at: now() + }); + } + return { rows: [], rowCount: 1 }; + } + + if (normalized === "insert into identities(user_id, provider, provider_user_id, email) values ($1,'microsoft',$2,$3) on conflict(provider, provider_user_id) do nothing") { + const existing = db.identities.find((item) => item.provider === 'microsoft' && item.provider_user_id === params[1]); + if (!existing) { + db.identities.push({ + id: uuid(), + user_id: params[0], + provider: 'microsoft', + provider_user_id: params[1], + email: params[2] || null, + password_hash: null, + created_at: now() + }); + } + return { rows: [], rowCount: 1 }; + } + + if (normalized.startsWith('insert into audit_logs(')) { + db.audit_logs.push({ + id: uuid(), + created_at: now(), + actor_user_id: params[0] || null, + actor_email: params[1] || null, + action: params[2], + target_type: params[3] || null, + target_id: params[4] || null, + ip: params[5] || null, + user_agent: params[6] || null, + metadata: params[7] || {} + }); + return { rows: [], rowCount: 1 }; + } + + if (normalized.includes('from projects p join project_members pm on pm.project_id=p.id and pm.user_id=$1 order by p.updated_at desc')) { + const rows = db.projects + .filter((project) => db.project_members.some((member) => member.project_id === project.id && member.user_id === params[0])) + .sort((a, b) => String(b.updated_at || '').localeCompare(String(a.updated_at || ''))); + return { rows: cloneRows(rows), rowCount: rows.length }; + } + + if (normalized === 'insert into projects(created_by_user_id,name,job_number,role_mode,gc_name,city,state,keywords) values ($1,$2,$3,$4,$5,$6,$7,$8) returning id') { + const project = { + id: uuid(), + created_at: now(), + updated_at: now(), + created_by_user_id: params[0], + name: params[1], + job_number: params[2] || null, + role_mode: params[3] || 'ec', + gc_name: params[4] || null, + address: null, + city: params[5] || null, + state: params[6] || null, + postal_code: null, + keywords: params[7] || null, + active: true + }; + db.projects.push(project); + return { rows: [{ id: project.id }], rowCount: 1 }; + } + + if (normalized === "insert into project_members(project_id,user_id,project_role) values ($1,$2,'owner') on conflict do nothing") { + const existing = db.project_members.find((member) => member.project_id === params[0] && member.user_id === params[1]); + if (!existing) { + db.project_members.push({ + project_id: params[0], + user_id: params[1], + project_role: 'owner', + created_at: now() + }); + } + return { rows: [], rowCount: 1 }; + } + + if (normalized.includes('from projects p join project_members pm on pm.project_id=p.id and pm.user_id=$1 where p.id=$2 limit 1')) { + const allowed = db.project_members.some((member) => member.project_id === params[1] && member.user_id === params[0]); + const project = allowed ? getProjectById(params[1]) : null; + return { rows: project ? [cloneRow(project)] : [], rowCount: project ? 1 : 0 }; + } + + if (normalized === 'select id, from_addr, subject, date, source from ingested_emails where project_id=$1 order by date desc nulls last, created_at desc limit 300') { + const rows = db.ingested_emails + .filter((email) => email.project_id === params[0]) + .sort((a, b) => String(b.date || b.created_at || '').localeCompare(String(a.date || a.created_at || ''))) + .slice(0, 300) + .map((email) => ({ id: email.id, from_addr: email.from_addr, subject: email.subject, date: email.date, source: email.source })); + return { rows: cloneRows(rows), rowCount: rows.length }; + } + + if (normalized === 'update projects set name=$1, job_number=$2, role_mode=$3, gc_name=$4, keywords=$5, updated_at=now() where id=$6') { + const project = getProjectById(params[5]); + if (project) { + project.name = params[0]; + project.job_number = params[1] || null; + project.role_mode = params[2] || 'ec'; + project.gc_name = params[3] || null; + project.keywords = params[4] || null; + project.updated_at = now(); + } + return { rows: [], rowCount: project ? 1 : 0 }; + } + + if (normalized === 'update projects set active = not active, updated_at=now() where id=$1') { + const project = getProjectById(params[0]); + if (project) { + project.active = !project.active; + project.updated_at = now(); + } + return { rows: [], rowCount: project ? 1 : 0 }; + } + + if (normalized === 'select active from projects where id=$1') { + const project = getProjectById(params[0]); + return { rows: project ? [{ active: project.active }] : [], rowCount: project ? 1 : 0 }; + } + + if (normalized.includes('select p.id, p.name, p.job_number from projects p join project_members pm on pm.project_id=p.id and pm.user_id=$1 where p.active=true')) { + const rows = db.projects + .filter((project) => project.active && db.project_members.some((member) => member.project_id === project.id && member.user_id === params[0])) + .sort((a, b) => String(b.updated_at || '').localeCompare(String(a.updated_at || ''))) + .map((project) => ({ id: project.id, name: project.name, job_number: project.job_number })); + return { rows: cloneRows(rows), rowCount: rows.length }; + } + + if (normalized === "select id, from_addr, subject, date, status from ingested_emails where status='unsorted' order by created_at desc limit 200") { + const rows = db.ingested_emails + .filter((email) => email.status === 'unsorted') + .sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || ''))) + .slice(0, 200) + .map((email) => ({ id: email.id, from_addr: email.from_addr, subject: email.subject, date: email.date, status: email.status })); + return { rows: cloneRows(rows), rowCount: rows.length }; + } + + if (normalized === 'select * from email_rules where enabled=true order by priority asc, created_at asc') { + const rows = db.email_rules + .filter((rule) => rule.enabled) + .sort((a, b) => (a.priority - b.priority) || String(a.created_at).localeCompare(String(b.created_at))); + return { rows: cloneRows(rows), rowCount: rows.length }; + } + + if (normalized.startsWith('insert into ingested_emails(')) { + const email = { + id: uuid(), + created_at: now(), + created_by_user_id: params[0] || null, + source: params[1] || 'upload', + source_message_id: null, + thread_key: null, + from_addr: params[2] || null, + to_addr: params[3] || null, + cc_addr: params[4] || null, + subject: params[5] || null, + date: params[6] ? new Date(params[6]).toISOString() : null, + body_text: params[7] || null, + body_html: params[8] || null, + has_attachments: Boolean(params[9]), + raw_path: params[10] || null, + sha256: params[11] || null, + project_id: null, + confidence: null, + status: 'unsorted' + }; + db.ingested_emails.push(email); + return { rows: [cloneRow(email)], rowCount: 1 }; + } + + if (normalized.startsWith('insert into email_attachments(')) { + const attachment = { + id: uuid(), + created_at: now(), + email_id: params[0], + filename: params[1] || null, + content_type: params[2] || null, + size_bytes: params[3] || 0, + sha256: params[4] || null, + storage_path: params[5] || null + }; + db.email_attachments.push(attachment); + return { rows: [], rowCount: 1 }; + } + + if (normalized === 'update ingested_emails set project_id=$1, status=$2, confidence=$3 where id=$4') { + const email = getEmailById(params[3]); + if (email) { + email.project_id = params[0]; + email.status = params[1]; + email.confidence = params[2]; + } + return { rows: [], rowCount: email ? 1 : 0 }; + } + + if (normalized === 'select * from ingested_emails where id=$1 limit 1' || normalized === 'select * from ingested_emails where id=$1') { + const email = getEmailById(params[0]); + return { rows: email ? [cloneRow(email)] : [], rowCount: email ? 1 : 0 }; + } + + if (normalized === 'select * from email_attachments where email_id=$1 order by created_at asc') { + const rows = db.email_attachments + .filter((attachment) => attachment.email_id === params[0]) + .sort((a, b) => String(a.created_at).localeCompare(String(b.created_at))); + return { rows: cloneRows(rows), rowCount: rows.length }; + } + + if (normalized === 'update ingested_emails set project_id=$1, status=\'assigned\', confidence=1.0 where id=$2') { + const email = getEmailById(params[1]); + if (email) { + email.project_id = params[0]; + email.status = 'assigned'; + email.confidence = 1; + } + return { rows: [], rowCount: email ? 1 : 0 }; + } + + if (normalized === 'select from_addr from ingested_emails where id=$1') { + const email = getEmailById(params[0]); + return { rows: email ? [{ from_addr: email.from_addr }] : [], rowCount: email ? 1 : 0 }; + } + + if (normalized === 'select subject from ingested_emails where id=$1') { + const email = getEmailById(params[0]); + return { rows: email ? [{ subject: email.subject }] : [], rowCount: email ? 1 : 0 }; + } + + if (normalized === 'insert into app_settings(key,value) values(\'base_url\',$1) on conflict(key) do update set value=excluded.value') { + const existing = db.app_settings.find((item) => item.key === 'base_url'); + if (existing) existing.value = params[0]; + else db.app_settings.push({ key: 'base_url', value: params[0] }); + return { rows: [], rowCount: 1 }; + } + + if (normalized === "select count(*)::int as c from identities where provider='local'") { + const count = db.identities.filter((item) => item.provider === 'local').length; + return { rows: [{ c: count }], rowCount: 1 }; + } + + if (normalized === "insert into users(email, display_name, role) values ($1,'owner','owner') returning id") { + const user = upsertUser({ email: params[0], displayName: 'Owner', role: 'owner' }); + return { rows: [{ id: user.id }], rowCount: 1 }; + } + + if (normalized === "insert into identities(user_id, provider, email, password_hash) values ($1,'local',$2,$3)") { + db.identities.push({ + id: uuid(), + user_id: params[0], + provider: 'local', + provider_user_id: null, + email: params[1], + password_hash: params[2], + created_at: now() + }); + return { rows: [], rowCount: 1 }; + } + + if (normalized.startsWith('select u.id, u.email, u.display_name, u.role, u.disabled,')) { + const rows = sortByCreatedDesc(db.users).map((user) => ({ + id: user.id, + email: user.email, + display_name: user.display_name, + role: user.role, + disabled: user.disabled, + providers: db.identities.filter((identity) => identity.user_id === user.id).map((identity) => identity.provider).filter(Boolean).join(',') + })); + return { rows: cloneRows(rows), rowCount: rows.length }; + } + + if (normalized === 'insert into users(email, display_name, role) values ($1,$2,$3) on conflict(email) do update set display_name=excluded.display_name, role=excluded.role returning id') { + const user = upsertUser({ email: params[0], displayName: params[1], role: params[2] }); + return { rows: [{ id: user.id }], rowCount: 1 }; + } + + if (normalized === "insert into identities(user_id, provider, email, password_hash) values ($1,'local',$2,$3) on conflict(provider, email) do update set password_hash=excluded.password_hash, user_id=excluded.user_id") { + let identity = db.identities.find((item) => item.provider === 'local' && cmpEmail(item.email, params[1])); + if (!identity) { + identity = { + id: uuid(), + user_id: params[0], + provider: 'local', + provider_user_id: null, + email: params[1], + password_hash: params[2], + created_at: now() + }; + db.identities.push(identity); + } else { + identity.user_id = params[0]; + identity.password_hash = params[2]; + } + return { rows: [], rowCount: 1 }; + } + + if (normalized === 'select email from users where id=$1') { + const user = getUserById(params[0]); + return { rows: user ? [{ email: user.email }] : [], rowCount: user ? 1 : 0 }; + } + + if (normalized === 'update users set disabled = not disabled where id=$1') { + const user = getUserById(params[0]); + if (user) user.disabled = !user.disabled; + return { rows: [], rowCount: user ? 1 : 0 }; + } + + if (normalized === 'select disabled,email from users where id=$1') { + const user = getUserById(params[0]); + return { rows: user ? [{ disabled: user.disabled, email: user.email }] : [], rowCount: user ? 1 : 0 }; + } + + if (normalized === 'select email,role from users where id=$1') { + const user = getUserById(params[0]); + return { rows: user ? [{ email: user.email, role: user.role }] : [], rowCount: user ? 1 : 0 }; + } + + if (normalized === 'delete from users where id=$1') { + const userId = params[0]; + db.users = db.users.filter((user) => user.id !== userId); + db.identities = db.identities.filter((identity) => identity.user_id !== userId); + db.project_members = db.project_members.filter((member) => member.user_id !== userId); + return { rows: [], rowCount: 1 }; + } + + if (normalized === "insert into email_connectors(provider, configured) values ($1,$2) on conflict(provider) do update set configured=excluded.configured, updated_at=now()") { + let connector = db.email_connectors.find((item) => item.provider === params[0]); + if (!connector) { + connector = { + id: uuid(), + created_at: now(), + updated_at: now(), + provider: params[0], + enabled: false, + configured: Boolean(params[1]), + authorized: false, + last_sync_at: null, + last_error: null + }; + db.email_connectors.push(connector); + } else { + connector.configured = Boolean(params[1]); + connector.updated_at = now(); + } + return { rows: [], rowCount: 1 }; + } + + if (normalized === 'select * from email_connectors order by provider asc') { + const rows = [...db.email_connectors].sort((a, b) => String(a.provider).localeCompare(String(b.provider))); + return { rows: cloneRows(rows), rowCount: rows.length }; + } + + if (normalized === 'update email_connectors set enabled = not enabled, updated_at=now() where provider=$1') { + const connector = db.email_connectors.find((item) => item.provider === params[0]); + if (connector) { + connector.enabled = !connector.enabled; + connector.updated_at = now(); + } + return { rows: [], rowCount: connector ? 1 : 0 }; + } + + if (normalized === 'select enabled, configured, authorized from email_connectors where provider=$1') { + const connector = db.email_connectors.find((item) => item.provider === params[0]); + return { rows: connector ? [{ enabled: connector.enabled, configured: connector.configured, authorized: connector.authorized }] : [], rowCount: connector ? 1 : 0 }; + } + + if (normalized === 'select password_hash from identities where user_id=$1 and provider=\'local\' limit 1') { + const identity = db.identities.find((item) => item.user_id === params[0] && item.provider === 'local'); + return { rows: identity ? [{ password_hash: identity.password_hash }] : [], rowCount: identity ? 1 : 0 }; + } + + if (normalized === 'select * from email_attachments where id=$1') { + const attachment = db.email_attachments.find((item) => item.id === params[0]) || null; + return { rows: attachment ? [cloneRow(attachment)] : [], rowCount: attachment ? 1 : 0 }; + } + + if (normalized.startsWith('select d.*, p.name as project_name from pco_drafts d left join projects p on p.id=d.project_id')) { + const rows = sortByCreatedDesc(db.pco_drafts).map((draft) => ({ ...draft, project_name: getProjectById(draft.project_id)?.name || null })); + return { rows: cloneRows(rows), rowCount: rows.length }; + } + + if (normalized.startsWith('select d.*, p.name as project_name from rfi_drafts d left join projects p on p.id=d.project_id')) { + const rows = sortByCreatedDesc(db.rfi_drafts).map((draft) => ({ ...draft, project_name: getProjectById(draft.project_id)?.name || null })); + return { rows: cloneRows(rows), rowCount: rows.length }; + } + + if (normalized === 'insert into pco_drafts(created_by_user_id, project_id, source_email_id, title, body) values ($1,$2,$3,$4,$5) returning id') { + const draft = { + id: uuid(), + created_at: now(), + updated_at: now(), + created_by_user_id: params[0], + project_id: params[1] || null, + source_email_id: params[2] || null, + status: 'draft', + title: params[3] || null, + body: params[4] || null + }; + db.pco_drafts.push(draft); + return { rows: [{ id: draft.id }], rowCount: 1 }; + } + + if (normalized === 'insert into rfi_drafts(created_by_user_id, project_id, source_email_id, title, body) values ($1,$2,$3,$4,$5) returning id') { + const draft = { + id: uuid(), + created_at: now(), + updated_at: now(), + created_by_user_id: params[0], + project_id: params[1] || null, + source_email_id: params[2] || null, + status: 'draft', + title: params[3] || null, + body: params[4] || null + }; + db.rfi_drafts.push(draft); + return { rows: [{ id: draft.id }], rowCount: 1 }; + } + + if (normalized === 'select * from projects where id=$1') { + const project = getProjectById(params[0]); + return { rows: project ? [cloneRow(project)] : [], rowCount: project ? 1 : 0 }; + } + + if (normalized === 'select * from pco_drafts where id=$1') { + const draft = db.pco_drafts.find((item) => item.id === params[0]) || null; + return { rows: draft ? [cloneRow(draft)] : [], rowCount: draft ? 1 : 0 }; + } + + if (normalized === 'update pco_drafts set title=$1, status=$2, body=$3, updated_at=now() where id=$4') { + const draft = db.pco_drafts.find((item) => item.id === params[3]) || null; + if (draft) { + draft.title = params[0]; + draft.status = params[1]; + draft.body = params[2]; + draft.updated_at = now(); + } + return { rows: [], rowCount: draft ? 1 : 0 }; + } + + if (normalized === 'select * from rfi_drafts where id=$1') { + const draft = db.rfi_drafts.find((item) => item.id === params[0]) || null; + return { rows: draft ? [cloneRow(draft)] : [], rowCount: draft ? 1 : 0 }; + } + + if (normalized === 'update rfi_drafts set title=$1, status=$2, body=$3, updated_at=now() where id=$4') { + const draft = db.rfi_drafts.find((item) => item.id === params[3]) || null; + if (draft) { + draft.title = params[0]; + draft.status = params[1]; + draft.body = params[2]; + draft.updated_at = now(); + } + return { rows: [], rowCount: draft ? 1 : 0 }; + } + + if (normalized.startsWith('select r.*, p.name as project_name from email_rules r join projects p on p.id=r.project_id')) { + const rows = [...db.email_rules] + .sort((a, b) => Number(b.enabled) - Number(a.enabled) || a.priority - b.priority || String(a.created_at).localeCompare(String(b.created_at))) + .map((rule) => ({ ...rule, project_name: getProjectById(rule.project_id)?.name || null })); + return { rows: cloneRows(rows), rowCount: rows.length }; + } + + if (normalized === 'insert into email_rules(created_by_user_id, project_id, match_type, match_value, priority) values ($1,$2,$3,$4,$5) returning id') { + const rule = { + id: uuid(), + created_at: now(), + created_by_user_id: params[0] || null, + enabled: true, + priority: params[4] ?? 100, + project_id: params[1], + match_type: params[2], + match_value: params[3] + }; + db.email_rules.push(rule); + return { rows: [{ id: rule.id }], rowCount: 1 }; + } + + if (normalized === "insert into email_rules(created_by_user_id, project_id, match_type, match_value, priority) values ($1,$2,'from_domain',$3,50) returning id") { + const rule = { + id: uuid(), + created_at: now(), + created_by_user_id: params[0] || null, + enabled: true, + priority: 50, + project_id: params[1], + match_type: 'from_domain', + match_value: params[2] + }; + db.email_rules.push(rule); + return { rows: [{ id: rule.id }], rowCount: 1 }; + } + + if (normalized === "insert into email_rules(created_by_user_id, project_id, match_type, match_value, priority) values ($1,$2,'subject_contains',$3,40) returning id") { + const rule = { + id: uuid(), + created_at: now(), + created_by_user_id: params[0] || null, + enabled: true, + priority: 40, + project_id: params[1], + match_type: 'subject_contains', + match_value: params[2] + }; + db.email_rules.push(rule); + return { rows: [{ id: rule.id }], rowCount: 1 }; + } + + if (normalized === 'update email_rules set enabled = not enabled where id=$1') { + const rule = db.email_rules.find((item) => item.id === params[0]) || null; + if (rule) rule.enabled = !rule.enabled; + return { rows: [], rowCount: rule ? 1 : 0 }; + } + + if (normalized === 'select enabled from email_rules where id=$1') { + const rule = db.email_rules.find((item) => item.id === params[0]) || null; + return { rows: rule ? [{ enabled: rule.enabled }] : [], rowCount: rule ? 1 : 0 }; + } + + if (normalized === 'delete from email_rules where id=$1') { + db.email_rules = db.email_rules.filter((item) => item.id !== params[0]); + return { rows: [], rowCount: 1 }; + } + + if (normalized.startsWith('select created_at, actor_email, action, target_type, target_id, ip, metadata from audit_logs')) { + const rows = sortByCreatedDesc(db.audit_logs) + .slice(0, 250) + .map((entry) => ({ + created_at: entry.created_at, + actor_email: entry.actor_email, + action: entry.action, + target_type: entry.target_type, + target_id: entry.target_id, + ip: entry.ip, + metadata: entry.metadata + })); + return { rows: cloneRows(rows), rowCount: rows.length }; + } + + throw new Error(`Unsupported memory SQL: ${sql}`); + } + + return { + query, + end: async () => {}, + __state: db + }; +} diff --git a/web/src/lib/utils.js b/web/src/lib/utils.js new file mode 100644 index 0000000..5896a28 --- /dev/null +++ b/web/src/lib/utils.js @@ -0,0 +1,75 @@ +export function extractDomain(fromAddr) { + const m = String(fromAddr || '').match(/@([A-Za-z0-9.-]+)/); + return m ? m[1].toLowerCase() : ''; +} + +export function extractFirstJobNumber(text) { + const t = String(text || ''); + const m = t.match(/\b\d{8,}\b/); + return m ? m[0] : ''; +} + +export function validatePassword(pw) { + const p = String(pw || ''); + if (p.length < 12) return 'Password must be at least 12 characters.'; + if (!/[A-Z]/.test(p)) return 'Password must include an uppercase letter.'; + if (!/[a-z]/.test(p)) return 'Password must include a lowercase letter.'; + if (!/\d/.test(p)) return 'Password must include a number.'; + return null; +} + +export function buildPCOBody(email, project) { + return [ + '# Potential Change Order (PCO) - Draft', + '', + `**Project:** ${project?.name || ''}`, + `**Job #:** ${project?.job_number || ''}`, + `**Source email:** ${email?.subject || ''} (${email?.id || ''})`, + `**From:** ${email?.from_addr || ''}`, + `**Date:** ${email?.date || ''}`, + '', + '## Description / Change event', + '- (Describe what changed and why)', + '', + '## Contract / drawing references', + '- (Sheet/spec refs)', + '', + '## Cost / schedule impact', + '- Labor:', + '- Material:', + '- Equipment:', + '- Schedule impact:', + '', + '## Supporting info', + '- Email excerpt:', + '', + `> ${String(email?.body_text || '').slice(0, 1200).replace(/\n/g, '\n> ')}` + ].join('\n'); +} + +export function buildRFIBody(email, project) { + return [ + '# RFI - Draft', + '', + `**Project:** ${project?.name || ''}`, + `**Job #:** ${project?.job_number || ''}`, + `**Source email:** ${email?.subject || ''} (${email?.id || ''})`, + `**From:** ${email?.from_addr || ''}`, + `**Date:** ${email?.date || ''}`, + '', + '## Question', + '- (Write the question clearly)', + '', + '## Background', + '- (Why this is needed / conflict)', + '', + '## Drawing/spec references', + '- (Sheet/detail/spec section)', + '', + '## Proposed resolution (optional)', + '-', + '', + '## Supporting excerpt', + `> ${String(email?.body_text || '').slice(0, 1200).replace(/\n/g, '\n> ')}` + ].join('\n'); +} diff --git a/worker/src/worker.js b/worker/src/worker.js index acabe16..4893c9b 100644 --- a/worker/src/worker.js +++ b/worker/src/worker.js @@ -2,14 +2,20 @@ import 'dotenv/config'; import pg from 'pg'; const { Pool } = pg; -const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +const isMemoryDb = !process.env.DATABASE_URL || process.env.DATABASE_URL.startsWith('memory://'); +const pool = isMemoryDb ? null : new Pool({ connectionString: process.env.DATABASE_URL }); +const tickMs = parseInt(process.env.WORKER_TICK_MS || '10000', 10); +const runOnce = process.env.WORKER_RUN_ONCE === '1'; // Placeholder worker loop async function main() { - // eslint-disable-next-line no-console - console.log('Mastermind worker started'); + if (pool) { + await pool.query('select 1'); + } + console.log(`Mastermind worker started (${isMemoryDb ? 'memory' : 'postgres'})`); while (true) { - await new Promise((r) => setTimeout(r, 10_000)); + await new Promise((r) => setTimeout(r, tickMs)); + if (runOnce) break; // future: ingest inbox, OCR docs, classify } }