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:
tusuii
2026-02-16 12:31:54 +05:30
parent 2db45de4c4
commit c604df281d
33 changed files with 5006 additions and 71 deletions

View File

@@ -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 . .

View File

@@ -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;

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -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
View 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
View 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
View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
});