diff --git a/app/controllers/board.controller.ts b/app/controllers/board.controller.ts index d082754..86f9d03 100644 --- a/app/controllers/board.controller.ts +++ b/app/controllers/board.controller.ts @@ -51,7 +51,9 @@ class BoardController { include: { columns: { orderBy: { order: 'asc' }, - include: { tasks: { orderBy: { order: 'asc' } } } + include: { + tasks: { include: { labels: true }, orderBy: { order: 'asc' } } + } } } }) diff --git a/app/controllers/index.ts b/app/controllers/index.ts index 3572ce9..70971b9 100644 --- a/app/controllers/index.ts +++ b/app/controllers/index.ts @@ -1,5 +1,6 @@ export { authController } from './auth.controller' export { boardController } from './board.controller' export { taskController } from './task.controller' +export { labelController } from './label.controller' export { columnController } from './column.controller' export { userController } from './user.controller' diff --git a/app/controllers/label.controller.ts b/app/controllers/label.controller.ts new file mode 100644 index 0000000..16d2555 --- /dev/null +++ b/app/controllers/label.controller.ts @@ -0,0 +1,89 @@ +import type { + CreateLabelSchema, + EditLabelSchema, + LabelParamsSchema +} from '@/schemas' +import type { + TypedRequest, + TypedRequestBody, + TypedRequestParams +} from '@/types' +import type { NextFunction, Request, Response } from 'express' +import type { ZodType } from 'zod' + +import { prisma } from '@/prisma' +import { NotFound } from 'http-errors' + +import { redisClient } from '@/config' + +class LabelController { + getAll = async ({ user }: Request, res: Response) => { + const cacheKey = `labels:user:${user.id}` + + const cachedLabels = await redisClient.get(cacheKey) + + if (cachedLabels) { + res.json(JSON.parse(cachedLabels)) + } else { + const labels = await prisma.label.findMany({ where: { userId: user.id } }) + + await redisClient.set(cacheKey, JSON.stringify(labels), 'EX', 5 * 60) + + res.json(labels) + } + } + + createLabel = async ( + { body, user }: TypedRequestBody, + res: Response + ) => { + const { name, color } = body + + const label = await prisma.label.create({ + data: { name, color, userId: user.id } + }) + + await redisClient.del(`labels:user:${user.id}`) + + res.json(label) + } + + updateById = async ( + { + params, + body, + user + }: TypedRequest, + res: Response, + next: NextFunction + ) => { + const updatedLabel = await prisma.label.updateIgnoreNotFound({ + where: { id: params.labelId, userId: user.id }, + data: body + }) + + if (!updatedLabel) return next(NotFound('Label not found')) + + await redisClient.del(`labels:user:${user.id}`) + + res.json(updatedLabel) + } + + deleteById = async ( + { params, user }: TypedRequestParams, + res: Response, + next: NextFunction + ) => { + const deletedLabel = await prisma.label.deleteIgnoreNotFound({ + where: { id: params.labelId, userId: user.id } + }) + + if (!deletedLabel) return next(NotFound('Label not found')) + + await redisClient.del(`labels:user:${user.id}`) + + res.sendStatus(204) + } +} + +export const labelController = new LabelController() diff --git a/app/controllers/task.controller.ts b/app/controllers/task.controller.ts index ad4c740..b2c8b01 100644 --- a/app/controllers/task.controller.ts +++ b/app/controllers/task.controller.ts @@ -33,7 +33,13 @@ class TaskController { const newOrder = await this.getNewTaskOrder(column.id) const newTask = await prisma.task.create({ - data: { ...body, columnId: column.id, order: newOrder } + data: { + ...body, + columnId: column.id, + order: newOrder, + labels: { connect: body.labels?.map(id => ({ id })) } + }, + include: { labels: true } }) await redisClient.del(`board:${column.boardId}:user:${column.board.userId}`) @@ -59,8 +65,14 @@ class TaskController { const updatedTask = await prisma.task.updateIgnoreNotFound({ where: { id: params.taskId }, - data: body, - include: { column: { include: { board: { select: { userId: true } } } } } + data: { + ...body, + labels: { set: body.labels?.map(id => ({ id })) } + }, + include: { + labels: true, + column: { include: { board: { select: { userId: true } } } } + } }) if (!updatedTask) return next(NotFound('Task not found')) diff --git a/app/routes/api/label.ts b/app/routes/api/label.ts new file mode 100644 index 0000000..271cbc1 --- /dev/null +++ b/app/routes/api/label.ts @@ -0,0 +1,23 @@ +import { Router } from 'express' + +import { labelController } from '@/controllers' + +import { authenticate, validateRequest } from '@/middlewares' + +import { CreateLabelSchema } from '@/schemas' + +export const labelRouter = Router() + +labelRouter.use(authenticate) + +labelRouter.get('/', labelController.getAll) + +labelRouter.post( + '/', + validateRequest({ body: CreateLabelSchema }), + labelController.createLabel +) + +labelRouter.patch('/:labelId', labelController.updateById) + +labelRouter.delete('/:labelId', labelController.deleteById) diff --git a/app/routes/index.ts b/app/routes/index.ts index a5dd712..f7faa13 100644 --- a/app/routes/index.ts +++ b/app/routes/index.ts @@ -7,6 +7,7 @@ import swaggerUi from 'swagger-ui-express' import { authRouter } from './api/auth' import { boardRouter } from './api/board' import { columnRouter } from './api/column' +import { labelRouter } from './api/label' import { taskRouter } from './api/task' import { userRouter } from './api/user' @@ -26,3 +27,4 @@ apiRouter.use('/user', userRouter) apiRouter.use('/board', boardRouter) apiRouter.use('/column', columnRouter) apiRouter.use('/task', taskRouter) +apiRouter.use('/label', labelRouter) diff --git a/app/schemas/index.ts b/app/schemas/index.ts index 2d148a0..6bf4cda 100644 --- a/app/schemas/index.ts +++ b/app/schemas/index.ts @@ -15,5 +15,10 @@ export { ColumnParamsSchema, UpdateColumnOrderSchema } from './column.schema' +export { + CreateLabelSchema, + EditLabelSchema, + LabelParamsSchema +} from './label.schema' export { SigninSchema, SignupSchema } from './auth.schema' export { EditUserSchema, NeedHelpSchema } from './user.schema' diff --git a/app/schemas/label.schema.ts b/app/schemas/label.schema.ts new file mode 100644 index 0000000..07d255f --- /dev/null +++ b/app/schemas/label.schema.ts @@ -0,0 +1,13 @@ +import { LabelColor } from '@prisma/client' +import * as z from 'zod' + +import { objectIdSchema } from './object-id.schema' + +export const CreateLabelSchema = z.object({ + name: z.string().min(2), + color: z.enum(LabelColor) +}) + +export const EditLabelSchema = CreateLabelSchema.partial() + +export const LabelParamsSchema = z.object({ labelId: objectIdSchema() }) diff --git a/app/schemas/task.schema.ts b/app/schemas/task.schema.ts index 95b63ba..683ec57 100644 --- a/app/schemas/task.schema.ts +++ b/app/schemas/task.schema.ts @@ -5,23 +5,30 @@ import { objectIdSchema } from './object-id.schema' export const AddTaskSchema = z.object({ title: z.string().min(3), - description: z.string().min(3), + description: z.optional(z.string().min(3)), priority: z.enum(Priority), - deadline: z.iso.datetime().refine(value => { - const checkDate = new Date(value) - const today = new Date() + labels: z.optional(z.array(objectIdSchema())), + deadline: z + .optional( + z.iso.datetime().refine(value => { + const checkDate = new Date(value) + const today = new Date() - // Strip time down to the day level for fair comparison - checkDate.setHours(0, 0, 0, 0) - today.setHours(0, 0, 0, 0) + // Strip time down to the day level for fair comparison + checkDate.setHours(0, 0, 0, 0) + today.setHours(0, 0, 0, 0) - return checkDate >= today - }, 'Deadline must be today or in the future') + return checkDate >= today + }, 'Deadline must be today or in the future') + ) + .transform(v => (v ? new Date(v) : null)) }) export const EditTaskSchema = z .object({ ...AddTaskSchema.shape, + description: z.nullable(z.string().min(3)), + deadline: z.nullable(z.iso.datetime()), columnId: objectIdSchema() }) .partial() diff --git a/package.json b/package.json index ad5cc55..7267d25 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "scripts": { "start": "node dist/app/server.js", "build": "swc app -d dist --copy-files", - "dev": "tsx watch app/server.ts", + "dev": "tsx watch --include ./swagger.json app/server.ts", "lint": "eslint --fix app/**/*.ts", "format": "prettier --write ./app", "format:check": "prettier --check ./app", diff --git a/prisma/models/label.prisma b/prisma/models/label.prisma new file mode 100644 index 0000000..8b498bb --- /dev/null +++ b/prisma/models/label.prisma @@ -0,0 +1,23 @@ +model Label { + id String @id @default(auto()) @map("_id") @db.ObjectId + name String + color LabelColor + userId String @db.ObjectId + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + task Task? @relation(fields: [taskId], references: [id]) + taskId String? @db.ObjectId + + @@unique([userId, name]) + @@map("labels") +} + +enum LabelColor { + blue + violet + rose + gray + cyan + amber + indigo + emerald +} diff --git a/prisma/models/task.prisma b/prisma/models/task.prisma index 0a7a748..19879c8 100644 --- a/prisma/models/task.prisma +++ b/prisma/models/task.prisma @@ -1,19 +1,20 @@ model Task { - id String @id @default(auto()) @map("_id") @db.ObjectId - title String - description String - priority Priority - deadline DateTime - order Int - columnId String @map("column_id") @db.ObjectId - column Column @relation(fields: [columnId], references: [id], onDelete: Cascade) + id String @id @default(auto()) @map("_id") @db.ObjectId + title String + description String? + priority Priority + deadline DateTime? + order Int + columnId String @map("column_id") @db.ObjectId + column Column @relation(fields: [columnId], references: [id], onDelete: Cascade) + labels Label[] - @@map("tasks") + @@map("tasks") } enum Priority { - Without - Low - Medium - High + Without + Low + Medium + High } diff --git a/prisma/models/user.prisma b/prisma/models/user.prisma index 4b447be..5b6f5a2 100644 --- a/prisma/models/user.prisma +++ b/prisma/models/user.prisma @@ -1,18 +1,19 @@ model User { - id String @id @default(auto()) @map("_id") @db.ObjectId - name String - email String @unique - password String? - theme Theme @default(light) - avatar String? - avatarPublicId String? @map("avatar_public_id") - boards Board[] + id String @id @default(auto()) @map("_id") @db.ObjectId + name String + email String @unique + password String? + theme Theme @default(light) + avatar String? + avatarPublicId String? @map("avatar_public_id") + boards Board[] + labels Label[] - @@map("users") + @@map("users") } enum Theme { - light - dark - violet + light + dark + violet } diff --git a/swagger.json b/swagger.json index 9e42840..33dd437 100644 --- a/swagger.json +++ b/swagger.json @@ -631,7 +631,7 @@ "application/json": { "schema": { "type": "object", - "required": ["title", "description", "priority", "deadline"], + "required": ["title", "priority"], "properties": { "title": { "type": "string", @@ -788,6 +788,111 @@ "404": { "$ref": "#/components/responses/NotFound" } } } + }, + "/label": { + "get": { + "tags": ["Label"], + "summary": "Get all labels", + "security": [{ "CookieAuth": [] }], + "responses": { + "200": { "$ref": "#/components/responses/AllLabelsResponse" }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + }, + "post": { + "tags": ["Label"], + "summary": "Add new label", + "security": [{ "CookieAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["name", "color"], + "properties": { + "name": { + "type": "string", + "minLength": 2, + "example": "Bug" + }, + "color": { + "type": "string", + "example": "rose" + } + } + } + } + } + }, + "responses": { + "201": { "$ref": "#/components/responses/LabelResponse" }, + "400": { "$ref": "#/components/responses/LabelBadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "/label/{labelId}": { + "patch": { + "tags": ["Label"], + "summary": "Edit label by id", + "parameters": [ + { + "in": "path", + "name": "labelId", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9a-fA-F]{24}$" + } + } + ], + "security": [{ "CookieAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 2, + "example": "Bug" + } + } + } + } + } + }, + "responses": { + "200": { "$ref": "#/components/responses/LabelResponse" }, + "400": { "$ref": "#/components/responses/LabelBadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "$ref": "#/components/responses/NotFound" } + } + }, + "delete": { + "tags": ["Label"], + "summary": "Delete label by id", + "parameters": [ + { + "in": "path", + "name": "labelId", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9a-fA-F]{24}$" + } + } + ], + "security": [{ "CookieAuth": [] }], + "responses": { + "204": { "description": "The resource was deleted successfully." }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "$ref": "#/components/responses/NotFound" } + } + } } }, "components": { @@ -840,6 +945,58 @@ } } }, + "Board": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[0-9a-fA-F]{24}$", + "example": "6672f1d2f02c8b04130c718c" + }, + "title": { "type": "string", "example": "Project office" }, + "icon": { + "type": "string", + "enum": [ + "project", + "star", + "loading", + "puzzle", + "container", + "lightning", + "colors", + "hexagon" + ], + "example": "project" + }, + "background": { "$ref": "#/components/schemas/BoardBackground" }, + "userId": { + "type": "string", + "pattern": "^[0-9a-fA-F]{24}$", + "example": "6671743db85c96453e04a684" + } + } + }, + "Column": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[0-9a-fA-F]{24}$", + "example": "6672f604c07147fc7ae1bb81" + }, + "title": { "type": "string", "example": "In progress" }, + "order": { "type": "integer", "example": 1 }, + "boardId": { + "type": "string", + "pattern": "^[0-9a-fA-F]{24}$", + "example": "67470718ed1b2ff3a2e5bd55" + }, + "tasks": { + "type": "array", + "items": { "$ref": "#/components/schemas/Task" } + } + } + }, "Task": { "type": "object", "properties": { @@ -854,6 +1011,7 @@ }, "description": { "type": "string", + "nullable": true, "example": "Review the project materials: Familiarize yourself with the project's content, including text, images, and any supplementary materials." }, "priority": { @@ -874,55 +1032,42 @@ } } }, - "Column": { + "Label": { "type": "object", "properties": { "id": { "type": "string", "pattern": "^[0-9a-fA-F]{24}$", - "example": "6672f604c07147fc7ae1bb81" - }, - "title": { "type": "string", "example": "In progress" }, - "order": { "type": "integer", "example": 1 }, - "boardId": { - "type": "string", - "pattern": "^[0-9a-fA-F]{24}$", - "example": "67470718ed1b2ff3a2e5bd55" + "example": "6672fdccc07147fc7ae1bb93" }, - "tasks": { - "type": "array", - "items": { "$ref": "#/components/schemas/Task" } - } - } - }, - "Board": { - "type": "object", - "properties": { - "id": { + "name": { "type": "string", - "pattern": "^[0-9a-fA-F]{24}$", - "example": "6672f1d2f02c8b04130c718c" + "example": "Bug" }, - "title": { "type": "string", "example": "Project office" }, - "icon": { + "color": { "type": "string", "enum": [ - "project", - "star", - "loading", - "puzzle", - "container", - "lightning", - "colors", - "hexagon" + "blue", + "violet", + "rose", + "gray", + "cyan", + "amber", + "indigo", + "emerald" ], - "example": "project" + "example": "rose" }, - "background": { "$ref": "#/components/schemas/BoardBackground" }, "userId": { "type": "string", "pattern": "^[0-9a-fA-F]{24}$", "example": "6671743db85c96453e04a684" + }, + "taskId": { + "type": "string", + "nullable": true, + "pattern": "^[0-9a-fA-F]{24}$", + "example": null } } }, @@ -1202,6 +1347,38 @@ } } } + }, + "AllLabelsResponse": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/Label" } + } + } + } + }, + "LabelResponse": { + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Label" } + } + } + }, + "LabelBadRequest": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse", + "example": { + "statusCode": 400, + "message": { + "color": "The field must be one of the following values: blue, violet, rose, gray, cyan, amber, indigo, emerald" + } + } + } + } + } } }, "securitySchemes": {