diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..fc7ea71
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,60 @@
+name: Build & Publish Unsigned macOS Release
+
+on:
+ push:
+ tags:
+ - 'v*'
+ workflow_dispatch:
+
+jobs:
+ build-macos:
+ runs-on: macos-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+
+ - name: Install frontend deps
+ run: |
+ cd frontend
+ npm ci
+
+ - name: Install backend deps
+ run: |
+ cd backend
+ npm ci
+
+ - name: Build & package Electron app
+ run: |
+ cd frontend
+ npm run electron:build
+
+ - name: Create GitHub Release
+ id: create_release
+ uses: actions/create-release@v1
+ with:
+ tag_name: ${{ github.ref_name || github.sha }}
+ release_name: Release ${{ github.ref_name || github.sha }}
+ draft: false
+ prerelease: false
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Upload packaged artifacts to release
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ UPLOAD_URL: ${{ steps.create_release.outputs.upload_url }}
+ run: |
+ set -euo pipefail
+ # Upload any dmg/zip files produced by electron-builder
+ for f in frontend/dist/*.{dmg,zip}; do
+ [ -f "$f" ] || continue
+ name=$(basename "$f")
+ echo "Uploading $f as $name"
+ curl -sSL -X POST -H "Authorization: token ${GITHUB_TOKEN}" -H "Content-Type: application/octet-stream" --data-binary @"$f" "${UPLOAD_URL}?name=${name}"
+ done
diff --git a/.gitignore b/.gitignore
index 9246ea6..d603ba4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,11 @@ node_modules/
# Database files (live data lives in Docker volume)
backend/db.json
backend/data/
+
+# TLS certificates — never commit private keys
+nginx/certs/*.pem
+
+# Environment secrets
+.env
+.env.local
+.env.production.local
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1a7903e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,158 @@
+Project Hub — Desktop app + backend
+
+This repository contains a React+Vite frontend packaged as an Electron desktop app, and a small Node/Express backend using lowdb for JSON persistence. Development builds and packaged macOS installer artifacts are stored in `releases/`.
+
+Quick links
+
+- Releases: `releases/` (DMG, ZIP, packaged .app)
+- Frontend: `frontend/`
+- Backend: `backend/`
+
+Development
+
+1) Backend (fast, using Docker):
+
+```bash
+# from project root
+cd backend
+# install deps (if needed)
+npm install
+# build image and run (example)
+docker build -t project-manager-backend:latest .
+docker run -d --name project-manager-api -p 4000:4000 \
+ -v project_manager_data:/app/data \
+ -e NODE_ENV=production -e PORT=4000 -e HOST=0.0.0.0 \
+ -e CORS_ORIGIN="http://localhost:5173" -e WRITE_API_KEY="dev-key" \
+ --restart unless-stopped project-manager-backend:latest
+```
+
+Or use `docker compose up -d` if you prefer Compose.
+
+Endpoints
+
+- Health: `GET /health`
+- API root: `GET /api/projects`, `GET /api/members`, etc.
+
+Config / env vars
+
+- `PORT` (default 4000)
+- `HOST` (default 0.0.0.0)
+- `DB_FILE` (optional path to db file)
+- `CORS_ORIGIN` (comma-separated allowed origins)
+- `WRITE_API_KEY` (if set, write operations require `x-api-key` header)
+
+2) Frontend (dev)
+
+```bash
+cd frontend
+npm install
+npm run electron:dev # starts Vite and launches Electron
+```
+
+Runtime API URL
+
+The app now supports runtime API endpoint configuration:
+
+- In the desktop UI, click **Server** in the top-right and set the API base URL (example: `http://localhost:4000/api`).
+- The value is persisted in Electron app storage and used on next requests without rebuilding.
+- If no runtime value is set, the app falls back to `VITE_API_URL`, then `http://localhost:4000/api`.
+
+Write authentication (`WRITE_API_KEY`)
+
+- If backend sets `WRITE_API_KEY`, all POST/PUT/DELETE endpoints require `x-api-key`.
+- Frontend and Electron now propagate this key automatically when provided via:
+ - `WRITE_API_KEY` (preferred for Electron runtime)
+ - `VITE_WRITE_API_KEY` (frontend build-time fallback)
+- GET endpoints remain readable without API key unless you add additional auth middleware.
+
+Build & releases
+
+- Build renderer and package Electron (mac example):
+
+```bash
+cd frontend
+npm run electron:build
+```
+
+- Built artifacts are placed into `frontend/dist/` and this project’s `releases/` directory after packaging.
+
+- Verified release build (runs persistence check before packaging):
+
+```bash
+npm run build:verified
+```
+
+- Verified release build with backend restart check:
+
+```bash
+npm run build:verified:restart
+```
+
+- One-command verified release pipeline (persistence test + frontend build + dist sync + Electron packaging):
+
+```bash
+npm run release:verified
+```
+
+- Same as above, but also verifies persistence across backend restart:
+
+```bash
+npm run release:verified:restart
+```
+
+Unsigned macOS Gatekeeper note
+
+These builds are currently unsigned. macOS Gatekeeper may prevent opening the app. Users can bypass with one of the following (instruct users to accept the risk):
+
+```bash
+# Open by right-clicking the app and choosing "Open", or run:
+xattr -r -d com.apple.quarantine "/Applications/Project Hub.app"
+# or for a packaged app in releases:
+xattr -r -d com.apple.quarantine "releases/Project Hub.app"
+```
+
+CI / Distribution suggestions
+
+- Add a GitHub Actions workflow to build and upload unsigned artifacts to GitHub Releases (I can add this).
+- To avoid Gatekeeper prompts for wide distribution, sign and notarize the app (requires Apple Developer account and credentials).
+
+Security & production notes
+
+- `lowdb` (JSON file) is fine short-term for small teams. For production/many users, migrate to a proper DB (Postgres, managed DB).
+- Use `WRITE_API_KEY` or user accounts to protect write operations; always run behind TLS (reverse proxy like nginx/Traefik) for public hosting.
+
+Need help?
+
+I can add a GitHub Actions workflow to build/upload unsigned releases, or add signing/notarization steps if you acquire an Apple Developer account. Tell me which and I’ll scaffold it.
+
+Persistence self-test
+
+Run this before release to verify that created records are persisted and can be read back:
+
+```bash
+npm run test:persistence
+```
+
+If your backend is running under Docker Compose and you also want to verify persistence across a backend restart:
+
+```bash
+npm run test:persistence:restart
+```
+
+Optional env vars:
+
+- `API_BASE` (default `http://localhost:4000/api`)
+- `WRITE_API_KEY` (needed if backend write endpoints are protected)
+
+Wiring smoke test
+
+Run a focused integration check for task/member/invite route wiring:
+
+```bash
+npm run test:wiring
+```
+
+Optional env vars for this test:
+
+- `API_BASE` (default `http://localhost:4000/api`)
+- `WRITE_API_KEY` (required if write endpoints are protected)
\ No newline at end of file
diff --git a/backend/Dockerfile b/backend/Dockerfile
index b26e6e1..71a3c56 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -1,12 +1,20 @@
FROM node:20-alpine
+ENV NODE_ENV=production
+
WORKDIR /app
COPY package*.json ./
-RUN npm install --omit=dev
+RUN apk add --no-cache curl && npm install --omit=dev
COPY . .
+RUN chown -R node:node /app
+
+USER node
EXPOSE 4000
+HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
+ CMD curl -f http://localhost:4000/health || exit 1
+
CMD ["node", "index.js"]
diff --git a/backend/index.js b/backend/index.js
index a8e95fb..4b31f14 100644
--- a/backend/index.js
+++ b/backend/index.js
@@ -1,45 +1,254 @@
import express from 'express';
import cors from 'cors';
+import helmet from 'helmet';
+import morgan from 'morgan';
+import rateLimit from 'express-rate-limit';
import { Low } from 'lowdb';
import { JSONFile } from 'lowdb/node';
-import { join, dirname } from 'path';
+import { join, dirname, isAbsolute, extname } from 'path';
import { fileURLToPath } from 'url';
+import { mkdirSync, unlinkSync, existsSync, readFileSync } from 'fs';
+import { randomBytes, randomUUID } from 'crypto';
+import multer from 'multer';
+import nodemailer from 'nodemailer';
const __dirname = dirname(fileURLToPath(import.meta.url));
const dataDir = join(__dirname, 'data');
-import { mkdirSync } from 'fs';
-mkdirSync(dataDir, { recursive: true });
-const dbFile = join(dataDir, 'db.json');
+
+const dbFileEnv = process.env.DB_FILE;
+const dbFile = dbFileEnv ? (isAbsolute(dbFileEnv) ? dbFileEnv : join(dataDir, dbFileEnv)) : join(dataDir, 'db.json');
+const legacyDbFile = join(__dirname, 'db.json');
+mkdirSync(dirname(dbFile), { recursive: true });
const adapter = new JSONFile(dbFile);
-const defaultData = { members: [], projects: [], tasks: [] };
+const defaultData = { members: [], projects: [], tasks: [], invites: [] };
const db = new Low(adapter, defaultData);
-await db.read();
+const safeReadJson = (filePath) => {
+ try {
+ if (!existsSync(filePath)) return null;
+ const raw = readFileSync(filePath, 'utf8').trim();
+ if (!raw) return null;
+ return JSON.parse(raw);
+ } catch {
+ return null;
+ }
+};
+
+const toInitials = (name = '') =>
+ String(name)
+ .split(' ')
+ .filter(Boolean)
+ .map(w => w[0])
+ .join('')
+ .toUpperCase()
+ .slice(0, 2);
+
+const ensureProjectShape = (project) => {
+ if (!Array.isArray(project.members)) project.members = [];
+ if (!Array.isArray(project.tasks)) project.tasks = [];
+ if (!Array.isArray(project.milestones)) project.milestones = [];
+};
+
+try {
+ await db.read();
+} catch (err) {
+ console.warn('Primary db file was unreadable, reinitializing with defaults.', err?.message || err);
+ db.data = { ...defaultData };
+ await db.write();
+}
+if (!db.data) db.data = defaultData;
+if (!db.data.invites) db.data.invites = [];
+if (!db.data.members) db.data.members = [];
+if (!db.data.projects) db.data.projects = [];
+if (!db.data.tasks) db.data.tasks = [];
+
+// One-time bootstrap from legacy backend/db.json when the runtime db is empty.
+const runtimeHasData = (db.data.members.length + db.data.projects.length + db.data.invites.length) > 0;
+if (!runtimeHasData && dbFile !== legacyDbFile) {
+ const legacy = safeReadJson(legacyDbFile);
+ if (legacy && typeof legacy === 'object') {
+ db.data.members = Array.isArray(legacy.members) ? legacy.members : [];
+ db.data.projects = Array.isArray(legacy.projects) ? legacy.projects : [];
+ db.data.invites = Array.isArray(legacy.invites) ? legacy.invites : [];
+ db.data.tasks = Array.isArray(legacy.tasks) ? legacy.tasks : [];
+ }
+}
+
+// Normalize projects and migrate any legacy flat tasks into nested project.tasks.
+for (const project of db.data.projects) ensureProjectShape(project);
+for (const task of db.data.tasks) {
+ const project = db.data.projects.find(p => p.id === task.projectId);
+ if (!project) continue;
+ ensureProjectShape(project);
+ const existing = project.tasks.find(t => t.id === task.id);
+ if (existing) continue;
+ project.tasks.push({
+ id: task.id || randomUUID(),
+ title: task.title || 'Untitled Task',
+ description: task.description || '',
+ status: task.status || 'todo',
+ priority: task.priority || 'medium',
+ dueDate: task.dueDate || '',
+ startDate: task.startDate || '',
+ assignedTo: task.assignedTo || task.memberId || '',
+ estimatedHours: task.estimatedHours,
+ actualHours: task.actualHours,
+ recurrence: task.recurrence || 'none',
+ subtasks: Array.isArray(task.subtasks) ? task.subtasks : [],
+ attachments: Array.isArray(task.attachments) ? task.attachments : [],
+ });
+}
+db.data.tasks = [];
+
+for (const project of db.data.projects) {
+ ensureProjectShape(project);
+ project.members = project.members
+ .map(member => {
+ if (member && typeof member === 'object') {
+ return {
+ ...member,
+ initials: member.initials || toInitials(member.name),
+ };
+ }
+ const roster = db.data.members.find(m => m.id === member);
+ if (!roster) return null;
+ return { ...roster, initials: roster.initials || toInitials(roster.name) };
+ })
+ .filter(Boolean);
+}
await db.write();
+// ── File uploads (multer) ───────────────────────────────────────────────────
+const UPLOAD_DIR = join(__dirname, 'uploads');
+mkdirSync(UPLOAD_DIR, { recursive: true });
+
+const ALLOWED_MIME = new Set([
+ 'image/jpeg','image/png','image/gif','image/webp','image/svg+xml',
+ 'application/pdf','text/plain','text/csv','text/markdown',
+ 'application/json',
+ 'application/zip','application/x-zip-compressed',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // xlsx
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // docx
+]);
+
+const storage = multer.diskStorage({
+ destination: (req, file, cb) => cb(null, UPLOAD_DIR),
+ filename: (req, file, cb) => cb(null, randomUUID() + extname(file.originalname).toLowerCase()),
+});
+const upload = multer({
+ storage,
+ limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
+ fileFilter: (req, file, cb) => {
+ if (ALLOWED_MIME.has(file.mimetype)) cb(null, true);
+ else cb(new Error('File type not allowed'));
+ },
+});
+
+// ── Notification helper ────────────────────────────────────────────────────
+async function notify({ webhookUrl, notifyEmail, subject, text, payload }) {
+ if (webhookUrl) {
+ try {
+ await fetch(webhookUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload || { subject, text }),
+ signal: AbortSignal.timeout(5000),
+ });
+ } catch (err) {
+ console.warn('Webhook notification failed', err?.message || err);
+ }
+ }
+ if (notifyEmail && process.env.SMTP_HOST) {
+ try {
+ const transporter = nodemailer.createTransport({
+ host: process.env.SMTP_HOST,
+ port: Number(process.env.SMTP_PORT) || 587,
+ secure: process.env.SMTP_SECURE === 'true',
+ auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } : undefined,
+ });
+ await transporter.sendMail({
+ from: process.env.SMTP_FROM || process.env.SMTP_USER || 'noreply@projecthub.local',
+ to: notifyEmail,
+ subject,
+ text,
+ });
+ } catch (err) {
+ console.warn('Email notification failed', err?.message || err);
+ }
+ }
+}
+
const app = express();
-app.use(cors());
+app.use(helmet());
+app.use(morgan(process.env.MORGAN_FORMAT || 'combined'));
+
+const configuredOrigins = (process.env.CORS_ORIGIN || '')
+ .split(',')
+ .map(o => o.trim())
+ .filter(Boolean);
+const allowedOrigins = configuredOrigins.length
+ ? configuredOrigins
+ : ['http://localhost:5173', 'http://localhost:4000', 'null'];
+
+const corsOptions = {
+ credentials: true,
+ origin: (origin, callback) => {
+ if (!origin) return callback(null, true);
+ if (origin === 'null' || origin.startsWith('file://')) return callback(null, true);
+ if (allowedOrigins.includes(origin)) return callback(null, true);
+ return callback(new Error('Not allowed by CORS'));
+ },
+};
+app.use(cors(corsOptions));
app.use(express.json());
+const limiter = rateLimit({
+ windowMs: Number(process.env.RATE_WINDOW_MS) || 15 * 60 * 1000,
+ max: Number(process.env.RATE_MAX) || 200,
+});
+
+const requireApiKey = (req, res, next) => {
+ const key = process.env.WRITE_API_KEY;
+ if (!key) return next();
+ const provided = req.get('x-api-key') || req.query.api_key;
+ if (provided === key) return next();
+ return res.status(401).json({ error: 'Unauthorized' });
+};
+
+// Health
+app.get('/health', async (req, res) => {
+ try {
+ await db.read();
+ return res.json({ status: 'ok', timestamp: Date.now() });
+ } catch (err) {
+ return res.status(500).json({ status: 'error' });
+ }
+});
+
// Member CRUD
app.get('/api/members', (req, res) => {
res.json(db.data.members);
});
-app.post('/api/members', (req, res) => {
+app.post('/api/members', limiter, requireApiKey, async (req, res) => {
const member = { id: Date.now().toString(), ...req.body };
+ member.initials = member.initials || toInitials(member.name);
db.data.members.push(member);
- db.write();
+ await db.write();
res.status(201).json(member);
});
-app.delete('/api/members/:id', (req, res) => {
+app.delete('/api/members/:id', limiter, requireApiKey, async (req, res) => {
db.data.members = db.data.members.filter(m => m.id !== req.params.id);
- db.write();
+ for (const project of db.data.projects) {
+ ensureProjectShape(project);
+ project.members = project.members.filter(m => m.id !== req.params.id);
+ }
+ await db.write();
res.status(204).end();
});
-app.put('/api/members/:id', async (req, res) => {
+app.put('/api/members/:id', limiter, requireApiKey, async (req, res) => {
const idx = db.data.members.findIndex(m => m.id === req.params.id);
if (idx === -1) return res.status(404).json({ error: 'Member not found' });
db.data.members[idx] = { ...db.data.members[idx], ...req.body, id: req.params.id };
@@ -52,55 +261,263 @@ app.get('/api/projects', (req, res) => {
res.json(db.data.projects);
});
-app.post('/api/projects', (req, res) => {
- const project = { id: Date.now().toString(), members: [], ...req.body };
+app.post('/api/projects', limiter, requireApiKey, async (req, res) => {
+ const project = { id: Date.now().toString(), members: [], tasks: [], milestones: [], ...req.body };
+ ensureProjectShape(project);
db.data.projects.push(project);
- db.write();
+ await db.write();
res.status(201).json(project);
});
-app.put('/api/projects/:id', async (req, res) => {
+app.put('/api/projects/:id', limiter, requireApiKey, async (req, res) => {
const idx = db.data.projects.findIndex(p => p.id === req.params.id);
const updated = { ...req.body, id: req.params.id };
+ ensureProjectShape(updated);
if (idx === -1) db.data.projects.push(updated);
else db.data.projects[idx] = updated;
await db.write();
res.json(updated);
});
-app.delete('/api/projects/:id', (req, res) => {
+app.delete('/api/projects/:id', limiter, requireApiKey, async (req, res) => {
db.data.projects = db.data.projects.filter(p => p.id !== req.params.id);
- db.write();
+ await db.write();
res.status(204).end();
});
// Assign member to project
-app.post('/api/projects/:projectId/members/:memberId', (req, res) => {
+app.post('/api/projects/:projectId/members/:memberId', limiter, requireApiKey, async (req, res) => {
const project = db.data.projects.find(p => p.id === req.params.projectId);
if (!project) return res.status(404).json({ error: 'Project not found' });
- if (!project.members.includes(req.params.memberId)) {
- project.members.push(req.params.memberId);
- db.write();
+ ensureProjectShape(project);
+ const member = db.data.members.find(m => m.id === req.params.memberId);
+ if (!member) return res.status(404).json({ error: 'Member not found' });
+
+ const alreadyAssigned = project.members.some(m => m.id === member.id);
+ if (!alreadyAssigned) {
+ project.members.push({ ...member, initials: member.initials || toInitials(member.name) });
+ await db.write();
}
res.json(project);
});
// Tasks (assign to member in project)
-app.post('/api/projects/:projectId/tasks', (req, res) => {
- const { title, description, memberId } = req.body;
+app.post('/api/projects/:projectId/tasks', limiter, requireApiKey, async (req, res) => {
+ const { title, description, memberId, assignedTo } = req.body;
+ const assigneeId = assignedTo || memberId || '';
const project = db.data.projects.find(p => p.id === req.params.projectId);
if (!project) return res.status(404).json({ error: 'Project not found' });
- if (!project.members.includes(memberId)) return res.status(400).json({ error: 'Member not assigned to project' });
- const task = { id: Date.now().toString(), projectId: project.id, memberId, title, description, status: 'todo' };
- db.data.tasks.push(task);
- db.write();
+ ensureProjectShape(project);
+ if (assigneeId && !project.members.some(m => m.id === assigneeId)) {
+ return res.status(400).json({ error: 'Member not assigned to project' });
+ }
+
+ const task = {
+ id: randomUUID(),
+ title: String(title || '').trim() || 'Untitled Task',
+ description: description || '',
+ status: req.body.status || 'todo',
+ priority: req.body.priority || 'medium',
+ dueDate: req.body.dueDate || '',
+ startDate: req.body.startDate || '',
+ assignedTo: assigneeId,
+ estimatedHours: req.body.estimatedHours,
+ actualHours: req.body.actualHours,
+ recurrence: req.body.recurrence || 'none',
+ subtasks: Array.isArray(req.body.subtasks) ? req.body.subtasks : [],
+ attachments: Array.isArray(req.body.attachments) ? req.body.attachments : [],
+ };
+
+ project.tasks.push(task);
+ await db.write();
res.status(201).json(task);
});
app.get('/api/projects/:projectId/tasks', (req, res) => {
- const tasks = db.data.tasks.filter(t => t.projectId === req.params.projectId);
- res.json(tasks);
+ const project = db.data.projects.find(p => p.id === req.params.projectId);
+ if (!project) return res.status(404).json({ error: 'Project not found' });
+ ensureProjectShape(project);
+ res.json(project.tasks);
+});
+
+app.put('/api/projects/:projectId/tasks/:taskId', limiter, requireApiKey, async (req, res) => {
+ const project = db.data.projects.find(p => p.id === req.params.projectId);
+ if (!project) return res.status(404).json({ error: 'Project not found' });
+ ensureProjectShape(project);
+ const idx = project.tasks.findIndex(t => t.id === req.params.taskId);
+ if (idx === -1) return res.status(404).json({ error: 'Task not found' });
+
+ const assigneeId = req.body.assignedTo || req.body.memberId || project.tasks[idx].assignedTo || '';
+ if (assigneeId && !project.members.some(m => m.id === assigneeId)) {
+ return res.status(400).json({ error: 'Member not assigned to project' });
+ }
+
+ project.tasks[idx] = {
+ ...project.tasks[idx],
+ ...req.body,
+ id: req.params.taskId,
+ assignedTo: assigneeId,
+ };
+ await db.write();
+ res.json(project.tasks[idx]);
+});
+
+app.delete('/api/projects/:projectId/tasks/:taskId', limiter, requireApiKey, async (req, res) => {
+ const project = db.data.projects.find(p => p.id === req.params.projectId);
+ if (!project) return res.status(404).json({ error: 'Project not found' });
+ ensureProjectShape(project);
+ const idx = project.tasks.findIndex(t => t.id === req.params.taskId);
+ if (idx === -1) return res.status(404).json({ error: 'Task not found' });
+ project.tasks.splice(idx, 1);
+ await db.write();
+ res.status(204).end();
+});
+
+// ── Static uploads ──────────────────────────────────────────────────────────
+app.use('/uploads', express.static(UPLOAD_DIR));
+
+// ── File attachments ────────────────────────────────────────────────────────
+// Attachments are stored inline in project.tasks[].attachments[]
+
+app.post('/api/projects/:projectId/tasks/:taskId/attachments', limiter, requireApiKey, (req, res, next) => {
+ upload.single('file')(req, res, async err => {
+ if (err) return res.status(400).json({ error: err.message });
+ if (!req.file) return res.status(400).json({ error: 'No file provided' });
+
+ const project = db.data.projects.find(p => p.id === req.params.projectId);
+ if (!project) return res.status(404).json({ error: 'Project not found' });
+ ensureProjectShape(project);
+ const task = project.tasks.find(t => t.id === req.params.taskId);
+ if (!task) return res.status(404).json({ error: 'Task not found' });
+
+ if (!task.attachments) task.attachments = [];
+ const attachment = {
+ id: randomUUID(),
+ filename: req.file.filename,
+ originalName: req.file.originalname,
+ mimetype: req.file.mimetype,
+ size: req.file.size,
+ url: `/uploads/${req.file.filename}`,
+ uploadedAt: new Date().toISOString(),
+ };
+ task.attachments.push(attachment);
+ await db.write();
+ res.status(201).json(attachment);
+ });
+});
+
+app.delete('/api/projects/:projectId/tasks/:taskId/attachments/:attachmentId', limiter, requireApiKey, async (req, res) => {
+ const project = db.data.projects.find(p => p.id === req.params.projectId);
+ if (!project) return res.status(404).json({ error: 'Project not found' });
+ ensureProjectShape(project);
+ const task = project.tasks.find(t => t.id === req.params.taskId);
+ if (!task) return res.status(404).json({ error: 'Task not found' });
+
+ const idx = task.attachments?.findIndex(a => a.id === req.params.attachmentId);
+ if (idx === undefined || idx === -1) return res.status(404).json({ error: 'Attachment not found' });
+
+ const [removed] = task.attachments.splice(idx, 1);
+ // Delete file from disk
+ const filePath = join(UPLOAD_DIR, removed.filename);
+ if (existsSync(filePath)) { try { unlinkSync(filePath); } catch {} }
+
+ await db.write();
+ res.status(204).end();
+});
+
+// ── Webhook/notification settings on project ────────────────────────────────
+// Projects can have optional webhookUrl and notifyEmail; these are just
+// stored as part of the project doc. The POST /api/projects/:id/notify endpoint
+// fires a test notification.
+app.post('/api/projects/:id/notify', limiter, requireApiKey, async (req, res) => {
+ const project = db.data.projects.find(p => p.id === req.params.id);
+ if (!project) return res.status(404).json({ error: 'Project not found' });
+ const { subject = 'Project Hub notification', text = `Update for project: ${project.name}` } = req.body;
+ notify({ webhookUrl: project.webhookUrl, notifyEmail: project.notifyEmail, subject, text, payload: { projectId: project.id, projectName: project.name, subject, text } });
+ res.json({ sent: true });
+});
+
+// ── Invite system ────────────────────────────────────────────────────────────
+// Generate an invite token for a project
+app.post('/api/projects/:id/invites', limiter, requireApiKey, async (req, res) => {
+ const project = db.data.projects.find(p => p.id === req.params.id);
+ if (!project) return res.status(404).json({ error: 'Project not found' });
+
+ const token = randomBytes(32).toString('hex');
+ const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
+ const invite = {
+ id: randomUUID(),
+ token,
+ projectId: project.id,
+ projectName: project.name,
+ email: req.body.email || null,
+ createdAt: new Date().toISOString(),
+ expiresAt: expiresAt.toISOString(),
+ acceptedAt: null,
+ };
+ db.data.invites.push(invite);
+ await db.write();
+
+ // Fire email if SMTP configured and email provided
+ if (req.body.email) {
+ const appUrl = process.env.APP_URL || 'http://localhost:5173';
+ notify({
+ notifyEmail: req.body.email,
+ subject: `You've been invited to "${project.name}"`,
+ text: `You've been invited to join the project "${project.name}".\n\nAccept your invitation:\n${appUrl}/invite/${token}\n\nThis link expires in 7 days.`,
+ });
+ }
+
+ res.status(201).json({ token, expiresAt: invite.expiresAt, inviteId: invite.id });
+});
+
+// Validate an invite token (public)
+app.get('/api/invites/:token', (req, res) => {
+ const invite = db.data.invites.find(i => i.token === req.params.token);
+ if (!invite) return res.status(404).json({ error: 'Invite not found or expired' });
+ if (new Date(invite.expiresAt) < new Date()) return res.status(410).json({ error: 'Invite has expired' });
+ if (invite.acceptedAt) return res.status(409).json({ error: 'Invite already accepted' });
+ res.json({ projectId: invite.projectId, projectName: invite.projectName, email: invite.email, expiresAt: invite.expiresAt });
+});
+
+// Accept an invite (attaches provided member name to project if given)
+app.post('/api/invites/:token/accept', limiter, async (req, res) => {
+ const invite = db.data.invites.find(i => i.token === req.params.token);
+ if (!invite) return res.status(404).json({ error: 'Invite not found' });
+ if (new Date(invite.expiresAt) < new Date()) return res.status(410).json({ error: 'Invite has expired' });
+ if (invite.acceptedAt) return res.status(409).json({ error: 'Invite already accepted' });
+
+ invite.acceptedAt = new Date().toISOString();
+
+ // Optionally add the member to the project
+ if (req.body.name) {
+ const project = db.data.projects.find(p => p.id === invite.projectId);
+ if (project) {
+ ensureProjectShape(project);
+ const initials = req.body.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
+ const email = (req.body.email || invite.email || '').trim();
+ let rosterMember = db.data.members.find(m => email && m.email === email);
+ if (!rosterMember) {
+ rosterMember = {
+ id: randomUUID(),
+ name: req.body.name,
+ role: req.body.role || 'Team Member',
+ initials,
+ email,
+ };
+ db.data.members.push(rosterMember);
+ }
+
+ if (!project.members.some(m => m.id === rosterMember.id)) {
+ project.members.push({ ...rosterMember, initials: rosterMember.initials || toInitials(rosterMember.name) });
+ }
+ }
+ }
+
+ await db.write();
+ res.json({ projectId: invite.projectId, projectName: invite.projectName });
});
const PORT = process.env.PORT || 4000;
-app.listen(PORT, () => console.log(`Backend running on port ${PORT}`));
+const HOST = process.env.HOST || '0.0.0.0';
+app.listen(PORT, HOST, () => console.log(`Backend running on ${HOST}:${PORT}`));
diff --git a/backend/package-lock.json b/backend/package-lock.json
index b53c0bc..3ebe13f 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -10,7 +10,12 @@
"dependencies": {
"cors": "^2.8.5",
"express": "^4.18.2",
- "lowdb": "^6.0.1"
+ "express-rate-limit": "^6.8.0",
+ "helmet": "^6.0.1",
+ "lowdb": "^6.0.1",
+ "morgan": "^1.10.0",
+ "multer": "^2.1.1",
+ "nodemailer": "^8.0.2"
},
"devDependencies": {
"nodemon": "^3.0.1"
@@ -43,6 +48,12 @@
"node": ">= 8"
}
},
+ "node_modules/append-field": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
+ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
+ "license": "MIT"
+ },
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -59,6 +70,24 @@
"node": "18 || 20 || >=22"
}
},
+ "node_modules/basic-auth": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
+ "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.1.2"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/basic-auth/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -122,6 +151,23 @@
"node": ">=8"
}
},
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "license": "MIT"
+ },
+ "node_modules/busboy": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
+ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+ "dependencies": {
+ "streamsearch": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=10.16.0"
+ }
+ },
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -185,6 +231,21 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/concat-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
+ "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
+ "engines": [
+ "node >= 6.0"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.0.2",
+ "typedarray": "^0.0.6"
+ }
+ },
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -386,6 +447,18 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/express-rate-limit": {
+ "version": "6.11.2",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.11.2.tgz",
+ "integrity": "sha512-a7uwwfNTh1U60ssiIkuLFWHt4hAC5yxlLGU2VP0X4YNlyEDZAqF4tK3GD3NSitVBrCQmQ0++0uOyFOgC2y4DDw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ },
+ "peerDependencies": {
+ "express": "^4 || ^5"
+ }
+ },
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -555,6 +628,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/helmet": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/helmet/-/helmet-6.2.0.tgz",
+ "integrity": "sha512-DWlwuXLLqbrIOltR6tFQXShj/+7Cyp0gLi6uAb8qMdFh/YBBFbKSgQ6nbXmScYd8emMctuthmgIa7tUfo9Rtyg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -755,12 +837,59 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/morgan": {
+ "version": "1.10.1",
+ "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
+ "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==",
+ "license": "MIT",
+ "dependencies": {
+ "basic-auth": "~2.0.1",
+ "debug": "2.6.9",
+ "depd": "~2.0.0",
+ "on-finished": "~2.3.0",
+ "on-headers": "~1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/morgan/node_modules/on-finished": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
+ "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
+ "node_modules/multer": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
+ "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
+ "license": "MIT",
+ "dependencies": {
+ "append-field": "^1.0.0",
+ "busboy": "^1.6.0",
+ "concat-stream": "^2.0.0",
+ "type-is": "^1.6.18"
+ },
+ "engines": {
+ "node": ">= 10.16.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -770,6 +899,15 @@
"node": ">= 0.6"
}
},
+ "node_modules/nodemailer": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.2.tgz",
+ "integrity": "sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/nodemon": {
"version": "3.1.14",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
@@ -867,6 +1005,15 @@
"node": ">= 0.8"
}
},
+ "node_modules/on-headers": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
+ "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -954,6 +1101,20 @@
"node": ">= 0.8"
}
},
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -1163,6 +1324,23 @@
"url": "https://github.com/sponsors/typicode"
}
},
+ "node_modules/streamsearch": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
+ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
"node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@@ -1221,6 +1399,12 @@
"node": ">= 0.6"
}
},
+ "node_modules/typedarray": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
+ "license": "MIT"
+ },
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@@ -1237,6 +1421,12 @@
"node": ">= 0.8"
}
},
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
diff --git a/backend/package.json b/backend/package.json
index ffb5418..8bc79e0 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -5,12 +5,18 @@
"type": "module",
"scripts": {
"start": "node index.js",
- "dev": "nodemon index.js"
+ "dev": "nodemon index.js",
+ "build": "node --check index.js"
},
"dependencies": {
+ "cors": "^2.8.5",
"express": "^4.18.2",
+ "express-rate-limit": "^6.8.0",
+ "helmet": "^6.0.1",
"lowdb": "^6.0.1",
- "cors": "^2.8.5"
+ "morgan": "^1.10.0",
+ "multer": "^2.1.1",
+ "nodemailer": "^8.0.2"
},
"devDependencies": {
"nodemon": "^3.0.1"
diff --git a/docker-compose.yml b/docker-compose.yml
index 2f518c1..95d6aac 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -6,6 +6,8 @@ services:
- "${PORT_BACKEND:-4000}:${PORT_BACKEND:-4000}"
environment:
- PORT=${PORT_BACKEND:-4000}
+ - CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:5173,http://localhost:4000,null}
+ - WRITE_API_KEY=${WRITE_API_KEY:-}
volumes:
- db_data:/app/data
restart: unless-stopped
diff --git a/frontend/electron/main.cjs b/frontend/electron/main.cjs
index 2ea1648..b02ac1f 100644
--- a/frontend/electron/main.cjs
+++ b/frontend/electron/main.cjs
@@ -4,8 +4,18 @@ const fs = require('fs')
const isDev = process.env.NODE_ENV !== 'production'
const devUrl = 'http://localhost:5173'
+const API_URL_KEY = 'runtime_api_url'
+const DEFAULT_API_URL = process.env.VITE_API_URL || 'http://localhost:4000/api'
let mainWindow
+function normalizeApiUrl(value) {
+ if (!value || typeof value !== 'string') return null
+ const trimmed = value.trim().replace(/\/+$/, '')
+ if (!trimmed) return null
+ if (!/^https?:\/\//i.test(trimmed)) return null
+ return /\/api$/i.test(trimmed) ? trimmed : `${trimmed}/api`
+}
+
function getStoragePath() {
return path.join(app.getPath('userData'), 'storage.json')
}
@@ -31,9 +41,11 @@ function writeStorage(obj) {
}
}
-ipcMain.handle('storage-get', () => {
+ipcMain.handle('storage-get', (event, key) => {
const s = readStorage()
- return s
+ if (!key) return s
+ if (!(key in s)) return null
+ return { value: s[key] }
})
ipcMain.handle('storage-set', (event, key, value) => {
@@ -50,7 +62,20 @@ ipcMain.handle('storage-remove', (event, key) => {
return true
})
-ipcMain.handle('get-api-url', () => process.env.VITE_API_URL || 'http://localhost:4000/api')
+ipcMain.handle('get-api-url', () => {
+ const s = readStorage()
+ return normalizeApiUrl(s[API_URL_KEY]) || normalizeApiUrl(DEFAULT_API_URL) || 'http://localhost:4000/api'
+})
+
+ipcMain.handle('set-api-url', (event, value) => {
+ const next = normalizeApiUrl(value)
+ if (!next) throw new Error('API URL must be a valid http(s) URL')
+
+ const s = readStorage()
+ s[API_URL_KEY] = next
+ writeStorage(s)
+ return next
+})
function createWindow() {
mainWindow = new BrowserWindow({
diff --git a/frontend/electron/preload.cjs b/frontend/electron/preload.cjs
index 457a5a4..1b130bf 100644
--- a/frontend/electron/preload.cjs
+++ b/frontend/electron/preload.cjs
@@ -1,7 +1,8 @@
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('ENV', {
- API_URL: process.env.VITE_API_URL || 'http://localhost:4000/api'
+ API_URL: process.env.VITE_API_URL || 'http://localhost:4000/api',
+ API_KEY: process.env.WRITE_API_KEY || process.env.VITE_WRITE_API_KEY || ''
})
contextBridge.exposeInMainWorld('app', {
@@ -10,5 +11,13 @@ contextBridge.exposeInMainWorld('app', {
set: (key, value) => ipcRenderer.invoke('storage-set', key, value),
remove: (key) => ipcRenderer.invoke('storage-remove', key)
},
- getAPIUrl: () => ipcRenderer.invoke('get-api-url')
+ getAPIUrl: () => ipcRenderer.invoke('get-api-url'),
+ setAPIUrl: (url) => ipcRenderer.invoke('set-api-url', url)
+})
+
+// Backward-compatible alias used by the React app persistence wrapper.
+contextBridge.exposeInMainWorld('storage', {
+ get: (key) => ipcRenderer.invoke('storage-get', key),
+ set: (key, value) => ipcRenderer.invoke('storage-set', key, value),
+ remove: (key) => ipcRenderer.invoke('storage-remove', key)
})
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 01190db..ad8e6f4 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,5 +1,8 @@
import React, { useState, useEffect } from 'react'
import { api } from './api.js'
+import GanttView from './components/GanttView.jsx'
+import CalendarView from './components/CalendarView.jsx'
+import BurndownChart from './components/BurndownChart.jsx'
// The full App adapted from your original inline HTML (keeps UI and behavior).
@@ -116,9 +119,17 @@ function App(){
const [delId, setDelId] = useState(null)
const [view, setView] = useState('projects')
const [allMembers, setAllMembers] = useState([])
+ const [serverOpen, setServerOpen] = useState(false)
+ const [apiUrlInput, setApiUrlInput] = useState('')
+ const [apiUrlMsg, setApiUrlMsg] = useState('')
useEffect(()=>{
(async()=>{
+ try {
+ const currentApiUrl = await api.getApiUrl()
+ setApiUrlInput(currentApiUrl)
+ } catch {}
+
try { const m = await api.getMembers(); setAllMembers(m) } catch {}
try {
const bp = await api.getProjects()
@@ -158,6 +169,42 @@ function App(){
setDelId(null)
api.deleteProject(id).catch(() => {})
}
+
+ const createTaskForProject = async (projectId, taskDraft) => {
+ try {
+ const created = await api.createTask(projectId, taskDraft)
+ setProjects(ps => ps.map(p => p.id === projectId ? { ...p, tasks: [...p.tasks, created] } : p))
+ if (sel?.id === projectId) setSel(s => s ? { ...s, tasks: [...s.tasks, created] } : s)
+ } catch {
+ const local = { ...taskDraft, id: uid(), subtasks: taskDraft.subtasks || [], attachments: taskDraft.attachments || [] }
+ setProjects(ps => ps.map(p => p.id === projectId ? { ...p, tasks: [...p.tasks, local] } : p))
+ if (sel?.id === projectId) setSel(s => s ? { ...s, tasks: [...s.tasks, local] } : s)
+ }
+ }
+
+ const updateTaskForProject = async (projectId, taskId, patch) => {
+ setProjects(ps => ps.map(p => p.id === projectId ? { ...p, tasks: p.tasks.map(t => t.id === taskId ? { ...t, ...patch } : t) } : p))
+ if (sel?.id === projectId) {
+ setSel(s => s ? { ...s, tasks: s.tasks.map(t => t.id === taskId ? { ...t, ...patch } : t) } : s)
+ }
+
+ try {
+ const updated = await api.updateTask(projectId, taskId, patch)
+ setProjects(ps => ps.map(p => p.id === projectId ? { ...p, tasks: p.tasks.map(t => t.id === taskId ? updated : t) } : p))
+ if (sel?.id === projectId) {
+ setSel(s => s ? { ...s, tasks: s.tasks.map(t => t.id === taskId ? updated : t) } : s)
+ }
+ } catch {}
+ }
+
+ const deleteTaskForProject = async (projectId, taskId) => {
+ setProjects(ps => ps.map(p => p.id === projectId ? { ...p, tasks: p.tasks.filter(t => t.id !== taskId) } : p))
+ if (sel?.id === projectId) {
+ setSel(s => s ? { ...s, tasks: s.tasks.filter(t => t.id !== taskId) } : s)
+ }
+ try { await api.deleteTask(projectId, taskId) } catch {}
+ }
+
const change = u => {
setProjects(ps => ps.map(p => p.id === u.id ? u : p))
setSel(u)
@@ -181,15 +228,22 @@ function App(){
+
- {view === 'projects' && }
+
+
+ {view === 'projects' &&
}
+
+ {view === 'mytasks' && { const p = projects.find(x => x.id === id); if(p) { setSel(p); setTab('tasks'); setView('projects') }}} />}
{view === 'members' && {
const temp = { ...m, id: uid() }
@@ -246,7 +300,7 @@ function App(){
>}
- {sel && setSel(null)} onEdit={() => setEditing(sel)} onChange={change} allMembers={allMembers} />}
+ {sel && setSel(null)} onEdit={() => setEditing(sel)} onChange={change} onTaskCreate={createTaskForProject} onTaskUpdate={updateTaskForProject} onTaskDelete={deleteTaskForProject} allMembers={allMembers} />}
{editing !== null && setEditing(null)} />}
{delId && (
@@ -260,6 +314,38 @@ function App(){
)}
+
+ {serverOpen && (
+
+
+
API Server
+
Set the backend base URL used by this app. Example: http://localhost:4000/api
+
setApiUrlInput(e.target.value)}
+ placeholder="http://localhost:4000/api"
+ style={{ ...inp(), marginBottom: 10 }}
+ />
+ {apiUrlMsg &&
{apiUrlMsg}
}
+
+
+
+
+
+
+ )}
)
}
@@ -308,11 +394,33 @@ function Card({ project: p, onOpen, onEdit, onDel }){
}
// ── Detail Panel ──
-function Panel({ project: p, tab, setTab, onClose, onEdit, onChange, allMembers = [] }){
+function Panel({ project: p, tab, setTab, onClose, onEdit, onChange, onTaskCreate, onTaskUpdate, onTaskDelete, allMembers = [] }){
const pr = prog(p.tasks), s = STATUS[p.status] || STATUS.planning
- const upTask = (id, u) => onChange({ ...p, tasks: p.tasks.map(t => t.id === id ? { ...t, ...u } : t) })
- const addTask = t => onChange({ ...p, tasks: [...p.tasks, { ...t, id: uid(), subtasks: [] }] })
- const delTask = id => onChange({ ...p, tasks: p.tasks.filter(t => t.id !== id) })
+ const upTask = (id, u) => {
+ const task = p.tasks.find(t => t.id === id)
+ if (!task) return
+
+ const merged = { ...task, ...u }
+ onTaskUpdate?.(p.id, id, u)
+
+ // Recurring: when marked done, auto-clone with next due date
+ if (u.status === 'done' && merged.recurrence && merged.recurrence !== 'none' && merged.dueDate) {
+ const base = new Date(merged.dueDate)
+ if (merged.recurrence === 'daily') base.setDate(base.getDate() + 1)
+ if (merged.recurrence === 'weekly') base.setDate(base.getDate() + 7)
+ if (merged.recurrence === 'monthly') base.setMonth(base.getMonth() + 1)
+ const next = {
+ ...merged,
+ status: 'todo',
+ dueDate: base.toISOString().slice(0,10),
+ subtasks: (merged.subtasks || []).map(s => ({ ...s, done: false })),
+ }
+ delete next.id
+ onTaskCreate?.(p.id, next)
+ }
+ }
+ const addTask = t => onTaskCreate?.(p.id, { ...t, subtasks: t.subtasks || [] })
+ const delTask = id => onTaskDelete?.(p.id, id)
const togMs = id => onChange({ ...p, milestones: p.milestones.map(m => m.id === id ? { ...m, completed: !m.completed } : m) })
const addMs = m => onChange({ ...p, milestones: [...p.milestones, { ...m, id: uid(), completed: false }] })
const delMs = id => onChange({ ...p, milestones: p.milestones.filter(m => m.id !== id) })
@@ -337,16 +445,22 @@ function Panel({ project: p, tab, setTab, onClose, onEdit, onChange, allMembers
Progress{pr}%
-
- {['tasks','milestones','team'].map(t => (
-
+
+ {['tasks','milestones','team','gantt','calendar','burndown'].map(t => (
+
))}
{tab === 'tasks' && }
{tab === 'milestones' && }
- {tab === 'team' && }
+ {tab === 'team' && }
+ {tab === 'gantt' && }
+ {tab === 'calendar' && upTask(t.id, {})} />}
+ {tab === 'burndown' && }
@@ -357,10 +471,11 @@ function Panel({ project: p, tab, setTab, onClose, onEdit, onChange, allMembers
// For brevity these components are preserved from the original inline app; include full implementations below.
function TasksTab({ project: p, onUpdate, onAdd, onDel }){
+ const EMPTY = { title: '', status: 'todo', priority: 'medium', dueDate: '', startDate: '', assignedTo: '', estimatedHours: '', recurrence: 'none' }
const [adding, setAdding] = useState(false)
- const [nt, setNt] = useState({ title: '', status: 'todo', priority: 'medium', dueDate: '', assignedTo: '' })
+ const [nt, setNt] = useState(EMPTY)
const [exp, setExp] = useState(null)
- const submit = () => { if(!nt.title.trim()) return; onAdd(nt); setNt({ title:'', status:'todo', priority:'medium', dueDate:'', assignedTo:'' }); setAdding(false) }
+ const submit = () => { if(!nt.title.trim()) return; onAdd({ ...nt, estimatedHours: nt.estimatedHours ? Number(nt.estimatedHours) : undefined }); setNt(EMPTY); setAdding(false) }
const groups = { todo: p.tasks.filter(t => t.status === 'todo'), 'in-progress': p.tasks.filter(t => t.status === 'in-progress'), done: p.tasks.filter(t => t.status === 'done') }
return (
@@ -375,7 +490,14 @@ function TasksTab({ project: p, onUpdate, onAdd, onDel }){
- setNt({ ...nt, dueDate: e.target.value })} style={{ ...inp(), flex: 1, padding: '6px 8px' }} />
+
+
+
+
+
Recurrence
{p.members.length > 0 && (
)}
- 0 ? 0 : 9 }}>
+
@@ -393,7 +515,7 @@ function TasksTab({ project: p, onUpdate, onAdd, onDel }){
groups[st].length > 0 && (
{st === 'in-progress' ? 'In Progress' : st === 'todo' ? 'To Do' : 'Done'} · {groups[st].length}
- {groups[st].map(t =>
setExp(exp === t.id ? null : t.id)} />)}
+ {groups[st].map(t => setExp(exp === t.id ? null : t.id)} />)}
)
))}
@@ -402,12 +524,27 @@ function TasksTab({ project: p, onUpdate, onAdd, onDel }){
)
}
-function TaskRow({ task: t, color, members = [], onUpdate, onDel, expanded, onToggle }){
+function TaskRow({ task: t, color, members = [], projectId, onUpdate, onDel, expanded, onToggle }){
const pr = PRI[t.priority] || PRI.medium, ov = overdue(t.dueDate)
const cycle = e => { e.stopPropagation(); const c = { todo: 'in-progress', 'in-progress': 'done', done: 'todo' }; onUpdate(t.id, { status: c[t.status] }) }
- const togSub = sid => onUpdate(t.id, { subtasks: t.subtasks.map(s => s.id === sid ? { ...s, done: !s.done } : s) })
- const addSub = title => onUpdate(t.id, { subtasks: [...t.subtasks, { id: uid(), title, done: false }] })
- const delSub = sid => onUpdate(t.id, { subtasks: t.subtasks.filter(s => s.id !== sid) })
+ const togSub = sid => onUpdate(t.id, { subtasks: (t.subtasks||[]).map(s => s.id === sid ? { ...s, done: !s.done } : s) })
+ const addSub = title => onUpdate(t.id, { subtasks: [...(t.subtasks||[]), { id: uid(), title, done: false }] })
+ const delSub = sid => onUpdate(t.id, { subtasks: (t.subtasks||[]).filter(s => s.id !== sid) })
+
+ const handleUpload = async e => {
+ const file = e.target.files?.[0]; if (!file || !projectId) return
+ try {
+ const att = await api.uploadAttachment(projectId, t.id, file)
+ onUpdate(t.id, { attachments: [...(t.attachments||[]), att] })
+ } catch { /* show nothing on failure */ }
+ e.target.value = ''
+ }
+ const delAttachment = async id => {
+ if (!projectId) return
+ await api.deleteAttachment(projectId, t.id, id).catch(()=>{})
+ onUpdate(t.id, { attachments: (t.attachments||[]).filter(a => a.id !== id) })
+ }
+
return (
@@ -419,12 +556,35 @@ function TaskRow({ task: t, color, members = [], onUpdate, onDel, expanded, onTo
{pr.label}
{t.dueDate &&
{fmt(t.dueDate)}}
- {t.subtasks.length > 0 &&
{t.subtasks.filter(s => s.done).length}/{t.subtasks.length}}
+ {t.estimatedHours > 0 &&
⏱{t.estimatedHours}h}
+ {t.recurrence && t.recurrence !== 'none' &&
🔁}
+ {(t.attachments?.length > 0) &&
📎{t.attachments.length}}
+ {t.subtasks?.length > 0 &&
{t.subtasks.filter(s => s.done).length}/{t.subtasks.length}}
{t.assignedTo && (() => { const m = members.find(x => x.id === t.assignedTo); return m ?
{m.initials}
: null })()}
- {expanded &&
}
+ {expanded && (
+ <>
+
+ {/* Attachments section */}
+
+
Attachments
+ {(t.attachments||[]).map(a => (
+
+
📎
+
{a.originalName}
+
{(a.size/1024).toFixed(0)}KB
+
+
+ ))}
+
+
+ >
+ )}
)
}
@@ -433,7 +593,7 @@ function SubList({ task, onToggle, onAdd, onDel }){
const [val, setVal] = useState('')
return (
- {task.subtasks.map(s => (
+ {(task.subtasks||[]).map(s => (
{s.title}
@@ -492,6 +652,19 @@ function TeamTab({ project: p, onChange, allMembers = [] }){
const [mode, setMode] = useState('roster')
const [nm, setNm] = useState({ name: '', role: '' })
const [rSearch, setRSearch] = useState('')
+ const [inviting, setInviting] = useState(false)
+ const [inviteEmail, setInviteEmail] = useState('')
+ const [inviteResult, setInviteResult] = useState(null)
+ const [inviteErr, setInviteErr] = useState('')
+
+ const doInvite = async () => {
+ setInviteErr(''); setInviteResult(null)
+ try {
+ const res = await api.createInvite(p.id, inviteEmail)
+ setInviteResult(res)
+ setInviteEmail('')
+ } catch (e) { setInviteErr('Failed to create invite. Is the backend running?') }
+ }
const assigned = new Set(p.members.map(m => m.id))
const available = allMembers.filter(m => !assigned.has(m.id) &&
@@ -508,7 +681,28 @@ function TeamTab({ project: p, onChange, allMembers = [] }){
return (
-
+
+
+
+
+
+ {/* Invite panel */}
+ {inviting && (
+
+
Send invite link
+
+ setInviteEmail(e.target.value)} placeholder="colleague@example.com" style={{ ...inp(), flex: 1, padding: '6px 8px' }} onKeyDown={e => e.key === 'Enter' && doInvite()} />
+
+
+ {inviteErr &&
{inviteErr}
}
+ {inviteResult && (
+
+ ✓ Invite created!{inviteResult.token && Token: {inviteResult.token.slice(0,16)}…}
+ Expires: {new Date(inviteResult.expiresAt).toLocaleDateString()}
+
+ )}
+
+ )}
{adding && (
@@ -658,4 +852,102 @@ function MembersPage({ members, onAdd, onUpdate, onDelete }){
)
}
+// ── My Tasks (cross-project view) ──
+const PRI_COLOR_MT = { high: '#ef4444', medium: '#f59e0b', low: '#10b981' }
+
+function MyTasksView({ projects, allMembers, onSelectProject }) {
+ const [memberId, setMemberId] = useState(allMembers[0]?.id || '')
+ const [filter, setFilter] = useState('all')
+
+ const member = allMembers.find(m => m.id === memberId)
+
+ // Collect tasks across all projects for this member
+ const taskItems = []
+ projects.forEach(proj => {
+ proj.tasks.forEach(t => {
+ if (memberId && t.assignedTo !== memberId) return
+ taskItems.push({ task: t, project: proj })
+ })
+ })
+
+ const filtered = filter === 'all' ? taskItems : taskItems.filter(i => i.task.status === filter)
+ const todayNow = new Date()
+
+ const groups = {
+ overdue: filtered.filter(i => i.task.dueDate && new Date(i.task.dueDate) < todayNow && i.task.status !== 'done'),
+ inProgress: filtered.filter(i => i.task.status === 'in-progress'),
+ todo: filtered.filter(i => i.task.status === 'todo' && !(i.task.dueDate && new Date(i.task.dueDate) < todayNow)),
+ done: filtered.filter(i => i.task.status === 'done'),
+ }
+
+ const renderItem = ({ task: t, project: proj }) => (
+
onSelectProject(proj.id)}
+ style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 13px', background: '#0d0d1a', border: '1px solid #1e2030', borderRadius: 8, marginBottom: 5, cursor: 'pointer' }}
+ onMouseEnter={e => e.currentTarget.style.borderColor = '#2d3148'}
+ onMouseLeave={e => e.currentTarget.style.borderColor = '#1e2030'}
+ >
+
+
+
{t.title}
+
+ {proj.name}
+ {t.priority}
+ {t.dueDate && {fmt(t.dueDate)}}
+ {t.recurrence && t.recurrence !== 'none' && 🔁 {t.recurrence}}
+
+
+ {t.estimatedHours > 0 &&
{t.estimatedHours}h}
+
+ )
+
+ const Section = ({ label, color, items }) => items.length === 0 ? null : (
+
+
{label} · {items.length}
+ {items.map(renderItem)}
+
+ )
+
+ return (
+
+
+ ✅ My Tasks
+ {taskItems.length} total across {projects.length} project{projects.length !== 1 ? 's' : ''}
+
+
+ {/* Member picker */}
+ {allMembers.length > 0 && (
+
+ Showing tasks for:
+
+
+ )}
+
+ {/* Status filter */}
+
+ {['all','todo','in-progress','done'].map(s => (
+
+ ))}
+
+
+ {filtered.length === 0 && (
+
+ {memberId ? 'No tasks assigned to this member.' : 'No tasks found.'}
+
+ )}
+
+
+
+
+ {(filter === 'all' || filter === 'done') &&
}
+
+ )
+}
+
export default App
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 7fb17e5..89f6fbc 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -1,17 +1,156 @@
-const BASE = (typeof window !== 'undefined' && window.ENV && window.ENV.API_URL) || import.meta.env.VITE_API_URL || 'http://localhost:4000/api'
-const h = { 'Content-Type': 'application/json' }
-const json = r => { if (!r.ok) throw new Error(r.status); return r.json() }
+const API_URL_STORAGE_KEY = 'project_hub_api_url'
+const DEFAULT_API_URL = 'http://localhost:4000/api'
+
+let cachedBaseUrl = null
+
+const normalizeApiUrl = (value) => {
+ if (!value || typeof value !== 'string') return null
+ const trimmed = value.trim().replace(/\/+$/, '')
+ if (!trimmed) return null
+ if (!/^https?:\/\//i.test(trimmed)) return null
+ return /\/api$/i.test(trimmed) ? trimmed : `${trimmed}/api`
+}
+
+const getEnvApiUrl = () => {
+ if (typeof window !== 'undefined' && window.ENV?.API_URL) return window.ENV.API_URL
+ return import.meta.env.VITE_API_URL || DEFAULT_API_URL
+}
+
+const getStoredApiUrl = () => {
+ if (typeof window === 'undefined') return null
+ try {
+ return localStorage.getItem(API_URL_STORAGE_KEY)
+ } catch {
+ return null
+ }
+}
+
+const getWriteApiKey = () => {
+ if (typeof window !== 'undefined' && window.ENV?.API_KEY) return window.ENV.API_KEY
+ return import.meta.env.VITE_WRITE_API_KEY || ''
+}
+
+const parseResponse = async (response) => {
+ if (!response.ok) {
+ const body = await response.text().catch(() => '')
+ throw new Error(body || String(response.status))
+ }
+
+ if (response.status === 204) return null
+ const contentType = response.headers.get('content-type') || ''
+ if (!contentType.includes('application/json')) return null
+ return response.json()
+}
+
+const getSyncBaseUrl = () => {
+ if (cachedBaseUrl) return cachedBaseUrl
+ const candidate = normalizeApiUrl(getStoredApiUrl()) || normalizeApiUrl(getEnvApiUrl()) || DEFAULT_API_URL
+ cachedBaseUrl = candidate
+ return cachedBaseUrl
+}
+
+const getBaseUrl = async () => {
+ if (cachedBaseUrl) return cachedBaseUrl
+
+ let runtimeUrl = null
+ if (typeof window !== 'undefined' && window.app?.getAPIUrl) {
+ runtimeUrl = await window.app.getAPIUrl().catch(() => null)
+ }
+
+ cachedBaseUrl =
+ normalizeApiUrl(runtimeUrl) ||
+ normalizeApiUrl(getStoredApiUrl()) ||
+ normalizeApiUrl(getEnvApiUrl()) ||
+ DEFAULT_API_URL
+
+ return cachedBaseUrl
+}
+
+const request = async (path, { method = 'GET', body, write = false, formData = false } = {}) => {
+ const base = await getBaseUrl()
+ const headers = {}
+
+ if (!formData) headers['Content-Type'] = 'application/json'
+ if (write) {
+ const key = getWriteApiKey()
+ if (key) headers['x-api-key'] = key
+ }
+
+ const options = { method, headers }
+ if (body !== undefined) options.body = formData ? body : JSON.stringify(body)
+
+ const response = await fetch(`${base}${path}`, options)
+ return parseResponse(response)
+}
+
+const toAbsoluteUrl = (maybeRelativeUrl) => {
+ if (!maybeRelativeUrl) return ''
+ if (/^https?:\/\//i.test(maybeRelativeUrl)) return maybeRelativeUrl
+ const root = getSyncBaseUrl().replace(/\/api$/i, '')
+ const suffix = maybeRelativeUrl.startsWith('/') ? maybeRelativeUrl : `/${maybeRelativeUrl}`
+ return `${root}${suffix}`
+}
export const api = {
+ getApiUrl: () => getBaseUrl(),
+ setApiUrl: async (url) => {
+ const normalized = normalizeApiUrl(url)
+ if (!normalized) throw new Error('API URL must be http(s) and non-empty')
+
+ if (typeof window !== 'undefined') {
+ try { localStorage.setItem(API_URL_STORAGE_KEY, normalized) } catch {}
+ if (window.app?.setAPIUrl) await window.app.setAPIUrl(normalized)
+ }
+
+ cachedBaseUrl = normalized
+ return normalized
+ },
+ toAbsoluteUrl,
+
// Members
- getMembers: () => fetch(`${BASE}/members`).then(json),
- addMember: d => fetch(`${BASE}/members`, { method: 'POST', headers: h, body: JSON.stringify(d) }).then(json),
- updateMember: (id, d) => fetch(`${BASE}/members/${id}`, { method: 'PUT', headers: h, body: JSON.stringify(d) }).then(json),
- deleteMember: id => fetch(`${BASE}/members/${id}`, { method: 'DELETE' }),
+ getMembers: () => request('/members'),
+ addMember: d => request('/members', { method: 'POST', body: d, write: true }),
+ updateMember: (id, d) => request(`/members/${id}`, { method: 'PUT', body: d, write: true }),
+ deleteMember: id => request(`/members/${id}`, { method: 'DELETE', write: true }),
// Projects
- getProjects: () => fetch(`${BASE}/projects`).then(json),
- createProject: d => fetch(`${BASE}/projects`, { method: 'POST', headers: h, body: JSON.stringify(d) }).then(json),
- updateProject: (id,d) => fetch(`${BASE}/projects/${id}`, { method: 'PUT', headers: h, body: JSON.stringify(d) }),
- deleteProject: id => fetch(`${BASE}/projects/${id}`, { method: 'DELETE' }),
+ getProjects: () => request('/projects'),
+ createProject: d => request('/projects', { method: 'POST', body: d, write: true }),
+ updateProject: (id, d) => request(`/projects/${id}`, { method: 'PUT', body: d, write: true }),
+ deleteProject: id => request(`/projects/${id}`, { method: 'DELETE', write: true }),
+
+ // Project members
+ assignMemberToProject: (projectId, memberId) =>
+ request(`/projects/${projectId}/members/${memberId}`, { method: 'POST', write: true }),
+
+ // Tasks
+ getTasks: projectId => request(`/projects/${projectId}/tasks`),
+ createTask: (projectId, data) => request(`/projects/${projectId}/tasks`, { method: 'POST', body: data, write: true }),
+ updateTask: (projectId, taskId, data) =>
+ request(`/projects/${projectId}/tasks/${taskId}`, { method: 'PUT', body: data, write: true }),
+ deleteTask: (projectId, taskId) => request(`/projects/${projectId}/tasks/${taskId}`, { method: 'DELETE', write: true }),
+
+ // Attachments
+ uploadAttachment: (projectId, taskId, file) => {
+ const fd = new FormData()
+ fd.append('file', file)
+ return request(`/projects/${projectId}/tasks/${taskId}/attachments`, {
+ method: 'POST',
+ body: fd,
+ formData: true,
+ write: true,
+ })
+ },
+ deleteAttachment: (projectId, taskId, attachmentId) =>
+ request(`/projects/${projectId}/tasks/${taskId}/attachments/${attachmentId}`, { method: 'DELETE', write: true }),
+
+ // Invites
+ createInvite: (projectId, email) =>
+ request(`/projects/${projectId}/invites`, { method: 'POST', body: { email }, write: true }),
+ getInvite: token => request(`/invites/${token}`),
+ acceptInvite: (token, data) => request(`/invites/${token}/accept`, { method: 'POST', body: data }),
+
+ // Notify (test webhook/email)
+ notify: (projectId, subject, text) =>
+ request(`/projects/${projectId}/notify`, { method: 'POST', body: { subject, text }, write: true }),
}
diff --git a/frontend/src/components/BurndownChart.jsx b/frontend/src/components/BurndownChart.jsx
new file mode 100644
index 0000000..0e96778
--- /dev/null
+++ b/frontend/src/components/BurndownChart.jsx
@@ -0,0 +1,173 @@
+import React, { useMemo } from 'react'
+
+const SVG_W = 480
+const SVG_H = 220
+const PAD = { top: 16, right: 20, bottom: 36, left: 40 }
+const CW = SVG_W - PAD.left - PAD.right
+const CH = SVG_H - PAD.top - PAD.bottom
+
+const addDays = (d, n) => { const r = new Date(d); r.setDate(r.getDate() + n); return r }
+const dayStr = d => d.toISOString().slice(0, 10)
+
+export default function BurndownChart({ project }) {
+ const { tasks, startDate: pStart, dueDate: pEnd } = project
+ const today = new Date(); today.setHours(0, 0, 0, 0)
+
+ const start = pStart ? new Date(pStart) : (() => {
+ const dates = tasks.filter(t => t.dueDate).map(t => new Date(t.dueDate))
+ return dates.length ? new Date(Math.min(...dates)) : today
+ })()
+ start.setHours(0, 0, 0, 0)
+
+ const end = pEnd ? new Date(pEnd) : (() => {
+ const dates = tasks.filter(t => t.dueDate).map(t => new Date(t.dueDate))
+ return dates.length ? addDays(new Date(Math.max(...dates)), 1) : addDays(today, 14)
+ })()
+ end.setHours(0, 0, 0, 0)
+
+ const total = tasks.length
+ const done = tasks.filter(t => t.status === 'done').length
+ const open = total - done
+
+ const spanDays = Math.max(Math.round((end - start) / 86400000), 1)
+ const daysLeft = Math.max(Math.round((end - today) / 86400000), 0)
+ const elapsed = Math.round((today - start) / 86400000)
+
+ // Ideal burndown: linear from (0, total) to (spanDays, 0)
+ const idealLine = total > 0
+ ? [[0, total], [spanDays, 0]].map(([d, v]) => [
+ PAD.left + (d / spanDays) * CW,
+ PAD.top + (1 - v / total) * CH,
+ ])
+ : []
+
+ // Actual: we only know current state, so we draw from (0, total) → (elapsed, open)
+ // This gives a useful snapshot even without historical data
+ const actualPoints = total > 0 ? [
+ [PAD.left, PAD.top],
+ [PAD.left + Math.min(elapsed, spanDays) / spanDays * CW, PAD.top + (1 - open / total) * CH],
+ ] : []
+
+ const todayX = PAD.left + Math.min(elapsed, spanDays) / spanDays * CW
+
+ // Y axis ticks (0%, 25%, 50%, 75%, 100%)
+ const yTicks = [0, 0.25, 0.5, 0.75, 1].map(frac => ({
+ y: PAD.top + (1 - frac) * CH,
+ label: Math.round(frac * total),
+ }))
+
+ // X axis ticks — start / mid / end
+ const xTicks = [0, 0.5, 1].map(frac => {
+ const d = addDays(start, Math.round(frac * spanDays))
+ return { x: PAD.left + frac * CW, label: d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) }
+ })
+
+ const toPoints = pts => pts.map(([x, y]) => `${x},${y}`).join(' ')
+
+ const behind = total > 0 && elapsed > 0 && open > (total * (1 - elapsed / spanDays))
+
+ const stats = [
+ { label: 'Total', value: total, color: '#64748b' },
+ { label: 'Done', value: done, color: '#10b981' },
+ { label: 'Open', value: open, color: '#f59e0b' },
+ { label: 'Days left',value: daysLeft, color: daysLeft <= 3 ? '#ef4444' : '#818cf8' },
+ ]
+
+ if (total === 0) {
+ return (
+
+ Add tasks to generate the burndown chart.
+
+ )
+ }
+
+ return (
+
+ {/* Stats row */}
+
+ {stats.map(s => (
+
+
{s.value}
+
{s.label}
+
+ ))}
+
+
+ {behind && (
+
+ ⚠ Behind schedule — ideal remaining at this point: {Math.round(total * (1 - elapsed / spanDays))} tasks
+
+ )}
+
+ {/* SVG chart */}
+
+
+ {/* Legend */}
+
+
+
+ Ideal
+
+
+
+ Actual
+
+
+ Snapshot as of today — historical data recorded over time
+
+
+
+ )
+}
diff --git a/frontend/src/components/CalendarView.jsx b/frontend/src/components/CalendarView.jsx
new file mode 100644
index 0000000..c42e33a
--- /dev/null
+++ b/frontend/src/components/CalendarView.jsx
@@ -0,0 +1,147 @@
+import React, { useState } from 'react'
+
+const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
+const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December']
+
+const PRI_COLOR = { high: '#ef4444', medium: '#f59e0b', low: '#10b981' }
+const STATUS_DOT = { todo: '#3d4166', 'in-progress': '#f59e0b', done: '#10b981' }
+
+const isoDate = d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
+
+export default function CalendarView({ project, onEditTask }) {
+ const today = new Date()
+ const [year, setYear] = useState(today.getFullYear())
+ const [month, setMonth] = useState(today.getMonth())
+
+ const prevMonth = () => { if (month === 0) { setYear(y => y-1); setMonth(11) } else setMonth(m => m-1) }
+ const nextMonth = () => { if (month === 11) { setYear(y => y+1); setMonth(0) } else setMonth(m => m+1) }
+
+ // Build a map of dateString → items
+ const itemMap = {}
+ const push = (dateStr, item) => { if (!itemMap[dateStr]) itemMap[dateStr] = []; itemMap[dateStr].push(item) }
+
+ project.tasks.forEach(t => {
+ if (!t.dueDate) return
+ const d = new Date(t.dueDate); d.setHours(0,0,0,0)
+ push(isoDate(d), { type: 'task', data: t })
+ })
+ project.milestones.forEach(m => {
+ if (!m.date) return
+ const d = new Date(m.date); d.setHours(0,0,0,0)
+ push(isoDate(d), { type: 'milestone', data: m })
+ })
+
+ // Build calendar grid
+ const firstDay = new Date(year, month, 1).getDay()
+ const daysInMonth = new Date(year, month + 1, 0).getDate()
+ const cells = []
+ for (let i = 0; i < firstDay; i++) cells.push(null)
+ for (let d = 1; d <= daysInMonth; d++) cells.push(d)
+ // Pad to full weeks
+ while (cells.length % 7 !== 0) cells.push(null)
+
+ const todayStr = isoDate(today)
+
+ const MAX_VISIBLE = 3
+
+ return (
+
+ {/* Header */}
+
+
+
{MONTHS[month]} {year}
+
+
+
+ {/* Day headers */}
+
+ {DAYS.map(d => (
+
{d}
+ ))}
+
+
+ {/* Grid */}
+
+ {cells.map((day, idx) => {
+ if (!day) return
+
+ const dateStr = `${year}-${String(month+1).padStart(2,'0')}-${String(day).padStart(2,'0')}`
+ const items = itemMap[dateStr] || []
+ const isToday = dateStr === todayStr
+ const visible = items.slice(0, MAX_VISIBLE)
+ const overflow = items.length - MAX_VISIBLE
+
+ return (
+
+ {/* Day number */}
+
+ {day}
+
+
+ {/* Items */}
+ {visible.map((item, i) => (
+ item.type === 'task' ? (
+
onEditTask && onEditTask(item.data)}
+ style={{
+ fontSize: 10, lineHeight: '14px',
+ background: '#0a0a18',
+ borderLeft: `2px solid ${PRI_COLOR[item.data.priority] || '#475569'}`,
+ borderRadius: '0 3px 3px 0',
+ padding: '2px 4px',
+ color: item.data.status === 'done' ? '#475569' : '#94a3b8',
+ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
+ cursor: 'pointer',
+ textDecoration: item.data.status === 'done' ? 'line-through' : 'none',
+ }}
+ title={item.data.title}
+ >
+
+ {item.data.title}
+
+ ) : (
+
+ ◆ {item.data.title}
+
+ )
+ ))}
+
+ {overflow > 0 && (
+
+{overflow} more
+ )}
+
+ )
+ })}
+
+
+ {/* Legend */}
+
+ {[['High priority','#ef4444'],['Medium','#f59e0b'],['Low','#10b981']].map(([l, c]) => (
+
+ {l}
+
+ ))}
+
+ ◆ Milestone
+
+
+
+ )
+}
diff --git a/frontend/src/components/GanttView.jsx b/frontend/src/components/GanttView.jsx
new file mode 100644
index 0000000..456308b
--- /dev/null
+++ b/frontend/src/components/GanttView.jsx
@@ -0,0 +1,231 @@
+import React, { useMemo, useRef, useState } from 'react'
+
+const ROW_H = 34
+const HEAD_H = 52
+const LEFT_W = 200
+const DAY_W = 28
+const PAD_DAYS = 3
+
+const STATUS_COLOR = {
+ todo: '#3d4166',
+ 'in-progress': '#f59e0b',
+ done: '#10b981',
+}
+const PRI_COLOR = { high: '#ef4444', medium: '#f59e0b', low: '#10b981' }
+
+const addDays = (d, n) => { const r = new Date(d); r.setDate(r.getDate() + n); return r }
+const diffDays = (a, b) => Math.round((b - a) / 86400000)
+const fmt = d => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
+
+export default function GanttView({ project }) {
+ const svgRef = useRef(null)
+ const [tooltip, setTooltip] = useState(null)
+
+ const { tasks, milestones } = project
+
+ // Compute timeline bounds -----------------------------------------
+ const dates = []
+ const today = new Date(); today.setHours(0, 0, 0, 0)
+
+ if (project.startDate) dates.push(new Date(project.startDate))
+ if (project.dueDate) dates.push(new Date(project.dueDate))
+ tasks.forEach(t => {
+ if (t.startDate) dates.push(new Date(t.startDate))
+ if (t.dueDate) dates.push(new Date(t.dueDate))
+ })
+ milestones.forEach(m => { if (m.date) dates.push(new Date(m.date)) })
+ dates.push(today)
+
+ if (dates.length === 0) {
+ return
Add tasks with due dates to see the Gantt chart.
+ }
+
+ const rawStart = new Date(Math.min(...dates))
+ const rawEnd = new Date(Math.max(...dates))
+ rawStart.setHours(0, 0, 0, 0)
+ rawEnd.setHours(0, 0, 0, 0)
+
+ const start = addDays(rawStart, -PAD_DAYS)
+ const end = addDays(rawEnd, PAD_DAYS + 1)
+ const totalDays = diffDays(start, end)
+
+ const SVG_W = LEFT_W + totalDays * DAY_W
+ const taskRows = tasks.filter(t => t.dueDate)
+ const msRows = milestones.filter(m => m.date)
+ const SVG_H = HEAD_H + (taskRows.length + msRows.length) * ROW_H + 10
+
+ const dayX = date => LEFT_W + diffDays(start, date) * DAY_W
+ const todayX = dayX(today)
+
+ // Build month tick marks
+ const months = []
+ const cur = new Date(start)
+ cur.setDate(1)
+ while (cur <= end) {
+ months.push({ label: cur.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }), x: dayX(cur) })
+ cur.setMonth(cur.getMonth() + 1)
+ }
+
+ // Week ticks (only if span ≤ 120 days)
+ const weekTicks = []
+ if (totalDays <= 120) {
+ const w = new Date(start)
+ while (w <= end) {
+ weekTicks.push(dayX(w))
+ w.setDate(w.getDate() + 7)
+ }
+ }
+
+ return (
+
+
+
+ {/* Tooltip */}
+ {tooltip && (
+
+
{tooltip.t.title}
+
Status: {tooltip.t.status}
+ {tooltip.t.dueDate &&
Due: {new Date(tooltip.t.dueDate).toLocaleDateString()}
}
+ {tooltip.t.estimatedHours &&
Est: {tooltip.t.estimatedHours}h
}
+
+ )}
+
+ {taskRows.length === 0 && msRows.length === 0 && (
+
+ Add tasks or milestones with dates to see the Gantt chart.
+
+ )}
+
+ )
+}
diff --git a/package.json b/package.json
index 041301f..799152c 100644
--- a/package.json
+++ b/package.json
@@ -4,10 +4,36 @@
"main": "main.js",
"scripts": {
"start": "electron .",
- "build": "electron-builder"
+ "build": "electron-builder",
+ "build:desktop": "npm run build:renderer && npm run sync:dist && npm run build",
+ "build:renderer": "npm run build --prefix frontend",
+ "sync:dist": "rm -rf dist && mkdir -p dist && cp -R frontend/dist/* dist/",
+ "test:wiring": "node scripts/wiring-smoke-test.cjs",
+ "test:persistence": "node scripts/persistence-self-test.cjs",
+ "test:persistence:restart": "node scripts/persistence-self-test.cjs --restart-backend",
+ "build:verified": "npm run test:persistence && npm run build",
+ "build:verified:restart": "npm run test:persistence:restart && npm run build",
+ "release:verified": "npm run test:persistence && npm run build:renderer && npm run sync:dist && npm run build",
+ "release:verified:restart": "npm run test:persistence:restart && npm run build:renderer && npm run sync:dist && npm run build"
+ },
+ "build": {
+ "appId": "com.projecthub.app",
+ "productName": "Project Hub",
+ "directories": {
+ "output": "releases"
+ },
+ "mac": {
+ "identity": null,
+ "hardenedRuntime": false,
+ "target": [
+ { "target": "dmg", "arch": ["arm64"] }
+ ]
+ },
+ "afterSign": "scripts/adhoc-sign.js"
},
"dependencies": {
"electron": "^30.0.0",
"electron-builder": "^24.0.0"
}
}
+
diff --git a/scripts/adhoc-sign.js b/scripts/adhoc-sign.js
new file mode 100644
index 0000000..f8bca86
--- /dev/null
+++ b/scripts/adhoc-sign.js
@@ -0,0 +1,15 @@
+// Post-build hook: ad-hoc sign the .app bundle so macOS 14+ will open it
+// without requiring an Apple Developer certificate.
+const { execSync } = require('child_process');
+const path = require('path');
+
+exports.default = async function (context) {
+ const appPath = path.join(
+ context.appOutDir,
+ `${context.packager.appInfo.productFilename}.app`,
+ );
+ console.log(`Ad-hoc signing: ${appPath}`);
+ execSync(`xattr -cr "${appPath}"`);
+ execSync(`codesign --force --deep --sign - "${appPath}"`);
+ console.log('Ad-hoc signing complete.');
+};
diff --git a/scripts/persistence-self-test.cjs b/scripts/persistence-self-test.cjs
new file mode 100644
index 0000000..d3b9856
--- /dev/null
+++ b/scripts/persistence-self-test.cjs
@@ -0,0 +1,185 @@
+#!/usr/bin/env node
+
+const { execSync } = require('child_process');
+
+const args = new Set(process.argv.slice(2));
+if (args.has('--help') || args.has('-h')) {
+ console.log('Project Hub persistence self-test');
+ console.log('');
+ console.log('Usage:');
+ console.log(' node scripts/persistence-self-test.cjs [--restart-backend]');
+ console.log('');
+ console.log('Environment:');
+ console.log(' API_BASE default: http://localhost:4000/api');
+ console.log(' WRITE_API_KEY optional x-api-key for write endpoints');
+ process.exit(0);
+}
+
+const API_BASE = process.env.API_BASE || 'http://localhost:4000/api';
+const HEALTH_URL = API_BASE.replace(/\/api\/?$/, '') + '/health';
+const WRITE_API_KEY = process.env.WRITE_API_KEY || '';
+const SHOULD_RESTART = args.has('--restart-backend');
+
+const marker = `selftest-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+const state = {
+ memberId: null,
+ projectId: null,
+ taskId: null,
+};
+
+function log(msg) {
+ console.log(`[persistence-test] ${msg}`);
+}
+
+async function sleep(ms) {
+ await new Promise(resolve => setTimeout(resolve, ms));
+}
+
+async function request(path, options = {}) {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs || 10000);
+
+ const headers = {
+ ...(options.body ? { 'Content-Type': 'application/json' } : {}),
+ ...(WRITE_API_KEY ? { 'x-api-key': WRITE_API_KEY } : {}),
+ ...(options.headers || {}),
+ };
+
+ const response = await fetch(`${API_BASE}${path}`, {
+ method: options.method || 'GET',
+ headers,
+ body: options.body ? JSON.stringify(options.body) : undefined,
+ signal: controller.signal,
+ }).finally(() => clearTimeout(timeout));
+
+ const text = await response.text();
+ let parsed = null;
+ try {
+ parsed = text ? JSON.parse(text) : null;
+ } catch {
+ parsed = null;
+ }
+
+ if (!response.ok) {
+ const details = parsed ? JSON.stringify(parsed) : text;
+ throw new Error(`${response.status} ${response.statusText}${details ? `: ${details}` : ''}`);
+ }
+
+ return parsed;
+}
+
+async function waitForHealth(maxSeconds = 30) {
+ const start = Date.now();
+ while ((Date.now() - start) / 1000 < maxSeconds) {
+ try {
+ const response = await fetch(HEALTH_URL, { signal: AbortSignal.timeout(3000) });
+ if (response.ok) return;
+ } catch {
+ // Keep polling.
+ }
+ await sleep(1000);
+ }
+ throw new Error(`Health check failed after ${maxSeconds}s at ${HEALTH_URL}`);
+}
+
+function restartBackendContainer() {
+ log('Restarting backend via docker compose...');
+ execSync('docker compose restart backend', { stdio: 'inherit' });
+}
+
+async function cleanup() {
+ if (state.projectId) {
+ try {
+ await request(`/projects/${state.projectId}`, { method: 'DELETE' });
+ log(`Cleanup: deleted project ${state.projectId}`);
+ } catch (e) {
+ log(`Cleanup warning: failed to delete project ${state.projectId}: ${e.message}`);
+ }
+ }
+
+ if (state.memberId) {
+ try {
+ await request(`/members/${state.memberId}`, { method: 'DELETE' });
+ log(`Cleanup: deleted member ${state.memberId}`);
+ } catch (e) {
+ log(`Cleanup warning: failed to delete member ${state.memberId}: ${e.message}`);
+ }
+ }
+}
+
+async function run() {
+ log(`Using API base ${API_BASE}`);
+ await waitForHealth();
+ log('Health check passed.');
+
+ const member = await request('/members', {
+ method: 'POST',
+ body: {
+ name: `Self Test ${marker}`,
+ role: 'QA Bot',
+ initials: 'ST',
+ email: `${marker}@local.test`,
+ },
+ });
+ state.memberId = member.id;
+ log(`Created member ${state.memberId}`);
+
+ const project = await request('/projects', {
+ method: 'POST',
+ body: {
+ name: `Persistence ${marker}`,
+ description: 'Automated persistence verification',
+ status: 'active',
+ color: '#10b981',
+ startDate: new Date().toISOString().slice(0, 10),
+ dueDate: new Date(Date.now() + 86400000).toISOString().slice(0, 10),
+ members: [],
+ tasks: [],
+ milestones: [],
+ },
+ });
+ state.projectId = project.id;
+ log(`Created project ${state.projectId}`);
+
+ await request(`/projects/${state.projectId}/members/${state.memberId}`, { method: 'POST' });
+ log(`Assigned member ${state.memberId} to project ${state.projectId}`);
+
+ const task = await request(`/projects/${state.projectId}/tasks`, {
+ method: 'POST',
+ body: {
+ title: `Task ${marker}`,
+ description: 'Persistence check task',
+ memberId: state.memberId,
+ },
+ });
+ state.taskId = task.id;
+ log(`Created task ${state.taskId}`);
+
+ if (SHOULD_RESTART) {
+ restartBackendContainer();
+ await waitForHealth(45);
+ log('Backend is healthy after restart.');
+ }
+
+ const projects = await request('/projects');
+ const persistedProject = projects.find(p => p.id === state.projectId);
+ if (!persistedProject) {
+ throw new Error(`Persisted project not found after verification step: ${state.projectId}`);
+ }
+
+ const tasks = await request(`/projects/${state.projectId}/tasks`);
+ const persistedTask = tasks.find(t => t.id === state.taskId);
+ if (!persistedTask) {
+ throw new Error(`Persisted task not found after verification step: ${state.taskId}`);
+ }
+
+ log('Persistence verified: project and task were read back successfully.');
+ await cleanup();
+ log('PASS');
+}
+
+run().catch(async err => {
+ console.error(`[persistence-test] FAIL: ${err.message}`);
+ await cleanup();
+ process.exit(1);
+});
diff --git a/scripts/wiring-smoke-test.cjs b/scripts/wiring-smoke-test.cjs
new file mode 100644
index 0000000..ab36da1
--- /dev/null
+++ b/scripts/wiring-smoke-test.cjs
@@ -0,0 +1,190 @@
+#!/usr/bin/env node
+
+const API_BASE = process.env.API_BASE || 'http://localhost:4000/api';
+const HEALTH_URL = API_BASE.replace(/\/api\/?$/, '') + '/health';
+const WRITE_API_KEY = process.env.WRITE_API_KEY || '';
+
+const marker = `wiring-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
+const state = {
+ memberId: null,
+ projectId: null,
+ taskId: null,
+ inviteToken: null,
+};
+
+function log(message) {
+ console.log(`[wiring-test] ${message}`);
+}
+
+async function sleep(ms) {
+ await new Promise(resolve => setTimeout(resolve, ms));
+}
+
+async function waitForHealth(maxSeconds = 30) {
+ const started = Date.now();
+ while ((Date.now() - started) / 1000 < maxSeconds) {
+ try {
+ const response = await fetch(HEALTH_URL, { signal: AbortSignal.timeout(3000) });
+ if (response.ok) return;
+ } catch {
+ // keep polling
+ }
+ await sleep(1000);
+ }
+ throw new Error(`Backend not healthy at ${HEALTH_URL} after ${maxSeconds}s`);
+}
+
+async function request(path, options = {}) {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs || 10000);
+ const headers = {
+ ...(options.body || options.formData ? {} : {}),
+ ...(options.body ? { 'Content-Type': 'application/json' } : {}),
+ ...(options.write && WRITE_API_KEY ? { 'x-api-key': WRITE_API_KEY } : {}),
+ ...(options.headers || {}),
+ };
+
+ const response = await fetch(`${API_BASE}${path}`, {
+ method: options.method || 'GET',
+ headers,
+ body: options.formData || (options.body ? JSON.stringify(options.body) : undefined),
+ signal: controller.signal,
+ }).finally(() => clearTimeout(timeout));
+
+ const text = await response.text();
+ let parsed = null;
+ try {
+ parsed = text ? JSON.parse(text) : null;
+ } catch {
+ parsed = null;
+ }
+
+ if (!response.ok) {
+ const details = parsed ? JSON.stringify(parsed) : text;
+ throw new Error(`${response.status} ${response.statusText}${details ? `: ${details}` : ''}`);
+ }
+
+ return parsed;
+}
+
+async function cleanup() {
+ if (state.projectId) {
+ try {
+ await request(`/projects/${state.projectId}`, { method: 'DELETE', write: true });
+ log(`Cleanup: deleted project ${state.projectId}`);
+ } catch (err) {
+ log(`Cleanup warning: project delete failed: ${err.message}`);
+ }
+ }
+
+ if (state.memberId) {
+ try {
+ await request(`/members/${state.memberId}`, { method: 'DELETE', write: true });
+ log(`Cleanup: deleted member ${state.memberId}`);
+ } catch (err) {
+ log(`Cleanup warning: member delete failed: ${err.message}`);
+ }
+ }
+}
+
+async function run() {
+ log(`Using API base ${API_BASE}`);
+ await waitForHealth();
+ log('Health check passed');
+
+ const member = await request('/members', {
+ method: 'POST',
+ write: true,
+ body: {
+ name: `Wiring Member ${marker}`,
+ role: 'QA Bot',
+ email: `${marker}@local.test`,
+ },
+ });
+ state.memberId = member.id;
+ log(`Created member ${state.memberId}`);
+
+ const project = await request('/projects', {
+ method: 'POST',
+ write: true,
+ body: {
+ name: `Wiring Project ${marker}`,
+ description: 'Wiring smoke test',
+ status: 'active',
+ color: '#6366f1',
+ members: [],
+ tasks: [],
+ milestones: [],
+ },
+ });
+ state.projectId = project.id;
+ log(`Created project ${state.projectId}`);
+
+ const assigned = await request(`/projects/${state.projectId}/members/${state.memberId}`, {
+ method: 'POST',
+ write: true,
+ });
+ if (!Array.isArray(assigned.members) || !assigned.members.some(m => m.id === state.memberId)) {
+ throw new Error('Assigned member missing from project.members');
+ }
+ log('Assigned member to project');
+
+ const task = await request(`/projects/${state.projectId}/tasks`, {
+ method: 'POST',
+ write: true,
+ body: {
+ title: `Wiring Task ${marker}`,
+ description: 'Task route check',
+ assignedTo: state.memberId,
+ status: 'todo',
+ priority: 'medium',
+ },
+ });
+ state.taskId = task.id;
+ log(`Created task ${state.taskId}`);
+
+ const updatedTask = await request(`/projects/${state.projectId}/tasks/${state.taskId}`, {
+ method: 'PUT',
+ write: true,
+ body: { status: 'done' },
+ });
+ if (updatedTask.status !== 'done') {
+ throw new Error('Task update did not persist expected status');
+ }
+ log('Updated task status to done');
+
+ const invite = await request(`/projects/${state.projectId}/invites`, {
+ method: 'POST',
+ write: true,
+ body: { email: `${marker}.invite@local.test` },
+ });
+ state.inviteToken = invite.token;
+ log('Created invite token');
+
+ await request(`/invites/${state.inviteToken}`);
+ await request(`/invites/${state.inviteToken}/accept`, {
+ method: 'POST',
+ body: { name: `Invitee ${marker}`, role: 'Collaborator', email: `${marker}.invite@local.test` },
+ });
+ log('Invite get/accept flow passed');
+
+ await request(`/projects/${state.projectId}/tasks/${state.taskId}`, {
+ method: 'DELETE',
+ write: true,
+ });
+ log('Deleted task');
+
+ const tasksAfterDelete = await request(`/projects/${state.projectId}/tasks`);
+ if (tasksAfterDelete.some(t => t.id === state.taskId)) {
+ throw new Error('Task still present after delete');
+ }
+
+ log('PASS');
+ await cleanup();
+}
+
+run().catch(async err => {
+ console.error(`[wiring-test] FAIL: ${err.message}`);
+ await cleanup();
+ process.exit(1);
+});