Compare commits

...

3 Commits

16 changed files with 1347 additions and 177 deletions

View File

@@ -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=

View File

@@ -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
```

View File

@@ -1,36 +1,40 @@
# INSTALL — Mastermind MVP
## Prerequisites
- Docker + Docker Compose
- Docker + Docker Compose (recommended)
- A stable hostname/IP you can reach (LAN or Tailscale)
## 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` — 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
```
App:
- http://<BASE_URL_HOST>: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 the bootstrap local owner from:
- `BOOTSTRAP_OWNER_EMAIL`
- `BOOTSTRAP_OWNER_PASSWORD`
Immediately change it:
Immediately change that password:
- http://<host>:3005/account/password
## Create your first project
@@ -51,3 +55,33 @@ These are the callback URLs the app expects:
- Microsoft: `BASE_URL/auth/microsoft/callback`
Set `BASE_URL` correctly before authorizing.
## Test
```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
```

View File

@@ -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
@@ -65,7 +71,7 @@ See **INSTALL.md**.
- `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.

View File

@@ -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:-owner@local}
BOOTSTRAP_OWNER_PASSWORD: ${BOOTSTRAP_OWNER_PASSWORD:-owner}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-}
@@ -34,9 +39,15 @@ 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
command: npm run start
environment:
NODE_ENV: development
DATABASE_URL: postgres://mastermind:${DB_PASSWORD:-mastermind}@db:5432/mastermind

View File

@@ -4,7 +4,12 @@
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"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",
"down": "docker compose down"
},
"keywords": [],
"author": "",

View File

@@ -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/);
});

62
test/helpers.test.mjs Normal file
View File

@@ -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 <Jane.Doe@Example.COM>'), '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/);
});

139
test/smoke.test.js Normal file
View File

@@ -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\./);
});

47
test/utils.test.js Normal file
View File

@@ -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 <Jane.Doe@Example.COM>'), '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\./);
});

View File

@@ -14,8 +14,19 @@ 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 { createMemoryPool } from './lib/memory-db.js';
import { applyRulesToEmail } from './lib/email-rules.js';
import {
buildPCOBody,
buildRFIBody,
extractDomain,
extractFirstJobNumber,
validatePassword
} from './lib/utils.js';
const { Pool } = pg;
const PgStore = connectPgSimple(session);
@@ -24,10 +35,17 @@ 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';
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)) {
@@ -42,7 +60,7 @@ function baseHost() {
}
}
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const pool = isMemoryDb ? createMemoryPool() : new Pool({ connectionString: process.env.DATABASE_URL });
app.use(
helmet({
@@ -88,8 +106,8 @@ app.use((req, res, next) => {
return res.status(403).send('Blocked (CSRF)');
});
const uploadDir = '/app/data/uploads';
const attachmentDir = '/app/data/attachments';
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 } });
@@ -99,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: 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());
@@ -701,27 +722,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 +786,48 @@ app.post('/setup/base-url', requireAuth, async (req, res) => {
res.redirect('/setup');
});
await ensureSchema();
let initialized = false;
// 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
}
export async function initializeApp() {
if (initialized) return;
// 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();
await ensureSchema();
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)`);
// 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(`
@@ -915,35 +924,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');
});
@@ -998,11 +978,11 @@ app.post('/admin/email-connectors/:provider/toggle', requireOwner, async (req, r
// Admin: email rules
// Docs (in-app)
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: 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'));
@@ -1041,62 +1021,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 +1202,14 @@ 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}`);
});
if (isMainModule) {
await initializeApp();
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}`);
});
}
}

View File

@@ -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;
}

106
web/src/lib/helpers.js Normal file
View File

@@ -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');
}

672
web/src/lib/memory-db.js Normal file
View File

@@ -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
};
}

75
web/src/lib/utils.js Normal file
View File

@@ -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');
}

View File

@@ -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
}
}