# Fur Finder NestJS Backend — Project Description

> **This document is living documentation.** As new modules are migrated and features are added,
> append new sections following the established format. Do not delete existing sections.

---

## Overview

**Fur Finder** is a pet community platform that helps users find lost pets, register pet profiles,
connect with pet owners, and engage with adoption shelters. This NestJS backend is a ground-up
rewrite of a legacy Express.js backend (`furfinder_backend`), adopting modern architecture patterns,
full type-safety, and Kysely-based PostgreSQL querying.

- **Framework:** NestJS (Express adapter)
- **Language:** TypeScript
- **Database:** PostgreSQL via Kysely query builder
- **Auth:** JWT Bearer tokens (RS256, `AuthGuard`)
- **Validation:** Zod schemas via `nestjs-zod` (global pipe + serializer)
- **Logging:** Pino via `pino-nestjs` + `LogModule`
- **Push Notifications:** Expo Push Notification SDK
- **File Uploads:** Multer via `@nestjs/platform-express`
- **Rate Limiting:** `ThrottlerGuard` — 100 requests per 10 minutes globally

---

## Project Structure

```
src/
├── main.ts                        # Bootstrap: Pino logger, static assets, CORS, listen
├── app.module.ts                  # Root module — imports all feature modules
├── config/
│   ├── configs.ts                 # Typed env var config factory (AppConfig)
│   ├── config.module.ts           # AppConfigModule (global ConfigModule.forRoot)
│   └── appconfig.validation.ts    # Joi/env validation schema
├── database/
│   ├── database.provider.ts       # pg Pool + Kysely<DB> providers
│   ├── database.module.ts         # DatabaseModule (exports Pool + Kysely)
│   ├── database.type.ts           # Auto-generated Kysely DB type (via kysely-codegen)
│   └── migrations/                # Kysely migration files
├── logger/
│   └── logger.module.ts           # Pino rolling log transport + LogModule
├── middleware/
│   ├── global-exception.filter.ts # GlobalExceptionFilter (APP_FILTER)
│   ├── roles.guard.ts             # RolesGuard — checks @Roles() decorator on user.role
│   └── roles.decorator.ts         # @Roles(...roles) metadata decorator
├── utils/
│   ├── auth.helpers.ts            # AuthHelper — verifyToken / signToken JWT utilities
│   ├── image/                     # ImageService — Cloudflare/S3 upload helpers
│   ├── flyer/                     # FlyerService — node-canvas PDF/image renderer
│   ├── qr/                        # QrService — in-memory base64 QR code generator
│   └── utils.module.ts            # SharedUtilsModule (exports all util services)
└── modules/
    ├── auth/                      # JWT register/login/me/password + device auth
    ├── devices/                   # Device upsert & Expo push token management
    ├── mail/                      # Nodemailer OTP/transactional email service
    ├── push/                      # PushService — Expo push notification dispatcher
    ├── users/                     # User profile, credits, blocking, content reports
    ├── pets/                      # Pet profiles, lost/found reports, claims
    ├── suburbs/                   # Suburb lookup and info
    ├── businessConfig/            # App settings / feature flags from DB
    ├── errors/                    # Client-side crash/error reporting
    ├── ads/                       # Active ad campaign retrieval
    ├── analytics/                 # Anonymous & user event telemetry
    ├── billing/                   # IAP receipt verification, subscriptions, webhooks
    └── admin/                     # Admin-only manual premium grants
```

---

## Application Bootstrap (`main.ts`)

```
NestFactory.create<NestExpressApplication>(AppModule)
  → useLogger(Pino)
  → useStaticAssets('./public')       ← serves /uploads/flyers/* etc.
  → enableCors({ origin: '*' })
  → listen(PORT ?? 3000)
```

---

## Global Middleware Pipeline

Every HTTP request passes through this global pipeline (registered in `AppModule.providers`):

