Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 47 additions & 14 deletions task-api/src/routes/tasks.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,41 @@
const express = require('express');
const express = require("express");
const router = express.Router();
const taskService = require('../services/taskService');
const { validateCreateTask, validateUpdateTask } = require('../utils/validators');
const taskService = require("../services/taskService");
const {
validateCreateTask,
validateUpdateTask,
validateAssignee,
} = require("../utils/validators");

router.get('/stats', (req, res) => {
const VALID_STATUS = ["todo", "in_progress", "done"];

router.get("/stats", (req, res) => {
const stats = taskService.getStats();
res.json(stats);
});

router.get('/', (req, res) => {
router.get("/", (req, res) => {
const { status, page, limit } = req.query;

if (status) {
if (!VALID_STATUS.includes(status)) {
return res.status(400).json({ error: "invalid status query parameter" });
}
const tasks = taskService.getByStatus(status);
return res.json(tasks);
}

if (page !== undefined || limit !== undefined) {
const pageNum = parseInt(page) || 1;
const limitNum = parseInt(limit) || 10;
const pageNum = page !== undefined ? parseInt(page, 10) : 1;
const limitNum = limit !== undefined ? parseInt(limit, 10) : 10;
if (
(page !== undefined && (Number.isNaN(pageNum) || pageNum < 1)) ||
(limit !== undefined && (Number.isNaN(limitNum) || limitNum < 1))
) {
return res
.status(400)
.json({ error: "page and limit must be positive integers" });
}
const tasks = taskService.getPaginated(pageNum, limitNum);
return res.json(tasks);
}
Expand All @@ -27,7 +44,7 @@ router.get('/', (req, res) => {
res.json(tasks);
});

router.post('/', (req, res) => {
router.post("/", (req, res) => {
const error = validateCreateTask(req.body);
if (error) {
return res.status(400).json({ error });
Expand All @@ -37,35 +54,51 @@ router.post('/', (req, res) => {
res.status(201).json(task);
});

router.put('/:id', (req, res) => {
router.put("/:id", (req, res) => {
const error = validateUpdateTask(req.body);
if (error) {
return res.status(400).json({ error });
}

const task = taskService.update(req.params.id, req.body);
if (!task) {
return res.status(404).json({ error: 'Task not found' });
return res.status(404).json({ error: "Task not found" });
}

res.json(task);
});

router.delete('/:id', (req, res) => {
router.delete("/:id", (req, res) => {
const deleted = taskService.remove(req.params.id);
if (!deleted) {
return res.status(404).json({ error: 'Task not found' });
return res.status(404).json({ error: "Task not found" });
}

res.status(204).send();
});

router.patch('/:id/complete', (req, res) => {
router.patch("/:id/complete", (req, res) => {
const task = taskService.completeTask(req.params.id);
if (!task) {
return res.status(404).json({ error: 'Task not found' });
return res.status(404).json({ error: "Task not found" });
}

res.json(task);
});

router.patch("/:id/assign", (req, res) => {
const error = validateAssignee(req.body);
if (error) {
return res.status(400).json({ error });
}

const assignee = req.body.assignee.trim();
const task = taskService.assignTask(req.params.id, assignee);
if (!task) {
return res.status(404).json({ error: "Task not found" });
}

// Overwrite existing assignee when present because reassignment should update the task assignee.
res.json(task);
});

Expand Down
38 changes: 31 additions & 7 deletions task-api/src/services/taskService.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
const { v4: uuidv4 } = require('uuid');
const { v4: uuidv4 } = require("uuid");

let tasks = [];

const getAll = () => [...tasks];

const findById = (id) => tasks.find((t) => t.id === id);

const getByStatus = (status) => tasks.filter((t) => t.status.includes(status));
const getByStatus = (status) => tasks.filter((t) => t.status === status);

const getPaginated = (page, limit) => {
const offset = page * limit;
// Expected: page 1 begins at offset 0, page 2 begins at offset limit.
// Actual bug: offset was computed as page * limit, causing the first page to skip items.
const offset = (page - 1) * limit;
return tasks.slice(offset, offset + limit);
};

Expand All @@ -20,22 +22,30 @@ const getStats = () => {

tasks.forEach((t) => {
if (counts[t.status] !== undefined) counts[t.status]++;
if (t.dueDate && t.status !== 'done' && new Date(t.dueDate) < now) {
if (t.dueDate && t.status !== "done" && new Date(t.dueDate) < now) {
overdue++;
}
});

return { ...counts, overdue };
};

const create = ({ title, description = '', status = 'todo', priority = 'medium', dueDate = null }) => {
const create = ({
title,
description = "",
status = "todo",
priority = "medium",
dueDate = null,
assignee = null,
}) => {
const task = {
id: uuidv4(),
title,
description,
status,
priority,
dueDate,
assignee,
completedAt: null,
createdAt: new Date().toISOString(),
};
Expand All @@ -52,6 +62,19 @@ const update = (id, fields) => {
return updated;
};

const assignTask = (id, assignee) => {
const task = findById(id);
if (!task) return null;

const updated = {
...task,
assignee,
};
const index = tasks.findIndex((t) => t.id === id);
tasks[index] = updated;
return updated;
};

const remove = (id) => {
const index = tasks.findIndex((t) => t.id === id);
if (index === -1) return false;
Expand All @@ -66,8 +89,8 @@ const completeTask = (id) => {

const updated = {
...task,
priority: 'medium',
status: 'done',
priority: "medium",
status: "done",
completedAt: new Date().toISOString(),
};

Expand All @@ -90,5 +113,6 @@ module.exports = {
update,
remove,
completeTask,
assignTask,
_reset,
};
56 changes: 43 additions & 13 deletions task-api/src/utils/validators.js
Original file line number Diff line number Diff line change
@@ -1,36 +1,66 @@
const VALID_STATUSES = ['todo', 'in_progress', 'done'];
const VALID_PRIORITIES = ['low', 'medium', 'high'];
const VALID_STATUSES = ["todo", "in_progress", "done"];
const VALID_PRIORITIES = ["low", "medium", "high"];

const validateCreateTask = (body) => {
if (!body.title || typeof body.title !== 'string' || body.title.trim() === '') {
return 'title is required and must be a non-empty string';
if (
!body.title ||
typeof body.title !== "string" ||
body.title.trim() === ""
) {
return "title is required and must be a non-empty string";
}
if (body.status && !VALID_STATUSES.includes(body.status)) {
return `status must be one of: ${VALID_STATUSES.join(', ')}`;
return `status must be one of: ${VALID_STATUSES.join(", ")}`;
}
if (body.priority && !VALID_PRIORITIES.includes(body.priority)) {
return `priority must be one of: ${VALID_PRIORITIES.join(', ')}`;
return `priority must be one of: ${VALID_PRIORITIES.join(", ")}`;
}
if (
body.assignee !== undefined &&
(typeof body.assignee !== "string" || body.assignee.trim() === "")
) {
return "assignee must be a non-empty string when provided";
}
if (body.dueDate && isNaN(Date.parse(body.dueDate))) {
return 'dueDate must be a valid ISO date string';
return "dueDate must be a valid ISO date string";
}
return null;
};

const validateUpdateTask = (body) => {
if (body.title !== undefined && (typeof body.title !== 'string' || body.title.trim() === '')) {
return 'title must be a non-empty string';
if (
body.title !== undefined &&
(typeof body.title !== "string" || body.title.trim() === "")
) {
return "title must be a non-empty string";
}
if (body.status && !VALID_STATUSES.includes(body.status)) {
return `status must be one of: ${VALID_STATUSES.join(', ')}`;
return `status must be one of: ${VALID_STATUSES.join(", ")}`;
}
if (body.priority && !VALID_PRIORITIES.includes(body.priority)) {
return `priority must be one of: ${VALID_PRIORITIES.join(', ')}`;
return `priority must be one of: ${VALID_PRIORITIES.join(", ")}`;
}
if (
body.assignee !== undefined &&
(typeof body.assignee !== "string" || body.assignee.trim() === "")
) {
return "assignee must be a non-empty string when provided";
}
if (body.dueDate && isNaN(Date.parse(body.dueDate))) {
return 'dueDate must be a valid ISO date string';
return "dueDate must be a valid ISO date string";
}
return null;
};

const validateAssignee = (body) => {
if (
!body ||
typeof body.assignee !== "string" ||
body.assignee.trim() === ""
) {
return "assignee is required and must be a non-empty string";
}
return null;
};

module.exports = { validateCreateTask, validateUpdateTask };
module.exports = { validateCreateTask, validateUpdateTask, validateAssignee };
Loading