From baaac7722b8c079d2651f3da46906929a27392b3 Mon Sep 17 00:00:00 2001 From: Salehalobilan Date: Fri, 23 May 2025 13:29:34 +0300 Subject: [PATCH 1/4] Built Tasks feature and endpoints (Tested and should work) --- API-REST/Task/Task-Create-Setup.bru | 1 + API-REST/Task/Task-Create.bru | 96 +++++++++++++++++++++++++ API-REST/Task/Task-Delete.bru | 53 ++++++++++++++ API-REST/Task/Task-Get-All.bru | 83 +++++++++++++++++++++ API-REST/Task/Task-Get-By-ID.bru | 75 +++++++++++++++++++ API-REST/Task/Task-Update.bru | 107 ++++++++++++++++++++++++++++ 6 files changed, 415 insertions(+) create mode 100644 API-REST/Task/Task-Create-Setup.bru create mode 100644 API-REST/Task/Task-Create.bru create mode 100644 API-REST/Task/Task-Delete.bru create mode 100644 API-REST/Task/Task-Get-All.bru create mode 100644 API-REST/Task/Task-Get-By-ID.bru create mode 100644 API-REST/Task/Task-Update.bru 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..8b114ca --- /dev/null +++ b/API-REST/Task/Task-Create.bru @@ -0,0 +1,96 @@ +meta { + name: Task-Create + type: http + seq: 3 +} + +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 +} \ No newline at end of file diff --git a/API-REST/Task/Task-Delete.bru b/API-REST/Task/Task-Delete.bru new file mode 100644 index 0000000..d546850 --- /dev/null +++ b/API-REST/Task/Task-Delete.bru @@ -0,0 +1,53 @@ +meta { + name: Task-Delete + type: http + seq: 5 +} + +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 +} \ No newline at end of file diff --git a/API-REST/Task/Task-Get-All.bru b/API-REST/Task/Task-Get-All.bru new file mode 100644 index 0000000..907ab87 --- /dev/null +++ b/API-REST/Task/Task-Get-All.bru @@ -0,0 +1,83 @@ +meta { + name: Task-Get-All + type: http + seq: 1 +} + +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`) + - `include`: Relations to include (e.g., `clubMembership,createdByUser`) + - `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&include=clubMembership + 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" + } + ], + "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 +} \ No newline at end of file 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..de5b041 --- /dev/null +++ b/API-REST/Task/Task-Get-By-ID.bru @@ -0,0 +1,75 @@ +meta { + name: Task-Get-By-ID + type: http + seq: 2 +} + +get { + url: http://localhost:5000/tasks/{{taskUuid}}?fields={{selectedFields}}&include={{includeRelations}} + body: none + auth: none +} + +headers { + x-user-id: {{userId}} +} + +vars:pre-request { + userId: 1 + taskUuid: "task-uuid-here" + selectedFields: "title,description,status,volunteeredSeconds,category" + includeRelations: "clubMembership,createdByUser" +} + +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 + - `include`: Comma-separated list of relations to include + - Available relations: clubMembership, createdByUser, updatedByUser + + ## Example + ``` + GET /tasks/550e8400-e29b-41d4-a716-446655440000?fields=title,description,status&include=clubMembership + 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" + } + } + } + ``` + - `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 +} \ No newline at end of file diff --git a/API-REST/Task/Task-Update.bru b/API-REST/Task/Task-Update.bru new file mode 100644 index 0000000..e22a882 --- /dev/null +++ b/API-REST/Task/Task-Update.bru @@ -0,0 +1,107 @@ +meta { + name: Task-Update + type: http + seq: 4 +} + +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 +} \ No newline at end of file From 598a73ed3b95b89d3bdc46becb9a377669e33e63 Mon Sep 17 00:00:00 2001 From: Salehalobilan Date: Fri, 23 May 2025 13:30:03 +0300 Subject: [PATCH 2/4] Built Tasks feature and endpoints (Tested and should work) --- app.js | 38 +--- controllers/taskController.js | 334 ++++++++++++++++++++++++++++++++++ routes/taskRoutes.js | 51 ++++++ services/taskService.js | 298 ++++++++++++++++++++++++++++++ 4 files changed, 685 insertions(+), 36 deletions(-) create mode 100644 controllers/taskController.js create mode 100644 routes/taskRoutes.js create mode 100644 services/taskService.js diff --git a/app.js b/app.js index e2d7d33..0510d01 100644 --- a/app.js +++ b/app.js @@ -40,48 +40,14 @@ app.get('/api', (req, res) => { res.json({ message: 'API is working!' }); }); -// API Routes (including TypeScript route files) +// API Routes 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')); -// Global error handlers -const createError = require('http-errors'); - -// Catch 404 for routes not found -app.use((req, res, next) => { - next(createError(404, 'Endpoint not found')); -}); - -// Global error handler -app.use((err, req, res, next) => { - console.error(err); - - // Get status code (default to 500 if not an HTTP error) - const status = err.status || err.statusCode || 500; - - // Format the error response - const errorResponse = { - error: { - code: status >= 500 ? 'INTERNAL_ERROR' : err.code || String(status), - message: - status >= 500 && process.env.NODE_ENV === 'production' - ? 'Internal Server Error' - : err.message || 'Something went wrong', - }, - }; - - // Include error details in development - if (process.env.NODE_ENV !== 'production' && err.stack) { - errorResponse.error.stack = err.stack; - } - - res.status(status).json(errorResponse); -}); - // Start the server const PORT = process.env.PORT || 3000; diff --git a/controllers/taskController.js b/controllers/taskController.js new file mode 100644 index 0000000..18d48d1 --- /dev/null +++ b/controllers/taskController.js @@ -0,0 +1,334 @@ +const { db } = require('../dist/db'); +const { task } = require('../dist/db/schema'); +const { eq, and, ilike, ne, sql } = require('drizzle-orm'); +const { insertTaskSchema, updateTaskSchema } = require('../dist/db/schema/task'); + +const getAllTasks = async (req, res) => { + try { + // Get query parameters (assume middleware passes these) + const page = parseInt(req.query.page) || 1; + const limit = Math.min(parseInt(req.query.limit) || 10, 50); // Max 50 items per page + const offset = (page - 1) * limit; + const search = req.query.search; + const status = req.query?.status?.eq; + const category = req.query?.category?.eq; + + let conditions = [ne(task.isArchived, true)]; // Filter out archived tasks + + if (search) { + conditions.push(ilike(task.title, `%${search}%`)); + } + + if (status) { + conditions.push(eq(task.status, status)); + } + + if (category) { + conditions.push(eq(task.category, category)); + } + + const tasks = await db.query.task.findMany({ + where: and(...conditions), + limit: limit, + offset: offset, + with: { + clubMembership: true, + createdByUser: { + columns: { + displayName: true, + uuid: true + } + } + } + }); + + const totalCount = await db.select({ count: sql`count(*)` }) + .from(task) + .where(and(...conditions)); + + const total = parseInt(totalCount[0]?.count || '0'); + const pages = Math.ceil(total / limit); + + res.json({ + success: true, + data: { + tasks, + pagination: { + page, + limit, + total, + pages + } + } + }); + } catch (error) { + console.error('Error in getAllTasks:', error); + res.status(500).json({ + success: false, + error: "Failed to fetch tasks", + message: error.message + }); + } +}; + + +const getTaskById = async (req, res) => { + try { + const { taskUuid } = req.params; + + if (!isValidUUID(taskUuid)) { + return res.status(400).json({ + success: false, + error: "Invalid task UUID format" + }); + } + + const taskData = await db.query.task.findFirst({ + where: and( + eq(task.uuid, taskUuid), + ne(task.isArchived, true) // Exclude archived tasks + ), + with: { + clubMembership: true, + createdByUser: { + columns: { + displayName: true, + uuid: true + } + }, + updatedByUser: { + columns: { + displayName: true, + uuid: true + } + } + } + }); + + if (!taskData) { + return res.status(404).json({ + success: false, + error: "Task not found" + }); + } + + res.json({ + success: true, + data: taskData + }); + } catch (error) { + console.error('Error in getTaskById:', error); + res.status(500).json({ + success: false, + error: "Failed to fetch task", + message: error.message + }); + } +}; + + +const createTask = async (req, res) => { + try { + console.log('=== DEBUG START ==='); + console.log('req.headers:', req.headers); + console.log('req.body:', req.body); + + // Get user ID from header for API authentication in non-production environments + // In production, req.user would be populated by passport + 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.' + }); + } + + // Prepare task data with required fields - hardcode for testing + const taskData = { + clubMembershipId: req.body.clubMembershipId, + title: req.body.title, + description: req.body.description, + volunteeredSeconds: req.body.volunteeredSeconds, + category: req.body.category, + attachment: req.body.attachment, + createdBy: userId, + updatedBy: userId, + status: 'pending' + }; + + console.log('taskData constructed:', taskData); + console.log('=== DEBUG END ==='); + + // Validate request body + const validatedData = insertTaskSchema.safeParse(taskData); + + if (!validatedData.success) { + console.log('Validation failed:', validatedData.error); + return res.status(400).json({ + success: false, + error: JSON.stringify(validatedData.error.errors, null, 2) + }); + } + + console.log('Validation successful, inserting:', validatedData.data); + + const [newTask] = await db.insert(task) + .values(validatedData.data) + .returning(); + + res.status(201).json({ + success: true, + data: newTask + }); + } catch (error) { + console.error('Error in createTask:', error); + res.status(500).json({ + success: false, + error: "Failed to create task", + message: error.message + }); + } +}; + +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.' + }); + } + + if (!isValidUUID(taskUuid)) { + return res.status(400).json({ + success: false, + error: "Invalid task UUID format" + }); + } + + const existingTask = await db.query.task.findFirst({ + where: and( + eq(task.uuid, taskUuid), + ne(task.isArchived, true) // Exclude archived tasks + ) + }); + + if (!existingTask) { + return res.status(404).json({ + success: false, + error: "Task not found" + }); + } + + // Prepare update data + const updateData = { + ...req.body, + updatedBy: userId + }; + + // Validate request body + const validatedData = updateTaskSchema.safeParse(updateData); + + if (!validatedData.success) { + return res.status(400).json({ + success: false, + error: JSON.stringify(validatedData.error.errors, null, 2) + }); + } + + const [updatedTask] = await db.update(task) + .set(validatedData.data) + .where(eq(task.uuid, taskUuid)) + .returning(); + + res.json({ + success: true, + data: updatedTask + }); + } catch (error) { + console.error('Error in updateTask:', error); + res.status(500).json({ + success: false, + error: "Failed to update task", + message: error.message + }); + } +}; + +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.' + }); + } + + if (!isValidUUID(taskUuid)) { + return res.status(400).json({ + success: false, + error: "Invalid task UUID format" + }); + } + + const existingTask = await db.query.task.findFirst({ + where: eq(task.uuid, taskUuid) + }); + + if (!existingTask) { + return res.status(404).json({ + success: false, + error: "Task not found" + }); + } + + // Archive task rather than deleting + await db.update(task) + .set({ + isArchived: true, + archivedAt: new Date(), + updatedBy: userId + }) + .where(eq(task.uuid, taskUuid)); + + res.status(204).send(); + } catch (error) { + console.error('Error in deleteTask:', error); + res.status(500).json({ + success: false, + error: "Failed to delete task", + message: error.message + }); + } +}; + +// 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); +}; + +module.exports = { + getAllTasks, + getTaskById, + createTask, + updateTask, + deleteTask +}; diff --git a/routes/taskRoutes.js b/routes/taskRoutes.js new file mode 100644 index 0000000..87778bd --- /dev/null +++ b/routes/taskRoutes.js @@ -0,0 +1,51 @@ +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'); +const { + insertTaskSchema, + updateTaskSchema, +} = require('../dist/db/schema/task'); + + +// 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 (task owner or club admin/HR) +router.put( + '/:taskUuid', + isAuthenticated, + asyncHandler(taskController.updateTask) +); + +// Delete task - soft delete (task owner or club admin/HR) +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..0d58047 --- /dev/null +++ b/services/taskService.js @@ -0,0 +1,298 @@ +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 = [], + include = [] + } = 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)]; + + // 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); + + // Build the with clause for relations + const withClause = {}; + if (include.includes('clubMembership')) { + withClause.clubMembership = { + with: { + user: true, + club: true + } + }; + } + if (include.includes('createdByUser')) { + withClause.createdByUser = true; + } + if (include.includes('updatedByUser')) { + withClause.updatedByUser = true; + } + + // Get paginated tasks + const tasks = await db.query.task.findMany({ + where: whereClause, + columns, + with: withClause, + 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 findTaskById = async (taskId, params = {}) => { + const { fields = [], include = [] } = 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); + + // Build the with clause for relations + const withClause = {}; + if (include.includes('clubMembership')) { + withClause.clubMembership = { + with: { + user: true, + club: true + } + }; + } + if (include.includes('createdByUser')) { + withClause.createdByUser = true; + } + if (include.includes('updatedByUser')) { + withClause.updatedByUser = true; + } + + const taskData = await db.query.task.findFirst({ + where: whereClause, + columns, + with: withClause + }); + + 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, + findTaskById, + createTask, + updateTask, + deleteTask, +}; \ No newline at end of file From 3fd0bef712c6398cfcfb875459e6b978e099122f Mon Sep 17 00:00:00 2001 From: Salehalobilan Date: Fri, 23 May 2025 13:35:55 +0300 Subject: [PATCH 3/4] Fixed some mistake did in app.js for debugging --- app.js | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/app.js b/app.js index 0510d01..b9ca8ff 100644 --- a/app.js +++ b/app.js @@ -40,14 +40,49 @@ app.get('/api', (req, res) => { res.json({ message: 'API is working!' }); }); -// API Routes +// API Routes (including TypeScript route files) 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')); +// Global error handlers +const createError = require('http-errors'); + +// Catch 404 for routes not found +app.use((req, res, next) => { + next(createError(404, 'Endpoint not found')); +}); + +// Global error handler +app.use((err, req, res, next) => { + console.error(err); + + // Get status code (default to 500 if not an HTTP error) + const status = err.status || err.statusCode || 500; + + // Format the error response + const errorResponse = { + error: { + code: status >= 500 ? 'INTERNAL_ERROR' : err.code || String(status), + message: + status >= 500 && process.env.NODE_ENV === 'production' + ? 'Internal Server Error' + : err.message || 'Something went wrong', + }, + }; + + // Include error details in development + if (process.env.NODE_ENV !== 'production' && err.stack) { + errorResponse.error.stack = err.stack; + } + + res.status(status).json(errorResponse); +}); + // Start the server const PORT = process.env.PORT || 3000; @@ -64,4 +99,4 @@ connectDB() }); // Export for testing -module.exports = app; +module.exports = app; \ No newline at end of file From 93ab5d03a726c8352dc878618ecfdefb10197fa8 Mon Sep 17 00:00:00 2001 From: Salehalobilan Date: Mon, 9 Jun 2025 11:27:32 +0300 Subject: [PATCH 4/4] seperate service layer from controller layer, remove include for complexity, add more get endpoints for membership and club --- API-REST/Task/Task-Create.bru | 4 +- API-REST/Task/Task-Delete.bru | 4 +- API-REST/Task/Task-Get-All-For-Club.bru | 113 +++++ API-REST/Task/Task-Get-All-For-Membership.bru | 109 +++++ API-REST/Task/Task-Get-All.bru | 25 +- API-REST/Task/Task-Get-By-ID.bru | 28 +- API-REST/Task/Task-Update.bru | 4 +- controllers/taskController.js | 453 +++++++++++------- routes/taskRoutes.js | 32 +- services/taskService.js | 414 ++++++++++++++-- 10 files changed, 950 insertions(+), 236 deletions(-) create mode 100644 API-REST/Task/Task-Get-All-For-Club.bru create mode 100644 API-REST/Task/Task-Get-All-For-Membership.bru diff --git a/API-REST/Task/Task-Create.bru b/API-REST/Task/Task-Create.bru index 8b114ca..c6a9479 100644 --- a/API-REST/Task/Task-Create.bru +++ b/API-REST/Task/Task-Create.bru @@ -1,7 +1,7 @@ meta { name: Task-Create type: http - seq: 3 + seq: 6 } post { @@ -93,4 +93,4 @@ docs { - `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 -} \ No newline at end of file +} diff --git a/API-REST/Task/Task-Delete.bru b/API-REST/Task/Task-Delete.bru index d546850..b954f51 100644 --- a/API-REST/Task/Task-Delete.bru +++ b/API-REST/Task/Task-Delete.bru @@ -1,7 +1,7 @@ meta { name: Task-Delete type: http - seq: 5 + seq: 8 } delete { @@ -50,4 +50,4 @@ docs { - 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 -} \ No newline at end of file +} 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 index 907ab87..d5ea888 100644 --- a/API-REST/Task/Task-Get-All.bru +++ b/API-REST/Task/Task-Get-All.bru @@ -1,7 +1,7 @@ meta { name: Task-Get-All type: http - seq: 1 + seq: 2 } get { @@ -35,7 +35,6 @@ docs { - `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 - `status[eq]`: Filter by status (accepted, pending, changes_requested, denied) - `category[eq]`: Filter by category @@ -43,7 +42,7 @@ docs { ## Example ``` - GET /tasks?page=1&limit=10&status[eq]=pending&include=clubMembership + GET /tasks?page=1&limit=10&status[eq]=pending x-user-id: 1 ``` @@ -64,7 +63,23 @@ docs { "attachment": "https://example.com/attachment.pdf", "reviewComment": null, "createdAt": "2024-01-01T00:00:00Z", - "updatedAt": "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": { @@ -80,4 +95,4 @@ docs { ``` - `401 Unauthorized` if user is not authenticated - `500 Internal Server Error` for server issues -} \ No newline at end of file +} diff --git a/API-REST/Task/Task-Get-By-ID.bru b/API-REST/Task/Task-Get-By-ID.bru index de5b041..180523a 100644 --- a/API-REST/Task/Task-Get-By-ID.bru +++ b/API-REST/Task/Task-Get-By-ID.bru @@ -1,11 +1,11 @@ meta { name: Task-Get-By-ID type: http - seq: 2 + seq: 3 } get { - url: http://localhost:5000/tasks/{{taskUuid}}?fields={{selectedFields}}&include={{includeRelations}} + url: http://localhost:5000/tasks/{{taskUuid}}?fields={{selectedFields}} body: none auth: none } @@ -18,7 +18,6 @@ vars:pre-request { userId: 1 taskUuid: "task-uuid-here" selectedFields: "title,description,status,volunteeredSeconds,category" - includeRelations: "clubMembership,createdByUser" } docs { @@ -34,12 +33,10 @@ docs { ## Query Parameters - `fields`: Comma-separated list of fields to include - - `include`: Comma-separated list of relations to include - - Available relations: clubMembership, createdByUser, updatedByUser ## Example ``` - GET /tasks/550e8400-e29b-41d4-a716-446655440000?fields=title,description,status&include=clubMembership + GET /tasks/550e8400-e29b-41d4-a716-446655440000?fields=title,description,status x-user-id: 1 ``` @@ -63,7 +60,22 @@ docs { "clubMembership": { "id": 1, "role": "member", - "status": "active" + "status": "active", + "user": { + "firstName": "John", + "lastName": "Doe" + }, + "club": { + "name": "Programming Club" + } + }, + "createdByUser": { + "firstName": "John", + "lastName": "Doe" + }, + "updatedByUser": { + "firstName": "John", + "lastName": "Doe" } } } @@ -72,4 +84,4 @@ docs { - `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 -} \ No newline at end of file +} diff --git a/API-REST/Task/Task-Update.bru b/API-REST/Task/Task-Update.bru index e22a882..2838586 100644 --- a/API-REST/Task/Task-Update.bru +++ b/API-REST/Task/Task-Update.bru @@ -1,7 +1,7 @@ meta { name: Task-Update type: http - seq: 4 + seq: 7 } put { @@ -104,4 +104,4 @@ docs { - `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 -} \ No newline at end of file +} diff --git a/controllers/taskController.js b/controllers/taskController.js index 18d48d1..a909645 100644 --- a/controllers/taskController.js +++ b/controllers/taskController.js @@ -1,140 +1,248 @@ -const { db } = require('../dist/db'); -const { task } = require('../dist/db/schema'); -const { eq, and, ilike, ne, sql } = require('drizzle-orm'); +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 query parameters (assume middleware passes these) - const page = parseInt(req.query.page) || 1; - const limit = Math.min(parseInt(req.query.limit) || 10, 50); // Max 50 items per page - const offset = (page - 1) * limit; - const search = req.query.search; - const status = req.query?.status?.eq; - const category = req.query?.category?.eq; - - let conditions = [ne(task.isArchived, true)]; // Filter out archived tasks - - if (search) { - conditions.push(ilike(task.title, `%${search}%`)); - } - - if (status) { - conditions.push(eq(task.status, status)); - } - - if (category) { - conditions.push(eq(task.category, category)); - } - - const tasks = await db.query.task.findMany({ - where: and(...conditions), - limit: limit, - offset: offset, - with: { - clubMembership: true, - createdByUser: { - columns: { - displayName: true, - uuid: true - } - } + // 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 totalCount = await db.select({ count: sql`count(*)` }) - .from(task) - .where(and(...conditions)); - - const total = parseInt(totalCount[0]?.count || '0'); - const pages = Math.ceil(total / limit); - + + const result = await taskService.getAllTasks(params); + res.json({ success: true, data: { - tasks, + tasks: result.items, pagination: { - page, - limit, - total, - pages + page: result.pagination.page, + limit: result.pagination.limit, + total: result.pagination.total, + pages: result.pagination.totalPages } } }); } catch (error) { console.error('Error in getAllTasks:', error); - res.status(500).json({ + const statusCode = error.statusCode || 500; + res.status(statusCode).json({ success: false, - error: "Failed to fetch tasks", - message: error.message + 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 getTaskById = async (req, res) => { + 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 { taskUuid } = req.params; - - if (!isValidUUID(taskUuid)) { - return res.status(400).json({ - success: false, - error: "Invalid task UUID format" - }); - } - - const taskData = await db.query.task.findFirst({ - where: and( - eq(task.uuid, taskUuid), - ne(task.isArchived, true) // Exclude archived tasks - ), - with: { - clubMembership: true, - createdByUser: { - columns: { - displayName: true, - uuid: true - } - }, - updatedByUser: { - columns: { - displayName: true, - uuid: true - } + 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; - if (!taskData) { - return res.status(404).json({ - success: false, - error: "Task not found" - }); - } - + const params = {}; + + const taskData = await taskService.findTaskById(taskUuid, params); + res.json({ success: true, data: taskData }); } catch (error) { console.error('Error in getTaskById:', error); - res.status(500).json({ + const statusCode = error.statusCode || 500; + res.status(statusCode).json({ success: false, - error: "Failed to fetch task", - message: error.message + 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 - // In production, req.user would be populated by passport const userId = req.user ? req.user.id : parseInt(req.headers['x-user-id']); console.log('x-user-id header:', req.headers['x-user-id']); @@ -149,38 +257,75 @@ const createTask = async (req, res) => { }); } - // Prepare task data with required fields - hardcode for testing + // 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: req.body.clubMembershipId, + clubMembershipId: clubMembershipId, title: req.body.title, description: req.body.description, volunteeredSeconds: req.body.volunteeredSeconds, category: req.body.category, attachment: req.body.attachment, - createdBy: userId, - updatedBy: userId, status: 'pending' }; console.log('taskData constructed:', taskData); console.log('=== DEBUG END ==='); - // Validate request body - const validatedData = insertTaskSchema.safeParse(taskData); + // 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: JSON.stringify(validatedData.error.errors, null, 2) + error: 'Validation failed', + details: validatedData.error.errors.map(err => ({ + field: err.path.join('.'), + message: err.message, + received: err.received + })) }); } - console.log('Validation successful, inserting:', validatedData.data); + console.log('Validation successful, creating task with service:', validatedData.data); - const [newTask] = await db.insert(task) - .values(validatedData.data) - .returning(); + const newTask = await taskService.createTask(validatedData.data, userId); res.status(201).json({ success: true, @@ -188,10 +333,21 @@ const createTask = async (req, res) => { }); } catch (error) { console.error('Error in createTask:', error); - res.status(500).json({ + + // 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: "Failed to create task", - message: error.message + error: error.message || "Failed to create task" }); } }; @@ -210,35 +366,8 @@ const updateTask = async (req, res) => { }); } - if (!isValidUUID(taskUuid)) { - return res.status(400).json({ - success: false, - error: "Invalid task UUID format" - }); - } - - const existingTask = await db.query.task.findFirst({ - where: and( - eq(task.uuid, taskUuid), - ne(task.isArchived, true) // Exclude archived tasks - ) - }); - - if (!existingTask) { - return res.status(404).json({ - success: false, - error: "Task not found" - }); - } - - // Prepare update data - const updateData = { - ...req.body, - updatedBy: userId - }; - // Validate request body - const validatedData = updateTaskSchema.safeParse(updateData); + const validatedData = updateTaskSchema.safeParse(req.body); if (!validatedData.success) { return res.status(400).json({ @@ -247,10 +376,7 @@ const updateTask = async (req, res) => { }); } - const [updatedTask] = await db.update(task) - .set(validatedData.data) - .where(eq(task.uuid, taskUuid)) - .returning(); + const updatedTask = await taskService.updateTask(taskUuid, validatedData.data, userId); res.json({ success: true, @@ -258,10 +384,10 @@ const updateTask = async (req, res) => { }); } catch (error) { console.error('Error in updateTask:', error); - res.status(500).json({ + const statusCode = error.statusCode || 500; + res.status(statusCode).json({ success: false, - error: "Failed to update task", - message: error.message + error: error.message || "Failed to update task" }); } }; @@ -280,53 +406,24 @@ const deleteTask = async (req, res) => { }); } - if (!isValidUUID(taskUuid)) { - return res.status(400).json({ - success: false, - error: "Invalid task UUID format" - }); - } - - const existingTask = await db.query.task.findFirst({ - where: eq(task.uuid, taskUuid) - }); - - if (!existingTask) { - return res.status(404).json({ - success: false, - error: "Task not found" - }); - } - - // Archive task rather than deleting - await db.update(task) - .set({ - isArchived: true, - archivedAt: new Date(), - updatedBy: userId - }) - .where(eq(task.uuid, taskUuid)); + await taskService.deleteTask(taskUuid, userId); res.status(204).send(); } catch (error) { console.error('Error in deleteTask:', error); - res.status(500).json({ + const statusCode = error.statusCode || 500; + res.status(statusCode).json({ success: false, - error: "Failed to delete task", - message: error.message + error: error.message || "Failed to delete 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); -}; - module.exports = { getAllTasks, + getAllTasksForUser, + getAllTasksForClub, + getAllTasksForMembership, getTaskById, createTask, updateTask, diff --git a/routes/taskRoutes.js b/routes/taskRoutes.js index 87778bd..2c504fa 100644 --- a/routes/taskRoutes.js +++ b/routes/taskRoutes.js @@ -5,12 +5,32 @@ const { isAuthenticated } = require('../middleware/CheckAuth'); const { parseQueryParams } = require('../middleware/QueryParser'); const validateBody = require('../middleware/validateBody'); const asyncHandler = require('../utils/asyncHandler'); -const { - insertTaskSchema, - updateTaskSchema, -} = require('../dist/db/schema/task'); +// 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( '/', @@ -34,14 +54,14 @@ router.post( asyncHandler(taskController.createTask) ); -// Update task (task owner or club admin/HR) +// Update task router.put( '/:taskUuid', isAuthenticated, asyncHandler(taskController.updateTask) ); -// Delete task - soft delete (task owner or club admin/HR) +// Delete task - soft delete router.delete( '/:taskUuid', isAuthenticated, diff --git a/services/taskService.js b/services/taskService.js index 0d58047..912050c 100644 --- a/services/taskService.js +++ b/services/taskService.js @@ -18,7 +18,7 @@ const getAllTasks = async (params = {}) => { search, filters = {}, fields = [], - include = [] + currentUserId = null } = params; const { page = 1, limit = 10 } = pagination; @@ -28,6 +28,11 @@ const getAllTasks = async (params = {}) => { // 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( @@ -55,28 +60,126 @@ const getAllTasks = async (params = {}) => { // Build columns object for field selection const columns = buildSelectFields(fields, task); - // Build the with clause for relations - const withClause = {}; - if (include.includes('clubMembership')) { - withClause.clubMembership = { - with: { - user: true, - club: true + // 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'); } - if (include.includes('createdByUser')) { - withClause.createdByUser = true; + + // 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'); } - if (include.includes('updatedByUser')) { - withClause.updatedByUser = true; + + // 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: withClause, + with: { + clubMembership: { + with: { + user: true, + club: true + } + }, + createdByUser: true, + updatedByUser: true + }, limit: limit, offset: offset, orderBy: (t, { asc, desc }) => { @@ -98,6 +201,7 @@ const getAllTasks = async (params = {}) => { return { items: tasks, + userUuid, pagination: { page, limit, @@ -109,9 +213,258 @@ const getAllTasks = async (params = {}) => { }; }; +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 = [], include = [] } = params; + const { fields = [] } = params; let whereClause; @@ -135,27 +488,19 @@ const findTaskById = async (taskId, params = {}) => { const columns = buildSelectFields(fields, task); - // Build the with clause for relations - const withClause = {}; - if (include.includes('clubMembership')) { - withClause.clubMembership = { - with: { - user: true, - club: true - } - }; - } - if (include.includes('createdByUser')) { - withClause.createdByUser = true; - } - if (include.includes('updatedByUser')) { - withClause.updatedByUser = true; - } - const taskData = await db.query.task.findFirst({ where: whereClause, columns, - with: withClause + with: { + clubMembership: { + with: { + user: true, + club: true + } + }, + createdByUser: true, + updatedByUser: true + } }); if (!taskData) { @@ -291,6 +636,9 @@ const deleteTask = async (taskId, userId) => { module.exports = { getAllTasks, + getAllTasksForUser, + getAllTasksForClub, + getAllTasksForMembership, findTaskById, createTask, updateTask,