From 8fd8f991e8b5c225d46cb9c9d4ac2da2c0b3401a Mon Sep 17 00:00:00 2001 From: richardtekula Date: Mon, 24 Nov 2025 11:17:28 +0100 Subject: [PATCH] Implement many-to-many TODO user assignments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create todo_users junction table for many-to-many relationship - Add migration to create todo_users table and migrate existing data - Update validators to accept assignedUserIds array instead of assignedTo - Update todo service to handle multiple user assignments - Fetch and return assigned users with each TODO 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/controllers/todo.controller.js | 6 +- .../migrations/0006_add_todo_users_table.sql | 29 +++ src/db/schema.js | 12 +- src/services/todo.service.js | 245 +++++++++++++++--- src/validators/crm.validators.js | 4 +- 5 files changed, 248 insertions(+), 48 deletions(-) create mode 100644 src/db/migrations/0006_add_todo_users_table.sql diff --git a/src/controllers/todo.controller.js b/src/controllers/todo.controller.js index 8724fd4..ded874a 100644 --- a/src/controllers/todo.controller.js +++ b/src/controllers/todo.controller.js @@ -100,13 +100,14 @@ export const getTodoWithRelations = async (req, res) => { /** * Create new todo * POST /api/todos - * Body: { title, description, projectId, companyId, assignedTo, status, priority, dueDate } + * Body: { title, description, projectId, companyId, assignedUserIds, status, priority, dueDate } */ export const createTodo = async (req, res) => { try { const userId = req.userId; const data = req.body; + console.log('Backend received todo data:', data); const todo = await todoService.createTodo(userId, data); res.status(201).json({ @@ -123,13 +124,14 @@ export const createTodo = async (req, res) => { /** * Update todo * PATCH /api/todos/:todoId - * Body: { title, description, projectId, companyId, assignedTo, status, priority, dueDate } + * Body: { title, description, projectId, companyId, assignedUserIds, status, priority, dueDate } */ export const updateTodo = async (req, res) => { try { const { todoId } = req.params; const data = req.body; + console.log('Backend received update data:', data); const todo = await todoService.updateTodo(todoId, data); res.status(200).json({ diff --git a/src/db/migrations/0006_add_todo_users_table.sql b/src/db/migrations/0006_add_todo_users_table.sql new file mode 100644 index 0000000..89cd5b4 --- /dev/null +++ b/src/db/migrations/0006_add_todo_users_table.sql @@ -0,0 +1,29 @@ +-- Migration: Add todo_users junction table and migrate from assignedTo +-- Created: 2025-11-24 +-- Description: Allows many-to-many relationship between todos and users + +-- Create todo_users junction table +CREATE TABLE IF NOT EXISTS todo_users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + todo_id UUID NOT NULL REFERENCES todos(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + assigned_by UUID REFERENCES users(id) ON DELETE SET NULL, + assigned_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT todo_user_unique UNIQUE(todo_id, user_id) +); + +-- Create indexes for better query performance +CREATE INDEX IF NOT EXISTS idx_todo_users_todo_id ON todo_users(todo_id); +CREATE INDEX IF NOT EXISTS idx_todo_users_user_id ON todo_users(user_id); + +-- Migrate existing assignedTo data to todo_users table +INSERT INTO todo_users (todo_id, user_id, assigned_by, assigned_at) +SELECT id, assigned_to, created_by, created_at +FROM todos +WHERE assigned_to IS NOT NULL; + +-- Drop the old assigned_to column +ALTER TABLE todos DROP COLUMN IF EXISTS assigned_to; + +-- Add comment +COMMENT ON TABLE todo_users IS 'Junction table for many-to-many relationship between todos and users (assigned users)'; diff --git a/src/db/schema.js b/src/db/schema.js index ad90cf5..e04845a 100644 --- a/src/db/schema.js +++ b/src/db/schema.js @@ -149,7 +149,6 @@ export const todos = pgTable('todos', { description: text('description'), projectId: uuid('project_id').references(() => projects.id, { onDelete: 'cascade' }), // todo môže patriť projektu companyId: uuid('company_id').references(() => companies.id, { onDelete: 'cascade' }), // alebo firme - assignedTo: uuid('assigned_to').references(() => users.id, { onDelete: 'set null' }), // komu je priradené status: todoStatusEnum('status').default('pending').notNull(), priority: todoPriorityEnum('priority').default('medium').notNull(), dueDate: timestamp('due_date'), @@ -159,6 +158,17 @@ export const todos = pgTable('todos', { updatedAt: timestamp('updated_at').defaultNow().notNull(), }); +// Todo Users - many-to-many medzi todos a users (priradení používatelia) +export const todoUsers = pgTable('todo_users', { + id: uuid('id').primaryKey().defaultRandom(), + todoId: uuid('todo_id').references(() => todos.id, { onDelete: 'cascade' }).notNull(), + userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + assignedBy: uuid('assigned_by').references(() => users.id, { onDelete: 'set null' }), // kto pridal používateľa k todo + assignedAt: timestamp('assigned_at').defaultNow().notNull(), +}, (table) => ({ + todoUserUnique: unique('todo_user_unique').on(table.todoId, table.userId), +})); + // Notes table - poznámky export const notes = pgTable('notes', { id: uuid('id').primaryKey().defaultRandom(), diff --git a/src/services/todo.service.js b/src/services/todo.service.js index ff98292..b23dbc3 100644 --- a/src/services/todo.service.js +++ b/src/services/todo.service.js @@ -1,6 +1,6 @@ import { db } from '../config/database.js'; -import { todos, notes, projects, companies, users } from '../db/schema.js'; -import { eq, desc, ilike, or, and } from 'drizzle-orm'; +import { todos, todoUsers, notes, projects, companies, users } from '../db/schema.js'; +import { eq, desc, ilike, or, and, inArray } from 'drizzle-orm'; import { NotFoundError } from '../utils/errors.js'; /** @@ -10,6 +10,90 @@ import { NotFoundError } from '../utils/errors.js'; export const getAllTodos = async (filters = {}) => { const { searchTerm, projectId, companyId, assignedTo, status } = filters; + // If filtering by assignedTo, we need to join with todo_users + if (assignedTo) { + const todoIdsWithUser = await db + .select({ todoId: todoUsers.todoId }) + .from(todoUsers) + .where(eq(todoUsers.userId, assignedTo)); + + const todoIds = todoIdsWithUser.map((row) => row.todoId); + + if (todoIds.length === 0) { + return []; + } + + let query = db.select().from(todos).where(inArray(todos.id, todoIds)); + + const conditions = []; + + if (searchTerm) { + conditions.push( + or( + ilike(todos.title, `%${searchTerm}%`), + ilike(todos.description, `%${searchTerm}%`) + ) + ); + } + + if (projectId) { + conditions.push(eq(todos.projectId, projectId)); + } + + if (companyId) { + conditions.push(eq(todos.companyId, companyId)); + } + + if (status) { + conditions.push(eq(todos.status, status)); + } + + if (conditions.length > 0) { + query = query.where(and(...conditions)); + } + + const result = await query.orderBy(desc(todos.createdAt)); + + // Fetch assigned users for all todos + if (result.length > 0) { + const todoIds = result.map(todo => todo.id); + const assignedUsersData = await db + .select({ + todoId: todoUsers.todoId, + userId: users.id, + username: users.username, + firstName: users.firstName, + lastName: users.lastName, + }) + .from(todoUsers) + .innerJoin(users, eq(todoUsers.userId, users.id)) + .where(inArray(todoUsers.todoId, todoIds)); + + // Group assigned users by todoId + const usersByTodoId = {}; + for (const row of assignedUsersData) { + if (!usersByTodoId[row.todoId]) { + usersByTodoId[row.todoId] = []; + } + usersByTodoId[row.todoId].push({ + id: row.userId, + username: row.username, + firstName: row.firstName, + lastName: row.lastName, + }); + } + + // Attach assigned users to each todo + return result.map(todo => ({ + ...todo, + assignedUsers: usersByTodoId[todo.id] || [], + })); + } + + return result; + } + + // No assignedTo filter - simple query let query = db.select().from(todos); const conditions = []; @@ -31,10 +115,6 @@ export const getAllTodos = async (filters = {}) => { conditions.push(eq(todos.companyId, companyId)); } - if (assignedTo) { - conditions.push(eq(todos.assignedTo, assignedTo)); - } - if (status) { conditions.push(eq(todos.status, status)); } @@ -44,6 +124,43 @@ export const getAllTodos = async (filters = {}) => { } const result = await query.orderBy(desc(todos.createdAt)); + + // Fetch assigned users for all todos + if (result.length > 0) { + const todoIds = result.map(todo => todo.id); + const assignedUsersData = await db + .select({ + todoId: todoUsers.todoId, + userId: users.id, + username: users.username, + firstName: users.firstName, + lastName: users.lastName, + }) + .from(todoUsers) + .innerJoin(users, eq(todoUsers.userId, users.id)) + .where(inArray(todoUsers.todoId, todoIds)); + + // Group assigned users by todoId + const usersByTodoId = {}; + for (const row of assignedUsersData) { + if (!usersByTodoId[row.todoId]) { + usersByTodoId[row.todoId] = []; + } + usersByTodoId[row.todoId].push({ + id: row.userId, + username: row.username, + firstName: row.firstName, + lastName: row.lastName, + }); + } + + // Attach assigned users to each todo + return result.map(todo => ({ + ...todo, + assignedUsers: usersByTodoId[todo.id] || [], + })); + } + return result; }; @@ -66,9 +183,12 @@ export const getTodoById = async (todoId) => { /** * Create new todo + * @param {string} userId - ID of user creating the todo + * @param {object} data - Todo data including assignedUserIds array */ export const createTodo = async (userId, data) => { - const { title, description, projectId, companyId, assignedTo, status, priority, dueDate } = data; + const { title, description, projectId, companyId, assignedUserIds, status, priority, dueDate } = data; + console.log('Service createTodo - assignedUserIds:', assignedUserIds); // Verify project exists if provided if (projectId) { @@ -96,19 +216,19 @@ export const createTodo = async (userId, data) => { } } - // Verify assigned user exists if provided - if (assignedTo) { - const [user] = await db - .select() + // Verify assigned users exist if provided + if (assignedUserIds && Array.isArray(assignedUserIds) && assignedUserIds.length > 0) { + const existingUsers = await db + .select({ id: users.id }) .from(users) - .where(eq(users.id, assignedTo)) - .limit(1); + .where(inArray(users.id, assignedUserIds)); - if (!user) { - throw new NotFoundError('Používateľ nenájdený'); + if (existingUsers.length !== assignedUserIds.length) { + throw new NotFoundError('Niektorí používatelia neboli nájdení'); } } + // Create the todo const [newTodo] = await db .insert(todos) .values({ @@ -116,7 +236,6 @@ export const createTodo = async (userId, data) => { description: description || null, projectId: projectId || null, companyId: companyId || null, - assignedTo: assignedTo || null, status: status || 'pending', priority: priority || 'medium', dueDate: dueDate ? new Date(dueDate) : null, @@ -124,16 +243,32 @@ export const createTodo = async (userId, data) => { }) .returning(); + // Assign users to the todo + if (assignedUserIds && Array.isArray(assignedUserIds) && assignedUserIds.length > 0) { + const todoUserInserts = assignedUserIds.map((assignedUserId) => ({ + todoId: newTodo.id, + userId: assignedUserId, + assignedBy: userId, + })); + + console.log('Inserting todo_users:', todoUserInserts); + await db.insert(todoUsers).values(todoUserInserts); + } else { + console.log('No users to assign - assignedUserIds:', assignedUserIds); + } + return newTodo; }; /** * Update todo + * @param {string} todoId - ID of todo to update + * @param {object} data - Updated data including assignedUserIds array */ export const updateTodo = async (todoId, data) => { const todo = await getTodoById(todoId); - const { title, description, projectId, companyId, assignedTo, status, priority, dueDate } = data; + const { title, description, projectId, companyId, assignedUserIds, status, priority, dueDate } = data; // Verify project exists if being changed if (projectId !== undefined && projectId !== null && projectId !== todo.projectId) { @@ -161,16 +296,15 @@ export const updateTodo = async (todoId, data) => { } } - // Verify assigned user exists if being changed - if (assignedTo !== undefined && assignedTo !== null && assignedTo !== todo.assignedTo) { - const [user] = await db - .select() + // Verify assigned users exist if being changed + if (assignedUserIds !== undefined && Array.isArray(assignedUserIds) && assignedUserIds.length > 0) { + const existingUsers = await db + .select({ id: users.id }) .from(users) - .where(eq(users.id, assignedTo)) - .limit(1); + .where(inArray(users.id, assignedUserIds)); - if (!user) { - throw new NotFoundError('Používateľ nenájdený'); + if (existingUsers.length !== assignedUserIds.length) { + throw new NotFoundError('Niektorí používatelia neboli nájdení'); } } @@ -189,7 +323,6 @@ export const updateTodo = async (todoId, data) => { description: description !== undefined ? description : todo.description, projectId: projectId !== undefined ? projectId : todo.projectId, companyId: companyId !== undefined ? companyId : todo.companyId, - assignedTo: assignedTo !== undefined ? assignedTo : todo.assignedTo, status: status !== undefined ? status : todo.status, priority: priority !== undefined ? priority : todo.priority, dueDate: dueDate !== undefined ? (dueDate ? new Date(dueDate) : null) : todo.dueDate, @@ -199,6 +332,23 @@ export const updateTodo = async (todoId, data) => { .where(eq(todos.id, todoId)) .returning(); + // Update assigned users if provided + if (assignedUserIds !== undefined) { + // Delete existing assignments + await db.delete(todoUsers).where(eq(todoUsers.todoId, todoId)); + + // Create new assignments + if (Array.isArray(assignedUserIds) && assignedUserIds.length > 0) { + const todoUserInserts = assignedUserIds.map((userId) => ({ + todoId: todoId, + userId: userId, + assignedBy: null, // We don't track who made the update + })); + + await db.insert(todoUsers).values(todoUserInserts); + } + } + return updated; }; @@ -214,7 +364,7 @@ export const deleteTodo = async (todoId) => { }; /** - * Get todo with related data (notes, project, company, assigned user) + * Get todo with related data (notes, project, company, assigned users) */ export const getTodoWithRelations = async (todoId) => { const todo = await getTodoById(todoId); @@ -239,20 +389,18 @@ export const getTodoWithRelations = async (todoId) => { .limit(1); } - // Get assigned user if exists - let assignedUser = null; - if (todo.assignedTo) { - [assignedUser] = await db - .select({ - id: users.id, - username: users.username, - firstName: users.firstName, - lastName: users.lastName, - }) - .from(users) - .where(eq(users.id, todo.assignedTo)) - .limit(1); - } + // Get assigned users from todo_users junction table + const assignedUsers = await db + .select({ + id: users.id, + username: users.username, + firstName: users.firstName, + lastName: users.lastName, + assignedAt: todoUsers.assignedAt, + }) + .from(todoUsers) + .innerJoin(users, eq(todoUsers.userId, users.id)) + .where(eq(todoUsers.todoId, todoId)); // Get related notes const todoNotes = await db @@ -265,7 +413,7 @@ export const getTodoWithRelations = async (todoId) => { ...todo, project, company, - assignedUser, + assignedUsers, notes: todoNotes, }; }; @@ -296,9 +444,20 @@ export const getTodosByCompanyId = async (companyId) => { * Get todos assigned to a user */ export const getTodosByUserId = async (userId) => { + const todoIdsWithUser = await db + .select({ todoId: todoUsers.todoId }) + .from(todoUsers) + .where(eq(todoUsers.userId, userId)); + + const todoIds = todoIdsWithUser.map((row) => row.todoId); + + if (todoIds.length === 0) { + return []; + } + return await db .select() .from(todos) - .where(eq(todos.assignedTo, userId)) + .where(inArray(todos.id, todoIds)) .orderBy(desc(todos.createdAt)); }; diff --git a/src/validators/crm.validators.js b/src/validators/crm.validators.js index 0be6bca..f972781 100644 --- a/src/validators/crm.validators.js +++ b/src/validators/crm.validators.js @@ -63,7 +63,7 @@ export const createTodoSchema = z.object({ description: z.string().max(1000).optional(), projectId: z.string().uuid('Neplatný formát project ID').optional().or(z.literal('')), companyId: z.string().uuid('Neplatný formát company ID').optional().or(z.literal('')), - assignedTo: z.string().uuid('Neplatný formát user ID').optional().or(z.literal('')), + assignedUserIds: z.array(z.string().uuid('Neplatný formát user ID')).optional(), status: z.enum(['pending', 'in_progress', 'completed', 'cancelled']).optional(), priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(), dueDate: z.string().optional().or(z.literal('')), @@ -74,7 +74,7 @@ export const updateTodoSchema = z.object({ description: z.string().max(1000).optional(), projectId: z.string().uuid('Neplatný formát project ID').optional().or(z.literal('').or(z.null())), companyId: z.string().uuid('Neplatný formát company ID').optional().or(z.literal('').or(z.null())), - assignedTo: z.string().uuid('Neplatný formát user ID').optional().or(z.literal('').or(z.null())), + assignedUserIds: z.array(z.string().uuid('Neplatný formát user ID')).optional(), status: z.enum(['pending', 'in_progress', 'completed', 'cancelled']).optional(), priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(), dueDate: z.string().optional().or(z.literal('').or(z.null())),