Commit 34f8f91b authored by ThinhNC's avatar ThinhNC

feat: CURD task

parent 8e152a7a
-- AlterTable
ALTER TABLE "tasks" ADD COLUMN "deleted_at" TIMESTAMP(3);
...@@ -146,6 +146,7 @@ model Task { ...@@ -146,6 +146,7 @@ model Task {
deadline DateTime deadline DateTime
priority TaskPriority @default(MEDIUM) priority TaskPriority @default(MEDIUM)
createdBy String @map("created_by") @db.Uuid createdBy String @map("created_by") @db.Uuid
deletedAt DateTime? @map("deleted_at")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
......
...@@ -246,8 +246,48 @@ export const swaggerSpec = { ...@@ -246,8 +246,48 @@ export const swaggerSpec = {
status: { type: 'string', enum: ['ACTIVE', 'COMPLETED', 'DROPPED'] }, status: { type: 'string', enum: ['ACTIVE', 'COMPLETED', 'DROPPED'] },
}, },
}, },
Task: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
title: { type: 'string', example: 'Lập trình tính năng đăng nhập' },
description: { type: 'string', nullable: true, example: 'Sử dụng JWT và bcrypt để bảo mật mật khẩu' },
deadline: { type: 'string', format: 'date-time' },
priority: { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH'] },
createdBy: { type: 'string', format: 'uuid' },
deletedAt: { type: 'string', format: 'date-time', nullable: true },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
creator: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
email: { type: 'string', format: 'email' },
fullName: { type: 'string', nullable: true },
},
},
},
},
CreateTaskBody: {
type: 'object',
required: ['title', 'deadline'],
properties: {
title: { type: 'string', example: 'Lập trình tính năng đăng nhập' },
description: { type: 'string', example: 'Sử dụng JWT và bcrypt' },
deadline: { type: 'string', format: 'date-time', example: '2025-08-10T17:00:00.000Z' },
priority: { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH'], default: 'MEDIUM' },
},
},
UpdateTaskBody: {
type: 'object',
properties: {
title: { type: 'string' },
description: { type: 'string', nullable: true },
deadline: { type: 'string', format: 'date-time' },
priority: { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH'] },
},
},
}, },
// ─── Reusable parameters ─────────────────────────────────────────────────
parameters: { parameters: {
PageParam: { in: 'query', name: 'page', schema: { type: 'integer', default: 1 } }, PageParam: { in: 'query', name: 'page', schema: { type: 'integer', default: 1 } },
LimitParam: { in: 'query', name: 'limit', schema: { type: 'integer', default: 20, maximum: 100 } }, LimitParam: { in: 'query', name: 'limit', schema: { type: 'integer', default: 20, maximum: 100 } },
...@@ -266,6 +306,7 @@ export const swaggerSpec = { ...@@ -266,6 +306,7 @@ export const swaggerSpec = {
{ name: 'Users', description: 'Quản lý tài khoản người dùng (Admin only)' }, { name: 'Users', description: 'Quản lý tài khoản người dùng (Admin only)' },
{ name: 'Applications', description: 'Đơn xin thực tập (Onboarding)' }, { name: 'Applications', description: 'Đơn xin thực tập (Onboarding)' },
{ name: 'Interns', description: 'Hồ sơ thực tập sinh (Interns)' }, { name: 'Interns', description: 'Hồ sơ thực tập sinh (Interns)' },
{ name: 'Tasks', description: 'Quản lý công việc (Tasks)' },
{ name: 'System', description: 'Health check' }, { name: 'System', description: 'Health check' },
], ],
paths: { paths: {
...@@ -581,5 +622,86 @@ export const swaggerSpec = { ...@@ -581,5 +622,86 @@ export const swaggerSpec = {
}, },
}, },
}, },
'/tasks': {
get: {
tags: ['Tasks'],
summary: 'Danh sách công việc (Admin / Leader / Intern)',
security: [{ BearerAuth: [] }],
parameters: [
{ in: 'query', name: 'title', schema: { type: 'string' } },
{ in: 'query', name: 'priority', schema: { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH'] } },
{ in: 'query', name: 'createdBy', schema: { type: 'string', format: 'uuid' } },
{ in: 'query', name: 'deadlineFrom', schema: { type: 'string', format: 'date-time' } },
{ in: 'query', name: 'deadlineTo', schema: { type: 'string', format: 'date-time' } },
{ in: 'query', name: 'sortBy', schema: { type: 'string', enum: ['createdAt', 'title', 'deadline', 'priority'], default: 'createdAt' } },
{ $ref: '#/components/parameters/OrderParam' },
{ $ref: '#/components/parameters/PageParam' },
{ $ref: '#/components/parameters/LimitParam' },
],
responses: {
200: {
description: 'Danh sách công việc có phân trang',
content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { type: 'array', items: { $ref: '#/components/schemas/Task' } }, meta: { $ref: '#/components/schemas/PaginationMeta' } } }] } } },
},
401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' },
},
},
post: {
tags: ['Tasks'],
summary: 'Tạo công việc mới (Admin / Leader)',
security: [{ BearerAuth: [] }],
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/CreateTaskBody' } } } },
responses: {
201: {
description: 'Công việc đã được tạo',
content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { $ref: '#/components/schemas/Task' } } }] } } },
},
401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' },
422: { $ref: '#/components/responses/Validation' },
},
},
},
'/tasks/{id}': {
get: {
tags: ['Tasks'],
summary: 'Chi tiết công việc theo ID',
security: [{ BearerAuth: [] }],
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string', format: 'uuid' } }],
responses: {
200: { description: 'Thông tin công việc', content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { $ref: '#/components/schemas/Task' } } }] } } } },
401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' },
404: { $ref: '#/components/responses/NotFound' },
},
},
put: {
tags: ['Tasks'],
summary: 'Cập nhật công việc (Admin / Leader)',
security: [{ BearerAuth: [] }],
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string', format: 'uuid' } }],
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/UpdateTaskBody' } } } },
responses: {
200: { description: 'Công việc đã được cập nhật', content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { data: { $ref: '#/components/schemas/Task' } } }] } } } },
401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' },
404: { $ref: '#/components/responses/NotFound' },
422: { $ref: '#/components/responses/Validation' },
},
},
delete: {
tags: ['Tasks'],
summary: 'Xoá mềm công việc (Admin / Leader)',
security: [{ BearerAuth: [] }],
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string', format: 'uuid' } }],
responses: {
200: { description: 'Đã xoá mềm', content: { 'application/json': { schema: { allOf: [{ $ref: '#/components/schemas/SuccessResponse' }, { type: 'object', properties: { message: { type: 'string' } } }] } } } },
401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' },
404: { $ref: '#/components/responses/NotFound' },
},
},
},
}, },
}; };
...@@ -12,42 +12,11 @@ import { ...@@ -12,42 +12,11 @@ import {
const router = Router(); const router = Router();
const controller = new InternController(); const controller = new InternController();
router.get( // GET /interns?fullName=...&department=...&position=...&status=...&leaderId=...&discordRoleGranted=...&startDateFrom=...&startDateTo=...&sortBy=...&order=...&page=...&limit=...
'/', router.get('/', authMiddleware, requireRole('ADMIN', 'LEADER'), validate(findAllInternSchema, 'query'), controller.findAll);
authMiddleware, router.get('/:id', authMiddleware, requireRole('ADMIN', 'LEADER'), controller.findById);
requireRole('ADMIN', 'LEADER'), router.post('/', authMiddleware, requireRole('ADMIN', 'LEADER'), validate(createInternSchema), controller.create);
validate(findAllInternSchema, 'query'), router.put('/:id', authMiddleware, requireRole('ADMIN', 'LEADER'), validate(updateInternSchema), controller.update);
controller.findAll, router.delete('/:id', authMiddleware, requireRole('ADMIN'), controller.delete);
);
router.get(
'/:id',
authMiddleware,
requireRole('ADMIN', 'LEADER'),
controller.findById,
);
router.post(
'/',
authMiddleware,
requireRole('ADMIN', 'LEADER'),
validate(createInternSchema),
controller.create,
);
router.put(
'/:id',
authMiddleware,
requireRole('ADMIN', 'LEADER'),
validate(updateInternSchema),
controller.update,
);
router.delete(
'/:id',
authMiddleware,
requireRole('ADMIN'),
controller.delete,
);
export default router; export default router;
import { Request, Response, NextFunction } from 'express';
import { TaskService } from './task.service';
import { TaskQueryDto } from './task.dto';
export class TaskController {
private readonly service = new TaskService();
findAll = async (req: Request, res: Response, next: NextFunction) => {
try {
const query = req.query as unknown as TaskQueryDto;
const result = await this.service.findAll(query);
res.json({ success: true, ...result });
} catch (error) {
next(error);
}
};
findById = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.findById(req.params.id);
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
};
create = async (req: Request, res: Response, next: NextFunction) => {
try {
const createdBy = req.user.id;
const result = await this.service.create(req.body, createdBy);
res.status(201).json({ success: true, data: result });
} catch (error) {
next(error);
}
};
update = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.update(req.params.id, req.body);
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
};
delete = async (req: Request, res: Response, next: NextFunction) => {
try {
await this.service.delete(req.params.id);
res.json({ success: true, message: 'Task deleted successfully' });
} catch (error) {
next(error);
}
};
}
import { TaskPriority } from '@prisma/client';
export interface TaskQueryDto {
title?: string;
priority?: TaskPriority;
createdBy?: string;
deadlineFrom?: string;
deadlineTo?: string;
sortBy?: 'createdAt' | 'title' | 'deadline' | 'priority';
order?: 'asc' | 'desc';
page?: number;
limit?: number;
}
export interface CreateTaskDto {
title: string;
description?: string;
deadline: string;
priority?: TaskPriority;
}
export interface UpdateTaskDto {
title?: string;
description?: string | null;
deadline?: string;
priority?: TaskPriority;
}
import { Prisma } from '@prisma/client';
import { prisma } from '../../database/prisma.client';
import { TaskQueryDto, CreateTaskDto, UpdateTaskDto } from './task.dto';
const creatorSelect = {
id: true,
email: true,
fullName: true,
};
const defaultInclude = {
creator: { select: creatorSelect },
assignment: true,
};
export class TaskRepository {
async findAll(query: TaskQueryDto) {
const {
title,
priority,
createdBy,
deadlineFrom,
deadlineTo,
sortBy = 'createdAt',
order = 'desc',
page = 1,
limit = 20,
} = query;
const where: Prisma.TaskWhereInput = {
deletedAt: null,
...(title ? { title: { contains: title, mode: 'insensitive' } } : {}),
...(priority ? { priority } : {}),
...(createdBy ? { createdBy } : {}),
...(deadlineFrom || deadlineTo
? {
deadline: {
...(deadlineFrom ? { gte: new Date(deadlineFrom) } : {}),
...(deadlineTo ? { lte: new Date(deadlineTo) } : {}),
},
}
: {}),
};
const skip = (page - 1) * limit;
const [data, total] = await prisma.$transaction([
prisma.task.findMany({
where,
include: defaultInclude,
orderBy: { [sortBy]: order },
skip,
take: limit,
}),
prisma.task.count({ where }),
]);
return {
data,
meta: { total, page, limit, totalPages: Math.ceil(total / limit) },
};
}
findById(id: string) {
return prisma.task.findFirst({
where: { id, deletedAt: null },
include: defaultInclude,
});
}
create(data: CreateTaskDto, createdBy: string) {
return prisma.task.create({
data: {
title: data.title,
description: data.description,
deadline: new Date(data.deadline),
priority: data.priority,
createdBy,
},
include: defaultInclude,
});
}
update(id: string, data: UpdateTaskDto) {
return prisma.task.update({
where: { id },
data: {
...(data.title !== undefined ? { title: data.title } : {}),
...(data.description !== undefined ? { description: data.description } : {}),
...(data.deadline !== undefined ? { deadline: new Date(data.deadline) } : {}),
...(data.priority !== undefined ? { priority: data.priority } : {}),
},
include: defaultInclude,
});
}
softDelete(id: string) {
return prisma.task.update({
where: { id },
data: { deletedAt: new Date() },
include: defaultInclude,
});
}
}
import { Router } from 'express';
import { TaskController } from './task.controller';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requireRole } from '../../middlewares/role.middleware';
import { validate } from '../../middlewares/validate.middleware';
import {
findAllTaskSchema,
createTaskSchema,
updateTaskSchema,
} from './task.validation';
const router = Router();
const controller = new TaskController();
// GET /tasks?page=...&limit=...&sortBy=...&order=...&title=...&priority=...&createdBy=...&deadlineFrom=...&deadlineTo=...
router.get('/', authMiddleware, requireRole('ADMIN', 'LEADER', 'INTERN'), validate(findAllTaskSchema, 'query'), controller.findAll);
router.get('/:id', authMiddleware, requireRole('ADMIN', 'LEADER', 'INTERN'), controller.findById);
router.post('/', authMiddleware, requireRole('ADMIN', 'LEADER'), validate(createTaskSchema), controller.create);
router.put('/:id', authMiddleware, requireRole('ADMIN', 'LEADER'), validate(updateTaskSchema), controller.update);
router.delete('/:id', authMiddleware, requireRole('ADMIN', 'LEADER'), controller.delete);
export default router;
\ No newline at end of file
import { TaskRepository } from './task.repository';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import { TaskQueryDto, CreateTaskDto, UpdateTaskDto } from './task.dto';
export class TaskService {
private readonly repository = new TaskRepository();
async findAll(query: TaskQueryDto) {
return this.repository.findAll(query);
}
async findById(id: string) {
const task = await this.repository.findById(id);
if (!task) {
throw new AppError('Task not found', 404, ERROR_CODE.NOT_FOUND);
}
return task;
}
async create(data: CreateTaskDto, createdBy: string) {
return this.repository.create(data, createdBy);
}
async update(id: string, data: UpdateTaskDto) {
await this.findById(id);
return this.repository.update(id, data);
}
async delete(id: string) {
await this.findById(id);
return this.repository.softDelete(id);
}
}
import { z } from 'zod';
export const findAllTaskSchema = z.object({
title: z.string().optional(),
priority: z.enum(['LOW', 'MEDIUM', 'HIGH']).optional(),
createdBy: z.string().uuid().optional(),
deadlineFrom: z
.string()
.refine((v) => !isNaN(Date.parse(v)), { message: 'Invalid deadlineFrom' })
.optional(),
deadlineTo: z
.string()
.refine((v) => !isNaN(Date.parse(v)), { message: 'Invalid deadlineTo' })
.optional(),
sortBy: z.enum(['createdAt', 'title', 'deadline', 'priority']).optional(),
order: z.enum(['asc', 'desc']).optional(),
page: z.coerce.number().int().positive().optional().default(1),
limit: z.coerce.number().int().min(1).max(100).optional().default(20),
});
export const createTaskSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().optional(),
deadline: z.string().refine((v) => !isNaN(Date.parse(v)), {
message: 'Invalid deadline',
}),
priority: z.enum(['LOW', 'MEDIUM', 'HIGH']).optional(),
});
export const updateTaskSchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().nullable().optional(),
deadline: z
.string()
.refine((v) => !isNaN(Date.parse(v)), { message: 'Invalid deadline' })
.optional(),
priority: z.enum(['LOW', 'MEDIUM', 'HIGH']).optional(),
});
...@@ -3,6 +3,7 @@ import authRoute from '../modules/auth/auth.route'; ...@@ -3,6 +3,7 @@ import authRoute from '../modules/auth/auth.route';
import userRoute from '../modules/users/user.route'; import userRoute from '../modules/users/user.route';
import applicationRoute from '../modules/applications/application.route'; import applicationRoute from '../modules/applications/application.route';
import internRoute from '../modules/interns/intern.route'; import internRoute from '../modules/interns/intern.route';
import taskRoute from '../modules/tasks/task.route';
const router = Router(); const router = Router();
...@@ -14,5 +15,6 @@ router.use('/auth', authRoute); ...@@ -14,5 +15,6 @@ router.use('/auth', authRoute);
router.use('/users', userRoute); router.use('/users', userRoute);
router.use('/applications', applicationRoute); router.use('/applications', applicationRoute);
router.use('/interns', internRoute); router.use('/interns', internRoute);
router.use('/tasks', taskRoute);
export default router; export default router;
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment