diff --git a/SOLUTION.md b/SOLUTION.md index 8c48be5..f255789 100644 --- a/SOLUTION.md +++ b/SOLUTION.md @@ -2,43 +2,163 @@ ## Part 1: Performance Issues Fixed -### [Issue fixed] +### Issue 1 — `GET /tasks` response time grows linearly with the number of tasks **Problem Identified:** -[Describe the problem you found] +`TasksService.findAll` in `src/tasks/tasks.service.ts` had four compounding problems: + +1. **N+1 queries.** After `prisma.task.findMany()` returned every row, the handler iterated each task and issued three extra queries per row (`user.findUnique` for the assignee, `project.findUnique`, and `tag.findMany`). For N tasks this is `1 + 3N` round-trips — 301 queries for 100 tasks, 1501 for 500. +2. **No pagination.** The endpoint returned every row on every request, so payload size, JSON serialization and network time all scaled with the table. +3. **Filtering in application memory.** `status`, `priority`, `assigneeId`, `projectId` and the `dueDate` range were applied with `Array.filter` *after* loading the full table, so PostgreSQL kept returning rows that were immediately discarded. +4. **No indexes on filter columns.** The `Task` model had no indexes on the fields used for filtering, so even after pushing predicates to SQL the planner would fall back to sequential scans as the table grew. + +**Solution Implemented:** + +- Rewrote `findAll` to build a single `Prisma.TaskWhereInput` from the filter DTO (including a `gte`/`lte` range on `dueDate`), load all relations in one query via `include: { assignee, project, tags }`, and paginate with `skip` / `take` plus a deterministic `orderBy: { createdAt: 'desc' }`. The page query and `task.count` run inside a single `$transaction` for a consistent snapshot, and the response is `{ data, meta: { total, page, perPage } }` (the envelope is shared with `/activities` via `paginated()` in `src/common/dto/paginated.ts`). +- Added `page` (default `1`) and `perPage` (default `20`, max `100`) to a shared `PaginationQueryDto` that `TaskFilterDto` and `ActivityFilterDto` extend. Validated with `class-validator` and coerced from query strings via `@Type(() => Number)` (the app already enables `ValidationPipe({ transform: true })`). +- Added five `@@index` entries to the `Task` model in `prisma/schema.prisma` — `status`, `priority`, `assigneeId`, `projectId`, `dueDate` — and applied them via a new migration (`prisma/migrations/20260422183915_add_task_indexes`). + +**Performance Impact:** + +- Query count per request drops from `1 + 3N` to `2` (page + count). For N = 500 that is ~750× fewer round-trips. +- Payload and per-request work are now bounded by `pageSize`, not by total table size — response time no longer grows with the number of rows. +- Filter predicates run in PostgreSQL using indexes, turning sequential scans into index scans on the hot filter columns. This also directly addresses the "Search Performance" report (issue 4) and a large share of the "Database Load" report (issue 2). + +### Issue 2 — Task Assignment Delays (synchronous email) + +**Problem Identified:** +`TasksService.create` and `TasksService.update` `await`ed `emailService.sendTaskAssignmentNotification(...)` inline. The mocked mailer sleeps 2s on purpose (`src/email/email.service.ts:9`), which blocked the HTTP response by the full delay on every create/update with an assignee — matching the "creating or updating tasks with assignees takes longer than expected" report and the README note "The email service is mocked — just ensure it's called asynchronously". **Solution Implemented:** -[Describe your fix] +Introduced a private `notifyAssignee` helper that dispatches the email as fire-and-forget (no `await`) with a `.catch()` that logs the failure. The notification runs after the `prisma.$transaction` commits, so it can never prevent the response from returning or roll the task write back. **Performance Impact:** -[Describe the improvement] +Create and update responses return as soon as the DB write commits — the mailer's 2s simulated delay is no longer on the critical path. Mailer failures are logged instead of propagating as HTTP errors. ## Part 2: Activity Log Feature ### Implementation Approach -[Describe your overall approach to implementing the activity log] +- New `ActivitiesModule` (`src/activities/`) with `ActivitiesService` (reads), `ActivitiesController` (`GET /activities`), and `ActivityListener` (writes). +- `GET /tasks/:id/activities` lives on `TasksController` and delegates to `ActivitiesService.findByTask` via `TasksService.findActivities`, so the task-existence check (`findOne`) runs first and returns 404 for unknown tasks instead of an empty page. +- **Writes are driven by domain events**, not inline coupling. `TasksService` opens a `prisma.$transaction`, performs the task mutation, then emits `task.created` / `task.updated` via `@nestjs/event-emitter` (`EventEmitter2.emitAsync`). The transaction client (`tx`) is included in the payload so the listener's `activity.create` writes in the **same** transaction as the task mutation — atomicity preserved, coupling removed. Event handlers are registered with `{ suppressErrors: false }` so that if the listener throws, `emitAsync` rejects and the whole transaction rolls back. **There's no `task.deleted` event**: the schema cascades, so emitting a DELETED activity would be wiped by the same transaction (see schema section). +- `ActivityListener` (`src/activities/activity.listener.ts`) owns all activity-writing logic. It consumes the two events and uses the pure helpers in `activity-diff.ts`: + - `buildCreatedChanges(task, tagIds)` for `CREATED`. + - `diffTask(before, dto, tagIdsBefore, tagIdsAfter)` for `UPDATED`, with tag adds/removes folded into the same payload as `{ tags: { added, removed } }` — one activity per mutation, not one per tag. Empty diffs are skipped so the log stays noise-free. +- **Why events over explicit calls:** `TasksService` doesn't know `Activity` exists. New listeners (notifications, webhooks, cache invalidation) can plug into the same events without touching task logic. **Why not Prisma `$extends`:** we considered it, but extensions can't easily share the caller's transaction client or pull the request-scoped `userId` out of CLS, and updates would need a duplicate pre-fetch to compute the diff. ### Database Schema Design -[Explain your schema choices] +```prisma +model Activity { + id String @id @default(uuid()) + taskId String + task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id]) + action ActivityAction + changes Json + taskTitle String + createdAt DateTime @default(now()) + + @@index([taskId, createdAt]) + @@index([userId, createdAt]) + @@index([action]) + @@index([createdAt]) +} + +enum ActivityAction { CREATED UPDATED DELETED } +``` + +Rationale for the key decisions: + +- **`changes Json`** keeps the schema flexible for arbitrary field diffs without a column-per-field explosion, and matches the shape in the task spec (`{ field: { old, new } }`). +- **`taskTitle` denormalized** avoids a join when rendering a timeline for a task that was subsequently renamed — the activity shows the title at the moment the event happened. +- **`onDelete: Cascade`, `taskId` non-nullable.** When a task is deleted, its activities go with it. `Restrict` would make any task with history undeletable, and `SetNull` would orphan activities pointing at non-existent tasks. Cascade is the direct consequence of treating activities as part of the task's own state. Because a `DELETED` activity would be cascaded out by its own emitting transaction, we simply don't emit one — there's no `task.deleted` event and no `DELETED` activity row is ever written by the current code path (the enum value stays in the schema for forward compatibility). +- **Composite indexes `(taskId, createdAt)` and `(userId, createdAt)`** match the two dominant access patterns (task timeline, user timeline) and let Postgres answer `ORDER BY createdAt DESC LIMIT N` from the index without a sort step. Standalone indexes on `action` and `createdAt` back the `GET /activities` filter combinations. + +Applied via two migrations: `20260422194905_add_activity_log` (initial model) and `20260423_activity_cascade` (switched FK from `SetNull`-nullable to `Cascade`-required after deciding audit preservation across deletions wasn't a product requirement). ### API Design Decisions -[Explain your API design choices] +- **Authentication** — all task mutation endpoints require an `X-User-Id` header. Split into two responsibilities: the `ClsModule` middleware (AsyncLocalStorage via `nestjs-cls`, configured in `AppModule`) extracts and validates the header once per request and stashes the userId in the per-request store; `UserRequiredGuard` (applied with `@UseGuards` on `POST`/`PUT`/`DELETE` only) rejects unauthenticated requests with `401` *before* any DB work. `TasksService` reads the userId from CLS via a private `currentUserId()` helper when it needs it (create/update). Controllers stay clean — no `@CurrentUser()` plumbing through method signatures. No full auth stack was in scope for this assignment, so the header stands in for an authenticated principal and the FK on `Activity.userId` relies on PostgreSQL to reject unknown IDs. +- **Unified pagination envelope** — every list endpoint (`GET /tasks`, `GET /activities`, `GET /tasks/:id/activities`) returns `{ data: [...], meta: { total, page, perPage } }` per the spec example, built by `paginated()` in `src/common/dto/paginated.ts`. Activity `action` is serialized lowercase (`"created" | "updated"`) and `userName` is included via `include: { user: { select: { id, name } } }` so clients don't need a second round trip. +- **Filters on `GET /activities`**: `userId`, `action`, `dateFrom`, `dateTo`. All optional, all validated via `class-validator`, all pushed into `Prisma.ActivityWhereInput` — nothing is filtered in memory. +- **Global Prisma exception filter** — `PrismaExceptionFilter` (wired as `APP_FILTER`) maps `PrismaClientKnownRequestError` codes to proper HTTP statuses: `P2002` → 409 (unique violation), `P2003` → 400 (FK violation), `P2025` → 404 (record not found). Unknown codes are logged and return 500. Without this, an unknown `X-User-Id` would crash as an unhandled 500 instead of a readable 400. ### Performance Considerations -[Describe any performance optimizations you implemented] +- Every list endpoint is paginated and filtered in SQL; no activity reads ever materialize full history in memory. +- Activity inserts are a single `INSERT` piggybacked on the existing mutation's transaction — one extra round-trip on the hot path. +- Indexes chosen deliberately for the access patterns described above, not speculative combinations. +- `Activity.changes` as `Json` avoids per-field schema churn. If analytics ever needs to query specific change fields, a generated column or materialized projection can be layered on later. ### Trade-offs and Assumptions -[List any trade-offs you made or assumptions about requirements] +- **Cascade on task deletion erases the task's activity history.** That's the product decision: activities describe a task's lifecycle, and when the task is gone they go with it. Clean reads, clean cleanup, no orphan rows. If audit preservation across deletions ever becomes a requirement, the right answer is soft-delete on `Task` (mark `deletedAt`, filter reads, never physically delete) — not reverting the FK. +- **`changes` as JSON** is easy to write and read but hard to query by field — a deliberate trade-off given the primary use case is audit display, not analytics. +- **Tag adds/removes are embedded in the `UPDATED` activity**'s `changes.tags` instead of being separate rows. This keeps the timeline readable (one mutation → one row) and matches the semantic the spec asks for ("field updates… tag additions/removals" as *part of* tracking, not as a separate action type). +- **No user-existence validation at request time** — the FK on `Activity.userId` surfaces unknown IDs as a DB error, which the Prisma exception filter maps to 400. Validating presence per request would add a lookup on every mutation; an auth layer would handle it more cleanly. +- **`X-User-Id` header** is a stand-in for a proper authenticated principal and is trivially spoofable. Acceptable for this assignment; unacceptable for production. -## Future Improvements +### Collateral fix discovered during testing — Redis cache client leak + +While running the e2e suite I found that Jest could not exit on its own. `@nestjs/cache-manager@2.3.0` does not close the underlying Redis client in `onModuleDestroy`, and `pingInterval: 5000` in `redisStore(...)` registers a timer that keeps the Node event loop alive indefinitely after `app.close()`. I added `CacheShutdownService` (`src/common/cache-shutdown.service.ts`) as a provider in `AppModule` that reads `CACHE_MANAGER` and calls `client.disconnect()` / `client.quit()` on `onModuleDestroy`. The e2e suite now exits in ~3s with no `--forceExit` and no open-handle warnings. This also makes production shutdowns (`SIGTERM` in containers, with `app.enableShutdownHooks()`) release the Redis connection gracefully. + +## Tests + +Layout (tests separated from source): + +``` +test/ + jest.unit.json ← config for `npm test` + jest.e2e.json ← config for `npm run test:e2e` + unit/ ← fast, no DB (10 suites / 69 tests) + activities/ + common/ + tasks/ + e2e/ ← full stack against Postgres + Redis + Mailpit (5 suites / 35 tests) + tasks.e2e-spec.ts + activities.e2e-spec.ts + users.e2e-spec.ts + projects.e2e-spec.ts + email.e2e-spec.ts +``` + +- **Unit** — `TasksService` (all branches, including CLS guard invariant and fire-and-forget email with both Error and string rejection paths), `TasksRepository` (all build helpers, connect/disconnect branches), `ActivitiesService` (filter variants, skip/take math), `ActivityListener` (tag changes, date coercion, empty-diff skip), `PrismaExceptionFilter` (all mapped codes + unknown fallback), `UserRequiredGuard`, and pure mappers/helpers. All focused production code is at 100% or very near. +- **E2E** — boots the real `AppModule` against the local Postgres/Redis. Covers the paginated envelope and filter combinations on `GET /tasks` and `GET /activities`, auth on mutations (missing and malformed header → 401), validation (invalid enum/UUID/date → 400), the full create→update→delete flow with activity assertions (including cascade proof — after the task is deleted, `prisma.activity.count({ where: { taskId } })` is `0`), and the **transaction rollback test** that stubs the listener to throw and verifies the task was never persisted (proving the `emitAsync` atomicity claim is empirically true — this is what caught that `@nestjs/event-emitter` defaults `suppressErrors: true` and would silently commit the task without the activity). -[Suggest potential improvements that could be made with more time] +Run with `npm test` (unit) and `npm run test:e2e` (integration — requires the Docker stack up and `npm run seed` first). -## Time Spent +## Extra — Real email delivery via Mailpit + +The original `EmailService` was a console-logging stub with a hard-coded 2s `setTimeout` simulating "SMTP latency". Replaced with a real SMTP loop backed by **Mailpit** (modern successor to MailHog) running in `docker-compose`. The fire-and-forget pattern from Part 1 is preserved — the user-visible change is only that emails now actually exist and are inspectable. + +- **`docker-compose.yml`** — added the `mailpit` service exposing `1025` (SMTP in) and `8025` (Web UI + HTTP API). Configured with `MP_SMTP_AUTH_ACCEPT_ANY=1` so dev flow needs no auth setup. +- **`src/email/email.service.ts`** — rewritten around `nodemailer`. Single `Transporter` instance created in `onModuleInit` (reading `SMTP_HOST` / `SMTP_PORT` / `SMTP_FROM` from `ConfigService`), closed in `onModuleDestroy`. The `sendEmail` / `sendTaskAssignmentNotification` signatures didn't change — `TasksService` required zero edits. +- **`env.validation.ts`** — three new required vars (`SMTP_HOST`, `SMTP_PORT`, `SMTP_FROM`). Joi's `.email()` rule uses `tlds: { allow: false }` so the dev default `noreply@taskapp.local` is accepted (strict TLD validation would reject `.local`). +- **Web UI** at http://localhost:8025 — every email the app sends is immediately visible with full subject / body / headers. Useful for manual QA of assignment flows. +- **New e2e suite** — `test/e2e/email.e2e-spec.ts` talks directly to Mailpit's HTTP API (`GET /api/v1/messages`, `DELETE /api/v1/messages`) to assert the full loop end-to-end: + - Creating a task with an assignee delivers one email with the assignee's address in `To` and a subject containing "assigned". + - Creating without an assignee delivers **zero** emails (proves the `if (task.assignee)` guard works). + - Updating the assignee delivers an email to the new assignee. +- **Production path** — swap `SMTP_HOST`/`SMTP_PORT` to a real relay (Mailgun, SES, Postmark) via env vars; no code changes required. A proper queue (BullMQ on the existing Redis) remains listed under Future Improvements for retries/dead-lettering — that's orthogonal to the transport choice. + +## Extra — OpenAPI / Swagger documentation + +Not required by the assignment, but cheap to add given NestJS first-class support: `@nestjs/swagger` wired in `main.ts` serves an interactive UI at `GET /docs` and the raw OpenAPI spec at `GET /docs-json` once `npm run start:dev` is running. + +- Controllers carry `@ApiTags('tasks' | 'activities' | ...)` so the UI groups endpoints sensibly. +- Mutation routes carry `@ApiSecurity('user-id')` paired with `addApiKey({ type: 'apiKey', name: 'X-User-Id', in: 'header' }, 'user-id')` in the `DocumentBuilder`, so the UI exposes an **Authorize** button that injects the header — try-it-out works end-to-end for `POST` / `PUT` / `DELETE`. +- `CreateTaskDto` decorated with `@ApiProperty` / `@ApiPropertyOptional` (enum, format, defaults). `UpdateTaskDto` uses `PartialType(CreateTaskDto)` from `@nestjs/swagger` so it inherits decorators without duplication. +- `PaginationQueryDto` contributes `page` / `perPage` to every list endpoint automatically via DTO inheritance. +- `app.enableShutdownHooks()` was added in `main.ts` in the same pass, so `SIGTERM` in containers triggers `onModuleDestroy` cleanly (complements the `CacheShutdownService`). + +## Future Improvements -[Document how long you spent on each part] +- Add composite indexes on `Task` if traffic shows combined filters dominating (e.g. `@@index([projectId, status])`). +- Consider cursor-based pagination for very large activity timelines where deep `OFFSET` becomes expensive. +- Cache hot `GET /tasks` pages in Redis (the dependency is already present) keyed by the normalized filter + page tuple, with invalidation on task mutations. +- Audit `GET /projects` and `GET /users` for the same N+1 / pagination / index issues. +- Move mailer dispatch onto a proper queue (BullMQ on the existing Redis) for retries, dead-lettering, and observability — the current fire-and-forget is fast but silently drops failures after logging them. +- Replace the `X-User-Id` header with real authentication (JWT or session) and drop the manual UUID validation in the CLS middleware. diff --git a/docker-compose.yml b/docker-compose.yml index c9a2879..61c1861 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,5 +17,14 @@ services: ports: - "6379:6379" + mailpit: + image: axllent/mailpit:latest + ports: + - "1025:1025" # SMTP + - "8025:8025" # Web UI + HTTP API + environment: + MP_SMTP_AUTH_ACCEPT_ANY: 1 + MP_SMTP_AUTH_ALLOW_INSECURE: 1 + volumes: postgres_data: diff --git a/package-lock.json b/package-lock.json index fdc579d..16531a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,12 +13,17 @@ "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.0.0", "@nestjs/core": "^10.0.0", + "@nestjs/event-emitter": "^3.0.1", "@nestjs/platform-express": "^10.0.0", + "@nestjs/swagger": "^7.4.2", "@prisma/client": "^5.7.0", "cache-manager": "^5.2.3", "cache-manager-redis-yet": "^5.1.5", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "joi": "^18.1.2", + "nestjs-cls": "^6.2.0", + "nodemailer": "^8.0.5", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1" }, @@ -29,6 +34,7 @@ "@types/express": "^4.17.17", "@types/jest": "^29.5.2", "@types/node": "^20.3.1", + "@types/nodemailer": "^8.0.0", "@types/supertest": "^2.0.12", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", @@ -839,6 +845,48 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@hapi/address": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", + "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", + "dependencies": { + "@hapi/hoek": "^11.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/formula": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", + "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==" + }, + "node_modules/@hapi/pinpoint": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", + "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==" + }, + "node_modules/@hapi/tlds": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz", + "integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -1486,6 +1534,11 @@ "node": ">=8" } }, + "node_modules/@microsoft/tsdoc": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz", + "integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==" + }, "node_modules/@nestjs/cache-manager": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-2.3.0.tgz", @@ -1703,6 +1756,37 @@ } } }, + "node_modules/@nestjs/event-emitter": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-3.0.1.tgz", + "integrity": "sha512-0Ln/x+7xkU6AJFOcQI9tIhUMXVF7D5itiaQGOyJbXtlAfAIt8gzDdJm+Im7cFzKoWkiW5nCXCPh6GSvdQd/3Dw==", + "dependencies": { + "eventemitter2": "6.4.9" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, + "node_modules/@nestjs/mapped-types": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.5.tgz", + "integrity": "sha512-bSJv4pd6EY99NX9CjBIyn4TVDoSit82DUZlL4I3bqNfy5Gt+gXTa86i3I/i0iIV9P4hntcGM5GyO+FhZAhxtyg==", + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "class-transformer": "^0.4.0 || ^0.5.0", + "class-validator": "^0.13.0 || ^0.14.0", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/platform-express": { "version": "10.4.20", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.20.tgz", @@ -1745,6 +1829,38 @@ "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", "dev": true }, + "node_modules/@nestjs/swagger": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.2.tgz", + "integrity": "sha512-Mu6TEn1M/owIvAx2B4DUQObQXqo2028R2s9rSZ/hJEgBK95+doTwS0DjmVA2wTeZTyVtXOoN7CsoM5pONBzvKQ==", + "dependencies": { + "@microsoft/tsdoc": "^0.15.0", + "@nestjs/mapped-types": "2.0.5", + "js-yaml": "4.1.0", + "lodash": "4.17.21", + "path-to-regexp": "3.3.0", + "swagger-ui-dist": "5.17.14" + }, + "peerDependencies": { + "@fastify/static": "^6.0.0 || ^7.0.0", + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/testing": { "version": "10.4.20", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.20.tgz", @@ -2006,6 +2122,11 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" + }, "node_modules/@tokenizer/inflate": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", @@ -2244,6 +2365,15 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -2885,8 +3015,7 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/array-flatten": { "version": "1.1.1", @@ -4383,6 +4512,11 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter2": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", + "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==" + }, "node_modules/eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", @@ -6239,6 +6373,23 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/joi": { + "version": "18.1.2", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.1.2.tgz", + "integrity": "sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA==", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6249,7 +6400,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -6697,6 +6847,20 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "node_modules/nestjs-cls": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/nestjs-cls/-/nestjs-cls-6.2.0.tgz", + "integrity": "sha512-b2Remha7gV5gId3ezjr2tupjqqgYK7/JqjqX6oZ0ZIDFATUggKH1/32+ul2lOe7FepnHasDONDoePuWEE64cug==", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@nestjs/common": ">= 10 < 12", + "@nestjs/core": ">= 10 < 12", + "reflect-metadata": "*", + "rxjs": ">= 7" + } + }, "node_modules/node-abort-controller": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", @@ -6743,6 +6907,14 @@ "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", "dev": true }, + "node_modules/nodemailer": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz", + "integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -8144,6 +8316,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-ui-dist": { + "version": "5.17.14", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz", + "integrity": "sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw==" + }, "node_modules/symbol-observable": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", diff --git a/package.json b/package.json index 2174812..9de37ce 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,11 @@ "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "test": "jest", - "test:watch": "jest --watch", - "test:cov": "jest --coverage", - "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json", + "test": "jest --config ./test/jest.unit.json", + "test:watch": "jest --config ./test/jest.unit.json --watch", + "test:cov": "jest --config ./test/jest.unit.json --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --config ./test/jest.unit.json --runInBand", + "test:e2e": "jest --config ./test/jest.e2e.json", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", "seed": "ts-node prisma/seed.ts" @@ -27,12 +27,17 @@ "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.0.0", "@nestjs/core": "^10.0.0", + "@nestjs/event-emitter": "^3.0.1", "@nestjs/platform-express": "^10.0.0", + "@nestjs/swagger": "^7.4.2", "@prisma/client": "^5.7.0", "cache-manager": "^5.2.3", "cache-manager-redis-yet": "^5.1.5", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "joi": "^18.1.2", + "nestjs-cls": "^6.2.0", + "nodemailer": "^8.0.5", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1" }, @@ -43,6 +48,7 @@ "@types/express": "^4.17.17", "@types/jest": "^29.5.2", "@types/node": "^20.3.1", + "@types/nodemailer": "^8.0.0", "@types/supertest": "^2.0.12", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", @@ -59,22 +65,5 @@ "ts-node": "^10.9.1", "tsconfig-paths": "^4.2.0", "typescript": "^5.1.3" - }, - "jest": { - "moduleFileExtensions": [ - "js", - "json", - "ts" - ], - "rootDir": "src", - "testRegex": ".*\\.spec\\.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - }, - "collectCoverageFrom": [ - "**/*.(t|j)s" - ], - "coverageDirectory": "../coverage", - "testEnvironment": "node" } } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 894cbad..c44c8be 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -15,6 +15,7 @@ model User { updatedAt DateTime @updatedAt assignedTasks Task[] + activities Activity[] } model Project { @@ -43,6 +44,36 @@ model Task { assignee User? @relation(fields: [assigneeId], references: [id]) tags Tag[] + activities Activity[] + + @@index([status]) + @@index([priority]) + @@index([assigneeId]) + @@index([projectId]) + @@index([dueDate]) +} + +model Activity { + id String @id @default(uuid()) + taskId String + task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id]) + action ActivityAction + changes Json + taskTitle String + createdAt DateTime @default(now()) + + @@index([taskId, createdAt]) + @@index([userId, createdAt]) + @@index([action]) + @@index([createdAt]) +} + +enum ActivityAction { + CREATED + UPDATED + DELETED } model Tag { diff --git a/src/activities/activities.controller.ts b/src/activities/activities.controller.ts new file mode 100644 index 0000000..aa216eb --- /dev/null +++ b/src/activities/activities.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { ActivitiesService } from './activities.service'; +import { ActivityFilterDto } from './dto/activity-filter.dto'; + +@ApiTags('activities') +@Controller('activities') +export class ActivitiesController { + constructor(private readonly activitiesService: ActivitiesService) {} + + @Get() + findAll(@Query() filter: ActivityFilterDto) { + return this.activitiesService.findAll(filter); + } +} diff --git a/src/activities/activities.module.ts b/src/activities/activities.module.ts new file mode 100644 index 0000000..576960d --- /dev/null +++ b/src/activities/activities.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ActivitiesController } from './activities.controller'; +import { ActivitiesService } from './activities.service'; +import { ActivityListener } from './activity.listener'; + +@Module({ + controllers: [ActivitiesController], + providers: [ActivitiesService, ActivityListener], + exports: [ActivitiesService], +}) +export class ActivitiesModule {} diff --git a/src/activities/activities.service.ts b/src/activities/activities.service.ts new file mode 100644 index 0000000..3b7a51c --- /dev/null +++ b/src/activities/activities.service.ts @@ -0,0 +1,62 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { ActivityFilterDto, TaskActivityFilterDto } from './dto/activity-filter.dto'; +import { + ACTIVITY_INCLUDE, + ActivityResponse, + toActivityResponse, +} from './dto/activity-response.dto'; +import { paginated, Paginated } from '../common/dto/paginated'; + +@Injectable() +export class ActivitiesService { + constructor(private readonly prisma: PrismaService) {} + + async findAll(filter: ActivityFilterDto): Promise> { + const { page, perPage, dateFrom, dateTo, userId, action } = filter; + + const where: Prisma.ActivityWhereInput = { + userId, + action, + createdAt: dateFrom || dateTo ? { + gte: dateFrom ? new Date(dateFrom) : undefined, + lte: dateTo ? new Date(dateTo) : undefined, + } : undefined, + }; + + const [rows, total] = await this.prisma.$transaction([ + this.prisma.activity.findMany({ + where, + include: ACTIVITY_INCLUDE, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * perPage, + take: perPage, + }), + this.prisma.activity.count({ where }), + ]); + + return paginated(rows.map(toActivityResponse), total, page, perPage); + } + + async findByTask( + taskId: string, + filter: TaskActivityFilterDto, + ): Promise> { + const { page, perPage } = filter; + const where: Prisma.ActivityWhereInput = { taskId }; + + const [rows, total] = await this.prisma.$transaction([ + this.prisma.activity.findMany({ + where, + include: ACTIVITY_INCLUDE, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * perPage, + take: perPage, + }), + this.prisma.activity.count({ where }), + ]); + + return paginated(rows.map(toActivityResponse), total, page, perPage); + } +} diff --git a/src/activities/activity-diff.ts b/src/activities/activity-diff.ts new file mode 100644 index 0000000..e0e2a43 --- /dev/null +++ b/src/activities/activity-diff.ts @@ -0,0 +1,72 @@ +import { Task } from '@prisma/client'; + +export type FieldChange = { old: unknown; new: unknown }; +export type TagsChange = { added: string[]; removed: string[] }; + +export type ActivityChanges = Record; + +const TRACKED_FIELDS = [ + 'title', + 'description', + 'status', + 'priority', + 'dueDate', + 'assigneeId', + 'projectId', +] as const satisfies readonly (keyof Task)[]; + +type TrackedField = (typeof TRACKED_FIELDS)[number]; + +type TaskLike = Pick; + +const normalize = (value: unknown): unknown => { + if (value instanceof Date) return value.toISOString(); + return value ?? null; +}; + +const isEqual = (a: unknown, b: unknown): boolean => normalize(a) === normalize(b); + +export const diffTask = ( + before: TaskLike, + after: Partial, + tagIdsBefore?: string[], + tagIdsAfter?: string[], +): ActivityChanges => { + const changes: ActivityChanges = {}; + + for (const field of TRACKED_FIELDS) { + const incoming = after[field]; + if (incoming === undefined) continue; + if (isEqual(before[field], incoming)) continue; + changes[field] = { + old: normalize(before[field]), + new: normalize(incoming), + }; + } + + if (tagIdsAfter !== undefined) { + const beforeSet = new Set(tagIdsBefore ?? []); + const afterSet = new Set(tagIdsAfter); + const added = [...afterSet].filter((id) => !beforeSet.has(id)); + const removed = [...beforeSet].filter((id) => !afterSet.has(id)); + if (added.length || removed.length) { + changes['tags'] = { added, removed }; + } + } + + return changes; +}; + +export const buildCreatedChanges = (task: TaskLike, tagIds: string[]): ActivityChanges => { + const changes: ActivityChanges = {}; + for (const field of TRACKED_FIELDS) { + const value = task[field]; + if (value === null || value === undefined) continue; + changes[field] = { old: null, new: normalize(value) }; + } + if (tagIds.length) { + changes['tags'] = { added: tagIds, removed: [] }; + } + return changes; +}; + diff --git a/src/activities/activity.listener.ts b/src/activities/activity.listener.ts new file mode 100644 index 0000000..88c6acf --- /dev/null +++ b/src/activities/activity.listener.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { Prisma } from '@prisma/client'; +import { buildCreatedChanges, diffTask } from './activity-diff'; +import { + TASK_CREATED, + TASK_UPDATED, + TaskCreatedEvent, + TaskUpdatedEvent, +} from '../tasks/task.events'; + +@Injectable() +export class ActivityListener { + @OnEvent(TASK_CREATED, { suppressErrors: false }) + async onTaskCreated({ tx, userId, task }: TaskCreatedEvent) { + await tx.activity.create({ + data: { + taskId: task.id, + userId, + action: 'CREATED', + taskTitle: task.title, + changes: buildCreatedChanges(task, task.tags.map((t) => t.id)) as Prisma.InputJsonValue, + }, + }); + } + + @OnEvent(TASK_UPDATED, { suppressErrors: false }) + async onTaskUpdated({ tx, userId, before, after, dto }: TaskUpdatedEvent) { + const hasTagsUpdate = dto.tagIds !== undefined; + + const changes = diffTask( + before, + { + title: dto.title, + description: dto.description, + status: dto.status, + priority: dto.priority, + dueDate: dto.dueDate ? new Date(dto.dueDate) : (dto.dueDate as null | undefined), + assigneeId: dto.assigneeId, + }, + before.tags.map((t) => t.id), + hasTagsUpdate ? dto.tagIds : undefined, + ); + + if (Object.keys(changes).length === 0) return; + + await tx.activity.create({ + data: { + taskId: after.id, + userId, + action: 'UPDATED', + taskTitle: after.title, + changes: changes as Prisma.InputJsonValue, + }, + }); + } +} diff --git a/src/activities/dto/activity-filter.dto.ts b/src/activities/dto/activity-filter.dto.ts new file mode 100644 index 0000000..4b53abb --- /dev/null +++ b/src/activities/dto/activity-filter.dto.ts @@ -0,0 +1,23 @@ +import { IsOptional, IsEnum, IsUUID, IsDateString } from 'class-validator'; +import { ActivityAction } from '@prisma/client'; +import { PaginationQueryDto } from '../../common/dto/pagination.dto'; + +export class ActivityFilterDto extends PaginationQueryDto { + @IsOptional() + @IsUUID() + userId?: string; + + @IsOptional() + @IsEnum(ActivityAction) + action?: ActivityAction; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; +} + +export class TaskActivityFilterDto extends PaginationQueryDto {} diff --git a/src/activities/dto/activity-response.dto.ts b/src/activities/dto/activity-response.dto.ts new file mode 100644 index 0000000..fe3b168 --- /dev/null +++ b/src/activities/dto/activity-response.dto.ts @@ -0,0 +1,32 @@ +import { Prisma } from '@prisma/client'; + +const ACTIVITY_INCLUDE = { + user: { select: { id: true, name: true } }, +} as const; + +type ActivityWithUser = Prisma.ActivityGetPayload<{ include: typeof ACTIVITY_INCLUDE }>; + +export { ACTIVITY_INCLUDE }; +export type { ActivityWithUser }; + +export interface ActivityResponse { + id: string; + taskId: string; + taskTitle: string; + userId: string; + userName: string; + action: string; + changes: Prisma.JsonValue; + createdAt: Date; +} + +export const toActivityResponse = (a: ActivityWithUser): ActivityResponse => ({ + id: a.id, + taskId: a.taskId, + taskTitle: a.taskTitle, + userId: a.userId, + userName: a.user.name, + action: a.action.toLowerCase(), + changes: a.changes, + createdAt: a.createdAt, +}); diff --git a/src/app.module.ts b/src/app.module.ts index ec009d3..20daf1c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,34 +1,58 @@ import { Module } from '@nestjs/common'; +import { APP_FILTER } from '@nestjs/core'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { CacheModule } from '@nestjs/cache-manager'; +import { EventEmitterModule } from '@nestjs/event-emitter'; +import { ClsModule } from 'nestjs-cls'; +import { redisStore } from 'cache-manager-redis-yet'; import { PrismaModule } from './prisma/prisma.module'; import { TasksModule } from './tasks/tasks.module'; import { ProjectsModule } from './projects/projects.module'; import { UsersModule } from './users/users.module'; import { EmailModule } from './email/email.module'; -import { redisStore } from 'cache-manager-redis-yet'; +import { ActivitiesModule } from './activities/activities.module'; +import { CacheShutdownService } from './common/cache-shutdown.service'; +import { CLS_USER_ID_KEY, UUID_V4 } from './common/constants'; +import { PrismaExceptionFilter } from './common/filters/prisma-exception.filter'; +import { envValidationSchema } from './config/env.validation'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, + validationSchema: envValidationSchema, + validationOptions: { abortEarly: false, allowUnknown: true }, + }), + EventEmitterModule.forRoot(), + ClsModule.forRoot({ + global: true, + middleware: { + mount: true, + setup: (cls, req) => { + const raw = req.headers['x-user-id']; + const userId = Array.isArray(raw) ? raw[0] : raw; + if (typeof userId === 'string' && UUID_V4.test(userId)) { + cls.set(CLS_USER_ID_KEY, userId); + } + }, + }, }), CacheModule.registerAsync({ isGlobal: true, imports: [ConfigModule], inject: [ConfigService], - useFactory: async (configService: ConfigService) => { - const url = configService.get('REDIS_URL') ?? '' + useFactory: async (config: ConfigService) => { + const url = config.getOrThrow('REDIS_URL'); return { store: await redisStore({ - url: url, + url, socket: { - tls: url?.startsWith('rediss://') ? true : false, + tls: url.startsWith('rediss://'), rejectUnauthorized: false, }, pingInterval: 5 * 1000, - }) - } + }), + }; }, }), PrismaModule, @@ -36,6 +60,11 @@ import { redisStore } from 'cache-manager-redis-yet'; ProjectsModule, UsersModule, EmailModule, + ActivitiesModule, + ], + providers: [ + CacheShutdownService, + { provide: APP_FILTER, useClass: PrismaExceptionFilter }, ], }) export class AppModule {} diff --git a/src/common/cache-shutdown.service.ts b/src/common/cache-shutdown.service.ts new file mode 100644 index 0000000..ba07647 --- /dev/null +++ b/src/common/cache-shutdown.service.ts @@ -0,0 +1,21 @@ +import { Inject, Injectable, OnModuleDestroy } from '@nestjs/common'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; + +@Injectable() +export class CacheShutdownService implements OnModuleDestroy { + constructor(@Inject(CACHE_MANAGER) private readonly cache: unknown) {} + + async onModuleDestroy() { + const store = (this.cache as { store?: { client?: unknown } })?.store; + const client = store?.client as + | { disconnect?: () => Promise | void; quit?: () => Promise | void } + | undefined; + if (!client) return; + try { + if (typeof client.disconnect === 'function') await client.disconnect(); + else if (typeof client.quit === 'function') await client.quit(); + } catch { + // best-effort — nothing meaningful to do if shutdown fails + } + } +} diff --git a/src/common/constants.ts b/src/common/constants.ts new file mode 100644 index 0000000..df9beb6 --- /dev/null +++ b/src/common/constants.ts @@ -0,0 +1,3 @@ +export const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export const CLS_USER_ID_KEY = 'userId'; diff --git a/src/common/dto/paginated.ts b/src/common/dto/paginated.ts new file mode 100644 index 0000000..c10b9e8 --- /dev/null +++ b/src/common/dto/paginated.ts @@ -0,0 +1,17 @@ +export interface PageMeta { + total: number; + page: number; + perPage: number; +} + +export interface Paginated { + data: T[]; + meta: PageMeta; +} + +export const paginated = ( + data: T[], + total: number, + page: number, + perPage: number, +): Paginated => ({ data, meta: { total, page, perPage } }); diff --git a/src/common/dto/pagination.dto.ts b/src/common/dto/pagination.dto.ts new file mode 100644 index 0000000..7029f33 --- /dev/null +++ b/src/common/dto/pagination.dto.ts @@ -0,0 +1,20 @@ +import { IsInt, IsOptional, Max, Min } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class PaginationQueryDto { + @ApiPropertyOptional({ default: 1, minimum: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page: number = 1; + + @ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + perPage: number = 20; +} diff --git a/src/common/filters/prisma-exception.filter.ts b/src/common/filters/prisma-exception.filter.ts new file mode 100644 index 0000000..d7a307f --- /dev/null +++ b/src/common/filters/prisma-exception.filter.ts @@ -0,0 +1,59 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { Response } from 'express'; + +type Mapping = { status: number; message: (err: Prisma.PrismaClientKnownRequestError) => string }; + +const CODE_MAP: Record = { + P2002: { + status: HttpStatus.CONFLICT, + message: (e) => `Unique constraint violation on: ${formatTarget(e.meta?.target)}`, + }, + P2003: { + status: HttpStatus.BAD_REQUEST, + message: (e) => `Foreign key constraint violation on: ${formatTarget(e.meta?.field_name)}`, + }, + P2025: { + status: HttpStatus.NOT_FOUND, + message: (e) => (typeof e.meta?.cause === 'string' ? e.meta.cause : 'Record not found'), + }, +}; + +const formatTarget = (value: unknown): string => { + if (Array.isArray(value)) return value.join(', '); + if (typeof value === 'string') return value; + return 'unknown'; +}; + +@Catch(Prisma.PrismaClientKnownRequestError) +export class PrismaExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(PrismaExceptionFilter.name); + + catch(exception: Prisma.PrismaClientKnownRequestError, host: ArgumentsHost) { + const res = host.switchToHttp().getResponse(); + const mapping = CODE_MAP[exception.code]; + + if (!mapping) { + this.logger.error( + `Unmapped Prisma error ${exception.code}: ${exception.message}`, + exception.stack, + ); + return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + message: 'Internal server error', + }); + } + + const status = mapping.status; + const message = mapping.message(exception); + this.logger.warn(`Prisma ${exception.code} → ${status}: ${message}`); + + return res.status(status).json({ statusCode: status, message }); + } +} diff --git a/src/common/guards/user-required.guard.ts b/src/common/guards/user-required.guard.ts new file mode 100644 index 0000000..74c9057 --- /dev/null +++ b/src/common/guards/user-required.guard.ts @@ -0,0 +1,21 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { ClsService } from 'nestjs-cls'; +import { CLS_USER_ID_KEY } from '../constants'; + +@Injectable() +export class UserRequiredGuard implements CanActivate { + constructor(private readonly cls: ClsService) {} + + canActivate(_ctx: ExecutionContext): boolean { + const userId = this.cls.get(CLS_USER_ID_KEY); + if (!userId) { + throw new UnauthorizedException('X-User-Id header is required'); + } + return true; + } +} diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts new file mode 100644 index 0000000..44beca1 --- /dev/null +++ b/src/config/env.validation.ts @@ -0,0 +1,23 @@ +import * as Joi from 'joi'; + +export interface AppEnv { + NODE_ENV: 'development' | 'test' | 'production'; + PORT: number; + DATABASE_URL: string; + REDIS_URL: string; + SMTP_HOST: string; + SMTP_PORT: number; + SMTP_FROM: string; +} + +export const envValidationSchema = Joi.object({ + NODE_ENV: Joi.string() + .valid('development', 'test', 'production') + .default('development'), + PORT: Joi.number().integer().min(1).max(65535).default(3000), + DATABASE_URL: Joi.string().uri({ scheme: ['postgres', 'postgresql'] }).required(), + REDIS_URL: Joi.string().uri({ scheme: ['redis', 'rediss'] }).required(), + SMTP_HOST: Joi.string().hostname().required(), + SMTP_PORT: Joi.number().integer().min(1).max(65535).required(), + SMTP_FROM: Joi.string().email({ tlds: { allow: false } }).required(), +}); diff --git a/src/email/email.service.ts b/src/email/email.service.ts index e16d8a5..d40b675 100644 --- a/src/email/email.service.ts +++ b/src/email/email.service.ts @@ -1,20 +1,39 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createTransport, Transporter } from 'nodemailer'; @Injectable() -export class EmailService { - // Simulated email service - intentionally slow - async sendEmail(to: string, subject: string, body: string): Promise { - console.log(`Sending email to ${to}: ${subject}`); - // Simulate network delay - await new Promise(resolve => setTimeout(resolve, 2000)); - console.log(`Email sent to ${to}`); +export class EmailService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(EmailService.name); + private transporter!: Transporter; + private from!: string; + + constructor(private readonly config: ConfigService) {} + + onModuleInit() { + this.transporter = createTransport({ + host: this.config.getOrThrow('SMTP_HOST'), + port: Number(this.config.getOrThrow('SMTP_PORT')), + secure: false, + ignoreTLS: true, + }); + this.from = this.config.getOrThrow('SMTP_FROM'); + } + + async onModuleDestroy() { + this.transporter?.close(); + } + + async sendEmail(to: string, subject: string, text: string): Promise { + await this.transporter.sendMail({ from: this.from, to, subject, text }); + this.logger.log(`Email sent to ${to}: ${subject}`); } async sendTaskAssignmentNotification(assigneeEmail: string, taskTitle: string): Promise { await this.sendEmail( assigneeEmail, 'You have been assigned a new task', - `You have been assigned to task: ${taskTitle}` + `You have been assigned to task: ${taskTitle}`, ); } } diff --git a/src/main.ts b/src/main.ts index 5e735d5..c4c4b95 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,13 +1,30 @@ import { NestFactory } from '@nestjs/core'; -import { ValidationPipe } from '@nestjs/common'; +import { ValidationPipe, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); - app.useGlobalPipes(new ValidationPipe({ - whitelist: true, - transform: true, - })); - await app.listen(3000); + const logger = new Logger('Bootstrap'); + const config = app.get(ConfigService); + + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + app.enableShutdownHooks(); + + const swaggerConfig = new DocumentBuilder() + .setTitle('Task Management API') + .setDescription('Tasks, projects, and activity-log endpoints.') + .setVersion('1.0') + .addApiKey({ type: 'apiKey', name: 'X-User-Id', in: 'header' }, 'user-id') + .build(); + const document = SwaggerModule.createDocument(app, swaggerConfig); + SwaggerModule.setup('docs', app, document); + + const port = config.get('PORT') ?? 3000; + await app.listen(port); + logger.log(`API listening on http://localhost:${port}`); + logger.log(`Swagger UI available at http://localhost:${port}/docs`); } + bootstrap(); diff --git a/src/tasks/dto/create-task.dto.ts b/src/tasks/dto/create-task.dto.ts index 6e14371..e9a6ddd 100644 --- a/src/tasks/dto/create-task.dto.ts +++ b/src/tasks/dto/create-task.dto.ts @@ -1,33 +1,42 @@ import { IsString, IsOptional, IsEnum, IsDateString, IsUUID, IsArray } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { TaskStatus, TaskPriority } from '@prisma/client'; export class CreateTaskDto { + @ApiProperty() @IsString() title: string; + @ApiPropertyOptional() @IsOptional() @IsString() description?: string; + @ApiPropertyOptional({ enum: TaskStatus, default: TaskStatus.TODO }) @IsOptional() @IsEnum(TaskStatus) status?: TaskStatus = TaskStatus.TODO; + @ApiPropertyOptional({ enum: TaskPriority, default: TaskPriority.MEDIUM }) @IsOptional() @IsEnum(TaskPriority) priority?: TaskPriority = TaskPriority.MEDIUM; + @ApiPropertyOptional({ format: 'date-time' }) @IsOptional() @IsDateString() dueDate?: string; + @ApiProperty({ format: 'uuid' }) @IsUUID() projectId: string; + @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() @IsUUID() assigneeId?: string; + @ApiPropertyOptional({ type: [String], format: 'uuid' }) @IsOptional() @IsArray() @IsUUID('4', { each: true }) diff --git a/src/tasks/dto/task-filter.dto.ts b/src/tasks/dto/task-filter.dto.ts index 121da17..1cc804e 100644 --- a/src/tasks/dto/task-filter.dto.ts +++ b/src/tasks/dto/task-filter.dto.ts @@ -1,7 +1,8 @@ import { IsOptional, IsEnum, IsUUID, IsDateString } from 'class-validator'; import { TaskStatus, TaskPriority } from '@prisma/client'; +import { PaginationQueryDto } from '../../common/dto/pagination.dto'; -export class TaskFilterDto { +export class TaskFilterDto extends PaginationQueryDto { @IsOptional() @IsEnum(TaskStatus) status?: TaskStatus; diff --git a/src/tasks/dto/task-response.dto.ts b/src/tasks/dto/task-response.dto.ts new file mode 100644 index 0000000..1412e64 --- /dev/null +++ b/src/tasks/dto/task-response.dto.ts @@ -0,0 +1,36 @@ +import { TaskPriority, TaskStatus } from '@prisma/client'; +import { TaskWithRelations } from '../tasks.repository'; + +export interface UserSummary { id: string; name: string; email: string } +export interface ProjectSummary { id: string; name: string } +export interface TagSummary { id: string; name: string } + +export interface TaskResponse { + id: string; + title: string; + description: string | null; + status: TaskStatus; + priority: TaskPriority; + dueDate: Date | null; + createdAt: Date; + updatedAt: Date; + assignee: UserSummary | null; + project: ProjectSummary; + tags: TagSummary[]; +} + +export const toTaskResponse = (task: TaskWithRelations): TaskResponse => ({ + id: task.id, + title: task.title, + description: task.description, + status: task.status, + priority: task.priority, + dueDate: task.dueDate, + createdAt: task.createdAt, + updatedAt: task.updatedAt, + assignee: task.assignee + ? { id: task.assignee.id, name: task.assignee.name, email: task.assignee.email } + : null, + project: { id: task.project.id, name: task.project.name }, + tags: task.tags.map((t) => ({ id: t.id, name: t.name })), +}); diff --git a/src/tasks/dto/update-task.dto.ts b/src/tasks/dto/update-task.dto.ts index a27b711..577593f 100644 --- a/src/tasks/dto/update-task.dto.ts +++ b/src/tasks/dto/update-task.dto.ts @@ -1,33 +1,9 @@ -import { IsString, IsOptional, IsEnum, IsDateString, IsUUID, IsArray } from 'class-validator'; -import { TaskStatus, TaskPriority } from '@prisma/client'; - -export class UpdateTaskDto { - @IsOptional() - @IsString() - title?: string; - - @IsOptional() - @IsString() - description?: string; - - @IsOptional() - @IsEnum(TaskStatus) - status?: TaskStatus; - - @IsOptional() - @IsEnum(TaskPriority) - priority?: TaskPriority; - - @IsOptional() - @IsDateString() - dueDate?: string; +import { PartialType } from '@nestjs/swagger'; +import { IsOptional, IsUUID } from 'class-validator'; +import { CreateTaskDto } from './create-task.dto'; +export class UpdateTaskDto extends PartialType(CreateTaskDto) { @IsOptional() @IsUUID() assigneeId?: string | null; - - @IsOptional() - @IsArray() - @IsUUID('4', { each: true }) - tagIds?: string[]; } diff --git a/src/tasks/task.events.ts b/src/tasks/task.events.ts new file mode 100644 index 0000000..180afab --- /dev/null +++ b/src/tasks/task.events.ts @@ -0,0 +1,23 @@ +import { Prisma, Tag, Task } from '@prisma/client'; +import { UpdateTaskDto } from './dto/update-task.dto'; + +export const TASK_CREATED = 'task.created'; +export const TASK_UPDATED = 'task.updated'; + +export type TxClient = Prisma.TransactionClient; + +type TaskWithTags = Task & { tags: Tag[] }; + +export interface TaskCreatedEvent { + tx: TxClient; + userId: string; + task: TaskWithTags; +} + +export interface TaskUpdatedEvent { + tx: TxClient; + userId: string; + before: TaskWithTags; + after: TaskWithTags; + dto: UpdateTaskDto; +} diff --git a/src/tasks/tasks.controller.ts b/src/tasks/tasks.controller.ts index 45b9d3f..19f158e 100644 --- a/src/tasks/tasks.controller.ts +++ b/src/tasks/tasks.controller.ts @@ -1,9 +1,23 @@ -import { Controller, Get, Post, Put, Delete, Param, Body, Query } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiSecurity, ApiTags } from '@nestjs/swagger'; import { TasksService } from './tasks.service'; import { CreateTaskDto } from './dto/create-task.dto'; import { UpdateTaskDto } from './dto/update-task.dto'; import { TaskFilterDto } from './dto/task-filter.dto'; +import { TaskActivityFilterDto } from '../activities/dto/activity-filter.dto'; +import { UserRequiredGuard } from '../common/guards/user-required.guard'; +@ApiTags('tasks') @Controller('tasks') export class TasksController { constructor(private readonly tasksService: TasksService) {} @@ -18,17 +32,31 @@ export class TasksController { return this.tasksService.findOne(id); } + @Get(':id/activities') + findActivities( + @Param('id') id: string, + @Query() filter: TaskActivityFilterDto, + ) { + return this.tasksService.findActivities(id, filter); + } + @Post() + @UseGuards(UserRequiredGuard) + @ApiSecurity('user-id') create(@Body() createTaskDto: CreateTaskDto) { return this.tasksService.create(createTaskDto); } @Put(':id') + @UseGuards(UserRequiredGuard) + @ApiSecurity('user-id') update(@Param('id') id: string, @Body() updateTaskDto: UpdateTaskDto) { return this.tasksService.update(id, updateTaskDto); } @Delete(':id') + @UseGuards(UserRequiredGuard) + @ApiSecurity('user-id') remove(@Param('id') id: string) { return this.tasksService.remove(id); } diff --git a/src/tasks/tasks.module.ts b/src/tasks/tasks.module.ts index 3971392..418c7f2 100644 --- a/src/tasks/tasks.module.ts +++ b/src/tasks/tasks.module.ts @@ -1,11 +1,13 @@ import { Module } from '@nestjs/common'; import { TasksController } from './tasks.controller'; import { TasksService } from './tasks.service'; +import { TasksRepository } from './tasks.repository'; import { EmailModule } from '../email/email.module'; +import { ActivitiesModule } from '../activities/activities.module'; @Module({ - imports: [EmailModule], + imports: [EmailModule, ActivitiesModule], controllers: [TasksController], - providers: [TasksService], + providers: [TasksService, TasksRepository], }) export class TasksModule {} diff --git a/src/tasks/tasks.repository.ts b/src/tasks/tasks.repository.ts new file mode 100644 index 0000000..7dc7ffc --- /dev/null +++ b/src/tasks/tasks.repository.ts @@ -0,0 +1,106 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateTaskDto } from './dto/create-task.dto'; +import { UpdateTaskDto } from './dto/update-task.dto'; +import { TaskFilterDto } from './dto/task-filter.dto'; + +type TxOrBase = PrismaService | Prisma.TransactionClient; + +export const TASK_INCLUDE = { + assignee: true, + project: true, + tags: true, +} satisfies Prisma.TaskInclude; + +export type TaskWithRelations = Prisma.TaskGetPayload<{ include: typeof TASK_INCLUDE }>; + +const buildWhere = (filter: TaskFilterDto): Prisma.TaskWhereInput => { + const { status, priority, assigneeId, projectId, dueDateFrom, dueDateTo } = filter; + return { + status, + priority, + assigneeId, + projectId, + dueDate: dueDateFrom || dueDateTo ? { + gte: dueDateFrom ? new Date(dueDateFrom) : undefined, + lte: dueDateTo ? new Date(dueDateTo) : undefined, + } : undefined, + }; +}; + +const buildCreateData = (dto: CreateTaskDto): Prisma.TaskCreateInput => ({ + title: dto.title, + description: dto.description, + status: dto.status, + priority: dto.priority, + dueDate: dto.dueDate, + project: { connect: { id: dto.projectId } }, + assignee: dto.assigneeId ? { connect: { id: dto.assigneeId } } : undefined, + tags: dto.tagIds?.length + ? { connect: dto.tagIds.map((id) => ({ id })) } + : undefined, +}); + +const buildUpdateData = (dto: UpdateTaskDto): Prisma.TaskUpdateInput => ({ + title: dto.title, + description: dto.description, + status: dto.status, + priority: dto.priority, + dueDate: dto.dueDate, + assignee: dto.assigneeId !== undefined + ? dto.assigneeId + ? { connect: { id: dto.assigneeId } } + : { disconnect: true } + : undefined, + tags: dto.tagIds !== undefined + ? { set: dto.tagIds.map((id) => ({ id })) } + : undefined, +}); + +@Injectable() +export class TasksRepository { + constructor(private readonly prisma: PrismaService) {} + + findAllPaginated(filter: TaskFilterDto) { + const { page, perPage } = filter; + const where = buildWhere(filter); + + return this.prisma.$transaction([ + this.prisma.task.findMany({ + where, + include: TASK_INCLUDE, + skip: (page - 1) * perPage, + take: perPage, + orderBy: { createdAt: 'desc' }, + }), + this.prisma.task.count({ where }), + ]); + } + + findById(id: string): Promise { + return this.prisma.task.findUnique({ + where: { id }, + include: TASK_INCLUDE, + }); + } + + create(dto: CreateTaskDto, client: TxOrBase = this.prisma): Promise { + return client.task.create({ + data: buildCreateData(dto), + include: TASK_INCLUDE, + }); + } + + update(id: string, dto: UpdateTaskDto, client: TxOrBase = this.prisma): Promise { + return client.task.update({ + where: { id }, + data: buildUpdateData(dto), + include: TASK_INCLUDE, + }); + } + + delete(id: string, client: TxOrBase = this.prisma): Promise { + return client.task.delete({ where: { id } }); + } +} diff --git a/src/tasks/tasks.service.ts b/src/tasks/tasks.service.ts index 7cc58c4..9292e47 100644 --- a/src/tasks/tasks.service.ts +++ b/src/tasks/tasks.service.ts @@ -1,180 +1,121 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { ClsService } from 'nestjs-cls'; import { PrismaService } from '../prisma/prisma.service'; import { EmailService } from '../email/email.service'; import { CreateTaskDto } from './dto/create-task.dto'; import { UpdateTaskDto } from './dto/update-task.dto'; import { TaskFilterDto } from './dto/task-filter.dto'; -import { Task, Prisma } from '@prisma/client'; +import { TasksRepository, TaskWithRelations } from './tasks.repository'; +import { toTaskResponse, TaskResponse } from './dto/task-response.dto'; +import { paginated, Paginated } from '../common/dto/paginated'; +import { CLS_USER_ID_KEY } from '../common/constants'; +import { ActivitiesService } from '../activities/activities.service'; +import { TaskActivityFilterDto } from '../activities/dto/activity-filter.dto'; +import { + TASK_CREATED, + TASK_UPDATED, + TaskCreatedEvent, + TaskUpdatedEvent, +} from './task.events'; @Injectable() export class TasksService { + private readonly logger = new Logger(TasksService.name); + constructor( - private prisma: PrismaService, - private emailService: EmailService, + private readonly prisma: PrismaService, + private readonly tasksRepository: TasksRepository, + private readonly emailService: EmailService, + private readonly activitiesService: ActivitiesService, + private readonly events: EventEmitter2, + private readonly cls: ClsService, ) {} - async findAll(filterDto: TaskFilterDto) { - const tasks = await this.prisma.task.findMany(); - - const tasksWithRelations = await Promise.all( - tasks.map(async (task) => { - const assignee = task.assigneeId - ? await this.prisma.user.findUnique({ where: { id: task.assigneeId } }) - : null; - - const project = await this.prisma.project.findUnique({ - where: { id: task.projectId } - }); - - const tags = await this.prisma.tag.findMany({ - where: { - tasks: { - some: { id: task.id } - } - } - }); - - return { - ...task, - assignee, - project, - tags, - }; - }) - ); - - let filteredTasks = tasksWithRelations; - - if (filterDto.status) { - filteredTasks = filteredTasks.filter(task => task.status === filterDto.status); - } - - if (filterDto.priority) { - filteredTasks = filteredTasks.filter(task => task.priority === filterDto.priority); - } - - if (filterDto.assigneeId) { - filteredTasks = filteredTasks.filter(task => task.assigneeId === filterDto.assigneeId); - } - - if (filterDto.projectId) { - filteredTasks = filteredTasks.filter(task => task.projectId === filterDto.projectId); - } - - if (filterDto.dueDateFrom || filterDto.dueDateTo) { - filteredTasks = filteredTasks.filter(task => { - if (!task.dueDate) return false; - const dueDate = new Date(task.dueDate); - - if (filterDto.dueDateFrom && dueDate < new Date(filterDto.dueDateFrom)) { - return false; - } - - if (filterDto.dueDateTo && dueDate > new Date(filterDto.dueDateTo)) { - return false; - } - - return true; - }); + private currentUserId(): string { + const userId = this.cls.get(CLS_USER_ID_KEY); + if (!userId) { + throw new Error('currentUserId() called without a user in CLS — guard invariant broken'); } + return userId; + } - return filteredTasks; + async findAll(filter: TaskFilterDto): Promise> { + const [rows, total] = await this.tasksRepository.findAllPaginated(filter); + return paginated(rows.map(toTaskResponse), total, filter.page, filter.perPage); } - async findOne(id: string) { - const task = await this.prisma.task.findUnique({ - where: { id }, - include: { - assignee: true, - project: true, - tags: true, - }, - }); + async findOne(id: string): Promise { + return toTaskResponse(await this.loadOrThrow(id)); + } + private async loadOrThrow(id: string): Promise { + const task = await this.tasksRepository.findById(id); if (!task) { throw new NotFoundException(`Task with ID ${id} not found`); } - return task; } - async create(createTaskDto: CreateTaskDto) { - const task = await this.prisma.task.create({ - data: { - title: createTaskDto.title, - description: createTaskDto.description, - status: createTaskDto.status, - priority: createTaskDto.priority, - dueDate: createTaskDto.dueDate, - project: { connect: { id: createTaskDto.projectId } }, - assignee: createTaskDto.assigneeId - ? { connect: { id: createTaskDto.assigneeId } } - : undefined, - tags: createTaskDto.tagIds - ? { connect: createTaskDto.tagIds.map(id => ({ id })) } - : undefined, - }, - include: { - assignee: true, - project: true, - tags: true, - }, + async findActivities(taskId: string, filter: TaskActivityFilterDto) { + await this.loadOrThrow(taskId); + return this.activitiesService.findByTask(taskId, filter); + } + + async create(dto: CreateTaskDto): Promise { + const userId = this.currentUserId(); + + const task = await this.prisma.$transaction(async (tx) => { + const created = await this.tasksRepository.create(dto, tx); + const event: TaskCreatedEvent = { tx, userId, task: created }; + await this.events.emitAsync(TASK_CREATED, event); + return created; }); if (task.assignee) { - await this.emailService.sendTaskAssignmentNotification( - task.assignee.email, - task.title - ); + this.notifyAssignee(task.assignee.email, task.title); } - return task; + return toTaskResponse(task); } - async update(id: string, updateTaskDto: UpdateTaskDto) { - const existingTask = await this.findOne(id); - - const task = await this.prisma.task.update({ - where: { id }, - data: { - title: updateTaskDto.title, - description: updateTaskDto.description, - status: updateTaskDto.status, - priority: updateTaskDto.priority, - dueDate: updateTaskDto.dueDate, - assignee: updateTaskDto.assigneeId !== undefined - ? updateTaskDto.assigneeId - ? { connect: { id: updateTaskDto.assigneeId } } - : { disconnect: true } - : undefined, - tags: updateTaskDto.tagIds - ? { set: updateTaskDto.tagIds.map(id => ({ id })) } - : undefined, - }, - include: { - assignee: true, - project: true, - tags: true, - }, + async update(id: string, dto: UpdateTaskDto): Promise { + const userId = this.currentUserId(); + const existingTask = await this.loadOrThrow(id); + + const task = await this.prisma.$transaction(async (tx) => { + const updated = await this.tasksRepository.update(id, dto, tx); + + await this.events.emitAsync(TASK_UPDATED, { + tx, + userId, + before: existingTask, + after: updated, + dto, + } as TaskUpdatedEvent); + + return updated; }); - if (updateTaskDto.assigneeId && updateTaskDto.assigneeId !== existingTask.assigneeId) { - await this.emailService.sendTaskAssignmentNotification( - task.assignee!.email, - task.title - ); + if (dto.assigneeId && dto.assigneeId !== existingTask.assigneeId) { + this.notifyAssignee(task.assignee!.email, task.title); } - return task; + return toTaskResponse(task); } async remove(id: string) { - await this.findOne(id); - - await this.prisma.task.delete({ - where: { id }, - }); - + await this.loadOrThrow(id); + await this.tasksRepository.delete(id); return { message: 'Task deleted successfully' }; } + + private notifyAssignee(email: string, title: string): void { + this.emailService + .sendTaskAssignmentNotification(email, title) + .catch((err: unknown) => { + const reason = err instanceof Error ? err.message : String(err); + this.logger.error(`Failed to send task assignment notification to ${email}: ${reason}`); + }); + } } diff --git a/test/e2e/activities.e2e-spec.ts b/test/e2e/activities.e2e-spec.ts new file mode 100644 index 0000000..eff9965 --- /dev/null +++ b/test/e2e/activities.e2e-spec.ts @@ -0,0 +1,173 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../../src/app.module'; +import { PrismaService } from '../../src/prisma/prisma.service'; + +describe('ActivitiesController (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + let userId: string; + let projectId: string; + let seededTaskId: string; + + // Baseline set of activities produced by the test: + // 1. CREATED (title "activities e2e seed") + // 2. UPDATED (title → "activities e2e seed renamed") + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + await app.init(); + + prisma = app.get(PrismaService); + const user = await prisma.user.findFirst(); + const project = await prisma.project.findFirst(); + if (!user || !project) { + throw new Error('Run `npm run seed` before running e2e tests.'); + } + userId = user.id; + projectId = project.id; + + const createRes = await request(app.getHttpServer()) + .post('/tasks') + .set('X-User-Id', userId) + .send({ title: 'activities e2e seed', projectId }) + .expect(201); + seededTaskId = createRes.body.id; + + await request(app.getHttpServer()) + .put(`/tasks/${seededTaskId}`) + .set('X-User-Id', userId) + .send({ title: 'activities e2e seed renamed' }) + .expect(200); + }); + + afterAll(async () => { + // Cascade wipes the 2 activities + any spill from earlier test failures. + if (seededTaskId) { + await request(app.getHttpServer()) + .delete(`/tasks/${seededTaskId}`) + .set('X-User-Id', userId); + } + await app.close(); + }); + + describe('GET /activities', () => { + it('returns the paginated envelope per spec ({ data, meta })', async () => { + const res = await request(app.getHttpServer()).get('/activities').expect(200); + expect(res.body).toHaveProperty('data'); + expect(res.body).toHaveProperty('meta'); + expect(res.body.meta).toMatchObject({ page: 1, perPage: 20 }); + expect(res.body.meta.total).toBeGreaterThanOrEqual(2); + }); + + it('honors page and perPage', async () => { + const res = await request(app.getHttpServer()) + .get('/activities?page=1&perPage=1') + .expect(200); + expect(res.body.meta.perPage).toBe(1); + expect(res.body.data.length).toBeLessThanOrEqual(1); + }); + + it('orders results by createdAt desc by default', async () => { + const res = await request(app.getHttpServer()) + .get('/activities?perPage=50') + .expect(200); + const times = res.body.data.map((a: any) => new Date(a.createdAt).getTime()); + for (let i = 1; i < times.length; i++) { + expect(times[i - 1]).toBeGreaterThanOrEqual(times[i]); + } + }); + + it('filters by userId', async () => { + const res = await request(app.getHttpServer()) + .get(`/activities?userId=${userId}&perPage=100`) + .expect(200); + expect(res.body.data.length).toBeGreaterThanOrEqual(2); + expect(res.body.data.every((a: any) => a.userId === userId)).toBe(true); + }); + + it('filters by action', async () => { + const res = await request(app.getHttpServer()) + .get('/activities?action=UPDATED&perPage=100') + .expect(200); + expect(res.body.data.every((a: any) => a.action === 'updated')).toBe(true); + }); + + it('filters by date range (past dateTo returns nothing)', async () => { + const res = await request(app.getHttpServer()) + .get('/activities?dateTo=2020-01-01T00:00:00Z') + .expect(200); + expect(res.body.data).toEqual([]); + expect(res.body.meta.total).toBe(0); + }); + + it('combines filters (userId + action)', async () => { + const res = await request(app.getHttpServer()) + .get(`/activities?userId=${userId}&action=CREATED&perPage=100`) + .expect(200); + expect( + res.body.data.every((a: any) => a.userId === userId && a.action === 'created'), + ).toBe(true); + }); + + it('rejects an invalid action enum with 400', async () => { + await request(app.getHttpServer()) + .get('/activities?action=BOGUS') + .expect(400); + }); + + it('rejects a malformed userId UUID with 400', async () => { + await request(app.getHttpServer()) + .get('/activities?userId=not-a-uuid') + .expect(400); + }); + + it('rejects a non-integer page with 400', async () => { + await request(app.getHttpServer()) + .get('/activities?page=abc') + .expect(400); + }); + + it('rejects a malformed dateFrom with 400', async () => { + await request(app.getHttpServer()) + .get('/activities?dateFrom=not-a-date') + .expect(400); + }); + }); + + describe('GET /tasks/:id/activities', () => { + it('returns all activities for the seeded task in createdAt desc order', async () => { + const res = await request(app.getHttpServer()) + .get(`/tasks/${seededTaskId}/activities`) + .expect(200); + expect(res.body.data).toHaveLength(2); + expect(res.body.data[0].action).toBe('updated'); + expect(res.body.data[1].action).toBe('created'); + }); + + it('every returned activity references the same taskId', async () => { + const res = await request(app.getHttpServer()) + .get(`/tasks/${seededTaskId}/activities`) + .expect(200); + expect(res.body.data.every((a: any) => a.taskId === seededTaskId)).toBe(true); + }); + + it('exposes userName via the User join', async () => { + const res = await request(app.getHttpServer()) + .get(`/tasks/${seededTaskId}/activities`) + .expect(200); + expect(res.body.data[0].userName).toEqual(expect.any(String)); + }); + + it('returns 404 when the task does not exist', async () => { + await request(app.getHttpServer()) + .get('/tasks/00000000-0000-4000-8000-000000000000/activities') + .expect(404); + }); + }); +}); diff --git a/test/e2e/email.e2e-spec.ts b/test/e2e/email.e2e-spec.ts new file mode 100644 index 0000000..d3aba25 --- /dev/null +++ b/test/e2e/email.e2e-spec.ts @@ -0,0 +1,137 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import * as request from 'supertest'; +import * as http from 'http'; +import { AppModule } from '../../src/app.module'; +import { PrismaService } from '../../src/prisma/prisma.service'; + +const MAILPIT_API = 'http://localhost:8025/api/v1'; + +function mailpit(method: 'GET' | 'DELETE', path: string): Promise { + return new Promise((resolve, reject) => { + const url = new URL(`${MAILPIT_API}${path}`); + const req = http.request( + { method, hostname: url.hostname, port: url.port, path: url.pathname + url.search }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => { + const body = Buffer.concat(chunks).toString(); + if (!body) return resolve({}); + try { + resolve(JSON.parse(body)); + } catch { + resolve(body); + } + }); + }, + ); + req.on('error', reject); + req.end(); + }); +} + +async function waitForMessage(predicate: (m: any) => boolean, timeoutMs = 3000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const res = await mailpit('GET', '/messages?limit=50'); + const match = (res.messages ?? []).find(predicate); + if (match) return match; + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error('message not found in mailpit within timeout'); +} + +describe('Email delivery (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + let userId: string; + let projectId: string; + let assigneeEmail: string; + let createdTaskIds: string[] = []; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + await app.init(); + + prisma = app.get(PrismaService); + const users = await prisma.user.findMany({ take: 2 }); + const project = await prisma.project.findFirst(); + if (users.length < 2 || !project) { + throw new Error('Need at least 2 users and 1 project seeded. Run `npm run seed`.'); + } + userId = users[0].id; + assigneeEmail = users[1].email; + projectId = project.id; + + await mailpit('DELETE', '/messages'); + }); + + afterAll(async () => { + for (const id of createdTaskIds) { + await request(app.getHttpServer()).delete(`/tasks/${id}`).set('X-User-Id', userId); + } + await app.close(); + }); + + it('delivers an assignment email when a task is created with an assignee', async () => { + const res = await request(app.getHttpServer()) + .post('/tasks') + .set('X-User-Id', userId) + .send({ title: 'email test create', projectId, assigneeId: (await prisma.user.findMany({ take: 2 }))[1].id }) + .expect(201); + createdTaskIds.push(res.body.id); + + const msg = await waitForMessage( + (m) => m.To?.some((t: any) => t.Address === assigneeEmail) && m.Subject.includes('assigned'), + ); + expect(msg.Subject).toContain('assigned'); + }); + + it('does not send an email when a task is created without an assignee', async () => { + await mailpit('DELETE', '/messages'); + + const res = await request(app.getHttpServer()) + .post('/tasks') + .set('X-User-Id', userId) + .send({ title: 'email test no assignee', projectId }) + .expect(201); + createdTaskIds.push(res.body.id); + + // Give the fire-and-forget path time to reach the mailer if it were going to. + await new Promise((r) => setTimeout(r, 300)); + + const inbox = await mailpit('GET', '/messages?limit=50'); + expect(inbox.messages ?? []).toEqual([]); + }); + + it('delivers an email when the assignee changes on update', async () => { + await mailpit('DELETE', '/messages'); + const users = await prisma.user.findMany({ take: 2 }); + + const created = await request(app.getHttpServer()) + .post('/tasks') + .set('X-User-Id', userId) + .send({ title: 'email test update', projectId, assigneeId: users[0].id }) + .expect(201); + createdTaskIds.push(created.body.id); + + await mailpit('DELETE', '/messages'); + + await request(app.getHttpServer()) + .put(`/tasks/${created.body.id}`) + .set('X-User-Id', userId) + .send({ assigneeId: users[1].id }) + .expect(200); + + const msg = await waitForMessage( + (m) => m.To?.some((t: any) => t.Address === users[1].email), + ); + expect(msg).toBeDefined(); + }); +}); diff --git a/test/e2e/projects.e2e-spec.ts b/test/e2e/projects.e2e-spec.ts new file mode 100644 index 0000000..367ecfa --- /dev/null +++ b/test/e2e/projects.e2e-spec.ts @@ -0,0 +1,48 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../../src/app.module'; + +describe('ProjectsController (e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('GET /projects', () => { + it('returns a non-empty array of projects', async () => { + const res = await request(app.getHttpServer()).get('/projects').expect(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body.length).toBeGreaterThan(0); + }); + + it('each project exposes id, name, createdAt and updatedAt', async () => { + const res = await request(app.getHttpServer()).get('/projects').expect(200); + for (const project of res.body) { + expect(project).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), + }), + ); + } + }); + + it('does not require X-User-Id (read-only endpoint)', async () => { + await request(app.getHttpServer()).get('/projects').expect(200); + }); + }); +}); diff --git a/test/e2e/tasks.e2e-spec.ts b/test/e2e/tasks.e2e-spec.ts new file mode 100644 index 0000000..21d5571 --- /dev/null +++ b/test/e2e/tasks.e2e-spec.ts @@ -0,0 +1,187 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../../src/app.module'; +import { PrismaService } from '../../src/prisma/prisma.service'; +import { ActivityListener } from '../../src/activities/activity.listener'; + +describe('TasksController (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + let userId: string; + let projectId: string; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + await app.init(); + + prisma = app.get(PrismaService); + + const user = await prisma.user.findFirst(); + const project = await prisma.project.findFirst(); + if (!user || !project) { + throw new Error('Run `npm run seed` before running e2e tests.'); + } + userId = user.id; + projectId = project.id; + }); + + afterAll(async () => { + await app.close(); + }); + + describe('GET /tasks', () => { + it('returns a paginated envelope with meta', async () => { + const res = await request(app.getHttpServer()).get('/tasks').expect(200); + + expect(res.body).toHaveProperty('data'); + expect(res.body).toHaveProperty('meta'); + expect(Array.isArray(res.body.data)).toBe(true); + expect(res.body.meta).toMatchObject({ page: 1, perPage: 20 }); + expect(res.body.meta.total).toBeGreaterThanOrEqual(0); + + res.body.data.forEach((t: any) => { + expect(t).toHaveProperty('assignee'); + expect(t).toHaveProperty('project'); + expect(t).toHaveProperty('tags'); + }); + }); + + it('honors page and perPage query params', async () => { + const res = await request(app.getHttpServer()) + .get('/tasks?page=1&perPage=5') + .expect(200); + + expect(res.body.meta.perPage).toBe(5); + expect(res.body.data.length).toBeLessThanOrEqual(5); + }); + + it('filters by status and returns only matching rows', async () => { + const res = await request(app.getHttpServer()) + .get('/tasks?status=TODO&perPage=100') + .expect(200); + + expect(res.body.data.every((t: any) => t.status === 'TODO')).toBe(true); + }); + + it('rejects an invalid status enum with 400', async () => { + await request(app.getHttpServer()) + .get('/tasks?status=NOPE') + .expect(400); + }); + }); + + describe('Auth', () => { + it('rejects mutations without X-User-Id header with 401', async () => { + await request(app.getHttpServer()) + .post('/tasks') + .send({ title: 'no user', projectId }) + .expect(401); + }); + + it('rejects mutations with a malformed X-User-Id with 401', async () => { + await request(app.getHttpServer()) + .post('/tasks') + .set('X-User-Id', 'not-a-uuid') + .send({ title: 'bad user', projectId }) + .expect(401); + }); + }); + + describe('Activity log', () => { + it('emits CREATED / UPDATED activities and cascades deletion', async () => { + const createRes = await request(app.getHttpServer()) + .post('/tasks') + .set('X-User-Id', userId) + .send({ + title: 'Activity log smoke test', + description: 'original', + projectId, + }) + .expect(201); + + const taskId = createRes.body.id; + + await request(app.getHttpServer()) + .put(`/tasks/${taskId}`) + .set('X-User-Id', userId) + .send({ title: 'renamed', status: 'IN_PROGRESS' }) + .expect(200); + + const taskActivitiesRes = await request(app.getHttpServer()) + .get(`/tasks/${taskId}/activities`) + .expect(200); + + expect(taskActivitiesRes.body).toHaveProperty('data'); + expect(taskActivitiesRes.body).toHaveProperty('meta'); + expect(taskActivitiesRes.body.data).toHaveLength(2); + + const [updated, created] = taskActivitiesRes.body.data; + expect(created.action).toBe('created'); + expect(updated.action).toBe('updated'); + expect(updated.changes).toMatchObject({ + title: { old: 'Activity log smoke test', new: 'renamed' }, + status: { old: 'TODO', new: 'IN_PROGRESS' }, + }); + expect(updated.userName).toBeDefined(); + + await request(app.getHttpServer()) + .delete(`/tasks/${taskId}`) + .set('X-User-Id', userId) + .expect(200); + + const orphanCount = await prisma.activity.count({ where: { taskId } }); + expect(orphanCount).toBe(0); + }); + + it('returns 404 on /tasks/:unknown/activities', async () => { + await request(app.getHttpServer()) + .get('/tasks/00000000-0000-4000-8000-000000000000/activities') + .expect(404); + }); + + it('filters /activities by action', async () => { + const res = await request(app.getHttpServer()) + .get('/activities?action=CREATED&perPage=5') + .expect(200); + + expect(res.body.data.every((a: any) => a.action === 'created')).toBe(true); + }); + + it('filters /activities by date range', async () => { + const far = '2020-01-01T00:00:00Z'; + const res = await request(app.getHttpServer()) + .get(`/activities?dateTo=${far}`) + .expect(200); + + expect(res.body.data).toEqual([]); + }); + }); + + describe('Transaction atomicity (rollback)', () => { + it('rolls back the task mutation when the activity listener throws', async () => { + const listener = app.get(ActivityListener); + const spy = jest + .spyOn(listener, 'onTaskCreated') + .mockRejectedValueOnce(new Error('boom')); + + const uniqueTitle = `rollback-probe-${Date.now()}`; + + await request(app.getHttpServer()) + .post('/tasks') + .set('X-User-Id', userId) + .send({ title: uniqueTitle, projectId }) + .expect(500); + + const persisted = await prisma.task.findFirst({ where: { title: uniqueTitle } }); + expect(persisted).toBeNull(); + + spy.mockRestore(); + }); + }); +}); diff --git a/test/e2e/users.e2e-spec.ts b/test/e2e/users.e2e-spec.ts new file mode 100644 index 0000000..e0127ab --- /dev/null +++ b/test/e2e/users.e2e-spec.ts @@ -0,0 +1,47 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../../src/app.module'; + +describe('UsersController (e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('GET /users', () => { + it('returns a non-empty array of users', async () => { + const res = await request(app.getHttpServer()).get('/users').expect(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body.length).toBeGreaterThan(0); + }); + + it('each user exposes id, email and name', async () => { + const res = await request(app.getHttpServer()).get('/users').expect(200); + for (const user of res.body) { + expect(user).toEqual( + expect.objectContaining({ + id: expect.any(String), + email: expect.any(String), + name: expect.any(String), + }), + ); + } + }); + + it('does not require X-User-Id (read-only endpoint)', async () => { + await request(app.getHttpServer()).get('/users').expect(200); + }); + }); +}); diff --git a/test/jest-e2e.json b/test/jest.e2e.json similarity index 66% rename from test/jest-e2e.json rename to test/jest.e2e.json index e9d912f..690a6d1 100644 --- a/test/jest-e2e.json +++ b/test/jest.e2e.json @@ -1,8 +1,8 @@ { "moduleFileExtensions": ["js", "json", "ts"], - "rootDir": ".", + "rootDir": "..", "testEnvironment": "node", - "testRegex": ".e2e-spec.ts$", + "testRegex": "test/e2e/.*\\.e2e-spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" } diff --git a/test/jest.unit.json b/test/jest.unit.json new file mode 100644 index 0000000..c2b3bb1 --- /dev/null +++ b/test/jest.unit.json @@ -0,0 +1,11 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": "..", + "testEnvironment": "node", + "testRegex": "test/unit/.*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": ["src/**/*.(t|j)s"], + "coverageDirectory": "/coverage" +} diff --git a/test/tasks.e2e-spec.ts b/test/tasks.e2e-spec.ts deleted file mode 100644 index 7221c07..0000000 --- a/test/tasks.e2e-spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication, ValidationPipe } from '@nestjs/common'; -import * as request from 'supertest'; -import { AppModule } from '../src/app.module'; - -describe('TasksController (e2e)', () => { - let app: INestApplication; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = moduleFixture.createNestApplication(); - app.useGlobalPipes(new ValidationPipe({ - whitelist: true, - transform: true, - })); - await app.init(); - }); - - afterEach(async () => { - await app.close(); - }); - - it('/tasks', async () => { - const response = await request(app.getHttpServer()) - .get('/tasks') - .expect(200); - - expect(response.body).toBeInstanceOf(Array); - expect(response.body.length).toBeGreaterThan(0); - - response.body.forEach(task => { - expect(task).toHaveProperty('assignee'); - expect(task).toHaveProperty('project'); - expect(task).toHaveProperty('tags'); - }); - }); - -}); diff --git a/test/unit/activities/activities.service.spec.ts b/test/unit/activities/activities.service.spec.ts new file mode 100644 index 0000000..642ede3 --- /dev/null +++ b/test/unit/activities/activities.service.spec.ts @@ -0,0 +1,133 @@ +import { ActivityAction } from '@prisma/client'; +import { ActivitiesService } from '../../../src/activities/activities.service'; +import { PrismaService } from '../../../src/prisma/prisma.service'; + +const sample = (overrides: any = {}) => ({ + id: 'a1', + taskId: 't1', + taskTitle: 'Title', + userId: 'u1', + action: ActivityAction.CREATED, + changes: {}, + createdAt: new Date('2026-04-23T00:00:00Z'), + user: { id: 'u1', name: 'Alice' }, + ...overrides, +}); + +describe('ActivitiesService', () => { + let prisma: any; + let service: ActivitiesService; + + beforeEach(() => { + prisma = { + activity: { + findMany: jest.fn(), + count: jest.fn(), + }, + $transaction: jest.fn((ops: Promise[]) => Promise.all(ops)), + }; + service = new ActivitiesService(prisma as unknown as PrismaService); + }); + + describe('findAll', () => { + it('builds an empty where clause and returns mapped paginated output', async () => { + prisma.activity.findMany.mockResolvedValue([sample()]); + prisma.activity.count.mockResolvedValue(1); + + const result = await service.findAll({ page: 1, perPage: 20 } as any); + + expect(prisma.activity.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + userId: undefined, + action: undefined, + createdAt: undefined, + }), + skip: 0, + take: 20, + orderBy: { createdAt: 'desc' }, + }), + ); + expect(result.data[0].userName).toBe('Alice'); + expect(result.data[0].action).toBe('created'); + expect(result.meta).toEqual({ total: 1, page: 1, perPage: 20 }); + }); + + it('pushes userId and action filters into the where clause', async () => { + prisma.activity.findMany.mockResolvedValue([]); + prisma.activity.count.mockResolvedValue(0); + + await service.findAll({ + page: 1, + perPage: 10, + userId: 'u-filter', + action: ActivityAction.UPDATED, + } as any); + + expect(prisma.activity.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + userId: 'u-filter', + action: ActivityAction.UPDATED, + }), + }), + ); + }); + + it('builds the createdAt range when dateFrom/dateTo provided', async () => { + prisma.activity.findMany.mockResolvedValue([]); + prisma.activity.count.mockResolvedValue(0); + + await service.findAll({ + page: 1, + perPage: 10, + dateFrom: '2026-04-01T00:00:00Z', + dateTo: '2026-04-30T00:00:00Z', + } as any); + + const args = prisma.activity.findMany.mock.calls[0][0]; + expect(args.where.createdAt.gte).toEqual(new Date('2026-04-01T00:00:00Z')); + expect(args.where.createdAt.lte).toEqual(new Date('2026-04-30T00:00:00Z')); + }); + + it('honors only dateFrom when dateTo is missing', async () => { + prisma.activity.findMany.mockResolvedValue([]); + prisma.activity.count.mockResolvedValue(0); + + await service.findAll({ + page: 1, + perPage: 10, + dateFrom: '2026-04-01T00:00:00Z', + } as any); + + const args = prisma.activity.findMany.mock.calls[0][0]; + expect(args.where.createdAt.gte).toEqual(new Date('2026-04-01T00:00:00Z')); + expect(args.where.createdAt.lte).toBeUndefined(); + }); + + it('computes skip from page/perPage', async () => { + prisma.activity.findMany.mockResolvedValue([]); + prisma.activity.count.mockResolvedValue(0); + + await service.findAll({ page: 3, perPage: 25 } as any); + const args = prisma.activity.findMany.mock.calls[0][0]; + expect(args.skip).toBe(50); + expect(args.take).toBe(25); + }); + }); + + describe('findByTask', () => { + it('filters by taskId and returns paginated', async () => { + prisma.activity.findMany.mockResolvedValue([sample({ taskId: 't-target' })]); + prisma.activity.count.mockResolvedValue(1); + + const result = await service.findByTask('t-target', { page: 1, perPage: 20 } as any); + + expect(prisma.activity.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { taskId: 't-target' } }), + ); + expect(result.data).toHaveLength(1); + expect(result.meta).toEqual({ total: 1, page: 1, perPage: 20 }); + }); + }); +}); diff --git a/test/unit/activities/activity-diff.spec.ts b/test/unit/activities/activity-diff.spec.ts new file mode 100644 index 0000000..6be3a25 --- /dev/null +++ b/test/unit/activities/activity-diff.spec.ts @@ -0,0 +1,107 @@ +import { Task, TaskPriority, TaskStatus } from '@prisma/client'; +import { buildCreatedChanges, diffTask } from '../../../src/activities/activity-diff'; + +const baseTask: Task = { + id: 'task-1', + title: 'Original', + description: 'old desc', + status: TaskStatus.TODO, + priority: TaskPriority.MEDIUM, + dueDate: new Date('2026-05-01T00:00:00Z'), + createdAt: new Date('2026-04-22T00:00:00Z'), + updatedAt: new Date('2026-04-22T00:00:00Z'), + projectId: 'project-1', + assigneeId: 'user-1', +}; + +describe('diffTask', () => { + it('returns an empty object when nothing changed', () => { + const changes = diffTask(baseTask, {}); + expect(changes).toEqual({}); + }); + + it('ignores fields that are undefined in the update DTO', () => { + const changes = diffTask(baseTask, { title: undefined }); + expect(changes).toEqual({}); + }); + + it('records changed scalar fields with old/new values', () => { + const changes = diffTask(baseTask, { + title: 'New', + status: TaskStatus.IN_PROGRESS, + }); + expect(changes).toEqual({ + title: { old: 'Original', new: 'New' }, + status: { old: 'TODO', new: 'IN_PROGRESS' }, + }); + }); + + it('treats equal dates as unchanged regardless of reference', () => { + const changes = diffTask(baseTask, { + dueDate: new Date('2026-05-01T00:00:00Z'), + }); + expect(changes).toEqual({}); + }); + + it('records date changes as ISO strings', () => { + const changes = diffTask(baseTask, { + dueDate: new Date('2026-06-01T00:00:00Z'), + }); + expect(changes).toEqual({ + dueDate: { + old: '2026-05-01T00:00:00.000Z', + new: '2026-06-01T00:00:00.000Z', + }, + }); + }); + + it('records assignee removal', () => { + const changes = diffTask(baseTask, { assigneeId: null }); + expect(changes).toEqual({ + assigneeId: { old: 'user-1', new: null }, + }); + }); + + it('records tag diffs as added/removed sets', () => { + const changes = diffTask(baseTask, {}, ['tag-a', 'tag-b'], ['tag-b', 'tag-c']); + expect(changes).toEqual({ + tags: { added: ['tag-c'], removed: ['tag-a'] }, + }); + }); + + it('omits tags when the after set equals the before set', () => { + const changes = diffTask(baseTask, {}, ['tag-a', 'tag-b'], ['tag-b', 'tag-a']); + expect(changes).toEqual({}); + }); + + it('omits tags entirely when tagIdsAfter is not provided', () => { + const changes = diffTask(baseTask, { title: 'New' }, ['tag-a']); + expect(changes).toEqual({ title: { old: 'Original', new: 'New' } }); + }); +}); + +describe('buildCreatedChanges', () => { + it('records all non-null fields with old=null', () => { + const changes = buildCreatedChanges(baseTask, ['tag-a']); + expect(changes).toMatchObject({ + title: { old: null, new: 'Original' }, + status: { old: null, new: 'TODO' }, + priority: { old: null, new: 'MEDIUM' }, + assigneeId: { old: null, new: 'user-1' }, + projectId: { old: null, new: 'project-1' }, + tags: { added: ['tag-a'], removed: [] }, + }); + }); + + it('skips null fields and omits tags entry when none', () => { + const changes = buildCreatedChanges( + { ...baseTask, description: null, dueDate: null, assigneeId: null }, + [], + ); + expect(changes).not.toHaveProperty('description'); + expect(changes).not.toHaveProperty('dueDate'); + expect(changes).not.toHaveProperty('assigneeId'); + expect(changes).not.toHaveProperty('tags'); + }); +}); + diff --git a/test/unit/activities/activity-response.spec.ts b/test/unit/activities/activity-response.spec.ts new file mode 100644 index 0000000..fb29e3d --- /dev/null +++ b/test/unit/activities/activity-response.spec.ts @@ -0,0 +1,37 @@ +import { ActivityAction } from '@prisma/client'; +import { + ActivityWithUser, + toActivityResponse, +} from '../../../src/activities/dto/activity-response.dto'; + +const base: ActivityWithUser = { + id: 'a1', + taskId: 't1', + taskTitle: 'Task title', + userId: 'u1', + action: ActivityAction.UPDATED, + changes: { title: { old: 'A', new: 'B' } }, + createdAt: new Date('2026-04-23T12:00:00Z'), + user: { id: 'u1', name: 'Alice' }, +}; + +describe('toActivityResponse', () => { + it('flattens user and lowercases action', () => { + const res = toActivityResponse(base); + expect(res).toEqual({ + id: 'a1', + taskId: 't1', + taskTitle: 'Task title', + userId: 'u1', + userName: 'Alice', + action: 'updated', + changes: { title: { old: 'A', new: 'B' } }, + createdAt: new Date('2026-04-23T12:00:00Z'), + }); + }); + + it('serializes CREATED and DELETED actions', () => { + expect(toActivityResponse({ ...base, action: ActivityAction.CREATED }).action).toBe('created'); + expect(toActivityResponse({ ...base, action: ActivityAction.DELETED }).action).toBe('deleted'); + }); +}); diff --git a/test/unit/activities/activity.listener.spec.ts b/test/unit/activities/activity.listener.spec.ts new file mode 100644 index 0000000..eef62e1 --- /dev/null +++ b/test/unit/activities/activity.listener.spec.ts @@ -0,0 +1,135 @@ +import { Tag, Task, TaskPriority, TaskStatus } from '@prisma/client'; +import { ActivityListener } from '../../../src/activities/activity.listener'; +import { TxClient } from '../../../src/tasks/task.events'; + +type TaskWithTags = Task & { tags: Tag[] }; + +const baseTask: TaskWithTags = { + id: 'task-1', + title: 'Original', + description: 'old desc', + status: TaskStatus.TODO, + priority: TaskPriority.MEDIUM, + dueDate: null, + createdAt: new Date('2026-04-23T00:00:00Z'), + updatedAt: new Date('2026-04-23T00:00:00Z'), + projectId: 'project-1', + assigneeId: 'user-1', + tags: [], +}; + +const makeTx = () => { + const create = jest.fn(); + const tx = { activity: { create } } as unknown as TxClient; + return { tx, create }; +}; + +describe('ActivityListener', () => { + const listener = new ActivityListener(); + + it('onTaskCreated inserts a CREATED activity with full snapshot', async () => { + const { tx, create } = makeTx(); + await listener.onTaskCreated({ tx, userId: 'u', task: baseTask }); + + expect(create).toHaveBeenCalledTimes(1); + const args = create.mock.calls[0][0]; + expect(args.data.action).toBe('CREATED'); + expect(args.data.taskId).toBe('task-1'); + expect(args.data.userId).toBe('u'); + expect(args.data.taskTitle).toBe('Original'); + expect(args.data.changes).toMatchObject({ + title: { old: null, new: 'Original' }, + }); + }); + + it('onTaskUpdated inserts an UPDATED activity with just the diff', async () => { + const { tx, create } = makeTx(); + await listener.onTaskUpdated({ + tx, + userId: 'u', + before: baseTask, + after: { ...baseTask, title: 'Renamed' }, + dto: { title: 'Renamed' }, + }); + + expect(create).toHaveBeenCalledTimes(1); + const args = create.mock.calls[0][0]; + expect(args.data.action).toBe('UPDATED'); + expect(args.data.changes).toEqual({ + title: { old: 'Original', new: 'Renamed' }, + }); + }); + + it('onTaskUpdated writes nothing when the diff is empty', async () => { + const { tx, create } = makeTx(); + await listener.onTaskUpdated({ + tx, + userId: 'u', + before: baseTask, + after: baseTask, + dto: {}, + }); + expect(create).not.toHaveBeenCalled(); + }); + + it('onTaskCreated folds initial tags into the changes payload', async () => { + const { tx, create } = makeTx(); + await listener.onTaskCreated({ + tx, + userId: 'u', + task: { + ...baseTask, + tags: [ + { id: 'tag-a', name: 'a', createdAt: new Date() }, + { id: 'tag-b', name: 'b', createdAt: new Date() }, + ], + }, + }); + + const args = create.mock.calls[0][0]; + expect(args.data.changes).toMatchObject({ + tags: { added: ['tag-a', 'tag-b'], removed: [] }, + }); + }); + + it('onTaskUpdated passes through a null dueDate in the DTO (clear)', async () => { + const { tx, create } = makeTx(); + const before = { ...baseTask, dueDate: new Date('2026-05-01T00:00:00Z') }; + const after = { ...baseTask, dueDate: null }; + + await listener.onTaskUpdated({ + tx, + userId: 'u', + before, + after, + dto: { dueDate: null as any }, + }); + + const args = create.mock.calls[0][0]; + expect(args.data.changes).toEqual({ + dueDate: { old: '2026-05-01T00:00:00.000Z', new: null }, + }); + }); + + it('onTaskUpdated converts a string dueDate DTO to a Date before diffing', async () => { + const { tx, create } = makeTx(); + const before = { ...baseTask, dueDate: new Date('2026-05-01T00:00:00Z') }; + const after = { ...baseTask, dueDate: new Date('2026-06-01T00:00:00Z') }; + + await listener.onTaskUpdated({ + tx, + userId: 'u', + before, + after, + dto: { dueDate: '2026-06-01T00:00:00Z' }, + }); + + const args = create.mock.calls[0][0]; + expect(args.data.changes).toEqual({ + dueDate: { + old: '2026-05-01T00:00:00.000Z', + new: '2026-06-01T00:00:00.000Z', + }, + }); + }); +}); diff --git a/test/unit/common/dto/paginated.spec.ts b/test/unit/common/dto/paginated.spec.ts new file mode 100644 index 0000000..fc2ec7d --- /dev/null +++ b/test/unit/common/dto/paginated.spec.ts @@ -0,0 +1,17 @@ +import { paginated } from '../../../../src/common/dto/paginated'; + +describe('paginated()', () => { + it('wraps data and exposes meta', () => { + expect(paginated(['a', 'b'], 42, 2, 10)).toEqual({ + data: ['a', 'b'], + meta: { total: 42, page: 2, perPage: 10 }, + }); + }); + + it('works for empty data', () => { + expect(paginated([], 0, 1, 20)).toEqual({ + data: [], + meta: { total: 0, page: 1, perPage: 20 }, + }); + }); +}); diff --git a/test/unit/common/filters/prisma-exception.filter.spec.ts b/test/unit/common/filters/prisma-exception.filter.spec.ts new file mode 100644 index 0000000..b298ea4 --- /dev/null +++ b/test/unit/common/filters/prisma-exception.filter.spec.ts @@ -0,0 +1,85 @@ +import { ArgumentsHost, HttpStatus } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaExceptionFilter } from '../../../../src/common/filters/prisma-exception.filter'; + +const makeHost = () => { + const status = jest.fn().mockReturnThis(); + const json = jest.fn(); + const res = { status, json }; + const host = { + switchToHttp: () => ({ getResponse: () => res }), + } as unknown as ArgumentsHost; + return { host, status, json }; +}; + +const knownError = (code: string, meta?: Record) => + new Prisma.PrismaClientKnownRequestError('msg', { + code, + clientVersion: 'test', + meta, + }); + +describe('PrismaExceptionFilter', () => { + let filter: PrismaExceptionFilter; + beforeEach(() => { + filter = new PrismaExceptionFilter(); + }); + + it('maps P2002 (unique violation) to 409 with target listed', () => { + const { host, status, json } = makeHost(); + filter.catch(knownError('P2002', { target: ['email', 'name'] }), host); + expect(status).toHaveBeenCalledWith(HttpStatus.CONFLICT); + expect(json).toHaveBeenCalledWith({ + statusCode: HttpStatus.CONFLICT, + message: expect.stringContaining('email, name'), + }); + }); + + it('maps P2003 (foreign key) to 400', () => { + const { host, status, json } = makeHost(); + filter.catch(knownError('P2003', { field_name: 'userId' }), host); + expect(status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); + expect(json).toHaveBeenCalledWith({ + statusCode: HttpStatus.BAD_REQUEST, + message: expect.stringContaining('userId'), + }); + }); + + it('maps P2025 (not found) to 404', () => { + const { host, status, json } = makeHost(); + filter.catch(knownError('P2025', { cause: 'Record to update not found.' }), host); + expect(status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND); + expect(json).toHaveBeenCalledWith({ + statusCode: HttpStatus.NOT_FOUND, + message: 'Record to update not found.', + }); + }); + + it('falls back to generic 404 message when P2025 has no cause', () => { + const { host, json } = makeHost(); + filter.catch(knownError('P2025'), host); + expect(json).toHaveBeenCalledWith({ + statusCode: HttpStatus.NOT_FOUND, + message: 'Record not found', + }); + }); + + it('returns 500 for unmapped Prisma codes', () => { + const { host, status, json } = makeHost(); + filter.catch(knownError('P9999'), host); + expect(status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR); + expect(json).toHaveBeenCalledWith({ + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + message: 'Internal server error', + }); + }); + + it('formats non-array, non-string targets as "unknown"', () => { + const { host, json } = makeHost(); + filter.catch(knownError('P2002', { target: 42 }), host); + expect(json).toHaveBeenCalledWith({ + statusCode: HttpStatus.CONFLICT, + message: expect.stringContaining('unknown'), + }); + }); +}); diff --git a/test/unit/common/guards/user-required.guard.spec.ts b/test/unit/common/guards/user-required.guard.spec.ts new file mode 100644 index 0000000..554fb46 --- /dev/null +++ b/test/unit/common/guards/user-required.guard.spec.ts @@ -0,0 +1,23 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { ClsService } from 'nestjs-cls'; +import { UserRequiredGuard } from '../../../../src/common/guards/user-required.guard'; + +const makeCls = (userId: string | undefined) => + ({ get: jest.fn().mockReturnValue(userId) }) as unknown as ClsService; + +describe('UserRequiredGuard', () => { + it('returns true when a userId is present in CLS', () => { + const guard = new UserRequiredGuard(makeCls('00000000-0000-4000-8000-000000000000')); + expect(guard.canActivate({} as any)).toBe(true); + }); + + it('throws UnauthorizedException when userId is missing', () => { + const guard = new UserRequiredGuard(makeCls(undefined)); + expect(() => guard.canActivate({} as any)).toThrow(UnauthorizedException); + }); + + it('throws UnauthorizedException when userId is an empty string', () => { + const guard = new UserRequiredGuard(makeCls('')); + expect(() => guard.canActivate({} as any)).toThrow(UnauthorizedException); + }); +}); diff --git a/test/unit/tasks/dto/task-response.spec.ts b/test/unit/tasks/dto/task-response.spec.ts new file mode 100644 index 0000000..b5faff2 --- /dev/null +++ b/test/unit/tasks/dto/task-response.spec.ts @@ -0,0 +1,55 @@ +import { TaskPriority, TaskStatus } from '@prisma/client'; +import { toTaskResponse } from '../../../../src/tasks/dto/task-response.dto'; +import { TaskWithRelations } from '../../../../src/tasks/tasks.repository'; + +const make = (overrides: Partial = {}): TaskWithRelations => ({ + id: 't1', + title: 'title', + description: 'desc', + status: TaskStatus.TODO, + priority: TaskPriority.MEDIUM, + dueDate: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + projectId: 'p1', + assigneeId: 'u1', + assignee: { + id: 'u1', + name: 'Alice', + email: 'alice@example.com', + createdAt: new Date(), + updatedAt: new Date(), + }, + project: { id: 'p1', name: 'Project', createdAt: new Date(), updatedAt: new Date() }, + tags: [{ id: 'tag1', name: 'bug', createdAt: new Date() }], + ...overrides, +}); + +describe('toTaskResponse', () => { + it('flattens relations to summary shapes', () => { + const res = toTaskResponse(make()); + expect(res).toEqual({ + id: 't1', + title: 'title', + description: 'desc', + status: TaskStatus.TODO, + priority: TaskPriority.MEDIUM, + dueDate: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + assignee: { id: 'u1', name: 'Alice', email: 'alice@example.com' }, + project: { id: 'p1', name: 'Project' }, + tags: [{ id: 'tag1', name: 'bug' }], + }); + }); + + it('serializes null assignee', () => { + const res = toTaskResponse(make({ assignee: null, assigneeId: null })); + expect(res.assignee).toBeNull(); + }); + + it('returns empty tags array when task has none', () => { + const res = toTaskResponse(make({ tags: [] })); + expect(res.tags).toEqual([]); + }); +}); diff --git a/test/unit/tasks/tasks.repository.spec.ts b/test/unit/tasks/tasks.repository.spec.ts new file mode 100644 index 0000000..5aab5d1 --- /dev/null +++ b/test/unit/tasks/tasks.repository.spec.ts @@ -0,0 +1,155 @@ +import { TaskPriority, TaskStatus } from '@prisma/client'; +import { TasksRepository } from '../../../src/tasks/tasks.repository'; +import { PrismaService } from '../../../src/prisma/prisma.service'; + +describe('TasksRepository', () => { + let prisma: any; + let repo: TasksRepository; + + beforeEach(() => { + prisma = { + task: { + findMany: jest.fn(), + count: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + $transaction: jest.fn((ops: Promise[]) => Promise.all(ops)), + }; + repo = new TasksRepository(prisma as unknown as PrismaService); + }); + + describe('findAllPaginated', () => { + it('builds a full where clause with dueDate range, skip and take', async () => { + prisma.task.findMany.mockResolvedValue([]); + prisma.task.count.mockResolvedValue(0); + + await repo.findAllPaginated({ + page: 3, + perPage: 5, + status: TaskStatus.TODO, + priority: TaskPriority.HIGH, + assigneeId: 'u1', + projectId: 'p1', + dueDateFrom: '2026-01-01T00:00:00Z', + dueDateTo: '2026-02-01T00:00:00Z', + } as any); + + const args = prisma.task.findMany.mock.calls[0][0]; + expect(args.where).toMatchObject({ + status: TaskStatus.TODO, + priority: TaskPriority.HIGH, + assigneeId: 'u1', + projectId: 'p1', + }); + expect(args.where.dueDate.gte).toEqual(new Date('2026-01-01T00:00:00Z')); + expect(args.where.dueDate.lte).toEqual(new Date('2026-02-01T00:00:00Z')); + expect(args.skip).toBe(10); + expect(args.take).toBe(5); + expect(args.orderBy).toEqual({ createdAt: 'desc' }); + }); + + it('omits the dueDate clause entirely when no dates supplied', async () => { + prisma.task.findMany.mockResolvedValue([]); + prisma.task.count.mockResolvedValue(0); + + await repo.findAllPaginated({ page: 1, perPage: 20 } as any); + + const args = prisma.task.findMany.mock.calls[0][0]; + expect(args.where.dueDate).toBeUndefined(); + }); + }); + + describe('findById', () => { + it('queries with include of all relations', async () => { + prisma.task.findUnique.mockResolvedValue(null); + await repo.findById('t1'); + expect(prisma.task.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 't1' }, + include: { assignee: true, project: true, tags: true }, + }), + ); + }); + }); + + describe('create', () => { + it('connects project, assignee and tags when provided', async () => { + prisma.task.create.mockResolvedValue({}); + await repo.create({ + title: 'T', + projectId: 'p1', + assigneeId: 'u1', + tagIds: ['tag-a', 'tag-b'], + } as any); + + const { data } = prisma.task.create.mock.calls[0][0]; + expect(data.project).toEqual({ connect: { id: 'p1' } }); + expect(data.assignee).toEqual({ connect: { id: 'u1' } }); + expect(data.tags).toEqual({ connect: [{ id: 'tag-a' }, { id: 'tag-b' }] }); + }); + + it('leaves assignee and tags undefined when not provided', async () => { + prisma.task.create.mockResolvedValue({}); + await repo.create({ title: 'T', projectId: 'p1' } as any); + + const { data } = prisma.task.create.mock.calls[0][0]; + expect(data.assignee).toBeUndefined(); + expect(data.tags).toBeUndefined(); + }); + + it('uses the passed tx client when provided', async () => { + const tx = { task: { create: jest.fn().mockResolvedValue({}) } }; + await repo.create({ title: 'T', projectId: 'p1' } as any, tx as any); + expect(tx.task.create).toHaveBeenCalledTimes(1); + expect(prisma.task.create).not.toHaveBeenCalled(); + }); + }); + + describe('update', () => { + it('connects a new assignee', async () => { + prisma.task.update.mockResolvedValue({}); + await repo.update('t1', { assigneeId: 'u2' } as any); + const { data } = prisma.task.update.mock.calls[0][0]; + expect(data.assignee).toEqual({ connect: { id: 'u2' } }); + }); + + it('disconnects the assignee when null is passed', async () => { + prisma.task.update.mockResolvedValue({}); + await repo.update('t1', { assigneeId: null } as any); + const { data } = prisma.task.update.mock.calls[0][0]; + expect(data.assignee).toEqual({ disconnect: true }); + }); + + it('leaves assignee untouched when key is absent', async () => { + prisma.task.update.mockResolvedValue({}); + await repo.update('t1', { title: 'new' } as any); + const { data } = prisma.task.update.mock.calls[0][0]; + expect(data.assignee).toBeUndefined(); + }); + + it('uses set for tags when tagIds is provided', async () => { + prisma.task.update.mockResolvedValue({}); + await repo.update('t1', { tagIds: ['a', 'b'] } as any); + const { data } = prisma.task.update.mock.calls[0][0]; + expect(data.tags).toEqual({ set: [{ id: 'a' }, { id: 'b' }] }); + }); + + it('leaves tags untouched when tagIds is absent', async () => { + prisma.task.update.mockResolvedValue({}); + await repo.update('t1', { title: 'x' } as any); + const { data } = prisma.task.update.mock.calls[0][0]; + expect(data.tags).toBeUndefined(); + }); + }); + + describe('delete', () => { + it('calls prisma.task.delete with the id', async () => { + prisma.task.delete.mockResolvedValue({}); + await repo.delete('t1'); + expect(prisma.task.delete).toHaveBeenCalledWith({ where: { id: 't1' } }); + }); + }); +}); diff --git a/test/unit/tasks/tasks.service.spec.ts b/test/unit/tasks/tasks.service.spec.ts new file mode 100644 index 0000000..fafb451 --- /dev/null +++ b/test/unit/tasks/tasks.service.spec.ts @@ -0,0 +1,269 @@ +import { NotFoundException } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { ClsService } from 'nestjs-cls'; +import { TaskPriority, TaskStatus } from '@prisma/client'; + +import { TasksService } from '../../../src/tasks/tasks.service'; +import { TasksRepository, TaskWithRelations } from '../../../src/tasks/tasks.repository'; +import { PrismaService } from '../../../src/prisma/prisma.service'; +import { EmailService } from '../../../src/email/email.service'; +import { ActivitiesService } from '../../../src/activities/activities.service'; +import { CLS_USER_ID_KEY } from '../../../src/common/constants'; +import { TASK_CREATED, TASK_UPDATED } from '../../../src/tasks/task.events'; + +const USER_ID = '00000000-0000-4000-8000-000000000001'; + +const makeTask = (overrides: Partial = {}): TaskWithRelations => ({ + id: 't1', + title: 'title', + description: null, + status: TaskStatus.TODO, + priority: TaskPriority.MEDIUM, + dueDate: null, + createdAt: new Date(), + updatedAt: new Date(), + projectId: 'p1', + assigneeId: null, + assignee: null, + project: { id: 'p1', name: 'P', createdAt: new Date(), updatedAt: new Date() }, + tags: [], + ...overrides, +}); + +const withAssignee = (email = 'alice@example.com', id = 'u-assignee'): Partial => ({ + assigneeId: id, + assignee: { + id, + name: 'Alice', + email, + createdAt: new Date(), + updatedAt: new Date(), + }, +}); + +describe('TasksService', () => { + let prisma: jest.Mocked>; + let repo: jest.Mocked; + let email: jest.Mocked; + let activities: jest.Mocked; + let events: jest.Mocked; + let cls: jest.Mocked; + let service: TasksService; + + const txStub = {} as any; + + beforeEach(() => { + prisma = { + $transaction: jest.fn(async (fn: any) => fn(txStub)), + } as any; + repo = { + findAllPaginated: jest.fn(), + findById: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + } as any; + email = { sendTaskAssignmentNotification: jest.fn() } as any; + activities = { findByTask: jest.fn() } as any; + events = { emitAsync: jest.fn().mockResolvedValue([]) } as any; + cls = { get: jest.fn().mockReturnValue(USER_ID) } as any; + + service = new TasksService( + prisma as unknown as PrismaService, + repo, + email, + activities, + events, + cls, + ); + }); + + describe('findAll', () => { + it('wraps repo results into the paginated envelope', async () => { + const task = makeTask(); + repo.findAllPaginated.mockResolvedValue([[task], 1] as any); + const result = await service.findAll({ page: 2, perPage: 10 } as any); + + expect(result.meta).toEqual({ total: 1, page: 2, perPage: 10 }); + expect(result.data[0].id).toBe('t1'); + }); + }); + + describe('findOne', () => { + it('returns the mapped task when found', async () => { + repo.findById.mockResolvedValue(makeTask({ id: 'abc' })); + const result = await service.findOne('abc'); + expect(result.id).toBe('abc'); + }); + + it('throws NotFoundException when missing', async () => { + repo.findById.mockResolvedValue(null); + await expect(service.findOne('missing')).rejects.toThrow(NotFoundException); + }); + }); + + describe('findActivities', () => { + it('verifies task exists then delegates to ActivitiesService', async () => { + repo.findById.mockResolvedValue(makeTask({ id: 't1' })); + activities.findByTask.mockResolvedValue({ data: [], meta: { total: 0, page: 1, perPage: 20 } } as any); + + await service.findActivities('t1', { page: 1, perPage: 20 } as any); + + expect(repo.findById).toHaveBeenCalledWith('t1'); + expect(activities.findByTask).toHaveBeenCalledWith('t1', { page: 1, perPage: 20 }); + }); + + it('propagates NotFoundException when task is missing', async () => { + repo.findById.mockResolvedValue(null); + await expect(service.findActivities('missing', {} as any)).rejects.toThrow(NotFoundException); + expect(activities.findByTask).not.toHaveBeenCalled(); + }); + }); + + describe('create', () => { + it('emits task.created inside the transaction and returns the mapped task', async () => { + const created = makeTask({ id: 'new' }); + repo.create.mockResolvedValue(created); + + const result = await service.create({ title: 'x', projectId: 'p1' } as any); + + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(repo.create).toHaveBeenCalledWith({ title: 'x', projectId: 'p1' }, txStub); + expect(events.emitAsync).toHaveBeenCalledWith(TASK_CREATED, { + tx: txStub, + userId: USER_ID, + task: created, + }); + expect(result.id).toBe('new'); + }); + + it('fires the email notification when an assignee is set', async () => { + const created = makeTask({ ...withAssignee('bob@example.com') }); + repo.create.mockResolvedValue(created); + email.sendTaskAssignmentNotification.mockResolvedValue(); + + await service.create({ title: 'x', projectId: 'p1', assigneeId: 'u-assignee' } as any); + + expect(email.sendTaskAssignmentNotification).toHaveBeenCalledWith('bob@example.com', 'title'); + }); + + it('does not call the mailer when there is no assignee', async () => { + repo.create.mockResolvedValue(makeTask()); + await service.create({ title: 'x', projectId: 'p1' } as any); + expect(email.sendTaskAssignmentNotification).not.toHaveBeenCalled(); + }); + + it('throws when CLS has no userId (guard invariant broken)', async () => { + cls.get.mockReturnValue(undefined as any); + await expect(service.create({ title: 'x', projectId: 'p1' } as any)).rejects.toThrow( + /guard invariant/, + ); + expect(repo.create).not.toHaveBeenCalled(); + }); + }); + + describe('update', () => { + it('loads existing task first, then emits task.updated inside the transaction', async () => { + const existing = makeTask({ title: 'old' }); + const updated = makeTask({ title: 'new' }); + repo.findById.mockResolvedValue(existing); + repo.update.mockResolvedValue(updated); + + await service.update('t1', { title: 'new' } as any); + + expect(repo.findById).toHaveBeenCalledWith('t1'); + expect(repo.update).toHaveBeenCalledWith('t1', { title: 'new' }, txStub); + expect(events.emitAsync).toHaveBeenCalledWith(TASK_UPDATED, { + tx: txStub, + userId: USER_ID, + before: existing, + after: updated, + dto: { title: 'new' }, + }); + }); + + it('notifies the new assignee when assignee actually changes', async () => { + const existing = makeTask({ ...withAssignee('old@x.com', 'u-old') }); + const updated = makeTask({ ...withAssignee('new@x.com', 'u-new') }); + repo.findById.mockResolvedValue(existing); + repo.update.mockResolvedValue(updated); + email.sendTaskAssignmentNotification.mockResolvedValue(); + + await service.update('t1', { assigneeId: 'u-new' } as any); + + expect(email.sendTaskAssignmentNotification).toHaveBeenCalledWith('new@x.com', 'title'); + }); + + it('skips notification when assignee is not changing', async () => { + const same = makeTask({ ...withAssignee('alice@x.com', 'u-1') }); + repo.findById.mockResolvedValue(same); + repo.update.mockResolvedValue(same); + + await service.update('t1', { assigneeId: 'u-1' } as any); + + expect(email.sendTaskAssignmentNotification).not.toHaveBeenCalled(); + }); + + it('propagates NotFoundException for missing task', async () => { + repo.findById.mockResolvedValue(null); + await expect(service.update('missing', {} as any)).rejects.toThrow(NotFoundException); + expect(repo.update).not.toHaveBeenCalled(); + }); + }); + + describe('remove', () => { + it('loads, then deletes — no transaction, no event', async () => { + repo.findById.mockResolvedValue(makeTask()); + repo.delete.mockResolvedValue(undefined as any); + + const result = await service.remove('t1'); + + expect(repo.delete).toHaveBeenCalledWith('t1'); + expect(prisma.$transaction).not.toHaveBeenCalled(); + expect(events.emitAsync).not.toHaveBeenCalled(); + expect(result).toEqual({ message: 'Task deleted successfully' }); + }); + + it('throws NotFoundException when task does not exist', async () => { + repo.findById.mockResolvedValue(null); + await expect(service.remove('missing')).rejects.toThrow(NotFoundException); + expect(repo.delete).not.toHaveBeenCalled(); + }); + }); + + describe('notifyAssignee (fire-and-forget)', () => { + it('does not reject the caller when the mailer fails with an Error', async () => { + const created = makeTask({ ...withAssignee('bob@example.com') }); + repo.create.mockResolvedValue(created); + email.sendTaskAssignmentNotification.mockRejectedValue(new Error('smtp down')); + const loggerSpy = jest.spyOn((service as any).logger, 'error').mockImplementation(() => undefined); + + await expect( + service.create({ title: 'x', projectId: 'p1', assigneeId: 'u-assignee' } as any), + ).resolves.toBeDefined(); + + await new Promise((r) => setImmediate(r)); + expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('smtp down')); + loggerSpy.mockRestore(); + }); + + it('stringifies non-Error rejection reasons', async () => { + const created = makeTask({ ...withAssignee('bob@example.com') }); + repo.create.mockResolvedValue(created); + email.sendTaskAssignmentNotification.mockRejectedValue('plain-string-reason'); + const loggerSpy = jest.spyOn((service as any).logger, 'error').mockImplementation(() => undefined); + + await service.create({ title: 'x', projectId: 'p1', assigneeId: 'u-assignee' } as any); + await new Promise((r) => setImmediate(r)); + + expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('plain-string-reason')); + loggerSpy.mockRestore(); + }); + }); + + it('reads userId from CLS using the shared key', async () => { + repo.create.mockResolvedValue(makeTask()); + await service.create({ title: 'x', projectId: 'p1' } as any); + expect(cls.get).toHaveBeenCalledWith(CLS_USER_ID_KEY); + }); +});