```
Request
  │
  ├─ ThrottlerGuard        → 100 req / 10 min (rate limit)
  ├─ RolesGuard            → checks @Roles() metadata (APP_GUARD)
  ├─ ZodValidationPipe     → validates @Body() against Zod schemas (APP_PIPE)
  ├─ [Route Handler]
  ├─ ZodSerializerInterceptor → strips extra fields from response (APP_INTERCEPTOR)
  └─ GlobalExceptionFilter → catches all thrown exceptions, formats JSON error (APP_FILTER)
```

**AuthGuard** is applied **per-route** or **per-controller** (`@UseGuards(AuthGuard)`), not globally.
It extracts the `Bearer` token from `Authorization` header, calls `AuthHelper.verifyToken()`,
and attaches the decoded payload to `req.user`.

**OptionalAuthGuard** is used on routes that serve both authenticated and anonymous users.
It silently ignores invalid/missing tokens rather than throwing `401`.

---

## Environment Variables (`.env`)

| Variable | Description |
| --- | --- |
| `PORT` | HTTP server port (default: 3000) |
| `JWT_SECRET` | JWT signing secret |
| `JWT_EXPIRES_IN` | JWT expiry (default: `7d`) |
| `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME` | PostgreSQL connection |
| `GMAIL_FROM_ADDRESS`, `GMAIL_USERNAME`, `GMAIL_PASSWORD`, `GMAIL_HOST`, `GMAIL_PORT` | Mail (nodemailer) |
| `EXPO_ACCESS_TOKEN` | Expo push notification access |
| `DISCORD_WEBHOOK` | Discord error/alert webhook |
| `VIBER_AUTH_TOKEN` | Viber notification channel |

---

## Module Reference

---

### AuthModule — `POST/GET /auth/*`

**File:** `src/modules/auth/`
**Dependencies:** `DeviceModule`, `MailModule`, `JwtModule`

Handles user registration, login, JWT issuance, password reset via OTP, and device session tracking.

| Method | Route | Auth | Description |
| --- | --- | --- | --- |
| `POST` | `/auth/register` | Public | Create account, hash password, award referral days, attach device, return JWT |
| `POST` | `/auth/login` | Public | Verify credentials, attach device, return JWT |
| `GET` | `/auth/me` | ✅ Bearer | Return current authenticated user profile |
| `POST` | `/auth/forgot-password` | Public | Send OTP email to registered address |
| `POST` | `/auth/reset-password` | Public | Validate OTP and reset password hash |
| `POST` | `/auth/devices/register` | ✅ Bearer | Upsert device record (push token, platform) |
| `POST` | `/auth/devices/logout` | ✅ Bearer | Detach push token from user (logout) |

**Flow — Register:**
```
POST /auth/register
  → Validate CreateUserDto (Zod)
  → Check email uniqueness (Kysely)
  → Hash password (bcrypt)
  → Insert user row
  → Check referral code → award premium days (transaction)
  → computeCredits() → set basic_credits
  → DeviceService.upsertDevice()
  → AuthHelper.signToken() → return JWT
```

---

### UsersModule — `* /users/*`

**File:** `src/modules/users/`
**Dependencies:** `AuthModule`
**All routes protected with `@UseGuards(AuthGuard)` at controller level**

| Method | Route | Description |
| --- | --- | --- |
| `PUT` | `/users/push-token` | Update Expo push token for current user |
| `GET` | `/users/blocked` | List all users blocked by current user |
| `POST` | `/users/block` | Block a user by ID |
| `DELETE` | `/users/:id/block` | Unblock a user |
| `POST` | `/users/content-report` | Submit a content abuse report |
| `GET` | `/users/credits` | Get credit balance (`basic_credits + additional_credits`) |
| `POST` | `/users/credits/deduct` | Deduct credits for a feature use (Kysely `FOR UPDATE` lock) |
| `POST` | `/users/credits/reward-ad` | Grant rewarded-ad bonus credits |

**Credits system:** Users have `basic_credits` (subscription-based monthly reset) and
`additional_credits` (purchased). Deductions consume `basic_credits` first, then `additional_credits`.
All credit mutations are wrapped in Kysely transactions with `FOR UPDATE` row locks to prevent race conditions.

