Fix local startup defaults and add MVP tests
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
# Core
|
# Core
|
||||||
DB_PASSWORD=mastermind
|
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)
|
# 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
|
# REQUIRED in production (>=24 chars). Generate with: openssl rand -base64 48
|
||||||
SESSION_SECRET=change-this-to-a-long-random-string
|
SESSION_SECRET=change-this-to-a-long-random-string
|
||||||
@@ -15,8 +15,8 @@ TRUST_PROXY=true
|
|||||||
COOKIE_SECURE=false
|
COOKIE_SECURE=false
|
||||||
|
|
||||||
# One-time bootstrap local owner (only used if there are no local identities yet)
|
# One-time bootstrap local owner (only used if there are no local identities yet)
|
||||||
BOOTSTRAP_OWNER_EMAIL=
|
BOOTSTRAP_OWNER_EMAIL=owner@local
|
||||||
BOOTSTRAP_OWNER_PASSWORD=
|
BOOTSTRAP_OWNER_PASSWORD=owner
|
||||||
|
|
||||||
# Google OAuth (optional)
|
# Google OAuth (optional)
|
||||||
GOOGLE_CLIENT_ID=
|
GOOGLE_CLIENT_ID=
|
||||||
|
|||||||
13
INSTALL.md
13
INSTALL.md
@@ -6,13 +6,14 @@
|
|||||||
|
|
||||||
## Configure
|
## Configure
|
||||||
```bash
|
```bash
|
||||||
cd /root/clawd/mastermind-mvp
|
cd /path/to/mastermind-mvp
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
Edit `.env`:
|
Edit `.env`:
|
||||||
- `SESSION_SECRET` — set to a long random value
|
- `SESSION_SECRET` — set to a long random value
|
||||||
- `BASE_URL` — the URL you use to reach the app (Tailscale IP recommended)
|
- `BASE_URL` — the URL you use to reach the app (Tailscale IP recommended)
|
||||||
|
- `BOOTSTRAP_OWNER_EMAIL` / `BOOTSTRAP_OWNER_PASSWORD` — defaults are set for local MVP use (`owner@local` / `owner`)
|
||||||
|
|
||||||
OAuth is optional for now:
|
OAuth is optional for now:
|
||||||
- `GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET`
|
- `GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET`
|
||||||
@@ -25,10 +26,11 @@ docker compose up -d --build
|
|||||||
|
|
||||||
App:
|
App:
|
||||||
- http://<BASE_URL_HOST>:3005/login
|
- http://<BASE_URL_HOST>:3005/login
|
||||||
|
- With the default `.env.example`, that is `http://localhost:3005/login`
|
||||||
|
|
||||||
## First login (important)
|
## First login (important)
|
||||||
On first run the app creates:
|
On first run, if no local identities exist, the app creates:
|
||||||
- `owner@local` / `owner`
|
- `owner@local` / `owner` by default from `.env.example`
|
||||||
|
|
||||||
Immediately change it:
|
Immediately change it:
|
||||||
- http://<host>:3005/account/password
|
- http://<host>:3005/account/password
|
||||||
@@ -51,3 +53,8 @@ These are the callback URLs the app expects:
|
|||||||
- Microsoft: `BASE_URL/auth/microsoft/callback`
|
- Microsoft: `BASE_URL/auth/microsoft/callback`
|
||||||
|
|
||||||
Set `BASE_URL` correctly before authorizing.
|
Set `BASE_URL` correctly before authorizing.
|
||||||
|
|
||||||
|
## Test
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ A portable, self-hosted dashboard that supports an Assistant PM workflow (starti
|
|||||||
## Quick start
|
## Quick start
|
||||||
See **INSTALL.md**.
|
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
|
## Repo layout
|
||||||
- `docker-compose.yml` — portable dev deploy
|
- `docker-compose.yml` — portable dev deploy
|
||||||
- `web/` — Express app + views
|
- `web/` — Express app + views
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ services:
|
|||||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||||
MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-}
|
MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-}
|
||||||
MICROSOFT_CLIENT_SECRET: ${MICROSOFT_CLIENT_SECRET:-}
|
MICROSOFT_CLIENT_SECRET: ${MICROSOFT_CLIENT_SECRET:-}
|
||||||
|
BOOTSTRAP_OWNER_EMAIL: ${BOOTSTRAP_OWNER_EMAIL:-owner@local}
|
||||||
|
BOOTSTRAP_OWNER_PASSWORD: ${BOOTSTRAP_OWNER_PASSWORD:-owner}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -34,6 +36,11 @@ services:
|
|||||||
- "3005:3005"
|
- "3005:3005"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./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:
|
worker:
|
||||||
build: ./worker
|
build: ./worker
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "node --test",
|
||||||
|
"test:ci": "CI=1 node --test --test-reporter=spec",
|
||||||
|
"up": "docker compose up --build",
|
||||||
|
"down": "docker compose down"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
14
test/compose.integration.test.mjs
Normal file
14
test/compose.integration.test.mjs
Normal 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
62
test/helpers.test.mjs
Normal 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/);
|
||||||
|
});
|
||||||
229
web/src/index.js
229
web/src/index.js
@@ -14,8 +14,18 @@ import multer from 'multer';
|
|||||||
import { simpleParser } from 'mailparser';
|
import { simpleParser } from 'mailparser';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import MarkdownIt from 'markdown-it';
|
import MarkdownIt from 'markdown-it';
|
||||||
|
import {
|
||||||
|
applyRulesToEmail,
|
||||||
|
buildPCOBody,
|
||||||
|
buildRFIBody,
|
||||||
|
extractDomain,
|
||||||
|
extractFirstJobNumber,
|
||||||
|
validatePassword
|
||||||
|
} from './lib/helpers.js';
|
||||||
|
|
||||||
const { Pool } = pg;
|
const { Pool } = pg;
|
||||||
const PgStore = connectPgSimple(session);
|
const PgStore = connectPgSimple(session);
|
||||||
@@ -24,6 +34,10 @@ const app = express();
|
|||||||
app.set('view engine', 'ejs');
|
app.set('view engine', 'ejs');
|
||||||
app.set('views', new URL('./views', import.meta.url).pathname);
|
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 PORT = process.env.PORT || 3005;
|
||||||
const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`;
|
const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`;
|
||||||
const isProd = process.env.NODE_ENV === 'production';
|
const isProd = process.env.NODE_ENV === 'production';
|
||||||
@@ -43,6 +57,7 @@ function baseHost() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
|
const useMemorySessionStore = process.env.SESSION_STORE === 'memory' || (!process.env.DATABASE_URL && process.env.NODE_ENV === 'test');
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
helmet({
|
helmet({
|
||||||
@@ -88,8 +103,13 @@ app.use((req, res, next) => {
|
|||||||
return res.status(403).send('Blocked (CSRF)');
|
return res.status(403).send('Blocked (CSRF)');
|
||||||
});
|
});
|
||||||
|
|
||||||
const uploadDir = '/app/data/uploads';
|
function firstExistingPath(...paths) {
|
||||||
const attachmentDir = '/app/data/attachments';
|
return paths.find((candidate) => candidate && fs.existsSync(candidate)) || paths[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataRoot = firstExistingPath('/app/data', path.resolve(__dirname, '../../data'));
|
||||||
|
const uploadDir = path.join(dataRoot, 'uploads');
|
||||||
|
const attachmentDir = path.join(dataRoot, 'attachments');
|
||||||
fs.mkdirSync(uploadDir, { recursive: true });
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
fs.mkdirSync(attachmentDir, { recursive: true });
|
fs.mkdirSync(attachmentDir, { recursive: true });
|
||||||
const upload = multer({ dest: uploadDir, limits: { fileSize: 50 * 1024 * 1024 } });
|
const upload = multer({ dest: uploadDir, limits: { fileSize: 50 * 1024 * 1024 } });
|
||||||
@@ -102,7 +122,7 @@ const uploadLimiter = rateLimit({ windowMs: 60_000, limit: 20 });
|
|||||||
app.use(
|
app.use(
|
||||||
session({
|
session({
|
||||||
name: 'mm.sid',
|
name: 'mm.sid',
|
||||||
store: new PgStore({ pool, createTableIfMissing: true }),
|
store: useMemorySessionStore ? undefined : new PgStore({ pool, createTableIfMissing: true }),
|
||||||
secret: process.env.SESSION_SECRET || 'change-me',
|
secret: process.env.SESSION_SECRET || 'change-me',
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
@@ -701,27 +721,6 @@ app.get('/inbox/:id', requireAuth, async (req, res) => {
|
|||||||
res.render('inbox_email', { email: rows[0], attachments });
|
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) => {
|
app.post('/inbox/:id/assign', requireAuth, async (req, res) => {
|
||||||
const emailId = req.params.id;
|
const emailId = req.params.id;
|
||||||
const projectId = (req.body.projectId || '').trim();
|
const projectId = (req.body.projectId || '').trim();
|
||||||
@@ -786,39 +785,6 @@ app.post('/setup/base-url', requireAuth, async (req, res) => {
|
|||||||
res.redirect('/setup');
|
res.redirect('/setup');
|
||||||
});
|
});
|
||||||
|
|
||||||
await ensureSchema();
|
|
||||||
|
|
||||||
// Backfill schema changes (safe alters)
|
|
||||||
await pool.query(`alter table users add column if not exists disabled boolean not null default false;`);
|
|
||||||
try {
|
|
||||||
await pool.query(`alter table identities add constraint identities_provider_email_unique unique (provider, email);`);
|
|
||||||
} catch (_) {
|
|
||||||
// constraint may already exist
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create an initial local owner account if none exists (bootstrap)
|
|
||||||
// For safety, we do NOT create a default owner/owner in production.
|
|
||||||
const { rows: existing } = await pool.query("select count(*)::int as c from identities where provider='local'");
|
|
||||||
if (existing[0].c === 0) {
|
|
||||||
const email = (process.env.BOOTSTRAP_OWNER_EMAIL || '').trim().toLowerCase();
|
|
||||||
const password = (process.env.BOOTSTRAP_OWNER_PASSWORD || '').trim();
|
|
||||||
|
|
||||||
if (!email || !password) {
|
|
||||||
console.warn('No local identities exist. Set BOOTSTRAP_OWNER_EMAIL and BOOTSTRAP_OWNER_PASSWORD to create the initial owner account.');
|
|
||||||
} else {
|
|
||||||
const hash = await bcrypt.hash(password, 12);
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
"insert into users(email, display_name, role) values ($1,'Owner','owner') returning id",
|
|
||||||
[email]
|
|
||||||
);
|
|
||||||
await pool.query(
|
|
||||||
"insert into identities(user_id, provider, email, password_hash) values ($1,'local',$2,$3)",
|
|
||||||
[rows[0].id, email, hash]
|
|
||||||
);
|
|
||||||
console.log(`Created bootstrap local owner: ${email} (change password ASAP)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin: user management
|
// Admin: user management
|
||||||
app.get('/admin/users', requireOwner, async (_req, res) => {
|
app.get('/admin/users', requireOwner, async (_req, res) => {
|
||||||
const { rows } = await pool.query(`
|
const { rows } = await pool.query(`
|
||||||
@@ -912,38 +878,6 @@ async function upsertConnector(provider) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await upsertConnector('gmail');
|
|
||||||
await upsertConnector('microsoft');
|
|
||||||
|
|
||||||
async function applyRulesToEmail(emailRow, rules) {
|
|
||||||
const from = (emailRow.from_addr || '').toLowerCase();
|
|
||||||
const subj = (emailRow.subject || '').toLowerCase();
|
|
||||||
const body = (emailRow.body_text || '').toLowerCase();
|
|
||||||
const thread = (emailRow.thread_key || '').toLowerCase();
|
|
||||||
|
|
||||||
for (const r of rules) {
|
|
||||||
if (!r.enabled) continue;
|
|
||||||
const v = (r.match_value || '').toLowerCase();
|
|
||||||
let hit = false;
|
|
||||||
if (r.match_type === 'from_domain') {
|
|
||||||
hit = v && from.includes('@') && from.split('@').pop()?.includes(v.replace(/^@/, ''));
|
|
||||||
} else if (r.match_type === 'from_contains') {
|
|
||||||
hit = v && from.includes(v);
|
|
||||||
} else if (r.match_type === 'subject_contains') {
|
|
||||||
hit = v && subj.includes(v);
|
|
||||||
} else if (r.match_type === 'body_contains') {
|
|
||||||
hit = v && body.includes(v);
|
|
||||||
} else if (r.match_type === 'thread_key') {
|
|
||||||
hit = v && thread && thread === v;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hit) {
|
|
||||||
return { projectId: r.project_id, confidence: 0.9, ruleId: r.id };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
app.get('/account/password', requireAuth, (req, res) => {
|
app.get('/account/password', requireAuth, (req, res) => {
|
||||||
res.render('account_password');
|
res.render('account_password');
|
||||||
});
|
});
|
||||||
@@ -997,12 +931,13 @@ app.post('/admin/email-connectors/:provider/toggle', requireOwner, async (req, r
|
|||||||
|
|
||||||
// Admin: email rules
|
// Admin: email rules
|
||||||
// Docs (in-app)
|
// Docs (in-app)
|
||||||
|
const docRoot = path.resolve(__dirname, '../../');
|
||||||
const DOCS = [
|
const DOCS = [
|
||||||
{ slug: 'readme', title: 'README', path: '/app/README.md' },
|
{ slug: 'readme', title: 'README', path: firstExistingPath('/app/README.md', path.join(docRoot, 'README.md')) },
|
||||||
{ slug: 'install', title: 'INSTALL', path: '/app/INSTALL.md' },
|
{ slug: 'install', title: 'INSTALL', path: firstExistingPath('/app/INSTALL.md', path.join(docRoot, 'INSTALL.md')) },
|
||||||
{ slug: 'operations', title: 'OPERATIONS', path: '/app/OPERATIONS.md' },
|
{ slug: 'operations', title: 'OPERATIONS', path: firstExistingPath('/app/OPERATIONS.md', path.join(docRoot, 'OPERATIONS.md')) },
|
||||||
{ slug: 'development', title: 'DEVELOPMENT', path: '/app/DEVELOPMENT.md' },
|
{ slug: 'development', title: 'DEVELOPMENT', path: firstExistingPath('/app/DEVELOPMENT.md', path.join(docRoot, 'DEVELOPMENT.md')) },
|
||||||
{ slug: 'changelog', title: 'CHANGELOG', path: '/app/CHANGELOG.md' }
|
{ slug: 'changelog', title: 'CHANGELOG', path: firstExistingPath('/app/CHANGELOG.md', path.join(docRoot, 'CHANGELOG.md')) }
|
||||||
];
|
];
|
||||||
|
|
||||||
app.get('/docs', requireAuth, (req, res) => res.redirect('/docs/readme'));
|
app.get('/docs', requireAuth, (req, res) => res.redirect('/docs/readme'));
|
||||||
@@ -1041,62 +976,6 @@ app.get('/drafts', requireAuth, async (req, res) => {
|
|||||||
res.render('drafts_list', { pcos, rfis });
|
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
|
// Create drafts from email
|
||||||
app.post('/drafts/pco/from-email', requireAuth, async (req, res) => {
|
app.post('/drafts/pco/from-email', requireAuth, async (req, res) => {
|
||||||
const emailId = (req.body.emailId || '').trim();
|
const emailId = (req.body.emailId || '').trim();
|
||||||
@@ -1278,7 +1157,53 @@ app.get('/admin/audit', requireOwner, async (_req, res) => {
|
|||||||
res.render('admin_audit', { logs: rows });
|
res.render('admin_audit', { logs: rows });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
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
|
// eslint-disable-next-line no-console
|
||||||
console.log(`Mastermind web listening on ${BASE_URL}`);
|
console.log(`Mastermind web listening on ${BASE_URL}`);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
106
web/src/lib/helpers.js
Normal file
106
web/src/lib/helpers.js
Normal 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');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user