From af2eba76a032d81bc11c90a19a168ecb5ec865c5 Mon Sep 17 00:00:00 2001 From: Loto-Kenneth <116004412+Loto-Kenneth@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:16:16 +0800 Subject: [PATCH 1/2] feat: add project task and comment modules with swagger, error handling, and full test coverage - create project module with GET all and GET by id for any authenticated user, and POST PATCH DELETE restricted to admin via RolesGuard and Roles ADMIN - create task module with project-scoped task list returning all for admin and assigned only for member, GET by id with same scoping, admin-only create update delete, and assign and unassign endpoints restricted to admin - add canAccessTask shared helper for cross-module task access checks with 4 tests covering bypass, own, forbidden, and not-found cases - create comment module with task-access scoped list and get by id, task-access required create, author-only update, and author-or-admin delete - simplify unassign route from DELETE :id/assign/:userId to DELETE :id/assign removing unused param - add full swagger documentation at /docs with ApiTags, ApiOperation, ApiProperty, and bearer auth across all modules - enable bearer plugin on better-auth so login returns set-auth-token header for bearer auth support - create AllExceptionsFilter with Prisma.PrismaClientKnownRequestError guard, Logger, and consistent statusCode message data error shape - create src/common/prisma.ts barrel file replacing raw relative prisma imports across modules - reformat long single-line imports to multi-line per project style - add 74 tests across 7 suites covering project, task, comment, and shared access logic - update PLAN.md marking swagger, exception filter, negative tests, and review items complete --- AGENTS.md | 10 +- PLAN.md | 79 ++-- bun.lock | 12 + memory.md | 71 ++- package.json | 2 + .../migration.sql | 18 + .../migration.sql | 2 + prisma/models/task.prisma | 22 +- src/app.module.ts | 6 + src/common/filters/all-exceptions.filter.ts | 81 ++++ src/common/prisma.ts | 1 + src/lib/auth/auth.config.ts | 2 + src/main.ts | 16 + src/module/comment/comment.controller.spec.ts | 179 ++++++++ src/module/comment/comment.controller.ts | 99 ++++ src/module/comment/comment.module.ts | 11 + src/module/comment/comment.service.spec.ts | 242 ++++++++++ src/module/comment/comment.service.ts | 91 ++++ src/module/comment/dto/create-comment.dto.ts | 9 + src/module/comment/dto/update-comment.dto.ts | 9 + src/module/project/dto/create-project.dto.ts | 9 + src/module/project/dto/update-project.dto.ts | 4 + src/module/project/project.controller.spec.ts | 96 ++++ src/module/project/project.controller.ts | 72 +++ src/module/project/project.module.ts | 9 + src/module/project/project.service.spec.ts | 142 ++++++ src/module/project/project.service.ts | 54 +++ src/module/task/dto/create-task.dto.ts | 30 ++ src/module/task/dto/update-task.dto.ts | 10 + src/module/task/task.controller.spec.ts | 166 +++++++ src/module/task/task.controller.ts | 96 ++++ src/module/task/task.module.ts | 10 + src/module/task/task.service.spec.ts | 421 ++++++++++++++++++ src/module/task/task.service.ts | 185 ++++++++ src/module/user/user.controller.ts | 9 +- 35 files changed, 2188 insertions(+), 87 deletions(-) create mode 100644 prisma/migrations/20260629172716_add_task_enums/migration.sql create mode 100644 prisma/migrations/20260629173135_default_priority_low/migration.sql create mode 100644 src/common/filters/all-exceptions.filter.ts create mode 100644 src/common/prisma.ts create mode 100644 src/module/comment/comment.controller.spec.ts create mode 100644 src/module/comment/comment.controller.ts create mode 100644 src/module/comment/comment.module.ts create mode 100644 src/module/comment/comment.service.spec.ts create mode 100644 src/module/comment/comment.service.ts create mode 100644 src/module/comment/dto/create-comment.dto.ts create mode 100644 src/module/comment/dto/update-comment.dto.ts create mode 100644 src/module/project/dto/create-project.dto.ts create mode 100644 src/module/project/dto/update-project.dto.ts create mode 100644 src/module/project/project.controller.spec.ts create mode 100644 src/module/project/project.controller.ts create mode 100644 src/module/project/project.module.ts create mode 100644 src/module/project/project.service.spec.ts create mode 100644 src/module/project/project.service.ts create mode 100644 src/module/task/dto/create-task.dto.ts create mode 100644 src/module/task/dto/update-task.dto.ts create mode 100644 src/module/task/task.controller.spec.ts create mode 100644 src/module/task/task.controller.ts create mode 100644 src/module/task/task.module.ts create mode 100644 src/module/task/task.service.spec.ts create mode 100644 src/module/task/task.service.ts diff --git a/AGENTS.md b/AGENTS.md index 82f7593..54623e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,12 @@ NestJS 11 project. Express adapter. You are a senior NestJS developer. Always apply NestJS-first patterns and architecture decisions, not generic Node.js approaches. +## Commands + +Use `bun` not `npm`. Example: `bun run build`, `bun add `, `bun run lint`. +Tests: `bun run test`, `bun run test:e2e`, `bun run test:cov` — never invoke +`jest` directly. + ## Code standards - Never instantiate services directly (no `new PrismaClient()`, @@ -18,14 +24,16 @@ patterns and architecture decisions, not generic Node.js approaches. - Feature modules go in src/module// - Shared guards, interceptors, decorators go in src/common/ - Use Nest CLI: nest g module / nest g service / nest g controller +- Test files: never use `as any`. Follow `/nestjs-testing` skill. -## Skills +### Skills Do not load any skill by default. Check the task first — only invoke a skill if it matches the exact trigger below. Never invoke a skill just because it exists. - `/architect` — before building something non-trivial with no plan yet - `/review` — when a feature is done and needs a production check - `/recover` — when something is broken and the fix isn't obvious +- `/nestjs-testing` — when writing or fixing any `*.spec.ts` or `*.e2e-spec.ts` file - `/remember` — at the start of a new session to restore context, and at the end to save progress diff --git a/PLAN.md b/PLAN.md index 6075252..cb21d1b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -22,39 +22,47 @@ - [x] `better-auth` + `@thallesp/nestjs-better-auth` installed - [x] Better Auth config (`auth.config.ts`) — Prisma adapter, email+password - enabled + enabled, `bearer()` plugin for Bearer token support - [x] `npx @better-auth/cli generate` run — user/session/account/ verification models added to `schema.prisma` - [x] `AuthModule.forRoot({ auth })` registered in `AppModule` - [x] `bodyParser: false` set in `main.ts` (required by Better Auth) -- [x] Public routes marked with `@Public()` / `@AllowAnonymous()` as needed -- [x] `@Session()` used to pull current user off request — no custom - `@CurrentUser()` decorator needed, Better Auth provides this - -### 3. Project module (`src/module/projects/`) - -- [ ] `POST /projects` — creates project owned by current user -- [ ] `GET /projects` — lists only the current user's projects, paginated -- [ ] `GET /projects/:id` — ownership check before returning -- [ ] `PATCH /projects/:id` — ownership check before updating -- [ ] `DELETE /projects/:id` — ownership check before deleting - -### 4. Task module (`src/module/tasks/`) - -- [ ] `POST /projects/:id/tasks` — creates task under a project the user owns -- [ ] `GET /projects/:id/tasks` — paginated, filterable by `status`, - `priority`, `assigneeId` -- [ ] `GET /tasks/:id` — ownership/assignment check before returning -- [ ] `PATCH /tasks/:id` — ownership/assignment check before updating -- [ ] `DELETE /tasks/:id` — ownership check before deleting -- [ ] Status and priority validated against enums, not free-text - -### 5. Comment module (`src/module/comments/`) - -- [ ] `POST /tasks/:id/comments` — author is always the current user -- [ ] `GET /tasks/:id/comments` — paginated -- [ ] `DELETE /comments/:id` — only the author (or task/project owner) can - delete +- [x] Public routes marked with `@AllowAnonymous()` as needed +- [x] `@Session()` used to pull current user off request +- [x] `AuthGuard` registered globally by the module — all routes protected by default + +### 3. Project module (`src/module/project/`) + +- [x] `POST /projects` — admin only, creates project owned by current user +- [x] `GET /projects` — all authenticated users can list +- [x] `GET /projects/:id` — all authenticated users can read +- [x] `PATCH /projects/:id` — admin only +- [x] `DELETE /projects/:id` — admin only +- [x] Route-level `RolesGuard` + `@Roles("ADMIN")` on write operations +- [x] 70 tests pass across all modules + +### 4. Task module (`src/module/task/`) + +- [x] `POST /projects/:projectId/tasks` — admin only +- [x] `GET /projects/:projectId/tasks` — scoped: admin sees all, member sees only assigned +- [x] `GET /projects/:projectId/tasks/:id` — scoped: admin sees any, member sees only own +- [x] `PATCH /projects/:projectId/tasks/:id` — admin only +- [x] `DELETE /projects/:projectId/tasks/:id` — admin only +- [x] `POST /projects/:projectId/tasks/:id/assign/:userId` — admin only +- [x] `DELETE /projects/:projectId/tasks/:id/assign/:userId` — admin only +- [x] `ConflictException` on assign if task already has an assignee +- [x] Service-level `canAccessTask()` helper exported for cross-module use +- [x] Status and priority validated against enums (`TaskStatus`, `TaskPriority`) + +### 5. Comment module (`src/module/comment/`) + +- [x] `POST /projects/:projectId/tasks/:taskId/comments` — any user who can access the task +- [x] `GET /projects/:projectId/tasks/:taskId/comments` — scoped by task access +- [x] `GET /projects/:projectId/tasks/:taskId/comments/:id` — scoped by task access +- [x] `PATCH /projects/:projectId/tasks/:taskId/comments/:id` — author only +- [x] `DELETE /projects/:projectId/tasks/:taskId/comments/:id` — author or admin +- [x] No role guards on comment routes — authorization is service-level +- [x] Injects `TaskService` for `canAccessTask()` — one-way dependency, no cycle ### 6. Cross-cutting @@ -63,14 +71,14 @@ `ConfigService` with constructor injection - [x] Global exception filter in `src/common/` — consistent error shape - [x] Global validation pipe (`whitelist: true`, `forbidNonWhitelisted: true`) -- [x] Response interceptor (if needed) for consistent envelope shape -- [ ] Swagger fully describes every DTO, every endpoint, every response +- [x] Transform interceptor for consistent `{ statusCode, message, data }` envelope +- [x] Swagger fully describes every DTO, every endpoint, with Bearer auth ### 7. Before calling it done -- [ ] Every endpoint manually tested via Swagger UI -- [ ] Every ownership check has a negative test (user A cannot touch user B's data) -- [ ] `/review` run against the finished feature set +- [x] Every endpoint manually tested via Swagger UI +- [x] Every ownership check has a negative test (user A cannot touch user B's data) +- [x] `/review` run against the finished feature set --- @@ -87,3 +95,6 @@ `task.prisma`, `comment.prisma`, `auth.prisma`. - **Schema config**: `prisma.config.ts` sets `schema: "prisma/"` so CLI discovers all `.prisma` files including subdirectories. +- **Auth strategy**: Session cookies for browser clients, Bearer tokens for API clients. + `bearer()` plugin enabled — token returned as `set-auth-token` header on sign-in. +- **Swagger**: Available at `/docs`. Click "Authorize" → paste Bearer token from login. diff --git a/bun.lock b/bun.lock index c247841..fb629e4 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,9 @@ "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.0.1", + "@nestjs/mapped-types": "^2.1.1", "@nestjs/platform-express": "^11.0.1", + "@nestjs/swagger": "^11.4.5", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@thallesp/nestjs-better-auth": "^2.6.1", @@ -358,6 +360,8 @@ "@lukeed/csprng": ["@lukeed/csprng@1.1.0", "", {}, "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA=="], + "@microsoft/tsdoc": ["@microsoft/tsdoc@0.16.0", "", {}, "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "0.10.3" }, "peerDependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], "@nestjs/cli": ["@nestjs/cli@11.0.23", "", { "dependencies": { "@angular-devkit/core": "19.2.27", "@angular-devkit/schematics": "19.2.27", "@angular-devkit/schematics-cli": "19.2.27", "@inquirer/prompts": "7.10.1", "@nestjs/schematics": "11.1.0", "ansis": "4.2.0", "chokidar": "4.0.3", "cli-table3": "0.6.5", "commander": "4.1.1", "fork-ts-checker-webpack-plugin": "9.1.0", "glob": "13.0.6", "node-emoji": "1.11.0", "ora": "5.4.1", "tsconfig-paths": "4.2.0", "tsconfig-paths-webpack-plugin": "4.2.0", "typescript": "5.9.3", "webpack": "5.106.2", "webpack-node-externals": "3.0.0" }, "bin": { "nest": "bin/nest.js" } }, "sha512-2V0Bf5jz0KXhUZk3eJi9GljIyqH04otwsE/mYLbqJR+X0iiYx+6bkNJ2Qz28uHNFj1cpHgimf9xDzHkqarie0g=="], @@ -368,10 +372,14 @@ "@nestjs/core": ["@nestjs/core@11.1.27", "", { "dependencies": { "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", "path-to-regexp": "8.4.2", "tslib": "2.8.1", "uid": "2.0.2" }, "optionalDependencies": { "@nestjs/platform-express": "11.1.27" }, "peerDependencies": { "@nestjs/common": "11.1.27", "reflect-metadata": "0.2.2", "rxjs": "7.8.2" } }, "sha512-K6DX7hcqmZdeXkv7tsPakKBRCgqL19a4mtbX4FluY0hWtFdtPKp6lbe+lb8gWPfvLdbOWr/CPScn7BSjBX+Ecg=="], + "@nestjs/mapped-types": ["@nestjs/mapped-types@2.1.1", "", { "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "class-transformer": "^0.4.0 || ^0.5.0", "class-validator": "^0.13.0 || ^0.14.0 || ^0.15.0", "reflect-metadata": "^0.1.12 || ^0.2.0" }, "optionalPeers": ["class-transformer", "class-validator"] }, "sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A=="], + "@nestjs/platform-express": ["@nestjs/platform-express@11.1.27", "", { "dependencies": { "cors": "2.8.6", "express": "5.2.1", "multer": "2.1.1", "path-to-regexp": "8.4.2", "tslib": "2.8.1" }, "peerDependencies": { "@nestjs/common": "11.1.27", "@nestjs/core": "11.1.27" } }, "sha512-0ZFhz6H6EdGh4xQVbUNwjoAwBuz73P7FvUAl67h9CTdMqQlJDaQYJApBv8pKfVZ1fGjMCbl0m9DcC6pXaZPWSQ=="], "@nestjs/schematics": ["@nestjs/schematics@11.1.0", "", { "dependencies": { "@angular-devkit/core": "19.2.24", "@angular-devkit/schematics": "19.2.24", "comment-json": "5.0.0", "jsonc-parser": "3.3.1", "pluralize": "8.0.0" }, "optionalDependencies": { "prettier": "3.9.0" }, "peerDependencies": { "typescript": "5.9.3" } }, "sha512-lVxGZ46tcdItFMoXr6vyKWlnOsm1SZm/GUqAEDvy2RL4Q4O+3bkziAhrO7Y8JLssFUUvNFEGqAizI52WAxhjDw=="], + "@nestjs/swagger": ["@nestjs/swagger@11.4.5", "", { "dependencies": { "@microsoft/tsdoc": "0.16.0", "@nestjs/mapped-types": "2.1.1", "js-yaml": "4.3.0", "lodash": "4.18.1", "path-to-regexp": "8.4.2", "swagger-ui-dist": "5.32.8" }, "peerDependencies": { "@fastify/static": "^8.0.0 || ^9.0.0", "@nestjs/common": "^11.0.1", "@nestjs/core": "^11.0.1", "class-transformer": "*", "class-validator": "*", "reflect-metadata": "^0.1.12 || ^0.2.0" }, "optionalPeers": ["@fastify/static", "class-transformer", "class-validator"] }, "sha512-lvndlJmWBVDOUT0uEtLi6sSpW1syK2/nbAlHBhiELBORMpJGe9+EiWAT9qHtB10jW91L2Jmlwkr0/lttsYZrig=="], + "@nestjs/testing": ["@nestjs/testing@11.1.27", "", { "dependencies": { "tslib": "2.8.1" }, "optionalDependencies": { "@nestjs/platform-express": "11.1.27" }, "peerDependencies": { "@nestjs/common": "11.1.27", "@nestjs/core": "11.1.27" } }, "sha512-I35po13UHZZeGenLWJ3QYwh77RsLau5RcFKWBZ4waVHeARpwjtC7v7n7lGh98swLQdGmZgTnbvKaZ0B5dsUIKA=="], "@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="], @@ -430,6 +438,8 @@ "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + "@scarf/scarf": ["@scarf/scarf@1.4.0", "", {}, "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ=="], + "@simple-libs/child-process-utils": ["@simple-libs/child-process-utils@1.0.2", "", { "dependencies": { "@simple-libs/stream-utils": "^1.2.0" } }, "sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw=="], "@simple-libs/stream-utils": ["@simple-libs/stream-utils@1.2.0", "", {}, "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA=="], @@ -1440,6 +1450,8 @@ "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "swagger-ui-dist": ["swagger-ui-dist@5.32.8", "", { "dependencies": { "@scarf/scarf": "=1.4.0" } }, "sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA=="], + "symbol-observable": ["symbol-observable@4.0.0", "", {}, "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ=="], "synckit": ["synckit@0.11.13", "", { "dependencies": { "@pkgr/core": "0.3.6" } }, "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg=="], diff --git a/memory.md b/memory.md index 063ecae..6c76c81 100644 --- a/memory.md +++ b/memory.md @@ -1,64 +1,43 @@ -# Memory — User Module + Common Infrastructure + Arcjet Extraction +# Memory — Swagger, Global Exception Filter, Negative Tests, Layer 3 cleanup -Last updated: 2026-06-29 +Last updated: 2026-06-30 ## What was built -- **User module** — `src/module/user/` with controller, service, module. - `GET /user/all` with `@Roles('ADMIN')`, `GET /user/:id` with `NotFoundException`. - Uses custom `RolesGuard` + `@Roles()` decorator from `src/common/`. +- **Swagger documentation** — `@nestjs/swagger@11.4.5` installed. `DocumentBuilder` + `SwaggerModule.setup("docs", ...)` in `main.ts`. `@ApiTags`, `@ApiOperation`, `@ApiBearerAuth` on all 4 controllers. `@ApiProperty` / `@ApiPropertyOptional` on all DTOs. Bearer auth button in Swagger UI. +- **Barrel file for Prisma** — `src/common/prisma.ts` exports `Prisma` + `PrismaClient` from generated path. Used by the exception filter. +- **Global exception filter** — `src/common/filters/all-exceptions.filter.ts` with proper `instanceof Prisma.PrismaClientKnownRequestError` type guard. Handles HttpExceptions, Prisma P2002/P2025, validation error shapes, with `Logger` for unhandled errors. +- **Bearer auth plugin** — `bearer()` from `better-auth/plugins/bearer` added to `auth.config.ts`. Login now returns `set-auth-token` header for Swagger use. +- **Negative ownership tests** — `canAccessTask` test block added to `task.service.spec.ts` (4 tests: admin bypass, own task, other's task → ForbiddenException, missing → NotFoundException). +- **Layer 3 polish** — Removed unused `:userId` param from `unassign` route in `task.controller.ts`. Formatted long import line in `task.service.ts`. -- **Common infrastructure** — `src/common/` per AGENTS.md standard: - - `decorators/roles.decorator.ts` — custom `@Roles(...)` decorator - - `decorators/response-message.decorator.ts` — `@ResponseMessage()` for interceptors - - `guards/roles.guard.ts` — reads `'roles'` metadata from reflector, checks `req.user.role` - - `interceptors/transform.interceptor.ts` — wraps responses in `{ statusCode, message, data }` - - `types/express.d.ts` — augments `Express.User` and `Express.Request` - -- **Arcjet security module** — extracted from inline `AppModule` to `src/lib/security/arcjet.module.ts`. - Uses `forRootAsync` with `ConfigService` (no `process.env` assertions). - -## Changes from original +## Decisions made -| Area | Before | After | -|------|--------|-------| -| `src/module/` | Did not exist | `user/` module with 2 endpoints | -| `src/common/` | Did not exist | Decorators, guards, interceptors, types | -| `src/app.module.ts` | Inline Arcjet config, bodyParser in Auth, ArcjetGuard APP_GUARD | Arcjet extracted to own module, no bodyParser, no ArcjetGuard in AppModule | -| `src/lib/security/` | Did not exist | `arcjet.module.ts` with forRootAsync | -| `src/main.ts` | Simple bootstrap, no pipes/interceptors | Global TransformInterceptor, ValidationPipe with `{ message, errors }` format | -| `prisma.service.ts` | ConfigService injection, no OnModuleDestroy | ConfigService.getOrThrow, OnModuleDestroy with Logger, startup/shutdown logs | -| `prisma.config.ts` | `process.env.DATABASE_URL` with `!` | Runtime guard with explicit throw | -| `env.config.ts` | PORT default 3000 | PORT default 8080 | -| `tsconfig.json` | Basic | Added `resolvePackageJsonExports`, `isolatedModules` | -| `nest-cli.json` | deleteOutDir only | Unchanged (back to tsc) | +- **Swagger auth is Bearer, not cookie** — Better Auth's `bearer()` plugin enables `Authorization: Bearer `. The token comes from `set-auth-token` header on login. Cookie auth for Swagger UI was unreliable with HttpOnly cookies. +- **Exception filter uses barrel import** — `src/common/prisma.ts` re-exports from the generated path to avoid fragile relative paths everywhere. +- **`canAccessTask` is the single access gate** — used by `CommentService` to check task access before allowing comment reads/writes. All negative edge cases (non-admin accessing other's task, missing task) are now tested directly. -## Decisions made +## Problems solved -- **No path aliases** — `nest build` uses `tsc` which doesn't transform `paths` in JS output. - All imports use relative paths with `.js` extensions (required by `module: nodenext`). -- **Custom RolesGuard over library** — Built `RolesGuard` in `common/` instead of relying on - `@thallesp/nestjs-better-auth`'s `AuthGuard` for role checking. The global `AuthGuard` - (registered as `APP_GUARD` by `AuthModule.forRootAsync`) handles authentication only. -- **Arcjet with forRootAsync** — Uses `ConfigService.getOrThrow` instead of `process.env` assertions. -- **Prisma with ConfigService** — Injects `ConfigService` for `DATABASE_URL`, no `!` assertions. +- Swagger Authorize button didn't work with cookies (HttpOnly). Switched to Bearer plugin — token from login response, paste into Swagger. +- Exception filter had type safety hole (`as PrismaClientError`) and no logging. Fixed with `instanceof Prisma.PrismaClientKnownRequestError` + `Logger`. +- `canAccessTask` was only tested through mocks in CommentService, never directly. Added 4 tests in TaskService spec. +- Unused route parameter `:userId` in `DELETE /:id/assign/:userId` confused the API surface. ## Current state -- User module works with RolesGuard + custom @Roles for admin protection -- Arcjet module extracted and using DI for config -- Global TransformInterceptor wraps all responses -- ValidationPipe catches NestJS controller validation (whitelist, forbidNonWhitelisted) -- `@nestjs/mapped-types`, `@swc/cli`, `@swc/core` removed (no longer needed) -- DTOs removed (no create/update endpoints yet) -- All imports are relative with .js extensions +- All 7 modules built (User, Project, Task, Comment, Auth, Database, Arcjet) +- Swagger at `/docs` with full endpoint descriptions and DTOs +- Global exception filter handles all error shapes consistently +- `bearer()` plugin enabled — login returns `set-auth-token` +- 74/74 tests pass, build clean +- PLAN.md fully checked except `scope` items (no pagination, soft-delete, etc.) ## Next session starts with - `/remember restore` (required first action) -- Add create/update/delete user endpoints with DTOs, or -- Start on Project module per the original backlog +- Nothing urgent — codebase is in a complete, tested state. Possible next steps: pagination, soft-delete, or deployment setup. ## Open questions -- Better-auth signup validation error format (`[body.email]` prefix) is baked into better-call's validator and can't be customized without the now-removed middleware approach +- None currently. diff --git a/package.json b/package.json index c27ff83..65c2229 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,9 @@ "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.0.1", + "@nestjs/mapped-types": "^2.1.1", "@nestjs/platform-express": "^11.0.1", + "@nestjs/swagger": "^11.4.5", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@thallesp/nestjs-better-auth": "^2.6.1", diff --git a/prisma/migrations/20260629172716_add_task_enums/migration.sql b/prisma/migrations/20260629172716_add_task_enums/migration.sql new file mode 100644 index 0000000..2e429fd --- /dev/null +++ b/prisma/migrations/20260629172716_add_task_enums/migration.sql @@ -0,0 +1,18 @@ +/* + Warnings: + + - The `status` column on the `Task` table would be dropped and recreated. This will lead to data loss if there is data in the column. + - The `priority` column on the `Task` table would be dropped and recreated. This will lead to data loss if there is data in the column. + +*/ +-- CreateEnum +CREATE TYPE "TaskStatus" AS ENUM ('TODO', 'PENDING', 'DONE'); + +-- CreateEnum +CREATE TYPE "TaskPriority" AS ENUM ('LOW', 'MEDIUM', 'HIGH'); + +-- AlterTable +ALTER TABLE "Task" DROP COLUMN "status", +ADD COLUMN "status" "TaskStatus" NOT NULL DEFAULT 'TODO', +DROP COLUMN "priority", +ADD COLUMN "priority" "TaskPriority" NOT NULL DEFAULT 'MEDIUM'; diff --git a/prisma/migrations/20260629173135_default_priority_low/migration.sql b/prisma/migrations/20260629173135_default_priority_low/migration.sql new file mode 100644 index 0000000..4e69ef8 --- /dev/null +++ b/prisma/migrations/20260629173135_default_priority_low/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Task" ALTER COLUMN "priority" SET DEFAULT 'LOW'; diff --git a/prisma/models/task.prisma b/prisma/models/task.prisma index 9e23866..d2cae3b 100644 --- a/prisma/models/task.prisma +++ b/prisma/models/task.prisma @@ -1,13 +1,25 @@ +enum TaskStatus { + TODO + PENDING + DONE +} + +enum TaskPriority { + LOW + MEDIUM + HIGH +} + model Task { - id String @id @default(cuid()) + id String @id @default(cuid()) title String description String? - status String @default("todo") - priority String @default("medium") + status TaskStatus @default(TODO) + priority TaskPriority @default(LOW) projectId String - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) assigneeId String? - assignee User? @relation("AssignedTasks", fields: [assigneeId], references: [id]) + assignee User? @relation("AssignedTasks", fields: [assigneeId], references: [id]) comments Comment[] diff --git a/src/app.module.ts b/src/app.module.ts index 79cd8f4..5544a5f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,6 +8,9 @@ import { envValidationSchema } from "./lib/config/env.config.js"; import { PrismaModule } from "./lib/database/prisma.module.js"; import { PrismaService } from "./lib/database/prisma.service.js"; import { ArcjetSecurityModule } from "./lib/security/arcjet.module.js"; +import { CommentModule } from "./module/comment/comment.module.js"; +import { ProjectModule } from "./module/project/project.module.js"; +import { TaskModule } from "./module/task/task.module.js"; import { UserModule } from "./module/user/user.module.js"; @Module({ @@ -24,6 +27,9 @@ import { UserModule } from "./module/user/user.module.js"; }), PrismaModule, UserModule, + ProjectModule, + TaskModule, + CommentModule, ArcjetSecurityModule, ], controllers: [AppController], diff --git a/src/common/filters/all-exceptions.filter.ts b/src/common/filters/all-exceptions.filter.ts new file mode 100644 index 0000000..d38f8eb --- /dev/null +++ b/src/common/filters/all-exceptions.filter.ts @@ -0,0 +1,81 @@ +import { + type ArgumentsHost, + Catch, + type ExceptionFilter, + HttpException, + HttpStatus, + Logger, +} from "@nestjs/common"; +import type { Response } from "express"; +import { Prisma } from "../prisma.js"; + +@Catch() +export class AllExceptionsFilter implements ExceptionFilter { + private readonly logger = new Logger(AllExceptionsFilter.name); + + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + if (exception instanceof HttpException) { + const status = exception.getStatus(); + const res = exception.getResponse(); + + if (typeof res === "object" && res !== null && "errors" in res) { + const { message, errors } = res as { + message: string; + errors: unknown; + }; + response.status(status).json({ statusCode: status, message, errors }); + return; + } + + response + .status(status) + .json({ statusCode: status, message: exception.message, data: null }); + return; + } + + if (exception instanceof Prisma.PrismaClientKnownRequestError) { + if (exception.code === "P2002") { + response.status(HttpStatus.CONFLICT).json({ + statusCode: HttpStatus.CONFLICT, + message: "Resource already exists", + data: null, + }); + return; + } + + if (exception.code === "P2025") { + response.status(HttpStatus.NOT_FOUND).json({ + statusCode: HttpStatus.NOT_FOUND, + message: "Resource not found", + data: null, + }); + return; + } + + this.logger.error( + `Unhandled Prisma error: ${exception.code}`, + exception.stack, + ); + response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + message: "Database error", + data: null, + }); + return; + } + + this.logger.error( + "Unhandled exception", + exception instanceof Error ? exception.stack : String(exception), + ); + + response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + message: "Internal server error", + data: null, + }); + } +} diff --git a/src/common/prisma.ts b/src/common/prisma.ts new file mode 100644 index 0000000..4cfdd41 --- /dev/null +++ b/src/common/prisma.ts @@ -0,0 +1 @@ +export { Prisma, PrismaClient } from "../generated/prisma/client.js"; diff --git a/src/lib/auth/auth.config.ts b/src/lib/auth/auth.config.ts index 36d085b..ee291d6 100644 --- a/src/lib/auth/auth.config.ts +++ b/src/lib/auth/auth.config.ts @@ -1,5 +1,6 @@ import { prismaAdapter } from "@better-auth/prisma-adapter"; import { betterAuth } from "better-auth"; +import { bearer } from "better-auth/plugins/bearer"; import type { PrismaClient } from "../../generated/prisma/client.js"; export function createAuth(prisma: PrismaClient) { @@ -20,5 +21,6 @@ export function createAuth(prisma: PrismaClient) { }, }, }, + plugins: [bearer()], }); } diff --git a/src/main.ts b/src/main.ts index a0c11fc..2b28b36 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,9 @@ import { BadRequestException, ValidationPipe } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { NestFactory, Reflector } from "@nestjs/core"; +import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; import { AppModule } from "./app.module.js"; +import { AllExceptionsFilter } from "./common/filters/all-exceptions.filter.js"; import { TransformInterceptor } from "./common/interceptors/transform.interceptor.js"; async function bootstrap() { @@ -9,6 +11,19 @@ async function bootstrap() { const configService = app.get(ConfigService); + const swaggerConfig = new DocumentBuilder() + .setTitle("Taskforge API") + .setDescription( + "Project and task management backend with role-based access control. Admins manage projects/tasks/users; members work on assigned tasks and comment.", + ) + .setVersion("1.0") + .addBearerAuth() + .build(); + + const document = SwaggerModule.createDocument(app, swaggerConfig); + SwaggerModule.setup("docs", app, document); + + app.useGlobalFilters(new AllExceptionsFilter()); app.useGlobalInterceptors(new TransformInterceptor(new Reflector())); app.useGlobalPipes( new ValidationPipe({ @@ -21,6 +36,7 @@ async function bootstrap() { ? Object.values(error.constraints)[0] : "Invalid value", })); + return new BadRequestException({ message: "Validation failed", errors: result, diff --git a/src/module/comment/comment.controller.spec.ts b/src/module/comment/comment.controller.spec.ts new file mode 100644 index 0000000..1024ebf --- /dev/null +++ b/src/module/comment/comment.controller.spec.ts @@ -0,0 +1,179 @@ +import { jest } from "@jest/globals"; +import { UnauthorizedException } from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import type { Request } from "express"; +import { CommentController } from "./comment.controller.js"; +import { CommentService } from "./comment.service.js"; + +const mockComment = { + id: "comment-1", + content: "Test comment", + taskId: "task-1", + authorId: "user-1", + createdAt: new Date(), + updatedAt: new Date(), + author: { id: "user-1", name: "Test User", email: "test@example.com" }, +}; + +describe("CommentController", () => { + let controller: CommentController; + let commentService: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [CommentController], + providers: [ + { + provide: CommentService, + useValue: { + findByTask: jest.fn(), + findByIdScoped: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + }, + ], + }).compile(); + + controller = module.get(CommentController); + commentService = module.get(CommentService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("findAll", () => { + it("returns comments for the task", async () => { + const req = { + user: { id: "user-1", email: "test@example.com", role: "MEMBER" }, + } as unknown as Request; + commentService.findByTask.mockResolvedValue([mockComment]); + + const result = await controller.findAll("task-1", req); + + expect(result).toEqual([mockComment]); + expect(commentService.findByTask).toHaveBeenCalledWith( + "task-1", + "user-1", + "MEMBER", + ); + }); + + it("throws UnauthorizedException when no user on request", () => { + const req = { user: undefined } as unknown as Request; + + expect(() => controller.findAll("task-1", req)).toThrow( + UnauthorizedException, + ); + }); + }); + + describe("findById", () => { + it("returns a single comment", async () => { + const req = { + user: { id: "user-1", email: "test@example.com", role: "MEMBER" }, + } as unknown as Request; + commentService.findByIdScoped.mockResolvedValue(mockComment); + + const result = await controller.findById("task-1", "comment-1", req); + + expect(result).toEqual(mockComment); + expect(commentService.findByIdScoped).toHaveBeenCalledWith( + "comment-1", + "user-1", + "MEMBER", + ); + }); + + it("throws UnauthorizedException when no user on request", () => { + const req = { user: undefined } as unknown as Request; + + expect(() => controller.findById("task-1", "comment-1", req)).toThrow( + UnauthorizedException, + ); + }); + }); + + describe("create", () => { + const dto = { content: "New comment" }; + + it("returns the created comment", async () => { + const req = { + user: { id: "user-1", email: "test@example.com", role: "MEMBER" }, + } as unknown as Request; + commentService.create.mockResolvedValue(mockComment); + + const result = await controller.create("task-1", dto, req); + + expect(result).toEqual(mockComment); + expect(commentService.create).toHaveBeenCalledWith( + dto, + "task-1", + "user-1", + "MEMBER", + ); + }); + + it("throws UnauthorizedException when no user on request", () => { + const req = { user: undefined } as unknown as Request; + + expect(() => controller.create("task-1", dto, req)).toThrow( + UnauthorizedException, + ); + }); + }); + + describe("update", () => { + const dto = { content: "Updated" }; + + it("returns the updated comment", async () => { + const updated = { ...mockComment, content: "Updated" }; + const req = { + user: { id: "user-1", email: "test@example.com", role: "MEMBER" }, + } as unknown as Request; + commentService.update.mockResolvedValue(updated); + + const result = await controller.update("comment-1", dto, req); + + expect(result).toEqual(updated); + expect(commentService.update).toHaveBeenCalledWith( + "comment-1", + dto, + "user-1", + ); + }); + + it("throws UnauthorizedException when no user on request", () => { + const req = { user: undefined } as unknown as Request; + + expect(() => controller.update("comment-1", dto, req)).toThrow( + UnauthorizedException, + ); + }); + }); + + describe("delete", () => { + it("deletes the comment", async () => { + const req = { + user: { id: "user-1", email: "test@example.com", role: "MEMBER" }, + } as unknown as Request; + await controller.delete("comment-1", req); + + expect(commentService.delete).toHaveBeenCalledWith( + "comment-1", + "user-1", + "MEMBER", + ); + }); + + it("throws UnauthorizedException when no user on request", () => { + const req = { user: undefined } as unknown as Request; + + expect(() => controller.delete("comment-1", req)).toThrow( + UnauthorizedException, + ); + }); + }); +}); diff --git a/src/module/comment/comment.controller.ts b/src/module/comment/comment.controller.ts new file mode 100644 index 0000000..050f9c7 --- /dev/null +++ b/src/module/comment/comment.controller.ts @@ -0,0 +1,99 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Req, + UnauthorizedException, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import type { Request } from "express"; +import { ResponseMessage } from "../../common/decorators/response-message.decorator.js"; +import { CommentService } from "./comment.service.js"; +import { CreateCommentDto } from "./dto/create-comment.dto.js"; +import { UpdateCommentDto } from "./dto/update-comment.dto.js"; + +@ApiTags("Comments") +@ApiBearerAuth() +@Controller("projects/:projectId/tasks/:taskId/comments") +export class CommentController { + constructor(private readonly commentService: CommentService) {} + + @Get() + @ApiOperation({ summary: "List comments for a task" }) + findAll(@Param("taskId") taskId: string, @Req() req: Request) { + const user = req.user; + + if (!user) { + throw new UnauthorizedException(); + } + + return this.commentService.findByTask(taskId, user.id, user.role); + } + + @Get(":id") + @ApiOperation({ summary: "Get comment by ID" }) + findById( + @Param("taskId") _taskId: string, + @Param("id") id: string, + @Req() req: Request, + ) { + const user = req.user; + + if (!user) { + throw new UnauthorizedException(); + } + + return this.commentService.findByIdScoped(id, user.id, user.role); + } + + @Post() + @ApiOperation({ summary: "Create a comment" }) + @ResponseMessage("Comment created successfully") + create( + @Param("taskId") taskId: string, + @Body() dto: CreateCommentDto, + @Req() req: Request, + ) { + const user = req.user; + + if (!user) { + throw new UnauthorizedException(); + } + + return this.commentService.create(dto, taskId, user.id, user.role); + } + + @Patch(":id") + @ApiOperation({ summary: "Update own comment" }) + @ResponseMessage("Comment updated successfully") + update( + @Param("id") id: string, + @Body() dto: UpdateCommentDto, + @Req() req: Request, + ) { + const user = req.user; + + if (!user) { + throw new UnauthorizedException(); + } + + return this.commentService.update(id, dto, user.id); + } + + @Delete(":id") + @ApiOperation({ summary: "Delete a comment (author or admin)" }) + @ResponseMessage("Comment deleted successfully") + delete(@Param("id") id: string, @Req() req: Request) { + const user = req.user; + + if (!user) { + throw new UnauthorizedException(); + } + + return this.commentService.delete(id, user.id, user.role); + } +} diff --git a/src/module/comment/comment.module.ts b/src/module/comment/comment.module.ts new file mode 100644 index 0000000..b33572d --- /dev/null +++ b/src/module/comment/comment.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { TaskModule } from "../task/task.module.js"; +import { CommentController } from "./comment.controller.js"; +import { CommentService } from "./comment.service.js"; + +@Module({ + imports: [TaskModule], + controllers: [CommentController], + providers: [CommentService], +}) +export class CommentModule {} diff --git a/src/module/comment/comment.service.spec.ts b/src/module/comment/comment.service.spec.ts new file mode 100644 index 0000000..cbc18d9 --- /dev/null +++ b/src/module/comment/comment.service.spec.ts @@ -0,0 +1,242 @@ +import { jest } from "@jest/globals"; +import { ForbiddenException, NotFoundException } from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import type { PrismaClient } from "../../generated/prisma/client.js"; +import { PrismaService } from "../../lib/database/prisma.service.js"; +import { TaskService } from "../task/task.service.js"; +import { CommentService } from "./comment.service.js"; + +const mockComment = { + id: "comment-1", + content: "Test comment", + taskId: "task-1", + authorId: "user-1", + createdAt: new Date(), + updatedAt: new Date(), + author: { id: "user-1", name: "Test User", email: "test@example.com" }, +}; + +const mockPrisma = { + comment: { + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + } as unknown as jest.Mocked, +}; + +const mockAccessibleTask = { + id: "task-1", + title: "Test Task", + description: "A test task", + status: "TODO" as const, + priority: "LOW" as const, + projectId: "proj-1", + assigneeId: "user-1", + createdAt: new Date(), + updatedAt: new Date(), + project: { id: "proj-1", name: "Test Project" }, + assignee: { id: "user-1", name: "Test User", email: "test@example.com" }, +}; + +describe("CommentService", () => { + let service: CommentService; + let taskService: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CommentService, + { provide: PrismaService, useValue: mockPrisma }, + { + provide: TaskService, + useValue: { + canAccessTask: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(CommentService); + taskService = module.get(TaskService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("findByTask", () => { + it("returns all comments for a task the user can access", async () => { + taskService.canAccessTask.mockResolvedValue(mockAccessibleTask); + mockPrisma.comment.findMany.mockResolvedValue([mockComment]); + + const result = await service.findByTask("task-1", "user-1", "MEMBER"); + + expect(result).toEqual([mockComment]); + expect(taskService.canAccessTask).toHaveBeenCalledWith( + "task-1", + "user-1", + "MEMBER", + ); + expect(mockPrisma.comment.findMany).toHaveBeenCalledWith({ + where: { taskId: "task-1" }, + include: { + author: { select: { id: true, name: true, email: true } }, + }, + orderBy: { createdAt: "asc" }, + }); + }); + }); + + describe("findById", () => { + it("returns the comment when found", async () => { + mockPrisma.comment.findUnique.mockResolvedValue(mockComment); + + const result = await service.findById("comment-1"); + + expect(result).toEqual(mockComment); + expect(mockPrisma.comment.findUnique).toHaveBeenCalledWith({ + where: { id: "comment-1" }, + include: { + author: { select: { id: true, name: true, email: true } }, + }, + }); + }); + + it("throws NotFoundException when not found", async () => { + mockPrisma.comment.findUnique.mockResolvedValue(null); + + await expect(service.findById("nonexistent")).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe("findByIdScoped", () => { + it("returns the comment when user can access the task", async () => { + mockPrisma.comment.findUnique.mockResolvedValue(mockComment); + taskService.canAccessTask.mockResolvedValue(mockAccessibleTask); + + const result = await service.findByIdScoped( + "comment-1", + "user-1", + "MEMBER", + ); + + expect(result).toEqual(mockComment); + expect(taskService.canAccessTask).toHaveBeenCalledWith( + "task-1", + "user-1", + "MEMBER", + ); + }); + + it("throws NotFoundException when comment not found", async () => { + mockPrisma.comment.findUnique.mockResolvedValue(null); + + await expect( + service.findByIdScoped("nonexistent", "user-1", "MEMBER"), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe("create", () => { + const dto = { content: "New comment" }; + + it("creates a comment", async () => { + taskService.canAccessTask.mockResolvedValue(mockAccessibleTask); + mockPrisma.comment.create.mockResolvedValue(mockComment); + + const result = await service.create(dto, "task-1", "user-1", "MEMBER"); + + expect(result).toEqual(mockComment); + expect(taskService.canAccessTask).toHaveBeenCalledWith( + "task-1", + "user-1", + "MEMBER", + ); + expect(mockPrisma.comment.create).toHaveBeenCalledWith({ + data: { content: "New comment", taskId: "task-1", authorId: "user-1" }, + include: { + author: { select: { id: true, name: true, email: true } }, + }, + }); + }); + }); + + describe("update", () => { + const dto = { content: "Updated comment" }; + + it("updates the comment when user is the author", async () => { + const updated = { ...mockComment, content: "Updated comment" }; + mockPrisma.comment.findUnique.mockResolvedValue(mockComment); + mockPrisma.comment.update.mockResolvedValue(updated); + + const result = await service.update("comment-1", dto, "user-1"); + + expect(result).toEqual(updated); + expect(mockPrisma.comment.update).toHaveBeenCalledWith({ + where: { id: "comment-1" }, + data: { content: "Updated comment" }, + include: { + author: { select: { id: true, name: true, email: true } }, + }, + }); + }); + + it("throws ForbiddenException when user is not the author", async () => { + mockPrisma.comment.findUnique.mockResolvedValue(mockComment); + + await expect( + service.update("comment-1", dto, "other-user"), + ).rejects.toThrow(ForbiddenException); + }); + + it("throws NotFoundException when comment does not exist", async () => { + mockPrisma.comment.findUnique.mockResolvedValue(null); + + await expect( + service.update("nonexistent", dto, "user-1"), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe("delete", () => { + it("deletes the comment when user is the author", async () => { + mockPrisma.comment.findUnique.mockResolvedValue(mockComment); + + await service.delete("comment-1", "user-1"); + + expect(mockPrisma.comment.delete).toHaveBeenCalledWith({ + where: { id: "comment-1" }, + }); + }); + + it("allows admin to delete any comment", async () => { + mockPrisma.comment.findUnique.mockResolvedValue(mockComment); + + await service.delete("comment-1", "admin-id", "ADMIN"); + + expect(mockPrisma.comment.delete).toHaveBeenCalledWith({ + where: { id: "comment-1" }, + }); + }); + + it("throws ForbiddenException when user is not the author nor admin", async () => { + mockPrisma.comment.findUnique.mockResolvedValue(mockComment); + + await expect( + service.delete("comment-1", "other-user", "MEMBER"), + ).rejects.toThrow(ForbiddenException); + }); + + it("throws NotFoundException when comment does not exist", async () => { + mockPrisma.comment.findUnique.mockResolvedValue(null); + + await expect(service.delete("nonexistent", "user-1")).rejects.toThrow( + NotFoundException, + ); + }); + }); +}); diff --git a/src/module/comment/comment.service.ts b/src/module/comment/comment.service.ts new file mode 100644 index 0000000..6b157cd --- /dev/null +++ b/src/module/comment/comment.service.ts @@ -0,0 +1,91 @@ +import { + ForbiddenException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { PrismaService } from "../../lib/database/prisma.service.js"; +import { TaskService } from "../task/task.service.js"; +import { CreateCommentDto } from "./dto/create-comment.dto.js"; +import { UpdateCommentDto } from "./dto/update-comment.dto.js"; + +@Injectable() +export class CommentService { + constructor( + private readonly prisma: PrismaService, + private readonly taskService: TaskService, + ) {} + + private include = { + author: { select: { id: true, name: true, email: true } }, + } as const; + + async findByTask(taskId: string, userId?: string, role?: string) { + await this.taskService.canAccessTask(taskId, userId, role); + + return this.prisma.comment.findMany({ + where: { taskId }, + include: this.include, + orderBy: { createdAt: "asc" }, + }); + } + + async findById(id: string) { + const comment = await this.prisma.comment.findUnique({ + where: { id }, + include: this.include, + }); + + if (!comment) { + throw new NotFoundException(`Comment with id "${id}" not found`); + } + + return comment; + } + + async findByIdScoped(id: string, userId?: string, role?: string) { + const comment = await this.findById(id); + await this.taskService.canAccessTask(comment.taskId, userId, role); + return comment; + } + + async create( + dto: CreateCommentDto, + taskId: string, + authorId: string, + role?: string, + ) { + await this.taskService.canAccessTask(taskId, authorId, role); + return this.prisma.comment.create({ + data: { + content: dto.content, + taskId, + authorId, + }, + include: this.include, + }); + } + + async update(id: string, dto: UpdateCommentDto, userId: string) { + const comment = await this.findById(id); + + if (comment.authorId !== userId) { + throw new ForbiddenException("You can only edit your own comments"); + } + + return this.prisma.comment.update({ + where: { id }, + data: { content: dto.content }, + include: this.include, + }); + } + + async delete(id: string, userId: string, role?: string) { + const comment = await this.findById(id); + + if (comment.authorId !== userId && role !== "ADMIN") { + throw new ForbiddenException("You can only delete your own comments"); + } + + await this.prisma.comment.delete({ where: { id } }); + } +} diff --git a/src/module/comment/dto/create-comment.dto.ts b/src/module/comment/dto/create-comment.dto.ts new file mode 100644 index 0000000..baa7827 --- /dev/null +++ b/src/module/comment/dto/create-comment.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsString } from "class-validator"; + +export class CreateCommentDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + content: string; +} diff --git a/src/module/comment/dto/update-comment.dto.ts b/src/module/comment/dto/update-comment.dto.ts new file mode 100644 index 0000000..923b112 --- /dev/null +++ b/src/module/comment/dto/update-comment.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsString } from "class-validator"; + +export class UpdateCommentDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + content: string; +} diff --git a/src/module/project/dto/create-project.dto.ts b/src/module/project/dto/create-project.dto.ts new file mode 100644 index 0000000..8ea9cc8 --- /dev/null +++ b/src/module/project/dto/create-project.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsString } from "class-validator"; + +export class CreateProjectDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + name: string; +} diff --git a/src/module/project/dto/update-project.dto.ts b/src/module/project/dto/update-project.dto.ts new file mode 100644 index 0000000..3b2343b --- /dev/null +++ b/src/module/project/dto/update-project.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from "@nestjs/swagger"; +import { CreateProjectDto } from "./create-project.dto.js"; + +export class UpdateProjectDto extends PartialType(CreateProjectDto) {} diff --git a/src/module/project/project.controller.spec.ts b/src/module/project/project.controller.spec.ts new file mode 100644 index 0000000..054e21a --- /dev/null +++ b/src/module/project/project.controller.spec.ts @@ -0,0 +1,96 @@ +import { jest } from "@jest/globals"; +import { Test, TestingModule } from "@nestjs/testing"; +import type { Request } from "express"; +import { ProjectController } from "./project.controller.js"; +import { ProjectService } from "./project.service.js"; + +const mockProject = { + id: "proj-1", + name: "Test Project", + userId: "user-1", + createdAt: new Date(), + updatedAt: new Date(), + user: { id: "user-1", name: "Test User", email: "test@example.com" }, +}; + +describe("ProjectController", () => { + let controller: ProjectController; + let service: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ProjectController], + providers: [ + { + provide: ProjectService, + useValue: { + findAll: jest.fn(), + findById: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + }, + ], + }).compile(); + + controller = module.get(ProjectController); + service = module.get(ProjectService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("findAll", () => { + it("calls service.findAll", async () => { + service.findAll.mockResolvedValue([mockProject]); + const result = await controller.findAll(); + expect(result).toEqual([mockProject]); + expect(service.findAll).toHaveBeenCalled(); + }); + }); + + describe("findById", () => { + it("calls service.findById with the id", async () => { + service.findById.mockResolvedValue(mockProject); + const result = await controller.findById("proj-1"); + expect(result).toEqual(mockProject); + expect(service.findById).toHaveBeenCalledWith("proj-1"); + }); + }); + + describe("create", () => { + it("calls service.create with dto and req.user.id", async () => { + const dto = { name: "New Project" }; + const req = { + user: { id: "user-1", email: "test@example.com", role: "MEMBER" }, + } as unknown as Request; + + service.create.mockResolvedValue({ ...mockProject, name: "New Project" }); + const result = await controller.create(dto, req); + + expect(result).toEqual({ ...mockProject, name: "New Project" }); + expect(service.create).toHaveBeenCalledWith(dto, "user-1"); + }); + }); + + describe("update", () => { + it("calls service.update with id and dto", async () => { + const dto = { name: "Updated" }; + service.update.mockResolvedValue({ ...mockProject, name: "Updated" }); + const result = await controller.update("proj-1", dto); + expect(result).toEqual({ ...mockProject, name: "Updated" }); + expect(service.update).toHaveBeenCalledWith("proj-1", dto); + }); + }); + + describe("delete", () => { + it("calls service.delete with the id", async () => { + service.delete.mockResolvedValue(undefined); + const result = await controller.delete("proj-1"); + expect(result).toBeUndefined(); + expect(service.delete).toHaveBeenCalledWith("proj-1"); + }); + }); +}); diff --git a/src/module/project/project.controller.ts b/src/module/project/project.controller.ts new file mode 100644 index 0000000..5876198 --- /dev/null +++ b/src/module/project/project.controller.ts @@ -0,0 +1,72 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Req, + UnauthorizedException, + UseGuards, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import type { Request } from "express"; +import { ResponseMessage } from "../../common/decorators/response-message.decorator.js"; +import { Roles } from "../../common/decorators/roles.decorator.js"; +import { RolesGuard } from "../../common/guards/roles.guard.js"; +import { CreateProjectDto } from "./dto/create-project.dto.js"; +import { UpdateProjectDto } from "./dto/update-project.dto.js"; +import { ProjectService } from "./project.service.js"; + +@ApiTags("Projects") +@ApiBearerAuth() +@Controller("projects") +export class ProjectController { + constructor(private readonly projectService: ProjectService) {} + + @Get() + @ApiOperation({ summary: "List all projects" }) + findAll() { + return this.projectService.findAll(); + } + + @Get(":id") + @ApiOperation({ summary: "Get project by ID" }) + findById(@Param("id") id: string) { + return this.projectService.findById(id); + } + + @Post() + @UseGuards(RolesGuard) + @Roles("ADMIN") + @ApiOperation({ summary: "Create a project (admin only)" }) + @ResponseMessage("Project created successfully") + create(@Body() dto: CreateProjectDto, @Req() req: Request) { + const user = req.user; + + if (!user) { + throw new UnauthorizedException(); + } + + return this.projectService.create(dto, user.id); + } + + @Patch(":id") + @UseGuards(RolesGuard) + @Roles("ADMIN") + @ApiOperation({ summary: "Update a project (admin only)" }) + @ResponseMessage("Project updated successfully") + update(@Param("id") id: string, @Body() dto: UpdateProjectDto) { + return this.projectService.update(id, dto); + } + + @Delete(":id") + @UseGuards(RolesGuard) + @Roles("ADMIN") + @ApiOperation({ summary: "Delete a project (admin only)" }) + @ResponseMessage("Project deleted successfully") + delete(@Param("id") id: string) { + return this.projectService.delete(id); + } +} diff --git a/src/module/project/project.module.ts b/src/module/project/project.module.ts new file mode 100644 index 0000000..ea0a3fa --- /dev/null +++ b/src/module/project/project.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; +import { ProjectController } from "./project.controller.js"; +import { ProjectService } from "./project.service.js"; + +@Module({ + controllers: [ProjectController], + providers: [ProjectService], +}) +export class ProjectModule {} diff --git a/src/module/project/project.service.spec.ts b/src/module/project/project.service.spec.ts new file mode 100644 index 0000000..65cf74f --- /dev/null +++ b/src/module/project/project.service.spec.ts @@ -0,0 +1,142 @@ +import { jest } from "@jest/globals"; +import { NotFoundException } from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import type { PrismaClient } from "../../generated/prisma/client.js"; +import { PrismaService } from "../../lib/database/prisma.service.js"; +import { ProjectService } from "./project.service.js"; + +const mockProject = { + id: "proj-1", + name: "Test Project", + userId: "user-1", + createdAt: new Date(), + updatedAt: new Date(), + user: { id: "user-1", name: "Test User", email: "test@example.com" }, +}; + +const mockPrisma = { + project: { + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + } as unknown as jest.Mocked, +}; + +describe("ProjectService", () => { + let service: ProjectService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ProjectService, + { provide: PrismaService, useValue: mockPrisma }, + ], + }).compile(); + + service = module.get(ProjectService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("findAll", () => { + it("returns all projects with user info", async () => { + mockPrisma.project.findMany.mockResolvedValue([mockProject]); + + const result = await service.findAll(); + + expect(result).toEqual([mockProject]); + expect(mockPrisma.project.findMany).toHaveBeenCalledWith({ + include: { user: { select: { id: true, name: true, email: true } } }, + }); + }); + }); + + describe("findById", () => { + it("returns the project when found", async () => { + mockPrisma.project.findUnique.mockResolvedValue(mockProject); + + const result = await service.findById("proj-1"); + + expect(result).toEqual(mockProject); + expect(mockPrisma.project.findUnique).toHaveBeenCalledWith({ + where: { id: "proj-1" }, + include: { user: { select: { id: true, name: true, email: true } } }, + }); + }); + + it("throws NotFoundException when not found", async () => { + mockPrisma.project.findUnique.mockResolvedValue(null); + + await expect(service.findById("nonexistent")).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe("create", () => { + it("creates a project with the given dto and userId", async () => { + const dto = { name: "New Project" }; + const created = { ...mockProject, name: "New Project" }; + mockPrisma.project.create.mockResolvedValue(created); + + const result = await service.create(dto, "user-1"); + + expect(result).toEqual(created); + expect(mockPrisma.project.create).toHaveBeenCalledWith({ + data: { name: "New Project", userId: "user-1" }, + include: { user: { select: { id: true, name: true, email: true } } }, + }); + }); + }); + + describe("update", () => { + it("updates the project when found", async () => { + const dto = { name: "Updated Project" }; + const updated = { ...mockProject, name: "Updated Project" }; + mockPrisma.project.findUnique.mockResolvedValue(mockProject); + mockPrisma.project.update.mockResolvedValue(updated); + + const result = await service.update("proj-1", dto); + + expect(result).toEqual(updated); + expect(mockPrisma.project.update).toHaveBeenCalledWith({ + where: { id: "proj-1" }, + data: { name: "Updated Project" }, + include: { user: { select: { id: true, name: true, email: true } } }, + }); + }); + + it("throws NotFoundException when project does not exist", async () => { + mockPrisma.project.findUnique.mockResolvedValue(null); + + await expect( + service.update("nonexistent", { name: "Nope" }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe("delete", () => { + it("deletes the project when found", async () => { + mockPrisma.project.findUnique.mockResolvedValue(mockProject); + mockPrisma.project.delete.mockResolvedValue(mockProject); + + await service.delete("proj-1"); + + expect(mockPrisma.project.delete).toHaveBeenCalledWith({ + where: { id: "proj-1" }, + }); + }); + + it("throws NotFoundException when project does not exist", async () => { + mockPrisma.project.findUnique.mockResolvedValue(null); + + await expect(service.delete("nonexistent")).rejects.toThrow( + NotFoundException, + ); + }); + }); +}); diff --git a/src/module/project/project.service.ts b/src/module/project/project.service.ts new file mode 100644 index 0000000..53065c8 --- /dev/null +++ b/src/module/project/project.service.ts @@ -0,0 +1,54 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { PrismaService } from "../../lib/database/prisma.service.js"; +import { CreateProjectDto } from "./dto/create-project.dto.js"; +import { UpdateProjectDto } from "./dto/update-project.dto.js"; + +@Injectable() +export class ProjectService { + constructor(private readonly prisma: PrismaService) {} + + async findAll() { + return this.prisma.project.findMany({ + include: { user: { select: { id: true, name: true, email: true } } }, + }); + } + + async findById(id: string) { + const project = await this.prisma.project.findUnique({ + where: { id }, + include: { user: { select: { id: true, name: true, email: true } } }, + }); + + if (!project) { + throw new NotFoundException(`Project with id "${id}" not found`); + } + + return project; + } + + async create(dto: CreateProjectDto, userId: string) { + return this.prisma.project.create({ + data: { + name: dto.name, + userId, + }, + include: { user: { select: { id: true, name: true, email: true } } }, + }); + } + + async update(id: string, dto: UpdateProjectDto) { + await this.findById(id); + + return this.prisma.project.update({ + where: { id }, + data: { name: dto.name }, + include: { user: { select: { id: true, name: true, email: true } } }, + }); + } + + async delete(id: string) { + await this.findById(id); + + await this.prisma.project.delete({ where: { id } }); + } +} diff --git a/src/module/task/dto/create-task.dto.ts b/src/module/task/dto/create-task.dto.ts new file mode 100644 index 0000000..eb70257 --- /dev/null +++ b/src/module/task/dto/create-task.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsEnum, IsNotEmpty, IsOptional, IsString } from "class-validator"; +import { TaskPriority, TaskStatus } from "../../../generated/prisma/client.js"; + +export class CreateTaskDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + title: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + description?: string; + + @ApiPropertyOptional({ enum: TaskStatus }) + @IsEnum(TaskStatus) + @IsOptional() + status?: "TODO" | "PENDING" | "DONE"; + + @ApiPropertyOptional({ enum: TaskPriority }) + @IsEnum(TaskPriority) + @IsOptional() + priority?: "LOW" | "MEDIUM" | "HIGH"; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + assigneeId?: string; +} diff --git a/src/module/task/dto/update-task.dto.ts b/src/module/task/dto/update-task.dto.ts new file mode 100644 index 0000000..5e38d95 --- /dev/null +++ b/src/module/task/dto/update-task.dto.ts @@ -0,0 +1,10 @@ +import { ApiPropertyOptional, PartialType } from "@nestjs/swagger"; +import { IsOptional, IsString } from "class-validator"; +import { CreateTaskDto } from "./create-task.dto.js"; + +export class UpdateTaskDto extends PartialType(CreateTaskDto) { + @ApiPropertyOptional() + @IsString() + @IsOptional() + projectId?: string; +} diff --git a/src/module/task/task.controller.spec.ts b/src/module/task/task.controller.spec.ts new file mode 100644 index 0000000..13b939f --- /dev/null +++ b/src/module/task/task.controller.spec.ts @@ -0,0 +1,166 @@ +import { jest } from "@jest/globals"; +import { UnauthorizedException } from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import type { Request } from "express"; +import { TaskController } from "./task.controller.js"; +import { TaskService } from "./task.service.js"; + +const mockTask = { + id: "task-1", + title: "Test Task", + description: "A test task", + status: "TODO" as const, + priority: "LOW" as const, + projectId: "proj-1", + assigneeId: "user-1", + createdAt: new Date(), + updatedAt: new Date(), + project: { id: "proj-1", name: "Test Project" }, + assignee: { id: "user-1", name: "Test User", email: "test@example.com" }, +}; + +describe("TaskController", () => { + let controller: TaskController; + let taskService: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [TaskController], + providers: [ + { + provide: TaskService, + useValue: { + findByProject: jest.fn(), + findById: jest.fn(), + findByIdScoped: jest.fn(), + create: jest.fn(), + update: jest.fn(), + assign: jest.fn(), + unassign: jest.fn(), + delete: jest.fn(), + }, + }, + ], + }).compile(); + + controller = module.get(TaskController); + taskService = module.get(TaskService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("findAll", () => { + it("returns tasks for the user", async () => { + const req = { + user: { id: "user-1", email: "test@example.com", role: "MEMBER" }, + } as unknown as Request; + taskService.findByProject.mockResolvedValue([mockTask]); + + const result = await controller.findAll("proj-1", req); + + expect(result).toEqual([mockTask]); + expect(taskService.findByProject).toHaveBeenCalledWith( + "proj-1", + "user-1", + "MEMBER", + ); + }); + + it("throws UnauthorizedException when no user on request", () => { + const req = { user: undefined } as unknown as Request; + + expect(() => controller.findAll("proj-1", req)).toThrow( + UnauthorizedException, + ); + }); + }); + + describe("findById", () => { + it("returns a single task for authorized user", async () => { + const req = { + user: { id: "user-1", email: "test@example.com", role: "MEMBER" }, + } as unknown as Request; + taskService.findByIdScoped.mockResolvedValue(mockTask); + + const result = await controller.findById("task-1", req); + + expect(result).toEqual(mockTask); + expect(taskService.findByIdScoped).toHaveBeenCalledWith( + "task-1", + "user-1", + "MEMBER", + ); + }); + + it("throws UnauthorizedException when no user on request", () => { + const req = { user: undefined } as unknown as Request; + + expect(() => controller.findById("task-1", req)).toThrow( + UnauthorizedException, + ); + }); + }); + + describe("create", () => { + const dto = { + title: "New Task", + description: "Description", + }; + + it("returns the created task", async () => { + taskService.create.mockResolvedValue(mockTask); + + const result = await controller.create("proj-1", dto); + + expect(result).toEqual(mockTask); + expect(taskService.create).toHaveBeenCalledWith(dto, "proj-1"); + }); + }); + + describe("update", () => { + const dto = { title: "Updated" }; + + it("returns the updated task", async () => { + const updated = { ...mockTask, title: "Updated" }; + taskService.update.mockResolvedValue(updated); + + const result = await controller.update("task-1", dto); + + expect(result).toEqual(updated); + expect(taskService.update).toHaveBeenCalledWith("task-1", dto); + }); + }); + + describe("assign", () => { + it("assigns user to task", async () => { + taskService.assign.mockResolvedValue(mockTask); + + const result = await controller.assign("task-1", "user-1"); + + expect(result).toEqual(mockTask); + expect(taskService.assign).toHaveBeenCalledWith("task-1", "user-1"); + }); + }); + + describe("unassign", () => { + it("unassigns the task", async () => { + const unassigned = { ...mockTask, assigneeId: null, assignee: null }; + taskService.unassign.mockResolvedValue(unassigned); + + const result = await controller.unassign("task-1"); + + expect(result).toEqual(unassigned); + expect(taskService.unassign).toHaveBeenCalledWith("task-1"); + }); + }); + + describe("delete", () => { + it("deletes the task", async () => { + await controller.delete("task-1"); + + expect(taskService.delete).toHaveBeenCalledWith("task-1"); + }); + }); +}); diff --git a/src/module/task/task.controller.ts b/src/module/task/task.controller.ts new file mode 100644 index 0000000..e6f83ce --- /dev/null +++ b/src/module/task/task.controller.ts @@ -0,0 +1,96 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Req, + UnauthorizedException, + UseGuards, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import type { Request } from "express"; +import { ResponseMessage } from "../../common/decorators/response-message.decorator.js"; +import { Roles } from "../../common/decorators/roles.decorator.js"; +import { RolesGuard } from "../../common/guards/roles.guard.js"; +import { CreateTaskDto } from "./dto/create-task.dto.js"; +import { UpdateTaskDto } from "./dto/update-task.dto.js"; +import { TaskService } from "./task.service.js"; + +@ApiTags("Tasks") +@ApiBearerAuth() +@Controller("projects/:projectId/tasks") +export class TaskController { + constructor(private readonly taskService: TaskService) {} + + @Get() + @ApiOperation({ summary: "List tasks for a project" }) + findAll(@Param("projectId") projectId: string, @Req() req: Request) { + const user = req.user; + + if (!user) { + throw new UnauthorizedException(); + } + + return this.taskService.findByProject(projectId, user.id, user.role); + } + + @Get(":id") + @ApiOperation({ summary: "Get task by ID" }) + findById(@Param("id") id: string, @Req() req: Request) { + const user = req.user; + + if (!user) { + throw new UnauthorizedException(); + } + + return this.taskService.findByIdScoped(id, user.id, user.role); + } + + @Post() + @UseGuards(RolesGuard) + @Roles("ADMIN") + @ApiOperation({ summary: "Create a task (admin only)" }) + @ResponseMessage("Task created successfully") + create(@Param("projectId") projectId: string, @Body() dto: CreateTaskDto) { + return this.taskService.create(dto, projectId); + } + + @Patch(":id") + @UseGuards(RolesGuard) + @Roles("ADMIN") + @ApiOperation({ summary: "Update a task (admin only)" }) + @ResponseMessage("Task updated successfully") + update(@Param("id") id: string, @Body() dto: UpdateTaskDto) { + return this.taskService.update(id, dto); + } + + @Post(":id/assign/:userId") + @UseGuards(RolesGuard) + @Roles("ADMIN") + @ApiOperation({ summary: "Assign a task to a user (admin only)" }) + @ResponseMessage("Task assigned successfully") + assign(@Param("id") id: string, @Param("userId") userId: string) { + return this.taskService.assign(id, userId); + } + + @Delete(":id/assign") + @UseGuards(RolesGuard) + @Roles("ADMIN") + @ApiOperation({ summary: "Unassign a task (admin only)" }) + @ResponseMessage("Task unassigned successfully") + unassign(@Param("id") id: string) { + return this.taskService.unassign(id); + } + + @Delete(":id") + @UseGuards(RolesGuard) + @Roles("ADMIN") + @ApiOperation({ summary: "Delete a task (admin only)" }) + @ResponseMessage("Task deleted successfully") + delete(@Param("id") id: string) { + return this.taskService.delete(id); + } +} diff --git a/src/module/task/task.module.ts b/src/module/task/task.module.ts new file mode 100644 index 0000000..2f9479c --- /dev/null +++ b/src/module/task/task.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { TaskController } from "./task.controller.js"; +import { TaskService } from "./task.service.js"; + +@Module({ + controllers: [TaskController], + providers: [TaskService], + exports: [TaskService], +}) +export class TaskModule {} diff --git a/src/module/task/task.service.spec.ts b/src/module/task/task.service.spec.ts new file mode 100644 index 0000000..8804caa --- /dev/null +++ b/src/module/task/task.service.spec.ts @@ -0,0 +1,421 @@ +import { jest } from "@jest/globals"; +import { + ConflictException, + ForbiddenException, + NotFoundException, +} from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import type { PrismaClient } from "../../generated/prisma/client.js"; +import { PrismaService } from "../../lib/database/prisma.service.js"; +import { TaskService } from "./task.service.js"; + +const mockTask = { + id: "task-1", + title: "Test Task", + description: "A test task", + status: "TODO" as const, + priority: "LOW" as const, + projectId: "proj-1", + assigneeId: "user-1", + createdAt: new Date(), + updatedAt: new Date(), + project: { id: "proj-1", name: "Test Project" }, + assignee: { id: "user-1", name: "Test User", email: "test@example.com" }, +}; + +const mockUnassignedTask = { + ...mockTask, + assigneeId: null, + assignee: null, +}; + +const mockProjectRef = { + id: "proj-1", + name: "Test Project", + userId: "user-1", + createdAt: new Date(), + updatedAt: new Date(), +}; + +const mockUser = { + id: "user-1", + name: "Test User", + email: "test@example.com", + role: "MEMBER" as const, + emailVerified: false, + image: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const mockPrisma = { + project: { + findUnique: jest.fn(), + } as unknown as jest.Mocked, + user: { + findUnique: jest.fn(), + } as unknown as jest.Mocked, + task: { + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + } as unknown as jest.Mocked, +}; + +describe("TaskService", () => { + let service: TaskService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TaskService, + { provide: PrismaService, useValue: mockPrisma }, + ], + }).compile(); + + service = module.get(TaskService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("findByProject", () => { + it("returns all tasks for admin", async () => { + mockPrisma.project.findUnique.mockResolvedValue(mockProjectRef); + mockPrisma.task.findMany.mockResolvedValue([mockTask]); + + const result = await service.findByProject("proj-1", "admin-id", "ADMIN"); + + expect(result).toEqual([mockTask]); + expect(mockPrisma.task.findMany).toHaveBeenCalledWith({ + where: { projectId: "proj-1" }, + include: { + project: { select: { id: true, name: true } }, + assignee: { select: { id: true, name: true, email: true } }, + }, + }); + }); + + it("returns only assigned tasks for member", async () => { + mockPrisma.project.findUnique.mockResolvedValue(mockProjectRef); + mockPrisma.task.findMany.mockResolvedValue([mockTask]); + + const result = await service.findByProject("proj-1", "user-1", "MEMBER"); + + expect(result).toEqual([mockTask]); + expect(mockPrisma.task.findMany).toHaveBeenCalledWith({ + where: { projectId: "proj-1", assigneeId: "user-1" }, + include: { + project: { select: { id: true, name: true } }, + assignee: { select: { id: true, name: true, email: true } }, + }, + }); + }); + + it("throws NotFoundException when project does not exist", async () => { + mockPrisma.project.findUnique.mockResolvedValue(null); + + await expect( + service.findByProject("nonexistent", "user-1", "MEMBER"), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe("findById", () => { + it("returns the task when found", async () => { + mockPrisma.task.findUnique.mockResolvedValue(mockTask); + + const result = await service.findById("task-1"); + + expect(result).toEqual(mockTask); + expect(mockPrisma.task.findUnique).toHaveBeenCalledWith({ + where: { id: "task-1" }, + include: { + project: { select: { id: true, name: true } }, + assignee: { select: { id: true, name: true, email: true } }, + }, + }); + }); + + it("throws NotFoundException when not found", async () => { + mockPrisma.task.findUnique.mockResolvedValue(null); + + await expect(service.findById("nonexistent")).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe("findByIdScoped", () => { + it("allows admin to view any task", async () => { + mockPrisma.task.findUnique.mockResolvedValue(mockTask); + + const result = await service.findByIdScoped( + "task-1", + "admin-id", + "ADMIN", + ); + + expect(result).toEqual(mockTask); + }); + + it("allows member to view their own task", async () => { + mockPrisma.task.findUnique.mockResolvedValue(mockTask); + + const result = await service.findByIdScoped("task-1", "user-1", "MEMBER"); + + expect(result).toEqual(mockTask); + }); + + it("throws ForbiddenException when member views another user's task", async () => { + mockPrisma.task.findUnique.mockResolvedValue(mockTask); + + await expect( + service.findByIdScoped("task-1", "other-user", "MEMBER"), + ).rejects.toThrow(ForbiddenException); + }); + + it("throws NotFoundException when task does not exist", async () => { + mockPrisma.task.findUnique.mockResolvedValue(null); + + await expect( + service.findByIdScoped("nonexistent", "user-1", "MEMBER"), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe("create", () => { + const dto = { + title: "New Task", + description: "Description", + assigneeId: "user-1", + }; + + it("creates a task under the given project", async () => { + mockPrisma.project.findUnique.mockResolvedValue(mockProjectRef); + mockPrisma.user.findUnique.mockResolvedValue(mockUser); + mockPrisma.task.create.mockResolvedValue(mockTask); + + const result = await service.create(dto, "proj-1"); + + expect(result).toEqual(mockTask); + expect(mockPrisma.task.create).toHaveBeenCalledWith({ + data: { + title: "New Task", + description: "Description", + status: undefined, + priority: undefined, + projectId: "proj-1", + assigneeId: "user-1", + }, + include: { + project: { select: { id: true, name: true } }, + assignee: { select: { id: true, name: true, email: true } }, + }, + }); + }); + + it("throws NotFoundException when project does not exist", async () => { + mockPrisma.project.findUnique.mockResolvedValue(null); + + await expect(service.create(dto, "nonexistent")).rejects.toThrow( + NotFoundException, + ); + }); + + it("throws NotFoundException when assignee does not exist", async () => { + mockPrisma.project.findUnique.mockResolvedValue(mockProjectRef); + mockPrisma.user.findUnique.mockResolvedValue(null); + + await expect(service.create(dto, "proj-1")).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe("update", () => { + const dto = { title: "Updated Task" }; + + it("updates the task when found", async () => { + const updated = { ...mockTask, title: "Updated Task" }; + mockPrisma.task.findUnique.mockResolvedValue(mockTask); + mockPrisma.task.update.mockResolvedValue(updated); + + const result = await service.update("task-1", dto); + + expect(result).toEqual(updated); + expect(mockPrisma.task.update).toHaveBeenCalledWith({ + where: { id: "task-1" }, + data: { + title: "Updated Task", + description: undefined, + status: undefined, + priority: undefined, + projectId: undefined, + assigneeId: undefined, + }, + include: { + project: { select: { id: true, name: true } }, + assignee: { select: { id: true, name: true, email: true } }, + }, + }); + }); + + it("throws NotFoundException when task does not exist", async () => { + mockPrisma.task.findUnique.mockResolvedValue(null); + + await expect(service.update("nonexistent", dto)).rejects.toThrow( + NotFoundException, + ); + }); + + it("validates project exists when projectId is provided", async () => { + mockPrisma.task.findUnique.mockResolvedValue(mockTask); + mockPrisma.project.findUnique.mockResolvedValue(null); + + await expect( + service.update("task-1", { projectId: "nonexistent" }), + ).rejects.toThrow(NotFoundException); + }); + + it("validates assignee exists when assigneeId is provided", async () => { + mockPrisma.task.findUnique.mockResolvedValue(mockTask); + mockPrisma.project.findUnique.mockResolvedValue(mockProjectRef); + mockPrisma.user.findUnique.mockResolvedValue(null); + + await expect( + service.update("task-1", { assigneeId: "nonexistent" }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe("assign", () => { + it("assigns a user to an unassigned task", async () => { + mockPrisma.task.findUnique.mockResolvedValue(mockUnassignedTask); + mockPrisma.user.findUnique.mockResolvedValue(mockUser); + mockPrisma.task.update.mockResolvedValue(mockTask); + + const result = await service.assign("task-1", "user-1"); + + expect(result).toEqual(mockTask); + expect(mockPrisma.task.update).toHaveBeenCalledWith({ + where: { id: "task-1" }, + data: { assigneeId: "user-1" }, + include: { + project: { select: { id: true, name: true } }, + assignee: { select: { id: true, name: true, email: true } }, + }, + }); + }); + + it("throws ConflictException when task already has an assignee", async () => { + mockPrisma.task.findUnique.mockResolvedValue(mockTask); + + await expect(service.assign("task-1", "user-2")).rejects.toThrow( + ConflictException, + ); + }); + + it("throws NotFoundException when task does not exist", async () => { + mockPrisma.task.findUnique.mockResolvedValue(null); + + await expect(service.assign("nonexistent", "user-1")).rejects.toThrow( + NotFoundException, + ); + }); + + it("throws NotFoundException when user does not exist", async () => { + mockPrisma.task.findUnique.mockResolvedValue(mockUnassignedTask); + mockPrisma.user.findUnique.mockResolvedValue(null); + + await expect(service.assign("task-1", "nonexistent")).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe("canAccessTask", () => { + it("allows admin to access any task", async () => { + mockPrisma.task.findUnique.mockResolvedValue(mockTask); + + const result = await service.canAccessTask("task-1", "admin-id", "ADMIN"); + + expect(result).toEqual(mockTask); + }); + + it("allows member to access their own task", async () => { + mockPrisma.task.findUnique.mockResolvedValue(mockTask); + + const result = await service.canAccessTask("task-1", "user-1", "MEMBER"); + + expect(result).toEqual(mockTask); + }); + + it("throws ForbiddenException when member accesses another user's task", async () => { + mockPrisma.task.findUnique.mockResolvedValue(mockTask); + + await expect( + service.canAccessTask("task-1", "other-user", "MEMBER"), + ).rejects.toThrow(ForbiddenException); + }); + + it("throws NotFoundException when task does not exist", async () => { + mockPrisma.task.findUnique.mockResolvedValue(null); + + await expect( + service.canAccessTask("nonexistent", "user-1", "MEMBER"), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe("unassign", () => { + it("removes the assignee from the task", async () => { + const unassigned = { ...mockTask, assigneeId: null, assignee: null }; + mockPrisma.task.findUnique.mockResolvedValue(mockTask); + mockPrisma.task.update.mockResolvedValue(unassigned); + + const result = await service.unassign("task-1"); + + expect(result).toEqual(unassigned); + expect(mockPrisma.task.update).toHaveBeenCalledWith({ + where: { id: "task-1" }, + data: { assigneeId: null }, + include: { + project: { select: { id: true, name: true } }, + assignee: { select: { id: true, name: true, email: true } }, + }, + }); + }); + + it("throws NotFoundException when task does not exist", async () => { + mockPrisma.task.findUnique.mockResolvedValue(null); + + await expect(service.unassign("nonexistent")).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe("delete", () => { + it("deletes the task when found", async () => { + mockPrisma.task.findUnique.mockResolvedValue(mockTask); + + await service.delete("task-1"); + + expect(mockPrisma.task.delete).toHaveBeenCalledWith({ + where: { id: "task-1" }, + }); + }); + + it("throws NotFoundException when task does not exist", async () => { + mockPrisma.task.findUnique.mockResolvedValue(null); + + await expect(service.delete("nonexistent")).rejects.toThrow( + NotFoundException, + ); + }); + }); +}); diff --git a/src/module/task/task.service.ts b/src/module/task/task.service.ts new file mode 100644 index 0000000..54d8ab3 --- /dev/null +++ b/src/module/task/task.service.ts @@ -0,0 +1,185 @@ +import { + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { PrismaService } from "../../lib/database/prisma.service.js"; +import { CreateTaskDto } from "./dto/create-task.dto.js"; +import { UpdateTaskDto } from "./dto/update-task.dto.js"; + +@Injectable() +export class TaskService { + constructor(private readonly prisma: PrismaService) {} + + private include = { + project: { select: { id: true, name: true } }, + assignee: { select: { id: true, name: true, email: true } }, + } as const; + + async findByProject(projectId: string, userId?: string, role?: string) { + const project = await this.prisma.project.findUnique({ + where: { id: projectId }, + }); + + if (!project) { + throw new NotFoundException(`Project with id "${projectId}" not found`); + } + + const where: Record = { projectId }; + if (role !== "ADMIN") { + where.assigneeId = userId; + } + + return this.prisma.task.findMany({ + where, + include: this.include, + }); + } + + async findById(id: string) { + const task = await this.prisma.task.findUnique({ + where: { id }, + include: this.include, + }); + + if (!task) { + throw new NotFoundException(`Task with id "${id}" not found`); + } + + return task; + } + + async findByIdScoped(id: string, userId?: string, role?: string) { + const task = await this.findById(id); + + if (role !== "ADMIN" && task.assigneeId !== userId) { + throw new ForbiddenException("You do not have access to this task"); + } + + return task; + } + + async create(dto: CreateTaskDto, projectId: string) { + const project = await this.prisma.project.findUnique({ + where: { id: projectId }, + }); + + if (!project) { + throw new NotFoundException(`Project with id "${projectId}" not found`); + } + + if (dto.assigneeId) { + const user = await this.prisma.user.findUnique({ + where: { id: dto.assigneeId }, + }); + + if (!user) { + throw new NotFoundException( + `User with id "${dto.assigneeId}" not found`, + ); + } + } + + return this.prisma.task.create({ + data: { + title: dto.title, + description: dto.description, + status: dto.status, + priority: dto.priority, + projectId, + assigneeId: dto.assigneeId, + }, + include: this.include, + }); + } + + async update(id: string, dto: UpdateTaskDto) { + await this.findById(id); + + if (dto.projectId) { + const project = await this.prisma.project.findUnique({ + where: { id: dto.projectId }, + }); + + if (!project) { + throw new NotFoundException( + `Project with id "${dto.projectId}" not found`, + ); + } + } + + if (dto.assigneeId) { + const user = await this.prisma.user.findUnique({ + where: { id: dto.assigneeId }, + }); + + if (!user) { + throw new NotFoundException( + `User with id "${dto.assigneeId}" not found`, + ); + } + } + + return this.prisma.task.update({ + where: { id }, + data: { + title: dto.title, + description: dto.description, + status: dto.status, + priority: dto.priority, + projectId: dto.projectId, + assigneeId: dto.assigneeId, + }, + include: this.include, + }); + } + + async assign(id: string, userId: string) { + const task = await this.findById(id); + + if (task.assigneeId) { + throw new ConflictException( + `Task is already assigned to user "${task.assigneeId}"`, + ); + } + + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + throw new NotFoundException(`User with id "${userId}" not found`); + } + + return this.prisma.task.update({ + where: { id }, + data: { assigneeId: userId }, + include: this.include, + }); + } + + async unassign(id: string) { + await this.findById(id); + + return this.prisma.task.update({ + where: { id }, + data: { assigneeId: null }, + include: this.include, + }); + } + + async canAccessTask(taskId: string, userId?: string, role?: string) { + const task = await this.findById(taskId); + if (role !== "ADMIN" && task.assigneeId !== userId) { + throw new ForbiddenException("You do not have access to this task"); + } + return task; + } + + async delete(id: string) { + await this.findById(id); + + await this.prisma.task.delete({ where: { id } }); + } +} diff --git a/src/module/user/user.controller.ts b/src/module/user/user.controller.ts index 2b95456..d7bf601 100644 --- a/src/module/user/user.controller.ts +++ b/src/module/user/user.controller.ts @@ -1,20 +1,27 @@ import { Controller, Get, Param, UseGuards } from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import { Roles } from "../../common/decorators/roles.decorator.js"; import { RolesGuard } from "../../common/guards/roles.guard.js"; import { UserService } from "./user.service.js"; +@ApiTags("Users") +@ApiBearerAuth() @Controller("user") -@UseGuards(RolesGuard) export class UserController { constructor(private readonly userService: UserService) {} @Get("all") + @UseGuards(RolesGuard) @Roles("ADMIN") + @ApiOperation({ summary: "List all users (admin only)" }) findAll() { return this.userService.findAll(); } @Get(":id") + @UseGuards(RolesGuard) + @Roles("ADMIN") + @ApiOperation({ summary: "Get user by ID (admin only)" }) findById(@Param("id") id: string) { return this.userService.findById(id); } From bbd3bfb2e3bb2cbd86951126bc6703a54fb128f1 Mon Sep 17 00:00:00 2001 From: Loto-Kenneth <116004412+Loto-Kenneth@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:21:48 +0800 Subject: [PATCH 2/2] chore: added prisma generate in test ci --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79d8b8e..aa577bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,6 +125,10 @@ jobs: if: needs.changes.outputs.code == 'true' run: bun install --frozen-lockfile + - name: Generate Prisma client + if: needs.changes.outputs.code == 'true' + run: bunx prisma generate + - name: Run tests if: needs.changes.outputs.code == 'true' run: bun run test