security hardening + drafts/attachments
This commit is contained in:
27
.env.example
Normal file
27
.env.example
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Core
|
||||||
|
DB_PASSWORD=mastermind
|
||||||
|
DATABASE_URL=postgres://postgres:${DB_PASSWORD}@db:5432/mastermind
|
||||||
|
|
||||||
|
# Public base URL (set to https://... when live)
|
||||||
|
BASE_URL=http://100.101.78.42:3005
|
||||||
|
|
||||||
|
# REQUIRED in production (>=24 chars). Generate with: openssl rand -base64 48
|
||||||
|
SESSION_SECRET=change-this-to-a-long-random-string
|
||||||
|
|
||||||
|
# If running behind a reverse proxy (Traefik/Nginx/Caddy), enable this so req.ip and secure cookies work
|
||||||
|
TRUST_PROXY=true
|
||||||
|
|
||||||
|
# If public site is HTTPS, set COOKIE_SECURE=true (or just make BASE_URL https://...)
|
||||||
|
COOKIE_SECURE=false
|
||||||
|
|
||||||
|
# One-time bootstrap local owner (only used if there are no local identities yet)
|
||||||
|
BOOTSTRAP_OWNER_EMAIL=
|
||||||
|
BOOTSTRAP_OWNER_PASSWORD=
|
||||||
|
|
||||||
|
# Google OAuth (optional)
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Microsoft OAuth (optional)
|
||||||
|
MICROSOFT_CLIENT_ID=
|
||||||
|
MICROSOFT_CLIENT_SECRET=
|
||||||
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# node
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# data
|
||||||
|
/data/
|
||||||
|
web/data/
|
||||||
|
worker/data/
|
||||||
|
|
||||||
|
# uploads/attachments
|
||||||
|
**/uploads/
|
||||||
|
**/attachments/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# misc
|
||||||
|
*.swp
|
||||||
10
CHANGELOG.md
Normal file
10
CHANGELOG.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
- Initial MVP scaffold
|
||||||
|
- Local/Google/Microsoft auth
|
||||||
|
- Owner user management + password change
|
||||||
|
- Audit logs + viewer
|
||||||
|
- Projects module
|
||||||
|
- Inbox import (.eml) + triage + rules-based auto-assign
|
||||||
|
- Email connectors + rules UI (placeholders until OAuth)
|
||||||
38
DEVELOPMENT.md
Normal file
38
DEVELOPMENT.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# DEVELOPMENT — Mastermind MVP
|
||||||
|
|
||||||
|
## Web app
|
||||||
|
- Entry point: `web/src/index.js`
|
||||||
|
- Views: `web/src/views/*.ejs`
|
||||||
|
|
||||||
|
## Database
|
||||||
|
Schema is created/altered on startup (MVP style) in `ensureSchema()`.
|
||||||
|
|
||||||
|
Tables of interest:
|
||||||
|
- `users`, `identities` — auth + multi-provider identities
|
||||||
|
- `audit_logs` — append-only audit trail
|
||||||
|
- `projects`, `project_members` — project profiles + membership
|
||||||
|
- `ingested_emails` — unified inbox store (upload now, OAuth later)
|
||||||
|
- `email_connectors` — gmail/microsoft status rows
|
||||||
|
- `email_rules` — sorting/assignment rules
|
||||||
|
|
||||||
|
## Audit logging convention
|
||||||
|
Use:
|
||||||
|
```js
|
||||||
|
await audit(req, 'namespace.action', { targetType, targetId, metadata })
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `auth.login_success`
|
||||||
|
- `admin.user_created`
|
||||||
|
- `project.created`
|
||||||
|
- `inbox.email_imported`
|
||||||
|
|
||||||
|
## Adding a new feature (pattern)
|
||||||
|
1) Add DB table/column in `ensureSchema()`
|
||||||
|
2) Add routes in `web/src/index.js`
|
||||||
|
3) Add views in `web/src/views/`
|
||||||
|
4) Log all state changes to `audit_logs`
|
||||||
|
|
||||||
|
## Worker
|
||||||
|
- `worker/src/worker.js` is a placeholder loop.
|
||||||
|
- Later it will pull from connectors, OCR, classify, and run rule assignment.
|
||||||
53
INSTALL.md
Normal file
53
INSTALL.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# INSTALL — Mastermind MVP
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- Docker + Docker Compose
|
||||||
|
- A stable hostname/IP you can reach (LAN or Tailscale)
|
||||||
|
|
||||||
|
## Configure
|
||||||
|
```bash
|
||||||
|
cd /root/clawd/mastermind-mvp
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env`:
|
||||||
|
- `SESSION_SECRET` — set to a long random value
|
||||||
|
- `BASE_URL` — the URL you use to reach the app (Tailscale IP recommended)
|
||||||
|
|
||||||
|
OAuth is optional for now:
|
||||||
|
- `GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET`
|
||||||
|
- `MICROSOFT_CLIENT_ID/MICROSOFT_CLIENT_SECRET`
|
||||||
|
|
||||||
|
## Start
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
App:
|
||||||
|
- http://<BASE_URL_HOST>:3005/login
|
||||||
|
|
||||||
|
## First login (important)
|
||||||
|
On first run the app creates:
|
||||||
|
- `owner@local` / `owner`
|
||||||
|
|
||||||
|
Immediately change it:
|
||||||
|
- http://<host>:3005/account/password
|
||||||
|
|
||||||
|
## Create your first project
|
||||||
|
- http://<host>:3005/projects
|
||||||
|
|
||||||
|
## Import email (until OAuth is connected)
|
||||||
|
Export emails as `.eml` and upload:
|
||||||
|
- http://<host>:3005/inbox
|
||||||
|
|
||||||
|
## Enable connector placeholders
|
||||||
|
- http://<host>:3005/admin/email
|
||||||
|
|
||||||
|
Connectors will show **Configured: no** until OAuth client credentials are added.
|
||||||
|
|
||||||
|
## OAuth callbacks (when you’re ready)
|
||||||
|
These are the callback URLs the app expects:
|
||||||
|
- Google: `BASE_URL/auth/google/callback`
|
||||||
|
- Microsoft: `BASE_URL/auth/microsoft/callback`
|
||||||
|
|
||||||
|
Set `BASE_URL` correctly before authorizing.
|
||||||
59
OPERATIONS.md
Normal file
59
OPERATIONS.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# OPERATIONS — Mastermind MVP
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
```bash
|
||||||
|
cd /root/clawd/mastermind-mvp
|
||||||
|
docker compose logs -f web
|
||||||
|
docker compose logs -f db
|
||||||
|
```
|
||||||
|
|
||||||
|
## Restart
|
||||||
|
```bash
|
||||||
|
docker compose restart
|
||||||
|
```
|
||||||
|
|
||||||
|
## Update (pull latest code and rebuild)
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data persistence
|
||||||
|
Data is stored under `./data/`:
|
||||||
|
- `data/postgres/` — Postgres volume
|
||||||
|
- `data/uploads/` — stored imported `.eml` files
|
||||||
|
|
||||||
|
## Backup (manual for now)
|
||||||
|
```bash
|
||||||
|
# Stop app for a consistent snapshot
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
tar -czf mastermind_backup_$(date +%Y%m%d_%H%M%S).tar.gz data .env docker-compose.yml
|
||||||
|
|
||||||
|
# Start again
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Restore
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
rm -rf data
|
||||||
|
|
||||||
|
tar -xzf mastermind_backup_YYYYMMDD_HHMMSS.tar.gz
|
||||||
|
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Move to a new server (portability)
|
||||||
|
Copy:
|
||||||
|
- the whole `mastermind-mvp/` folder (or at minimum: `docker-compose.yml`, `web/`, `worker/`, `data/`, `.env`)
|
||||||
|
|
||||||
|
On new host:
|
||||||
|
```bash
|
||||||
|
cd mastermind-mvp
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Then verify:
|
||||||
|
- `/health`
|
||||||
|
- login works
|
||||||
48
docker-compose.yml
Normal file
48
docker-compose.yml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: mastermind
|
||||||
|
POSTGRES_USER: mastermind
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-mastermind}
|
||||||
|
volumes:
|
||||||
|
- ./data/postgres:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U mastermind -d mastermind"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
web:
|
||||||
|
build: ./web
|
||||||
|
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}
|
||||||
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||||
|
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||||
|
MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-}
|
||||||
|
MICROSOFT_CLIENT_SECRET: ${MICROSOFT_CLIENT_SECRET:-}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "3005:3005"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
|
||||||
|
worker:
|
||||||
|
build: ./worker
|
||||||
|
environment:
|
||||||
|
NODE_ENV: development
|
||||||
|
DATABASE_URL: postgres://mastermind:${DB_PASSWORD:-mastermind}@db:5432/mastermind
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
# (MVP) no bind-mount to avoid masking node_modules inside container
|
||||||
|
|
||||||
|
volumes: {}
|
||||||
13
package.json
Normal file
13
package.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "mastermind-mvp",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs"
|
||||||
|
}
|
||||||
27
scripts/backup.sh
Executable file
27
scripts/backup.sh
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE=$(cd "$(dirname "$0")/.." && pwd)
|
||||||
|
TS=$(date +%Y%m%d_%H%M%S)
|
||||||
|
OUT="$HERE/backups"
|
||||||
|
mkdir -p "$OUT"
|
||||||
|
|
||||||
|
ARCHIVE="$OUT/mastermind_backup_${TS}.tar.gz"
|
||||||
|
|
||||||
|
echo "[backup] stopping containers for consistent snapshot..."
|
||||||
|
cd "$HERE"
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
echo "[backup] creating archive: $ARCHIVE"
|
||||||
|
tar -czf "$ARCHIVE" \
|
||||||
|
data \
|
||||||
|
.env \
|
||||||
|
docker-compose.yml \
|
||||||
|
README.md INSTALL.md OPERATIONS.md DEVELOPMENT.md CHANGELOG.md \
|
||||||
|
web worker scripts \
|
||||||
|
2>/dev/null || true
|
||||||
|
|
||||||
|
echo "[backup] starting containers..."
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
echo "[backup] done: $ARCHIVE"
|
||||||
26
scripts/restore.sh
Executable file
26
scripts/restore.sh
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ $# -ne 1 ]; then
|
||||||
|
echo "Usage: $0 <backup-archive.tar.gz>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ARCHIVE="$1"
|
||||||
|
HERE=$(cd "$(dirname "$0")/.." && pwd)
|
||||||
|
|
||||||
|
cd "$HERE"
|
||||||
|
|
||||||
|
echo "[restore] stopping containers..."
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
echo "[restore] wiping data/..."
|
||||||
|
rm -rf data
|
||||||
|
|
||||||
|
echo "[restore] extracting $ARCHIVE"
|
||||||
|
tar -xzf "$ARCHIVE" -C "$HERE"
|
||||||
|
|
||||||
|
echo "[restore] starting containers..."
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
echo "[restore] done"
|
||||||
7
web/Dockerfile
Normal file
7
web/Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 3005
|
||||||
|
CMD ["npm","run","dev"]
|
||||||
1825
web/package-lock.json
generated
Normal file
1825
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
web/package.json
Normal file
28
web/package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "mastermind-web",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node --watch src/index.js",
|
||||||
|
"start": "node src/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"connect-pg-simple": "^10.0.0",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"ejs": "^3.1.10",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"express-rate-limit": "^7.4.0",
|
||||||
|
"express-session": "^1.18.1",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"mailparser": "^3.7.2",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"marked": "^12.0.2",
|
||||||
|
"markdown-it": "^14.1.0",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-azure-ad-oauth2": "^0.0.4",
|
||||||
|
"passport-google-oauth20": "^2.0.0",
|
||||||
|
"passport-local": "^1.0.0",
|
||||||
|
"pg": "^8.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
1284
web/src/index.js
Normal file
1284
web/src/index.js
Normal file
File diff suppressed because it is too large
Load Diff
45
web/src/views/account_password.ejs
Normal file
45
web/src/views/account_password.ejs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Mastermind — Change Password</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
|
||||||
|
header{background:#111824;border-bottom:1px solid #233043;padding:14px 16px;position:sticky;top:0}
|
||||||
|
main{max-width:700px;margin:0 auto;padding:16px}
|
||||||
|
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:14px;margin:12px 0}
|
||||||
|
label{display:block;margin:10px 0 6px;color:#a9b7c6}
|
||||||
|
input{width:100%;padding:12px;border-radius:10px;border:1px solid #233043;background:#0e1520;color:#e8eef7}
|
||||||
|
button{padding:12px 14px;border-radius:10px;border:1px solid #233043;background:#1c2a3d;color:#e8eef7;margin-top:12px;width:100%}
|
||||||
|
a{color:#7cc4ff;text-decoration:none}
|
||||||
|
.muted{color:#a9b7c6;font-size:13px;line-height:1.4}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
|
||||||
|
<div style="font-weight:700">Change Password</div>
|
||||||
|
<div style="display:flex;gap:10px">
|
||||||
|
<a href="/">Dashboard</a>
|
||||||
|
<a href="/logout">Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="card">
|
||||||
|
<p class="muted">This changes your <b>local</b> password (SSO passwords are managed by Google/Microsoft). If you don’t have a local identity yet, this will create one.</p>
|
||||||
|
<form method="post" action="/account/password">
|
||||||
|
<label>Current password (leave blank if you have no local password yet)</label>
|
||||||
|
<input name="currentPassword" type="password" />
|
||||||
|
<label>New password</label>
|
||||||
|
<input name="newPassword" type="password" required />
|
||||||
|
<label>Confirm new password</label>
|
||||||
|
<input name="confirmPassword" type="password" required />
|
||||||
|
<button type="submit">Update password</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
64
web/src/views/admin_audit.ejs
Normal file
64
web/src/views/admin_audit.ejs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Mastermind — Audit Log</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
|
||||||
|
header{background:#111824;border-bottom:1px solid #233043;padding:14px 16px;position:sticky;top:0}
|
||||||
|
main{max-width:1200px;margin:0 auto;padding:16px}
|
||||||
|
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:14px;margin:12px 0}
|
||||||
|
a{color:#7cc4ff;text-decoration:none}
|
||||||
|
.muted{color:#a9b7c6}
|
||||||
|
table{width:100%;border-collapse:collapse}
|
||||||
|
th,td{border-bottom:1px solid #233043;padding:10px 6px;text-align:left;color:#a9b7c6;font-size:13px;vertical-align:top}
|
||||||
|
th{color:#e8eef7}
|
||||||
|
code{font-family:ui-monospace,Menlo,Consolas,monospace;color:#d6e3f3}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
|
||||||
|
<div style="font-weight:700">Audit Log</div>
|
||||||
|
<div style="display:flex;gap:10px">
|
||||||
|
<a href="/admin/users">Users</a>
|
||||||
|
<a href="/">Dashboard</a>
|
||||||
|
<a href="/logout">Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="card">
|
||||||
|
<div class="muted">Showing latest <%= logs.length %> events (most recent first).</div>
|
||||||
|
<div style="overflow:auto;margin-top:10px">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Actor</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Target</th>
|
||||||
|
<th>IP</th>
|
||||||
|
<th>Metadata</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% logs.forEach(l => { %>
|
||||||
|
<tr>
|
||||||
|
<td><%= l.created_at %></td>
|
||||||
|
<td><%= l.actor_email || '' %></td>
|
||||||
|
<td><code><%= l.action %></code></td>
|
||||||
|
<td><%= l.target_type || '' %> <%= l.target_id || '' %></td>
|
||||||
|
<td><%= l.ip || '' %></td>
|
||||||
|
<td><code><%= JSON.stringify(l.metadata || {}) %></code></td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
119
web/src/views/admin_users.ejs
Normal file
119
web/src/views/admin_users.ejs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Mastermind — Users</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
|
||||||
|
header{background:#111824;border-bottom:1px solid #233043;padding:14px 16px;position:sticky;top:0}
|
||||||
|
main{max-width:1100px;margin:0 auto;padding:16px}
|
||||||
|
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:14px;margin:12px 0}
|
||||||
|
a{color:#7cc4ff;text-decoration:none}
|
||||||
|
.muted{color:#a9b7c6}
|
||||||
|
table{width:100%;border-collapse:collapse}
|
||||||
|
th,td{border-bottom:1px solid #233043;padding:10px 6px;text-align:left;color:#a9b7c6;font-size:13px;vertical-align:top}
|
||||||
|
th{color:#e8eef7}
|
||||||
|
input,select{padding:10px;border-radius:10px;border:1px solid #233043;background:#0e1520;color:#e8eef7;width:100%}
|
||||||
|
button{padding:10px 12px;border-radius:10px;border:1px solid #233043;background:#1c2a3d;color:#e8eef7}
|
||||||
|
.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:12px}
|
||||||
|
.col6{grid-column:span 12}
|
||||||
|
@media(min-width:900px){.col6{grid-column:span 6}}
|
||||||
|
.pill{display:inline-block;padding:2px 8px;border:1px solid #233043;border-radius:999px;font-size:12px;color:#a9b7c6}
|
||||||
|
.danger{background:#2b1216;border-color:#5a1e28}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
|
||||||
|
<div style="font-weight:700">User Management</div>
|
||||||
|
<div style="display:flex;gap:10px">
|
||||||
|
<a href="/">Dashboard</a>
|
||||||
|
<a href="/logout">Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin:0 0 8px">Create local user</h2>
|
||||||
|
<form method="post" action="/admin/users/create" class="grid">
|
||||||
|
<div class="col6">
|
||||||
|
<label class="muted">Email</label>
|
||||||
|
<input name="email" placeholder="user@company.com" required />
|
||||||
|
</div>
|
||||||
|
<div class="col6">
|
||||||
|
<label class="muted">Display name</label>
|
||||||
|
<input name="displayName" placeholder="Jane Doe" />
|
||||||
|
</div>
|
||||||
|
<div class="col6">
|
||||||
|
<label class="muted">Role</label>
|
||||||
|
<select name="role">
|
||||||
|
<option value="apm">apm</option>
|
||||||
|
<option value="pm">pm</option>
|
||||||
|
<option value="viewer">viewer</option>
|
||||||
|
<option value="owner">owner</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col6">
|
||||||
|
<label class="muted">Temporary password</label>
|
||||||
|
<input name="tempPassword" placeholder="Temp password" required />
|
||||||
|
</div>
|
||||||
|
<div style="grid-column:span 12">
|
||||||
|
<button type="submit">Create user</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p class="muted" style="margin-top:10px">SSO users (Google/Microsoft) will appear here after their first login.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin:0 0 8px">Users</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Identities</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% users.forEach(u => { %>
|
||||||
|
<tr>
|
||||||
|
<td><%= u.email || '(no email)' %></td>
|
||||||
|
<td><%= u.display_name || '' %></td>
|
||||||
|
<td><span class="pill"><%= u.role %></span></td>
|
||||||
|
<td>
|
||||||
|
<% if (u.disabled) { %>
|
||||||
|
<span class="pill danger">disabled</span>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="pill">active</span>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
<td class="muted">
|
||||||
|
<%= (u.providers || '').split(',').filter(Boolean).join(', ') %>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/admin/users/<%= u.id %>/reset" style="display:inline-block;min-width:220px">
|
||||||
|
<input name="newPassword" placeholder="New temp password" />
|
||||||
|
<button type="submit" style="margin-top:6px;width:100%">Reset local password</button>
|
||||||
|
</form>
|
||||||
|
<div style="height:8px"></div>
|
||||||
|
<form method="post" action="/admin/users/<%= u.id %>/toggle" style="display:inline-block;width:220px">
|
||||||
|
<button type="submit" style="width:100%"><%= u.disabled ? 'Enable user' : 'Disable user' %></button>
|
||||||
|
</form>
|
||||||
|
<div style="height:8px"></div>
|
||||||
|
<form method="post" action="/admin/users/<%= u.id %>/delete" onsubmit="return confirm('Delete user? This cannot be undone.');" style="display:inline-block;width:220px">
|
||||||
|
<button type="submit" style="width:100%;background:#2b1216;border-color:#5a1e28">Delete user</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
59
web/src/views/docs.ejs
Normal file
59
web/src/views/docs.ejs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Mastermind — Docs</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
|
||||||
|
header{background:#111824;border-bottom:1px solid #233043;padding:14px 16px;position:sticky;top:0}
|
||||||
|
main{max-width:1100px;margin:0 auto;padding:16px}
|
||||||
|
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:14px;margin:12px 0}
|
||||||
|
a{color:#7cc4ff;text-decoration:none}
|
||||||
|
.muted{color:#a9b7c6}
|
||||||
|
.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:12px}
|
||||||
|
.nav{grid-column:span 12}
|
||||||
|
.content{grid-column:span 12}
|
||||||
|
@media(min-width:900px){.nav{grid-column:span 3}.content{grid-column:span 9}}
|
||||||
|
.nav a{display:block;padding:10px;border:1px solid #233043;border-radius:10px;margin-bottom:8px;background:#0e1520}
|
||||||
|
.doc{background:#0e1520;border:1px solid #233043;border-radius:10px;padding:14px;overflow:auto}
|
||||||
|
.doc h1,.doc h2,.doc h3{color:#e8eef7}
|
||||||
|
.doc p,.doc li{color:#a9b7c6;line-height:1.45}
|
||||||
|
.doc code,.doc pre{font-family:ui-monospace,Menlo,Consolas,monospace}
|
||||||
|
.doc pre{background:#0b0f14;border:1px solid #233043;border-radius:10px;padding:12px;color:#d6e3f3;overflow:auto}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
|
||||||
|
<div style="font-weight:700">Docs</div>
|
||||||
|
<div style="display:flex;gap:10px">
|
||||||
|
<a href="/">Dashboard</a>
|
||||||
|
<a href="/logout">Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="nav">
|
||||||
|
<div class="card">
|
||||||
|
<% docs.forEach(d => { %>
|
||||||
|
<a href="/docs/<%= d.slug %>"><%= d.title %></a>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="card">
|
||||||
|
<div class="muted">Viewing: <b><%= current.title %></b></div>
|
||||||
|
<div class="muted">Source file: <code><%= current.path %></code></div>
|
||||||
|
</div>
|
||||||
|
<div class="doc">
|
||||||
|
<%- html %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
75
web/src/views/draft_edit.ejs
Normal file
75
web/src/views/draft_edit.ejs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Mastermind — Draft</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
|
||||||
|
header{background:#111824;border-bottom:1px solid #233043;padding:14px 16px;position:sticky;top:0}
|
||||||
|
main{max-width:1100px;margin:0 auto;padding:16px}
|
||||||
|
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:14px;margin:12px 0}
|
||||||
|
a{color:#7cc4ff;text-decoration:none}
|
||||||
|
.muted{color:#a9b7c6}
|
||||||
|
textarea,input,select{width:100%;padding:12px;border-radius:10px;border:1px solid #233043;background:#0e1520;color:#e8eef7}
|
||||||
|
button{padding:12px 14px;border-radius:10px;border:1px solid #233043;background:#1c2a3d;color:#e8eef7}
|
||||||
|
.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:12px}
|
||||||
|
.col6{grid-column:span 12}
|
||||||
|
@media(min-width:900px){.col6{grid-column:span 6}}
|
||||||
|
pre{white-space:pre-wrap;background:#0e1520;border:1px solid #233043;border-radius:10px;padding:12px;color:#d6e3f3}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
|
||||||
|
<div style="font-weight:700"><%= kind.toUpperCase() %> Draft</div>
|
||||||
|
<div style="display:flex;gap:10px">
|
||||||
|
<a href="/">Dashboard</a>
|
||||||
|
<a href="/logout">Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<div class="card">
|
||||||
|
<div class="muted"><b>Project:</b> <%= project ? project.name : '(unassigned)' %></div>
|
||||||
|
<div class="muted"><b>Source email:</b> <% if (email) { %><a href="/inbox/<%= email.id %>"><%= email.subject || email.id %></a><% } else { %>—<% } %></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div style="display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end;margin-bottom:10px">
|
||||||
|
<a href="/drafts/<%= kind %>/<%= draft.id %>/export.md">Download .md</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="/drafts/<%= kind %>/<%= draft.id %>/save" class="grid">
|
||||||
|
<div class="col6">
|
||||||
|
<label class="muted">Title</label>
|
||||||
|
<input name="title" value="<%= draft.title || '' %>" />
|
||||||
|
</div>
|
||||||
|
<div class="col6">
|
||||||
|
<label class="muted">Status</label>
|
||||||
|
<select name="status">
|
||||||
|
<% ['draft','ready','sent'].forEach(s => { %>
|
||||||
|
<option value="<%= s %>" <%= draft.status===s?'selected':'' %>><%= s %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="grid-column:span 12">
|
||||||
|
<label class="muted">Body (Markdown)</label>
|
||||||
|
<textarea name="body" rows="14"><%= draft.body || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
<div style="grid-column:span 12">
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin:0 0 8px">Preview</h2>
|
||||||
|
<div class="muted" style="margin-bottom:8px">Rendered Markdown preview</div>
|
||||||
|
<div style="background:#0e1520;border:1px solid #233043;border-radius:10px;padding:12px;overflow:auto">
|
||||||
|
<%- previewHtml %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
70
web/src/views/drafts_list.ejs
Normal file
70
web/src/views/drafts_list.ejs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Mastermind — Drafts</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
|
||||||
|
header{background:#111824;border-bottom:1px solid #233043;padding:14px 16px;position:sticky;top:0}
|
||||||
|
main{max-width:1200px;margin:0 auto;padding:16px}
|
||||||
|
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:14px;margin:12px 0}
|
||||||
|
a{color:#7cc4ff;text-decoration:none}
|
||||||
|
.muted{color:#a9b7c6}
|
||||||
|
table{width:100%;border-collapse:collapse}
|
||||||
|
th,td{border-bottom:1px solid #233043;padding:10px 6px;text-align:left;color:#a9b7c6;font-size:13px;vertical-align:top}
|
||||||
|
th{color:#e8eef7}
|
||||||
|
.pill{display:inline-block;padding:2px 8px;border:1px solid #233043;border-radius:999px;font-size:12px;color:#a9b7c6}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
|
||||||
|
<div style="font-weight:700">Drafts</div>
|
||||||
|
<div style="display:flex;gap:10px">
|
||||||
|
<a href="/">Dashboard</a>
|
||||||
|
<a href="/logout">Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<div class="card">
|
||||||
|
<div class="muted">PCO and RFI drafts generated from emails. Nothing is sent automatically.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin:0 0 8px">PCO drafts</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Created</th><th>Project</th><th>Title</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<% pcos.forEach(d => { %>
|
||||||
|
<tr>
|
||||||
|
<td><%= d.created_at %></td>
|
||||||
|
<td><%= d.project_name || '' %></td>
|
||||||
|
<td><a href="/drafts/pco/<%= d.id %>"><%= d.title || '(untitled)' %></a></td>
|
||||||
|
<td><span class="pill"><%= d.status %></span></td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin:0 0 8px">RFI drafts</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Created</th><th>Project</th><th>Title</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<% rfis.forEach(d => { %>
|
||||||
|
<tr>
|
||||||
|
<td><%= d.created_at %></td>
|
||||||
|
<td><%= d.project_name || '' %></td>
|
||||||
|
<td><a href="/drafts/rfi/<%= d.id %>"><%= d.title || '(untitled)' %></a></td>
|
||||||
|
<td><span class="pill"><%= d.status %></span></td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
110
web/src/views/email_rules.ejs
Normal file
110
web/src/views/email_rules.ejs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Mastermind — Email Rules</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
|
||||||
|
header{background:#111824;border-bottom:1px solid #233043;padding:14px 16px;position:sticky;top:0}
|
||||||
|
main{max-width:1200px;margin:0 auto;padding:16px}
|
||||||
|
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:14px;margin:12px 0}
|
||||||
|
a{color:#7cc4ff;text-decoration:none}
|
||||||
|
.muted{color:#a9b7c6}
|
||||||
|
input,select{padding:12px;border-radius:10px;border:1px solid #233043;background:#0e1520;color:#e8eef7;width:100%}
|
||||||
|
button{padding:12px 14px;border-radius:10px;border:1px solid #233043;background:#1c2a3d;color:#e8eef7}
|
||||||
|
table{width:100%;border-collapse:collapse}
|
||||||
|
th,td{border-bottom:1px solid #233043;padding:10px 6px;text-align:left;color:#a9b7c6;font-size:13px;vertical-align:top}
|
||||||
|
th{color:#e8eef7}
|
||||||
|
.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:12px}
|
||||||
|
.col4{grid-column:span 12}
|
||||||
|
@media(min-width:900px){.col4{grid-column:span 4}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
|
||||||
|
<div style="font-weight:700">Email Rules</div>
|
||||||
|
<div style="display:flex;gap:10px">
|
||||||
|
<a href="/admin/email">Email Accounts</a>
|
||||||
|
<a href="/">Dashboard</a>
|
||||||
|
<a href="/logout">Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin:0 0 8px">Create rule</h2>
|
||||||
|
<form method="post" action="/admin/email-rules/create" class="grid">
|
||||||
|
<div class="col4">
|
||||||
|
<label class="muted">Project</label>
|
||||||
|
<select name="projectId" required>
|
||||||
|
<% projects.forEach(p => { %>
|
||||||
|
<option value="<%= p.id %>"><%= p.name %> (<%= p.job_number || '—' %>)</option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col4">
|
||||||
|
<label class="muted">Match type</label>
|
||||||
|
<select name="matchType">
|
||||||
|
<option value="from_domain">From domain</option>
|
||||||
|
<option value="from_contains">From contains</option>
|
||||||
|
<option value="subject_contains">Subject contains</option>
|
||||||
|
<option value="body_contains">Body contains</option>
|
||||||
|
<option value="thread_key">Thread key</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col4">
|
||||||
|
<label class="muted">Match value</label>
|
||||||
|
<input name="matchValue" placeholder="@gc.com or 0222600001 or project name" required />
|
||||||
|
</div>
|
||||||
|
<div class="col4">
|
||||||
|
<label class="muted">Priority (lower = earlier)</label>
|
||||||
|
<input name="priority" value="100" />
|
||||||
|
</div>
|
||||||
|
<div style="grid-column:span 12">
|
||||||
|
<button type="submit">Add rule</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin:0 0 8px">Rules</h2>
|
||||||
|
<div style="overflow:auto">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Enabled</th>
|
||||||
|
<th>Priority</th>
|
||||||
|
<th>Project</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Value</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% rules.forEach(r => { %>
|
||||||
|
<tr>
|
||||||
|
<td><%= r.enabled ? 'yes' : 'no' %></td>
|
||||||
|
<td><%= r.priority %></td>
|
||||||
|
<td><%= r.project_name %></td>
|
||||||
|
<td><%= r.match_type %></td>
|
||||||
|
<td><%= r.match_value %></td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/admin/email-rules/<%= r.id %>/toggle" style="display:inline">
|
||||||
|
<button type="submit"><%= r.enabled ? 'Disable' : 'Enable' %></button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/email-rules/<%= r.id %>/delete" onsubmit="return confirm('Delete rule?');" style="display:inline">
|
||||||
|
<button type="submit" style="background:#2b1216;border-color:#5a1e28">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
86
web/src/views/email_settings.ejs
Normal file
86
web/src/views/email_settings.ejs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Mastermind — Email Accounts</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
|
||||||
|
header{background:#111824;border-bottom:1px solid #233043;padding:14px 16px;position:sticky;top:0}
|
||||||
|
main{max-width:1100px;margin:0 auto;padding:16px}
|
||||||
|
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:14px;margin:12px 0}
|
||||||
|
a{color:#7cc4ff;text-decoration:none}
|
||||||
|
.muted{color:#a9b7c6}
|
||||||
|
table{width:100%;border-collapse:collapse}
|
||||||
|
th,td{border-bottom:1px solid #233043;padding:10px 6px;text-align:left;color:#a9b7c6;font-size:13px;vertical-align:top}
|
||||||
|
th{color:#e8eef7}
|
||||||
|
button{padding:10px 12px;border-radius:10px;border:1px solid #233043;background:#1c2a3d;color:#e8eef7}
|
||||||
|
.pill{display:inline-block;padding:2px 8px;border:1px solid #233043;border-radius:999px;font-size:12px;color:#a9b7c6}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
|
||||||
|
<div style="font-weight:700">Email Accounts</div>
|
||||||
|
<div style="display:flex;gap:10px">
|
||||||
|
<a href="/admin/email-rules">Rules</a>
|
||||||
|
<a href="/">Dashboard</a>
|
||||||
|
<a href="/logout">Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<div class="card">
|
||||||
|
<p class="muted">Connectors are first-class from day 1. They’ll show <b>Not configured</b> until you provide OAuth credentials. Manual .eml upload works now.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin:0 0 8px">Connectors</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Provider</th>
|
||||||
|
<th>Enabled</th>
|
||||||
|
<th>Configured</th>
|
||||||
|
<th>Authorized</th>
|
||||||
|
<th>Last sync</th>
|
||||||
|
<th>Last error</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% connectors.forEach(c => { %>
|
||||||
|
<tr>
|
||||||
|
<td><b><%= c.provider %></b></td>
|
||||||
|
<td><span class="pill"><%= c.enabled ? 'yes' : 'no' %></span></td>
|
||||||
|
<td><span class="pill"><%= c.configured ? 'yes' : 'no' %></span></td>
|
||||||
|
<td><span class="pill"><%= c.authorized ? 'yes' : 'no' %></span></td>
|
||||||
|
<td><%= c.last_sync_at || '' %></td>
|
||||||
|
<td><%= c.last_error || '' %></td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/admin/email-connectors/<%= c.provider %>/toggle">
|
||||||
|
<button type="submit"><%= c.enabled ? 'Disable' : 'Enable' %></button>
|
||||||
|
</form>
|
||||||
|
<div class="muted" style="margin-top:6px">
|
||||||
|
<% if (!c.configured) { %>
|
||||||
|
Not configured — add OAuth credentials in <code>.env</code> (we’ll add UI later).
|
||||||
|
<% } else if (!c.authorized) { %>
|
||||||
|
Configured — needs OAuth authorization (pending).
|
||||||
|
<% } else { %>
|
||||||
|
Connected.
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin:0 0 8px">Manual import</h2>
|
||||||
|
<p><a href="/inbox">→ Inbox import + triage</a></p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
73
web/src/views/home.ejs
Normal file
73
web/src/views/home.ejs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Mastermind — Dashboard</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
|
||||||
|
header{position:sticky;top:0;background:#111824;border-bottom:1px solid #233043;padding:14px 16px}
|
||||||
|
main{max-width:1100px;margin:0 auto;padding:16px}
|
||||||
|
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:14px;margin:12px 0}
|
||||||
|
a{color:#7cc4ff;text-decoration:none}
|
||||||
|
a:hover{text-decoration:underline}
|
||||||
|
.muted{color:#a9b7c6}
|
||||||
|
.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:12px}
|
||||||
|
.col6{grid-column:span 12}
|
||||||
|
@media(min-width:900px){.col6{grid-column:span 6}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:700">Mastermind</div>
|
||||||
|
<div class="muted" style="font-size:13px">Logged in as <%= user.email || user.display_name || user.id %></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:10px">
|
||||||
|
<a href="/account/password">Password</a>
|
||||||
|
<a href="/setup">Setup</a>
|
||||||
|
<a href="/docs">Docs</a>
|
||||||
|
<% if (user.role === 'owner') { %>
|
||||||
|
<a href="/admin/email">Email</a>
|
||||||
|
<a href="/admin/email-rules">Rules</a>
|
||||||
|
<a href="/admin/users">Users</a>
|
||||||
|
<a href="/admin/audit">Audit</a>
|
||||||
|
<% } %>
|
||||||
|
<a href="/logout">Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card col6">
|
||||||
|
<h2 style="margin:0 0 8px">MVP status</h2>
|
||||||
|
<div class="muted">Auth is wired (local + optional Google/Microsoft). Next: project wizard + inbox ingestion.</div>
|
||||||
|
</div>
|
||||||
|
<div class="card col6">
|
||||||
|
<h2 style="margin:0 0 8px">Base URL</h2>
|
||||||
|
<div class="muted">Current: <code><%= baseUrl %></code></div>
|
||||||
|
<div class="muted" style="margin-top:6px">If OAuth callbacks don’t work, update Base URL under Setup.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin:0 0 8px">Start</h2>
|
||||||
|
<p class="muted">Create a project profile so Mastermind can sort your single mailbox automatically.</p>
|
||||||
|
<p><a href="/projects">→ Projects</a></p>
|
||||||
|
<p><a href="/inbox">→ Inbox (import + triage)</a></p>
|
||||||
|
<p><a href="/drafts">→ Drafts (PCO / RFI)</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin:0 0 8px">Next build steps</h2>
|
||||||
|
<ol class="muted">
|
||||||
|
<li>Inbox triage queue (single mailbox → auto-sort per project)</li>
|
||||||
|
<li>RFI/Submittal/PCO logs + draft generators with citations</li>
|
||||||
|
<li>Email connectors (Gmail + Microsoft) behind OAuth + local fallback imports</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
114
web/src/views/inbox.ejs
Normal file
114
web/src/views/inbox.ejs
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Mastermind — Inbox</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
|
||||||
|
header{background:#111824;border-bottom:1px solid #233043;padding:14px 16px;position:sticky;top:0}
|
||||||
|
main{max-width:1200px;margin:0 auto;padding:16px}
|
||||||
|
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:14px;margin:12px 0}
|
||||||
|
a{color:#7cc4ff;text-decoration:none}
|
||||||
|
.muted{color:#a9b7c6}
|
||||||
|
input,select{padding:12px;border-radius:10px;border:1px solid #233043;background:#0e1520;color:#e8eef7;width:100%}
|
||||||
|
button{padding:12px 14px;border-radius:10px;border:1px solid #233043;background:#1c2a3d;color:#e8eef7}
|
||||||
|
table{width:100%;border-collapse:collapse}
|
||||||
|
th,td{border-bottom:1px solid #233043;padding:10px 6px;text-align:left;color:#a9b7c6;font-size:13px;vertical-align:top}
|
||||||
|
th{color:#e8eef7}
|
||||||
|
.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:12px}
|
||||||
|
.col6{grid-column:span 12}
|
||||||
|
@media(min-width:900px){.col6{grid-column:span 6}}
|
||||||
|
.actions{display:flex;gap:8px;flex-wrap:wrap}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
|
||||||
|
<div style="font-weight:700">Inbox</div>
|
||||||
|
<div style="display:flex;gap:10px">
|
||||||
|
<a href="/projects">Projects</a>
|
||||||
|
<a href="/">Dashboard</a>
|
||||||
|
<a href="/logout">Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin:0 0 8px">Import emails (until OAuth is connected)</h2>
|
||||||
|
<div class="muted">Upload .eml files. Mastermind stores them and puts them into the Unsorted queue for triage.</div>
|
||||||
|
<form method="post" action="/inbox/upload" enctype="multipart/form-data" class="grid" style="margin-top:10px">
|
||||||
|
<div class="col6">
|
||||||
|
<label class="muted">.eml files</label>
|
||||||
|
<input type="file" name="emls" multiple accept=".eml,message/rfc822" required />
|
||||||
|
</div>
|
||||||
|
<div class="col6">
|
||||||
|
<label class="muted">Source label</label>
|
||||||
|
<input name="source" placeholder="outlook-export" value="upload" />
|
||||||
|
</div>
|
||||||
|
<div style="grid-column:span 12">
|
||||||
|
<button type="submit">Import</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin:0 0 8px">Unsorted</h2>
|
||||||
|
<div class="muted">Assign each email to a project. Later this will be automated with rules + OAuth sync.</div>
|
||||||
|
<div style="overflow:auto;margin-top:10px">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>From</th>
|
||||||
|
<th>Subject</th>
|
||||||
|
<th>Assign</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% emails.forEach(e => { %>
|
||||||
|
<tr>
|
||||||
|
<td><%= e.date || '' %></td>
|
||||||
|
<td><%= e.from_addr || '' %></td>
|
||||||
|
<td><a href="/inbox/<%= e.id %>"><%= e.subject || '(no subject)' %></a></td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/inbox/<%= e.id %>/assign" class="actions" style="margin-bottom:8px">
|
||||||
|
<select name="projectId" required>
|
||||||
|
<option value="" disabled selected>Select project…</option>
|
||||||
|
<% projects.forEach(p => { %>
|
||||||
|
<option value="<%= p.id %>"><%= p.name %> (<%= p.job_number || '—' %>)</option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
<button type="submit">Assign</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="muted" style="font-size:12px;margin-bottom:6px">Make a rule from this email:</div>
|
||||||
|
<form method="post" action="/inbox/<%= e.id %>/rule/from_domain" class="actions" style="margin-bottom:8px">
|
||||||
|
<select name="projectId" required>
|
||||||
|
<option value="" disabled selected>Project…</option>
|
||||||
|
<% projects.forEach(p => { %>
|
||||||
|
<option value="<%= p.id %>"><%= p.name %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
<button type="submit">Rule: From domain</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="/inbox/<%= e.id %>/rule/subject_job" class="actions">
|
||||||
|
<select name="projectId" required>
|
||||||
|
<option value="" disabled selected>Project…</option>
|
||||||
|
<% projects.forEach(p => { %>
|
||||||
|
<option value="<%= p.id %>"><%= p.name %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
<button type="submit">Rule: Subject job#</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
88
web/src/views/inbox_email.ejs
Normal file
88
web/src/views/inbox_email.ejs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Mastermind — Email</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
|
||||||
|
header{background:#111824;border-bottom:1px solid #233043;padding:14px 16px;position:sticky;top:0}
|
||||||
|
main{max-width:1100px;margin:0 auto;padding:16px}
|
||||||
|
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:14px;margin:12px 0}
|
||||||
|
a{color:#7cc4ff;text-decoration:none}
|
||||||
|
.muted{color:#a9b7c6}
|
||||||
|
pre{white-space:pre-wrap;background:#0e1520;border:1px solid #233043;border-radius:10px;padding:12px;color:#d6e3f3}
|
||||||
|
table{width:100%;border-collapse:collapse}
|
||||||
|
th,td{border-bottom:1px solid #233043;padding:10px 6px;text-align:left;color:#a9b7c6;font-size:13px;vertical-align:top}
|
||||||
|
th{color:#e8eef7}
|
||||||
|
button{padding:10px 12px;border-radius:10px;border:1px solid #233043;background:#1c2a3d;color:#e8eef7}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
|
||||||
|
<div style="font-weight:700">Email</div>
|
||||||
|
<div style="display:flex;gap:10px">
|
||||||
|
<a href="/inbox">Inbox</a>
|
||||||
|
<a href="/projects">Projects</a>
|
||||||
|
<a href="/">Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<div class="card">
|
||||||
|
<div class="muted"><b>Date:</b> <%= email.date || '' %></div>
|
||||||
|
<div class="muted"><b>From:</b> <%= email.from_addr || '' %></div>
|
||||||
|
<div class="muted"><b>To:</b> <%= email.to_addr || '' %></div>
|
||||||
|
<div class="muted"><b>Subject:</b> <%= email.subject || '' %></div>
|
||||||
|
<div class="muted"><b>Status:</b> <%= email.status %></div>
|
||||||
|
<div class="muted"><b>Raw file:</b> <%= email.raw_path || '' %></div>
|
||||||
|
|
||||||
|
<div style="height:10px"></div>
|
||||||
|
<div style="display:flex;gap:10px;flex-wrap:wrap">
|
||||||
|
<form method="post" action="/drafts/pco/from-email">
|
||||||
|
<input type="hidden" name="emailId" value="<%= email.id %>" />
|
||||||
|
<button type="submit">Create PCO draft from this email</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/drafts/rfi/from-email">
|
||||||
|
<input type="hidden" name="emailId" value="<%= email.id %>" />
|
||||||
|
<button type="submit">Create RFI draft from this email</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin:0 0 8px">Attachments</h2>
|
||||||
|
<% if (!attachments.length) { %>
|
||||||
|
<div class="muted">No attachments stored for this email.</div>
|
||||||
|
<% } else { %>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Filename</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% attachments.forEach(a => { %>
|
||||||
|
<tr>
|
||||||
|
<td><%= a.filename || '' %></td>
|
||||||
|
<td><%= a.content_type || '' %></td>
|
||||||
|
<td><%= a.size_bytes || '' %></td>
|
||||||
|
<td><a href="/attachments/<%= a.id %>">Download</a></td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin:0 0 8px">Body (text)</h2>
|
||||||
|
<pre><%= email.body_text || '' %></pre>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
49
web/src/views/login.ejs
Normal file
49
web/src/views/login.ejs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Mastermind — Login</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
|
||||||
|
.wrap{max-width:420px;margin:0 auto;padding:28px}
|
||||||
|
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:16px}
|
||||||
|
label{display:block;margin:10px 0 6px;color:#a9b7c6}
|
||||||
|
input{width:100%;padding:12px;border-radius:10px;border:1px solid #233043;background:#0e1520;color:#e8eef7}
|
||||||
|
button{width:100%;padding:12px;border-radius:10px;border:1px solid #233043;background:#1c2a3d;color:#e8eef7;margin-top:12px}
|
||||||
|
.row{display:flex;gap:10px;margin-top:12px}
|
||||||
|
.row a{flex:1;text-align:center;text-decoration:none;padding:12px;border-radius:10px;border:1px solid #233043;color:#7cc4ff;background:#0e1520}
|
||||||
|
.muted{color:#a9b7c6;font-size:13px;line-height:1.4}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1 style="margin:0 0 8px">Mastermind</h1>
|
||||||
|
<div class="muted">Local login + Google/Microsoft OAuth. Mobile friendly.</div>
|
||||||
|
<div style="height:12px"></div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<form method="post" action="/login">
|
||||||
|
<label>Email</label>
|
||||||
|
<input name="email" placeholder="you@company.com" />
|
||||||
|
<label>Password</label>
|
||||||
|
<input name="password" type="password" placeholder="••••••••" />
|
||||||
|
<button type="submit">Sign in (local)</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<% if (googleEnabled) { %>
|
||||||
|
<a href="/auth/google">Google</a>
|
||||||
|
<% } %>
|
||||||
|
<% if (microsoftEnabled) { %>
|
||||||
|
<a href="/auth/microsoft">Microsoft</a>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="muted" style="margin-top:12px">
|
||||||
|
First run creates <b>owner@local / owner</b> (change ASAP).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
80
web/src/views/project_detail.ejs
Normal file
80
web/src/views/project_detail.ejs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Mastermind — Project</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
|
||||||
|
header{background:#111824;border-bottom:1px solid #233043;padding:14px 16px;position:sticky;top:0}
|
||||||
|
main{max-width:1100px;margin:0 auto;padding:16px}
|
||||||
|
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:14px;margin:12px 0}
|
||||||
|
a{color:#7cc4ff;text-decoration:none}
|
||||||
|
.muted{color:#a9b7c6}
|
||||||
|
.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:12px}
|
||||||
|
.col6{grid-column:span 12}
|
||||||
|
@media(min-width:900px){.col6{grid-column:span 6}}
|
||||||
|
input,select{padding:12px;border-radius:10px;border:1px solid #233043;background:#0e1520;color:#e8eef7;width:100%}
|
||||||
|
button{padding:12px 14px;border-radius:10px;border:1px solid #233043;background:#1c2a3d;color:#e8eef7}
|
||||||
|
.pill{display:inline-block;padding:2px 8px;border:1px solid #233043;border-radius:999px;font-size:12px;color:#a9b7c6}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:700"><%= project.name %></div>
|
||||||
|
<div class="muted" style="font-size:13px">Job: <%= project.job_number || '—' %> · Mode: <span class="pill"><%= project.role_mode %></span></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:10px">
|
||||||
|
<a href="/projects/<%= project.id %>/inbox">Inbox</a>
|
||||||
|
<a href="/projects">Projects</a>
|
||||||
|
<a href="/">Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin:0 0 8px">Project profile</h2>
|
||||||
|
<form method="post" action="/projects/<%= project.id %>/update" class="grid">
|
||||||
|
<div class="col6">
|
||||||
|
<label class="muted">Name</label>
|
||||||
|
<input name="name" value="<%= project.name %>" required />
|
||||||
|
</div>
|
||||||
|
<div class="col6">
|
||||||
|
<label class="muted">Job #</label>
|
||||||
|
<input name="jobNumber" value="<%= project.job_number || '' %>" />
|
||||||
|
</div>
|
||||||
|
<div class="col6">
|
||||||
|
<label class="muted">Mode</label>
|
||||||
|
<select name="roleMode">
|
||||||
|
<option value="ec" <%= project.role_mode==='ec'?'selected':'' %>>Electrical contractor</option>
|
||||||
|
<option value="gc" <%= project.role_mode==='gc'?'selected':'' %>>General contractor</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col6">
|
||||||
|
<label class="muted">GC name</label>
|
||||||
|
<input name="gcName" value="<%= project.gc_name || '' %>" />
|
||||||
|
</div>
|
||||||
|
<div style="grid-column:span 12">
|
||||||
|
<label class="muted">Keywords (used for email sorting)</label>
|
||||||
|
<input name="keywords" value="<%= project.keywords || '' %>" />
|
||||||
|
</div>
|
||||||
|
<div style="grid-column:span 12">
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin:0 0 8px">Next modules</h2>
|
||||||
|
<ul class="muted">
|
||||||
|
<li>Inbox for this project (auto-sorted from the single mailbox)</li>
|
||||||
|
<li>Logs: RFI / Submittals / PCO / Procurement</li>
|
||||||
|
<li>Draft tools: create RFI or PCO from an email</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
63
web/src/views/project_inbox.ejs
Normal file
63
web/src/views/project_inbox.ejs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Mastermind — Project Inbox</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
|
||||||
|
header{background:#111824;border-bottom:1px solid #233043;padding:14px 16px;position:sticky;top:0}
|
||||||
|
main{max-width:1200px;margin:0 auto;padding:16px}
|
||||||
|
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:14px;margin:12px 0}
|
||||||
|
a{color:#7cc4ff;text-decoration:none}
|
||||||
|
.muted{color:#a9b7c6}
|
||||||
|
table{width:100%;border-collapse:collapse}
|
||||||
|
th,td{border-bottom:1px solid #233043;padding:10px 6px;text-align:left;color:#a9b7c6;font-size:13px;vertical-align:top}
|
||||||
|
th{color:#e8eef7}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:700"><%= project.name %> — Inbox</div>
|
||||||
|
<div class="muted" style="font-size:13px">Job: <%= project.job_number || '—' %></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:10px">
|
||||||
|
<a href="/projects/<%= project.id %>">Project</a>
|
||||||
|
<a href="/projects">Projects</a>
|
||||||
|
<a href="/inbox">Global Inbox</a>
|
||||||
|
<a href="/">Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin:0 0 8px">Assigned emails</h2>
|
||||||
|
<div style="overflow:auto">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>From</th>
|
||||||
|
<th>Subject</th>
|
||||||
|
<th>Source</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% emails.forEach(e => { %>
|
||||||
|
<tr>
|
||||||
|
<td><%= e.date || '' %></td>
|
||||||
|
<td><%= e.from_addr || '' %></td>
|
||||||
|
<td><a href="/inbox/<%= e.id %>"><%= e.subject || '(no subject)' %></a></td>
|
||||||
|
<td><%= e.source %></td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
113
web/src/views/projects.ejs
Normal file
113
web/src/views/projects.ejs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Mastermind — Projects</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
|
||||||
|
header{background:#111824;border-bottom:1px solid #233043;padding:14px 16px;position:sticky;top:0}
|
||||||
|
main{max-width:1100px;margin:0 auto;padding:16px}
|
||||||
|
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:14px;margin:12px 0}
|
||||||
|
a{color:#7cc4ff;text-decoration:none}
|
||||||
|
.muted{color:#a9b7c6}
|
||||||
|
.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:12px}
|
||||||
|
.col6{grid-column:span 12}
|
||||||
|
@media(min-width:900px){.col6{grid-column:span 6}}
|
||||||
|
input,select{padding:12px;border-radius:10px;border:1px solid #233043;background:#0e1520;color:#e8eef7;width:100%}
|
||||||
|
button{padding:12px 14px;border-radius:10px;border:1px solid #233043;background:#1c2a3d;color:#e8eef7}
|
||||||
|
table{width:100%;border-collapse:collapse}
|
||||||
|
th,td{border-bottom:1px solid #233043;padding:10px 6px;text-align:left;color:#a9b7c6;font-size:13px;vertical-align:top}
|
||||||
|
th{color:#e8eef7}
|
||||||
|
.pill{display:inline-block;padding:2px 8px;border:1px solid #233043;border-radius:999px;font-size:12px;color:#a9b7c6}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
|
||||||
|
<div style="font-weight:700">Projects</div>
|
||||||
|
<div style="display:flex;gap:10px">
|
||||||
|
<a href="/">Dashboard</a>
|
||||||
|
<a href="/logout">Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin:0 0 8px">New project (2-minute setup)</h2>
|
||||||
|
<div class="muted">Minimum fields: name + job #. Add keywords if sorting needs help.</div>
|
||||||
|
<form method="post" action="/projects/create" class="grid" style="margin-top:10px">
|
||||||
|
<div class="col6">
|
||||||
|
<label class="muted">Project name</label>
|
||||||
|
<input name="name" placeholder="1100 Columbia FA" required />
|
||||||
|
</div>
|
||||||
|
<div class="col6">
|
||||||
|
<label class="muted">Job #</label>
|
||||||
|
<input name="jobNumber" placeholder="0222600001" />
|
||||||
|
</div>
|
||||||
|
<div class="col6">
|
||||||
|
<label class="muted">Mode</label>
|
||||||
|
<select name="roleMode">
|
||||||
|
<option value="ec">Electrical contractor</option>
|
||||||
|
<option value="gc">General contractor</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col6">
|
||||||
|
<label class="muted">GC name</label>
|
||||||
|
<input name="gcName" placeholder="GC / Construction Manager" />
|
||||||
|
</div>
|
||||||
|
<div class="col6">
|
||||||
|
<label class="muted">City</label>
|
||||||
|
<input name="city" placeholder="Chapel Hill" />
|
||||||
|
</div>
|
||||||
|
<div class="col6">
|
||||||
|
<label class="muted">State</label>
|
||||||
|
<input name="state" placeholder="NC" />
|
||||||
|
</div>
|
||||||
|
<div style="grid-column:span 12">
|
||||||
|
<label class="muted">Keywords (optional)</label>
|
||||||
|
<input name="keywords" placeholder="owner name, building nickname, address fragments, common subject tags" />
|
||||||
|
</div>
|
||||||
|
<div style="grid-column:span 12">
|
||||||
|
<button type="submit">Create project</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin:0 0 8px">Your projects</h2>
|
||||||
|
<div style="overflow:auto">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Job #</th>
|
||||||
|
<th>Mode</th>
|
||||||
|
<th>GC</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% projects.forEach(p => { %>
|
||||||
|
<tr>
|
||||||
|
<td><a href="/projects/<%= p.id %>"><%= p.name %></a></td>
|
||||||
|
<td><%= p.job_number || '' %></td>
|
||||||
|
<td><span class="pill"><%= p.role_mode %></span></td>
|
||||||
|
<td><%= p.gc_name || '' %></td>
|
||||||
|
<td><%= p.active ? 'active' : 'archived' %></td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/projects/<%= p.id %>/toggle" style="display:inline">
|
||||||
|
<button type="submit"><%= p.active ? 'Archive' : 'Unarchive' %></button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
37
web/src/views/setup.ejs
Normal file
37
web/src/views/setup.ejs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Mastermind — Setup</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
|
||||||
|
header{background:#111824;border-bottom:1px solid #233043;padding:14px 16px}
|
||||||
|
main{max-width:700px;margin:0 auto;padding:16px}
|
||||||
|
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:14px;margin:12px 0}
|
||||||
|
label{display:block;margin:10px 0 6px;color:#a9b7c6}
|
||||||
|
input{width:100%;padding:12px;border-radius:10px;border:1px solid #233043;background:#0e1520;color:#e8eef7}
|
||||||
|
button{padding:12px 14px;border-radius:10px;border:1px solid #233043;background:#1c2a3d;color:#e8eef7;margin-top:12px}
|
||||||
|
a{color:#7cc4ff;text-decoration:none}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
|
||||||
|
<div style="font-weight:700">Setup</div>
|
||||||
|
<a href="/">Back</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin:0 0 8px">Public Base URL</h2>
|
||||||
|
<div style="color:#a9b7c6;font-size:13px;line-height:1.4">This should be the URL you use to reach the app (e.g., Tailscale IP). OAuth callbacks rely on this.</div>
|
||||||
|
<form method="post" action="/setup/base-url">
|
||||||
|
<label>Base URL</label>
|
||||||
|
<input name="baseUrl" value="<%= baseUrl %>" />
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
6
worker/Dockerfile
Normal file
6
worker/Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
CMD ["npm","run","dev"]
|
||||||
173
worker/package-lock.json
generated
Normal file
173
worker/package-lock.json
generated
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
{
|
||||||
|
"name": "mastermind-worker",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "mastermind-worker",
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"pg": "^8.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dotenv": {
|
||||||
|
"version": "16.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
|
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg": {
|
||||||
|
"version": "8.18.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz",
|
||||||
|
"integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"pg-connection-string": "^2.11.0",
|
||||||
|
"pg-pool": "^3.11.0",
|
||||||
|
"pg-protocol": "^1.11.0",
|
||||||
|
"pg-types": "2.2.0",
|
||||||
|
"pgpass": "1.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"pg-cloudflare": "^1.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg-native": ">=3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pg-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-cloudflare": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/pg-connection-string": {
|
||||||
|
"version": "2.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz",
|
||||||
|
"integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-int8": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-pool": {
|
||||||
|
"version": "3.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz",
|
||||||
|
"integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-protocol": {
|
||||||
|
"version": "1.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz",
|
||||||
|
"integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-types": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-int8": "1.0.1",
|
||||||
|
"postgres-array": "~2.0.0",
|
||||||
|
"postgres-bytea": "~1.0.0",
|
||||||
|
"postgres-date": "~1.0.4",
|
||||||
|
"postgres-interval": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pgpass": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-array": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-bytea": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-date": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-interval": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xtend": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
worker/package.json
Normal file
13
worker/package.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "mastermind-worker",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node --watch src/worker.js",
|
||||||
|
"start": "node src/worker.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"pg": "^8.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
20
worker/src/worker.js
Normal file
20
worker/src/worker.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import pg from 'pg';
|
||||||
|
|
||||||
|
const { Pool } = pg;
|
||||||
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
|
|
||||||
|
// Placeholder worker loop
|
||||||
|
async function main() {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('Mastermind worker started');
|
||||||
|
while (true) {
|
||||||
|
await new Promise((r) => setTimeout(r, 10_000));
|
||||||
|
// future: ingest inbox, OCR docs, classify
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user