feat: MySQL integration, Docker setup, drag-and-drop kanban
This commit is contained in:
12
server/Dockerfile
Normal file
12
server/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["node", "index.js"]
|
||||
94
server/db.js
Normal file
94
server/db.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '3306'),
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || 'scrumpass',
|
||||
database: process.env.DB_NAME || 'scrum_manager',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
});
|
||||
|
||||
export async function initDB() {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(50) NOT NULL DEFAULT 'employee',
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
color VARCHAR(20) DEFAULT '#818cf8',
|
||||
avatar VARCHAR(10) DEFAULT '',
|
||||
dept VARCHAR(100) DEFAULT ''
|
||||
)
|
||||
`);
|
||||
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
description TEXT,
|
||||
status ENUM('todo','inprogress','review','done') NOT NULL DEFAULT 'todo',
|
||||
priority ENUM('critical','high','medium','low') NOT NULL DEFAULT 'medium',
|
||||
assignee_id VARCHAR(36),
|
||||
reporter_id VARCHAR(36),
|
||||
due_date DATE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (assignee_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (reporter_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS subtasks (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
task_id VARCHAR(36) NOT NULL,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
done BOOLEAN DEFAULT FALSE,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
task_id VARCHAR(36) NOT NULL,
|
||||
user_id VARCHAR(36),
|
||||
text TEXT NOT NULL,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS activities (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
task_id VARCHAR(36) NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS task_tags (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
task_id VARCHAR(36) NOT NULL,
|
||||
tag VARCHAR(100) NOT NULL,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY unique_task_tag (task_id, tag)
|
||||
)
|
||||
`);
|
||||
|
||||
console.log('✅ Database tables initialized');
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
export default pool;
|
||||
35
server/index.js
Normal file
35
server/index.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { initDB } from './db.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import taskRoutes from './routes/tasks.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/tasks', taskRoutes);
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Initialize DB and start server
|
||||
async function start() {
|
||||
try {
|
||||
await initDB();
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 Backend server running on port ${PORT}`);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('❌ Failed to start server:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
start();
|
||||
15
server/package.json
Normal file
15
server/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "scrum-manager-backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"mysql2": "^3.14.1",
|
||||
"cors": "^2.8.5",
|
||||
"bcryptjs": "^3.0.2"
|
||||
}
|
||||
}
|
||||
79
server/routes/auth.js
Normal file
79
server/routes/auth.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import pool from '../db.js';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// POST /api/auth/login
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: 'Email and password required' });
|
||||
}
|
||||
|
||||
const [rows] = await pool.query('SELECT * FROM users WHERE email = ?', [email]);
|
||||
if (rows.length === 0) {
|
||||
return res.status(401).json({ error: 'Invalid email or password' });
|
||||
}
|
||||
|
||||
const user = rows[0];
|
||||
const valid = await bcrypt.compare(password, user.password_hash);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: 'Invalid email or password' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: user.id, name: user.name, role: user.role, email: user.email,
|
||||
color: user.color, avatar: user.avatar, dept: user.dept,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Login error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/register
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
const { name, email, password, role, dept } = req.body;
|
||||
if (!name || !email || !password) {
|
||||
return res.status(400).json({ error: 'Name, email and password required' });
|
||||
}
|
||||
|
||||
const [existing] = await pool.query('SELECT id FROM users WHERE email = ?', [email]);
|
||||
if (existing.length > 0) {
|
||||
return res.status(409).json({ error: 'Email already registered' });
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const password_hash = await bcrypt.hash(password, 10);
|
||||
const avatar = name.split(' ').map(w => w[0]).join('').substring(0, 2).toUpperCase();
|
||||
const colors = ['#818cf8', '#f59e0b', '#34d399', '#f472b6', '#fb923c', '#60a5fa', '#a78bfa'];
|
||||
const color = colors[Math.floor(Math.random() * colors.length)];
|
||||
|
||||
await pool.query(
|
||||
'INSERT INTO users (id, name, role, email, password_hash, color, avatar, dept) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, name, role || 'employee', email, password_hash, color, avatar, dept || '']
|
||||
);
|
||||
|
||||
res.status(201).json({ id, name, role: role || 'employee', email, color, avatar, dept: dept || '' });
|
||||
} catch (err) {
|
||||
console.error('Register error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/auth/users
|
||||
router.get('/users', async (_req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT id, name, role, email, color, avatar, dept FROM users');
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error('Get users error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
202
server/routes/tasks.js
Normal file
202
server/routes/tasks.js
Normal file
@@ -0,0 +1,202 @@
|
||||
import { Router } from 'express';
|
||||
import pool from '../db.js';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Helper: fetch full task with subtasks, comments, activities, tags
|
||||
async function getFullTask(taskId) {
|
||||
const [taskRows] = await pool.query('SELECT * FROM tasks WHERE id = ?', [taskId]);
|
||||
if (taskRows.length === 0) return null;
|
||||
|
||||
const task = taskRows[0];
|
||||
const [subtasks] = await pool.query('SELECT id, title, done FROM subtasks WHERE task_id = ? ORDER BY id', [taskId]);
|
||||
const [comments] = await pool.query('SELECT id, user_id AS userId, text, timestamp FROM comments WHERE task_id = ? ORDER BY timestamp', [taskId]);
|
||||
const [activities] = await pool.query('SELECT id, text, timestamp FROM activities WHERE task_id = ? ORDER BY timestamp', [taskId]);
|
||||
const [tagRows] = await pool.query('SELECT tag FROM task_tags WHERE task_id = ?', [taskId]);
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
description: task.description || '',
|
||||
status: task.status,
|
||||
priority: task.priority,
|
||||
assignee: task.assignee_id || '',
|
||||
reporter: task.reporter_id || '',
|
||||
dueDate: task.due_date ? task.due_date.toISOString().split('T')[0] : '',
|
||||
tags: tagRows.map(r => r.tag),
|
||||
subtasks: subtasks.map(s => ({ id: s.id, title: s.title, done: !!s.done })),
|
||||
comments: comments.map(c => ({ id: c.id, userId: c.userId, text: c.text, timestamp: c.timestamp?.toISOString() || '' })),
|
||||
activity: activities.map(a => ({ id: a.id, text: a.text, timestamp: a.timestamp?.toISOString() || '' })),
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/tasks
|
||||
router.get('/', async (_req, res) => {
|
||||
try {
|
||||
const [taskRows] = await pool.query('SELECT id FROM tasks ORDER BY created_at DESC');
|
||||
const tasks = await Promise.all(taskRows.map(t => getFullTask(t.id)));
|
||||
res.json(tasks.filter(Boolean));
|
||||
} catch (err) {
|
||||
console.error('Get tasks error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/tasks
|
||||
router.post('/', async (req, res) => {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
const { title, description, status, priority, assignee, reporter, dueDate, tags, subtasks } = req.body;
|
||||
const id = randomUUID();
|
||||
|
||||
await conn.query(
|
||||
'INSERT INTO tasks (id, title, description, status, priority, assignee_id, reporter_id, due_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, title, description || '', status || 'todo', priority || 'medium', assignee || null, reporter || null, dueDate || null]
|
||||
);
|
||||
|
||||
// Insert tags
|
||||
if (tags && tags.length > 0) {
|
||||
const tagValues = tags.map(tag => [id, tag]);
|
||||
await conn.query('INSERT INTO task_tags (task_id, tag) VALUES ?', [tagValues]);
|
||||
}
|
||||
|
||||
// Insert subtasks
|
||||
if (subtasks && subtasks.length > 0) {
|
||||
for (const st of subtasks) {
|
||||
await conn.query('INSERT INTO subtasks (id, task_id, title, done) VALUES (?, ?, ?, ?)',
|
||||
[st.id || randomUUID(), id, st.title, st.done || false]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add creation activity
|
||||
const actId = randomUUID();
|
||||
await conn.query('INSERT INTO activities (id, task_id, text) VALUES (?, ?, ?)',
|
||||
[actId, id, '📝 Task created']);
|
||||
|
||||
await conn.commit();
|
||||
const task = await getFullTask(id);
|
||||
res.status(201).json(task);
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
console.error('Create task error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/tasks/:id
|
||||
router.put('/:id', async (req, res) => {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
const { title, description, status, priority, assignee, reporter, dueDate, tags } = req.body;
|
||||
const taskId = req.params.id;
|
||||
|
||||
// Check task exists
|
||||
const [existing] = await conn.query('SELECT id FROM tasks WHERE id = ?', [taskId]);
|
||||
if (existing.length === 0) {
|
||||
await conn.rollback();
|
||||
return res.status(404).json({ error: 'Task not found' });
|
||||
}
|
||||
|
||||
await conn.query(
|
||||
`UPDATE tasks SET title = COALESCE(?, title), description = COALESCE(?, description),
|
||||
status = COALESCE(?, status), priority = COALESCE(?, priority),
|
||||
assignee_id = COALESCE(?, assignee_id), reporter_id = COALESCE(?, reporter_id),
|
||||
due_date = COALESCE(?, due_date) WHERE id = ?`,
|
||||
[title, description, status, priority, assignee, reporter, dueDate, taskId]
|
||||
);
|
||||
|
||||
// Update tags if provided
|
||||
if (tags !== undefined) {
|
||||
await conn.query('DELETE FROM task_tags WHERE task_id = ?', [taskId]);
|
||||
if (tags.length > 0) {
|
||||
const tagValues = tags.map(tag => [taskId, tag]);
|
||||
await conn.query('INSERT INTO task_tags (task_id, tag) VALUES ?', [tagValues]);
|
||||
}
|
||||
}
|
||||
|
||||
await conn.commit();
|
||||
const task = await getFullTask(taskId);
|
||||
res.json(task);
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
console.error('Update task error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/tasks/:id/subtasks
|
||||
router.post('/:id/subtasks', async (req, res) => {
|
||||
try {
|
||||
const { title } = req.body;
|
||||
const id = randomUUID();
|
||||
await pool.query('INSERT INTO subtasks (id, task_id, title, done) VALUES (?, ?, ?, ?)',
|
||||
[id, req.params.id, title, false]);
|
||||
res.status(201).json({ id, title, done: false });
|
||||
} catch (err) {
|
||||
console.error('Add subtask error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/tasks/:id/subtasks/:sid
|
||||
router.put('/:id/subtasks/:sid', async (req, res) => {
|
||||
try {
|
||||
const { done } = req.body;
|
||||
await pool.query('UPDATE subtasks SET done = ? WHERE id = ? AND task_id = ?',
|
||||
[done, req.params.sid, req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Toggle subtask error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/tasks/:id/comments
|
||||
router.post('/:id/comments', async (req, res) => {
|
||||
try {
|
||||
const { userId, text } = req.body;
|
||||
const id = randomUUID();
|
||||
const timestamp = new Date();
|
||||
await pool.query('INSERT INTO comments (id, task_id, user_id, text, timestamp) VALUES (?, ?, ?, ?, ?)',
|
||||
[id, req.params.id, userId, text, timestamp]);
|
||||
res.status(201).json({ id, userId, text, timestamp: timestamp.toISOString() });
|
||||
} catch (err) {
|
||||
console.error('Add comment error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/tasks/:id/activity
|
||||
router.post('/:id/activity', async (req, res) => {
|
||||
try {
|
||||
const { text } = req.body;
|
||||
const id = randomUUID();
|
||||
const timestamp = new Date();
|
||||
await pool.query('INSERT INTO activities (id, task_id, text, timestamp) VALUES (?, ?, ?, ?)',
|
||||
[id, req.params.id, text, timestamp]);
|
||||
res.status(201).json({ id, text, timestamp: timestamp.toISOString() });
|
||||
} catch (err) {
|
||||
console.error('Add activity error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/tasks/:id
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
await pool.query('DELETE FROM tasks WHERE id = ?', [req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Delete task error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user