Fix local startup defaults and add MVP tests

This commit is contained in:
2026-03-01 16:50:54 -05:00
parent a0105956e4
commit 5e27459325
9 changed files with 292 additions and 162 deletions

View File

@@ -14,8 +14,18 @@ import multer from 'multer';
import { simpleParser } from 'mailparser';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { marked } from 'marked';
import MarkdownIt from 'markdown-it';
import {
applyRulesToEmail,
buildPCOBody,
buildRFIBody,
extractDomain,
extractFirstJobNumber,
validatePassword
} from './lib/helpers.js';
const { Pool } = pg;
const PgStore = connectPgSimple(session);
@@ -24,6 +34,10 @@ const app = express();
app.set('view engine', 'ejs');
app.set('views', new URL('./views', import.meta.url).pathname);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const isMainModule = process.argv[1] && path.resolve(process.argv[1]) === __filename;
const PORT = process.env.PORT || 3005;
const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`;
const isProd = process.env.NODE_ENV === 'production';
@@ -43,6 +57,7 @@ function baseHost() {
}
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const useMemorySessionStore = process.env.SESSION_STORE === 'memory' || (!process.env.DATABASE_URL && process.env.NODE_ENV === 'test');
app.use(
helmet({
@@ -88,8 +103,13 @@ app.use((req, res, next) => {
return res.status(403).send('Blocked (CSRF)');
});
const uploadDir = '/app/data/uploads';
const attachmentDir = '/app/data/attachments';
function firstExistingPath(...paths) {
return paths.find((candidate) => candidate && fs.existsSync(candidate)) || paths[0];
}
const dataRoot = firstExistingPath('/app/data', path.resolve(__dirname, '../../data'));
const uploadDir = path.join(dataRoot, 'uploads');
const attachmentDir = path.join(dataRoot, 'attachments');
fs.mkdirSync(uploadDir, { recursive: true });
fs.mkdirSync(attachmentDir, { recursive: true });
const upload = multer({ dest: uploadDir, limits: { fileSize: 50 * 1024 * 1024 } });
@@ -102,7 +122,7 @@ const uploadLimiter = rateLimit({ windowMs: 60_000, limit: 20 });
app.use(
session({
name: 'mm.sid',
store: new PgStore({ pool, createTableIfMissing: true }),
store: useMemorySessionStore ? undefined : new PgStore({ pool, createTableIfMissing: true }),
secret: process.env.SESSION_SECRET || 'change-me',
resave: false,
saveUninitialized: false,
@@ -701,27 +721,6 @@ app.get('/inbox/:id', requireAuth, async (req, res) => {
res.render('inbox_email', { email: rows[0], attachments });
});
function extractDomain(fromAddr) {
const m = String(fromAddr || '').match(/@([A-Za-z0-9.-]+)/);
return m ? m[1].toLowerCase() : '';
}
function extractFirstJobNumber(text) {
const t = String(text || '');
// heuristic: prefer 8+ digit job numbers (e.g., 0222600001)
const m = t.match(/\b\d{8,}\b/);
return m ? m[0] : '';
}
function validatePassword(pw) {
const p = String(pw || '');
if (p.length < 12) return 'Password must be at least 12 characters.';
if (!/[A-Z]/.test(p)) return 'Password must include an uppercase letter.';
if (!/[a-z]/.test(p)) return 'Password must include a lowercase letter.';
if (!/\d/.test(p)) return 'Password must include a number.';
return null;
}
app.post('/inbox/:id/assign', requireAuth, async (req, res) => {
const emailId = req.params.id;
const projectId = (req.body.projectId || '').trim();
@@ -786,39 +785,6 @@ app.post('/setup/base-url', requireAuth, async (req, res) => {
res.redirect('/setup');
});
await ensureSchema();
// Backfill schema changes (safe alters)
await pool.query(`alter table users add column if not exists disabled boolean not null default false;`);
try {
await pool.query(`alter table identities add constraint identities_provider_email_unique unique (provider, email);`);
} catch (_) {
// constraint may already exist
}
// Create an initial local owner account if none exists (bootstrap)
// For safety, we do NOT create a default owner/owner in production.
const { rows: existing } = await pool.query("select count(*)::int as c from identities where provider='local'");
if (existing[0].c === 0) {
const email = (process.env.BOOTSTRAP_OWNER_EMAIL || '').trim().toLowerCase();
const password = (process.env.BOOTSTRAP_OWNER_PASSWORD || '').trim();
if (!email || !password) {
console.warn('No local identities exist. Set BOOTSTRAP_OWNER_EMAIL and BOOTSTRAP_OWNER_PASSWORD to create the initial owner account.');
} else {
const hash = await bcrypt.hash(password, 12);
const { rows } = await pool.query(
"insert into users(email, display_name, role) values ($1,'Owner','owner') returning id",
[email]
);
await pool.query(
"insert into identities(user_id, provider, email, password_hash) values ($1,'local',$2,$3)",
[rows[0].id, email, hash]
);
console.log(`Created bootstrap local owner: ${email} (change password ASAP)`);
}
}
// Admin: user management
app.get('/admin/users', requireOwner, async (_req, res) => {
const { rows } = await pool.query(`
@@ -912,38 +878,6 @@ async function upsertConnector(provider) {
);
}
await upsertConnector('gmail');
await upsertConnector('microsoft');
async function applyRulesToEmail(emailRow, rules) {
const from = (emailRow.from_addr || '').toLowerCase();
const subj = (emailRow.subject || '').toLowerCase();
const body = (emailRow.body_text || '').toLowerCase();
const thread = (emailRow.thread_key || '').toLowerCase();
for (const r of rules) {
if (!r.enabled) continue;
const v = (r.match_value || '').toLowerCase();
let hit = false;
if (r.match_type === 'from_domain') {
hit = v && from.includes('@') && from.split('@').pop()?.includes(v.replace(/^@/, ''));
} else if (r.match_type === 'from_contains') {
hit = v && from.includes(v);
} else if (r.match_type === 'subject_contains') {
hit = v && subj.includes(v);
} else if (r.match_type === 'body_contains') {
hit = v && body.includes(v);
} else if (r.match_type === 'thread_key') {
hit = v && thread && thread === v;
}
if (hit) {
return { projectId: r.project_id, confidence: 0.9, ruleId: r.id };
}
}
return null;
}
app.get('/account/password', requireAuth, (req, res) => {
res.render('account_password');
});
@@ -997,12 +931,13 @@ app.post('/admin/email-connectors/:provider/toggle', requireOwner, async (req, r
// Admin: email rules
// Docs (in-app)
const docRoot = path.resolve(__dirname, '../../');
const DOCS = [
{ slug: 'readme', title: 'README', path: '/app/README.md' },
{ slug: 'install', title: 'INSTALL', path: '/app/INSTALL.md' },
{ slug: 'operations', title: 'OPERATIONS', path: '/app/OPERATIONS.md' },
{ slug: 'development', title: 'DEVELOPMENT', path: '/app/DEVELOPMENT.md' },
{ slug: 'changelog', title: 'CHANGELOG', path: '/app/CHANGELOG.md' }
{ slug: 'readme', title: 'README', path: firstExistingPath('/app/README.md', path.join(docRoot, 'README.md')) },
{ slug: 'install', title: 'INSTALL', path: firstExistingPath('/app/INSTALL.md', path.join(docRoot, 'INSTALL.md')) },
{ slug: 'operations', title: 'OPERATIONS', path: firstExistingPath('/app/OPERATIONS.md', path.join(docRoot, 'OPERATIONS.md')) },
{ slug: 'development', title: 'DEVELOPMENT', path: firstExistingPath('/app/DEVELOPMENT.md', path.join(docRoot, 'DEVELOPMENT.md')) },
{ slug: 'changelog', title: 'CHANGELOG', path: firstExistingPath('/app/CHANGELOG.md', path.join(docRoot, 'CHANGELOG.md')) }
];
app.get('/docs', requireAuth, (req, res) => res.redirect('/docs/readme'));
@@ -1041,62 +976,6 @@ app.get('/drafts', requireAuth, async (req, res) => {
res.render('drafts_list', { pcos, rfis });
});
function buildPCOBody(email, project) {
return [
`# Potential Change Order (PCO) — Draft`,
``,
`**Project:** ${project?.name || ''}`,
`**Job #:** ${project?.job_number || ''}`,
`**Source email:** ${email?.subject || ''} (${email?.id || ''})`,
`**From:** ${email?.from_addr || ''}`,
`**Date:** ${email?.date || ''}`,
``,
`## Description / Change event`,
`- (Describe what changed and why)`,
``,
`## Contract / drawing references`,
`- (Sheet/spec refs)`,
``,
`## Cost / schedule impact`,
`- Labor:`,
`- Material:`,
`- Equipment:`,
`- Schedule impact:`,
``,
`## Supporting info`,
`- Email excerpt:`,
``,
`> ${String(email?.body_text || '').slice(0, 1200).replace(/\n/g,'\n> ')}`
].join('\n');
}
function buildRFIBody(email, project) {
return [
`# RFI — Draft`,
``,
`**Project:** ${project?.name || ''}`,
`**Job #:** ${project?.job_number || ''}`,
`**Source email:** ${email?.subject || ''} (${email?.id || ''})`,
`**From:** ${email?.from_addr || ''}`,
`**Date:** ${email?.date || ''}`,
``,
`## Question`,
`- (Write the question clearly)`,
``,
`## Background`,
`- (Why this is needed / conflict)`,
``,
`## Drawing/spec references`,
`- (Sheet/detail/spec section)`,
``,
`## Proposed resolution (optional)`,
`-`,
``,
`## Supporting excerpt`,
`> ${String(email?.body_text || '').slice(0, 1200).replace(/\n/g,'\n> ')}`
].join('\n');
}
// Create drafts from email
app.post('/drafts/pco/from-email', requireAuth, async (req, res) => {
const emailId = (req.body.emailId || '').trim();
@@ -1278,7 +1157,53 @@ app.get('/admin/audit', requireOwner, async (_req, res) => {
res.render('admin_audit', { logs: rows });
});
app.listen(PORT, () => {
// eslint-disable-next-line no-console
console.log(`Mastermind web listening on ${BASE_URL}`);
});
let initialized = false;
export async function initializeApp() {
if (initialized) return;
await ensureSchema();
await pool.query(`alter table users add column if not exists disabled boolean not null default false;`);
try {
await pool.query(`alter table identities add constraint identities_provider_email_unique unique (provider, email);`);
} catch (_) {
// constraint may already exist
}
const { rows: existing } = await pool.query("select count(*)::int as c from identities where provider='local'");
if (existing[0].c === 0) {
const email = (process.env.BOOTSTRAP_OWNER_EMAIL || '').trim().toLowerCase();
const password = (process.env.BOOTSTRAP_OWNER_PASSWORD || '').trim();
if (!email || !password) {
console.warn('No local identities exist. Set BOOTSTRAP_OWNER_EMAIL and BOOTSTRAP_OWNER_PASSWORD to create the initial owner account.');
} else {
const hash = await bcrypt.hash(password, 12);
const { rows } = await pool.query(
"insert into users(email, display_name, role) values ($1,'Owner','owner') returning id",
[email]
);
await pool.query(
"insert into identities(user_id, provider, email, password_hash) values ($1,'local',$2,$3)",
[rows[0].id, email, hash]
);
console.log(`Created bootstrap local owner: ${email} (change password ASAP)`);
}
}
await upsertConnector('gmail');
await upsertConnector('microsoft');
initialized = true;
}
export { app };
if (isMainModule) {
await initializeApp();
app.listen(PORT, () => {
// eslint-disable-next-line no-console
console.log(`Mastermind web listening on ${BASE_URL}`);
});
}

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