diff --git a/src/ctask/ctask.controller.ts b/src/ctask/ctask.controller.ts new file mode 100644 index 0000000..d50ac90 --- /dev/null +++ b/src/ctask/ctask.controller.ts @@ -0,0 +1,99 @@ +import { FastifyReply, FastifyRequest } from "fastify"; +import { CreateTaskInput, UpdateTaskInput } from "./ctask.schema"; +import { + createTask, + deleteTask, + getTask, + listTasks, + searchTasks, + updateTask, +} from "./ctask.service"; +import { PageQueryParams } from "../pagination"; + +export async function createTaskHandler( + req: FastifyRequest, + res: FastifyReply +) { + const input = req.body as CreateTaskInput; + + try { + const rts = await createTask(input, req.user); + return res.code(201).send(rts); + } catch (err) { + return err; + } +} + +export async function getTaskHandler(req: FastifyRequest, res: FastifyReply) { + const { taskId } = req.params as { taskId: string }; + + try { + const task = await getTask(taskId, req.user); + if (task == null) + return res.code(404).send({ error: "resource not found" }); + + return res.code(200).send(task); + } catch (err) { + return err; + } +} + +export async function listTaskHandler(req: FastifyRequest, res: FastifyReply) { + const queryParams = req.query as PageQueryParams; + + try { + const taskList = await listTasks(queryParams, req.user); + return res.code(200).send(taskList); + } catch (err) { + return err; + } +} + +export async function updateTaskHandler( + req: FastifyRequest, + res: FastifyReply +) { + const input = req.body as UpdateTaskInput; + const { taskId } = req.params as { taskId: string }; + + try { + const updatedTask = await updateTask(taskId, input, req.user); + if (!updatedTask) + return res.code(404).send({ error: "resource not found" }); + + return res.code(200).send(updatedTask); + } catch (err) { + return err; + } +} + +export async function deleteTaskHandler( + req: FastifyRequest, + res: FastifyReply +) { + const { taskId } = req.params as { taskId: string }; + + try { + const deleteResult = await deleteTask(taskId, req.user); + if (deleteResult.deletedCount == 0) + return res.code(404).send({ error: "resource not found" }); + + return res.code(204).send(); + } catch (err) { + return err; + } +} + +export async function searchTaskHandler( + req: FastifyRequest, + res: FastifyReply +) { + const queryParams = req.query as PageQueryParams; + + try { + const taskList = await searchTasks(queryParams, req.user); + return res.code(200).send(taskList); + } catch (err) { + return err; + } +} diff --git a/src/ctask/ctask.route.ts b/src/ctask/ctask.route.ts new file mode 100644 index 0000000..08a6992 --- /dev/null +++ b/src/ctask/ctask.route.ts @@ -0,0 +1,127 @@ +import { FastifyInstance } from "fastify"; +import { $ctask } from "./ctask.schema"; +import { + createTaskHandler, + deleteTaskHandler, + getTaskHandler, + listTaskHandler, + searchTaskHandler, + updateTaskHandler, +} from "./ctask.controller"; +import { noteRoutes } from "../note/note.route"; +import { getUniqueFields } from "../unique"; + +export async function ctaskRoutes(fastify: FastifyInstance) { + fastify.post( + "/", + { + schema: { + body: $ctask("createTaskInput"), + }, + config: { requiredClaims: ["ctask:write"] }, + preHandler: [fastify.authorize], + }, + createTaskHandler + ); + + fastify.get( + "/:taskId", + { + schema: { + params: { + type: "object", + properties: { + taskId: { type: "string" }, + }, + }, + }, + config: { requiredClaims: ["ctask:read"] }, + preHandler: [fastify.authorize], + }, + getTaskHandler + ); + + fastify.get( + "/", + { + schema: { + querystring: $ctask("pageQueryParams"), + }, + + config: { requiredClaims: ["ctask:read"] }, + preHandler: [fastify.authorize], + }, + listTaskHandler + ); + + fastify.patch( + "/:taskId", + { + schema: { + params: { + type: "object", + properties: { taskId: { type: "string" } }, + }, + body: $ctask("updateTaskInput"), + }, + config: { requiredClaims: ["ctask:write"] }, + preHandler: [fastify.authorize], + }, + updateTaskHandler + ); + + fastify.delete( + "/:taskId", + { + schema: { + params: { + type: "object", + properties: { taskId: { type: "string" } }, + }, + }, + config: { requiredClaims: ["ctask:delete"] }, + preHandler: [fastify.authorize], + }, + deleteTaskHandler + ); + + fastify.get( + "/search", + { + schema: { + querystring: $ctask("pageQueryParams"), + }, + config: { requiredClaims: ["ctask:read"] }, + preHandler: [fastify.authorize], + }, + searchTaskHandler + ); + + fastify.get( + "/fields/:field", + { + schema: { + params: { + type: "object", + properties: { + field: { type: "string" }, + }, + }, + }, + config: { requiredClaims: ["ctask:read"] }, + preHandler: [fastify.authorize], + }, + async (req, res) => { + const { field } = req.params as { field: string }; + + try { + const uniqueValues = await getUniqueFields(field, "task", req.user); + return res.code(200).send(uniqueValues); + } catch (err) { + return err; + } + } + ); + + await noteRoutes(fastify); +} diff --git a/src/ctask/ctask.schema.ts b/src/ctask/ctask.schema.ts new file mode 100644 index 0000000..954873f --- /dev/null +++ b/src/ctask/ctask.schema.ts @@ -0,0 +1,99 @@ +import mongoose from "mongoose"; +import { z } from "zod"; +import { files } from "../file/file.schema"; +import { buildJsonSchemas } from "fastify-zod"; +import { pageQueryParams } from "../pagination"; + +const taskSchema = new mongoose.Schema({ + tenantId: { type: String, required: true }, + orgId: { type: String, required: true, ref: "organization" }, + pid: { type: String, required: true, unique: true }, + title: String, + dueDate: Date, + labels: [String], + priority: String, + stage: new mongoose.Schema( + { + pipeline: Array, + currentStage: Number, + }, + { _id: false } + ), + createdAt: Date, + createdBy: { + type: mongoose.Types.ObjectId, + ref: "user", + }, + assignedTo: { + type: mongoose.Types.ObjectId, + ref: "user", + }, +}); + +export const taskFields = Object.keys(taskSchema.paths).filter( + (path) => path !== "__v" +); + +export const taskModel = mongoose.model("ctask", taskSchema, "ctask"); + +const createTaskInput = z.object({ + title: z.string(), + dueDate: z.date().optional(), + files: z.array(files).optional(), + assignedTo: z.string().optional(), + labels: z.array(z.string()).optional(), + priority: z.string().optional(), + stage: z + .object({ + pipeline: z.array( + z.object({ + name: z.string(), + date: z.date().nullable().optional(), + description: z.string().optional(), + comment: z.string().optional(), + }) + ), + currentStage: z.number(), + }) + .optional(), +}); + +const updateTaskInput = z.object({ + title: z.string().optional(), + dueDate: z.date().optional(), + files: z.array(files).optional(), + assignedTo: z.string().optional(), + labels: z.array(z.string()).optional(), + priority: z.string().optional(), + stage: z + .object({ + pipeline: z.array( + z.object({ + name: z.string(), + date: z.date().nullable().optional(), + description: z.string().optional(), + comment: z.string().optional(), + }) + ), + currentStage: z.number(), + }) + .optional(), +}); + +const uploadTaskInput = z.object({ + files: z.array(files), +}); + +export type CreateTaskInput = z.infer; +export type UpdateTaskInput = z.infer; +export type UploadTaskInput = z.infer; + +export const { schemas: cTaskSchemas, $ref: $ctask } = buildJsonSchemas( + { + createTaskInput, + updateTaskInput, + uploadTaskInput, + pageQueryParams, + }, + { $id: "ctask" } +); diff --git a/src/ctask/ctask.service.ts b/src/ctask/ctask.service.ts new file mode 100644 index 0000000..99ff179 --- /dev/null +++ b/src/ctask/ctask.service.ts @@ -0,0 +1,262 @@ +import { AuthenticatedUser } from "../auth"; +import { getFilterObject, getSortObject, PageQueryParams } from "../pagination"; +import { generateId } from "../utils/id"; +import { taskPipeline } from "../utils/pipeline"; +import { + CreateTaskInput, + taskFields, + taskModel, + UpdateTaskInput, + UploadTaskInput, +} from "./ctask.schema"; + +export async function createTask( + input: CreateTaskInput, + user: AuthenticatedUser +) { + if (!input.stage) { + input.stage = { + pipeline: taskPipeline, + currentStage: 0, + }; + } + + const task = await taskModel.create({ + ...input, + tenantId: user.tenantId, + orgId: user.orgId, + pid: generateId(), + createdAt: new Date(), + createdBy: user.userId ?? null, + }); + + return await taskModel + .findOne({ pid: task.pid }) + .populate({ path: "createdBy", select: "pid name avatar" }) + .populate({ path: "assignedTo", select: "pid name avatar" }); +} + +export async function updateTask( + taskId: string, + input: UpdateTaskInput, + user: AuthenticatedUser +) { + const updatedTask = await taskModel + .findOneAndUpdate( + { tenantId: user.tenantId, orgId: user.orgId, pid: taskId }, + input, + { new: true } + ) + .populate({ path: "createdBy", select: "pid name avatar" }) + .populate({ path: "assignedTo", select: "pid name avatar" }); + + return updatedTask; +} + +export async function getTask(taskId: string, user: AuthenticatedUser) { + return await taskModel + .findOne({ tenantId: user.tenantId, orgId: user.orgId, pid: taskId }) + .populate({ path: "createdBy", select: "pid name avatar" }) + .populate({ path: "assignedTo", select: "pid name avatar" }); +} + +export async function listTasks( + params: PageQueryParams, + user: AuthenticatedUser +) { + const page = params.page || 1; + const pageSize = params.pageSize || 10; + const sortObj = getSortObject(params, taskFields); + const filterObj = getFilterObject(params) || []; + + const taskList = await taskModel.aggregate([ + { + $match: { + $and: [ + { tenantId: user.tenantId }, + { orgId: user.orgId }, + ...filterObj, + ], + }, + }, + { + $lookup: { + from: "users", + localField: "createdBy", + foreignField: "_id", + as: "createdBy", + }, + }, + { + $lookup: { + from: "users", + localField: "assignedTo", + foreignField: "_id", + as: "assignedTo", + }, + }, + { + $project: { + _id: 1, + pid: 1, + title: 1, + dueDate: 1, + labels: 1, + priority: 1, + documents: 1, + stage: 1, + createdAt: 1, + createdBy: { + $let: { + vars: { createdBy: { $arrayElemAt: ["$createdBy", 0] } }, + in: { + _id: "$$createdBy._id", + pid: "$$createdBy.pid", + name: "$$createdBy.name", + }, + }, + }, + assignedTo: { + $let: { + vars: { assignedTo: { $arrayElemAt: ["$assignedTo", 0] } }, + in: { + _id: "$$assignedTo._id", + pid: "$$assignedTo.pid", + name: "$$assignedTo.name", + }, + }, + }, + }, + }, + { + $facet: { + metadata: [{ $count: "count" }], + data: [ + { $sort: sortObj }, + { $skip: (page - 1) * pageSize }, + { $limit: pageSize }, + ], + }, + }, + ]); + + if (taskList[0].data.length === 0) + return { tasks: [], metadata: { count: 0, page, pageSize } }; + + return { + tasks: taskList[0]?.data, + metadata: { + count: taskList[0].metadata[0].count, + page, + pageSize, + }, + }; +} + +export async function deleteTask(taskId: string, user: AuthenticatedUser) { + return await taskModel.deleteOne({ + pid: taskId, + orgId: user.orgId, + tenantId: user.tenantId, + }); +} + +export async function searchTasks( + params: PageQueryParams, + user: AuthenticatedUser +) { + const page = params.page || 1; + const pageSize = params.pageSize || 10; + const sortObj = getSortObject(params, taskFields); + const filterObj = getFilterObject(params) || []; + + const regex = new RegExp(params.searchToken, "i"); + + const taskList = await taskModel.aggregate([ + { + $match: { + $and: [ + { tenantId: user.tenantId }, + { orgId: user.orgId }, + ...filterObj, + ], + }, + }, + { + $match: { + $or: [{ title: { $regex: regex } }], + }, + }, + { + $lookup: { + from: "users", + localField: "createdBy", + foreignField: "_id", + as: "createdBy", + }, + }, + { + $lookup: { + from: "users", + localField: "assignedTo", + foreignField: "_id", + as: "assignedTo", + }, + }, + { + $project: { + _id: 1, + pid: 1, + title: 1, + dueDate: 1, + labels: 1, + priority: 1, + documents: 1, + stage: 1, + createdAt: 1, + createdBy: { + $let: { + vars: { createdBy: { $arrayElemAt: ["$createdBy", 0] } }, + in: { + _id: "$$createdBy._id", + pid: "$$createdBy.pid", + name: "$$createdBy.name", + }, + }, + }, + assignedTo: { + $let: { + vars: { assignedTo: { $arrayElemAt: ["$assignedTo", 0] } }, + in: { + _id: "$$assignedTo._id", + pid: "$$assignedTo.pid", + name: "$$assignedTo.name", + }, + }, + }, + }, + }, + { + $facet: { + metadata: [{ $count: "count" }], + data: [ + { $sort: sortObj }, + { $skip: (page - 1) * pageSize }, + { $limit: pageSize }, + ], + }, + }, + ]); + + if (taskList[0].data.length === 0) + return { tasks: [], metadata: { count: 0, page, pageSize } }; + + return { + tasks: taskList[0]?.data, + metadata: { + count: taskList[0].metadata[0].count, + page, + pageSize, + }, + }; +} diff --git a/src/routes.ts b/src/routes.ts index 5063dac..35ce9c5 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -13,6 +13,7 @@ import { configRoutes } from "./config/config.route"; import { mailProxyRoutes } from "./mailProxy/mailProxy.route"; import { viewRoutes } from "./view/view.route"; import { processedRoutes } from "./processed/processed.route"; +import { ctaskRoutes } from "./ctask/ctask.route"; export default async function routes(fastify: FastifyInstance) { fastify.addHook("preHandler", authHandler); @@ -23,6 +24,7 @@ export default async function routes(fastify: FastifyInstance) { fastify.register(fileRoutes, { prefix: "/files" }); fastify.register(rtsRoutes, { prefix: "/rts" }); fastify.register(taskRoutes, { prefix: "/tasks" }); + fastify.register(ctaskRoutes, { prefix: "/ctasks" }); fastify.register(notificationRoutes, { prefix: "/notifications" }); fastify.register(mailProxyRoutes, { prefix: "/proxy" }); fastify.register(configRoutes, { prefix: "/config" }); diff --git a/src/server.ts b/src/server.ts index 5799809..9a748ce 100644 --- a/src/server.ts +++ b/src/server.ts @@ -21,6 +21,7 @@ import { configSchemas } from "./config/config.schema"; import { mailSchemas } from "./mailProxy/mailProxy.schema"; import { viewSchemas } from "./view/view.schema"; import { processedSchemas } from "./processed/processed.schema"; +import { cTaskSchemas } from "./ctask/ctask.schema"; const app = fastify({ logger: true, trustProxy: true }); @@ -49,6 +50,7 @@ for (const schema of [ ...fileSchemas, ...rtsSchemas, ...taskSchemas, + ...cTaskSchemas, ...notificationSchemas, ...noteSchemas, ...configSchemas, diff --git a/src/task/task.route.ts b/src/task/task.route.ts index b7944ea..d6a17de 100644 --- a/src/task/task.route.ts +++ b/src/task/task.route.ts @@ -65,6 +65,8 @@ export async function taskRoutes(fastify: FastifyInstance) { }, body: $task("updateTaskInput"), }, + config: { requiredClaims: ["task:write"] }, + preHandler: [fastify.authorize], }, updateTaskHandler ); @@ -119,7 +121,7 @@ export async function taskRoutes(fastify: FastifyInstance) { }, }, }, - config: { requiredClaims: ["rts:read"] }, + config: { requiredClaims: ["task:read"] }, preHandler: [fastify.authorize], }, async (req, res) => { diff --git a/src/utils/claims.ts b/src/utils/claims.ts index 3dcf97e..5f6e262 100644 --- a/src/utils/claims.ts +++ b/src/utils/claims.ts @@ -20,6 +20,9 @@ export type Claim = | "task:read" | "task:write" | "task:delete" + | "ctask:read" + | "ctask:write" + | "ctask:delete" | "notification:read" | "notification:write" | "notification:delete" diff --git a/src/utils/roles.ts b/src/utils/roles.ts index dec4f90..3dfa663 100644 --- a/src/utils/roles.ts +++ b/src/utils/roles.ts @@ -126,6 +126,9 @@ export const rules: Record< "view:read", "view:write", "view:delete", + "ctask:read", + "ctask:write", + "ctask:delete", "note:read", "note:write", ],