---
title: Fur Finder API Migration Tracker (Express → NestJS)
tags: [migration, api, furfinder, nestjs, express]
status: complete
source_express: /Users/nlv-001/Documents/projects/fur-finder-proj/backends/backend-main
target_nestjs: /Users/nlv-001/Documents/projects/fur-finder-proj/backends/backendV2
generated: 2026-06-22
updated: 2026-07-26
---

# 🐾 Fur Finder — API Migration Tracker

> [!abstract] Purpose
> A **verified, endpoint-by-endpoint** record of the legacy **Express** backend API and its **NestJS** migration status. Use this to know exactly **what is done**, **what drifted**, and **what is left to migrate**. This file only **records** — it does **not** perform migration.
>
> - **Old (source):** `furfinder_backend` — Express, route files under `src/modules/**/**.route(s).ts`, mounted in [`src/routes/index.ts`].
> - **New (target):** `furfinder_nestjs` — NestJS controllers under `src/modules/**`.

---

## 🔑 Legend

| Symbol | Meaning |
|---|---|
| ✅ | **Done** — migrated, path + method + guard match |
| ⚠️ | **Drift** — migrated but **path/method/guard differs** from Express (will break clients) |
| 🟡 | **Partial** — exists but not wired up, or missing guards/limiter/attestation |
| ❌ | **Missing** — not migrated yet |
| 🔒 | Requires auth (`AuthGuard`) |
| 🌐 | Public (no auth) |
| 🚩 | Behind a **feature flag** in Express (`requireFeatureEnabled`) |
| 🛡️ | Has **attestation** (`requireAttestationRiskBased`) in Express |
| 🐢 | Has **sensitive rate limit** (`sensitiveRateLimit`) in Express |

> [!important] All Express routes are served under the global prefix `/api/v1`.
> NestJS [`src/main.ts`](src/main.ts) calls `app.setGlobalPrefix('api/v1')`. ✅

---

## 📊 Status Summary

