├── .husky ├── pre-commit ├── pre-push ├── commit-msg ├── validate-branch.sh └── validate-commit-msg.sh ├── apps ├── server │ ├── sharedb.d.ts │ ├── src │ │ ├── project │ │ │ ├── enum │ │ │ │ ├── project-role.enum.ts │ │ │ │ ├── subTaskStatus.enum.ts │ │ │ │ ├── contributor-status.enum.ts │ │ │ │ └── eventType.enum.ts │ │ │ ├── event │ │ │ │ ├── task-deleted.event.ts │ │ │ │ ├── subtask-changed.event.ts │ │ │ │ ├── title-updated.event.ts │ │ │ │ ├── labels-changed.event.ts │ │ │ │ ├── position-updated.event.ts │ │ │ │ ├── assignees-changed.event.ts │ │ │ │ └── task-created.event.ts │ │ │ ├── dto │ │ │ │ ├── task │ │ │ │ │ ├── delete-task-response.dto.ts │ │ │ │ │ ├── create-task-response.dto.ts │ │ │ │ │ ├── update-information.type.ts │ │ │ │ │ ├── task-event-response.dto.ts │ │ │ │ │ ├── move-task-response.dto.ts │ │ │ │ │ ├── update-task-response.dto.ts │ │ │ │ │ ├── update-task-details-request.dto.ts │ │ │ │ │ ├── task-event.dto.ts │ │ │ │ │ ├── update-task-details-response.dto.ts │ │ │ │ │ ├── task-response.dto.ts │ │ │ │ │ └── task-details-response.dto.ts │ │ │ │ ├── label │ │ │ │ │ ├── update-labels-request.dto.ts │ │ │ │ │ ├── label-details-response.dto.ts │ │ │ │ │ └── label-details-request.dto.ts │ │ │ │ ├── project │ │ │ │ │ ├── create-project-request.dto.ts │ │ │ │ │ ├── create-project-response.dto.ts │ │ │ │ │ └── user-projects-response.dto.ts │ │ │ │ ├── assignee │ │ │ │ │ ├── update-assignees-request.dto.ts │ │ │ │ │ └── assignee-details-response.dto.ts │ │ │ │ ├── subtask │ │ │ │ │ ├── create-subTask-request.dto.ts │ │ │ │ │ ├── update-subTask-request.dto.ts │ │ │ │ │ └── create-subTask-response.dto.ts │ │ │ │ ├── contributor │ │ │ │ │ ├── invite-user-request.dto.ts │ │ │ │ │ ├── project-contributors-response-dto.ts │ │ │ │ │ ├── user-invitation-response.dto.ts │ │ │ │ │ └── update-contributor-request.dts.ts │ │ │ │ └── sprint │ │ │ │ │ ├── sprint-details-response.dto.ts │ │ │ │ │ └── sprint-details-request.dto.ts │ │ │ ├── interface │ │ │ │ └── custom-response.interface.ts │ │ │ ├── entity │ │ │ │ ├── project.entity.ts │ │ │ │ ├── task-label.entity.ts │ │ │ │ ├── task-assignee.entity.ts │ │ │ │ ├── section.entity.ts │ │ │ │ ├── sprint.entity.ts │ │ │ │ ├── label.entity.ts │ │ │ │ ├── contributor.entity.ts │ │ │ │ ├── subTask.entity.ts │ │ │ │ └── task.entity.ts │ │ │ ├── controller │ │ │ │ ├── projects.controller.ts │ │ │ │ ├── label.controller.ts │ │ │ │ ├── sprint.controller.ts │ │ │ │ ├── event.controller.ts │ │ │ │ └── subTask.controller.ts │ │ │ └── project.module.ts │ │ ├── app.service.ts │ │ ├── image │ │ │ ├── dto │ │ │ │ ├── access-url-response.dto.ts │ │ │ │ ├── file-name-request.dto.ts │ │ │ │ └── presigned-url-response.dto.ts │ │ │ ├── image.module.ts │ │ │ ├── controller │ │ │ │ └── image.controller.ts │ │ │ └── service │ │ │ │ └── image.service.ts │ │ ├── account │ │ │ ├── dto │ │ │ │ ├── update-profile-image.dto.ts │ │ │ │ ├── user.dto.ts │ │ │ │ ├── create-user.dto.ts │ │ │ │ ├── signin-user.dto.ts │ │ │ │ └── auth.dto.ts │ │ │ ├── guard │ │ │ │ ├── accessToken.guard.ts │ │ │ │ └── refreshToken.guard.ts │ │ │ ├── decorator │ │ │ │ └── authUser.decorator.ts │ │ │ ├── user.controller.ts │ │ │ ├── entity │ │ │ │ └── account.entity.ts │ │ │ ├── user.service.ts │ │ │ ├── account.module.ts │ │ │ ├── strategy │ │ │ │ ├── accessToken.strategy.ts │ │ │ │ └── refreshToken.strategy.ts │ │ │ └── auth.controller.ts │ │ ├── common │ │ │ ├── decorator │ │ │ │ ├── response-status.decorator.ts │ │ │ │ └── response-message.decorator.ts │ │ │ ├── entity-timestamp.entity.ts │ │ │ ├── BaseResponse.ts │ │ │ ├── interceptor │ │ │ │ ├── httpLog.Interceptor.ts │ │ │ │ └── response.interceptor.ts │ │ │ ├── allException.filter.ts │ │ │ └── socket-adapter │ │ │ │ └── custom-io-adapter.ts │ │ ├── app.controller.ts │ │ ├── planning-poker │ │ │ └── planning-poker.module.ts │ │ ├── main.ts │ │ ├── app.controller.spec.ts │ │ └── app.module.ts │ ├── tsconfig.build.json │ ├── nest-cli.json │ ├── test │ │ ├── jest-e2e.json │ │ └── app.e2e-spec.ts │ ├── tsconfig.json │ ├── .eslintrc.js │ ├── ecosystem.config.js │ └── config │ │ └── typeorm.config.ts └── client │ ├── src │ ├── vite-env.d.ts │ ├── lib │ │ ├── utils.ts │ │ ├── useToast.tsx │ │ └── axios.ts │ ├── components │ │ ├── logo │ │ │ ├── index.ts │ │ │ ├── GitHub.tsx │ │ │ └── Harmony.tsx │ │ ├── Tag.tsx │ │ ├── Header.tsx │ │ ├── TabView.tsx │ │ ├── ui │ │ │ ├── textarea.tsx │ │ │ ├── label.tsx │ │ │ ├── separator.tsx │ │ │ ├── progress.tsx │ │ │ ├── input.tsx │ │ │ ├── sonner.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── badge.tsx │ │ │ ├── popover.tsx │ │ │ ├── avatar.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── section.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ └── date-range-picker.tsx │ │ ├── ProjectCard.tsx │ │ ├── Footer.tsx │ │ └── dialog │ │ │ └── CreateProjectDialog.tsx │ ├── types │ │ ├── index.ts │ │ └── project.ts │ ├── features │ │ ├── project │ │ │ ├── sprint │ │ │ │ ├── getSprintStatus.ts │ │ │ │ ├── getStatusColor.ts │ │ │ │ ├── sprintSchema.ts │ │ │ │ ├── useSprintsQuery.ts │ │ │ │ └── useSprintMutations.ts │ │ │ ├── label │ │ │ │ ├── generateRandomColor.ts │ │ │ │ ├── labelSchema.ts │ │ │ │ ├── useLabelsQuery.ts │ │ │ │ ├── components │ │ │ │ │ └── ColorInput.tsx │ │ │ │ └── useLabelMutations.ts │ │ │ ├── useUsersQuery.ts │ │ │ ├── board │ │ │ │ ├── components │ │ │ │ │ ├── SubtaskProgress.tsx │ │ │ │ │ ├── AssigneeAvatars.tsx │ │ │ │ │ └── TaskTextarea.tsx │ │ │ │ ├── useBoardMutations.ts │ │ │ │ ├── api.ts │ │ │ │ ├── useDragAndDrop.ts │ │ │ │ ├── utils.ts │ │ │ │ ├── types.ts │ │ │ │ └── useLongPollingEvents.ts │ │ │ ├── types.ts │ │ │ └── api.ts │ │ ├── auth │ │ │ ├── useAuth.ts │ │ │ ├── LoginFormSchema.ts │ │ │ ├── types.ts │ │ │ ├── SignupFormSchema.ts │ │ │ ├── api.ts │ │ │ └── components │ │ │ │ └── LoginForm.tsx │ │ ├── task │ │ │ ├── useSuspenseTaskQuery.ts │ │ │ ├── subtask │ │ │ │ ├── types.ts │ │ │ │ ├── api.ts │ │ │ │ └── useSubtaskMutations.ts │ │ │ ├── types.ts │ │ │ ├── useTaskMutations.ts │ │ │ ├── api.ts │ │ │ └── components │ │ │ │ ├── TaskDescription.tsx │ │ │ │ └── Estimate.tsx │ │ └── types.ts │ ├── routes │ │ ├── login.tsx │ │ ├── signup.tsx │ │ ├── index.tsx │ │ ├── _auth.$project.index.tsx │ │ ├── __root.tsx │ │ ├── _auth.$project.settings.index.tsx │ │ ├── _auth.$project.settings.labels.tsx │ │ ├── _auth.$project.settings.sprints.tsx │ │ ├── _auth.account.index.tsx │ │ ├── _auth.$project.board.tsx │ │ ├── _auth.account.tsx │ │ ├── _auth.account.settings.tsx │ │ ├── _auth.$project.tsx │ │ └── _auth.$project.settings.tsx │ ├── main.tsx │ ├── shared │ │ └── utils │ │ │ ├── useDebounce.ts │ │ │ └── throttle.ts │ ├── config │ │ └── env.ts │ ├── pages │ │ ├── LabelsSettings.tsx │ │ ├── SprintsSettings.tsx │ │ ├── Signup.tsx │ │ ├── Board.tsx │ │ ├── Login.tsx │ │ └── AccountOverview.tsx │ ├── App.tsx │ └── global.css │ ├── postcss.config.js │ ├── tsconfig.json │ ├── components.json │ ├── tsconfig.node.json │ ├── index.html │ ├── vite.config.ts │ ├── tsconfig.app.json │ ├── .eslintrc.cjs │ ├── public │ └── favicon.svg │ └── package.json ├── pnpm-workspace.yaml ├── scripts └── deploy.sh ├── turbo.json ├── .prettierrc ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── pull_request_template.md └── workflows │ ├── CI.yaml │ └── CD.yaml └── package.json /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | sh .husky/validate-branch.sh -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | sh .husky/validate-commit-msg.sh "$1" -------------------------------------------------------------------------------- /apps/server/sharedb.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'sharedb'; 2 | -------------------------------------------------------------------------------- /apps/client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "apps/*" 3 | - "packages/*" 4 | -------------------------------------------------------------------------------- /apps/server/src/project/enum/project-role.enum.ts: -------------------------------------------------------------------------------- 1 | export enum ProjectRole { 2 | ADMIN = 'ADMIN', 3 | GUEST = 'GUEST', 4 | } 5 | -------------------------------------------------------------------------------- /apps/client/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /apps/server/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /apps/server/src/project/enum/subTaskStatus.enum.ts: -------------------------------------------------------------------------------- 1 | export enum SubTaskStatus { 2 | 'PENDING' = 'PENDING', 3 | 'COMPLETED' = 'COMPLETED', 4 | } 5 | -------------------------------------------------------------------------------- /apps/server/src/project/enum/contributor-status.enum.ts: -------------------------------------------------------------------------------- 1 | export enum ContributorStatus { 2 | PENDING = 'PENDING', 3 | ACCEPTED = 'ACCEPTED', 4 | REJECTED = 'REJECTED', 5 | } 6 | -------------------------------------------------------------------------------- /apps/server/src/project/event/task-deleted.event.ts: -------------------------------------------------------------------------------- 1 | export class TaskDeletedEvent { 2 | constructor(taskId: number) { 3 | this.id = taskId; 4 | } 5 | 6 | id: number; 7 | } 8 | -------------------------------------------------------------------------------- /apps/server/src/project/dto/task/delete-task-response.dto.ts: -------------------------------------------------------------------------------- 1 | export class DeleteTaskResponse { 2 | constructor(id: number) { 3 | this.id = id; 4 | } 5 | 6 | id: number; 7 | } 8 | -------------------------------------------------------------------------------- /apps/server/src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService { 5 | getHello(): string { 6 | return 'Hello World!'; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /apps/server/src/image/dto/access-url-response.dto.ts: -------------------------------------------------------------------------------- 1 | export class AccessUrlResponse { 2 | accessUrl: string; 3 | 4 | constructor(accessUrl: string) { 5 | this.accessUrl = accessUrl; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /apps/server/src/image/dto/file-name-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString } from 'class-validator'; 2 | 3 | export class FileNameRequest { 4 | @IsNotEmpty() 5 | @IsString() 6 | fileName: string; 7 | } 8 | -------------------------------------------------------------------------------- /apps/server/src/project/interface/custom-response.interface.ts: -------------------------------------------------------------------------------- 1 | import { Response } from 'express'; 2 | 3 | export interface CustomResponse extends Response { 4 | userId: number; 5 | 6 | version: number; 7 | } 8 | -------------------------------------------------------------------------------- /apps/client/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /apps/server/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /apps/server/src/account/dto/update-profile-image.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString } from 'class-validator'; 2 | 3 | export class UpdateProfileImageRequest { 4 | @IsNotEmpty() 5 | @IsString() 6 | profileImage: string; 7 | } 8 | -------------------------------------------------------------------------------- /apps/server/src/account/guard/accessToken.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class AccessTokenGuard extends AuthGuard('access_token') {} 6 | -------------------------------------------------------------------------------- /apps/server/src/account/guard/refreshToken.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class RefreshTokenGuard extends AuthGuard('refresh_token') {} 6 | -------------------------------------------------------------------------------- /apps/server/src/common/decorator/response-status.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | export const RESPONSE_STATUS = 'response_status'; 4 | export const ResponseStatus = (status: number) => SetMetadata(RESPONSE_STATUS, status); 5 | -------------------------------------------------------------------------------- /apps/server/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /apps/client/src/components/logo/index.ts: -------------------------------------------------------------------------------- 1 | import Github from '@/components/logo/GitHub'; 2 | import HarmonyWithText from '@/components/logo/HarmonyWithText'; 3 | import Harmony from '@/components/logo/Harmony'; 4 | 5 | export { HarmonyWithText, Harmony, Github }; 6 | -------------------------------------------------------------------------------- /apps/client/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export type TTask = { 2 | id: number; 3 | title: string; 4 | description: string; 5 | position: string; 6 | }; 7 | 8 | export type TSection = { 9 | id: number; 10 | name: string; 11 | tasks: TTask[]; 12 | }; 13 | -------------------------------------------------------------------------------- /apps/server/src/project/dto/label/update-labels-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsArray, IsDefined, IsInt } from 'class-validator'; 2 | 3 | export class UpdateLabelsRequest { 4 | @IsDefined() 5 | @IsArray() 6 | @IsInt({ each: true }) 7 | labels: number[]; 8 | } 9 | -------------------------------------------------------------------------------- /apps/server/src/project/dto/project/create-project-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString, Length } from 'class-validator'; 2 | 3 | export class CreateProjectRequest { 4 | @IsNotEmpty() 5 | @IsString() 6 | @Length(1, 20) 7 | title: string; 8 | } 9 | -------------------------------------------------------------------------------- /apps/server/src/common/decorator/response-message.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | export const RESPONSE_MESSAGE = 'response_message'; 4 | export const ResponseMessage = (message: string) => SetMetadata(RESPONSE_MESSAGE, message); 5 | -------------------------------------------------------------------------------- /apps/client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }], 4 | "compilerOptions": { 5 | "baseUrl": ".", 6 | "paths": { 7 | "@/*": ["./src/*"] 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /apps/server/src/common/entity-timestamp.entity.ts: -------------------------------------------------------------------------------- 1 | import { CreateDateColumn, UpdateDateColumn } from 'typeorm'; 2 | 3 | export class EntityTimestamp { 4 | @CreateDateColumn() 5 | createdAt: Date; 6 | 7 | @UpdateDateColumn() 8 | updatedAt: Date; 9 | } 10 | -------------------------------------------------------------------------------- /apps/server/src/project/dto/assignee/update-assignees-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsArray, IsDefined, IsInt } from 'class-validator'; 2 | 3 | export class UpdateAssigneesRequest { 4 | @IsDefined() 5 | @IsArray() 6 | @IsInt({ each: true }) 7 | assignees: number[]; 8 | } 9 | -------------------------------------------------------------------------------- /apps/server/src/image/dto/presigned-url-response.dto.ts: -------------------------------------------------------------------------------- 1 | export class PresignedUrlResponse { 2 | presignedUrl: string; 3 | 4 | key: string; 5 | 6 | constructor(presignedUrl: string, key: string) { 7 | this.presignedUrl = presignedUrl; 8 | this.key = key; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /apps/client/src/features/project/sprint/getSprintStatus.ts: -------------------------------------------------------------------------------- 1 | export const getSprintStatus = (startDate: string, endDate: string) => { 2 | const now = new Date().toISOString().split('T')[0]; 3 | if (now < startDate) return 'PLANNED'; 4 | if (now > endDate) return 'COMPLETED'; 5 | return 'CURRENT'; 6 | }; 7 | -------------------------------------------------------------------------------- /apps/client/src/features/project/label/generateRandomColor.ts: -------------------------------------------------------------------------------- 1 | export const generateRandomColor = () => { 2 | const letters = '0123456789ABCDEF'; 3 | let color = '#'; 4 | 5 | for (let i = 0; i < 6; i += 1) { 6 | color += letters[Math.floor(Math.random() * 16)]; 7 | } 8 | 9 | return color; 10 | }; 11 | -------------------------------------------------------------------------------- /apps/server/src/account/decorator/authUser.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | 3 | export const AuthUser = createParamDecorator((data: never, context: ExecutionContext) => { 4 | const request = context.switchToHttp().getRequest(); 5 | return request.user; 6 | }); 7 | -------------------------------------------------------------------------------- /apps/server/src/project/dto/subtask/create-subTask-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsBoolean, IsOptional, IsString } from 'class-validator'; 2 | 3 | export class CreateSubTaskRequest { 4 | @IsOptional() 5 | @IsString() 6 | content: string; 7 | 8 | @IsOptional() 9 | @IsBoolean() 10 | completed: boolean; 11 | } 12 | -------------------------------------------------------------------------------- /apps/server/src/project/dto/subtask/update-subTask-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsBoolean, IsOptional, IsString } from 'class-validator'; 2 | 3 | export class UpdateSubTaskRequest { 4 | @IsOptional() 5 | @IsString() 6 | content: string; 7 | 8 | @IsOptional() 9 | @IsBoolean() 10 | completed: boolean; 11 | } 12 | -------------------------------------------------------------------------------- /apps/server/src/project/dto/task/create-task-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { Task } from '@/project/entity/task.entity'; 2 | 3 | export class CreateTaskResponse { 4 | constructor(task: Task) { 5 | this.id = task.id; 6 | this.position = task.position; 7 | } 8 | 9 | id: number; 10 | 11 | position: string; 12 | } 13 | -------------------------------------------------------------------------------- /apps/server/src/project/event/subtask-changed.event.ts: -------------------------------------------------------------------------------- 1 | export class SubTaskChangedEvent { 2 | constructor(taskId: number, total: number, completed: number) { 3 | this.id = taskId; 4 | this.subtasks = { total, completed }; 5 | } 6 | 7 | id: number; 8 | 9 | subtasks: { total: number; completed: number }; 10 | } 11 | -------------------------------------------------------------------------------- /scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | PNPM="/root/.local/share/pnpm/pnpm" 4 | PM2="/root/.nvm/versions/node/v22.11.0/bin/pm2" 5 | 6 | $PNPM install 7 | $PNPM build 8 | 9 | $PM2 restart apps/server/ecosystem.config.js --env production || $PM2 start apps/server/ecosystem.config.js --env production 10 | 11 | echo "deploy success" -------------------------------------------------------------------------------- /apps/server/src/project/dto/project/create-project-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { Project } from '@/project/entity/project.entity'; 2 | 3 | export class CreateProjectResponse { 4 | id: number; 5 | 6 | title: string; 7 | 8 | constructor(project: Project) { 9 | this.id = project.id; 10 | this.title = project.title; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /apps/server/src/project/dto/contributor/invite-user-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsNumber, IsPositive, Length } from 'class-validator'; 2 | 3 | export class InviteUserRequest { 4 | @IsNotEmpty() 5 | @Length(6, 15) 6 | username: string; 7 | 8 | @IsNotEmpty() 9 | @IsNumber() 10 | @IsPositive() 11 | projectId: number; 12 | } 13 | -------------------------------------------------------------------------------- /apps/server/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from '@/app.service'; 3 | 4 | @Controller() 5 | export class AppController { 6 | constructor(private readonly appService: AppService) {} 7 | 8 | @Get() 9 | getHello(): string { 10 | return this.appService.getHello(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /apps/server/src/project/entity/project.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; 2 | import { EntityTimestamp } from '@/common/entity-timestamp.entity'; 3 | 4 | @Entity() 5 | export class Project extends EntityTimestamp { 6 | @PrimaryGeneratedColumn() 7 | id: number; 8 | 9 | @Column() 10 | title: string; 11 | } 12 | -------------------------------------------------------------------------------- /apps/server/src/project/event/title-updated.event.ts: -------------------------------------------------------------------------------- 1 | export class TitleUpdatedEvent { 2 | constructor(taskId: number, position: number, content: string, length: number) { 3 | this.id = taskId; 4 | this.title = { position, content, length }; 5 | } 6 | 7 | id: number; 8 | 9 | title: { position: number; content: string; length: number }; 10 | } 11 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "ui": "tui", 4 | "tasks": { 5 | "build": { 6 | "dependsOn": ["^build"] 7 | }, 8 | "lint": { 9 | "dependsOn": ["^lint"] 10 | }, 11 | "dev": { 12 | "cache": false, 13 | "persistent": true 14 | }, 15 | "format": { 16 | "dependsOn": ["^format"] 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /apps/server/src/planning-poker/planning-poker.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PlanningPokerGateway } from './gateway/planning-poker.gateway'; 3 | import { AccountModule } from '@/account/account.module'; 4 | 5 | @Module({ 6 | providers: [PlanningPokerGateway], 7 | imports: [AccountModule], 8 | }) 9 | export class PlanningPokerModule {} 10 | -------------------------------------------------------------------------------- /apps/server/src/project/dto/task/update-information.type.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsOptional, IsString } from 'class-validator'; 2 | 3 | export class UpdateInformation { 4 | @IsOptional() 5 | @IsNumber() 6 | position: number; 7 | 8 | @IsOptional() 9 | @IsString() 10 | content: string; 11 | 12 | @IsOptional() 13 | @IsNumber() 14 | length: number; 15 | } 16 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "printWidth": 100, 4 | "semi": true, 5 | "singleQuote": true, 6 | "trailingComma": "es5", 7 | "arrowParens": "always", 8 | "singleAttributePerLine": false, 9 | "bracketSpacing": true, 10 | "bracketSameLine": false, 11 | "quoteProps": "as-needed", 12 | "plugins": [ 13 | "prettier-plugin-tailwindcss" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /apps/client/src/features/auth/useAuth.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { AuthContext } from '@/features/auth/AuthProvider.tsx'; 4 | 5 | export const useAuth = () => { 6 | const context = useContext(AuthContext); 7 | if (!context) { 8 | throw new Error('useAuth must be used within an AuthProvider'); 9 | } 10 | 11 | return context; 12 | }; 13 | -------------------------------------------------------------------------------- /apps/client/src/routes/login.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute, redirect } from '@tanstack/react-router'; 2 | 3 | import Login from '@/pages/Login'; 4 | 5 | export const Route = createFileRoute('/login')({ 6 | beforeLoad: ({ context }) => { 7 | if (context.auth.isAuthenticated) { 8 | throw redirect({ to: '/account' }); 9 | } 10 | }, 11 | component: Login, 12 | }); 13 | -------------------------------------------------------------------------------- /apps/server/src/project/dto/assignee/assignee-details-response.dto.ts: -------------------------------------------------------------------------------- 1 | export class AssigneeDetailsResponse { 2 | constructor(id: number, username: string, profileImage: string) { 3 | this.id = id; 4 | this.username = username; 5 | this.profileImage = profileImage; 6 | } 7 | 8 | id: number; 9 | 10 | username: string; 11 | 12 | profileImage: string; 13 | } 14 | -------------------------------------------------------------------------------- /apps/client/src/features/task/useSuspenseTaskQuery.ts: -------------------------------------------------------------------------------- 1 | import { useSuspenseQuery } from '@tanstack/react-query'; 2 | import { taskAPI } from '@/features/task/api.ts'; 3 | 4 | export const useSuspenseTaskQuery = (taskId: number) => { 5 | return useSuspenseQuery({ 6 | queryKey: ['task', taskId], 7 | queryFn: () => taskAPI.getDetail(taskId), 8 | retry: 0, 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /apps/client/src/routes/signup.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute, redirect } from '@tanstack/react-router'; 2 | import { Signup } from '@/pages/Signup'; 3 | 4 | export const Route = createFileRoute('/signup')({ 5 | beforeLoad: ({ context }) => { 6 | if (context.auth.isAuthenticated) { 7 | throw redirect({ to: '/account' }); 8 | } 9 | }, 10 | component: Signup, 11 | }); 12 | -------------------------------------------------------------------------------- /apps/client/src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute, redirect } from '@tanstack/react-router'; 2 | import Home from '@/pages/Home'; 3 | 4 | export const Route = createFileRoute('/')({ 5 | beforeLoad: ({ context }) => { 6 | if (context.auth.isAuthenticated) { 7 | throw redirect({ 8 | to: '/account', 9 | }); 10 | } 11 | }, 12 | component: Home, 13 | }); 14 | -------------------------------------------------------------------------------- /apps/server/src/project/event/labels-changed.event.ts: -------------------------------------------------------------------------------- 1 | import { LabelDetailsResponse } from '@/project/dto/label/label-details-response.dto'; 2 | 3 | export class LabelsChangedEvent { 4 | constructor(taskId: number, labels: LabelDetailsResponse[]) { 5 | this.id = taskId; 6 | this.labels = labels; 7 | } 8 | 9 | id: number; 10 | 11 | labels: LabelDetailsResponse[]; 12 | } 13 | -------------------------------------------------------------------------------- /apps/server/src/account/dto/user.dto.ts: -------------------------------------------------------------------------------- 1 | import { Account } from '@/account/entity/account.entity'; 2 | 3 | export class UserDto { 4 | id: number; 5 | 6 | username: string; 7 | 8 | profileImage: string; 9 | 10 | constructor(account: Account) { 11 | this.id = account.id; 12 | this.username = account.username; 13 | this.profileImage = account.profileImage; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /apps/server/src/project/event/position-updated.event.ts: -------------------------------------------------------------------------------- 1 | import { Task } from '@/project/entity/task.entity'; 2 | 3 | export class PositionUpdatedEvent { 4 | constructor(task: Task) { 5 | this.id = task.id; 6 | this.sectionId = task.section.id; 7 | this.position = task.position; 8 | } 9 | 10 | id: number; 11 | 12 | sectionId: number; 13 | 14 | position: string; 15 | } 16 | -------------------------------------------------------------------------------- /apps/client/src/features/auth/LoginFormSchema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const loginFormSchema = z.object({ 4 | username: z.string().min(1, { 5 | message: 'Username should not be empty.', 6 | }), 7 | 8 | password: z.string().min(1, { 9 | message: 'Password should not be empty.', 10 | }), 11 | }); 12 | 13 | export type LoginFormValues = z.infer; 14 | -------------------------------------------------------------------------------- /apps/client/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import './global.css'; 4 | import App from './App.tsx'; 5 | 6 | const rootElement = document.getElementById('root')!; 7 | if (!rootElement.innerHTML) { 8 | const root = createRoot(rootElement); 9 | root.render( 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /apps/server/src/project/event/assignees-changed.event.ts: -------------------------------------------------------------------------------- 1 | import { AssigneeDetailsResponse } from '@/project/dto/assignee/assignee-details-response.dto'; 2 | 3 | export class AssigneesChangedEvent { 4 | constructor(taskId: number, assignees: AssigneeDetailsResponse[]) { 5 | this.id = taskId; 6 | this.assignees = assignees; 7 | } 8 | 9 | id: number; 10 | 11 | assignees: AssigneeDetailsResponse[]; 12 | } 13 | -------------------------------------------------------------------------------- /apps/client/src/features/project/sprint/getStatusColor.ts: -------------------------------------------------------------------------------- 1 | export const getStatusColor = (status: string) => { 2 | switch (status) { 3 | case 'PLANNED': 4 | return 'bg-gray-200 text-gray-800'; 5 | case 'CURRENT': 6 | return 'bg-blue-100 text-blue-800'; 7 | case 'COMPLETED': 8 | return 'bg-green-100 text-green-800'; 9 | default: 10 | return 'bg-gray-200 text-gray-800'; 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /apps/server/src/image/image.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ImageController } from './controller/image.controller'; 3 | import { ImageService } from './service/image.service'; 4 | import { AccountModule } from '@/account/account.module'; 5 | 6 | @Module({ 7 | controllers: [ImageController], 8 | providers: [ImageService], 9 | imports: [AccountModule], 10 | }) 11 | export class ImageModule {} 12 | -------------------------------------------------------------------------------- /apps/client/src/shared/utils/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export function useDebounce(value: T, delay: number): T { 4 | const [debouncedValue, setDebouncedValue] = useState(value); 5 | 6 | useEffect(() => { 7 | const handler = setTimeout(() => setDebouncedValue(value), delay); 8 | 9 | return () => clearTimeout(handler); 10 | }, [value, delay]); 11 | 12 | return debouncedValue; 13 | } 14 | -------------------------------------------------------------------------------- /apps/server/src/account/dto/create-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString, Length, Matches } from 'class-validator'; 2 | 3 | export class CreateUserDto { 4 | @IsNotEmpty() 5 | @IsString() 6 | @Length(6, 15) 7 | @Matches(/^\S+$/, { message: '공백은 사용 불가합니다.' }) 8 | username: string; 9 | 10 | @IsNotEmpty() 11 | @IsString() 12 | @Length(8, 15) 13 | @Matches(/^\S+$/, { message: '공백은 사용 불가합니다.' }) 14 | password: string; 15 | } 16 | -------------------------------------------------------------------------------- /apps/server/src/project/dto/task/task-event-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { EventType } from '@/project/enum/eventType.enum'; 2 | 3 | export class TaskEventResponse { 4 | constructor(eventTitle: EventType, event: any) { 5 | const now = new Date(); 6 | this.event = eventTitle; 7 | this.version = now.getTime(); 8 | this.task = event; 9 | } 10 | 11 | event: EventType; 12 | 13 | version: number; 14 | 15 | task: any; 16 | } 17 | -------------------------------------------------------------------------------- /apps/client/src/features/project/label/labelSchema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const labelFormSchema = z.object({ 4 | name: z.string().min(1, 'Label name is required').max(10, 'Label name is too long'), 5 | description: z.string().min(1, 'Label description is required'), 6 | color: z.string().regex(/^#[0-9A-F]{6}$/i, 'Must be a valid hex color'), 7 | }); 8 | 9 | export type LabelFormValues = z.infer; 10 | -------------------------------------------------------------------------------- /apps/server/src/account/dto/signin-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString, Matches } from 'class-validator'; 2 | 3 | export class SigninUserDto { 4 | @IsNotEmpty({ message: '유저네임을 입력해주세요.' }) 5 | @IsString() 6 | @Matches(/^\S+$/, { message: '공백은 사용 불가합니다.' }) 7 | username: string; 8 | 9 | @IsNotEmpty({ message: '비밀번호를 입력해주세요.' }) 10 | @IsString() 11 | @Matches(/^\S+$/, { message: '공백은 사용 불가합니다.' }) 12 | password: string; 13 | } 14 | -------------------------------------------------------------------------------- /apps/client/src/features/project/useUsersQuery.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | import { projectAPI } from '@/features/project/api.ts'; 3 | 4 | export const useUsersQuery = (projectId: number) => { 5 | return useQuery({ 6 | queryKey: ['users'], 7 | queryFn: async () => { 8 | const { result } = await projectAPI.getMembers(projectId); 9 | 10 | return result; 11 | }, 12 | retry: 0, 13 | }); 14 | }; 15 | -------------------------------------------------------------------------------- /apps/server/src/project/entity/task-label.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; 2 | import { EntityTimestamp } from '@/common/entity-timestamp.entity'; 3 | 4 | @Entity() 5 | export class TaskLabel extends EntityTimestamp { 6 | @PrimaryGeneratedColumn() 7 | id: number; 8 | 9 | @Column() 10 | projectId: number; 11 | 12 | @Column() 13 | taskId: number; 14 | 15 | @Column() 16 | labelId: number; 17 | } 18 | -------------------------------------------------------------------------------- /apps/server/src/common/BaseResponse.ts: -------------------------------------------------------------------------------- 1 | export class BaseResponse { 2 | status: number; 3 | 4 | message: string; 5 | 6 | result?: any; 7 | 8 | constructor(status: number, message: string, result?: any) { 9 | this.status = status; 10 | this.message = message; 11 | this.result = result; 12 | } 13 | 14 | static create(status: number, message: string, result?: any) { 15 | return new BaseResponse(status, message, result); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /apps/server/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import * as dotenv from 'dotenv'; 3 | import { AppModule } from '@/app.module'; 4 | import { CustomIoAdapter } from './common/socket-adapter/custom-io-adapter'; 5 | 6 | dotenv.config(); 7 | 8 | async function bootstrap() { 9 | const app = await NestFactory.create(AppModule); 10 | app.useWebSocketAdapter(new CustomIoAdapter(app)); 11 | await app.listen(3000); 12 | } 13 | bootstrap(); 14 | -------------------------------------------------------------------------------- /apps/server/src/project/dto/contributor/project-contributors-response-dto.ts: -------------------------------------------------------------------------------- 1 | import { ContributorStatus } from '@/project/enum/contributor-status.enum'; 2 | 3 | export class ProjectContributorsResponse { 4 | id: number; 5 | 6 | username: string; 7 | 8 | role: ContributorStatus; 9 | 10 | constructor(id: number, username: string, role: ContributorStatus) { 11 | this.id = id; 12 | this.username = username; 13 | this.role = role; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /apps/server/src/project/dto/label/label-details-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { Label } from '@/project/entity/label.entity'; 2 | 3 | export class LabelDetailsResponse { 4 | constructor(label: Label) { 5 | this.id = label.id; 6 | this.name = label.title; 7 | this.description = label.description; 8 | this.color = label.color; 9 | } 10 | 11 | id: number; 12 | 13 | name: string; 14 | 15 | description: string; 16 | 17 | color: string; 18 | } 19 | -------------------------------------------------------------------------------- /apps/server/src/project/dto/project/user-projects-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ContributorStatus } from '@/project/enum/contributor-status.enum'; 2 | 3 | export class UserProjectsResponse { 4 | role: ContributorStatus; 5 | 6 | project: { id: number; title: string; createdAt: Date }; 7 | 8 | constructor(role: ContributorStatus, project: { id: number; title: string; createdAt: Date }) { 9 | this.role = role; 10 | this.project = project; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /apps/server/src/project/entity/task-assignee.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; 2 | import { EntityTimestamp } from '@/common/entity-timestamp.entity'; 3 | 4 | @Entity() 5 | export class TaskAssignee extends EntityTimestamp { 6 | @PrimaryGeneratedColumn() 7 | id: number; 8 | 9 | @Column() 10 | projectId: number; 11 | 12 | @Column() 13 | taskId: number; 14 | 15 | @Column() 16 | accountId: number; 17 | } 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: 새로운 기능 제안 4 | title: '[FEAT] 새로운 기능 제안' 5 | labels: 'Feature' 6 | assignees: '' 7 | --- 8 | 9 | ## 세부 기능 10 | 11 | [//]: # (이 백로그에서 구현할 세부 기능을 작성해주세요.) 12 | 13 | - [ ] 구현할 세부 기능 1 14 | - [ ] 구현할 세부 기능 2 15 | 16 | ## 완료 조건 17 | 18 | [//]: # (이 백로그의 완료 혹은 종료 조건을 작성해주세요.) 19 | 20 | - [ ] 완료 조건 1 21 | - [ ] 완료 조건 2 22 | 23 | ## 참고 자료 24 | 25 | [//]: # (참고할 문서, 디자인, API 문서 등을 첨부해주세요.) 26 | 27 | - 참고 자료 1 -------------------------------------------------------------------------------- /apps/client/src/features/project/label/useLabelsQuery.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | import { projectAPI } from '@/features/project/api.ts'; 3 | 4 | export const useLabelsQuery = (projectId: number) => { 5 | return useQuery({ 6 | queryKey: ['labels', projectId], 7 | queryFn: async () => { 8 | const { result } = await projectAPI.getLabels(projectId); 9 | 10 | return result; 11 | }, 12 | throwOnError: true, 13 | }); 14 | }; 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: 버그 수정 요청 4 | title: '[BUG] 버그 수정 요청' 5 | labels: 'BugFix' 6 | assignees: '' 7 | --- 8 | 9 | ## 버그 설명 10 | 11 | [//]: # (어떤 버그가 발생했는지 설명을 작성해주세요.) 12 | 13 | ## 재현 방법 14 | 15 | 1. 어디를 가서 16 | 2. 무엇을 클릭했더니 17 | 3. 어떤 화면에서 18 | 4. 에러가 발생했다 19 | 20 | ## 정상 동작 21 | 22 | [//]: # (원래 기대했던 동작을 설명해주세요.) 23 | 24 | ## 스크린샷 25 | 26 | [//]: # (가능한 경우 스크린샷을 첨부해주세요.) 27 | 28 | ## 환경 정보 29 | 30 | - OS: 31 | - Browser: 32 | - Version: -------------------------------------------------------------------------------- /apps/server/src/project/dto/contributor/user-invitation-response.dto.ts: -------------------------------------------------------------------------------- 1 | export class UserInvitationResponse { 2 | contributorId: number; 3 | 4 | projectId: number; 5 | 6 | projectTitle: string; 7 | 8 | inviter: string; 9 | 10 | constructor(contributorId: number, projectId: number, projectTitle: string, inviter: string) { 11 | this.contributorId = contributorId; 12 | this.projectId = projectId; 13 | this.projectTitle = projectTitle; 14 | this.inviter = inviter; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /apps/server/src/project/dto/subtask/create-subTask-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { SubTask } from '@/project/entity/subTask.entity'; 2 | import { SubTaskStatus } from '@/project/enum/subTaskStatus.enum'; 3 | 4 | export class CreateSubTaskResponse { 5 | constructor(subTask: SubTask) { 6 | this.id = subTask.id; 7 | this.content = subTask.content; 8 | this.completed = subTask.status === SubTaskStatus.COMPLETED; 9 | } 10 | 11 | id: number; 12 | 13 | content: string; 14 | 15 | completed: boolean; 16 | } 17 | -------------------------------------------------------------------------------- /apps/client/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/App.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /apps/client/src/features/project/sprint/sprintSchema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const sprintFormSchema = z.object({ 4 | name: z.string().min(1, 'Sprint name is required').max(10, 'Sprint name is too long'), 5 | dateRange: z 6 | .object({ 7 | from: z.date(), 8 | to: z.date(), 9 | }) 10 | .refine((data) => data.from <= data.to, { 11 | message: 'End date must be after start date', 12 | }), 13 | }); 14 | 15 | export type SprintFormValues = z.infer; 16 | -------------------------------------------------------------------------------- /apps/server/src/project/dto/sprint/sprint-details-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { Sprint } from '@/project/entity/sprint.entity'; 2 | 3 | export class SprintDetailsResponse { 4 | constructor(sprint: Sprint) { 5 | this.id = sprint.id; 6 | this.name = sprint.title; 7 | [this.startDate] = sprint.startDate.toISOString().split('T'); 8 | [this.endDate] = sprint.endDate.toISOString().split('T'); 9 | } 10 | 11 | id: number; 12 | 13 | name: string; 14 | 15 | startDate: string; 16 | 17 | endDate: string; 18 | } 19 | -------------------------------------------------------------------------------- /apps/server/src/project/dto/task/move-task-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { Task } from '@/project/entity/task.entity'; 2 | 3 | export class MoveTaskResponse { 4 | constructor(task: Task) { 5 | this.id = task.id; 6 | this.title = task.title; 7 | this.description = task.description; 8 | this.sectionId = task.section.id; 9 | this.position = task.position; 10 | } 11 | 12 | id: number; 13 | 14 | title: string; 15 | 16 | description: string; 17 | 18 | sectionId: number; 19 | 20 | position: string; 21 | } 22 | -------------------------------------------------------------------------------- /apps/server/src/project/dto/task/update-task-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { Task } from '@/project/entity/task.entity'; 2 | 3 | export class UpdateTaskResponse { 4 | constructor(task: Task) { 5 | this.id = task.id; 6 | this.title = task.title; 7 | this.description = task.description; 8 | this.sectionId = task.section.id; 9 | this.position = task.position; 10 | } 11 | 12 | id: number; 13 | 14 | title: string; 15 | 16 | description: string; 17 | 18 | sectionId: number; 19 | 20 | position: string; 21 | } 22 | -------------------------------------------------------------------------------- /apps/server/src/project/entity/section.entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn } from 'typeorm'; 2 | import { Project } from '@/project/entity/project.entity'; 3 | import { EntityTimestamp } from '@/common/entity-timestamp.entity'; 4 | 5 | @Entity() 6 | export class Section extends EntityTimestamp { 7 | @PrimaryGeneratedColumn() 8 | id: number; 9 | 10 | @Column() 11 | name: string; 12 | 13 | @ManyToOne(() => Project) 14 | @JoinColumn({ name: 'project_id' }) 15 | project: Project; 16 | } 17 | -------------------------------------------------------------------------------- /apps/server/src/account/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Query } from '@nestjs/common'; 2 | import { UserService } from './user.service'; 3 | import { ResponseMessage } from '@/common/decorator/response-message.decorator'; 4 | 5 | @Controller('user') 6 | export class UserController { 7 | constructor(private readonly userService: UserService) {} 8 | 9 | @Get() 10 | @ResponseMessage('유저 정보가 성공적으로 조회되었습니다.') 11 | async searchUsers(@Query('search') query: string) { 12 | return this.userService.searchUsers(query); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /apps/client/src/components/Tag.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/lib/utils.ts'; 2 | 3 | export interface LabelProps { 4 | text: string; 5 | className?: string; 6 | } 7 | 8 | function Tag({ text, className, ...props }: LabelProps) { 9 | return ( 10 | 17 | {text} 18 | 19 | ); 20 | } 21 | 22 | export default Tag; 23 | -------------------------------------------------------------------------------- /apps/server/src/account/dto/auth.dto.ts: -------------------------------------------------------------------------------- 1 | import { Account } from '@/account/entity/account.entity'; 2 | 3 | export class AuthDto { 4 | id: number; 5 | 6 | username: string; 7 | 8 | profileImage: string; 9 | 10 | accessToken: string; 11 | 12 | static of(accessToken: string, user: Account) { 13 | const response = new AuthDto(); 14 | response.id = user.id; 15 | response.username = user.username; 16 | response.profileImage = user.profileImage; 17 | response.accessToken = accessToken; 18 | return response; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /apps/client/src/routes/_auth.$project.index.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from '@tanstack/react-router'; 2 | import { Suspense } from 'react'; 3 | import ProjectOverview from '@/pages/ProjectOverview'; 4 | 5 | export const Route = createFileRoute('/_auth/$project/')({ 6 | component: () => ( 7 | Loading...}> 8 | 9 | 10 | ), 11 | errorComponent: () => ( 12 |
13 |

Failed to load overview

14 |

Sorry, an unexpected error occured.

15 |
16 | ), 17 | }); 18 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Pull Request Template 3 | about: PR 템플릿 4 | title: '[Feat] ' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | [//]: # (PR 제목은 [Feat] {제목} 형식으로 작성해주세요.) 10 | [//]: # (Reviewer, Assignees, Labe을 붙여주세요.) 11 | 12 | ## 관련 이슈 번호 13 | 14 | [//]: # (해당 PR이 어떤 이슈와 관련이 있는지 작성해주세요.) 15 | [//]: # (ex. close #1, resolve #2) 16 | 17 | ## 작업 내용 18 | 19 | [//]: # (해당 PR에서 어떤 작업을 했는지 작성해주세요.) 20 | 21 | ## 고민과 학습내용 22 | 23 | [//]: # (해당 작업을 하면서 고민했던 점이나 학습한 내용을 작성해주세요.) 24 | [//]: # (팀 노션의 학습로그와 연결해도 좋습니다.) 25 | 26 | ## 스크린샷 27 | 28 | [//]: # (가능한 경우 스크린샷을 첨부해주세요.) -------------------------------------------------------------------------------- /apps/client/src/features/project/sprint/useSprintsQuery.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | import { projectAPI } from '@/features/project/api.ts'; 3 | 4 | export const useSprintsQuery = (projectId: number) => { 5 | return useQuery({ 6 | queryKey: ['sprints', projectId], 7 | queryFn: async () => { 8 | const { result } = await projectAPI.getSprints(projectId); 9 | 10 | return result.sort((a, b) => { 11 | return new Date(a.startDate).getTime() - new Date(b.startDate).getTime(); 12 | }); 13 | }, 14 | throwOnError: true, 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /apps/server/src/project/dto/contributor/update-contributor-request.dts.ts: -------------------------------------------------------------------------------- 1 | import { IsEnum, IsIn, IsNotEmpty, IsNumber, IsPositive } from 'class-validator'; 2 | import { ContributorStatus } from '@/project/enum/contributor-status.enum'; 3 | 4 | export class UpdateContributorRequest { 5 | @IsNotEmpty() 6 | @IsNumber() 7 | @IsPositive() 8 | contributorId: number; 9 | 10 | @IsNotEmpty() 11 | @IsEnum(ContributorStatus) 12 | @IsIn([ContributorStatus.ACCEPTED, ContributorStatus.REJECTED], { 13 | message: 'Required ACCEPTED or REJECTED', 14 | }) 15 | status: ContributorStatus; 16 | } 17 | -------------------------------------------------------------------------------- /apps/client/src/shared/utils/throttle.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 2 | export function throttle any>(func: T, delay: number = 100) { 3 | let lastRun = 0; 4 | let timeout: number | null = null; 5 | 6 | return function (this: ThisParameterType, ...args: Parameters) { 7 | const now = Date.now(); 8 | 9 | if (now - lastRun >= delay) { 10 | if (timeout) { 11 | clearTimeout(timeout); 12 | timeout = null; 13 | } 14 | func.apply(this, args); 15 | lastRun = now; 16 | } 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /apps/client/src/features/types.ts: -------------------------------------------------------------------------------- 1 | export interface BaseResponse { 2 | status: number; 3 | message: string; 4 | result: T extends void ? never : T; 5 | } 6 | 7 | export type User = { 8 | id: number; 9 | username: string; 10 | role?: string; 11 | profileImage?: string; // or imageUrl 12 | }; 13 | 14 | export type Sprint = { 15 | id: number; 16 | name: string; 17 | startDate: string; 18 | endDate: string; 19 | }; 20 | 21 | export type Label = { 22 | id: number; 23 | name: string; 24 | description: string; 25 | color: string; 26 | }; 27 | 28 | export type Assignee = User; 29 | -------------------------------------------------------------------------------- /apps/client/src/routes/__root.tsx: -------------------------------------------------------------------------------- 1 | import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'; 2 | import { QueryClient } from '@tanstack/react-query'; 3 | import { AuthContextValue } from '@/features/auth/AuthProvider.tsx'; 4 | import { Toaster } from '@/components/ui/sonner.tsx'; 5 | 6 | interface RouterContext { 7 | auth: AuthContextValue; 8 | queryClient: QueryClient; 9 | } 10 | 11 | export const Route = createRootRouteWithContext()({ 12 | component: () => ( 13 | <> 14 | 15 | 16 | 17 | ), 18 | }); 19 | -------------------------------------------------------------------------------- /apps/server/src/project/enum/eventType.enum.ts: -------------------------------------------------------------------------------- 1 | export enum EventType { 2 | 'CREATE_TASK' = 'CREATE_TASK', 3 | 'DELETE_TASK' = 'DELETE_TASK', 4 | 'INSERT_TITLE' = 'INSERT_TITLE', 5 | 'DELETE_TITLE' = 'DELETE_TITLE', 6 | 'UPDATE_POSITION' = 'UPDATE_POSITION', 7 | 8 | 'TASK_CREATED' = 'TASK_CREATED', 9 | 'TASK_DELETED' = 'TASK_DELETED', 10 | 'TITLE_INSERTED' = 'TITLE_INSERTED', 11 | 'TITLE_DELETED' = 'TITLE_DELETED', 12 | 'POSITION_UPDATED' = 'POSITION_UPDATED', 13 | 'LABELS_CHANGED' = 'LABELS_CHANGED', 14 | 'ASSIGNEES_CHANGED' = 'ASSIGNEES_CHANGED', 15 | 'SUBTASKS_CHANGED' = 'SUBTASKS_CHANGED', 16 | } 17 | -------------------------------------------------------------------------------- /apps/client/src/features/auth/types.ts: -------------------------------------------------------------------------------- 1 | export interface AuthState { 2 | isAuthenticated: boolean; 3 | username: string; 4 | accessToken: string; 5 | profileImage: string; 6 | } 7 | 8 | export interface LoginRequestDto { 9 | username: string; 10 | password: string; 11 | } 12 | 13 | export interface LoginResult { 14 | id: number; 15 | username: string; 16 | accessToken: string; 17 | profileImage: string; 18 | } 19 | 20 | export interface RegisterRequestDto { 21 | username: string; 22 | password: string; 23 | } 24 | 25 | export interface RegisterResult { 26 | id: number; 27 | username: string; 28 | } 29 | -------------------------------------------------------------------------------- /apps/server/src/project/dto/task/update-task-details-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsOptional, IsPositive, IsString, ValidateIf } from 'class-validator'; 2 | 3 | export class UpdateTaskDetailsRequest { 4 | @ValidateIf((dto) => dto.priority !== null) 5 | @IsOptional() 6 | @IsPositive() 7 | priority: number; 8 | 9 | @ValidateIf((dto) => dto.priority !== null) 10 | @IsOptional() 11 | @IsPositive() 12 | sprintId: number; 13 | 14 | @ValidateIf((dto) => dto.priority !== null) 15 | @IsOptional() 16 | @IsPositive() 17 | estimate: number; 18 | 19 | @IsOptional() 20 | @IsString() 21 | description: string; 22 | } 23 | -------------------------------------------------------------------------------- /apps/client/src/features/task/subtask/types.ts: -------------------------------------------------------------------------------- 1 | import { BaseResponse } from '@/features/types.ts'; 2 | 3 | export type Subtask = { 4 | id: number; 5 | content: string; 6 | completed: boolean; 7 | }; 8 | 9 | // create 10 | export type CreateSubtaskResponse = BaseResponse; 11 | 12 | // update 13 | export interface UpdateSubtaskDto { 14 | content?: string; 15 | completed?: boolean; 16 | } 17 | 18 | export type UpdateSubtaskResponse = BaseResponse; 19 | 20 | // delete 21 | export interface DeleteSubtaskResult { 22 | subtask: { 23 | id: number; 24 | content: string; 25 | completed: boolean; 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /apps/client/src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | function Header({ children }: { children: ReactNode }) { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | } 10 | 11 | function Left({ children }: { children: ReactNode }) { 12 | return
{children}
; 13 | } 14 | 15 | function Right({ children }: { children: ReactNode }) { 16 | return
{children}
; 17 | } 18 | 19 | Header.Left = Left; 20 | Header.Right = Right; 21 | 22 | export default Header; 23 | -------------------------------------------------------------------------------- /apps/server/src/project/dto/label/label-details-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from '@nestjs/common'; 2 | import { IsOptional, IsString, Length, Matches } from 'class-validator'; 3 | 4 | export class LabelDetailsRequest { 5 | @IsOptional() 6 | @IsString() 7 | @Length(1, 10) 8 | name: string; 9 | 10 | @IsOptional() 11 | @IsString() 12 | description: string; 13 | 14 | @IsOptional() 15 | @IsString() 16 | @Matches(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/) 17 | color: string; 18 | 19 | validate() { 20 | if (!this.name || !this.description || !this.color) { 21 | throw new BadRequestException('Required all fields'); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /apps/client/src/features/task/types.ts: -------------------------------------------------------------------------------- 1 | import { Subtask } from '@/features/task/subtask/types.ts'; 2 | import { Assignee, Label, Sprint } from '@/features/types.ts'; 3 | 4 | export interface UpdateTaskDto { 5 | description?: string; 6 | priority?: number | null; 7 | sprintId?: number | null; 8 | estimate?: number | null; 9 | } 10 | 11 | export interface DetailedTask { 12 | id: number; 13 | title: string; 14 | description: string; 15 | priority: number; 16 | estimate: number; 17 | sprint: Sprint; 18 | assignees: Assignee[]; 19 | labels: Label[]; 20 | subtasks: Subtask[]; 21 | } 22 | 23 | export type Priority = number; 24 | 25 | export type Estimate = number; 26 | -------------------------------------------------------------------------------- /.github/workflows/CI.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: [ dev ] 6 | 7 | jobs: 8 | ci: 9 | name: CI 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v3 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: '20' 22 | 23 | - name: Setup PNPM 24 | uses: pnpm/action-setup@v2 25 | with: 26 | version: '9' 27 | 28 | - name: Install dependencies 29 | run: pnpm install 30 | 31 | - name: Build 32 | run: pnpm build 33 | -------------------------------------------------------------------------------- /apps/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "paths": { 14 | "@/*": ["src/*"] 15 | }, 16 | "incremental": true, 17 | "skipLibCheck": true, 18 | "strictNullChecks": false, 19 | "noImplicitAny": false, 20 | "strictBindCallApply": false, 21 | "forceConsistentCasingInFileNames": false, 22 | "noFallthroughCasesInSwitch": false 23 | }, 24 | "exclude": ["ecosystem.config.js"] 25 | } 26 | -------------------------------------------------------------------------------- /apps/server/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from '@/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()).get('/').expect(200).expect('Hello World!'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /apps/client/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "Bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /apps/client/src/components/TabView.tsx: -------------------------------------------------------------------------------- 1 | function TabView({ children }: { children: React.ReactNode }) { 2 | return
{children}
; 3 | } 4 | 5 | function Title({ children }: { children: React.ReactNode }) { 6 | return ( 7 |
8 |
9 |

{children}

10 |
11 |
12 | ); 13 | } 14 | 15 | function Content({ children }: { children: React.ReactNode }) { 16 | return
{children}
; 17 | } 18 | 19 | TabView.Title = Title; 20 | TabView.Content = Content; 21 | 22 | export default TabView; 23 | -------------------------------------------------------------------------------- /apps/server/src/account/entity/account.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; 2 | import { EntityTimestamp } from '@/common/entity-timestamp.entity'; 3 | 4 | @Entity() 5 | export class Account extends EntityTimestamp { 6 | @PrimaryGeneratedColumn() 7 | id: number; 8 | 9 | @Column() 10 | username: string; 11 | 12 | @Column() 13 | password: string; 14 | 15 | @Column({ 16 | default: 'https://kr.object.ncloudstorage.com/4card-harmony-bucket/default_profile.jpg', 17 | }) 18 | profileImage: string; 19 | 20 | @Column({ nullable: true }) 21 | refreshToken: string; 22 | 23 | setRefreshToken(refreshToken: string | null) { 24 | this.refreshToken = refreshToken; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /apps/server/src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from '@/app.controller'; 3 | import { AppService } from '@/app.service'; 4 | 5 | describe('AppController', () => { 6 | let appController: AppController; 7 | 8 | beforeEach(async () => { 9 | const app: TestingModule = await Test.createTestingModule({ 10 | controllers: [AppController], 11 | providers: [AppService], 12 | }).compile(); 13 | 14 | appController = app.get(AppController); 15 | }); 16 | 17 | describe('root', () => { 18 | it('should return "Hello World!"', () => { 19 | expect(appController.getHello()).toBe('Hello World!'); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "harmony", 3 | "version": "1.0.0", 4 | "description": "", 5 | "keywords": [], 6 | "author": "", 7 | "license": "MIT", 8 | "scripts": { 9 | "build": "turbo build", 10 | "dev": "turbo dev", 11 | "lint": "turbo lint", 12 | "format": "turbo format", 13 | "postinstall": "husky" 14 | }, 15 | "devDependencies": { 16 | "husky": "^9.1.6", 17 | "prettier": "^3.3.3", 18 | "prettier-plugin-tailwindcss": "^0.6.8", 19 | "turbo": "^2.2.3", 20 | "typescript": "~5.6.2" 21 | }, 22 | "lint-staged": { 23 | "**/*.{js,jsx,ts,tsx}": [ 24 | "eslint --fix", 25 | "prettier --write" 26 | ], 27 | "**/*.{json,md}": [ 28 | "prettier --write" 29 | ] 30 | }, 31 | "packageManager": "pnpm@9.12.3" 32 | } 33 | -------------------------------------------------------------------------------- /apps/client/src/features/project/board/components/SubtaskProgress.tsx: -------------------------------------------------------------------------------- 1 | export function SubtaskProgress({ total, completed }: { total: number; completed: number }) { 2 | if (total === 0) return null; 3 | 4 | const percentage = Math.floor((completed / total) * 100); 5 | 6 | return ( 7 |
8 |
9 |
13 |
14 | 15 | {completed}/{total} 16 | 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /apps/client/src/config/env.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | const envSchema = z.object({ 4 | AUTH_STORAGE_KEY: z.string(), 5 | API_BASE_URL: z.string(), 6 | API_SOCKET_URL: z.string(), 7 | }); 8 | 9 | type Env = z.infer; 10 | 11 | const processEnv: Env = { 12 | AUTH_STORAGE_KEY: import.meta.env.VITE_AUTH_STORAGE_KEY, 13 | API_BASE_URL: import.meta.env.VITE_API_URL, 14 | API_SOCKET_URL: import.meta.env.VITE_SOCKET_URL, 15 | }; 16 | 17 | function validateEnv() { 18 | try { 19 | return envSchema.parse(processEnv); 20 | } catch (error) { 21 | if (error instanceof z.ZodError) { 22 | throw new Error('적절한 환경 변수가 설정되지 않았습니다.'); 23 | } 24 | 25 | throw error; 26 | } 27 | } 28 | 29 | export const ENV = validateEnv(); 30 | -------------------------------------------------------------------------------- /apps/server/src/project/entity/sprint.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; 2 | import { EntityTimestamp } from '@/common/entity-timestamp.entity'; 3 | 4 | @Entity() 5 | export class Sprint extends EntityTimestamp { 6 | @PrimaryGeneratedColumn() 7 | id: number; 8 | 9 | @Column() 10 | projectId: number; 11 | 12 | @Column() 13 | title: string; 14 | 15 | @Column() 16 | startDate: Date; 17 | 18 | @Column() 19 | endDate: Date; 20 | 21 | update(title: string, startDate: string, endDate: string) { 22 | if (title) { 23 | this.title = title; 24 | } 25 | if (startDate && endDate) { 26 | this.startDate = new Date(startDate); 27 | this.endDate = new Date(endDate); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /apps/server/src/project/entity/label.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; 2 | import { EntityTimestamp } from '@/common/entity-timestamp.entity'; 3 | 4 | @Entity() 5 | export class Label extends EntityTimestamp { 6 | @PrimaryGeneratedColumn() 7 | id: number; 8 | 9 | @Column() 10 | projectId: number; 11 | 12 | @Column() 13 | title: string; 14 | 15 | @Column() 16 | description: string; 17 | 18 | @Column() 19 | color: string; 20 | 21 | update(title: string, description: string, color: string) { 22 | if (title) { 23 | this.title = title; 24 | } 25 | if (description) { 26 | this.description = description; 27 | } 28 | if (color) { 29 | this.color = color; 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /apps/server/src/project/entity/contributor.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; 2 | import { ContributorStatus } from '@/project/enum/contributor-status.enum'; 3 | import { ProjectRole } from '@/project/enum/project-role.enum'; 4 | import { EntityTimestamp } from '@/common/entity-timestamp.entity'; 5 | 6 | @Entity() 7 | export class Contributor extends EntityTimestamp { 8 | @PrimaryGeneratedColumn() 9 | id: number; 10 | 11 | @Column() 12 | userId: number; 13 | 14 | @Column() 15 | inviterId: number; 16 | 17 | @Column() 18 | projectId: number; 19 | 20 | @Column({ type: 'enum', enum: ContributorStatus }) 21 | status: ContributorStatus; 22 | 23 | @Column({ type: 'enum', enum: ProjectRole }) 24 | role: ProjectRole; 25 | } 26 | -------------------------------------------------------------------------------- /apps/client/src/components/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { cn } from '@/lib/utils'; 4 | 5 | const Textarea = React.forwardRef>( 6 | ({ className, ...props }, ref) => { 7 | return ( 8 |