---

### DeviceModule — Internal

**File:** `src/modules/devices/`

Internal-only service. No HTTP controller. Consumed by `AuthModule` and `UsersModule`.

- `upsertDevice(data)` — Insert or update a device record with push token + platform
- `detachDeviceUser(deviceId)` — Null out `user_id` on device (logout)
- `getDevicePushTokens(deviceId)` — Return Expo tokens for a device
- `getUserPushTokens(userId)` — Return all Expo tokens for a user
- `getAnonymousPushTokens(limit)` — Return up to N anonymous device tokens

---

### MailModule — Internal

**File:** `src/modules/mail/`

Internal-only service. No HTTP controller. Consumed by `AuthModule`.

- `sendOtpEmail(to, otp)` — Sends OTP password reset email via nodemailer (SMTP/Gmail)

---

### PushModule — Internal

**File:** `src/modules/push/`

Internal-only service. No HTTP controller. Consumed by `ClaimsModule`, `NotificationsModule`.

- `sendPushNotification(token, userId, title, message, data)` — Dispatches Expo push with 3-retry logic

---

### PetsModule — `/pet/*`

**File:** `src/modules/pets/`
**Dependencies:** `SharedUtilsModule` (ImageService, FlyerService, QrService), `PushModule`

The largest module, split into four sub-controllers sharing one module container:

#### PetsController — Base `/pet`

| Method | Route | Auth | Description |
| --- | --- | --- | --- |
| `GET` | `/pet/get` | Public | Health/status stub |
| `POST` | `/pet/generate-flyer` | Public | Render a pet flyer as PNG using node-canvas + in-memory QR code |
| `POST` | `/pet/subscription-access/apply` | ✅ Bearer | Transactionally deactivate old profiles/reports and activate selected ones |

**Flyer Generation flow:**
```
POST /pet/generate-flyer
  → Validate GenerateFlyerDto
  → QrService.generateBase64QR()       ← in-memory, no disk I/O
  → FlyerService.drawCanvas(qrDataUrl)  ← node-canvas rendering
  → fs.promises.mkdir + writeFile       ← async, non-blocking
  → upsert flyer record (Kysely onConflict)
  → return { url: '/uploads/flyers/<id>.png' }
```

#### ProfilesController — `/pet/profiles`

| Method | Route | Auth | Description |
| --- | --- | --- | --- |
| `GET` | `/pet/profiles` | ✅ Bearer | List all profiles owned by user (active + inactive) |
| `GET` | `/pet/profiles/all` | Public | List all active profiles sitewide |
| `GET` | `/pet/profiles/suburb/:suburb` | Public | Filter profiles matching suburb name |
| `POST` | `/pet/profiles` | ✅ Bearer | Create profile; upload photos + biometric photos via `FileFieldsInterceptor` |
| `GET` | `/pet/profiles/:id` | ✅ Bearer | Fetch profile detail with `is_owner` flag computed for requester |
| `GET` | `/pet/profiles/shared/:id` | ✅ Bearer | Public-facing shared profile view |
| `PUT` | `/pet/profiles/:id` | ✅ Bearer | Update profile fields and replace photos |
| `DELETE` | `/pet/profiles/:id` | ✅ Bearer | Soft-delete (`deleted_at = NOW()`) |

Free tier limit: checked against `app_settings.max_free_profiles` before creating.

#### ClaimsController — `/pet/claims`

**All routes protected at controller level.**

| Method | Route | Description |
| --- | --- | --- |
| `GET` | `/pet/claims` | Filter sent/received claim rows by `status` and `type` query params |
| `POST` | `/pet/claims/approve/:id` | Approve claim → Kysely transaction → update status + insert notification + push alert |
| `POST` | `/pet/claims/reject/:id` | Reject claim → same transaction pattern |
| `POST` | `/pet/claims/create/:id` | Link a found-report to a lost-report claim |
| `GET` | `/pet/claims/pet/:pet_id` | All claims linked to a specific pet profile |