| Module | Prefix | Endpoints | Done | Drift/Partial | Missing |
|---|---|---:|---:|---:|---:|
| [[#Auth]] | `/auth` | 10 | 10 | 0 | 0 |
| [[#Pets (general)]] | `/pet` | 3 | 3 | 0 | 0 |
| [[#Pet Reports (public)]] | `/pet/reports` | 6 | 6 | 0 | 0 |
| [[#Pet Reports (authenticated)]] | `/pet/reports` | 16 | 16 | 0 | 0 |
| [[#Pet Claims]] | `/pet/claims` | 5 | 5 | 0 | 0 |
| [[#Pet Profiles]] | `/pet/profiles` | 8 | 8 | 0 | 0 |
| [[#Users]] | `/users` | 8 | 8 | 0 | 0 |
| [[#AI]] | `/ai` | 5 | 5 | 0 | 0 |
| [[#Notifications]] | `/notifications` | 5 | 5 | 0 | 0 |
| [[#Conversations]] | `/conversations` | 7 | 7 | 0 | 0 |
| [[#Org / Partners]] | `/org` | 11 | 11 | 0 | 0 |
| [[#Ads]] | `/ads` | 6 | 6 | 0 | 0 |
| [[#Reunited Stories]] | `/reunited-stories` | 3 | 3 | 0 | 0 |
| [[#Referrals]] | `/referral` | 3 | 3 | 0 | 0 |
| [[#Suburbs]] | `/suburbs` | 2 | 2 | 0 | 0 |
| [[#Analytics]] | `/analytics` | 1 | 1 | 0 | 0 |
| [[#Errors]] | `/errors` | 1 | 1 | 0 | 0 |
| [[#Billing]] | `/billing` | 3 | 3 | 0 | 0 |
| [[#Webhooks]] | `/webhooks` | 2 | 2 | 0 | 0 |
| [[#Admin]] | `/admin` | 3 | 3 | 0 | 0 |
| [[#Config]] | `/config` | 1 | 1 | 0 | 0 |
| [[#Quick Snaps]] | `/quick-snaps` | 5 | 5 | 0 | 0 |
| [[#Invites]] | `/invites` | 2 | 2 | 0 | 0 |
| **TOTAL** | | **116** | **116** | **0** | **0** |

> [!success] Progress as of 2026-07-26
> **116/116 endpoints migrated.** `POST /auth/social` (Google/Apple OAuth, `google-auth-library`, signup bonus + referral hooks) is implemented and wired — the tracker previously listed it as missing but it was completed and never recorded here. Webhook source-secret validation and `webhook_events`-based dedup are both implemented (`processWebhook` in `billing.service.ts`). `/config` payload parity with Express's `getAppConfig()` verified field-for-field. `POST /pet/reports/search` already honours `breed`/`color`/`pet_type` array filters via `normalizeQueryList`. All previously missing modules created and wired: AI (5), Conversations (7), Org (11), Ads (5), Quick Snaps (5), Referrals (3), Invites (2), Admin feature-flags (2), Users referral (1). Security guards (`SensitiveRateLimitGuard`, `AttestationGuard`) applied to all required routes. A full DB-table audit (44 tables in `dump-furfinder-202607212105.sql`) confirmed every table is referenced by some module; the only tables with zero backend references (`blogs`, `faqs`, `how_it_works_steps`, `pricing_plans`, `websites`, `who_its_for_segments`) are unused in Express too — out of scope for this backend.

---

## 🚧 Global Blockers

- [x] **Global prefix** — `app.setGlobalPrefix('api/v1')` in [`src/main.ts`](src/main.ts). ✅
- [x] **Feature-flag guard** 🚩 — `FeatureFlagGuard` + `@RequireFeature(key)` in [`src/modules/feature-flags/`](src/modules/feature-flags/). Applied on Reports, Claims, Profiles, AI, Conversations, Referrals, Quick Snaps, Org `/public`. ✅
- [x] **Wallet / credit-ledger service** — `WalletService` in [`src/modules/wallet/`](src/modules/wallet/) (`@Global`). Used by AI, Ads, Billing. ✅
- [x] **Attestation guard** 🛡️ — [`src/middleware/attestation.guard.ts`](src/middleware/attestation.guard.ts). Reads `x-app-attestation-*` + `x-device-id`, blocks `high` risk with `403 ATTESTATION_REQUIRED`. Applied on Reports POST, Billing verify, AI detect-breed, Ads rewards. ✅
- [x] **Sensitive rate limiter** 🐢 — [`src/middleware/sensitive-rate-limit.guard.ts`](src/middleware/sensitive-rate-limit.guard.ts). 60 req/60s keyed by `userId:deviceId:ip`. Applied on Reports POST, Billing verify, AI detect-breed/dispute, Ads rewards. ✅
- [x] **Device-info middleware** — `x-device-id` parsed in `AttestationGuard` and `SensitiveRateLimitGuard`. Auth routes read `user-agent` + IP directly from request. ✅ (parity confirmed)

> [!note] Feature gate not yet applied everywhere
> Still to apply when needed: AI (`feature_ai_matching_enabled`) ✅ applied. Remaining: verify `feature_community_chat_enabled` on Conversations, `feature_referrals_enabled` on Referrals, `section_quick_snap_enabled` on Quick Snaps — all applied at controller level.

---

## Auth
`/auth` · Express: [`auth.route.ts`] · NestJS: [`auth.controller.ts`](src/modules/auth/auth.controller.ts)

| Method | Path | What it does | Express guard | NestJS | Notes |
|---|---|---|---|---|---|
| POST | `/register` | Creates user, sends email-verify OTP, returns JWT + profile | 🌐 | ✅ | Returns `emailVerificationRequired`, `otpExpiresAt`, `premium_*`, `is_email_verified` |
| POST | `/login` | Validates credentials, returns JWT + profile | 🌐 | ✅ | Returns `premium_until`, `premium_source`, `is_premium`, `is_email_verified` |
| POST | `/social` | Google/Apple OAuth login or signup | 🌐 | ✅ | Verifies Google ID token (`google-auth-library`) or Apple ID token (JWKS/RS256); links by `google_subject`/`apple_subject`, falls back to verified-email match, else creates a new user with signup bonus + referral hooks wired |
| GET | `/me` | Returns authenticated user's full profile | 🔒 | ✅ | |
| POST | `/verify-email` | Validates OTP to mark email verified | 🔒 | ✅ | |
| POST | `/verify-email/request-otp` | Re-sends email OTP | 🔒 | ✅ | |
| POST | `/forgot-password` | Sends OTP to registered email | 🌐 | ✅ | |
| POST | `/reset-password` | Resets password using valid OTP | 🌐 | ✅ | |
| POST | `/devices/register` | Upserts device push token | 🌐 | ✅ | `OptionalAuthGuard`; links to user when token present |
| POST | `/devices/logout` | Clears push token for device | 🌐 | ✅ | |

**Remaining:** none — `register`, `login`, and `socialLogin` all call `BillingService.grantSignupBonusPremium` and (where applicable) `ReferralsService.createPendingReferralFromSignup` / `processReferralValidationForUser`.

---

## Pets (general)
`/pet` · Express: [`pet.route.ts`] · NestJS: [`pets.controller.ts`](src/modules/pets/pets.controller.ts)

| Method | Path | What it does | Express guard | NestJS | Notes |
|---|---|---|---|---|---|
| GET | `/get` | Returns all user's pets (reports + profiles) | 🔒 | ✅ | |
| POST | `/generate-flyer` | Generates printable lost-pet flyer with QR code | 🔒 | ✅ | Uses `FlyerService` + `QrService` |
| POST | `/subscription-access/apply` | Applies user's subscription to selected pet access tier | 🔒 | ✅ | |

---

## Pet Reports (public)
`/pet/reports` 🚩`section_reports_enabled` · Express: [`reports.public.route.ts`] · NestJS: [`reports.controller.ts`](src/modules/pets/reports.controller.ts)

| Method | Path | What it does | Express guard | NestJS | Notes |
|---|---|---|---|---|---|
| GET | `/` | Paginated list of lost/found reports with optional geo filter | 🌐🚩 | ✅ | `OptionalAuthGuard`; enriches with save/like state when authenticated |
| GET | `/filter-options` | Distinct breeds, suburbs, pet types for filter UI | 🌐🚩 | ✅ | Covers `pet_reports` only (scraped reports need AI module) |
| POST | `/search` | Keyword + attribute search over reports | 🌐🚩 | ✅ | Maps body to `getAllReports` query shape |
| GET | `/reunited` | Recently reunited reports list | 🌐🚩 | ✅ | `OptionalAuthGuard` |
| GET | `/stats` | Aggregate stats (total, lost, found, reunited counts) | 🌐🚩 | ✅ | `OptionalAuthGuard` |
| GET | `/:id` | Single report detail with comments, likes, save state | 🌐🚩 | ✅ | `OptionalAuthGuard` |

---

## Pet Reports (authenticated)
`/pet/reports` 🚩 · Express: [`reports.route.ts`] · NestJS: [`reports.controller.ts`](src/modules/pets/reports.controller.ts)

| Method | Path | What it does | Guard | NestJS | Notes |
|---|---|---|---|---|---|
| GET | `/` | Same as public GET / but always with auth context | 🔒🚩 | ✅ | |
| POST | `/` | Creates lost/found report with photo upload | 🔒🚩🐢🛡️ | ✅ | `SensitiveRateLimitGuard + AttestationGuard` added |
| POST | `/my-pet` | Reports user's own registered pet as lost | 🔒🚩🐢🛡️ | ✅ | `SensitiveRateLimitGuard + AttestationGuard` added |
| GET | `/mine` | Returns only the authenticated user's reports | 🔒🚩 | ✅ | |
| GET | `/reunited` | Same as public but auth-scoped | 🔒🚩 | ✅ | |
| GET | `/stats` | Same as public stats | 🔒🚩 | ✅ | |
| GET | `/:id` | Same as public detail | 🔒🚩 | ✅ | |
| PUT | `/:id` | Updates report fields + photos | 🔒🚩 | ✅ | |
| DELETE | `/:id` | Soft-deletes report | 🔒🚩 | ✅ | |
| POST | `/:id/like` | Toggles like on a report | 🔒🚩 | ✅ | |
| POST | `/:id/comments` | Adds comment to a report | 🔒🚩 | ✅ | |
| POST | `/:id/reward` | Adds monetary reward to report's reward pool | 🔒🚩 | ✅ | |
| POST | `/:id/boost` | Boosts report visibility | 🔒🚩 | ✅ | |
| POST | `/:id/reunite` | Marks report as reunited, triggers story creation | 🔒🚩 | ✅ | |
| POST | `/:id/save` | Saves report to user's saved list | 🔒🚩 | ✅ | |
| DELETE | `/:id/save` | Removes report from saved list | 🔒🚩 | ✅ | |

---

## Pet Claims
`/pet/claims` 🚩`section_reports_enabled` · Express: [`claim.routes.ts`] · NestJS: [`claims.controller.ts`](src/modules/pets/claims.controller.ts)

| Method | Path | What it does | Guard | NestJS | Notes |
|---|---|---|---|---|---|
| GET | `/` | Lists all claims for the authenticated user | 🔒🚩 | ✅ | |
| GET | `/:pet_id` | Gets claim details for a specific pet | 🔒🚩 | ✅ | |
| POST | `/:id/claim` | Creates a new ownership claim on a report | 🔒🚩 | ✅ | |
| POST | `/:id/approve` | Approves a pending claim | 🔒🚩 | ✅ | |
| POST | `/:id/reject` | Rejects a pending claim | 🔒🚩 | ✅ | |

---

## Pet Profiles
`/pet/profiles` 🚩`section_reports_enabled` · Express: [`profile.routes.ts`] · NestJS: [`profiles.controller.ts`](src/modules/pets/profiles.controller.ts)

| Method | Path | What it does | Guard | NestJS | Notes |
|---|---|---|---|---|---|
| GET | `/` | Lists authenticated user's registered pet profiles | 🔒🚩 | ✅ | |
| GET | `/all` | List of all registered pet profiles | 🔒🚩 | ✅ | Express's route comment says "public", but `profile.routes.ts` is mounted under `/pet` in `routes/index.ts` with `requireAuth` applied to the whole sub-router — the comment was stale, actual behavior requires auth. NestJS now applies `AuthGuard` to match (fixed 2026-07-26; was previously public, a real access-control gap) |
| GET | `/suburb/:suburb` | Profiles filtered by suburb | 🔒🚩 | ✅ | Same fix as `/all` |
| POST | `/` | Creates a new pet profile with photos + biometric photos | 🔒🚩 | ✅ | Upload: `photos` + `biometricPhotos`, 5MB/file, image-only (`imageUploadOptions`) |
| GET | `/shared/:id` | Gets a profile via shareable link | 🔒🚩 | ✅ | |
| GET | `/:id` | Gets a single profile | 🔒🚩 | ✅ | |
| PUT | `/:id` | Updates profile fields + photos | 🔒🚩 | ✅ | |
| DELETE | `/:id` | Soft-deletes a profile | 🔒🚩 | ✅ | |

---

## Users
`/users` · Express: [`users.routes.ts`] · NestJS: [`users.controller.ts`](src/modules/users/users.controller.ts)

| Method | Path | What it does | Guard | NestJS | Notes |
|---|---|---|---|---|---|
| PUT | `/push-token` | Updates Expo push token for current device | 🔒 | ✅ | |
| GET | `/blocked` | Lists users the current user has blocked | 🔒 | ✅ | |
| POST | `/block` | Blocks a user by ID | 🔒 | ✅ | |
| DELETE | `/:id/block` | Unblocks a user | 🔒 | ✅ | |
| POST | `/content-report` | Reports a piece of content for moderation | 🔒 | ✅ | |
| DELETE | `/account` | Soft-deletes user account, clears push token | 🔒 | ✅ | |
| GET | `/credits` | Returns current credit balance (basic + additional) | 🔒 | ✅ | |
| GET | `/credit-activity` | Paginated credit ledger history | 🔒 | ✅ | Queries `credit_ledger` |
| GET | `/referral` | Lightweight referral stats for the current user | 🔒 | ✅ | Summary view; full state managed by `ReferralsModule` |

---

## AI
`/ai` 🚩`feature_ai_matching_enabled` · Express: [`ai.routes.ts`] · NestJS: [`ai.controller.ts`](src/modules/ai/ai.controller.ts)

| Method | Path | What it does | Guard | NestJS | Notes |
|---|---|---|---|---|---|
| GET | `/matches/:report_id` | Scores internal reports + org animals + scraped reports against a given report | 🔒🚩 | ✅ | Distance + color + breed scoring; notifies on best match ≥ 60 |
| POST | `/quick-snap-match` | Uploads photo, returns candidate matches (reports, profiles, org animals) | 🔒🚩 | ✅ | Upload: `photo` x1; returns shuffled top-15 |
| POST | `/scan-post` | Extracts lost/found attributes from text/URL and returns matches | 🔒🚩 | ✅ | NLP is keyword-based; match list via `quickSnapMatch` |
| POST | `/detect-breed` | Sends photos to breed classifier, deducts 1 credit, returns detection | 🔒🚩🐢🛡️ | ✅ | Idempotent via `x-idempotency-key`; flags non-animal detections |
| POST | `/detection-flag-dispute` | Submits a user dispute on a system-created flag | 🔒🚩🐢 | ✅ | Writes to `flagged_reports` with `flag_status = 'disputed'` |

---

## Notifications
`/notifications` · Express: [`notifications.routes.ts`] · NestJS: [`notifications.controller.ts`](src/modules/notifications/notifications.controller.ts)

| Method | Path | What it does | Guard | NestJS | Notes |
|---|---|---|---|---|---|
| GET | `/` | Last 50 notifications for the user | 🔒 | ✅ | |
| POST | `/send-push` | Manually sends push to device/user/token | 🔒 | ✅ | Uses `DeviceService` + `PushService` |
| POST | `/read-all` | Marks all notifications as read | 🔒 | ✅ | |
| PUT | `/:id/read` | Marks a single notification as read | 🔒 | ✅ | |
| DELETE | `/` | Soft-deletes all notifications for user | 🔒 | ✅ | |

> [!note] Internal notification helpers (`createSignupBonusNotification`, `createReferralSignupNotification`) from Express were not ported as standalone service methods — those flows are now inline within `BillingService` and `ReferralsService`.

---

## Conversations
`/conversations` 🚩`feature_community_chat_enabled` · Express: [`conversations.routes.ts`] · NestJS: [`conversations.controller.ts`](src/modules/conversations/conversations.controller.ts)

| Method | Path | What it does | Guard | NestJS | Notes |
|---|---|---|---|---|---|
| GET | `/` | Thread list with last message per conversation | 🔒🚩 | ✅ | |
| POST | `/` | Creates a conversation thread for a report | 🔒🚩 | ✅ | |
| POST | `/open` | Opens or creates a thread between two participants | 🔒🚩 | ✅ | Upsert by participant pair |
| GET | `/:id` | Summary of a single conversation (participants, report) | 🔒🚩 | ✅ | |
| GET | `/:id/messages` | Paginated messages in a conversation | 🔒🚩 | ✅ | `?limit` clamped to 100 |
| POST | `/:id/messages` | Sends a message in a conversation | 🔒🚩 | ✅ | Updates `last_message_text` on thread |
| POST | `/:id/read` | Marks all unread messages as read | 🔒🚩 | ✅ | |

> [!note] No WebSocket `ChatGateway` in Express either — real-time delivery was via polling. NestJS matches.

---

## Org / Partners
`/org` · Express: [`org.routes.ts`] · NestJS: [`org.controller.ts`](src/modules/org/org.controller.ts)

| Method | Path | What it does | Guard | NestJS | Notes |
|---|---|---|---|---|---|
| GET | `/public` | Public list of organisations (vets, shelters, rescues) | 🌐🚩`feature_vets_enabled` | ✅ | Supports text/type/geo/opening_type filters + Haversine sort |
| GET | `/:id` | Public detail for a single organisation | 🌐 | ✅ | |
| GET | `/:id/animals` | Adoptable animals listed by an org | 🌐 | ✅ | |
| POST | `/register` | Onboards a user as a partner organisation | 🔒 | ✅ | Moderation + conflict check + role update |
| GET | `/me` | Current user's organisation profile | 🔒 | ✅ | Includes animal count |
| PUT | `/me` | Updates organisation fields | 🔒 | ✅ | COALESCE patch |
| GET | `/me/animals` | Lists animals registered by current user's org | 🔒 | ✅ | |
| POST | `/me/animals` | Adds an animal to current user's org | 🔒 | ✅ | Org status check |
| GET | `/me/animals/:animalId` | Gets a single animal by current user's org | 🔒 | ✅ | |
| PUT | `/me/animals/:animalId` | Updates an animal's details | 🔒 | ✅ | |
| DELETE | `/me/animals/:animalId` | Soft-deletes an animal | 🔒 | ✅ | |

---

## Ads
`/ads` · Express: [`ads.routes.ts`] · NestJS: [`ads.controller.ts`](src/modules/ads/ads.controller.ts)

| Method | Path | What it does | Guard | NestJS | Notes |
|---|---|---|---|---|---|
| GET | `/active` | Returns active ads filtered by app toggles | 🌐 | ✅ | Respects `ads_enabled`, `google_ads_enabled`, `own_ads_enabled` |
| GET | `/reward/ssv/admob` | AdMob SSV callback — verifies signature, records reward | 🌐 | ✅ | Hash dedup; marks intent as `ssv_verified` |
| GET | `/reward/ssv/admob-callback` | Back-compat alias for the SSV callback | 🌐 | ✅ | |
| POST | `/reward/start` | Creates a pending reward intent | 🔒🐢🛡️ | ✅ | Custom vs admob path; 30-min expiry |
| POST | `/reward/claim` | Claims a verified reward intent for credits | 🔒🐢🛡️ | ✅ | `FOR UPDATE` lock; custom watch-time check; credit via `WalletService` |
| POST | `/reward/callback-complete` | Completes reward from admob SSV callback flow | 🔒🐢🛡️ | ✅ | Skips watch-time check for SSV-verified intents |

---

## Reunited Stories
`/reunited-stories` · Express: [`stories.routes.ts`] · NestJS: [`stories.controller.ts`](src/modules/stories/stories.controller.ts)

| Method | Path | What it does | Guard | NestJS | Notes |
|---|---|---|---|---|---|
| GET | `/public` | Published reunion stories, newest first | 🌐 | ✅ | |
| GET | `/:slug` | Single story by slug | 🌐 | ✅ | Declared after `/public` so literal wins |
| POST | `/` | Submits a reunion story; flips linked reports to `reunited` | 🔒 | ✅ | Content moderation check |

---

## Referrals
`/referral` 🚩`feature_referrals_enabled` · Express: [`referrals.routes.ts`] · NestJS: [`referrals.controller.ts`](src/modules/referrals/referrals.controller.ts)

| Method | Path | What it does | Guard | NestJS | Notes |
|---|---|---|---|---|---|
| GET | `/` | Referral code, share link, pending/completed reward summary | 🔒🚩 | ✅ | Also triggers validation pass |
| GET | `/stats` | Alias for GET `/` | 🔒🚩 | ✅ | Same handler |
| POST | `/log-share` | Records a share intent with fraud + cooldown scoring | 🔒🚩 | ✅ | Delayed validation; milestone reward check |

---

## Suburbs
`/suburbs` · Express: [`suburbs.routes.ts`] · NestJS: [`suburbs.controller.ts`](src/modules/suburbs/suburbs.controller.ts)

| Method | Path | What it does | Guard | NestJS | Notes |
|---|---|---|---|---|---|
| GET | `/` | Full suburb list | 🌐 | ✅ | |
| GET | `/info` | Suburb metadata (state, postcode lookup) | 🌐 | ✅ | |

---

## Analytics
`/analytics` · Express: [`analytics.routes.ts`] · NestJS: [`analytics.controller.ts`](src/modules/analytics/analytics.controller.ts)

| Method | Path | What it does | Guard | NestJS | Notes |
|---|---|---|---|---|---|
| POST | `/event` | Logs an analytics event | 🌐 | ✅ | Fire-and-forget; returns 204 |

---

## Errors
`/errors` · Express: [`errors.routes.ts`] · NestJS: [`errors.controller.ts`](src/modules/errors/errors.controller.ts)

| Method | Path | What it does | Guard | NestJS | Notes |
|---|---|---|---|---|---|
| POST | `/report` | Logs a client crash/error report | 🌐 | ✅ | Returns 204 |

---

## Billing
`/billing` · Express: [`billing.routes.ts`] · NestJS: [`billing.controller.ts`](src/modules/billing/billing.controller.ts)

| Method | Path | What it does | Guard | NestJS | Notes |
|---|---|---|---|---|---|
| POST | `/verify` | Verifies Apple/Google IAP receipt, grants subscription or credits | 🔒🐢🛡️ | ✅ | `SensitiveRateLimitGuard + AttestationGuard` added; idempotent via purchase token |
| GET | `/subscription` | Returns current subscription status + expiry | 🔒 | ✅ | |
| GET | `/history` | Paginated purchase history | 🔒 | ✅ | |

---

## Webhooks
`/webhooks` · Express: [`webhooks.routes.ts`] · NestJS: [`webhooks.controller.ts`](src/modules/billing/webhooks.controller.ts)

| Method | Path | What it does | Guard | NestJS | Notes |
|---|---|---|---|---|---|
| POST | `/webhooks/apple` | Handles Apple App Store server notification | 🌐 (secret) | ✅ | Constant-time secret check (`timingSafeEqual`) via query `?secret=`, `Authorization: Bearer`, or `x-webhook-secret` |
| POST | `/webhooks/google` | Handles Google Play RTDN notification | 🌐 (secret) | ✅ | Same secret check |

**Remaining:** none — `processWebhook` in `billing.service.ts` inserts into `webhook_events` with `ON CONFLICT (source, event_id) DO NOTHING` inside a transaction, matching Express's dedup exactly.

---

## Admin
`/admin` · Express: [`admin.routes.ts`] · NestJS: [`admin.controller.ts`](src/modules/admin/admin.controller.ts)

| Method | Path | What it does | Guard | NestJS | Notes |
|---|---|---|---|---|---|
| POST | `/grant-premium` | Manually grants premium subscription to a user | 🔒 admin | ✅ | `@Roles(['admin'])` enforced |
| GET | `/feature-flags` | Lists all feature flags from `app_settings` | 🔒 admin | ✅ | |
| PATCH | `/feature-flags/:key` | Toggles a feature flag and invalidates cache | 🔒 admin | ✅ | |

---

## Config
`/config` · Express: [`config.routes.ts`] · NestJS: [`businessConfig.controller.ts`](src/modules/businessConfig/businessConfig.controller.ts)

| Method | Path | What it does | Guard | NestJS | Notes |
|---|---|---|---|---|---|
| GET | `/` | Returns full app config: feature flags + version policy | 🌐 | ✅ | Verify payload parity with mobile client expectations |

---

## Quick Snaps
`/quick-snaps` 🚩`section_quick_snap_enabled` · Express: [`quick-snaps.routes.ts`] · NestJS: [`quick-snaps.controller.ts`](src/modules/quick-snaps/quick-snaps.controller.ts)

| Method | Path | What it does | Guard | NestJS | Notes |
|---|---|---|---|---|---|
| POST | `/` | Creates a quick snap with photo, runs classifier, sends nearby push | 🔒🚩 | ✅ | Upload: `photo` x1 |
| GET | `/mine` | Lists user's own quick snaps | 🔒🚩 | ✅ | |
| GET | `/:id` | Gets a quick snap (visibility + block check) | 🔒🚩 | ✅ | |
| POST | `/:id/flag-dispute` | Disputes a system classifier flag | 🔒🚩 | ✅ | |
| DELETE | `/:id` | Soft-deletes a quick snap | 🔒🚩 | ✅ | |

---

## Invites
`/invites` · Express: [`invites.routes.ts`] · NestJS: [`invites.controller.ts`](src/modules/invites/invites.controller.ts)

| Method | Path | What it does | Guard | NestJS | Notes |
|---|---|---|---|---|---|
| POST | `/send-test-invite` | Emails an Android beta test invite | 🌐 | ✅ | Config-checked download link |
| POST | `/send-update-invitation` | Sends app update push/email to a list | 🌐 | ✅ | `Promise.allSettled` batch |

---

## Inactive / Dead Modules

These exist in the Express `src/modules/` tree but are **NOT mounted** in [`src/routes/index.ts`]. Do **not** migrate.

- `modules/crud/**` — generic CRUD scaffold, unmounted
- `modules/public/**` — unmounted
- `modules/vets/**` — superseded by [[#Org / Partners]] (`/org`)
- `modules/wallet/wallet.service.ts` — internal service (used by ads/billing/referrals), no routes

---

## 🗂️ Shared Services & Middleware Crosswalk

| Express | Purpose | NestJS | Status |
|---|---|---|---|
| `utils/push.ts` | Expo push | [`modules/push/push.service.ts`](src/modules/push/push.service.ts) | ✅ |
| `utils/sendMailOtp.ts` | OTP email | [`modules/mail/mail.service.ts`](src/modules/mail/mail.service.ts) | ✅ |
| `modules/devices/device.service.ts` | Device upsert/tokens | [`modules/devices/device.service.ts`](src/modules/devices/device.service.ts) | ✅ |
| `utils/uploadToCDN.ts` | Image upload | [`utils/image/image.service.ts`](src/utils/image/image.service.ts) | ✅ |
| `utils/lost-canvas.ts` / `found-canvas.ts` | Flyer canvas | [`utils/flyer/flyer.service.ts`](src/utils/flyer/flyer.service.ts) | ✅ |
| QR (inline) | QR code | [`utils/qr/qr.service.ts`](src/utils/qr/qr.service.ts) | ✅ |
| `modules/wallet/wallet.service.ts` | Credit ledger | [`modules/wallet/wallet.service.ts`](src/modules/wallet/wallet.service.ts) | ✅ `@Global` |
| `middlewares/featureFlag.middleware.ts` | 🚩 feature gate | [`modules/feature-flags/feature-flag.guard.ts`](src/modules/feature-flags/feature-flag.guard.ts) | ✅ |
| `middlewares/attestation.ts` | 🛡️ attestation | [`middleware/attestation.guard.ts`](src/middleware/attestation.guard.ts) | ✅ |
| `middlewares/sensitiveRateLimit.ts` | 🐢 strict limiter | [`middleware/sensitive-rate-limit.guard.ts`](src/middleware/sensitive-rate-limit.guard.ts) | ✅ |
| `utils/contentModeration.ts` | profanity/spam | [`utils/content-moderation.ts`](src/utils/content-moderation.ts) | ✅ |
| `middlewares/deviceInfo.ts` | UA/IP parse | — | ✅ parsed inline by guards + auth routes |
| `middlewares/requireAdmin.ts` | admin gate | [`middleware/roles.guard.ts`](src/middleware/roles.guard.ts) + `@Roles` | ✅ |

---

## 🔗 Service Dependency Map

> Which NestJS services call other services, and why. Use this when changing a service to know what else might break.

```
AuthService
  └─► DeviceService       (register/login: upsert push token)
  └─► MailService         (register: email OTP; forgot-password: OTP)
  └─► ReferralsService    (register: createPendingReferralFromSignup — NOT yet wired)
  └─► BillingService      (register: grantSignupBonusPremium — NOT yet wired)

ReportsService
  └─► ImageService        (createReport/updateReport: photo upload)
  └─► DeviceService       (createReport/reportMyPet: nearby push tokens)
  └─► PushService         (createReport/reportMyPet: push notification)

AiService
  └─► WalletService       (detectBreed: -1 credit debit)
  └─► DeviceService       (getMatches: match notification push tokens)
  └─► PushService         (getMatches: push on high-confidence match)
  └─► ImageService        (quickSnapMatch: upload photo to CDN)

AdsService
  └─► WalletService       (claimReward: credit grant)

BillingService
  └─► WalletService       (grantPremium: credit operations)
  └─► UsersService        (subscription updates)

ReferralsService → RewardEngineService
  RewardEngineService
    └─► WalletService     (grantReward: credit grant)
    └─► PushService       (grantReward: push notification)

QuickSnapsService
  └─► ImageService        (createQuickSnap: photo upload)
  └─► PushService         (createQuickSnap: nearby push)

NotificationsService
  └─► DeviceService       (sendPush: token lookup)
  └─► PushService         (sendPush: delivery)

OrgService
  └─► ImageService        (registerPartner/updateOrg: logo upload)

StoriesService             (no external service deps)
ConversationsService       (no external service deps)
UsersService               (no external service deps)
AdminService
  └─► BillingService      (grantPremium: delegates entirely)
  └─► FeatureFlagsService (getFeatureFlags, patchFeatureFlag)

WalletService    (@Global — injected anywhere)
FeatureFlagsService (@Global — injected anywhere)
```

**Global singletons** (available everywhere without explicit import):
- `WalletService` — all credit mutations must go through this
- `FeatureFlagsService` — 15s-cached reads from `app_settings`
- `Kysely<DB>` — query builder
- `Pool` — raw pg pool (used by AI for complex UNION queries)

---

## 📋 Change Tracking

> How to keep this file in sync when a route or service changes.

### When to update this file

Update the relevant row/section in this tracker whenever you:

| Change type | What to update |
|---|---|
| Add a new route | Add a row to the right table; update Summary counts |
| Remove a route | Strike the row with ~~strikethrough~~; update counts |
| Change a route's path or method | Update the row; add ⚠️ drift note if it breaks existing clients |
| Add/remove an auth guard | Update the Guard column |
| Add/remove `SensitiveRateLimitGuard` or `AttestationGuard` | Update the Guard column and Notes |
| Add/remove a feature flag | Update 🚩 in Legend column and feature-flags note |
| A service starts calling another service | Update the **Service Dependency Map** |
| A service stops calling another service | Update the **Service Dependency Map** |

### Quick grep to verify a route is registered

```bash
# Find the NestJS controller for a given path
grep -rn '"pet/reports"\|"ai"\|"billing"' src/modules/*/**.controller.ts

# Check which guards are applied to a route
grep -n "UseGuards\|SensitiveRate\|Attestation" src/modules/billing/billing.controller.ts
```

### Drift detection pattern

If you add a route in Express and forget to port it to NestJS, it will silently 404. Run this check before releases:

```bash
# List all Express route registrations
grep -rn 'router\.\(get\|post\|put\|delete\|patch\)' \
  /home/rm/Documents/testing/backends/furfinder_backend/src/modules \
  | grep -v '\.route\.ts:' | sort

# List all NestJS handler decorators
grep -rn '@Get\|@Post\|@Put\|@Delete\|@Patch' \
  /home/rm/Documents/testing/backends/furfinder_nestjs/src/modules \
  | sort
```

Diff the two lists — any Express entry without a matching NestJS entry is missing.

---

## 🎯 Remaining Work

None outstanding. Every item previously listed here was found, on re-verification (2026-07-26), to already be implemented — this section had gone stale relative to the actual codebase:

- ~~`POST /auth/social`~~ — fully implemented (`AuthService.socialLogin`), wired, `google-auth-library` installed.
- ~~Webhook source-secret validation + event dedup~~ — both implemented in `WebhooksController`/`BillingService.processWebhook`.
- ~~`/config` response payload parity~~ — verified field-for-field against Express's `getAppConfig()` (DEFAULTS, `pricing_features`, `version_policy`, `feature_flags` all match).
- ~~`getAllReports` breed/color/pet_type array filters~~ — implemented via `normalizeQueryList`.
- `/pet/profiles` `/all` and `/suburb/:suburb` auth-gating — resolved by tracing Express's actual mount chain (see [[#Pet Profiles]]); NestJS now matches real Express behavior, not the stale in-code comment.

**Full DB-table audit (2026-07-26):** cross-referenced all 44 tables in `dump-furfinder-202607212105.sql` against both codebases. Every table used by `backend-main` has a corresponding reference in `backendV2`. Six tables (`blogs`, `faqs`, `how_it_works_steps`, `pricing_plans`, `websites`, `who_its_for_segments`) exist in the shared database but are referenced by neither backend — out of scope, likely belonging to a separate service sharing the same Postgres instance.

**Also fixed this pass (not endpoint-shaped, so not in the table above):**
- File-upload `MulterOptions` (size limits + image-only `fileFilter`) were missing entirely on every upload endpoint (AI, Quick Snaps, Profiles, Reports) — added [`utils/image/image-upload.options.ts`](src/utils/image/image-upload.options.ts) mirroring Express's `createImageUpload()` (5MB standard / 25MB `detect-breed`).

---

## 🚀 Scalability Suggestions

> [!abstract] Purpose
> Forward-looking suggestions for running Fur Finder at scale — specifically **multiple instances behind a load balancer**. These are **not migration blockers**; the API is functionally complete. Every item below is tied to a real file and verified against the code. Ordered by impact.

### ⚠️ Horizontal-scaling blockers (in-memory state)

The biggest risk to scaling out is **per-process state**: anything stored in a JS `Map`/variable lives in one instance only, so behind a load balancer it is duplicated, inconsistent, or bypassable.

| Priority | Area | Issue (file) | Suggested change |
|---|---|---|---|
| 🔴 **P0** | Sensitive rate limit | In-process `Map` (60 req/60s keyed `user:device:ip`) — [`sensitive-rate-limit.guard.ts`](src/middleware/sensitive-rate-limit.guard.ts). Each instance keeps its own counter → effective limit is `N×` and bypassable via LB spread. | Back the limiter with **Redis** so the window is shared across instances. |
| 🔴 **P0** | Global throttler | `ThrottlerModule` (100 req/10 min) uses the default in-memory `MemoryStore` — [`app.module.ts`](src/app.module.ts). Same per-instance bypass. | Use `@nestjs/throttler` **Redis storage** (`ThrottlerStorageRedisService`). |
| 🔴 **P0** | Feature-flag cache | In-process 15s TTL cache — [`feature-flags.service.ts`](src/modules/feature-flags/feature-flags.service.ts). `PATCH /admin/feature-flags/:key` → `invalidateCache()` only clears the instance that served the request → up to 15s of **inconsistent flag state** across instances. | Move cache to **Redis** with **pub/sub invalidation** on flag change (or drop TTL toward ~1s as a stop-gap). |

> [!important] P0 items share one fix: **introduce Redis.** A single Redis instance unblocks shared rate-limiting, throttling, and flag caching — and doubles as the job-queue backend for the P1 items below.

### 🐢 Request-path / I/O bottlenecks

These don't break correctness across instances, but they hold the request open (and a DB connection) longer than needed, hurting throughput and tail latency under load.

| Priority | Area | Issue (file) | Suggested change |
|---|---|---|---|
| 🟠 **P1** | Push fan-out | Nearby-device push loop runs **inline** with up to 3 retries each; response blocks until all sends finish — [`quick-snaps.service.ts`](src/modules/quick-snaps/quick-snaps.service.ts) (and report creation in [`reports.service.ts`](src/modules/pets/reports.service.ts)). | Offload to a **job queue (BullMQ/Redis)**; **batch** Expo sends (Expo supports batch); make fan-out fire-and-forget. |
| 🟡 **P2** | Image uploads | Multer buffers up to **15 files in memory**, then uploads to CDN **inline** (30s timeout, no retry) — [`image.service.ts`](src/utils/image/image.service.ts). Memory spikes + connections held for the whole upload. | Stream uploads; offload to queue/async; add retry + circuit breaker; keep zero reliance on local disk so any instance can serve. |
| 🟡 **P2** | External calls | Inconsistent timeouts/retries: breed classifier (15s, no retry), IAP verify, and error webhooks (Viber/Discord, **no timeout**) — [`errors.service.ts`](src/modules/errors/errors.service.ts). A slow third party stalls the request path. | Standardize timeouts, add retries/circuit breakers, move non-critical calls async. |

### 🗄️ Database

| Priority | Area | Issue (file) | Suggested change |
|---|---|---|---|
| 🟠 **P1** | Connection pool | Pool `max: 20` hardcoded — [`database.provider.ts`](src/database/database.provider.ts). Not env-configurable; long CDN/push I/O holds connections; `N instances × 20` can exceed Postgres `max_connections`. | Make pool size **env-driven**; front with **PgBouncer**; use a **read replica** for heavy read paths. |
| 🟠 **P1** | Geo / search queries | Per-row Haversine, nested `EXISTS`, `CROSS JOIN LATERAL`, and `OFFSET` pagination — [`reports.service.ts`](src/modules/pets/reports.service.ts) (`getAllReports`, `getReportFilterOptions`). Full scans at scale. | **PostGIS/GiST spatial index** (project already uses `earth_distance`/`cube`), **bounding-box pre-filter**, **keyset pagination**, and indexes on `blocked_users`/`saved_pets` join keys. |

### 📈 Observability / ops

| Priority | Area | Issue (file) | Suggested change |
|---|---|---|---|
| 🔵 **P3** | Logging | Daily rotating **local file** logger — [`logger.module.ts`](src/logger/logger.module.ts). Per-pod files fragment logs and are lost on restart. | Log JSON to **stdout only** and aggregate centrally (Datadog/ELK/CloudWatch); drop the local-file transport in containers. |

### ✅ Already scale-safe (do not "fix")

> [!success] Verified concurrency-safe — leave as is
> - **WalletService** — [`wallet.service.ts`](src/modules/wallet/wallet.service.ts): DB transactions + `pg_advisory_xact_lock` + `FOR UPDATE` + idempotency-key uniqueness. Safe under concurrent multi-instance writes.
> - **Analytics insert** ([`analytics.service.ts`](src/modules/analytics/analytics.service.ts)) — stateless, writes straight to DB.
> - **AttestationGuard** ([`attestation.guard.ts`](src/middleware/attestation.guard.ts)) — pure/stateless scoring.
> - **OTP email** ([`auth.service.ts`](src/modules/auth/auth.service.ts)) — fire-and-forget, non-critical.

### 🧱 Suggested infra additions

- **Redis** — shared rate-limit/throttle store, feature-flag cache + pub/sub, and job-queue backend (covers all three P0s and the P1 push queue).
- **BullMQ worker process** — separate from the API process; handles push fan-out, image uploads, and other async work so the request path returns fast.
- **PgBouncer + read replica** — connection multiplexing and offloaded read traffic for geo/search.
- **Object-storage/CDN streaming** — stream uploads instead of buffering; no local-disk dependency.
- **Central log sink** — stdout → aggregator; remove file transport.

---

## 🧪 Route Test Coverage

> [!abstract] Purpose
> Tracks **per-route automated test coverage**. The migration is functionally complete; this section drives the test-writing effort. Started **2026-06-30**.

### Strategy — where we started & why

> [!important] Layer chosen: **per-module route (HTTP) tests with a mocked service layer.**
> We boot a *minimal* Nest HTTP app containing only the module's controller(s) + mocked services, then drive it with `supertest`. This exercises the things the controller layer actually owns — **routing (path + method + status), guard enforcement, DTO/Zod validation, and controller→service delegation** — **without a live Postgres or external services**. Service-internal SQL/business logic is out of scope here and is better covered by separate service unit tests later.

**Start order:** foundation first, then **Auth** as the pilot to lock the pattern, then fan out following the [[#📊 Status Summary]] table top-to-bottom.

**Harness:** [`test/utils/route-test-harness.ts`](test/utils/route-test-harness.ts) — `createRouteTestApp({ controllers, providers })` mirrors prod wiring that affects routes (global `api/v1` prefix, global `ZodValidationPipe`, `GlobalExceptionFilter` so validation → 422 with the real error shape). A stub `AuthHelper` makes the **real** `AuthGuard`/`OptionalAuthGuard` run with no JWT/DB — send `Bearer ${VALID_TOKEN}` to authenticate as `TEST_USER`. `createMock<T>([...methods])` builds a `jest.fn`-backed service double.

**Run:** `pnpm test:e2e` (or `npx jest --config ./test/jest-e2e.json`). Specs live in `test/modules/*.e2e-spec.ts`, one file per module.

**What each spec should assert per route:** success status + service called with mapped args · `401` on auth-required routes when unauthenticated · `422` on at least one DTO validation failure · any non-default `@HttpCode` (e.g. login `200`, logout `204`) · `OptionalAuthGuard` routes work both with and without a token.

### Changes made (2026-06-30)

- **Fixed pre-existing TS errors** (unblocked typecheck): [`ai.dto.ts`](src/modules/ai/ai.dto.ts) `z.record(z.string(), z.unknown())` (Zod v4 needs key+value); [`ai.service.ts`](src/modules/ai/ai.service.ts) non-null assertions on `existingFlag`.
- **Added test harness** [`test/utils/route-test-harness.ts`](test/utils/route-test-harness.ts).
- **Added jest types config** [`test/tsconfig.json`](test/tsconfig.json) (`types: [node, jest]`, `noEmit`) — resolves the "Cannot find name `describe`" errors.
- **Updated** [`test/jest-e2e.json`](test/jest-e2e.json): `@/` + `src/` module mappers, points ts-jest at `test/tsconfig.json`, and `transformIgnorePatterns` whitelists ESM-only deps (`kysely`, `uuid`) under pnpm's `.pnpm/` layout.
- **Removed** the stock `test/app.e2e-spec.ts` (booted the full `AppModule`, which needs a live Postgres — incompatible with the mocked-service approach).
- **Pilot:** [`test/modules/auth.e2e-spec.ts`](test/modules/auth.e2e-spec.ts) — all 10 Auth routes, **18 assertions green**.

### Per-module coverage checklist

| Module | Routes | Spec file | Status |
|---|---:|---|---|
| [[#Auth]] | 10 | `test/modules/auth.e2e-spec.ts` | ✅ done (9 live + `/social` pending migration) |
| [[#Pets (general)]] | 3 | `test/modules/pets.e2e-spec.ts` | ❌ todo |
| [[#Pet Reports (public)]] | 6 | `test/modules/reports.e2e-spec.ts` | ❌ todo |
| [[#Pet Reports (authenticated)]] | 16 | `test/modules/reports.e2e-spec.ts` | ❌ todo |
| [[#Pet Claims]] | 5 | `test/modules/claims.e2e-spec.ts` | ❌ todo |
| [[#Pet Profiles]] | 8 | `test/modules/profiles.e2e-spec.ts` | ❌ todo |
| [[#Users]] | 8 | `test/modules/users.e2e-spec.ts` | ❌ todo |
| [[#AI]] | 5 | `test/modules/ai.e2e-spec.ts` | ❌ todo |
| [[#Notifications]] | 5 | `test/modules/notifications.e2e-spec.ts` | ❌ todo |
| [[#Conversations]] | 7 | `test/modules/conversations.e2e-spec.ts` | ❌ todo |
| [[#Org / Partners]] | 11 | `test/modules/org.e2e-spec.ts` | ❌ todo |
| [[#Ads]] | 6 | `test/modules/ads.e2e-spec.ts` | ❌ todo |
| [[#Reunited Stories]] | 3 | `test/modules/stories.e2e-spec.ts` | ❌ todo |
| [[#Referrals]] | 3 | `test/modules/referrals.e2e-spec.ts` | ❌ todo |
| [[#Suburbs]] | 2 | `test/modules/suburbs.e2e-spec.ts` | ❌ todo |
| [[#Analytics]] | 1 | `test/modules/analytics.e2e-spec.ts` | ❌ todo |
| [[#Errors]] | 1 | `test/modules/errors.e2e-spec.ts` | ❌ todo |
| [[#Billing]] | 3 | `test/modules/billing.e2e-spec.ts` | ❌ todo |
| [[#Webhooks]] | 2 | `test/modules/webhooks.e2e-spec.ts` | ❌ todo |
| [[#Admin]] | 3 | `test/modules/admin.e2e-spec.ts` | ❌ todo |
| [[#Config]] | 1 | `test/modules/config.e2e-spec.ts` | ❌ todo |
| [[#Quick Snaps]] | 5 | `test/modules/quick-snaps.e2e-spec.ts` | ❌ todo |
| [[#Invites]] | 2 | `test/modules/invites.e2e-spec.ts` | ❌ todo |

### Open test todos / follow-ups

- [ ] **Guard coverage gap:** the harness provides `AuthGuard`/`OptionalAuthGuard` but **not** `SensitiveRateLimitGuard`, `AttestationGuard`, `RolesGuard`, or `FeatureFlagGuard`. For routes carrying 🐢🛡️🔒admin🚩, decide per spec whether to wire the real guard (with mocked deps) or assert it's applied via reflected metadata. Affects Reports POST, Billing verify, AI detect-breed/dispute, Ads rewards, Admin, all 🚩 routes.
- [ ] **Multipart upload routes** (Reports, Profiles, Quick Snaps, AI quick-snap, Org logo) need `supertest` `.attach()` + a mocked `ImageService`; confirm Multer field names match controllers (`photos`, `biometricPhotos`, `photo`).
- [ ] **`ZodSerializerInterceptor`** is not wired into the harness — add it if/when we start asserting response *body shape* (currently we assert pass-through). 
- [ ] **Service-layer unit tests** (SQL/business logic) are a separate effort not covered by this route layer — track separately when started.
- [ ] Add a root `pnpm test:routes` script alias if the e2e config starts covering both full-app and route tests.

---

> [!tip] Obsidian usage
> Section links above use `[[#Heading]]`. Tags are searchable in Graph/Search. Checkboxes in Reading view aggregate via the Tasks/Dataview plugins.
