A production-structured Project Management REST API built with Prisma + PostgreSQL + Express + TypeScript.
Part of the BackSmith reference implementation series — a collection of 4 backend repos each demonstrating a different ORM/database stack with identical architecture patterns.
| Layer | Technology |
|---|---|
| Runtime | Node.js + TypeScript |
| Framework | Express |
| ORM | Prisma v5 |
| Database | PostgreSQL 16 |
| Validation | Zod |
| Auth | JWT (access + refresh token rotation) |
| Dev DB | Docker |
Project Management Platform — a Linear-inspired backend covering workspaces, projects, tasks, labels, comments, attachments, activity logs, and notifications.
- JWT authentication with HttpOnly cookie refresh token rotation
- Zod-validated request bodies, query params, and route params
- Module-based folder structure — every feature is fully self-contained
- Thin controllers, fat services — all business logic and DB calls in the service layer
- Centralized error handling with typed error classes (
AppError,NotFoundError,ForbiddenError, etc.) - Prisma error code mapping (
P2002→ 409,P2025→ 404) - Soft delete on
UserandTaskwith restore support - Offset pagination and cursor-based pagination via reusable utility
- Full-text search via
$queryRawwithto_tsvector/plainto_tsquery - Activity log (append-only) automatically written on task mutations
- Postman collection with happy path + 400/401/403/404 cases + full lifecycle integration test
Every directive and query pattern from the Prisma docs is demonstrated at least once:
Schema directives
@id @default(cuid()), @default(uuid()), @unique, @@unique([compound]), @index, @@index([compound]), @default(now()), @updatedAt, onDelete: Cascade, onDelete: Restrict, onDelete: SetNull, onDelete: NoAction, enums, Json field, Float field, self-referential relations, explicit M:N junction tables
Query patterns
findUnique, findUniqueOrThrow, findMany with where/orderBy/take/skip, cursor pagination, include (nested 2 levels), select, create with nested connect, create with nested create, update, updateMany, upsert, delete, deleteMany, soft delete pattern, $transaction (array form), $transaction (interactive), count, aggregate, groupBy, $queryRaw, Prisma error code mapping
backsmith-prisma/
├── prisma/
│ ├── schema.prisma
│ └── seed.ts
├── postman/
│ ├── backsmith-prisma.collection.json
│ └── environments/local.json
└── src/
├── app.ts
├── config/
│ ├── env.ts
│ └── constants.ts
├── lib/
│ └── prisma.ts
├── middleware/
│ ├── auth.middleware.ts
│ ├── error.middleware.ts
│ └── validate.middleware.ts
├── types/
│ └── express.d.ts
├── utils/
│ ├── errors.ts
│ ├── pagination.ts
│ └── slug.ts
└── modules/
├── auth/
├── workspace/
├── project/
├── task/
├── label/
├── comment/
├── attachment/
├── activity/
└── notification/
Each module contains: [feature].routes.ts, [feature].controller.ts, [feature].service.ts, [feature].schema.ts
User
├── owns → Workspace (Restrict)
├── member of → WorkspaceMember (Cascade)
├── owns → Project (Restrict)
├── assigned → Task (SetNull)
├── reported → Task (Restrict)
├── authored → Comment (Restrict)
└── uploaded → Attachment (Restrict)
Workspace
├── has many → WorkspaceMember (Cascade)
└── has many → Project (Cascade)
Project
├── has many → Task (Cascade)
└── has many → Label (Cascade)
Task (self-referential subtasks)
├── has many → TaskLabel (Cascade)
├── has many → Comment (Cascade, self-referential threads)
├── has many → Attachment (Cascade)
├── has many → Activity (Cascade)
└── has many → Notification (SetNull)
- Node.js 20+
- Docker (for PostgreSQL)
docker compose up -dnpm installcp .env.example .envEdit .env — the only values you must change:
JWT_SECRET=any-random-string-minimum-32-characters-long
JWT_REFRESH_SECRET=another-random-string-minimum-32-charactersEverything else works out of the box with the Docker defaults.
npx prisma migrate dev --name init
npx prisma db seedSeed creates:
alice@example.com/password123— ADMINbob@example.com/password123— MEMBER- A workspace, project, tasks, labels, comments, and notifications
npm run devServer runs on http://localhost:3001
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/auth/register |
Register |
| POST | /api/v1/auth/login |
Login |
| POST | /api/v1/auth/refresh |
Refresh access token |
| POST | /api/v1/auth/logout |
Logout |
| POST | /api/v1/auth/verify-email |
Verify email |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/workspaces |
Create workspace |
| GET | /api/v1/workspaces |
List my workspaces |
| GET | /api/v1/workspaces/:workspaceId |
Get workspace |
| PATCH | /api/v1/workspaces/:workspaceId |
Update workspace |
| DELETE | /api/v1/workspaces/:workspaceId |
Delete workspace |
| GET | /api/v1/workspaces/:workspaceId/stats |
Workspace stats |
| POST | /api/v1/workspaces/:workspaceId/members |
Invite member |
| GET | /api/v1/workspaces/:workspaceId/members |
List members |
| PATCH | /api/v1/workspaces/:workspaceId/members/:userId |
Change member role |
| DELETE | /api/v1/workspaces/:workspaceId/members/:userId |
Remove member |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/workspaces/:workspaceId/projects |
Create project |
| GET | /api/v1/workspaces/:workspaceId/projects |
List projects |
| GET | /api/v1/projects/:projectId |
Get project |
| PATCH | /api/v1/projects/:projectId |
Update project |
| DELETE | /api/v1/projects/:projectId |
Delete project |
| POST | /api/v1/projects/:projectId/archive |
Archive project |
| PATCH | /api/v1/projects/:projectId/visibility |
Change visibility |
| GET | /api/v1/projects/:projectId/task-breakdown |
Task counts by status |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/projects/:projectId/tasks |
Create task |
| GET | /api/v1/projects/:projectId/tasks |
List tasks (offset or cursor) |
| GET | /api/v1/projects/:projectId/tasks/search?q= |
Full-text search |
| GET | /api/v1/tasks/:taskId |
Get task |
| PATCH | /api/v1/tasks/:taskId |
Update task |
| DELETE | /api/v1/tasks/:taskId |
Soft delete task |
| POST | /api/v1/tasks/:taskId/restore |
Restore task |
| PATCH | /api/v1/tasks/:taskId/status |
Change status |
| PATCH | /api/v1/tasks/:taskId/priority |
Change priority |
| PATCH | /api/v1/tasks/:taskId/assign |
Assign/unassign |
| PATCH | /api/v1/tasks/:taskId/reorder |
Reorder (drag-and-drop) |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/projects/:projectId/labels |
Create label |
| GET | /api/v1/projects/:projectId/labels |
List labels |
| PATCH | /api/v1/projects/:projectId/labels/:labelId |
Update label |
| DELETE | /api/v1/projects/:projectId/labels/:labelId |
Delete label |
| POST | /api/v1/tasks/:taskId/labels |
Attach label to task |
| DELETE | /api/v1/tasks/:taskId/labels/:labelId |
Detach label from task |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/tasks/:taskId/comments |
Create comment (supports threading via parentId) |
| GET | /api/v1/tasks/:taskId/comments |
List comments |
| PATCH | /api/v1/tasks/:taskId/comments/:commentId |
Edit comment |
| DELETE | /api/v1/tasks/:taskId/comments/:commentId |
Soft delete comment |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/tasks/:taskId/attachments |
Register attachment |
| GET | /api/v1/tasks/:taskId/attachments |
List attachments |
| DELETE | /api/v1/tasks/:taskId/attachments/:attachmentId |
Delete attachment |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/tasks/:taskId/activities |
Activity log for task |
| GET | /api/v1/notifications |
List notifications |
| GET | /api/v1/notifications/unread-count |
Unread count |
| POST | /api/v1/notifications/mark-all-read |
Mark all read |
| PATCH | /api/v1/notifications/:notificationId/read |
Mark one read |
- Import
postman/backsmith-prisma.collection.json - Import
postman/environments/local.jsonand set it as the active environment - Open the Collection Runner, select all folders, enable Run in sequence
- Hit Run
The collection runs top-to-bottom as a complete lifecycle integration test. Each request saves variables ({{token}}, {{workspaceId}}, {{projectId}}, {{taskId}}) for downstream requests. Every entity includes 400/401/403/404 error case coverage.
To reset between runs:
npx prisma migrate reset --force| Script | Description |
|---|---|
npm run dev |
Start with hot reload |
npm run build |
Compile TypeScript |
npm start |
Run compiled output |
npx prisma migrate dev |
Run migrations |
npx prisma db seed |
Seed database |
npx prisma studio |
Visual DB browser |
npx prisma migrate reset --force |
Reset DB (wipes all data) |
See DECISIONS.md for a full log of non-obvious architectural choices made in this repo.