diff --git a/apps/backend/prisma/migrations/20260228001232_user_model/migration.sql b/apps/backend/prisma/migrations/20260228001232_user_model/migration.sql deleted file mode 100644 index f9eabf8..0000000 --- a/apps/backend/prisma/migrations/20260228001232_user_model/migration.sql +++ /dev/null @@ -1,16 +0,0 @@ --- CreateEnum -CREATE TYPE "UserRole" AS ENUM ('USER', 'ADMIN', 'MODERATOR'); - --- CreateTable -CREATE TABLE "User" ( - "id" SERIAL NOT NULL, - "name" TEXT NOT NULL, - "email" TEXT NOT NULL, - "password" TEXT NOT NULL, - "role" "UserRole" NOT NULL DEFAULT 'USER', - - CONSTRAINT "User_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); diff --git a/apps/backend/prisma/migrations/20260306090912_init/migration.sql b/apps/backend/prisma/migrations/20260306090912_init/migration.sql new file mode 100644 index 0000000..f97a4f3 --- /dev/null +++ b/apps/backend/prisma/migrations/20260306090912_init/migration.sql @@ -0,0 +1,258 @@ +-- CreateEnum +CREATE TYPE "UserRole" AS ENUM ('USER', 'ADMIN', 'MODERATOR'); + +-- CreateEnum +CREATE TYPE "TeamMemberRole" AS ENUM ('MEMBER', 'OWNER', 'ADMIN'); + +-- CreateEnum +CREATE TYPE "TaskPriority" AS ENUM ('LOW', 'MEDIUM', 'HIGH', 'URGENT'); + +-- CreateEnum +CREATE TYPE "TaskType" AS ENUM ('TASK', 'EPIC', 'STORY', 'BUG', 'TECH_DEBT'); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + "role" "UserRole" NOT NULL DEFAULT 'USER', + "avatarURL" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamMember" ( + "id" TEXT NOT NULL, + "role" "TeamMemberRole" NOT NULL DEFAULT 'MEMBER', + "userId" TEXT NOT NULL, + "teamId" TEXT NOT NULL, + "joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Team" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "ownerId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Team_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Project" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "teamId" TEXT NOT NULL, + + CONSTRAINT "Project_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Board" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Board_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "BoardColumn" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "position" INTEGER NOT NULL, + "boardId" TEXT NOT NULL, + "statusId" TEXT NOT NULL, + + CONSTRAINT "BoardColumn_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Task" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "type" "TaskType" NOT NULL, + "priority" "TaskPriority" NOT NULL, + "statusId" TEXT NOT NULL, + "boardId" TEXT NOT NULL, + "assignedToId" TEXT, + "createdById" TEXT, + "parentId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Task_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TaskStatus" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "boardId" TEXT NOT NULL, + + CONSTRAINT "TaskStatus_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TasksFilter" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "isPrivate" BOOLEAN NOT NULL DEFAULT true, + "query" JSONB NOT NULL, + "creatorId" TEXT, + "boardId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TasksFilter_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TaskHistory" ( + "id" TEXT NOT NULL, + "field" TEXT NOT NULL, + "newValue" JSONB, + "oldValue" JSONB, + "teamMemberId" TEXT, + "taskId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TaskHistory_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TaskComment" ( + "id" TEXT NOT NULL, + "text" TEXT NOT NULL, + "teamMemberId" TEXT, + "taskId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TaskComment_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE INDEX "TeamMember_teamId_idx" ON "TeamMember"("teamId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamMember_userId_teamId_key" ON "TeamMember"("userId", "teamId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Team_name_key" ON "Team"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "Project_teamId_name_key" ON "Project"("teamId", "name"); + +-- CreateIndex +CREATE INDEX "Board_projectId_idx" ON "Board"("projectId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Board_projectId_name_key" ON "Board"("projectId", "name"); + +-- CreateIndex +CREATE INDEX "BoardColumn_statusId_idx" ON "BoardColumn"("statusId"); + +-- CreateIndex +CREATE UNIQUE INDEX "BoardColumn_boardId_position_key" ON "BoardColumn"("boardId", "position"); + +-- CreateIndex +CREATE UNIQUE INDEX "BoardColumn_boardId_title_key" ON "BoardColumn"("boardId", "title"); + +-- CreateIndex +CREATE INDEX "Task_statusId_idx" ON "Task"("statusId"); + +-- CreateIndex +CREATE INDEX "Task_boardId_idx" ON "Task"("boardId"); + +-- CreateIndex +CREATE INDEX "Task_assignedToId_idx" ON "Task"("assignedToId"); + +-- CreateIndex +CREATE INDEX "Task_parentId_idx" ON "Task"("parentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TaskStatus_boardId_name_key" ON "TaskStatus"("boardId", "name"); + +-- CreateIndex +CREATE INDEX "TasksFilter_boardId_idx" ON "TasksFilter"("boardId"); + +-- CreateIndex +CREATE INDEX "TasksFilter_creatorId_idx" ON "TasksFilter"("creatorId"); + +-- CreateIndex +CREATE INDEX "TaskHistory_taskId_idx" ON "TaskHistory"("taskId"); + +-- CreateIndex +CREATE INDEX "TaskHistory_teamMemberId_idx" ON "TaskHistory"("teamMemberId"); + +-- CreateIndex +CREATE INDEX "TaskComment_taskId_idx" ON "TaskComment"("taskId"); + +-- AddForeignKey +ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Team" ADD CONSTRAINT "Team_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Board" ADD CONSTRAINT "Board_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BoardColumn" ADD CONSTRAINT "BoardColumn_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "Board"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BoardColumn" ADD CONSTRAINT "BoardColumn_statusId_fkey" FOREIGN KEY ("statusId") REFERENCES "TaskStatus"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Task" ADD CONSTRAINT "Task_statusId_fkey" FOREIGN KEY ("statusId") REFERENCES "TaskStatus"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Task" ADD CONSTRAINT "Task_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "Board"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Task" ADD CONSTRAINT "Task_assignedToId_fkey" FOREIGN KEY ("assignedToId") REFERENCES "TeamMember"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Task" ADD CONSTRAINT "Task_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "TeamMember"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Task" ADD CONSTRAINT "Task_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Task"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TaskStatus" ADD CONSTRAINT "TaskStatus_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "Board"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TasksFilter" ADD CONSTRAINT "TasksFilter_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "TeamMember"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TasksFilter" ADD CONSTRAINT "TasksFilter_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "Board"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TaskHistory" ADD CONSTRAINT "TaskHistory_teamMemberId_fkey" FOREIGN KEY ("teamMemberId") REFERENCES "TeamMember"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TaskHistory" ADD CONSTRAINT "TaskHistory_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "Task"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TaskComment" ADD CONSTRAINT "TaskComment_teamMemberId_fkey" FOREIGN KEY ("teamMemberId") REFERENCES "TeamMember"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TaskComment" ADD CONSTRAINT "TaskComment_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "Task"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index cb4e6f6..2462ec1 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -13,10 +13,220 @@ enum UserRole { MODERATOR } +enum TeamMemberRole { + MEMBER + OWNER + ADMIN +} + +enum TaskPriority { + LOW + MEDIUM + HIGH + URGENT +} + +enum TaskType { + TASK + EPIC + STORY + BUG + TECH_DEBT +} + model User { - id Int @id @default(autoincrement()) - name String - email String @unique - password String - role UserRole @default(USER) + id String @id @default(cuid()) + name String + email String @unique + password String + role UserRole @default(USER) + avatarURL String? + + memberships TeamMember[] + ownedTeams Team[] @relation("TeamOwner") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model TeamMember { + id String @id @default(cuid()) + role TeamMemberRole @default(MEMBER) + + history TaskHistory[] + + tasksAssigned Task[] @relation("AssignedTasks") + tasksComments TaskComment[] + tasksCreated Task[] @relation("CreatedTasks") + tasksFilters TasksFilter[] + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + teamId String + + joinedAt DateTime @default(now()) + + @@unique([userId, teamId]) + @@index([teamId]) +} + +model Team { + id String @id @default(cuid()) + name String @unique + + members TeamMember[] + projects Project[] + + owner User @relation("TeamOwner", fields: [ownerId], references: [id], onDelete: Restrict) + ownerId String + + createdAt DateTime @default(now()) +} + +model Project { + id String @id @default(cuid()) + name String + + boards Board[] + + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + teamId String + + @@unique([teamId, name]) +} + +model Board { + id String @id @default(cuid()) + name String + + columns BoardColumn[] + tasks Task[] + tasksFilters TasksFilter[] + statuses TaskStatus[] + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + projectId String + + createdAt DateTime @default(now()) + + @@unique([projectId, name]) + @@index([projectId]) +} + +model BoardColumn { + id String @id @default(cuid()) + title String + position Int + + board Board @relation(fields: [boardId], references: [id], onDelete: Cascade) + boardId String + + status TaskStatus @relation(fields: [statusId], references: [id], onDelete: Restrict) + statusId String + + @@unique([boardId, position]) + @@unique([boardId, title]) + @@index([statusId]) +} + +model Task { + id String @id @default(cuid()) + title String + description String? + type TaskType + priority TaskPriority + + history TaskHistory[] + comments TaskComment[] + + status TaskStatus @relation(fields: [statusId], references: [id], onDelete: Restrict) + statusId String + + board Board @relation(fields: [boardId], references: [id], onDelete: Cascade) + boardId String + + assignedTo TeamMember? @relation("AssignedTasks", fields: [assignedToId], references: [id], onDelete: SetNull) + assignedToId String? + + createdBy TeamMember? @relation("CreatedTasks", fields: [createdById], references: [id], onDelete: SetNull) + createdById String? + + parentId String? + parent Task? @relation("TaskHierarchy", fields: [parentId], references: [id], onDelete: SetNull) + children Task[] @relation("TaskHierarchy") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([statusId]) + @@index([boardId]) + @@index([assignedToId]) + @@index([parentId]) +} + +model TaskStatus { + id String @id @default(cuid()) + name String + + tasks Task[] + boardColumns BoardColumn[] + + board Board @relation(fields: [boardId], references: [id], onDelete: Cascade) + boardId String + + @@unique([boardId, name]) +} + +model TasksFilter { + id String @id @default(cuid()) + name String + isPrivate Boolean @default(true) + query Json + + creator TeamMember? @relation(fields: [creatorId], references: [id], onDelete: SetNull) + creatorId String? + + board Board @relation(fields: [boardId], references: [id], onDelete: Cascade) + boardId String + + createdAt DateTime @default(now()) + + @@index([boardId]) + @@index([creatorId]) +} + +model TaskHistory { + id String @id @default(cuid()) + field String + newValue Json? + oldValue Json? + + teamMember TeamMember? @relation(fields: [teamMemberId], references: [id], onDelete: SetNull) + teamMemberId String? + + task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) + taskId String + + createdAt DateTime @default(now()) + + @@index([taskId]) + @@index([teamMemberId]) +} + +model TaskComment { + id String @id @default(cuid()) + text String + + teamMember TeamMember? @relation(fields: [teamMemberId], references: [id], onDelete: SetNull) + teamMemberId String? + + task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) + taskId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([taskId]) } diff --git a/apps/backend/src/modules/auth/types/jwt-payload.type.ts b/apps/backend/src/modules/auth/types/jwt-payload.type.ts index 844c122..4a216af 100644 --- a/apps/backend/src/modules/auth/types/jwt-payload.type.ts +++ b/apps/backend/src/modules/auth/types/jwt-payload.type.ts @@ -1,7 +1,7 @@ import type { UserRole } from '@prisma/client'; export interface JwtPayload { - sub: number; + sub: string; email: string; role: UserRole; } diff --git a/apps/backend/src/modules/user/dto/user.dto.ts b/apps/backend/src/modules/user/dto/user.dto.ts index 4542d01..cf9d7bf 100644 --- a/apps/backend/src/modules/user/dto/user.dto.ts +++ b/apps/backend/src/modules/user/dto/user.dto.ts @@ -1,7 +1,7 @@ import type { UserRole } from '@prisma/client'; export interface UserDto { - id: number; + id: string; name: string; email: string; role: UserRole; diff --git a/apps/backend/src/modules/user/user.controller.ts b/apps/backend/src/modules/user/user.controller.ts index 5773f66..59a9196 100644 --- a/apps/backend/src/modules/user/user.controller.ts +++ b/apps/backend/src/modules/user/user.controller.ts @@ -9,7 +9,6 @@ import { UseGuards, Req, ForbiddenException, - ParseIntPipe, } from '@nestjs/common'; import { UserService } from './user.service'; import { UpdateUserDto } from './dto/update-user.dto'; @@ -18,6 +17,7 @@ import { UserDto } from './dto/user.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { UserRole } from '@prisma/client'; import { JwtPayload } from '../auth/types/jwt-payload.type'; +import { ParseCuidPipe } from '../../shared/pipes/parse-cuid.pipe'; @Controller('user') export class UserController { @@ -35,7 +35,7 @@ export class UserController { } @Get(':id') - async findById(@Param('id', ParseIntPipe) id: number): Promise> { + async findById(@Param('id', ParseCuidPipe) id: string): Promise> { const user = await this.userService.findById(id); return { data: user }; @@ -44,7 +44,7 @@ export class UserController { @UseGuards(JwtAuthGuard) @Patch(':id') async update( - @Param('id', ParseIntPipe) id: number, + @Param('id', ParseCuidPipe) id: string, @Body() updateUserDto: UpdateUserDto, @Req() req: Request & { user: JwtPayload } ): Promise> { @@ -62,7 +62,7 @@ export class UserController { @UseGuards(JwtAuthGuard) @Delete(':id') @HttpCode(204) - remove(@Param('id', ParseIntPipe) id: number, @Req() req: Request & { user: JwtPayload }) { + remove(@Param('id', ParseCuidPipe) id: string, @Req() req: Request & { user: JwtPayload }) { if (req.user.role !== UserRole.ADMIN && req.user.sub !== id) { throw new ForbiddenException(); } diff --git a/apps/backend/src/modules/user/user.service.ts b/apps/backend/src/modules/user/user.service.ts index 8be365c..02d74ca 100644 --- a/apps/backend/src/modules/user/user.service.ts +++ b/apps/backend/src/modules/user/user.service.ts @@ -23,7 +23,7 @@ export class UserService { }); } - async findById(id: number): Promise { + async findById(id: string): Promise { const user = await this.prisma.user.findUnique({ where: { id }, }); @@ -58,7 +58,7 @@ export class UserService { }; } - async update(id: number, updateUserDto: UpdateUserDto): Promise { + async update(id: string, updateUserDto: UpdateUserDto): Promise { try { const updatedUser = await this.prisma.user.update({ where: { id }, @@ -79,7 +79,7 @@ export class UserService { } } - async remove(id: number): Promise { + async remove(id: string): Promise { try { await this.prisma.user.delete({ where: { id }, diff --git a/apps/backend/src/shared/pipes/parse-cuid.pipe.ts b/apps/backend/src/shared/pipes/parse-cuid.pipe.ts new file mode 100644 index 0000000..149c187 --- /dev/null +++ b/apps/backend/src/shared/pipes/parse-cuid.pipe.ts @@ -0,0 +1,15 @@ +import { PipeTransform, BadRequestException, Injectable } from '@nestjs/common'; + +//reg +const CUID_REGEX = /^c[a-z0-9]{24}$/i; + +@Injectable() +export class ParseCuidPipe implements PipeTransform { + transform(value: string): string { + if (!CUID_REGEX.test(value)) { + throw new BadRequestException('Invalid cuid'); + } + + return value; + } +}