Problem Identified:
TasksService.findAll in src/tasks/tasks.service.ts had four compounding problems:
- N+1 queries. After
prisma.task.findMany()returned every row, the handler iterated each task and issued three extra queries per row (user.findUniquefor the assignee,project.findUnique, andtag.findMany). For N tasks this is1 + 3Nround-trips — 301 queries for 100 tasks, 1501 for 500. - No pagination. The endpoint returned every row on every request, so payload size, JSON serialization and network time all scaled with the table.
- Filtering in application memory.
status,priority,assigneeId,projectIdand thedueDaterange were applied withArray.filterafter loading the full table, so PostgreSQL kept returning rows that were immediately discarded. - No indexes on filter columns. The
Taskmodel 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
findAllto build a singlePrisma.TaskWhereInputfrom the filter DTO (including agte/lterange ondueDate), load all relations in one query viainclude: { assignee, project, tags }, and paginate withskip/takeplus a deterministicorderBy: { createdAt: 'desc' }. The page query andtask.countrun inside a single$transactionfor a consistent snapshot, and the response is{ data, meta: { total, page, perPage } }(the envelope is shared with/activitiesviapaginated()insrc/common/dto/paginated.ts). - Added
page(default1) andperPage(default20, max100) to a sharedPaginationQueryDtothatTaskFilterDtoandActivityFilterDtoextend. Validated withclass-validatorand coerced from query strings via@Type(() => Number)(the app already enablesValidationPipe({ transform: true })). - Added five
@@indexentries to theTaskmodel inprisma/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 + 3Nto2(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).
Problem Identified:
TasksService.create and TasksService.update awaited 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:
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: 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.
- New
ActivitiesModule(src/activities/) withActivitiesService(reads),ActivitiesController(GET /activities), andActivityListener(writes). GET /tasks/:id/activitieslives onTasksControllerand delegates toActivitiesService.findByTaskviaTasksService.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.
TasksServiceopens aprisma.$transaction, performs the task mutation, then emitstask.created/task.updatedvia@nestjs/event-emitter(EventEmitter2.emitAsync). The transaction client (tx) is included in the payload so the listener'sactivity.createwrites 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,emitAsyncrejects and the whole transaction rolls back. There's notask.deletedevent: 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 inactivity-diff.ts:buildCreatedChanges(task, tagIds)forCREATED.diffTask(before, dto, tagIdsBefore, tagIdsAfter)forUPDATED, 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:
TasksServicedoesn't knowActivityexists. 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-scopeduserIdout of CLS, and updates would need a duplicate pre-fetch to compute the diff.
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 Jsonkeeps the schema flexible for arbitrary field diffs without a column-per-field explosion, and matches the shape in the task spec ({ field: { old, new } }).taskTitledenormalized 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,taskIdnon-nullable. When a task is deleted, its activities go with it.Restrictwould make any task with history undeletable, andSetNullwould orphan activities pointing at non-existent tasks. Cascade is the direct consequence of treating activities as part of the task's own state. Because aDELETEDactivity would be cascaded out by its own emitting transaction, we simply don't emit one — there's notask.deletedevent and noDELETEDactivity 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 answerORDER BY createdAt DESC LIMIT Nfrom the index without a sort step. Standalone indexes onactionandcreatedAtback theGET /activitiesfilter 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).
- Authentication — all task mutation endpoints require an
X-User-Idheader. Split into two responsibilities: theClsModulemiddleware (AsyncLocalStorage vianestjs-cls, configured inAppModule) extracts and validates the header once per request and stashes the userId in the per-request store;UserRequiredGuard(applied with@UseGuardsonPOST/PUT/DELETEonly) rejects unauthenticated requests with401before any DB work.TasksServicereads the userId from CLS via a privatecurrentUserId()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 onActivity.userIdrelies 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 bypaginated()insrc/common/dto/paginated.ts. Activityactionis serialized lowercase ("created" | "updated") anduserNameis included viainclude: { 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 viaclass-validator, all pushed intoPrisma.ActivityWhereInput— nothing is filtered in memory. - Global Prisma exception filter —
PrismaExceptionFilter(wired asAPP_FILTER) mapsPrismaClientKnownRequestErrorcodes 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 unknownX-User-Idwould crash as an unhandled 500 instead of a readable 400.
- Every list endpoint is paginated and filtered in SQL; no activity reads ever materialize full history in memory.
- Activity inserts are a single
INSERTpiggybacked 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.changesasJsonavoids per-field schema churn. If analytics ever needs to query specific change fields, a generated column or materialized projection can be layered on later.
- 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(markdeletedAt, filter reads, never physically delete) — not reverting the FK. changesas 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
UPDATEDactivity'schanges.tagsinstead 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.userIdsurfaces 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-Idheader is a stand-in for a proper authenticated principal and is trivially spoofable. Acceptable for this assignment; unacceptable for production.
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.
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
AppModuleagainst the local Postgres/Redis. Covers the paginated envelope and filter combinations onGET /tasksandGET /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 } })is0), and the transaction rollback test that stubs the listener to throw and verifies the task was never persisted (proving theemitAsyncatomicity claim is empirically true — this is what caught that@nestjs/event-emitterdefaultssuppressErrors: trueand would silently commit the task without the activity).
Run with npm test (unit) and npm run test:e2e (integration — requires the Docker stack up and npm run seed first).
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 themailpitservice exposing1025(SMTP in) and8025(Web UI + HTTP API). Configured withMP_SMTP_AUTH_ACCEPT_ANY=1so dev flow needs no auth setup.src/email/email.service.ts— rewritten aroundnodemailer. SingleTransporterinstance created inonModuleInit(readingSMTP_HOST/SMTP_PORT/SMTP_FROMfromConfigService), closed inonModuleDestroy. ThesendEmail/sendTaskAssignmentNotificationsignatures didn't change —TasksServicerequired zero edits.env.validation.ts— three new required vars (SMTP_HOST,SMTP_PORT,SMTP_FROM). Joi's.email()rule usestlds: { allow: false }so the dev defaultnoreply@taskapp.localis 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.tstalks 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
Toand 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.
- Creating a task with an assignee delivers one email with the assignee's address in
- Production path — swap
SMTP_HOST/SMTP_PORTto 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.
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 withaddApiKey({ type: 'apiKey', name: 'X-User-Id', in: 'header' }, 'user-id')in theDocumentBuilder, so the UI exposes an Authorize button that injects the header — try-it-out works end-to-end forPOST/PUT/DELETE. CreateTaskDtodecorated with@ApiProperty/@ApiPropertyOptional(enum, format, defaults).UpdateTaskDtousesPartialType(CreateTaskDto)from@nestjs/swaggerso it inherits decorators without duplication.PaginationQueryDtocontributespage/perPageto every list endpoint automatically via DTO inheritance.app.enableShutdownHooks()was added inmain.tsin the same pass, soSIGTERMin containers triggersonModuleDestroycleanly (complements theCacheShutdownService).
- Add composite indexes on
Taskif traffic shows combined filters dominating (e.g.@@index([projectId, status])). - Consider cursor-based pagination for very large activity timelines where deep
OFFSETbecomes expensive. - Cache hot
GET /taskspages in Redis (the dependency is already present) keyed by the normalized filter + page tuple, with invalidation on task mutations. - Audit
GET /projectsandGET /usersfor 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-Idheader with real authentication (JWT or session) and drop the manual UUID validation in the CLS middleware.