diff --git a/.env.example b/.env.example index d2dc1c1..965f01d 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,9 @@ # Core DB_PASSWORD=mastermind -DATABASE_URL=postgres://postgres:${DB_PASSWORD}@db:5432/mastermind +DATABASE_URL=postgres://mastermind:${DB_PASSWORD}@db:5432/mastermind # Public base URL (set to https://... when live) -BASE_URL=http://100.101.78.42:3005 +BASE_URL=http://localhost:3005 # REQUIRED in production (>=24 chars). Generate with: openssl rand -base64 48 SESSION_SECRET=change-this-to-a-long-random-string @@ -15,8 +15,8 @@ TRUST_PROXY=true COOKIE_SECURE=false # One-time bootstrap local owner (only used if there are no local identities yet) -BOOTSTRAP_OWNER_EMAIL= -BOOTSTRAP_OWNER_PASSWORD= +BOOTSTRAP_OWNER_EMAIL=owner@local +BOOTSTRAP_OWNER_PASSWORD=owner # Google OAuth (optional) GOOGLE_CLIENT_ID= diff --git a/INSTALL.md b/INSTALL.md index e68d23c..ef4df14 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -6,13 +6,14 @@ ## Configure ```bash -cd /root/clawd/mastermind-mvp +cd /path/to/mastermind-mvp 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`) OAuth is optional for now: - `GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET` @@ -25,10 +26,11 @@ docker compose up -d --build App: - http://:3005/login +- With the default `.env.example`, that is `http://localhost:3005/login` ## First login (important) -On first run the app creates: -- `owner@local` / `owner` +On first run, if no local identities exist, the app creates: +- `owner@local` / `owner` by default from `.env.example` Immediately change it: - http://:3005/account/password @@ -51,3 +53,8 @@ These are the callback URLs the app expects: - Microsoft: `BASE_URL/auth/microsoft/callback` Set `BASE_URL` correctly before authorizing. + +## Test +```bash +npm test +``` diff --git a/README.md b/README.md index b86d57e..36babab 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,12 @@ A portable, self-hosted dashboard that supports an Assistant PM workflow (starti ## Quick start See **INSTALL.md**. +Common local commands: +- `npm test` — run the repo test suite +- `docker compose up -d --build` — start Postgres, web, and worker +- `docker compose logs -f web worker` — follow app logs +- `docker compose down` — stop the stack + ## Repo layout - `docker-compose.yml` — portable dev deploy - `web/` — Express app + views diff --git a/docker-compose.yml b/docker-compose.yml index e520195..b71cce3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,8 @@ services: GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-} MICROSOFT_CLIENT_SECRET: ${MICROSOFT_CLIENT_SECRET:-} + BOOTSTRAP_OWNER_EMAIL: ${BOOTSTRAP_OWNER_EMAIL:-owner@local} + BOOTSTRAP_OWNER_PASSWORD: ${BOOTSTRAP_OWNER_PASSWORD:-owner} depends_on: db: condition: service_healthy @@ -34,6 +36,11 @@ services: - "3005:3005" volumes: - ./data:/app/data + - ./README.md:/app/README.md:ro + - ./INSTALL.md:/app/INSTALL.md:ro + - ./DEVELOPMENT.md:/app/DEVELOPMENT.md:ro + - ./OPERATIONS.md:/app/OPERATIONS.md:ro + - ./CHANGELOG.md:/app/CHANGELOG.md:ro worker: build: ./worker diff --git a/package.json b/package.json index 8ab6b8a..0f6b819 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,10 @@ "description": "", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --test", + "test:ci": "CI=1 node --test --test-reporter=spec", + "up": "docker compose up --build", + "down": "docker compose down" }, "keywords": [], "author": "", diff --git a/test/compose.integration.test.mjs b/test/compose.integration.test.mjs new file mode 100644 index 0000000..4952f18 --- /dev/null +++ b/test/compose.integration.test.mjs @@ -0,0 +1,14 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +test('docker compose config exposes the expected local services and safe defaults', () => { + const config = fs.readFileSync('/tmp/mastermind-mvp/docker-compose.yml', 'utf8'); + + assert.match(config, /db:/); + assert.match(config, /web:/); + assert.match(config, /worker:/); + assert.match(config, /BOOTSTRAP_OWNER_EMAIL: \$\{BOOTSTRAP_OWNER_EMAIL:-owner@local\}/); + assert.match(config, /BOOTSTRAP_OWNER_PASSWORD: \$\{BOOTSTRAP_OWNER_PASSWORD:-owner\}/); + assert.match(config, /\.\/README\.md:\/app\/README\.md:ro/); +}); diff --git a/test/helpers.test.mjs b/test/helpers.test.mjs new file mode 100644 index 0000000..50ef656 --- /dev/null +++ b/test/helpers.test.mjs @@ -0,0 +1,62 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + applyRulesToEmail, + buildPCOBody, + buildRFIBody, + extractDomain, + extractFirstJobNumber, + validatePassword +} from '../web/src/lib/helpers.js'; + +test('extractDomain returns normalized domain', () => { + assert.equal(extractDomain('Jane Doe '), 'example.com'); + assert.equal(extractDomain('no-domain'), ''); +}); + +test('extractFirstJobNumber finds the first long job number', () => { + assert.equal(extractFirstJobNumber('RE: Job 0222600001 - panel issue'), '0222600001'); + assert.equal(extractFirstJobNumber('ticket 12345 only'), ''); +}); + +test('validatePassword enforces the documented rules', () => { + assert.equal(validatePassword('short'), 'Password must be at least 12 characters.'); + assert.equal(validatePassword('lowercaseonly1'), 'Password must include an uppercase letter.'); + assert.equal(validatePassword('UPPERCASEONLY1'), 'Password must include a lowercase letter.'); + assert.equal(validatePassword('NoDigitsHere!'), 'Password must include a number.'); + assert.equal(validatePassword('ValidPassword1'), null); +}); + +test('applyRulesToEmail returns the first matching rule', async () => { + const match = await applyRulesToEmail( + { + from_addr: 'pm@gc.example.com', + subject: '0222600001 change request', + body_text: 'Please review the change request.', + thread_key: 'thread-1' + }, + [ + { id: '1', enabled: true, match_type: 'subject_contains', match_value: '0222600001', project_id: 'project-a' }, + { id: '2', enabled: true, match_type: 'from_domain', match_value: 'example.com', project_id: 'project-b' } + ] + ); + + assert.deepEqual(match, { projectId: 'project-a', confidence: 0.9, ruleId: '1' }); +}); + +test('draft builders include key context from the source email', () => { + const email = { + id: 'email-1', + subject: 'Need clarification', + from_addr: 'pm@example.com', + date: '2026-03-01', + body_text: 'Line one\nLine two' + }; + const project = { name: 'Tower A', job_number: '0222600001' }; + + assert.match(buildPCOBody(email, project), /Potential Change Order/); + assert.match(buildPCOBody(email, project), /Tower A/); + assert.match(buildRFIBody(email, project), /RFI/); + assert.match(buildRFIBody(email, project), /> Line one/); +}); diff --git a/web/src/index.js b/web/src/index.js index c0876c3..553ecc2 100644 --- a/web/src/index.js +++ b/web/src/index.js @@ -14,8 +14,18 @@ import multer from 'multer'; import { simpleParser } from 'mailparser'; import crypto from 'crypto'; import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; import { marked } from 'marked'; import MarkdownIt from 'markdown-it'; +import { + applyRulesToEmail, + buildPCOBody, + buildRFIBody, + extractDomain, + extractFirstJobNumber, + validatePassword +} from './lib/helpers.js'; const { Pool } = pg; const PgStore = connectPgSimple(session); @@ -24,6 +34,10 @@ const app = express(); app.set('view engine', 'ejs'); app.set('views', new URL('./views', import.meta.url).pathname); +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const isMainModule = process.argv[1] && path.resolve(process.argv[1]) === __filename; + const PORT = process.env.PORT || 3005; const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`; const isProd = process.env.NODE_ENV === 'production'; @@ -43,6 +57,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'); app.use( helmet({ @@ -88,8 +103,13 @@ app.use((req, res, next) => { return res.status(403).send('Blocked (CSRF)'); }); -const uploadDir = '/app/data/uploads'; -const attachmentDir = '/app/data/attachments'; +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 }); fs.mkdirSync(attachmentDir, { recursive: true }); const upload = multer({ dest: uploadDir, limits: { fileSize: 50 * 1024 * 1024 } }); @@ -102,7 +122,7 @@ const uploadLimiter = rateLimit({ windowMs: 60_000, limit: 20 }); app.use( session({ name: 'mm.sid', - store: new PgStore({ pool, createTableIfMissing: true }), + store: useMemorySessionStore ? undefined : new PgStore({ pool, createTableIfMissing: true }), secret: process.env.SESSION_SECRET || 'change-me', resave: false, saveUninitialized: false, @@ -701,27 +721,6 @@ app.get('/inbox/:id', requireAuth, async (req, res) => { res.render('inbox_email', { email: rows[0], attachments }); }); -function extractDomain(fromAddr) { - const m = String(fromAddr || '').match(/@([A-Za-z0-9.-]+)/); - return m ? m[1].toLowerCase() : ''; -} - -function extractFirstJobNumber(text) { - const t = String(text || ''); - // heuristic: prefer 8+ digit job numbers (e.g., 0222600001) - const m = t.match(/\b\d{8,}\b/); - return m ? m[0] : ''; -} - -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; -} - app.post('/inbox/:id/assign', requireAuth, async (req, res) => { const emailId = req.params.id; const projectId = (req.body.projectId || '').trim(); @@ -786,39 +785,6 @@ app.post('/setup/base-url', requireAuth, async (req, res) => { res.redirect('/setup'); }); -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)`); - } -} - // Admin: user management app.get('/admin/users', requireOwner, async (_req, res) => { const { rows } = await pool.query(` @@ -912,38 +878,6 @@ async function upsertConnector(provider) { ); } -await upsertConnector('gmail'); -await upsertConnector('microsoft'); - -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 r of rules) { - if (!r.enabled) continue; - const v = (r.match_value || '').toLowerCase(); - let hit = false; - if (r.match_type === 'from_domain') { - hit = v && from.includes('@') && from.split('@').pop()?.includes(v.replace(/^@/, '')); - } else if (r.match_type === 'from_contains') { - hit = v && from.includes(v); - } else if (r.match_type === 'subject_contains') { - hit = v && subj.includes(v); - } else if (r.match_type === 'body_contains') { - hit = v && body.includes(v); - } else if (r.match_type === 'thread_key') { - hit = v && thread && thread === v; - } - - if (hit) { - return { projectId: r.project_id, confidence: 0.9, ruleId: r.id }; - } - } - return null; -} - app.get('/account/password', requireAuth, (req, res) => { res.render('account_password'); }); @@ -997,12 +931,13 @@ 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: '/app/README.md' }, - { slug: 'install', title: 'INSTALL', path: '/app/INSTALL.md' }, - { slug: 'operations', title: 'OPERATIONS', path: '/app/OPERATIONS.md' }, - { slug: 'development', title: 'DEVELOPMENT', path: '/app/DEVELOPMENT.md' }, - { slug: 'changelog', title: 'CHANGELOG', path: '/app/CHANGELOG.md' } + { 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')) } ]; app.get('/docs', requireAuth, (req, res) => res.redirect('/docs/readme')); @@ -1041,62 +976,6 @@ app.get('/drafts', requireAuth, async (req, res) => { res.render('drafts_list', { pcos, rfis }); }); -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'); -} - -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'); -} - // Create drafts from email app.post('/drafts/pco/from-email', requireAuth, async (req, res) => { const emailId = (req.body.emailId || '').trim(); @@ -1278,7 +1157,53 @@ app.get('/admin/audit', requireOwner, async (_req, res) => { res.render('admin_audit', { logs: rows }); }); -app.listen(PORT, () => { - // eslint-disable-next-line no-console - console.log(`Mastermind web listening on ${BASE_URL}`); -}); +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}`); + }); +} diff --git a/web/src/lib/helpers.js b/web/src/lib/helpers.js new file mode 100644 index 0000000..04c30ec --- /dev/null +++ b/web/src/lib/helpers.js @@ -0,0 +1,106 @@ +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 || ''); + // heuristic: prefer 8+ digit job numbers (e.g., 0222600001) + 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 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 r of rules) { + if (!r.enabled) continue; + const v = (r.match_value || '').toLowerCase(); + let hit = false; + if (r.match_type === 'from_domain') { + hit = v && from.includes('@') && from.split('@').pop()?.includes(v.replace(/^@/, '')); + } else if (r.match_type === 'from_contains') { + hit = v && from.includes(v); + } else if (r.match_type === 'subject_contains') { + hit = v && subj.includes(v); + } else if (r.match_type === 'body_contains') { + hit = v && body.includes(v); + } else if (r.match_type === 'thread_key') { + hit = v && thread && thread === v; + } + + if (hit) { + return { projectId: r.project_id, confidence: 0.9, ruleId: r.id }; + } + } + + 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'); +}