#### ReportsController — `/pet/reports`

| Method | Route | Auth | Description |
| --- | --- | --- | --- |
| `GET` | `/pet/reports` | Optional | Query active/saved reports; supports geo-radius, suburb, pet type filters |
| `GET` | `/pet/reports/reunited` | Public | Paginated reunion stories for landing page |
| `GET` | `/pet/reports/stats` | Public | Count breakdown: lost / found / reunited |
| `GET` | `/pet/reports/:id` | Optional | Full detail: report + comments + timeline events |
| `POST` | `/pet/reports` | ✅ Bearer | Create lost or found pet report with photo uploads |
| `POST` | `/pet/reports/report-my-pet` | ✅ Bearer | Convert an owned pet profile into a lost pet report |
| `PUT` | `/pet/reports/:id` | ✅ Bearer | Update report fields |
| `DELETE` | `/pet/reports/:id` | ✅ Bearer | Soft-delete report |
| `POST` | `/pet/reports/:id/like` | ✅ Bearer | Toggle like counter |
| `POST` | `/pet/reports/:id/comment` | ✅ Bearer | Add comment + append timeline entry |
| `POST` | `/pet/reports/:id/reward` | ✅ Bearer | Add custom fund to reward pool |
| `POST` | `/pet/reports/:id/boost` | ✅ Bearer | Boost report search priority score |
| `POST` | `/pet/reports/:id/reunited` | ✅ Bearer | Mark pet reunited; stops active flyer distribution |
| `POST` | `/pet/reports/:id/save` | ✅ Bearer | Save report to user's watchlist |
| `POST` | `/pet/reports/:id/unsave` | ✅ Bearer | Remove from watchlist |

Geo-proximity queries use `sql\`earth_distance(...)\`` Kysely raw templates.
Optional-auth reports mask sensitive fields (phone, owner name) for anonymous viewers.

---

### SuburbsModule — `/suburbs`

**File:** `src/modules/suburbs/`

| Method | Route | Auth | Description |
| --- | --- | --- | --- |
| `GET` | `/suburbs` | Public | List all suburb names from DB |
| `GET` | `/suburbs/info` | Public | Return metadata for a given `?suburb=` query param |

---

### BusinessConfigModule — `/config`

**File:** `src/modules/businessConfig/`

| Method | Route | Auth | Description |
| --- | --- | --- | --- |
| `GET` | `/config` | Public | Return all active key/value pairs from `app_settings` table |

Used by mobile clients to read feature flags, limits (`max_free_profiles`, `ads_enabled`), and
dynamic UI strings without requiring an app release.

---

### ErrorsModule — `/errors`

**File:** `src/modules/errors/`

| Method | Route | Auth | Description |
| --- | --- | --- | --- |
| `POST` | `/errors/report` | Public | Log client crash reports to `error_reports` table |

Accepts optional `user_id` from request context or body payload. Used for mobile crash telemetry.

---

### AdsModule — `/ads`

**File:** `src/modules/ads/`

| Method | Route | Auth | Description |
| --- | --- | --- | --- |
| `GET` | `/ads/active` | Public | Return up to 5 approved, non-expired ads in random order |

Checks `app_settings.ads_enabled` before querying — returns empty array if globally disabled.
Parses `photo_uris` JSON field into an array on the response.

---

### AnalyticsModule — `/analytics`

**File:** `src/modules/analytics/`

| Method | Route | Auth | Description |
| --- | --- | --- | --- |
| `POST` | `/analytics/event` | Optional | Log a named analytics event with sanitized properties |

**Sanitization rules:**
- `eventName` truncated to 100 chars
- Sensitive keys (`email`, `phone`, `password`, `name`, `token`) stripped from `properties`
- Inserts to `analytics_events` table with `user_id`, `session_id`, `platform`

---

### BillingModule — `/billing`, `/billing/webhooks`

