feat: add more roles (tech_lead, scrum_master, product_owner, designer, qa)
- Registration form: added 5 new role options to dropdown - Sidebar: new roles get proper nav access via ALL_ROLES/LEADER_ROLES - Dashboard: isLeader check expanded to include new leadership roles - Shared/Pages: role badge colors added for all new roles - Invite modal: expanded role dropdown
This commit is contained in:
@@ -2,8 +2,8 @@ FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
53
server/db.js
53
server/db.js
@@ -1,20 +1,20 @@
|
||||
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,
|
||||
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(`
|
||||
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,
|
||||
@@ -27,7 +27,7 @@ export async function initDB() {
|
||||
)
|
||||
`);
|
||||
|
||||
await conn.query(`
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
@@ -43,7 +43,7 @@ export async function initDB() {
|
||||
)
|
||||
`);
|
||||
|
||||
await conn.query(`
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS subtasks (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
task_id VARCHAR(36) NOT NULL,
|
||||
@@ -53,7 +53,7 @@ export async function initDB() {
|
||||
)
|
||||
`);
|
||||
|
||||
await conn.query(`
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
task_id VARCHAR(36) NOT NULL,
|
||||
@@ -65,7 +65,7 @@ export async function initDB() {
|
||||
)
|
||||
`);
|
||||
|
||||
await conn.query(`
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS activities (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
task_id VARCHAR(36) NOT NULL,
|
||||
@@ -75,7 +75,7 @@ export async function initDB() {
|
||||
)
|
||||
`);
|
||||
|
||||
await conn.query(`
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS task_tags (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
task_id VARCHAR(36) NOT NULL,
|
||||
@@ -85,10 +85,23 @@ export async function initDB() {
|
||||
)
|
||||
`);
|
||||
|
||||
console.log('✅ Database tables initialized');
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS dependencies (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
task_id VARCHAR(36) NOT NULL,
|
||||
depends_on_user_id VARCHAR(36),
|
||||
description TEXT NOT NULL,
|
||||
resolved BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (depends_on_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
|
||||
console.log('✅ Database tables initialized');
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
export default pool;
|
||||
|
||||
@@ -19,17 +19,24 @@ app.get('/api/health', (_req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Initialize DB and start server
|
||||
// Initialize DB and start server
|
||||
async function start() {
|
||||
try {
|
||||
await initDB();
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 Backend server running on port ${PORT}`);
|
||||
});
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 Backend server running on port ${PORT}`);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('❌ Failed to start server:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
start();
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
start();
|
||||
}
|
||||
|
||||
export { app, start };
|
||||
|
||||
2481
server/package-lock.json
generated
Normal file
2481
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,12 +4,17 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
"start": "node index.js",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"mysql2": "^3.14.1",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"cors": "^2.8.5",
|
||||
"bcryptjs": "^3.0.2"
|
||||
"express": "^5.1.0",
|
||||
"mysql2": "^3.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"supertest": "^7.2.2",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { randomUUID } from 'crypto';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Helper: fetch full task with subtasks, comments, activities, tags
|
||||
// Helper: fetch full task with subtasks, comments, activities, tags, dependencies
|
||||
async function getFullTask(taskId) {
|
||||
const [taskRows] = await pool.query('SELECT * FROM tasks WHERE id = ?', [taskId]);
|
||||
if (taskRows.length === 0) return null;
|
||||
@@ -14,6 +14,7 @@ async function getFullTask(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]);
|
||||
const [depRows] = await pool.query('SELECT id, depends_on_user_id AS dependsOnUserId, description, resolved FROM dependencies WHERE task_id = ? ORDER BY created_at', [taskId]);
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
@@ -28,6 +29,7 @@ async function getFullTask(taskId) {
|
||||
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() || '' })),
|
||||
dependencies: depRows.map(d => ({ id: d.id, dependsOnUserId: d.dependsOnUserId || '', description: d.description, resolved: !!d.resolved })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,7 +50,7 @@ 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 { title, description, status, priority, assignee, reporter, dueDate, tags, subtasks, dependencies } = req.body;
|
||||
const id = randomUUID();
|
||||
|
||||
await conn.query(
|
||||
@@ -70,6 +72,16 @@ router.post('/', async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Insert dependencies
|
||||
if (dependencies && dependencies.length > 0) {
|
||||
for (const dep of dependencies) {
|
||||
await conn.query(
|
||||
'INSERT INTO dependencies (id, task_id, depends_on_user_id, description, resolved) VALUES (?, ?, ?, ?, ?)',
|
||||
[randomUUID(), id, dep.dependsOnUserId || null, dep.description, false]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add creation activity
|
||||
const actId = randomUUID();
|
||||
await conn.query('INSERT INTO activities (id, task_id, text) VALUES (?, ?, ?)',
|
||||
@@ -188,6 +200,49 @@ router.post('/:id/activity', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- DEPENDENCY ROUTES ---
|
||||
|
||||
// POST /api/tasks/:id/dependencies
|
||||
router.post('/:id/dependencies', async (req, res) => {
|
||||
try {
|
||||
const { dependsOnUserId, description } = req.body;
|
||||
const id = randomUUID();
|
||||
await pool.query(
|
||||
'INSERT INTO dependencies (id, task_id, depends_on_user_id, description, resolved) VALUES (?, ?, ?, ?, ?)',
|
||||
[id, req.params.id, dependsOnUserId || null, description, false]
|
||||
);
|
||||
res.status(201).json({ id, dependsOnUserId: dependsOnUserId || '', description, resolved: false });
|
||||
} catch (err) {
|
||||
console.error('Add dependency error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/tasks/:id/dependencies/:depId
|
||||
router.put('/:id/dependencies/:depId', async (req, res) => {
|
||||
try {
|
||||
const { resolved } = req.body;
|
||||
await pool.query('UPDATE dependencies SET resolved = ? WHERE id = ? AND task_id = ?',
|
||||
[resolved, req.params.depId, req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Update dependency error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/tasks/:id/dependencies/:depId
|
||||
router.delete('/:id/dependencies/:depId', async (req, res) => {
|
||||
try {
|
||||
await pool.query('DELETE FROM dependencies WHERE id = ? AND task_id = ?',
|
||||
[req.params.depId, req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Delete dependency error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/tasks/:id
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
|
||||
127
server/tests/auth.test.js
Normal file
127
server/tests/auth.test.js
Normal file
@@ -0,0 +1,127 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { app } from '../index.js';
|
||||
import pool from '../db.js';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../db.js', () => ({
|
||||
default: {
|
||||
query: vi.fn(),
|
||||
},
|
||||
initDB: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('bcryptjs', () => ({
|
||||
default: {
|
||||
compare: vi.fn(),
|
||||
hash: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
describe('Auth Routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('POST /api/auth/login', () => {
|
||||
it('logs in successfully with correct credentials', async () => {
|
||||
const mockUser = {
|
||||
id: 'u1',
|
||||
name: 'Test User',
|
||||
email: 'test@test.com',
|
||||
password_hash: 'hashed_password',
|
||||
role: 'employee',
|
||||
dept: 'dev'
|
||||
};
|
||||
|
||||
// Mock DB response
|
||||
pool.query.mockResolvedValue([[mockUser]]);
|
||||
// Mock bcrypt comparison
|
||||
bcrypt.compare.mockResolvedValue(true);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ email: 'test@test.com', password: 'password' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(expect.objectContaining({
|
||||
id: 'u1',
|
||||
email: 'test@test.com'
|
||||
}));
|
||||
expect(res.body).not.toHaveProperty('password_hash');
|
||||
});
|
||||
|
||||
it('returns 401 for invalid password', async () => {
|
||||
const mockUser = {
|
||||
id: 'u1',
|
||||
email: 'test@test.com',
|
||||
password_hash: 'hashed_password'
|
||||
};
|
||||
|
||||
pool.query.mockResolvedValue([[mockUser]]);
|
||||
bcrypt.compare.mockResolvedValue(false);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ email: 'test@test.com', password: 'wrong' });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: 'Invalid email or password' });
|
||||
});
|
||||
|
||||
it('returns 401 for user not found', async () => {
|
||||
pool.query.mockResolvedValue([[]]); // Empty array
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ email: 'notfound@test.com', password: 'password' });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/auth/register', () => {
|
||||
it('registers a new user successfully', async () => {
|
||||
pool.query.mockResolvedValueOnce([[]]); // Check existing email (empty)
|
||||
pool.query.mockResolvedValueOnce({}); // Insert success
|
||||
|
||||
bcrypt.hash.mockResolvedValue('hashed_new_password');
|
||||
|
||||
const newUser = {
|
||||
name: 'New User',
|
||||
email: 'new@test.com',
|
||||
password: 'password',
|
||||
role: 'employee'
|
||||
};
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send(newUser);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toEqual(expect.objectContaining({
|
||||
name: 'New User',
|
||||
email: 'new@test.com'
|
||||
}));
|
||||
|
||||
// Verify DB insert called
|
||||
expect(pool.query).toHaveBeenCalledTimes(2);
|
||||
expect(pool.query).toHaveBeenLastCalledWith(
|
||||
expect.stringContaining('INSERT INTO users'),
|
||||
expect.arrayContaining(['New User', 'employee', 'new@test.com', 'hashed_new_password'])
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 409 if email already exists', async () => {
|
||||
pool.query.mockResolvedValueOnce([[{ id: 'existing' }]]);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/register')
|
||||
.send({ name: 'User', email: 'existing@test.com', password: 'pw' });
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
});
|
||||
});
|
||||
120
server/tests/tasks.test.js
Normal file
120
server/tests/tasks.test.js
Normal file
@@ -0,0 +1,120 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { app } from '../index.js';
|
||||
import pool from '../db.js';
|
||||
|
||||
// Mock DB
|
||||
const mockQuery = vi.fn();
|
||||
const mockRelease = vi.fn();
|
||||
const mockCommit = vi.fn();
|
||||
const mockRollback = vi.fn();
|
||||
const mockBeginTransaction = vi.fn();
|
||||
|
||||
vi.mock('../db.js', () => ({
|
||||
default: {
|
||||
query: vi.fn(),
|
||||
getConnection: vi.fn()
|
||||
},
|
||||
initDB: vi.fn()
|
||||
}));
|
||||
|
||||
describe('Task Routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockQuery.mockReset(); // Important to clear implementations
|
||||
pool.query = mockQuery;
|
||||
pool.getConnection.mockResolvedValue({
|
||||
query: mockQuery,
|
||||
release: mockRelease,
|
||||
beginTransaction: mockBeginTransaction,
|
||||
commit: mockCommit,
|
||||
rollback: mockRollback
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/tasks', () => {
|
||||
it('returns list of tasks', async () => {
|
||||
// Mock fetching task IDs
|
||||
mockQuery.mockResolvedValueOnce([[{ id: 't1' }]]);
|
||||
|
||||
// Mock getFullTask queries for 't1'
|
||||
// 1. Task details
|
||||
mockQuery.mockResolvedValueOnce([[{ id: 't1', title: 'Task 1', status: 'todo' }]]); // Task
|
||||
// 2. Subtasks
|
||||
mockQuery.mockResolvedValueOnce([[]]); // Subtasks
|
||||
// 3. Comments
|
||||
mockQuery.mockResolvedValueOnce([[]]); // Comments
|
||||
// 4. Activities
|
||||
mockQuery.mockResolvedValueOnce([[]]); // Activities
|
||||
// 5. Tags
|
||||
mockQuery.mockResolvedValueOnce([[]]); // Tags
|
||||
// 6. Dependencies
|
||||
mockQuery.mockResolvedValueOnce([[]]); // Dependencies
|
||||
|
||||
const res = await request(app).get('/api/tasks');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
expect(res.body[0].title).toBe('Task 1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/tasks', () => {
|
||||
it('creates a new task', async () => {
|
||||
// Mock INSERTs (1: Task, 2: Activities) -> Return {}
|
||||
mockQuery.mockResolvedValueOnce({}); // Insert task
|
||||
mockQuery.mockResolvedValueOnce({}); // Insert activity
|
||||
|
||||
// getFullTask queries (3-8)
|
||||
mockQuery.mockResolvedValueOnce([[{ id: 'new-id', title: 'New Task', status: 'todo' }]]); // Task
|
||||
mockQuery.mockResolvedValueOnce([[]]); // Subtasks
|
||||
mockQuery.mockResolvedValueOnce([[]]); // Comments
|
||||
mockQuery.mockResolvedValueOnce([[]]); // Activities
|
||||
mockQuery.mockResolvedValueOnce([[]]); // Tags
|
||||
mockQuery.mockResolvedValueOnce([[]]); // Deps
|
||||
|
||||
const newTask = {
|
||||
// For getFullTask called at end
|
||||
title: 'New Task',
|
||||
description: 'Desc',
|
||||
status: 'todo'
|
||||
};
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/tasks')
|
||||
.send(newTask);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockBeginTransaction).toHaveBeenCalled();
|
||||
expect(mockCommit).toHaveBeenCalled();
|
||||
expect(mockRelease).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rolls back on error', async () => {
|
||||
mockQuery.mockRejectedValue(new Error('DB Error'));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/tasks')
|
||||
.send({ title: 'Task' });
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(mockRollback).toHaveBeenCalled();
|
||||
expect(mockRelease).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/tasks/:id', () => {
|
||||
it('deletes a task', async () => {
|
||||
mockQuery.mockResolvedValue({});
|
||||
|
||||
const res = await request(app).delete('/api/tasks/t1');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.stringContaining('DELETE FROM tasks'),
|
||||
['t1']
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
9
server/vitest.config.js
Normal file
9
server/vitest.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user