diff --git a/API-REST/Task/Task-Create-Setup.bru b/API-REST/Task/Task-Create-Setup.bru new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/API-REST/Task/Task-Create-Setup.bru @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/API-REST/Task/Task-Create.bru b/API-REST/Task/Task-Create.bru new file mode 100644 index 0000000..c6a9479 --- /dev/null +++ b/API-REST/Task/Task-Create.bru @@ -0,0 +1,96 @@ +meta { + name: Task-Create + type: http + seq: 6 +} + +post { + url: http://localhost:5000/tasks + body: json + auth: none +} + +headers { + Content-Type: application/json + x-user-id: {{userId}} +} + +body:json { + { + "clubMembershipId": {{clubMembershipId}}, + "title": "{{taskTitle}}", + "description": "{{taskDescription}}", + "volunteeredSeconds": {{volunteeredSeconds}}, + "category": "{{taskCategory}}", + "attachment": "{{attachmentUrl}}" + } +} + +vars:pre-request { + userId: 1 + clubMembershipId: 1 + taskTitle: Website Development + taskDescription: Develop a modern website for our programming club with member registration and event management features + volunteeredSeconds: 7200 + taskCategory: club_programs_projects + attachmentUrl: https://example.com/project-proposal.pdf +} + +docs { + # Create Task API + + This endpoint creates a new task for a club member. + + ## Authentication + - Requires a valid user ID in the x-user-id header + - User must have the club membership specified in clubMembershipId + + ## Request Body + - `clubMembershipId`: ID of the user's club membership (required, integer) + - `title`: Task title (required, string, max 100 characters) + - `description`: Task description (required, string, max 500 characters) + - `volunteeredSeconds`: Time spent in seconds (required, integer, min 0) + - `category`: Task category (required, string, enum) + - Available categories: club_programs_projects, uni_collab, external_collab, club_initiatives, internal_activities, community_contributions + - `attachment`: URL to attachment (optional, max 4096 characters) + + ## Example + ``` + POST /tasks + x-user-id: 1 + Content-Type: application/json + + { + "clubMembershipId": 1, + "title": "Website Development", + "description": "Develop club website", + "volunteeredSeconds": 7200, + "category": "club_programs_projects", + "attachment": "https://example.com/attachment.pdf" + } + ``` + + ## Response + - `201 Created` on success with created task: + ```json + { + "data": { + "id": 1, + "uuid": "new-task-uuid", + "title": "Website Development", + "description": "Develop club website", + "volunteeredSeconds": 7200, + "category": "club_programs_projects", + "status": "pending", + "attachment": "https://example.com/attachment.pdf", + "reviewComment": null, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z" + } + } + ``` + - `400 Bad Request` if validation fails + - `401 Unauthorized` if user is not authenticated + - `403 Forbidden` if user doesn't have the specified club membership + - `500 Internal Server Error` for server issues +} diff --git a/API-REST/Task/Task-Delete.bru b/API-REST/Task/Task-Delete.bru new file mode 100644 index 0000000..b954f51 --- /dev/null +++ b/API-REST/Task/Task-Delete.bru @@ -0,0 +1,53 @@ +meta { + name: Task-Delete + type: http + seq: 8 +} + +delete { + url: http://localhost:5000/tasks/{{taskUuid}} + body: none + auth: none +} + +headers { + x-user-id: {{userId}} +} + +vars:pre-request { + userId: 1 + taskUuid: "task-uuid-here" +} + +docs { + # Delete Task API + + This endpoint soft deletes a task by setting isArchived to true. The task is not permanently deleted but marked as archived. + + ## Authentication + - Requires a valid user ID in the x-user-id header + - User must be the task owner or have club admin/HR role + + ## Path Parameters + - `taskUuid`: Task UUID + + ## Example + ``` + DELETE /tasks/550e8400-e29b-41d4-a716-446655440000 + x-user-id: 1 + ``` + + ## Response + - `204 No Content` on successful deletion (no response body) + - `400 Bad Request` if task UUID format is invalid + - `401 Unauthorized` if user is not authenticated + - `403 Forbidden` if user doesn't have permission to delete this task + - `404 Not Found` if task doesn't exist + - `500 Internal Server Error` for server issues + + ## Important Notes + - This is a soft delete operation + - The task is marked as archived with isArchived=true and archivedAt timestamp + - Archived tasks are automatically filtered out from GET requests + - System cleanup job will permanently delete tasks archived for more than 1 year +} diff --git a/API-REST/Task/Task-Get-All-For-Club.bru b/API-REST/Task/Task-Get-All-For-Club.bru new file mode 100644 index 0000000..b5f5563 --- /dev/null +++ b/API-REST/Task/Task-Get-All-For-Club.bru @@ -0,0 +1,113 @@ +meta { + name: Task-Get-All-For-Club + type: http + seq: 4 +} + +get { + url: http://localhost:5000/tasks/clubs/{{clubUuid}}/tasks?page={{page}}&limit={{limit}}&status[eq]={{taskStatus}}&category[eq]={{taskCategory}} + body: none + auth: none +} + +headers { + x-user-id: {{userId}} +} + +vars:pre-request { + userId: 1 + clubUuid: "club-uuid-here" + page: 1 + limit: 10 + taskStatus: pending + taskCategory: club_programs_projects +} + +docs { + # Get All Tasks for Club API + + This endpoint retrieves all tasks for a specific club, including tasks from all club members. + + ## Authentication + - Requires a valid user ID in the x-user-id header + + ## Path Parameters + - `clubUuid`: Club UUID + + ## Query Parameters + - `page`: Page number (default: 1) + - `limit`: Items per page (default: 10, max: 50) + - `sort`: Sort fields (e.g., `-createdAt,title`) + - `fields`: Fields to include (e.g., `title,description,status`) + - `search`: Search term (searches in title and description) + - `status[eq]`: Filter by status (accepted, pending, changes_requested, denied) + - `category[eq]`: Filter by category + + ## Example + ``` + GET /tasks/clubs/550e8400-e29b-41d4-a716-446655440000/tasks?page=1&limit=10&status[eq]=pending + x-user-id: 1 + ``` + + ## Response + - `200 OK` on success with array of tasks and pagination metadata: + ```json + { + "success": true, + "data": { + "tasks": [ + { + "id": 1, + "uuid": "task-uuid", + "title": "Website Development", + "description": "Develop club website", + "volunteeredSeconds": 7200, + "category": "club_programs_projects", + "status": "pending", + "attachment": "https://example.com/attachment.pdf", + "reviewComment": null, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + "clubMembership": { + "id": 1, + "uuid": "membership-uuid", + "role": "member", + "status": "active", + "user": { + "id": 1, + "firstName": "John", + "lastName": "Doe" + }, + "club": { + "id": 1, + "name": "Programming Club" + } + }, + "createdByUser": { + "id": 1, + "firstName": "John", + "lastName": "Doe" + } + } + ], + "clubUuid": "550e8400-e29b-41d4-a716-446655440000", + "pagination": { + "page": 1, + "limit": 10, + "total": 1, + "pages": 1 + } + } + } + ``` + - `400 Bad Request` if club UUID format is invalid + - `401 Unauthorized` if user is not authenticated + - `404 Not Found` if club doesn't exist or is archived + - `500 Internal Server Error` for server issues + + ## Notes + - This endpoint returns tasks from all active members of the club + - Archived tasks and tasks from archived memberships are automatically filtered out + - If the club has no members, an empty array is returned + - Related data (clubMembership, user info) is always included in the response +} diff --git a/API-REST/Task/Task-Get-All-For-Membership.bru b/API-REST/Task/Task-Get-All-For-Membership.bru new file mode 100644 index 0000000..84964c0 --- /dev/null +++ b/API-REST/Task/Task-Get-All-For-Membership.bru @@ -0,0 +1,109 @@ +meta { + name: Task-Get-All-For-Membership + type: http + seq: 5 +} + +get { + url: http://localhost:5000/tasks/memberships/{{membershipUuid}}/tasks?page={{page}}&limit={{limit}}&status[eq]={{taskStatus}}&category[eq]={{taskCategory}} + body: none + auth: none +} + +headers { + x-user-id: {{userId}} +} + +vars:pre-request { + userId: 1 + membershipUuid: "membership-uuid-here" + page: 1 + limit: 10 + taskStatus: pending + taskCategory: club_programs_projects +} + +docs { + # Get All Tasks for Membership API + + This endpoint retrieves all tasks for a specific club membership. + + ## Authentication + - Requires a valid user ID in the x-user-id header + + ## Path Parameters + - `membershipUuid`: Club Membership UUID + + ## Query Parameters + - `page`: Page number (default: 1) + - `limit`: Items per page (default: 10, max: 50) + - `sort`: Sort fields (e.g., `-createdAt,title`) + - `fields`: Fields to include (e.g., `title,description,status`) + - `include`: Relations to include (e.g., `clubMembership,createdByUser`) + - `search`: Search term (searches in title and description) + - `status[eq]`: Filter by status (accepted, pending, changes_requested, denied) + - `category[eq]`: Filter by category + + ## Example + ``` + GET /tasks/memberships/550e8400-e29b-41d4-a716-446655440000/tasks?page=1&limit=10&status[eq]=pending + x-user-id: 1 + ``` + + ## Response + - `200 OK` on success with array of tasks and pagination metadata: + ```json + { + "success": true, + "data": { + "tasks": [ + { + "id": 1, + "uuid": "task-uuid", + "title": "Website Development", + "description": "Develop club website", + "volunteeredSeconds": 7200, + "category": "club_programs_projects", + "status": "pending", + "attachment": "https://example.com/attachment.pdf", + "reviewComment": null, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + "clubMembership": { + "id": 1, + "uuid": "membership-uuid", + "role": "member", + "status": "active", + "club": { + "id": 1, + "uuid": "club-uuid", + "name": "Programming Club" + }, + "user": { + "id": 1, + "firstName": "John", + "lastName": "Doe" + } + } + } + ], + "membershipUuid": "550e8400-e29b-41d4-a716-446655440000", + "pagination": { + "page": 1, + "limit": 10, + "total": 1, + "pages": 1 + } + } + } + ``` + - `400 Bad Request` if membership UUID format is invalid + - `401 Unauthorized` if user is not authenticated + - `404 Not Found` if membership doesn't exist or is archived + - `500 Internal Server Error` for server issues + + ## Notes + - This endpoint returns tasks only for the specified membership + - Archived tasks are automatically filtered out + - Useful for viewing all tasks submitted by a specific member in a specific club +} diff --git a/API-REST/Task/Task-Get-All.bru b/API-REST/Task/Task-Get-All.bru new file mode 100644 index 0000000..d5ea888 --- /dev/null +++ b/API-REST/Task/Task-Get-All.bru @@ -0,0 +1,98 @@ +meta { + name: Task-Get-All + type: http + seq: 2 +} + +get { + url: http://localhost:5000/tasks?page={{page}}&limit={{limit}}&status[eq]={{taskStatus}}&category[eq]={{taskCategory}} + body: none + auth: none +} + +headers { + x-user-id: {{userId}} +} + +vars:pre-request { + userId: 1 + page: 1 + limit: 10 + taskStatus: pending + taskCategory: club_programs_projects +} + +docs { + # Get All Tasks API + + This endpoint retrieves all tasks with optional filtering, pagination, and sorting. + + ## Authentication + - Requires a valid user ID in the x-user-id header + + ## Query Parameters + - `page`: Page number (default: 1) + - `limit`: Items per page (default: 10, max: 50) + - `sort`: Sort fields (e.g., `-createdAt,title`) + - `fields`: Fields to include (e.g., `title,description,status`) + - `search`: Search term + - `status[eq]`: Filter by status (accepted, pending, changes_requested, denied) + - `category[eq]`: Filter by category + - `clubMembershipId[eq]`: Filter by club membership ID + + ## Example + ``` + GET /tasks?page=1&limit=10&status[eq]=pending + x-user-id: 1 + ``` + + ## Response + - `200 OK` on success with array of tasks and pagination metadata: + ```json + { + "data": { + "tasks": [ + { + "id": 1, + "uuid": "task-uuid", + "title": "Website Development", + "description": "Develop club website", + "volunteeredSeconds": 7200, + "category": "club_programs_projects", + "status": "pending", + "attachment": "https://example.com/attachment.pdf", + "reviewComment": null, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + "clubMembership": { + "id": 1, + "role": "member", + "status": "active", + "user": { + "firstName": "John", + "lastName": "Doe" + }, + "club": { + "name": "Programming Club" + } + }, + "createdByUser": { + "firstName": "John", + "lastName": "Doe" + } + } + ], + "pagination": { + "page": 1, + "limit": 10, + "total": 1, + "totalPages": 1, + "hasNext": false, + "hasPrev": false + } + } + } + ``` + - `401 Unauthorized` if user is not authenticated + - `500 Internal Server Error` for server issues +} diff --git a/API-REST/Task/Task-Get-By-ID.bru b/API-REST/Task/Task-Get-By-ID.bru new file mode 100644 index 0000000..180523a --- /dev/null +++ b/API-REST/Task/Task-Get-By-ID.bru @@ -0,0 +1,87 @@ +meta { + name: Task-Get-By-ID + type: http + seq: 3 +} + +get { + url: http://localhost:5000/tasks/{{taskUuid}}?fields={{selectedFields}} + body: none + auth: none +} + +headers { + x-user-id: {{userId}} +} + +vars:pre-request { + userId: 1 + taskUuid: "task-uuid-here" + selectedFields: "title,description,status,volunteeredSeconds,category" +} + +docs { + # Get Task by UUID API + + This endpoint retrieves a specific task by its UUID. + + ## Authentication + - Requires a valid user ID in the x-user-id header + + ## Path Parameters + - `taskUuid`: Task UUID + + ## Query Parameters + - `fields`: Comma-separated list of fields to include + + ## Example + ``` + GET /tasks/550e8400-e29b-41d4-a716-446655440000?fields=title,description,status + x-user-id: 1 + ``` + + ## Response + - `200 OK` on success with task data: + ```json + { + "success": true, + "data": { + "id": 1, + "uuid": "550e8400-e29b-41d4-a716-446655440000", + "title": "Website Development", + "description": "Develop club website", + "volunteeredSeconds": 7200, + "category": "club_programs_projects", + "status": "pending", + "attachment": null, + "reviewComment": null, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + "clubMembership": { + "id": 1, + "role": "member", + "status": "active", + "user": { + "firstName": "John", + "lastName": "Doe" + }, + "club": { + "name": "Programming Club" + } + }, + "createdByUser": { + "firstName": "John", + "lastName": "Doe" + }, + "updatedByUser": { + "firstName": "John", + "lastName": "Doe" + } + } + } + ``` + - `400 Bad Request` if task UUID format is invalid + - `401 Unauthorized` if user is not authenticated + - `404 Not Found` if task doesn't exist or is archived + - `500 Internal Server Error` for server issues +} diff --git a/API-REST/Task/Task-Update.bru b/API-REST/Task/Task-Update.bru new file mode 100644 index 0000000..2838586 --- /dev/null +++ b/API-REST/Task/Task-Update.bru @@ -0,0 +1,107 @@ +meta { + name: Task-Update + type: http + seq: 7 +} + +put { + url: http://localhost:5000/tasks/{{taskUuid}} + body: json + auth: none +} + +headers { + Content-Type: application/json + x-user-id: {{userId}} +} + +body:json { + { + "title": "{{taskTitle}}", + "description": "{{taskDescription}}", + "volunteeredSeconds": {{volunteeredSeconds}}, + "category": "{{taskCategory}}", + "attachment": "{{attachmentUrl}}", + "status": "{{taskStatus}}", + "reviewComment": "{{reviewComment}}" + } +} + +vars:pre-request { + userId: 1 + taskUuid: "task-uuid-here" + taskTitle: Updated Website Development + taskDescription: Updated description with new requirements + volunteeredSeconds: 9000 + taskCategory: club_initiatives + attachmentUrl: https://example.com/updated-proposal.pdf + taskStatus: accepted + reviewComment: Good work, approved! +} + +docs { + # Update Task API + + This endpoint updates an existing task. + + ## Authentication + - Requires a valid user ID in the x-user-id header + - User must be the task owner or have club admin/HR role + + ## Path Parameters + - `taskUuid`: Task UUID + + ## Request Body + All fields are optional for updates: + - `title`: Task title (string, max 100 characters) + - `description`: Task description (string, max 500 characters) + - `volunteeredSeconds`: Time spent in seconds (integer, min 0) + - `category`: Task category (string, enum) + - Available categories: club_programs_projects, uni_collab, external_collab, club_initiatives, internal_activities, community_contributions + - `attachment`: URL to attachment (string, max 4096 characters) + - `status`: Task status (string, enum) - Usually updated by admins/HR + - Available statuses: pending, accepted, changes_requested, denied + - `reviewComment`: Review comment (string, max 500 characters) - Usually added by admins/HR + + ## Example + ``` + PUT /tasks/550e8400-e29b-41d4-a716-446655440000 + x-user-id: 1 + Content-Type: application/json + + { + "title": "Updated Website Development", + "description": "Updated description with new requirements", + "volunteeredSeconds": 9000, + "category": "club_initiatives", + "status": "accepted", + "reviewComment": "Good work, approved!" + } + ``` + + ## Response + - `200 OK` on success with updated task: + ```json + { + "success": true, + "data": { + "id": 1, + "uuid": "550e8400-e29b-41d4-a716-446655440000", + "title": "Updated Website Development", + "description": "Updated description with new requirements", + "volunteeredSeconds": 9000, + "category": "club_initiatives", + "status": "accepted", + "attachment": "https://example.com/updated-proposal.pdf", + "reviewComment": "Good work, approved!", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T12:00:00Z" + } + } + ``` + - `400 Bad Request` if validation fails or task UUID format is invalid + - `401 Unauthorized` if user is not authenticated + - `403 Forbidden` if user doesn't have permission to update this task + - `404 Not Found` if task doesn't exist or is archived + - `500 Internal Server Error` for server issues +} diff --git a/app.js b/app.js index e2d7d33..b9ca8ff 100644 --- a/app.js +++ b/app.js @@ -44,6 +44,7 @@ app.get('/api', (req, res) => { app.use('/clubs', require('./routes/clubRoutes')); app.use('/events', require('./routes/eventRoutes')); app.use('/images', require('./routes/imgRoutes')); +app.use('/tasks', require('./routes/taskRoutes')); app.use(require('./routes/auth')); app.use(require('./routes/profile')); app.use(require('./routes/main-misc')); @@ -98,4 +99,4 @@ connectDB() }); // Export for testing -module.exports = app; +module.exports = app; \ No newline at end of file diff --git a/controllers/taskController.js b/controllers/taskController.js new file mode 100644 index 0000000..a909645 --- /dev/null +++ b/controllers/taskController.js @@ -0,0 +1,431 @@ +const taskService = require('../services/taskService'); +const { insertTaskSchema, updateTaskSchema } = require('../dist/db/schema/task'); + +// Helper function for UUID validation +const isValidUUID = (uuid) => { + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + return uuidRegex.test(uuid); +}; + +const getAllTasks = async (req, res) => { + try { + // Get user ID for myTasks filtering + const userId = req.user ? req.user.id : parseInt(req.headers['x-user-id']); + const myTasks = req.query.myTasks === 'true'; + + // Build service parameters + const params = { + pagination: { + page: parseInt(req.query.page) || 1, + limit: Math.min(parseInt(req.query.limit) || 10, 50) + }, + search: req.query.search, + filters: { + status: req.query?.status?.eq ? { eq: req.query.status.eq } : undefined, + category: req.query?.category?.eq ? { eq: req.query.category.eq } : undefined + }, + currentUserId: myTasks && userId && !isNaN(userId) ? userId : null + }; + + // Clean up undefined filters + Object.keys(params.filters).forEach(key => { + if (params.filters[key] === undefined) { + delete params.filters[key]; + } + }); + + const result = await taskService.getAllTasks(params); + + res.json({ + success: true, + data: { + tasks: result.items, + pagination: { + page: result.pagination.page, + limit: result.pagination.limit, + total: result.pagination.total, + pages: result.pagination.totalPages + } + } + }); + } catch (error) { + console.error('Error in getAllTasks:', error); + const statusCode = error.statusCode || 500; + res.status(statusCode).json({ + success: false, + error: error.message || "Failed to fetch tasks" + }); + } +}; + +const getAllTasksForUser = async (req, res) => { + try { + const { userUuid } = req.params; + + // Build service parameters + const params = { + pagination: { + page: parseInt(req.query.page) || 1, + limit: Math.min(parseInt(req.query.limit) || 10, 50) + }, + search: req.query.search, + filters: { + status: req.query?.status?.eq ? { eq: req.query.status.eq } : undefined, + category: req.query?.category?.eq ? { eq: req.query.category.eq } : undefined + } + }; + + // Clean up undefined filters + Object.keys(params.filters).forEach(key => { + if (params.filters[key] === undefined) { + delete params.filters[key]; + } + }); + + const result = await taskService.getAllTasksForUser(userUuid, params); + + res.json({ + success: true, + data: { + tasks: result.items, + userUuid: result.userUuid, + pagination: { + page: result.pagination.page, + limit: result.pagination.limit, + total: result.pagination.total, + pages: result.pagination.totalPages + } + } + }); + } catch (error) { + console.error('Error in getAllTasksForUser:', error); + const statusCode = error.statusCode || 500; + res.status(statusCode).json({ + success: false, + error: error.message || "Failed to fetch user tasks" + }); + } +}; + +const getAllTasksForClub = async (req, res) => { + try { + const { clubUuid } = req.params; + + // Build service parameters + const params = { + pagination: { + page: parseInt(req.query.page) || 1, + limit: Math.min(parseInt(req.query.limit) || 10, 50) + }, + search: req.query.search, + filters: { + status: req.query?.status?.eq ? { eq: req.query.status.eq } : undefined, + category: req.query?.category?.eq ? { eq: req.query.category.eq } : undefined + } + }; + + // Clean up undefined filters + Object.keys(params.filters).forEach(key => { + if (params.filters[key] === undefined) { + delete params.filters[key]; + } + }); + + const result = await taskService.getAllTasksForClub(clubUuid, params); + + res.json({ + success: true, + data: { + tasks: result.items, + clubUuid: result.clubUuid, + pagination: { + page: result.pagination.page, + limit: result.pagination.limit, + total: result.pagination.total, + pages: result.pagination.totalPages + } + } + }); + } catch (error) { + console.error('Error in getAllTasksForClub:', error); + const statusCode = error.statusCode || 500; + res.status(statusCode).json({ + success: false, + error: error.message || "Failed to fetch club tasks" + }); + } +}; + +const getAllTasksForMembership = async (req, res) => { + try { + const { membershipUuid } = req.params; + + // Build service parameters + const params = { + pagination: { + page: parseInt(req.query.page) || 1, + limit: Math.min(parseInt(req.query.limit) || 10, 50) + }, + search: req.query.search, + filters: { + status: req.query?.status?.eq ? { eq: req.query.status.eq } : undefined, + category: req.query?.category?.eq ? { eq: req.query.category.eq } : undefined + } + }; + + // Clean up undefined filters + Object.keys(params.filters).forEach(key => { + if (params.filters[key] === undefined) { + delete params.filters[key]; + } + }); + + const result = await taskService.getAllTasksForMembership(membershipUuid, params); + + res.json({ + success: true, + data: { + tasks: result.items, + membershipUuid: result.membershipUuid, + pagination: { + page: result.pagination.page, + limit: result.pagination.limit, + total: result.pagination.total, + pages: result.pagination.totalPages + } + } + }); + } catch (error) { + console.error('Error in getAllTasksForMembership:', error); + const statusCode = error.statusCode || 500; + res.status(statusCode).json({ + success: false, + error: error.message || "Failed to fetch membership tasks" + }); + } +}; + +const getTaskById = async (req, res) => { + try { + const { taskUuid } = req.params; + + const params = {}; + + const taskData = await taskService.findTaskById(taskUuid, params); + + res.json({ + success: true, + data: taskData + }); + } catch (error) { + console.error('Error in getTaskById:', error); + const statusCode = error.statusCode || 500; + res.status(statusCode).json({ + success: false, + error: error.message || "Failed to fetch task" + }); + } +}; + +const createTask = async (req, res) => { + try { + console.log('=== DEBUG START ==='); + console.log('req.headers:', req.headers); + console.log('req.body:', req.body); + + // Check if request body is empty or malformed + if (!req.body || Object.keys(req.body).length === 0) { + return res.status(400).json({ + success: false, + error: 'Request body is required and must be valid JSON' + }); + } + + // Get user ID from header for API authentication in non-production environments + const userId = req.user ? req.user.id : parseInt(req.headers['x-user-id']); + + console.log('x-user-id header:', req.headers['x-user-id']); + console.log('userId after parseInt:', userId); + console.log('typeof userId:', typeof userId); + console.log('isNaN(userId):', isNaN(userId)); + + if (!userId || isNaN(userId)) { + return res.status(401).json({ + success: false, + error: 'Authentication required. Please provide valid x-user-id header.' + }); + } + + // Check required fields + const requiredFields = ['clubMembershipId', 'title', 'description']; + const missingFields = requiredFields.filter(field => !req.body[field]); + + if (missingFields.length > 0) { + return res.status(400).json({ + success: false, + error: `Missing required fields: ${missingFields.join(', ')}` + }); + } + + // Handle clubMembershipId - can be either integer ID or UUID + let clubMembershipId; + + if (isValidUUID(req.body.clubMembershipId)) { + // If it's a UUID, provide helpful error message + return res.status(400).json({ + success: false, + error: 'clubMembershipId must be an integer ID, not UUID', + message: 'To get the correct clubMembershipId, please use the club membership API: GET /club-memberships', + received: req.body.clubMembershipId, + expectedFormat: 'integer (e.g., 1, 2, 3)' + }); + } else { + // Convert to integer + clubMembershipId = parseInt(req.body.clubMembershipId); + if (isNaN(clubMembershipId)) { + return res.status(400).json({ + success: false, + error: 'clubMembershipId must be a valid integer ID', + received: req.body.clubMembershipId, + expectedFormat: 'integer (e.g., 1, 2, 3)' + }); + } + } + + // Prepare task data with required fields (exclude createdBy/updatedBy - service will add them) + const taskData = { + clubMembershipId: clubMembershipId, + title: req.body.title, + description: req.body.description, + volunteeredSeconds: req.body.volunteeredSeconds, + category: req.body.category, + attachment: req.body.attachment, + status: 'pending' + }; + + console.log('taskData constructed:', taskData); + console.log('=== DEBUG END ==='); + + // Validate request body (without createdBy/updatedBy since service adds them) + const validatedData = insertTaskSchema.omit({ createdBy: true, updatedBy: true }).safeParse(taskData); + + if (!validatedData.success) { + console.log('Validation failed:', validatedData.error); + return res.status(400).json({ + success: false, + error: 'Validation failed', + details: validatedData.error.errors.map(err => ({ + field: err.path.join('.'), + message: err.message, + received: err.received + })) + }); + } + + console.log('Validation successful, creating task with service:', validatedData.data); + + const newTask = await taskService.createTask(validatedData.data, userId); + + res.status(201).json({ + success: true, + data: newTask + }); + } catch (error) { + console.error('Error in createTask:', error); + + // Handle specific error types + if (error.type === 'entity.parse.failed') { + return res.status(400).json({ + success: false, + error: 'Invalid JSON format in request body', + message: 'Please ensure all string values are properly quoted and JSON syntax is correct', + details: error.message + }); + } + + const statusCode = error.statusCode || 500; + res.status(statusCode).json({ + success: false, + error: error.message || "Failed to create task" + }); + } +}; + +const updateTask = async (req, res) => { + try { + const { taskUuid } = req.params; + + // Get user ID from header for API authentication in non-production environments + const userId = req.user ? req.user.id : parseInt(req.headers['x-user-id']); + + if (!userId || isNaN(userId)) { + return res.status(401).json({ + success: false, + error: 'Authentication required. Please provide valid x-user-id header.' + }); + } + + // Validate request body + const validatedData = updateTaskSchema.safeParse(req.body); + + if (!validatedData.success) { + return res.status(400).json({ + success: false, + error: JSON.stringify(validatedData.error.errors, null, 2) + }); + } + + const updatedTask = await taskService.updateTask(taskUuid, validatedData.data, userId); + + res.json({ + success: true, + data: updatedTask + }); + } catch (error) { + console.error('Error in updateTask:', error); + const statusCode = error.statusCode || 500; + res.status(statusCode).json({ + success: false, + error: error.message || "Failed to update task" + }); + } +}; + +const deleteTask = async (req, res) => { + try { + const { taskUuid } = req.params; + + // Get user ID from header for API authentication in non-production environments + const userId = req.user ? req.user.id : parseInt(req.headers['x-user-id']); + + if (!userId || isNaN(userId)) { + return res.status(401).json({ + success: false, + error: 'Authentication required. Please provide valid x-user-id header.' + }); + } + + await taskService.deleteTask(taskUuid, userId); + + res.status(204).send(); + } catch (error) { + console.error('Error in deleteTask:', error); + const statusCode = error.statusCode || 500; + res.status(statusCode).json({ + success: false, + error: error.message || "Failed to delete task" + }); + } +}; + +module.exports = { + getAllTasks, + getAllTasksForUser, + getAllTasksForClub, + getAllTasksForMembership, + getTaskById, + createTask, + updateTask, + deleteTask +}; diff --git a/routes/taskRoutes.js b/routes/taskRoutes.js new file mode 100644 index 0000000..2c504fa --- /dev/null +++ b/routes/taskRoutes.js @@ -0,0 +1,71 @@ +const express = require('express'); +const router = express.Router(); +const taskController = require('../controllers/taskController'); +const { isAuthenticated } = require('../middleware/CheckAuth'); +const { parseQueryParams } = require('../middleware/QueryParser'); +const validateBody = require('../middleware/validateBody'); +const asyncHandler = require('../utils/asyncHandler'); + + +// Get all tasks for a specific club +router.get( + '/clubs/:clubUuid/tasks', + isAuthenticated, + parseQueryParams, + asyncHandler(taskController.getAllTasksForClub) +); + +// Get all tasks for a specific membership +router.get( + '/memberships/:membershipUuid/tasks', + isAuthenticated, + parseQueryParams, + asyncHandler(taskController.getAllTasksForMembership) +); + +// Get all tasks for a specific user +router.get( + '/user/:userUuid', + isAuthenticated, + parseQueryParams, + asyncHandler(taskController.getAllTasksForUser) +); + +// Get all tasks +router.get( + '/', + isAuthenticated, + parseQueryParams, + asyncHandler(taskController.getAllTasks) +); + +// Get task by UUID +router.get( + '/:taskUuid', + isAuthenticated, + parseQueryParams, + asyncHandler(taskController.getTaskById) +); + +// Create task +router.post( + '/', + isAuthenticated, + asyncHandler(taskController.createTask) +); + +// Update task +router.put( + '/:taskUuid', + isAuthenticated, + asyncHandler(taskController.updateTask) +); + +// Delete task - soft delete +router.delete( + '/:taskUuid', + isAuthenticated, + asyncHandler(taskController.deleteTask) +); + +module.exports = router; \ No newline at end of file diff --git a/services/taskService.js b/services/taskService.js new file mode 100644 index 0000000..912050c --- /dev/null +++ b/services/taskService.js @@ -0,0 +1,646 @@ +const { db } = require('../dist/db'); +const { task, clubMembership, club, user } = require('../dist/db/schema'); +const { eq, and, or, count, sql, inArray } = require('drizzle-orm'); +const { buildFilterConditions } = require('../utils/queryFilterBuilder'); +const { buildSelectFields } = require('../utils/queryFieldSelector'); +const createError = require('http-errors'); + +const isValidUUID = (uuid) => { + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + return uuidRegex.test(uuid); +}; + +const getAllTasks = async (params = {}) => { + const { + pagination = {}, + sort = {}, + search, + filters = {}, + fields = [], + currentUserId = null + } = params; + const { page = 1, limit = 10 } = pagination; + + // Calculate offset + const offset = (page - 1) * limit; + + // Build where conditions - always filter out archived tasks + let whereConditions = [eq(task.isArchived, false)]; + + // Filter by current user if requested + if (currentUserId) { + whereConditions.push(eq(task.createdBy, currentUserId)); + } + + // Add search condition if provided (search in title and description) + if (search) { + whereConditions.push( + or( + sql`LOWER(${task.title}) LIKE ${`%${search.toLowerCase()}%`}`, + sql`LOWER(${task.description}) LIKE ${`%${search.toLowerCase()}%`}`, + ), + ); + } + + // Add filter conditions + whereConditions.push(...buildFilterConditions(filters, task)); + + // Combine conditions with AND + const whereClause = and(...whereConditions); + + // Get total count for pagination + const [countResult] = await db + .select({ value: count() }) + .from(task) + .where(whereClause); + + const total = countResult?.value || 0; + + // Build columns object for field selection + const columns = buildSelectFields(fields, task); + + // Get paginated tasks + const tasks = await db.query.task.findMany({ + where: whereClause, + columns, + with: { + clubMembership: { + with: { + user: true, + club: true + } + }, + createdByUser: true, + updatedByUser: true + }, + limit: limit, + offset: offset, + orderBy: (t, { asc, desc }) => { + const entries = Object.entries(sort); + if (entries.length) { + return entries.map(([field, dir]) => + dir === 'desc' ? desc(t[field]) : asc(t[field]), + ); + } + // Default sort by createdAt descending + return [desc(t.createdAt)]; + }, + }); + + // Calculate pagination metadata + const totalPages = Math.ceil(total / limit); + const hasNext = page < totalPages; + const hasPrev = page > 1; + + return { + items: tasks, + pagination: { + page, + limit, + total, + totalPages, + hasNext, + hasPrev, + }, + }; +}; + +const getAllTasksForUser = async (userUuid, params = {}) => { + const { + pagination = {}, + sort = {}, + search, + filters = {}, + fields = [] + } = params; + const { page = 1, limit = 10 } = pagination; + + // Validate user UUID + if (!isValidUUID(userUuid)) { + throw createError(400, 'Invalid user UUID format'); + } + + // First, find the user to make sure they exist + const targetUser = await db.query.user.findFirst({ + where: eq(user.uuid, userUuid) + }); + + if (!targetUser) { + throw createError(404, 'User not found'); + } + + // Calculate offset + const offset = (page - 1) * limit; + + // Build where conditions - filter out archived tasks and filter by user + let whereConditions = [ + eq(task.isArchived, false), + eq(task.createdBy, targetUser.id) + ]; + + // Add search condition if provided (search in title and description) + if (search) { + whereConditions.push( + or( + sql`LOWER(${task.title}) LIKE ${`%${search.toLowerCase()}%`}`, + sql`LOWER(${task.description}) LIKE ${`%${search.toLowerCase()}%`}`, + ), + ); + } + + // Add filter conditions + whereConditions.push(...buildFilterConditions(filters, task)); + + // Combine conditions with AND + const whereClause = and(...whereConditions); + + // Get total count for pagination + const [countResult] = await db + .select({ value: count() }) + .from(task) + .where(whereClause); + + const total = countResult?.value || 0; + + // Build columns object for field selection + const columns = buildSelectFields(fields, task); + + // Get paginated tasks + const tasks = await db.query.task.findMany({ + where: whereClause, + columns, + with: { + clubMembership: { + with: { + user: true, + club: true + } + }, + createdByUser: true, + updatedByUser: true + }, + limit: limit, + offset: offset, + orderBy: (t, { asc, desc }) => { + const entries = Object.entries(sort); + if (entries.length) { + return entries.map(([field, dir]) => + dir === 'desc' ? desc(t[field]) : asc(t[field]), + ); + } + // Default sort by createdAt descending + return [desc(t.createdAt)]; + }, + }); + + // Calculate pagination metadata + const totalPages = Math.ceil(total / limit); + const hasNext = page < totalPages; + const hasPrev = page > 1; + + return { + items: tasks, + userUuid, + pagination: { + page, + limit, + total, + totalPages, + hasNext, + hasPrev, + }, + }; +}; + +const getAllTasksForClub = async (clubUuid, params = {}) => { + const { + pagination = {}, + sort = {}, + search, + filters = {}, + fields = [] + } = params; + const { page = 1, limit = 10 } = pagination; + + if (!isValidUUID(clubUuid)) { + throw createError(400, 'Invalid club UUID format'); + } + + // First, find the club to make sure it exists + const targetClub = await db.query.club.findFirst({ + where: and( + eq(club.uuid, clubUuid), + eq(club.isArchived, false) + ) + }); + + if (!targetClub) { + throw createError(404, 'Club not found'); + } + + // Get all membership IDs for this club + const memberships = await db.query.clubMembership.findMany({ + where: and( + eq(clubMembership.clubId, targetClub.id), + eq(clubMembership.isArchived, false) + ), + columns: { + id: true + } + }); + + const membershipIds = memberships.map(m => m.id); + + // Calculate offset + const offset = (page - 1) * limit; + + // Build where conditions - filter out archived tasks and filter by club memberships + let whereConditions = [ + eq(task.isArchived, false) + ]; + + // Only add membership filter if there are memberships + if (membershipIds.length > 0) { + whereConditions.push(inArray(task.clubMembershipId, membershipIds)); + } else { + // If no memberships, return empty result + return { + items: [], + clubUuid, + pagination: { + page, + limit, + total: 0, + totalPages: 0, + hasNext: false, + hasPrev: false, + }, + }; + } + + // Add search condition if provided (search in title and description) + if (search) { + whereConditions.push( + or( + sql`LOWER(${task.title}) LIKE ${`%${search.toLowerCase()}%`}`, + sql`LOWER(${task.description}) LIKE ${`%${search.toLowerCase()}%`}`, + ), + ); + } + + // Add filter conditions + whereConditions.push(...buildFilterConditions(filters, task)); + + // Combine conditions with AND + const whereClause = and(...whereConditions); + + // Get total count for pagination + const [countResult] = await db + .select({ value: count() }) + .from(task) + .where(whereClause); + + const total = countResult?.value || 0; + + // Build columns object for field selection + const columns = buildSelectFields(fields, task); + + // Get paginated tasks + const tasks = await db.query.task.findMany({ + where: whereClause, + columns, + with: { + clubMembership: { + with: { + user: true, + club: true + } + }, + createdByUser: true, + updatedByUser: true + }, + limit: limit, + offset: offset, + orderBy: (t, { asc, desc }) => { + const entries = Object.entries(sort); + if (entries.length) { + return entries.map(([field, dir]) => + dir === 'desc' ? desc(t[field]) : asc(t[field]), + ); + } + // Default sort by createdAt descending + return [desc(t.createdAt)]; + }, + }); + + // Calculate pagination metadata + const totalPages = Math.ceil(total / limit); + const hasNext = page < totalPages; + const hasPrev = page > 1; + + return { + items: tasks, + clubUuid, + pagination: { + page, + limit, + total, + totalPages, + hasNext, + hasPrev, + }, + }; +}; + +const getAllTasksForMembership = async (membershipUuid, params = {}) => { + const { + pagination = {}, + sort = {}, + search, + filters = {}, + fields = [] + } = params; + const { page = 1, limit = 10 } = pagination; + + // Validate membership UUID + if (!isValidUUID(membershipUuid)) { + throw createError(400, 'Invalid membership UUID format'); + } + + // First, find the membership to make sure it exists + const targetMembership = await db.query.clubMembership.findFirst({ + where: and( + eq(clubMembership.uuid, membershipUuid), + eq(clubMembership.isArchived, false) + ) + }); + + if (!targetMembership) { + throw createError(404, 'Membership not found'); + } + + // Calculate offset + const offset = (page - 1) * limit; + + // Build where conditions - filter out archived tasks and filter by membership + let whereConditions = [ + eq(task.isArchived, false), + eq(task.clubMembershipId, targetMembership.id) + ]; + + // Add search condition if provided (search in title and description) + if (search) { + whereConditions.push( + or( + sql`LOWER(${task.title}) LIKE ${`%${search.toLowerCase()}%`}`, + sql`LOWER(${task.description}) LIKE ${`%${search.toLowerCase()}%`}`, + ), + ); + } + + // Add filter conditions + whereConditions.push(...buildFilterConditions(filters, task)); + + // Combine conditions with AND + const whereClause = and(...whereConditions); + + // Get total count for pagination + const [countResult] = await db + .select({ value: count() }) + .from(task) + .where(whereClause); + + const total = countResult?.value || 0; + + // Build columns object for field selection + const columns = buildSelectFields(fields, task); + + // Get paginated tasks + const tasks = await db.query.task.findMany({ + where: whereClause, + columns, + with: { + clubMembership: { + with: { + user: true, + club: true + } + }, + createdByUser: true, + updatedByUser: true + }, + limit: limit, + offset: offset, + orderBy: (t, { asc, desc }) => { + const entries = Object.entries(sort); + if (entries.length) { + return entries.map(([field, dir]) => + dir === 'desc' ? desc(t[field]) : asc(t[field]), + ); + } + // Default sort by createdAt descending + return [desc(t.createdAt)]; + }, + }); + + // Calculate pagination metadata + const totalPages = Math.ceil(total / limit); + const hasNext = page < totalPages; + const hasPrev = page > 1; + + return { + items: tasks, + membershipUuid, + pagination: { + page, + limit, + total, + totalPages, + hasNext, + hasPrev, + }, + }; +}; + +const findTaskById = async (taskId, params = {}) => { + const { fields = [] } = params; + + let whereClause; + + // Check if it's a UUID + if (isValidUUID(taskId)) { + whereClause = and( + eq(task.uuid, taskId), + eq(task.isArchived, false) + ); + } else { + // Try numeric ID + const numericId = parseInt(taskId); + if (isNaN(numericId)) { + throw createError(400, 'Invalid task ID format'); + } + whereClause = and( + eq(task.id, numericId), + eq(task.isArchived, false) + ); + } + + const columns = buildSelectFields(fields, task); + + const taskData = await db.query.task.findFirst({ + where: whereClause, + columns, + with: { + clubMembership: { + with: { + user: true, + club: true + } + }, + createdByUser: true, + updatedByUser: true + } + }); + + if (!taskData) { + throw createError(404, 'Task not found'); + } + + return taskData; +}; + +const createTask = async (data, userId) => { + // Verify the club membership exists and belongs to the user + const membership = await db.query.clubMembership.findFirst({ + where: and( + eq(clubMembership.id, data.clubMembershipId), + eq(clubMembership.userId, userId), + eq(clubMembership.isArchived, false) + ) + }); + + if (!membership) { + throw createError(403, 'Invalid club membership or insufficient permissions'); + } + + // Add metadata + const taskData = { + ...data, + createdBy: userId, + updatedBy: userId, + }; + + const [newTask] = await db.insert(task).values(taskData).returning(); + + return newTask; +}; + +const updateTask = async (taskId, data, userId) => { + // Find the task first + const existingTask = await findTaskById(taskId); + + // Check permissions - user must be the task creator or have appropriate role + const membership = await db.query.clubMembership.findFirst({ + where: eq(clubMembership.id, existingTask.clubMembershipId), + with: { + club: true + } + }); + + if (!membership) { + throw createError(404, 'Associated membership not found'); + } + + // Check if user is authorized to update + const isTaskOwner = existingTask.createdBy === userId; + const isClubAdmin = await db.query.clubMembership.findFirst({ + where: and( + eq(clubMembership.clubId, membership.club.id), + eq(clubMembership.userId, userId), + inArray(clubMembership.role, ['clubAdmin', 'hr']), + eq(clubMembership.isArchived, false) + ) + }); + + if (!isTaskOwner && !isClubAdmin) { + throw createError(403, 'Insufficient permissions to update this task'); + } + + // Update the task + const [updatedTask] = await db + .update(task) + .set({ + ...data, + updatedBy: userId, + updatedAt: new Date(), + }) + .where(eq(task.id, existingTask.id)) + .returning(); + + return updatedTask; +}; + +/** + * Delete task (soft delete) + */ +const deleteTask = async (taskId, userId) => { + // Find the task first + const existingTask = await findTaskById(taskId); + + // Check permissions - user must be the task creator or have appropriate role + const membership = await db.query.clubMembership.findFirst({ + where: eq(clubMembership.id, existingTask.clubMembershipId), + with: { + club: true + } + }); + + if (!membership) { + throw createError(404, 'Associated membership not found'); + } + + // Check if user is authorized to delete + const isTaskOwner = existingTask.createdBy === userId; + const isClubAdmin = await db.query.clubMembership.findFirst({ + where: and( + eq(clubMembership.clubId, membership.club.id), + eq(clubMembership.userId, userId), + inArray(clubMembership.role, ['clubAdmin', 'hr']), + eq(clubMembership.isArchived, false) + ) + }); + + if (!isTaskOwner && !isClubAdmin) { + throw createError(403, 'Insufficient permissions to delete this task'); + } + + // Soft delete by setting isArchived and archivedAt + const [archivedTask] = await db + .update(task) + .set({ + isArchived: true, + archivedAt: new Date(), + updatedBy: userId, + updatedAt: new Date(), + }) + .where(eq(task.id, existingTask.id)) + .returning({ + id: task.id, + isArchived: task.isArchived, + archivedAt: task.archivedAt + }); + + return archivedTask; +}; + +module.exports = { + getAllTasks, + getAllTasksForUser, + getAllTasksForClub, + getAllTasksForMembership, + findTaskById, + createTask, + updateTask, + deleteTask, +}; \ No newline at end of file