feat: MySQL integration, Docker setup, drag-and-drop kanban

This commit is contained in:
tusuii
2026-02-16 10:20:27 +05:30
parent 5d8af1f173
commit 892a2ceba1
24 changed files with 919 additions and 196 deletions

12
server/Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
View 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;