**File:** `src/modules/billing/`
**Dependencies:** `UsersModule`, `AuthModule`

#### BillingController — `/billing`

| Method | Route | Auth | Description |
| --- | --- | --- | --- |
| `POST` | `/billing/verify` | ✅ Bearer | Verify App Store (iOS) or Play Store (Android) receipt |
| `GET` | `/billing/subscription` | ✅ Bearer | Fetch user's latest active subscription record |
| `GET` | `/billing/history` | ✅ Bearer | Full purchase event history |

**Receipt verification flow:**
```
POST /billing/verify
  → verifyApplePurchase() or verifyGooglePurchase()
     → normalize: transactionId, expiresAt, status
  → Kysely transaction:
     → Check if originalTransactionId bound to another account
     → If forceTransfer=true → re-bind subscription + purchase_events rows
     → upsertSubscription() → INSERT or UPDATE subscriptions + UPDATE users.premium_until
     → logPurchaseEvent() → INSERT purchase_events (ON CONFLICT DO NOTHING)
     → If credit pack → UsersService.grantAdditionalCreditsForPurchase()
  → Return active status + transactionId
```

#### WebhooksController — `/billing/webhooks`

| Method | Route | Auth | Description |
| --- | --- | --- | --- |
| `POST` | `/billing/webhooks/apple` | Public | Process Apple server-to-server S2S subscription events |
| `POST` | `/billing/webhooks/google` | Public | Process Google Real-Time Developer Notifications (RTDN) |

**Webhook flow:**
```
POST /billing/webhooks/apple|google
  → eventIdFromPayload() — SHA256 hash-derived deduplication key
  → Kysely transaction:
     → INSERT webhook_events ON CONFLICT (source, event_id) DO NOTHING
     → If duplicate → return { processed: false, duplicate: true }
     → upsertSubscription() → update status (active/expired/cancelled/revoked)
     → logPurchaseEvent()
     → UPDATE webhook_events SET processed = true
```

**Helper utilities:**
- `verifyApple.ts` — normalizes iOS receipt fields into `NormalizedStorePurchase`
- `verifyGoogle.ts` — normalizes Android Play billing fields
- `subscriptionEngine.ts` — type-safe Kysely upsert for `subscriptions` + `users.premium_until`
- `purchaseLogger.ts` — conflict-safe insert into `purchase_events`
- `billingProducts.ts` — product ID catalog and alias maps

**Subscription plans:** `pr_monthly`, `pr_yearly`
**Credit packs:** `10_credits` ($1.99), `100_credits` ($14.99, 25% off), `500_credits` ($39.99, 60% off)

---

### AdminModule — `/admin`

**File:** `src/modules/admin/`
**Dependencies:** `BillingModule`, `AuthModule`
**All routes require `AuthGuard` + `RolesGuard` + `@Roles('admin')`**

| Method | Route | Auth | Description |
| --- | --- | --- | --- |
| `POST` | `/admin/grant-premium` | ✅ Admin | Manually grant premium subscription days to any user |

**Flow:**
```
POST /admin/grant-premium { user_id, days, reason }
  → Validate user_id + days (positive integer)
  → BillingService.grantPremium()
     → Kysely transaction:
        → upsertSubscription(source: 'manual', transactionId: manual_<uuid>)
        → logPurchaseEvent(eventType: 'manual_grant')
     → return { premiumUntil: Date }
```

---

## Cross-Cutting Patterns

### Authentication Pattern

```typescript
// Full auth — throws 401 if no/invalid token
@UseGuards(AuthGuard)
someRoute(@Req() req) {
  const userId = req.user.id;  // JwtPayload
}

// Optional auth — silently ignores missing/invalid tokens
@UseGuards(OptionalAuthGuard)
someRoute(@Req() req) {
  const userId = req.user?.id ?? null;
}
```

### Role-Based Access Control

```typescript
@UseGuards(AuthGuard, RolesGuard)
@Roles('admin')
@Controller('admin')
export class AdminController { }
```

