Merge MVP startup defaults with memory-db mode + tests
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
39
INSTALL.md
39
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://<host>: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
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
139
test/smoke.test.js
Normal file
139
test/smoke.test.js
Normal 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
47
test/utils.test.js
Normal 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\./);
|
||||
});
|
||||
162
web/src/index.js
162
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}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
29
web/src/lib/email-rules.js
Normal file
29
web/src/lib/email-rules.js
Normal 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;
|
||||
}
|
||||
672
web/src/lib/memory-db.js
Normal file
672
web/src/lib/memory-db.js
Normal 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
75
web/src/lib/utils.js
Normal 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');
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user