🌊 TeamFlow — Modern Trello alternative with email integration
- Full-stack: React 18 + Express + SQLite - Drag-and-drop kanban boards with @hello-pangea/dnd - Google App Password email integration (SMTP + IMAP) - Inbound email: create cards by sending emails - Reply-to-card: email replies become comments - Admin/user management with role-based access - Setup wizard: email config → admin creation - Checklists, time tracking, priorities, labels, due dates - Real-time notifications with activity feed - Beautiful HTML email templates
This commit is contained in:
182
server/db.js
Normal file
182
server/db.js
Normal file
@@ -0,0 +1,182 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const DB_PATH = join(__dirname, 'data', 'teamflow.db');
|
||||
|
||||
import { mkdirSync } from 'fs';
|
||||
mkdirSync(join(__dirname, 'data'), { recursive: true });
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS app_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
avatar_color TEXT DEFAULT '#6366f1',
|
||||
role TEXT DEFAULT 'member',
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS boards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
background TEXT DEFAULT 'gradient-blue',
|
||||
is_archived INTEGER DEFAULT 0,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS board_members (
|
||||
board_id INTEGER REFERENCES boards(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT DEFAULT 'member',
|
||||
joined_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (board_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lists (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
board_id INTEGER REFERENCES boards(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
position REAL NOT NULL,
|
||||
is_archived INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
list_id INTEGER REFERENCES lists(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
position REAL NOT NULL,
|
||||
due_date TEXT,
|
||||
priority TEXT DEFAULT 'none',
|
||||
color TEXT DEFAULT '',
|
||||
estimated_hours REAL,
|
||||
time_spent REAL DEFAULT 0,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
assigned_to INTEGER REFERENCES users(id),
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS labels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
board_id INTEGER REFERENCES boards(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS card_labels (
|
||||
card_id INTEGER REFERENCES cards(id) ON DELETE CASCADE,
|
||||
label_id INTEGER REFERENCES labels(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (card_id, label_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS card_comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
card_id INTEGER REFERENCES cards(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
content TEXT NOT NULL,
|
||||
is_email_reply INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS card_activity (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
card_id INTEGER REFERENCES cards(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
action TEXT NOT NULL,
|
||||
details TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS email_config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
smtp_host TEXT DEFAULT 'smtp.gmail.com',
|
||||
smtp_port INTEGER DEFAULT 587,
|
||||
email TEXT NOT NULL,
|
||||
app_password TEXT NOT NULL,
|
||||
imap_host TEXT DEFAULT 'imap.gmail.com',
|
||||
imap_port INTEGER DEFAULT 993,
|
||||
inbound_enabled INTEGER DEFAULT 0,
|
||||
inbound_folder TEXT DEFAULT 'INBOX',
|
||||
board_email_prefix TEXT DEFAULT 'tf-',
|
||||
configured_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS email_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
from_email TEXT,
|
||||
to_email TEXT,
|
||||
subject TEXT,
|
||||
card_id INTEGER REFERENCES cards(id),
|
||||
direction TEXT,
|
||||
status TEXT,
|
||||
error TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS email_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
card_id INTEGER REFERENCES cards(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
purpose TEXT DEFAULT 'reply',
|
||||
expires_at TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
message TEXT DEFAULT '',
|
||||
card_id INTEGER,
|
||||
board_id INTEGER,
|
||||
is_read INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS checklists (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
card_id INTEGER REFERENCES cards(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL DEFAULT 'Checklist',
|
||||
position REAL DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS checklist_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
checklist_id INTEGER REFERENCES checklists(id) ON DELETE CASCADE,
|
||||
text TEXT NOT NULL,
|
||||
is_checked INTEGER DEFAULT 0,
|
||||
position REAL DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cards_list ON cards(list_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_card_activity_card ON card_activity(card_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_card_comments_card ON card_comments(card_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id, is_read);
|
||||
CREATE INDEX IF NOT EXISTS idx_lists_board ON lists(board_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_tokens_token ON email_tokens(token);
|
||||
`);
|
||||
|
||||
export default db;
|
||||
155
server/email/imap.js
Normal file
155
server/email/imap.js
Normal file
@@ -0,0 +1,155 @@
|
||||
import { ImapFlow } from 'imapflow';
|
||||
import { simpleParser } from 'mailparser';
|
||||
import db from '../db.js';
|
||||
import { generateToken } from '../middleware/auth.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
let imapClient = null;
|
||||
let pollInterval = null;
|
||||
|
||||
function getConfig() {
|
||||
return db.prepare('SELECT * FROM email_config WHERE id = 1').get();
|
||||
}
|
||||
|
||||
function getBoardByPrefix(prefix) {
|
||||
const boards = db.prepare('SELECT id, title FROM boards WHERE is_archived = 0').all();
|
||||
return boards.find(b => b.title.toLowerCase().replace(/\s+/g, '-').startsWith(prefix.toLowerCase()));
|
||||
}
|
||||
|
||||
function getCardByToken(token) {
|
||||
const record = db.prepare(`
|
||||
SELECT et.*, c.title as card_title, l.board_id, b.title as board_title, u.name as user_name
|
||||
FROM email_tokens et
|
||||
JOIN cards c ON c.id = et.card_id
|
||||
JOIN lists l ON l.id = c.list_id
|
||||
JOIN boards b ON b.id = l.board_id
|
||||
LEFT JOIN users u ON u.id = et.user_id
|
||||
WHERE et.token = ? AND et.expires_at > datetime('now')
|
||||
`).get(token);
|
||||
return record;
|
||||
}
|
||||
|
||||
async function processMessage(msg) {
|
||||
const config = getConfig();
|
||||
if (!config || !config.inbound_enabled) return;
|
||||
|
||||
const parsed = await simpleParser(msg.source);
|
||||
|
||||
const fromAddr = parsed.from?.value?.[0]?.address || '';
|
||||
const subject = parsed.subject || '(no subject)';
|
||||
const bodyText = parsed.text || '';
|
||||
const bodyHtml = parsed.html || '';
|
||||
const messageId = parsed.messageId || '';
|
||||
const references = parsed.references || '';
|
||||
const inReplyTo = parsed.inReplyTo || '';
|
||||
|
||||
const sender = db.prepare('SELECT id, name, email FROM users WHERE email = ? AND is_active = 1').get(fromAddr);
|
||||
if (!sender) return;
|
||||
|
||||
// Check if this is a reply to an existing card
|
||||
if (inReplyTo || references) {
|
||||
const refStr = (inReplyTo + ' ' + references).toLowerCase();
|
||||
const tokens = db.prepare('SELECT * FROM email_tokens').all();
|
||||
for (const t of tokens) {
|
||||
if (refStr.includes(t.token.toLowerCase())) {
|
||||
const card = getCardByToken(t.token);
|
||||
if (card) {
|
||||
db.prepare(`INSERT INTO card_comments (card_id, user_id, content, is_email_reply) VALUES (?, ?, ?, 1)`)
|
||||
.run(card.card_id, sender.id, `📧 ${bodyText.trim()}`);
|
||||
db.prepare(`INSERT INTO card_activity (card_id, user_id, action, details) VALUES (?, ?, 'email_reply', ?)`)
|
||||
.run(card.card_id, sender.id, `Replied via email`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if subject matches board prefix pattern (e.g., [tf-board-name] Card title)
|
||||
const prefixMatch = subject.match(/^\[(.+?)\]\s*(.+)/);
|
||||
if (prefixMatch) {
|
||||
const board = getBoardByPrefix(prefixMatch[1]);
|
||||
if (board) {
|
||||
const firstList = db.prepare('SELECT id FROM lists WHERE board_id = ? AND is_archived = 0 ORDER BY position LIMIT 1').get(board.id);
|
||||
if (firstList) {
|
||||
const result = db.prepare(`INSERT INTO cards (list_id, title, description, position, created_by) VALUES (?, ?, ?, ?, ?)`)
|
||||
.run(firstList.id, prefixMatch[2].trim(), bodyText.substring(0, 2000), Date.now(), sender.id);
|
||||
db.prepare(`INSERT INTO card_activity (card_id, user_id, action, details) VALUES (?, ?, 'created_via_email', ?)`)
|
||||
.run(result.lastInsertRowid, sender.id, `Created via email from ${fromAddr}`);
|
||||
db.prepare(`INSERT INTO email_log (from_email, subject, card_id, direction, status) VALUES (?, ?, ?, 'received', 'processed')`)
|
||||
.run(fromAddr, subject, result.lastInsertRowid);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function pollInbox() {
|
||||
const config = getConfig();
|
||||
if (!config || !config.inbound_enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!imapClient) {
|
||||
imapClient = new ImapFlow({
|
||||
host: config.imap_host,
|
||||
port: config.imap_port,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: config.email,
|
||||
pass: config.app_password,
|
||||
},
|
||||
logger: false,
|
||||
});
|
||||
await imapClient.connect();
|
||||
}
|
||||
|
||||
const lock = await imapClient.getMailboxLock(config.inbound_folder);
|
||||
try {
|
||||
const lastPoll = db.prepare("SELECT value FROM app_state WHERE key = 'last_imap_poll'").get();
|
||||
let since = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
if (lastPoll) {
|
||||
const d = new Date(lastPoll.value);
|
||||
if (!isNaN(d)) since = d;
|
||||
}
|
||||
|
||||
for await (const msg of imapClient.fetch({ since }, { source: true })) {
|
||||
try {
|
||||
await processMessage(msg);
|
||||
} catch (err) {
|
||||
console.error('Error processing email:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare("INSERT OR REPLACE INTO app_state (key, value) VALUES ('last_imap_poll', ?)")
|
||||
.run(new Date().toISOString());
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('IMAP poll error:', err.message);
|
||||
imapClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function startImapPolling() {
|
||||
if (pollInterval) clearInterval(pollInterval);
|
||||
const config = getConfig();
|
||||
if (config?.inbound_enabled) {
|
||||
pollInterval = setInterval(pollInbox, 60000);
|
||||
pollInbox();
|
||||
}
|
||||
}
|
||||
|
||||
export function stopImapPolling() {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval);
|
||||
pollInterval = null;
|
||||
}
|
||||
if (imapClient) {
|
||||
imapClient.logout().catch(() => {});
|
||||
imapClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
export { pollInbox };
|
||||
110
server/email/transporter.js
Normal file
110
server/email/transporter.js
Normal file
@@ -0,0 +1,110 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import db from '../db.js';
|
||||
|
||||
let transporter = null;
|
||||
|
||||
export function getEmailConfig() {
|
||||
return db.prepare('SELECT * FROM email_config WHERE id = 1').get();
|
||||
}
|
||||
|
||||
export async function initTransporter() {
|
||||
const config = getEmailConfig();
|
||||
if (!config) return null;
|
||||
try {
|
||||
transporter = nodemailer.createTransport({
|
||||
host: config.smtp_host,
|
||||
port: config.smtp_port,
|
||||
secure: config.smtp_port === 465,
|
||||
auth: {
|
||||
user: config.email,
|
||||
pass: config.app_password,
|
||||
},
|
||||
});
|
||||
await transporter.verify();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Email transporter init failed:', err.message);
|
||||
transporter = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function testConnection(host, port, email, password) {
|
||||
const t = nodemailer.createTransport({
|
||||
host,
|
||||
port,
|
||||
secure: port === 465,
|
||||
auth: { user: email, pass: password },
|
||||
});
|
||||
await t.verify();
|
||||
await t.close();
|
||||
}
|
||||
|
||||
export async function sendMail({ to, subject, html, replyTo, inReplyTo, references }) {
|
||||
if (!transporter) await initTransporter();
|
||||
if (!transporter) throw new Error('Email not configured');
|
||||
const config = getEmailConfig();
|
||||
const msg = {
|
||||
from: `"TeamFlow" <${config.email}>`,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
headers: {},
|
||||
};
|
||||
if (replyTo) msg.replyTo = replyTo;
|
||||
if (inReplyTo) msg.headers['In-Reply-To'] = inReplyTo;
|
||||
if (references) msg.headers.References = references;
|
||||
const info = await transporter.sendMail(msg);
|
||||
db.prepare(`INSERT INTO email_log (from_email, to_email, subject, direction, status, message_id)
|
||||
VALUES (?, ?, ?, 'sent', 'sent', ?)`)
|
||||
.run(config.email, to, subject, info.messageId);
|
||||
return info;
|
||||
}
|
||||
|
||||
export function buildCardUrl(cardId, boardId) {
|
||||
const origin = process.env.APP_URL || 'http://localhost:5173';
|
||||
return `${origin}/board/${boardId}?card=${cardId}`;
|
||||
}
|
||||
|
||||
export function buildCardNotificationHtml({ userName, boardTitle, cardTitle, action, cardUrl, comment }) {
|
||||
const actionMessages = {
|
||||
assigned: `assigned you to a card`,
|
||||
commented: `commented on a card`,
|
||||
moved: `moved a card`,
|
||||
mentioned: `mentioned you in a comment`,
|
||||
due_soon: `has a due date coming up`,
|
||||
};
|
||||
return `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><style>
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f1f5f9;margin:0;padding:0}
|
||||
.container{max-width:560px;margin:40px auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.1)}
|
||||
.header{background:linear-gradient(135deg,#6366f1,#8b5cf6);padding:32px;color:#fff}
|
||||
.header h1{margin:0;font-size:20px;font-weight:600}
|
||||
.header p{margin:8px 0 0;opacity:.9;font-size:14px}
|
||||
.body{padding:32px}
|
||||
.card-preview{background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:16px;margin:16px 0}
|
||||
.card-preview h3{margin:0 0 8px;font-size:16px}
|
||||
.card-preview p{margin:0;color:#64748b;font-size:14px}
|
||||
.comment{background:#f1f5f9;border-radius:8px;padding:16px;margin:16px 0;font-size:14px;line-height:1.6;color:#334155}
|
||||
.btn{display:inline-block;background:#6366f1;color:#fff!important;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:500;margin-top:16px}
|
||||
.footer{padding:20px 32px;background:#f8fafc;border-top:1px solid #e2e8f0;font-size:12px;color:#94a3b8;text-align:center}
|
||||
</style></head><body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>TeamFlow</h1>
|
||||
<p>${userName} ${actionMessages[action] || action}</p>
|
||||
</div>
|
||||
<div class="body">
|
||||
<p style="color:#64748b;margin:0 0 8px">Board: <strong>${boardTitle}</strong></p>
|
||||
<div class="card-preview">
|
||||
<h3>${cardTitle}</h3>
|
||||
${comment ? `<div class="comment">${comment}</div>` : ''}
|
||||
</div>
|
||||
<a href="${cardUrl}" class="btn">View Card</a>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Sent by TeamFlow · <a href="${cardUrl}" style="color:#6366f1">Open in browser</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
}
|
||||
66
server/index.js
Normal file
66
server/index.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { readFileSync } from 'fs';
|
||||
import { createServer as createHttpsServer } from 'https';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
import db from './db.js';
|
||||
import { initTransporter } from './email/transporter.js';
|
||||
import { startImapPolling } from './email/imap.js';
|
||||
|
||||
import setupRoutes from './routes/setup.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import userRoutes from './routes/users.js';
|
||||
import boardRoutes from './routes/boards.js';
|
||||
import cardRoutes from './routes/cards.js';
|
||||
import notificationRoutes from './routes/notifications.js';
|
||||
import emailRoutes from './routes/email.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
|
||||
app.use('/api/setup', setupRoutes);
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/boards', boardRoutes);
|
||||
app.use('/api/cards', cardRoutes);
|
||||
app.use('/api/notifications', notificationRoutes);
|
||||
app.use('/api/email', emailRoutes);
|
||||
|
||||
app.use('/api', (req, res) => res.status(404).json({ error: 'Not found' }));
|
||||
|
||||
const clientDist = join(__dirname, '..', 'client', 'dist');
|
||||
app.use(express.static(clientDist));
|
||||
app.get('*', (req, res) => {
|
||||
if (req.path.startsWith('/api')) return res.status(404).json({ error: 'Not found' });
|
||||
res.sendFile(join(clientDist, 'index.html'));
|
||||
});
|
||||
|
||||
const SSL_KEY = process.env.SSL_KEY || '';
|
||||
const SSL_CERT = process.env.SSL_CERT || '';
|
||||
|
||||
async function startServer() {
|
||||
const hasEmail = !!db.prepare('SELECT 1 FROM email_config WHERE id = 1').get();
|
||||
if (hasEmail) {
|
||||
const ok = await initTransporter();
|
||||
if (ok) console.log('✉️ Email configured and verified');
|
||||
startImapPolling();
|
||||
}
|
||||
|
||||
if (SSL_KEY && SSL_CERT) {
|
||||
const server = createHttpsServer({
|
||||
key: readFileSync(SSL_KEY),
|
||||
cert: readFileSync(SSL_CERT),
|
||||
}, app);
|
||||
server.listen(PORT, () => console.log(`🚀 TeamFlow server running on https://localhost:${PORT}`));
|
||||
} else {
|
||||
app.listen(PORT, () => console.log(`🚀 TeamFlow server running on http://localhost:${PORT}`));
|
||||
}
|
||||
}
|
||||
|
||||
startServer();
|
||||
34
server/middleware/auth.js
Normal file
34
server/middleware/auth.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import db from '../db.js';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'teamflow-secret-change-in-production';
|
||||
|
||||
export function generateToken(user) {
|
||||
return jwt.sign(
|
||||
{ id: user.id, email: user.email, role: user.role },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
}
|
||||
|
||||
export function authMiddleware(req, res, next) {
|
||||
const header = req.headers.authorization;
|
||||
if (!header || !header.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
try {
|
||||
const token = header.split(' ')[1];
|
||||
const payload = jwt.verify(token, JWT_SECRET);
|
||||
const user = db.prepare('SELECT id, email, name, avatar_color, role, is_active FROM users WHERE id = ?').get(payload.id);
|
||||
if (!user || !user.is_active) return res.status(401).json({ error: 'Invalid token' });
|
||||
req.user = user;
|
||||
next();
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
}
|
||||
|
||||
export function adminOnly(req, res, next) {
|
||||
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
|
||||
next();
|
||||
}
|
||||
1945
server/package-lock.json
generated
Normal file
1945
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
server/package.json
Normal file
21
server/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "teamflow-server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"imapflow": "^1.0.173",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mailparser": "^3.7.2",
|
||||
"nodemailer": "^6.10.0",
|
||||
"uuid": "^11.0.5"
|
||||
}
|
||||
}
|
||||
36
server/routes/auth.js
Normal file
36
server/routes/auth.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import db from '../db.js';
|
||||
import { generateToken, authMiddleware } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/login', (req, res) => {
|
||||
const { email, password } = req.body;
|
||||
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
|
||||
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
|
||||
if (!user || !user.is_active) return res.status(401).json({ error: 'Invalid credentials' });
|
||||
if (!bcrypt.compareSync(password, user.password)) return res.status(401).json({ error: 'Invalid credentials' });
|
||||
const token = generateToken(user);
|
||||
res.json({ user: { id: user.id, email: user.email, name: user.name, avatar_color: user.avatar_color, role: user.role }, token });
|
||||
});
|
||||
|
||||
router.get('/me', authMiddleware, (req, res) => {
|
||||
res.json(req.user);
|
||||
});
|
||||
|
||||
router.put('/me', authMiddleware, async (req, res) => {
|
||||
const { name, avatar_color, current_password, new_password } = req.body;
|
||||
if (name) db.prepare('UPDATE users SET name = ? WHERE id = ?').run(name, req.user.id);
|
||||
if (avatar_color) db.prepare('UPDATE users SET avatar_color = ? WHERE id = ?').run(avatar_color, req.user.id);
|
||||
if (new_password) {
|
||||
const user = db.prepare('SELECT password FROM users WHERE id = ?').get(req.user.id);
|
||||
if (!bcrypt.compareSync(current_password, user.password)) return res.status(400).json({ error: 'Current password incorrect' });
|
||||
const hash = await bcrypt.hash(new_password, 12);
|
||||
db.prepare('UPDATE users SET password = ? WHERE id = ?').run(hash, req.user.id);
|
||||
}
|
||||
const updated = db.prepare('SELECT id, email, name, avatar_color, role FROM users WHERE id = ?').get(req.user.id);
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
export default router;
|
||||
129
server/routes/boards.js
Normal file
129
server/routes/boards.js
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../db.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(authMiddleware);
|
||||
|
||||
const BACKGROUNDS = [
|
||||
'gradient-blue', 'gradient-purple', 'gradient-green', 'gradient-orange',
|
||||
'gradient-pink', 'gradient-teal', 'gradient-indigo', 'gradient-red',
|
||||
];
|
||||
|
||||
function canAccessBoard(userId, boardId) {
|
||||
const m = db.prepare('SELECT 1 FROM board_members WHERE board_id = ? AND user_id = ?').get(boardId, userId);
|
||||
return !!m;
|
||||
}
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const boards = db.prepare(`
|
||||
SELECT b.*, bm.role as my_role,
|
||||
(SELECT COUNT(*) FROM lists l JOIN cards c ON c.list_id = l.id WHERE l.board_id = b.id AND c.assigned_to = ? AND c.due_date IS NOT NULL AND date(c.due_date) <= date('now', '+3 days')) as due_soon_count
|
||||
FROM boards b
|
||||
JOIN board_members bm ON bm.board_id = b.id AND bm.user_id = ?
|
||||
WHERE b.is_archived = 0
|
||||
ORDER BY b.created_at DESC
|
||||
`).all(req.user.id, req.user.id);
|
||||
res.json(boards);
|
||||
});
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
const { title, description, background } = req.body;
|
||||
if (!title) return res.status(400).json({ error: 'Title required' });
|
||||
const bg = background && BACKGROUNDS.includes(background) ? background : BACKGROUNDS[Math.floor(Math.random() * BACKGROUNDS.length)];
|
||||
const result = db.prepare('INSERT INTO boards (title, description, background, created_by) VALUES (?, ?, ?, ?)')
|
||||
.run(title, description || '', bg, req.user.id);
|
||||
db.prepare('INSERT INTO board_members (board_id, user_id, role) VALUES (?, ?, ?)').run(result.lastInsertRowid, req.user.id, 'admin');
|
||||
const defaultLists = ['To Do', 'In Progress', 'Review', 'Done'];
|
||||
const insertList = db.prepare('INSERT INTO lists (board_id, title, position) VALUES (?, ?, ?)');
|
||||
defaultLists.forEach((l, i) => insertList.run(result.lastInsertRowid, l, i * 65536));
|
||||
const board = db.prepare('SELECT * FROM boards WHERE id = ?').get(result.lastInsertRowid);
|
||||
board.my_role = 'admin';
|
||||
res.json(board);
|
||||
});
|
||||
|
||||
router.get('/:id', (req, res) => {
|
||||
if (!canAccessBoard(req.user.id, req.params.id)) return res.status(403).json({ error: 'Access denied' });
|
||||
const board = db.prepare('SELECT * FROM boards WHERE id = ?').get(req.params.id);
|
||||
res.json(board);
|
||||
});
|
||||
|
||||
router.put('/:id', (req, res) => {
|
||||
if (!canAccessBoard(req.user.id, req.params.id)) return res.status(403).json({ error: 'Access denied' });
|
||||
const { title, description, background, is_archived } = req.body;
|
||||
const board = db.prepare('SELECT * FROM boards WHERE id = ?').get(req.params.id);
|
||||
if (!board) return res.status(404).json({ error: 'Not found' });
|
||||
if (title !== undefined) db.prepare('UPDATE boards SET title = ? WHERE id = ?').run(title, req.params.id);
|
||||
if (description !== undefined) db.prepare('UPDATE boards SET description = ? WHERE id = ?').run(description, req.params.id);
|
||||
if (background !== undefined) db.prepare('UPDATE boards SET background = ? WHERE id = ?').run(background, req.params.id);
|
||||
if (is_archived !== undefined) db.prepare('UPDATE boards SET is_archived = ? WHERE id = ?').run(is_archived ? 1 : 0, req.params.id);
|
||||
const updated = db.prepare('SELECT * FROM boards WHERE id = ?').get(req.params.id);
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
if (!canAccessBoard(req.user.id, req.params.id)) return res.status(403).json({ error: 'Access denied' });
|
||||
db.prepare('DELETE FROM boards WHERE id = ?').run(req.params.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.get('/:id/full', (req, res) => {
|
||||
if (!canAccessBoard(req.user.id, req.params.id)) return res.status(403).json({ error: 'Access denied' });
|
||||
const board = db.prepare('SELECT * FROM boards WHERE id = ?').get(req.params.id);
|
||||
const lists = db.prepare('SELECT * FROM lists WHERE board_id = ? AND is_archived = 0 ORDER BY position').all(req.params.id);
|
||||
const labels = db.prepare('SELECT * FROM labels WHERE board_id = ? ORDER BY name').all(req.params.id);
|
||||
const members = db.prepare(`
|
||||
SELECT u.id, u.email, u.name, u.avatar_color, bm.role as board_role
|
||||
FROM board_members bm JOIN users u ON u.id = bm.user_id WHERE bm.board_id = ?
|
||||
`).all(req.params.id);
|
||||
|
||||
const cards = db.prepare(`
|
||||
SELECT c.*,
|
||||
u1.name as creator_name, u1.avatar_color as creator_color,
|
||||
u2.name as assignee_name, u2.avatar_color as assignee_color,
|
||||
(SELECT COUNT(*) FROM checklist_items ci JOIN checklists cl ON cl.id = ci.checklist_id WHERE cl.card_id = c.id) as total_items,
|
||||
(SELECT COUNT(*) FROM checklist_items ci JOIN checklists cl ON cl.id = ci.checklist_id WHERE cl.card_id = c.id AND ci.is_checked = 1) as done_items
|
||||
FROM cards c
|
||||
LEFT JOIN users u1 ON u1.id = c.created_by
|
||||
LEFT JOIN users u2 ON u2.id = c.assigned_to
|
||||
WHERE c.list_id IN (SELECT id FROM lists WHERE board_id = ? AND is_archived = 0)
|
||||
ORDER BY c.position
|
||||
`).all(req.params.id);
|
||||
|
||||
const cardLabels = db.prepare(`
|
||||
SELECT cl.card_id, l.id as label_id, l.name, l.color
|
||||
FROM card_labels cl JOIN labels l ON l.id = cl.label_id
|
||||
WHERE l.board_id = ?
|
||||
`).all(req.params.id);
|
||||
|
||||
const cardsById = {};
|
||||
cards.forEach(c => { c.labels = []; cardsById[c.id] = c; });
|
||||
cardLabels.forEach(cl => { if (cardsById[cl.card_id]) cardsById[cl.card_id].labels.push({ id: cl.label_id, name: cl.name, color: cl.color }); });
|
||||
|
||||
const listsWithCards = lists.map(l => ({
|
||||
...l,
|
||||
cards: cards.filter(c => c.list_id === l.id),
|
||||
}));
|
||||
|
||||
res.json({ ...board, lists: listsWithCards, labels, members });
|
||||
});
|
||||
|
||||
router.post('/:id/members', (req, res) => {
|
||||
if (!canAccessBoard(req.user.id, req.params.id)) return res.status(403).json({ error: 'Access denied' });
|
||||
const { user_id, role } = req.body;
|
||||
if (!user_id) return res.status(400).json({ error: 'User ID required' });
|
||||
const existing = db.prepare('SELECT 1 FROM board_members WHERE board_id = ? AND user_id = ?').get(req.params.id, user_id);
|
||||
if (existing) return res.status(400).json({ error: 'Already a member' });
|
||||
db.prepare('INSERT INTO board_members (board_id, user_id, role) VALUES (?, ?, ?)').run(req.params.id, user_id, role || 'member');
|
||||
const member = db.prepare(`SELECT u.id, u.email, u.name, u.avatar_color, bm.role as board_role
|
||||
FROM board_members bm JOIN users u ON u.id = bm.user_id WHERE bm.board_id = ? AND bm.user_id = ?`).get(req.params.id, user_id);
|
||||
res.json(member);
|
||||
});
|
||||
|
||||
router.delete('/:id/members/:userId', (req, res) => {
|
||||
if (!canAccessBoard(req.user.id, req.params.id)) return res.status(403).json({ error: 'Access denied' });
|
||||
db.prepare('DELETE FROM board_members WHERE board_id = ? AND user_id = ?').run(req.params.id, req.params.userId);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
296
server/routes/cards.js
Normal file
296
server/routes/cards.js
Normal file
@@ -0,0 +1,296 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../db.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { sendMail, buildCardNotificationHtml, buildCardUrl, getEmailConfig } from '../email/transporter.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const router = Router();
|
||||
router.use(authMiddleware);
|
||||
|
||||
function canAccessCard(userId, cardId) {
|
||||
const card = db.prepare(`
|
||||
SELECT 1 FROM cards c
|
||||
JOIN lists l ON l.id = c.list_id
|
||||
JOIN board_members bm ON bm.board_id = l.board_id AND bm.user_id = ?
|
||||
WHERE c.id = ?
|
||||
`).get(userId, cardId);
|
||||
return !!card;
|
||||
}
|
||||
|
||||
function getBoardIdForCard(cardId) {
|
||||
const r = db.prepare(`SELECT l.board_id FROM cards c JOIN lists l ON l.id = c.list_id WHERE c.id = ?`).get(cardId);
|
||||
return r?.board_id;
|
||||
}
|
||||
|
||||
async function notifyCard({ cardId, userId, action, comment }) {
|
||||
const card = db.prepare(`
|
||||
SELECT c.*, l.board_id, b.title as board_title,
|
||||
u.name as user_name, u2.email as assignee_email, u2.name as assignee_name
|
||||
FROM cards c
|
||||
JOIN lists l ON l.id = c.list_id
|
||||
JOIN boards b ON b.id = l.board_id
|
||||
LEFT JOIN users u ON u.id = ?
|
||||
LEFT JOIN users u2 ON u2.id = c.assigned_to
|
||||
WHERE c.id = ?
|
||||
`).get(userId, cardId);
|
||||
if (!card || !card.assignee_email) return;
|
||||
if (card.assigned_to === userId) return;
|
||||
const config = getEmailConfig();
|
||||
if (!config) return;
|
||||
const token = uuidv4();
|
||||
db.prepare('INSERT INTO email_tokens (card_id, user_id, token, expires_at) VALUES (?, ?, ?, datetime("now", "+30 days"))')
|
||||
.run(cardId, card.assigned_to, token);
|
||||
db.prepare(`INSERT INTO notifications (user_id, type, title, message, card_id, board_id) VALUES (?, ?, ?, ?, ?, ?)`)
|
||||
.run(card.assigned_to, action, card.title, `${card.user_name} ${action}`, cardId, card.board_id);
|
||||
try {
|
||||
await sendMail({
|
||||
to: card.assignee_email,
|
||||
subject: `[TeamFlow] ${card.user_name} ${action} — ${card.title}`,
|
||||
html: buildCardNotificationHtml({
|
||||
userName: card.user_name,
|
||||
boardTitle: card.board_title,
|
||||
cardTitle: card.title,
|
||||
action,
|
||||
cardUrl: buildCardUrl(cardId, card.board_id),
|
||||
comment,
|
||||
}),
|
||||
replyTo: config.email,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Lists
|
||||
router.post('/:boardId/lists', (req, res) => {
|
||||
const { title, position } = req.body;
|
||||
if (!title) return res.status(400).json({ error: 'Title required' });
|
||||
const maxPos = db.prepare('SELECT MAX(position) as p FROM lists WHERE board_id = ?').get(req.params.boardId);
|
||||
const result = db.prepare('INSERT INTO lists (board_id, title, position) VALUES (?, ?, ?)')
|
||||
.run(req.params.boardId, title, position ?? (maxPos.p || 0) + 65536);
|
||||
const list = db.prepare('SELECT * FROM lists WHERE id = ?').get(result.lastInsertRowid);
|
||||
res.json(list);
|
||||
});
|
||||
|
||||
router.put('/lists/:id/reorder', (req, res) => {
|
||||
const { position } = req.body;
|
||||
db.prepare('UPDATE lists SET position = ? WHERE id = ?').run(position, req.params.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.put('/lists/:id', (req, res) => {
|
||||
const { title, is_archived } = req.body;
|
||||
if (title !== undefined) db.prepare('UPDATE lists SET title = ? WHERE id = ?').run(title, req.params.id);
|
||||
if (is_archived !== undefined) db.prepare('UPDATE lists SET is_archived = ? WHERE id = ?').run(is_archived ? 1 : 0, req.params.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Cards
|
||||
router.post('/lists/:listId/cards', async (req, res) => {
|
||||
const { title, description, due_date, priority, assigned_to, labels } = req.body;
|
||||
if (!title) return res.status(400).json({ error: 'Title required' });
|
||||
const maxPos = db.prepare('SELECT MAX(position) as p FROM cards WHERE list_id = ?').get(req.params.listId);
|
||||
const result = db.prepare('INSERT INTO cards (list_id, title, description, position, due_date, priority, assigned_to, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(req.params.listId, title, description || '', (maxPos.p || 0) + 65536, due_date || null, priority || 'none', assigned_to || null, req.user.id);
|
||||
if (labels?.length) {
|
||||
const ins = db.prepare('INSERT OR IGNORE INTO card_labels (card_id, label_id) VALUES (?, ?)');
|
||||
labels.forEach(lid => ins.run(result.lastInsertRowid, lid));
|
||||
}
|
||||
db.prepare('INSERT INTO card_activity (card_id, user_id, action) VALUES (?, ?, ?)').run(result.lastInsertRowid, req.user.id, 'created');
|
||||
const card = db.prepare('SELECT c.*, u.name as creator_name FROM cards c LEFT JOIN users u ON u.id = c.created_by WHERE c.id = ?').get(result.lastInsertRowid);
|
||||
if (assigned_to) notifyCard({ cardId: result.lastInsertRowid, userId: req.user.id, action: 'assigned you to a card' });
|
||||
res.json(card);
|
||||
});
|
||||
|
||||
router.get('/cards/:id', (req, res) => {
|
||||
if (!canAccessCard(req.user.id, req.params.id)) return res.status(403).json({ error: 'Access denied' });
|
||||
const card = db.prepare(`
|
||||
SELECT c.*, u1.name as creator_name, u2.name as assignee_name, u2.avatar_color as assignee_color
|
||||
FROM cards c
|
||||
LEFT JOIN users u1 ON u1.id = c.created_by
|
||||
LEFT JOIN users u2 ON u2.id = c.assigned_to
|
||||
WHERE c.id = ?
|
||||
`).get(req.params.id);
|
||||
const labels = db.prepare(`SELECT l.* FROM card_labels cl JOIN labels l ON l.id = cl.label_id WHERE cl.card_id = ?`).all(req.params.id);
|
||||
const comments = db.prepare(`
|
||||
SELECT cc.*, u.name, u.avatar_color FROM card_comments cc
|
||||
LEFT JOIN users u ON u.id = cc.user_id WHERE cc.card_id = ? ORDER BY cc.created_at
|
||||
`).all(req.params.id);
|
||||
const activity = db.prepare(`
|
||||
SELECT ca.*, u.name, u.avatar_color FROM card_activity ca
|
||||
LEFT JOIN users u ON u.id = ca.user_id WHERE ca.card_id = ? ORDER BY ca.created_at DESC LIMIT 50
|
||||
`).all(req.params.id);
|
||||
const checklists = db.prepare(`
|
||||
SELECT cl.*,
|
||||
(SELECT COUNT(*) FROM checklist_items WHERE checklist_id = cl.id) as total_items,
|
||||
(SELECT COUNT(*) FROM checklist_items WHERE checklist_id = cl.id AND is_checked = 1) as done_items
|
||||
FROM checklists cl WHERE cl.card_id = ? ORDER BY cl.position
|
||||
`).all(req.params.id);
|
||||
const checklistItems = {};
|
||||
for (const cl of checklists) {
|
||||
checklistItems[cl.id] = db.prepare('SELECT * FROM checklist_items WHERE checklist_id = ? ORDER BY position').all(cl.id);
|
||||
}
|
||||
res.json({ ...card, labels, comments, activity, checklists, checklistItems });
|
||||
});
|
||||
|
||||
router.put('/cards/:id', async (req, res) => {
|
||||
if (!canAccessCard(req.user.id, req.params.id)) return res.status(403).json({ error: 'Access denied' });
|
||||
const { title, description, due_date, priority, color, assigned_to, list_id, position, estimated_hours, time_spent } = req.body;
|
||||
const old = db.prepare('SELECT * FROM cards WHERE id = ?').get(req.params.id);
|
||||
if (!old) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
const updates = [];
|
||||
const params = [];
|
||||
if (title !== undefined) { updates.push('title = ?'); params.push(title); }
|
||||
if (description !== undefined) { updates.push('description = ?'); params.push(description); }
|
||||
if (due_date !== undefined) { updates.push('due_date = ?'); params.push(due_date); }
|
||||
if (priority !== undefined) { updates.push('priority = ?'); params.push(priority); }
|
||||
if (color !== undefined) { updates.push('color = ?'); params.push(color); }
|
||||
if (assigned_to !== undefined) { updates.push('assigned_to = ?'); params.push(assigned_to); }
|
||||
if (list_id !== undefined) { updates.push('list_id = ?'); params.push(list_id); }
|
||||
if (position !== undefined) { updates.push('position = ?'); params.push(position); }
|
||||
if (estimated_hours !== undefined) { updates.push('estimated_hours = ?'); params.push(estimated_hours); }
|
||||
if (time_spent !== undefined) { updates.push('time_spent = ?'); params.push(time_spent); }
|
||||
updates.push("updated_at = datetime('now')");
|
||||
params.push(req.params.id);
|
||||
|
||||
if (updates.length > 1) {
|
||||
db.prepare(`UPDATE cards SET ${updates.join(', ')} WHERE id = ?`).run(...params);
|
||||
}
|
||||
|
||||
if (assigned_to && assigned_to !== old.assigned_to) {
|
||||
db.prepare('INSERT INTO card_activity (card_id, user_id, action, details) VALUES (?, ?, ?, ?)')
|
||||
.run(req.params.id, req.user.id, 'assigned', `Assigned to ${assigned_to}`);
|
||||
notifyCard({ cardId: req.params.id, userId: req.user.id, action: 'assigned you to a card' });
|
||||
}
|
||||
if (list_id && list_id !== old.list_id) {
|
||||
const newList = db.prepare('SELECT title FROM lists WHERE id = ?').get(list_id);
|
||||
db.prepare('INSERT INTO card_activity (card_id, user_id, action, details) VALUES (?, ?, ?, ?)')
|
||||
.run(req.params.id, req.user.id, 'moved', `Moved to ${newList?.title || 'Unknown'}`);
|
||||
const assignee = db.prepare('SELECT assigned_to FROM cards WHERE id = ?').get(req.params.id);
|
||||
if (assignee?.assigned_to && assignee.assigned_to !== req.user.id) {
|
||||
notifyCard({ cardId: req.params.id, userId: req.user.id, action: 'moved a card' });
|
||||
}
|
||||
}
|
||||
|
||||
const card = db.prepare('SELECT * FROM cards WHERE id = ?').get(req.params.id);
|
||||
res.json(card);
|
||||
});
|
||||
|
||||
router.delete('/cards/:id', (req, res) => {
|
||||
if (!canAccessCard(req.user.id, req.params.id)) return res.status(403).json({ error: 'Access denied' });
|
||||
db.prepare('DELETE FROM cards WHERE id = ?').run(req.params.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Card reorder (batch)
|
||||
router.put('/cards/reorder', (req, res) => {
|
||||
const { cards } = req.body;
|
||||
if (!Array.isArray(cards)) return res.status(400).json({ error: 'Cards array required' });
|
||||
const update = db.prepare('UPDATE cards SET list_id = ?, position = ? WHERE id = ?');
|
||||
const moveActivity = db.prepare('INSERT INTO card_activity (card_id, user_id, action, details) VALUES (?, ?, ?, ?)');
|
||||
for (const c of cards) {
|
||||
update.run(c.list_id, c.position, c.id);
|
||||
if (c.moved && c.old_list_id !== c.list_id) {
|
||||
const listName = db.prepare('SELECT title FROM lists WHERE id = ?').get(c.list_id)?.title || '';
|
||||
moveActivity.run(c.id, req.user.id, 'moved', `Moved to ${listName}`);
|
||||
}
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Comments
|
||||
router.post('/cards/:id/comments', async (req, res) => {
|
||||
if (!canAccessCard(req.user.id, req.params.id)) return res.status(403).json({ error: 'Access denied' });
|
||||
const { content } = req.body;
|
||||
if (!content) return res.status(400).json({ error: 'Content required' });
|
||||
const result = db.prepare('INSERT INTO card_comments (card_id, user_id, content) VALUES (?, ?, ?)').run(req.params.id, req.user.id, content);
|
||||
db.prepare('INSERT INTO card_activity (card_id, user_id, action, details) VALUES (?, ?, ?, ?)').run(req.params.id, req.user.id, 'commented', '');
|
||||
notifyCard({ cardId: req.params.id, userId: req.user.id, action: 'commented on a card', comment: content });
|
||||
const comment = db.prepare('SELECT cc.*, u.name, u.avatar_color FROM card_comments cc LEFT JOIN users u ON u.id = cc.user_id WHERE cc.id = ?').get(result.lastInsertRowid);
|
||||
res.json(comment);
|
||||
});
|
||||
|
||||
// Labels
|
||||
router.post('/:boardId/labels', (req, res) => {
|
||||
const { name, color } = req.body;
|
||||
if (!name || !color) return res.status(400).json({ error: 'Name and color required' });
|
||||
const result = db.prepare('INSERT INTO labels (board_id, name, color) VALUES (?, ?, ?)').run(req.params.boardId, name, color);
|
||||
const label = db.prepare('SELECT * FROM labels WHERE id = ?').get(result.lastInsertRowid);
|
||||
res.json(label);
|
||||
});
|
||||
|
||||
router.put('/labels/:id', (req, res) => {
|
||||
const { name, color } = req.body;
|
||||
if (name !== undefined) db.prepare('UPDATE labels SET name = ? WHERE id = ?').run(name, req.params.id);
|
||||
if (color !== undefined) db.prepare('UPDATE labels SET color = ? WHERE id = ?').run(color, req.params.id);
|
||||
const label = db.prepare('SELECT * FROM labels WHERE id = ?').get(req.params.id);
|
||||
res.json(label);
|
||||
});
|
||||
|
||||
router.delete('/labels/:id', (req, res) => {
|
||||
db.prepare('DELETE FROM labels WHERE id = ?').run(req.params.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.put('/cards/:id/labels', (req, res) => {
|
||||
if (!canAccessCard(req.user.id, req.params.id)) return res.status(403).json({ error: 'Access denied' });
|
||||
const { labels } = req.body;
|
||||
db.prepare('DELETE FROM card_labels WHERE card_id = ?').run(req.params.id);
|
||||
if (labels?.length) {
|
||||
const ins = db.prepare('INSERT OR IGNORE INTO card_labels (card_id, label_id) VALUES (?, ?)');
|
||||
labels.forEach(lid => ins.run(req.params.id, lid));
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Checklists
|
||||
router.post('/cards/:id/checklists', (req, res) => {
|
||||
if (!canAccessCard(req.user.id, req.params.id)) return res.status(403).json({ error: 'Access denied' });
|
||||
const { title } = req.body;
|
||||
const maxPos = db.prepare('SELECT MAX(position) as p FROM checklists WHERE card_id = ?').get(req.params.id);
|
||||
const result = db.prepare('INSERT INTO checklists (card_id, title, position) VALUES (?, ?, ?)')
|
||||
.run(req.params.id, title || 'Checklist', (maxPos?.p || 0) + 65536);
|
||||
const cl = db.prepare('SELECT * FROM checklists WHERE id = ?').get(result.lastInsertRowid);
|
||||
res.json({ ...cl, items: [] });
|
||||
});
|
||||
|
||||
router.post('/checklists/:id/items', (req, res) => {
|
||||
const checklist = db.prepare('SELECT * FROM checklists WHERE id = ?').get(req.params.id);
|
||||
if (!checklist) return res.status(404).json({ error: 'Not found' });
|
||||
if (!canAccessCard(req.user.id, checklist.card_id)) return res.status(403).json({ error: 'Access denied' });
|
||||
const { text } = req.body;
|
||||
if (!text) return res.status(400).json({ error: 'Text required' });
|
||||
const maxPos = db.prepare('SELECT MAX(position) as p FROM checklist_items WHERE checklist_id = ?').get(req.params.id);
|
||||
const result = db.prepare('INSERT INTO checklist_items (checklist_id, text, position) VALUES (?, ?, ?)')
|
||||
.run(req.params.id, text, (maxPos?.p || 0) + 65536);
|
||||
const item = db.prepare('SELECT * FROM checklist_items WHERE id = ?').get(result.lastInsertRowid);
|
||||
res.json(item);
|
||||
});
|
||||
|
||||
router.put('/checklist-items/:id', (req, res) => {
|
||||
const item = db.prepare('SELECT ci.*, cl.card_id FROM checklist_items ci JOIN checklists cl ON cl.id = ci.checklist_id WHERE ci.id = ?').get(req.params.id);
|
||||
if (!item) return res.status(404).json({ error: 'Not found' });
|
||||
if (!canAccessCard(req.user.id, item.card_id)) return res.status(403).json({ error: 'Access denied' });
|
||||
const { text, is_checked } = req.body;
|
||||
if (text !== undefined) db.prepare('UPDATE checklist_items SET text = ? WHERE id = ?').run(text, req.params.id);
|
||||
if (is_checked !== undefined) db.prepare('UPDATE checklist_items SET is_checked = ? WHERE id = ?').run(is_checked ? 1 : 0, req.params.id);
|
||||
const updated = db.prepare('SELECT * FROM checklist_items WHERE id = ?').get(req.params.id);
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
router.delete('/checklist-items/:id', (req, res) => {
|
||||
const item = db.prepare('SELECT ci.*, cl.card_id FROM checklist_items ci JOIN checklists cl ON cl.id = ci.checklist_id WHERE ci.id = ?').get(req.params.id);
|
||||
if (!item) return res.status(404).json({ error: 'Not found' });
|
||||
if (!canAccessCard(req.user.id, item.card_id)) return res.status(403).json({ error: 'Access denied' });
|
||||
db.prepare('DELETE FROM checklist_items WHERE id = ?').run(req.params.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.delete('/checklists/:id', (req, res) => {
|
||||
const cl = db.prepare('SELECT * FROM checklists WHERE id = ?').get(req.params.id);
|
||||
if (!cl) return res.status(404).json({ error: 'Not found' });
|
||||
if (!canAccessCard(req.user.id, cl.card_id)) return res.status(403).json({ error: 'Access denied' });
|
||||
db.prepare('DELETE FROM checklists WHERE id = ?').run(req.params.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
37
server/routes/email.js
Normal file
37
server/routes/email.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../db.js';
|
||||
import { authMiddleware, adminOnly } from '../middleware/auth.js';
|
||||
import { getEmailConfig } from '../email/transporter.js';
|
||||
import { pollInbox } from '../email/imap.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(authMiddleware);
|
||||
|
||||
router.get('/config', adminOnly, (req, res) => {
|
||||
const config = getEmailConfig();
|
||||
if (!config) return res.status(404).json({ error: 'Not configured' });
|
||||
res.json({ ...config, app_password: '••••••••' });
|
||||
});
|
||||
|
||||
router.get('/log', adminOnly, (req, res) => {
|
||||
const logs = db.prepare('SELECT * FROM email_log ORDER BY created_at DESC LIMIT 100').all();
|
||||
res.json(logs);
|
||||
});
|
||||
|
||||
router.post('/poll', adminOnly, async (req, res) => {
|
||||
try {
|
||||
await pollInbox();
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/stats', adminOnly, (req, res) => {
|
||||
const sent = db.prepare("SELECT COUNT(*) as c FROM email_log WHERE direction = 'sent'").get().c;
|
||||
const received = db.prepare("SELECT COUNT(*) as c FROM email_log WHERE direction = 'received'").get().c;
|
||||
const failed = db.prepare("SELECT COUNT(*) as c FROM email_log WHERE status = 'failed'").get().c;
|
||||
res.json({ sent, received, failed });
|
||||
});
|
||||
|
||||
export default router;
|
||||
34
server/routes/notifications.js
Normal file
34
server/routes/notifications.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../db.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(authMiddleware);
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const notifs = db.prepare(`
|
||||
SELECT n.* FROM notifications n
|
||||
WHERE n.user_id = ?
|
||||
ORDER BY n.is_read ASC, n.created_at DESC
|
||||
LIMIT 50
|
||||
`).all(req.user.id);
|
||||
const unread = db.prepare('SELECT COUNT(*) as c FROM notifications WHERE user_id = ? AND is_read = 0').get(req.user.id).c;
|
||||
res.json({ notifications: notifs, unread });
|
||||
});
|
||||
|
||||
router.put('/:id/read', (req, res) => {
|
||||
db.prepare('UPDATE notifications SET is_read = 1 WHERE id = ? AND user_id = ?').run(req.params.id, req.user.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.put('/read-all', (req, res) => {
|
||||
db.prepare('UPDATE notifications SET is_read = 1 WHERE user_id = ?').run(req.user.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
db.prepare('DELETE FROM notifications WHERE id = ? AND user_id = ?').run(req.params.id, req.user.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
83
server/routes/setup.js
Normal file
83
server/routes/setup.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import db from '../db.js';
|
||||
import { generateToken } from '../middleware/auth.js';
|
||||
import { testConnection, initTransporter } from '../email/transporter.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/status', (req, res) => {
|
||||
const hasEmail = !!db.prepare('SELECT 1 FROM email_config WHERE id = 1').get();
|
||||
const userCount = db.prepare('SELECT COUNT(*) as c FROM users').get().c;
|
||||
const setupComplete = hasEmail && userCount > 0;
|
||||
res.json({ hasEmail, userCount, setupComplete });
|
||||
});
|
||||
|
||||
router.post('/email', async (req, res) => {
|
||||
try {
|
||||
const { smtp_host, smtp_port, email, app_password } = req.body;
|
||||
if (!email || !app_password) return res.status(400).json({ error: 'Email and app password required' });
|
||||
const host = smtp_host || 'smtp.gmail.com';
|
||||
const port = parseInt(smtp_port) || 587;
|
||||
await testConnection(host, port, email, app_password);
|
||||
db.prepare(`INSERT OR REPLACE INTO email_config (id, smtp_host, smtp_port, email, app_password)
|
||||
VALUES (1, ?, ?, ?, ?)`).run(host, port, email, app_password);
|
||||
await initTransporter();
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/email/test', async (req, res) => {
|
||||
try {
|
||||
const { to } = req.body;
|
||||
const config = db.prepare('SELECT * FROM email_config WHERE id = 1').get();
|
||||
if (!config) return res.status(400).json({ error: 'Email not configured' });
|
||||
const { sendMail } = await import('../email/transporter.js');
|
||||
await sendMail({
|
||||
to,
|
||||
subject: 'TeamFlow — Test Email',
|
||||
html: `<div style="padding:32px;font-family:sans-serif">
|
||||
<h2 style="color:#6366f1">✅ TeamFlow Email Working!</h2>
|
||||
<p>Your email integration is configured correctly.</p>
|
||||
</div>`,
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/admin', async (req, res) => {
|
||||
const hasEmail = !!db.prepare('SELECT 1 FROM email_config WHERE id = 1').get();
|
||||
if (!hasEmail) return res.status(400).json({ error: 'Configure email first' });
|
||||
const existing = db.prepare('SELECT COUNT(*) as c FROM users').get();
|
||||
if (existing.c > 0) return res.status(400).json({ error: 'Admin already exists' });
|
||||
const { email, name, password } = req.body;
|
||||
if (!email || !name || !password) return res.status(400).json({ error: 'All fields required' });
|
||||
if (password.length < 6) return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
const result = db.prepare('INSERT INTO users (email, name, password, role) VALUES (?, ?, ?, ?)').run(email, name, hash);
|
||||
const user = db.prepare('SELECT id, email, name, avatar_color, role FROM users WHERE id = ?').get(result.lastInsertRowid);
|
||||
const token = generateToken(user);
|
||||
res.json({ user, token });
|
||||
});
|
||||
|
||||
router.put('/email/inbound', async (req, res) => {
|
||||
const config = db.prepare('SELECT * FROM email_config WHERE id = 1').get();
|
||||
if (!config) return res.status(400).json({ error: 'Email not configured' });
|
||||
const { enabled, folder, prefix } = req.body;
|
||||
db.prepare(`UPDATE email_config SET inbound_enabled = ?, inbound_folder = ?, board_email_prefix = ? WHERE id = 1`)
|
||||
.run(enabled ? 1 : 0, folder || 'INBOX', prefix || 'tf-');
|
||||
if (enabled) {
|
||||
const { startImapPolling } = await import('../email/imap.js');
|
||||
startImapPolling();
|
||||
} else {
|
||||
const { stopImapPolling } = await import('../email/imap.js');
|
||||
stopImapPolling();
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
77
server/routes/users.js
Normal file
77
server/routes/users.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import db from '../db.js';
|
||||
import { authMiddleware, adminOnly, generateToken } from '../middleware/auth.js';
|
||||
import { sendMail, buildCardNotificationHtml, buildCardUrl } from '../email/transporter.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
|
||||
const users = db.prepare('SELECT id, email, name, avatar_color, role, is_active, created_at FROM users ORDER BY created_at').all();
|
||||
res.json(users);
|
||||
});
|
||||
|
||||
router.post('/', adminOnly, async (req, res) => {
|
||||
const { email, name, password, role } = req.body;
|
||||
if (!email || !name || !password) return res.status(400).json({ error: 'All fields required' });
|
||||
if (password.length < 6) return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
|
||||
if (existing) return res.status(400).json({ error: 'Email already exists' });
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
const colors = ['#ef4444','#f97316','#eab308','#22c55e','#14b8a6','#3b82f6','#6366f1','#a855f7','#ec4899'];
|
||||
const color = colors[Math.floor(Math.random() * colors.length)];
|
||||
const result = db.prepare('INSERT INTO users (email, name, password, role, avatar_color) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(email, name, hash, role || 'member', color);
|
||||
const user = db.prepare('SELECT id, email, name, avatar_color, role FROM users WHERE id = ?').get(result.lastInsertRowid);
|
||||
try {
|
||||
await sendMail({
|
||||
to: email,
|
||||
subject: 'Welcome to TeamFlow! 🚀',
|
||||
html: `<div style="padding:32px;font-family:sans-serif;max-width:480px;margin:0 auto">
|
||||
<h2 style="color:#6366f1">Welcome to TeamFlow, ${name}!</h2>
|
||||
<p>Your account has been created. Log in to get started.</p>
|
||||
<p><strong>Email:</strong> ${email}</p>
|
||||
<p><strong>Password:</strong> (set by your admin)</p>
|
||||
<a href="${process.env.APP_URL || 'http://localhost:5173'}" style="display:inline-block;background:#6366f1;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;margin-top:16px">Go to TeamFlow</a>
|
||||
</div>`,
|
||||
});
|
||||
} catch {}
|
||||
res.json(user);
|
||||
});
|
||||
|
||||
router.put('/:id', adminOnly, (req, res) => {
|
||||
const { name, role, is_active } = req.body;
|
||||
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
if (user.id === req.user.id) return res.status(400).json({ error: 'Cannot modify yourself' });
|
||||
if (name !== undefined) db.prepare('UPDATE users SET name = ? WHERE id = ?').run(name, user.id);
|
||||
if (role !== undefined) db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, user.id);
|
||||
if (is_active !== undefined) db.prepare('UPDATE users SET is_active = ? WHERE id = ?').run(is_active ? 1 : 0, user.id);
|
||||
const updated = db.prepare('SELECT id, email, name, avatar_color, role, is_active FROM users WHERE id = ?').get(user.id);
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
router.put('/:id/reset-password', adminOnly, async (req, res) => {
|
||||
const { password } = req.body;
|
||||
if (!password || password.length < 6) return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
const user = db.prepare('SELECT id, email, name FROM users WHERE id = ?').get(req.params.id);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
db.prepare('UPDATE users SET password = ? WHERE id = ?').run(hash, user.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.get('/board/:boardId', (req, res) => {
|
||||
const members = db.prepare(`
|
||||
SELECT u.id, u.email, u.name, u.avatar_color, u.role, bm.role as board_role
|
||||
FROM board_members bm
|
||||
JOIN users u ON u.id = bm.user_id
|
||||
WHERE bm.board_id = ?
|
||||
`).all(req.params.boardId);
|
||||
res.json(members);
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user