`RolesGuard` reads `@Roles()` metadata from `Reflector`, checks `req.user.role` matches.
If no `@Roles()` decorator is present, all authenticated users are allowed.

### Kysely Transaction Pattern

```typescript
return await this.db.transaction().execute(async (trx) => {
  // All operations inside auto-rollback on exception
  await trx.updateTable('...').set({...}).execute();
  await trx.insertInto('...').values({...}).execute();
});
```

### Response Shape Convention

All service methods return structured objects that pass through `ZodSerializerInterceptor`:

```typescript
{ status: true, statusCode: 200, data: { ... } }   // success
{ status: false, statusCode: 4xx, message: '...' }  // error (thrown as NestJS exceptions)
```

HTTP exceptions (`BadRequestException`, `NotFoundException`, etc.) are caught by
`GlobalExceptionFilter` and formatted consistently.

### File Upload Pattern

```typescript
@UseInterceptors(FileFieldsInterceptor([
  { name: 'photos', maxCount: 10 },
  { name: 'biometricPhotos', maxCount: 10 },
]))
async createProfile(@UploadedFiles() files?: { photos?: Express.Multer.File[] }) {
  // files passed to ImageService for upload to storage
}
```

---

## Database Layer

- **ORM:** Kysely (type-safe query builder, not an ORM)
- **Connection:** `pg.Pool` (max 20 connections), shared between `Pool` provider and `Kysely` provider
- **Type generation:** `kysely-codegen` → `database.type.ts` — regenerate when schema changes
- **Migrations:** Kysely `Migrator` — files in `src/database/migrations/`

**Key tables referenced:**

| Table | Purpose |
| --- | --- |
| `users` | User accounts, credits, premium status |
| `devices` | Device records with Expo push tokens |
| `pet_profiles` | Pet identity profiles |
| `pet_reports` | Lost & found reports |
| `pet_claims` | Claim linking lost → found reports |
| `report_comments` | Comments on reports |
| `report_timeline` | Event timeline entries per report |
| `subscriptions` | Active IAP subscription records |
| `purchase_events` | Full purchase event audit log |
| `webhook_events` | Deduplicated store webhook event log |
| `notifications` | In-app notification history |
| `analytics_events` | User analytics event telemetry |
| `app_settings` | Key/value feature flags and config |
| `ads` | Advertisement campaigns |
| `error_reports` | Client crash report submissions |
| `suburbs` | Suburb location data |

---

## Pending Modules (Not Yet Migrated)

| Priority | Module | Route Prefix | Status |
| --- | --- | --- | --- |
| 5 | Notifications | `/notifications` | ⬜ Pending |
| 7 | Conversations | `/conversations` | ⬜ Pending |
| 8 | AI | `/ai` | ⬜ Pending |
| 9 | Referrals | `/referral` | ⬜ Pending |
| 10 | Stories | `/reunited-stories` | ⬜ Pending |
| 11 | Quick Snaps | `/quick-snaps` | ⬜ Pending |
| 15 | Partners/Org | `/org` | ⬜ Pending |

See `migration_plan.md` (Phase 3.5) for detailed blueprints of each pending module.

---

## Adding a New Module (Checklist)

1. Create `src/modules/<name>/` directory
2. Add `<name>.service.ts` — inject `Kysely<DB>` and `Logger`
3. Add `<name>.controller.ts` — apply `@Controller('<prefix>')`, `@UseGuards(AuthGuard)` as needed
4. Add `<name>.dto.ts` — define Zod schemas with `nestjs-zod`
5. Add `<name>.module.ts` — import `AuthModule` if auth guards needed
6. Register `<NameModule>` in `src/app.module.ts` imports array
7. Update `migration_plan.md` status table
8. Update this file — add a new `### <Name>Module` section

---

*Last updated: 2026-05-28 | Migrated modules: Auth, Devices, Mail, Push, Users, Pets (Profiles + Claims + Reports), Suburbs, BusinessConfig, Errors, Ads, Analytics, Billing, Admin*
