Backend API for URL shortener with analytics, built with NestJS + Prisma + PostgreSQL + Redis.
| Component | Technology |
|---|---|
| Framework | NestJS 11 |
| Database | PostgreSQL 17 (Prisma TypedSQL) |
| Cache | Redis 7 (ioredis) |
| Queue | BullMQ |
| Validation | Zod |
| Auth | JWT (access + refresh tokens) |
| API Docs | Swagger/OpenAPI (@nestjs/swagger) |
| Security | helmet, cors, express-rate-limit |
| Testing | Jest + jest-mock-extended |
| Container | Docker + Docker Compose |
| CI/CD | GitHub Actions |
Controller → Service → Repository → Prisma → PostgreSQL
| Layer | Responsibility |
|---|---|
| Controller | Parse request, validate, return response |
| Service | Business logic |
| Repository | Database queries (TypedSQL) |
| Prisma | ORM wrapper |
sequenceDiagram
participant C as Client
participant Ctrl as Controller
participant Svc as Service
participant Repo as Repository
participant DB as PostgreSQL
C->>Ctrl: HTTP Request
Ctrl->>Ctrl: Validate (Zod)
Ctrl->>Svc: Call service method
Svc->>Repo: Call repository method
Repo->>DB: SQL Query (TypedSQL)
DB-->>Repo: Result
Repo-->>Svc: Domain object
Svc-->>Ctrl: Business result
Ctrl-->>C: ApiResponse { message, data, requestId }
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /auth/register |
No | Register user |
| POST | /auth/login |
No | Login, get JWT + refresh |
| POST | /auth/rotate |
No | Refresh token rotation |
| POST | /auth/logout |
No | Revoke refresh token |
| GET | /auth/me |
Yes | Get current user |
| POST | /shorten |
Yes | Create short URL |
| GET | /:slug |
No | Redirect to original URL |
| GET | /urls/:id/stats |
Yes | Get click analytics (via AnalyticsService) |
| DELETE | /urls/:id |
Yes | Delete URL |
Request:
{
"email": "user@example.com",
"password": "password123",
"name": "John Doe"
}Response 201:
{
"message": "User registered successfully",
"data": {
"user": {
"id": "1",
"email": "user@example.com",
"name": "John Doe",
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z"
},
"accessToken": "eyJhbGciOiJIUzI1NiIs..."
},
"requestId": "req_abc123"
}Response 400:
{
"message": "Validation failed",
"code": "validation.failed",
"error": [
{ "field": "email", "message": "Invalid email format" },
{ "field": "password", "message": "Password must be at least 6 characters" }
],
"requestId": "req_abc123"
}Response 409:
{
"message": "Email already registered",
"code": "auth.user.exists",
"requestId": "req_abc123"
}Request:
{
"email": "user@example.com",
"password": "password123"
}Response 200:
{
"message": "Login successful",
"data": {
"user": {
"id": "1",
"email": "user@example.com",
"name": "John Doe",
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z"
},
"accessToken": "eyJhbGciOiJIUzI1NiIs..."
},
"requestId": "req_abc123"
}Set-Cookie:
refresh_token=eyJhbGciOiJIUzI1NiIs...; HttpOnly; SameSite=Lax; Path=/; Max-Age=604800
Response 400:
{
"message": "Validation failed",
"code": "validation.failed",
"error": [
{ "field": "email", "message": "Invalid email format" }
],
"requestId": "req_abc123"
}Response 401:
{
"message": "Invalid email or password",
"code": "auth.credentials.invalid",
"requestId": "req_abc123"
}Request:
- Cookie:
refresh_token=eyJhbGciOiJIUzI1NiIs...
Response 200:
{
"message": "Token rotated successfully",
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs..."
},
"requestId": "req_abc123"
}Response 401:
{
"message": "Invalid refresh token",
"code": "auth.refresh.invalid",
"requestId": "req_abc123"
}Response 401 (revoked):
{
"message": "Refresh token has been revoked",
"code": "auth.refresh.revoked",
"requestId": "req_abc123"
}Response 401 (expired):
{
"message": "Refresh token has expired",
"code": "auth.refresh.expired",
"requestId": "req_abc123"
}Request:
- Cookie:
refresh_token=eyJhbGciOiJIUzI1NiIs...
Response 200:
{
"message": "Logged out successfully",
"requestId": "req_abc123"
}Set-Cookie:
refresh_token=; Path=/
Request:
- Header:
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Response 200:
{
"message": "User retrieved successfully",
"data": {
"user": {
"id": "1",
"email": "user@example.com",
"name": "John Doe",
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z"
}
},
"requestId": "req_abc123"
}Response 401:
{
"message": "Unauthorized",
"code": "auth.unauthorized",
"requestId": "req_abc123"
}Request:
- Header:
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
{
"url": "https://example.com/very/long/path",
"slug": "custom-slug"
}
slugis optional. Auto-generated if not provided.
Response 201:
{
"message": "URL shortened successfully",
"data": {
"url": {
"id": "1",
"slug": "custom-slug",
"originalUrl": "https://example.com/very/long/path",
"userId": "1",
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z"
}
},
"requestId": "req_abc123"
}Response 400:
{
"message": "Validation failed",
"code": "validation.failed",
"error": [
{ "field": "url", "message": "Invalid URL format" }
],
"requestId": "req_abc123"
}Response 401:
{
"message": "Unauthorized",
"code": "auth.unauthorized",
"requestId": "req_abc123"
}Response 409:
{
"message": "Slug already exists",
"code": "urls.slug.exists",
"requestId": "req_abc123"
}Request:
- No auth required
- No request body
Response 302:
Location: https://example.com/very/long/path
Response 404:
{
"message": "URL not found",
"code": "urls.not_found",
"requestId": "req_abc123"
}Request:
- Header:
Authorization: Bearer eyJhbGciOiJIUzI1NiIs... - Query:
from(optional, ISO date string) - Query:
to(optional, ISO date string)
Response 200:
{
"message": "Analytics retrieved successfully",
"data": {
"stats": {
"totalClicks": 150,
"clicksByDate": [
{ "date": "2026-01-01", "count": 50 },
{ "date": "2026-01-02", "count": 100 }
],
"clicksByReferrer": [
{ "referrer": "google.com", "count": 80 },
{ "referrer": "direct", "count": 70 }
]
}
},
"requestId": "req_abc123"
}Response 401:
{
"message": "Unauthorized",
"code": "auth.unauthorized",
"requestId": "req_abc123"
}Request:
- Header:
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Response 200:
{
"message": "URL deleted successfully",
"requestId": "req_abc123"
}Response 401:
{
"message": "Unauthorized",
"code": "auth.unauthorized",
"requestId": "req_abc123"
}Response 403:
{
"message": "You do not own this URL",
"code": "urls.forbidden",
"requestId": "req_abc123"
}Response 404:
{
"message": "URL not found",
"code": "urls.not_found",
"requestId": "req_abc123"
}sequenceDiagram
participant C as Client
participant Auth as AuthController
participant Svc as AuthService
participant Repo as AuthRepository
participant DB as PostgreSQL
Note over C,DB: Register
C->>Auth: POST /auth/register
Auth->>Svc: register(dto)
Svc->>Repo: findByEmail → null
Svc->>Svc: bcrypt.hash(password)
Svc->>Repo: createUser
Svc->>Svc: jwt.sign(accessToken)
Svc->>Repo: createRefreshToken
Svc-->>Auth: {user, tokens}
Auth-->>C: 201 + tokens
Note over C,DB: Login
C->>Auth: POST /auth/login
Auth->>Svc: login(dto)
Svc->>Repo: findPasswordByEmail
Svc->>Svc: bcrypt.compare
Svc->>Repo: findById
Svc->>Svc: jwt.sign(accessToken)
Svc->>Repo: createRefreshToken
Svc-->>Auth: {user, tokens}
Auth-->>C: 200 + setCookie(refreshToken)
Note over C,DB: Rotate
C->>Auth: POST /auth/rotate (cookie: refreshToken)
Auth->>Svc: rotate(refreshToken)
Svc->>Repo: findRefreshToken
Svc->>Svc: validate (not revoked, not expired)
Svc->>Repo: revokeRefreshToken
Svc->>Repo: createRefreshToken
Svc-->>Auth: {accessToken, refreshToken}
Auth-->>C: 200 + new tokens
sequenceDiagram
participant C as Client
participant Ctrl as UrlsController
participant Svc as UrlsService
participant Repo as UrlsRepository
participant Cache as CacheService
participant DB as PostgreSQL
Note over C,DB: Create Short URL
C->>Ctrl: POST /shorten (url, slug?)
Ctrl->>Svc: createUrl(dto, userId)
Svc->>Svc: generateSlug() if no slug
Svc->>Repo: slugExists(slug)
Svc->>Repo: createUrl(data)
Svc-->>Ctrl: UrlResponse
Ctrl-->>C: 201 + url
Note over C,DB: Redirect
C->>Ctrl: GET /:slug
Ctrl->>Svc: findBySlug(slug)
Svc->>Cache: get(url:slug:{slug})
alt Cache HIT
Cache-->>Svc: cached url
else Cache MISS
Svc->>Repo: findBySlug(slug)
Repo-->>Svc: url from DB
Svc->>Cache: set(url:slug:{slug}, url, 3600)
end
Svc-->>Ctrl: UrlResponse
Note over Ctrl: Async: recordClick (BullMQ)
Ctrl-->>C: 302 redirect to originalUrl
erDiagram
users {
bigint id PK
text email UK
text password_hash
text name
timestamptz created_at
timestamptz updated_at
}
refresh_tokens {
bigint id PK
text token UK
bigint user_id FK
timestamptz expires_at
timestamptz created_at
boolean revoked
}
urls {
bigint id PK
text slug UK
text original_url
bigint user_id FK
timestamptz created_at
timestamptz updated_at
}
click_events {
bigint id PK
bigint url_id FK
timestamptz created_at
text referrer
text user_agent
}
users ||--o{ refresh_tokens : "has refresh tokens"
users ||--o{ urls : "creates urls"
urls ||--o{ click_events : "tracks clicks"
| Method | TODO |
|---|---|
onModuleInit() |
Connect to database |
onModuleDestroy() |
Disconnect from database |
# After implementing schema
pnpm run db:migrate
pnpm run db:generate| Method | TODO |
|---|---|
createUser(data) |
INSERT INTO users, return user |
findByEmail(email) |
SELECT user by email |
findById(id) |
SELECT user by id |
findPasswordByEmail(email) |
SELECT id + password_hash by email |
createRefreshToken(data) |
INSERT INTO refresh_tokens |
findRefreshToken(token) |
SELECT token by value |
revokeRefreshToken(token) |
UPDATE revoked = true |
revokeAllRefreshTokens(userId) |
UPDATE all user's tokens revoked = true |
| Method | TODO |
|---|---|
register(dto) |
Hash password (bcrypt), create user, generate tokens |
login(dto) |
Validate password (bcrypt), generate tokens |
me(userId) |
Find user by id |
rotate(refreshToken) |
Validate token, revoke old, create new |
logout(refreshToken) |
Revoke refresh token |
| Method | TODO |
|---|---|
createUrl(data) |
INSERT INTO urls, return url |
findBySlug(slug) |
SELECT url by slug |
findById(id) |
SELECT url by id |
deleteUrl(id) |
DELETE url by id |
slugExists(slug) |
SELECT COUNT by slug |
| Method | TODO |
|---|---|
createUrl(dto, userId?) |
Generate/check slug, create url |
findBySlug(slug) |
Delegate to repository |
findById(id) |
Delegate to repository |
deleteUrl(id, userId) |
Check ownership, delete |
generateSlug() |
Generate 7-char alphanumeric |
| Method | TODO |
|---|---|
recordClick(data) |
INSERT INTO click_events |
getTotalClicks(urlId) |
SELECT COUNT by url_id |
getClicksByDate(urlId, from?, to?) |
SELECT GROUP BY date |
getClicksByReferrer(urlId) |
SELECT GROUP BY referrer |
getStats(urlId, from?, to?) |
Combine 3 queries above |
| Method | TODO |
|---|---|
get<T>(key) |
Get from Redis |
set<T>(key, value, ttlSeconds?) |
Set to Redis with TTL |
del(key) |
Delete from Redis |
isConnected() |
Check Redis connection |
Install @nestjs/swagger + add decorators to controllers and DTOs.
pnpm add @nestjs/swaggerImport all feature modules + register global filter + interceptor.
Add helmet, cors, cookie-parser, rate-limit, swagger setup.
Multi-stage Dockerfile + update docker-compose.yml with app service.
Create src/modules/analytics/analytics.processor.ts as BullMQ processor. Register in analytics.module.ts.
Create .github/workflows/ci.yml — lint + build + test on PR.
Write integration tests for newly implemented features:
| File | Status | Description |
|---|---|---|
src/modules/auth/auth.repository.integration-spec.ts |
EXISTS | Test auth CRUD against real DB |
src/modules/urls/urls.repository.integration-spec.ts |
MISSING | Test URL CRUD against real DB |
src/modules/analytics/analytics.repository.integration-spec.ts |
MISSING | Test click tracking and analytics queries |
src/modules/cache/cache.service.integration-spec.ts |
MISSING | Test Redis get/set/del operations |
# After implementing + writing tests
docker-compose up -d postgres redis
pnpm run db:migrate
pnpm run test:integration
pnpm run test:e2eReview existing unit tests, write missing ones, delete outdated ones:
| File | Action |
|---|---|
auth.service.spec.ts |
Review + update for real implementation |
auth.controller.spec.ts |
Review + update |
urls.service.spec.ts |
Review + update |
analytics.service.spec.ts |
Review + update |
cache.service.spec.ts |
Review + update |
# Verify all unit tests pass
pnpm run test:unitWrite e2e tests for each endpoint:
| File | Status | Description |
|---|---|---|
test/auth.e2e-spec.ts |
EXISTS | Test register, login, rotate, logout, me |
test/shorten.e2e-spec.ts |
EXISTS | Test create, redirect |
test/app.e2e-spec.ts |
EXISTS | Test health check |
test/urls.e2e-spec.ts |
MISSING | Test stats (GET /urls/:id/stats) + delete (DELETE /urls/:id) |
# Verify all e2e tests pass
pnpm run test:e2e# Build
pnpm run build
# Lint
pnpm run lint
# Unit tests (mock-based, no DB needed)
pnpm run test:unit
# Integration tests (need DB)
pnpm run test:integration
# E2E tests (need DB + Redis)
pnpm run test:e2e
# Database
pnpm run db:migrate
pnpm run db:generate
# Docker (infrastructure only)
docker-compose up -d postgres redis
docker-compose down
# Docker (full app with build)
docker-compose up -d --build
docker-compose down
# Docker (rebuild from scratch)
docker-compose down -v
docker-compose up -d --build
# View logs
docker-compose logs -f app
docker-compose logs -f postgres
docker-compose logs -f redis
# Check status
docker-compose psExpected state:
test:unit→ all PASS (after implementation)test:integration→ all PASS (after implementation)test:e2e→ all PASS (after implementation)
url-shortener-api/
├── .github/workflows/ci.yml ← TODO
├── prisma/
│ ├── schema.prisma ← TODO (4 tables)
│ └── sql/
├── src/
│ ├── main.ts ← TODO (helmet, cors, rate-limit, swagger)
│ ├── app.module.ts ← TODO (import modules)
│ ├── app.controller.ts
│ ├── app.controller.spec.ts
│ ├── app.service.ts
│ ├── prisma/
│ │ ├── prisma.module.ts
│ │ └── prisma.service.ts ← TODO (PrismaClient init)
│ ├── common/
│ │ ├── guards/jwt-auth.guard.ts
│ │ ├── decorators/current-user.decorator.ts
│ │ ├── pipes/zod-validation.pipe.ts
│ │ ├── filters/zod-exception.filter.ts
│ │ ├── interceptors/response.interceptor.ts
│ │ └── types/api-response.ts
│ └── modules/
│ ├── auth/
│ │ ├── interfaces/
│ │ ├── dto/
│ │ ├── strategies/jwt.strategy.ts
│ │ ├── auth.module.ts
│ │ ├── auth.controller.ts
│ │ ├── auth.controller.spec.ts
│ │ ├── auth.service.ts ← TODO (bcrypt, JWT)
│ │ ├── auth.service.spec.ts
│ │ ├── auth.repository.ts ← TODO (TypedSQL)
│ │ └── auth.repository.integration-spec.ts
│ ├── urls/
│ │ ├── interfaces/
│ │ ├── dto/
│ │ ├── urls.module.ts
│ │ ├── urls.controller.ts ← TODO (add stats endpoint, inject AnalyticsService)
│ │ ├── urls.service.ts ← TODO (slug gen, cache)
│ │ └── urls.repository.ts ← TODO (TypedSQL)
│ ├── analytics/
│ │ ├── interfaces/
│ │ ├── analytics.module.ts
│ │ ├── analytics.processor.ts ← TODO (BullMQ in-app worker)
│ │ ├── analytics.service.ts
│ │ └── analytics.repository.ts ← TODO (TypedSQL)
│ └── cache/
│ ├── interfaces/
│ ├── cache.module.ts
│ ├── cache.service.ts ← TODO (ioredis)
│ └── cache.service.spec.ts
├── test/
│ ├── jest-e2e.json
│ ├── setup.ts
│ ├── auth.e2e-spec.ts
│ ├── app.e2e-spec.ts
│ └── shorten.e2e-spec.ts
├── Dockerfile ← TODO (multi-stage)
├── docker-compose.yml
├── .env
├── .env.example
├── package.json
└── README.md
DATABASE_URL=postgresql://postgres:postgres@localhost:5435/urlshortener_dev
JWT_SECRET=your-secret-key
JWT_REFRESH_SECRET=your-refresh-secret-key
REDIS_URL=redis://localhost:6379
PORT=3000-
pnpm run build— 0 errors -
pnpm run lint— 0 errors -
pnpm run test:unit— all PASS -
pnpm run test:integration— all PASS -
pnpm run test:e2e— all PASS - Redirect uses cache-aside pattern
- Click events logged async (BullMQ)
- Rate limit works (spam → 429)
- Swagger UI accessible at
/docs -
docker-compose upbrings up all services - CI runs on every PR (lint + test)
- Cache-aside vs read-through vs write-through
- Cache stampede and prevention
- JWT access + refresh token flow
- Token rotation security
- Prisma TypedSQL vs raw queries
- BullMQ job retry vs dead letter queue
- Swagger decorators (@ApiProperty, @ApiOperation, @ApiResponse)
- Docker multi-stage build benefits
- GitHub Actions workflow structure