# Campground Profile Completion Flow - Implementation Changes

**Branch:** `PCSDB-81-api-authentication-flow-login-forgot-password-otp-reset-password-and-o-auth-endpoints`
**Date:** 2026-04-21
**Scope:** Full implementation of the campground profile completion flow (Steps 1-3), chunk media upload, availability setup, FAQ global override system, profile activation and visibility, and public profile endpoint.

---

## Table of Contents

1. [Overview](#1-overview)
2. [Breaking Changes](#2-breaking-changes)
3. [Schema / Model Changes](#3-schema--model-changes)
4. [New Models](#4-new-models)
5. [Repository Changes](#5-repository-changes)
6. [New Repositories](#6-new-repositories)
7. [Middleware Changes](#7-middleware-changes)
8. [Utility Changes](#8-utility-changes)
9. [Service Changes](#9-service-changes)
10. [API Changes - Updated Endpoints](#10-api-changes---updated-endpoints)
11. [API Changes - New Endpoints](#11-api-changes---new-endpoints)
12. [Validation Changes](#12-validation-changes)
13. [Other Fixes](#13-other-fixes)
14. [Migration Notes](#14-migration-notes)

---

## 1. Overview

The campground profile completion flow allows a campsite owner to set up their public profile in three overall steps:

```
Step 1 - Campground Profile Setup
  ├── 1a. Basic Info       (name, location, description)
  ├── 1b. Media Upload     (chunk upload — images + videos, cover + gallery)
  ├── 1c. Social Links     (website, Instagram, Facebook, YouTube, Twitter, booking URL)
  └── 1d. FAQs & Contact   (global FAQ overrides + custom FAQs)

Step 2 - Availability
  └── Choose: Manual Setup OR ERP Integration

Step 3 - Activate Profile
  ├── Complete Setup  (validates Steps 1+2, generates public slug)
  ├── Activate        (sets isActive = true)
  └── Visibility      (independently toggles public visibility)
```

**Status tracking is automatic.** After every write operation the server recomputes `step1Status`, `step2Status`, and `setupStatus` — the frontend never needs to send status values.

---

## 2. Breaking Changes

These changes affect existing integrations and require frontend updates.

### 2.1 Media `type` field renamed

| Before | After | Affected model |
|---|---|---|
| `type: "main"` | `type: "cover"` | `CampsiteMedia` |
| `type: "gallery"` | `type: "gallery"` | `CampsiteMedia` (unchanged) |

All existing `CampsiteMedia` records with `type: "main"` must be migrated to `type: "cover"`.

### 2.2 Media upload endpoint replaced

The old single-file upload endpoint is replaced by a chunk upload endpoint.

| Before | After |
|---|---|
| `POST /api/campground/:id/media` (multipart, single file) | `POST /api/campground/:id/media/chunk` (multipart, one chunk at a time) |

### 2.3 `GET /api/campground/:id` response shape changed

`setup_status` now returns the extended enum (`draft`, `in_progress`, `completed`, `active`) and the response now includes `step1_status`.

### 2.4 `setup_status` no longer accepted from client

`PATCH /api/campground/:id` previously accepted `setup_status: "draft"` in the request body. This field is now fully server-computed and will be stripped if sent.

### 2.5 Deletes are now permanent (hard delete)

`CampsiteMedia` and `CampsiteFaq` previously used soft delete (`isDeleted: true`). Both now use hard (permanent) delete. There is no recovery path for deleted media or FAQs.

---

## 3. Schema / Model Changes

### 3.1 `Campsite` (`src/models/Campsite.js`)

#### Added fields

| Field | Type | Default | Description |
|---|---|---|---|
| `step1Status` | `String` enum `pending\|in_progress\|completed` | `"pending"` | Auto-computed after every Step 1 write. `completed` requires name + location.address + description + at least 1 image. |
| `step2Status` | `String` enum `pending\|configured` | `"pending"` | Auto-computed after every availability save. |
| `isVisible` | `Boolean` | `false` | Public visibility toggle. Independent from `isActive`. Requires `isActive: true` before it can be set to `true`. |
| `slug` | `String` (unique, sparse) | `null` | URL-safe public identifier. Auto-generated from campground name on `POST /complete`. Format: `name-slug-xxxx` where `xxxx` is a 4-char random suffix for uniqueness. |

#### Modified fields

| Field | Before | After |
|---|---|---|
| `setupStatus` enum | `["draft", "complete"]` | `["draft", "in_progress", "completed", "active"]` |

#### Unchanged fields (kept for admin flow compatibility)

`isDeleted` is kept on `Campsite` because the admin campsite module still uses it for listing and filtering.

---

### 3.2 `CampsiteMedia` (`src/models/CampsiteMedia.js`)

#### Changed fields

| Field | Before | After |
|---|---|---|
| `type` enum | `["main", "gallery"]` | `["cover", "gallery"]` |

#### Added fields

| Field | Type | Description |
|---|---|---|
| `mediaCategory` | `String` enum `image\|video` | Distinguishes images from videos inside the gallery. Cover is always `image`. |

#### Removed fields

| Field | Reason |
|---|---|
| `isDeleted` | Hard delete only. Removed to eliminate stale data accumulation. |

---

### 3.3 `CampsiteFaq` (`src/models/CampsiteFaq.js`)

#### Added fields

| Field | Type | Default | Description |
|---|---|---|---|
| `sourceType` | `String` enum `admin_override\|custom` | `"custom"` | `admin_override` = owner has overridden a global FAQ. `custom` = owner-created with no global equivalent. |
| `globalFaqId` | `ObjectId` ref `Faq` | `null` | Set when `sourceType = "admin_override"`. References the original global FAQ. |

#### New compound index

```
{ campsiteId: 1, globalFaqId: 1 }  — unique, sparse
```

Prevents a campsite from having two override records for the same global FAQ.

#### Removed fields

| Field | Reason |
|---|---|
| `isDeleted` | Hard delete only. Deleting an override automatically restores the original global FAQ in the list. |

---

## 4. New Models

### 4.1 `Availability` (`src/models/Availability.js`)

Collection: `campsite_availability`

| Field | Type | Required | Description |
|---|---|---|---|
| `campsiteId` | `ObjectId` ref `Campsite` | Yes | One record per campsite (unique constraint). |
| `type` | `String` enum `manual\|erp` | Yes | Owner selects their availability management approach. |
| `erpProvider` | `String` | No | ERP system name (e.g. `"apaleo"`, `"protel"`). Null when type is `manual`. |
| `erpConfig` | `Mixed` | No | Generic config bag for ERP credentials/endpoints. Never expose raw to frontend. |
| `isConfigured` | `Boolean` | - | Set to `true` on first save. Drives `step2Status`. |

---

## 5. Repository Changes

### 5.1 `CampsiteMediaRepository` (`src/repositories/campsiteMedia.repository.js`)

| Change | Detail |
|---|---|
| Removed `softDelete(id)` | Replaced with `deleteById(id)` - calls `findByIdAndDelete` |
| Removed `isDeleted: false` filters | From `findByCampsiteId`, `findById`, `findByIdAndCampsiteId` |
| Added `findCoverByCampsiteId(campsiteId)` | Returns the active cover media record |
| Added `deleteCoverByCampsiteId(campsiteId)` | Hard-deletes all cover records for a campsite before uploading a new one |
| Added `countImagesByCampsiteId(campsiteId)` | Returns count of media records with `mediaCategory: "image"`. Used for `step1Status` computation. |

---

### 5.2 `CampsiteFaqRepository` (`src/repositories/campsiteFaq.repository.js`)

| Change | Detail |
|---|---|
| Removed `softDelete(id)` | Replaced with `deleteById(id)` - calls `findByIdAndDelete` |
| Removed `isDeleted: false` filters | From `findByCampsiteId`, `findByIdAndCampsiteId`, `getNextOrder` |
| Added `findOverrideByGlobalFaqId(campsiteId, globalFaqId)` | Finds an existing admin_override record for a given global FAQ |
| Added `upsertOverride(campsiteId, globalFaqId, data)` | `findOneAndUpdate` with `upsert: true` on `{ campsiteId, globalFaqId }`. Prevents duplicate overrides. |

---

### 5.3 `CampsiteRepository` (`src/repositories/campsite.repository.js`)

| Change | Detail |
|---|---|
| Added `findBySlug(slug)` | Finds an active campsite by slug. Used during slug uniqueness check in `completeSetup`. |
| Added `findPublicBySlug(slug)` | Finds a campsite that is `isActive: true` AND `isVisible: true`. Used by the public profile endpoint. |

---

## 6. New Repositories

### 6.1 `AvailabilityRepository` (`src/repositories/availability.repository.js`)

| Method | Description |
|---|---|
| `findByCampsiteId(campsiteId)` | Returns the availability config for a campsite, or null |
| `upsert(campsiteId, data)` | Create or update availability (`findOneAndUpdate` with `upsert: true`) |
| `deleteById(id)` | Hard delete by ID |
| `deleteByCampsiteId(campsiteId)` | Hard delete by campsite ID |

---

## 7. Middleware Changes

### 7.1 `campsiteAuth.middleware.js` (`src/middlewares/campsiteAuth.middleware.js`)

**Before:**
```js
if (!campsite.isActive) {
  return res.status(403).json({ message: "This campsite is not active." });
}
```

**After:** The `isActive` check is removed entirely.

**Why:** Every new campsite starts with `isActive: false`. The previous check made the entire setup flow inaccessible - owners could never complete Step 1-3 because the auth middleware blocked them before any controller was reached. The `isActive` check belongs only on public-facing endpoints, not on authenticated owner setup routes.

---

## 8. Utility Changes

### 8.1 `upload.js` (`src/utils/upload.js`)

Four new functions added for the chunk upload system:

#### `saveChunk(uploadId, chunkIndex, buffer)`
Writes a single chunk buffer to `public/temp/chunks/:uploadId/:chunkIndex`. Creates the temp directory if it does not exist.

#### `allChunksReceived(uploadId, totalChunks)`
Returns `true` when every file index `0` through `totalChunks - 1` exists in the chunk temp directory. Called after each chunk save to determine when assembly can start.

#### `assembleChunks(uploadId, totalChunks, filename, finalDestination)`
Reads all chunks in order using `readFileSync` + `writeFileSync`/`appendFileSync` (fully synchronous, no stream races), writes the assembled file to `finalDestination`, deletes the temp chunk directory with `fs.rmSync`, and returns the relative storage path.

#### `cleanupChunks(uploadId)`
Removes the temp chunk directory for an upload session. Called on abort/cancel.

**Temp directory location:** `public/temp/chunks/:uploadId/`

---

## 9. Service Changes

### 9.1 `CampgroundService` (`src/services/campground.service.js`) - Full rewrite

#### Internal helper methods

| Method | Purpose |
|---|---|
| `_recomputeStep1Status(campsiteId)` | Queries campsite + image count. Sets `step1Status` = `pending` / `in_progress` / `completed`. Called after every Basic Info or Media change. |
| `_recomputeStep2Status(campsiteId)` | Queries availability. Sets `step2Status` = `pending` / `configured`. Called after every availability save. |
| `_recomputeSetupStatus(campsiteId)` | Derives `setupStatus` from step statuses. Never downgrades an `active` profile. |
| `_completionPercentage(campsite, imageCount, availabilityConfigured)` | Returns 0-100 based on 4x25% scoring (basic info done, image exists, availability configured, profile active). |
| `_generateUniqueSlug(name)` | Converts name to URL-safe slug + 4-char random suffix. Retries up to 10 times on collision. |
| `_getMergedFaqs(campsiteId)` | Merges global FAQs (`audience: campingSpotOwners`) with campsite records. Override records replace their global counterpart. Result has `source` field on every item. |

#### Public methods

| Method | What changed |
|---|---|
| `getBasicInfo` | Response now includes `step1_status` |
| `updateBasicInfo` | Removed `setup_status` from accepted payload. Now calls `_recomputeStep1Status` and `_recomputeSetupStatus` after save. Uses single `findByIdAndUpdate` (no double read). |
| `uploadMedia` (removed) | Replaced by `uploadChunk` |
| `uploadChunk` | New. Handles chunk-by-chunk file upload with assembly on last chunk. Enforces MIME validation, cover uniqueness, `coverImageUrl` sync, and `step1Status` recompute. |
| `cancelChunkUpload` | New. Cleans up temp chunks for an aborted upload. |
| `deleteMedia` | Now calls `deleteById` (hard delete). Clears `coverImageUrl` if the deleted item is the cover. Calls `_recomputeStep1Status`. |
| `updateLinks` | Uses single `findByIdAndUpdate` with `{ new: true }` — removed the unnecessary second read. |
| `getFaqs` | Now calls `_getMergedFaqs` instead of only returning campsite FAQs. |
| `createFaq` | Accepts optional `globalFaqId`. When provided, upserts an `admin_override` record via `faqRepo.upsertOverride`. |
| `deleteFaq` | Now calls `deleteById` (hard delete). Deleting an override automatically restores the global FAQ in subsequent `getFaqs` responses. |
| `getAvailability` | New. Returns availability config or null. |
| `upsertAvailability` | New. Creates or updates availability. Sets `isConfigured: true`. Triggers step2 + setup status recompute. |
| `completeSetup` | Rewritten. Validates both step statuses before proceeding. Generates a unique slug. Returns 422 with specific error if prerequisites not met. Returns 409 if already active. |
| `activateProfile` | New. Requires `setupStatus = completed`. Sets `isActive = true` and `setupStatus = "active"`. |
| `updateVisibility` | New. Requires `isActive = true`. Toggles `isVisible`. |
| `getDetail` | New. Single aggregated response: basic info + media (cover + gallery split) + links + FAQs + availability + step statuses + completion percentage. |
| `getPublicProfile` | New. Unauthenticated. Requires `isActive: true` AND `isVisible: true`. Returns public-safe fields only. |

---

## 10. API Changes - Updated Endpoints

### `GET /api/campground/:id`

Response now includes `step1_status`:

```json
{
  "data": {
    "id": "...",
    "campground_name": "...",
    "location": {},
    "short_description": "...",
    "setup_status": "in_progress",
    "step1_status": "in_progress"
  }
}
```

---

### `PATCH /api/campground/:id`

**Removed** from request body: `setup_status` (was previously accepted as `"draft"`). Sending it now has no effect — it is stripped by validation.

---

### `GET /api/campground/:id/media`

Response shape changed from a flat array to an object with `cover` and `gallery` keys:

**Before:**
```json
[
  { "id": "...", "url": "...", "type": "main" },
  { "id": "...", "url": "...", "type": "gallery" }
]
```

**After:**
```json
{
  "cover": { "id": "...", "url": "...", "type": "cover", "mediaCategory": "image" },
  "gallery": [
    { "id": "...", "url": "...", "type": "gallery", "mediaCategory": "image" },
    { "id": "...", "url": "...", "type": "gallery", "mediaCategory": "video" }
  ]
}
```

---

### `GET /api/campground/:id/faqs`

Response now includes a `source` field on every FAQ item and `globalFaqId` for global/override items:

```json
{
  "contact_url": null,
  "faqs": [
    { "id": "...", "question": "...", "answer": "...", "order": 1, "source": "global",         "globalFaqId": "..." },
    { "id": "...", "question": "...", "answer": "...", "order": 2, "source": "admin_override", "globalFaqId": "..." },
    { "id": "...", "question": "...", "answer": "...", "order": 3, "source": "custom" }
  ]
}
```

---

### `POST /api/campground/:id/faqs`

Added optional `globalFaqId` field. When provided, the FAQ becomes an `admin_override` — the global FAQ is replaced in the list by this version. Subsequent calls with the same `globalFaqId` update the override (upsert).

```json
{
  "question": "Custom check-in time?",
  "answer": "Yes, from 14:00.",
  "globalFaqId": "666a000000000000000000a2"
}
```

---

### `DELETE /api/campground/:id/faqs/:faqId`

Now permanently deletes (hard delete). If the deleted record was an `admin_override`, the original global FAQ reappears in `GET /faqs` responses automatically.

---

### `DELETE /api/campground/:id/media/:mediaId`

Now permanently deletes (hard delete). Physical file is also deleted from disk. If the item was the cover image, `Campsite.coverImageUrl` is cleared.

---

### `POST /api/campground/:id/complete`

**Before:** Set `setupStatus: "complete"` with no validation.

**After:**
- Recomputes `step1Status` and `step2Status` fresh before checking
- Returns `422` if `step1Status !== "completed"` with message `errors.step1NotCompleted`
- Returns `422` if `step2Status !== "configured"` with message `errors.step2NotConfigured`
- Returns `409` if `setupStatus === "active"` (already active, cannot re-complete)
- On success: sets `setupStatus = "completed"`, generates and saves unique `slug`
- Response now includes `slug`

```json
{ "setup_status": "completed", "slug": "alpine-hut-a3f2" }
```

---

## 11. API Changes - New Endpoints

All campground endpoints (except public profile) require `Authorization: Bearer <campsite-jwt>`.

### `GET /api/campground/:id/detail`

Full aggregated response for the campground detail page. Replaces the need to call 4-5 separate endpoints. Returns:

```json
{
  "id": "...",
  "campground_name": "...",
  "location": {},
  "short_description": "...",
  "cover_image_url": "...",
  "is_active": false,
  "is_visible": false,
  "slug": null,
  "setup_status": "in_progress",
  "step1_status": "in_progress",
  "step2_status": "pending",
  "completion_percentage": 25,
  "links": { "website_url": null, "instagram_url": null, ... },
  "media": {
    "cover": null,
    "gallery": []
  },
  "faqs": [],
  "availability": null
}
```

**Completion percentage logic (4 x 25%):**
- 25% — name + location.address + description are all non-null
- 25% — at least 1 image exists
- 25% — availability is configured
- 25% — profile is active (`isActive = true`)

---

### `POST /api/campground/:id/media/chunk`

Chunk upload endpoint for images and videos.

**Request** (`multipart/form-data`):

| Field | Type | Required | Description |
|---|---|---|---|
| `chunk` | binary | Yes | Raw chunk bytes. Max 10 MB per chunk. |
| `uploadId` | string (UUID) | Yes | Client-generated UUID. Same for all chunks of a single file. |
| `chunkIndex` | integer | Yes | 0-based index of this chunk. |
| `totalChunks` | integer | Yes | Total number of chunks for this file. |
| `filename` | string | Yes | Original filename including extension. Used to preserve the file extension on assembly. |
| `mimeType` | string | Yes | MIME type of the complete file. Validated against allowed types. |
| `mediaType` | `cover` or `gallery` | Yes | `cover` replaces the previous cover and syncs `coverImageUrl`. `gallery` appends to the gallery. |

**Allowed MIME types:**
- Cover: `image/jpeg`, `image/jpg`, `image/png`, `image/webp`
- Gallery: all image types above + `video/mp4`, `video/quicktime`, `video/webm`, `video/x-msvideo`

**Responses:**
- `200` while chunks are still pending: `{ "status": "chunk_received", "chunkIndex": 0 }`
- `201` when all chunks received and file assembled: `{ "status": "complete", "media": { "id": "...", "url": "...", "type": "cover", "mediaCategory": "image" } }`

**Temp storage:** `public/temp/chunks/:uploadId/` — cleaned up automatically on assembly or cancel.

---

### `DELETE /api/campground/:id/media/chunk/:uploadId`

Cancels an in-progress chunked upload and cleans up all temp chunk files.

---

### `GET /api/campground/:id/availability`

Returns the availability config for this campsite, or `null` if not yet configured.

```json
{ "type": "manual", "erpProvider": null, "isConfigured": true }
```

---

### `PATCH /api/campground/:id/availability`

Create or update availability configuration (upsert). Automatically sets `step2_status` to `configured` and triggers a `setupStatus` recompute.

**Request:**
```json
{ "type": "manual" }
```
or
```json
{ "type": "erp", "erpProvider": "apaleo", "erpConfig": { "apiKey": "YOUR_ERP_API_KEY" } }
```

**Note:** Do not include real ERP credentials in logs or API documentation examples.

---

### `POST /api/campground/:id/activate`

Activates the campground profile. Sets `isActive = true` and `setupStatus = "active"`.

**Requires:** `setupStatus = completed` (i.e. `POST /complete` must have been called first).

**Note:** Activation does not make the profile publicly visible. Use `PATCH /visibility` for that.

---

### `PATCH /api/campground/:id/visibility`

Toggles public visibility independently of activation.

**Requires:** `isActive = true`.

**Request:** `{ "is_visible": true }` or `{ "is_visible": false }`

A profile can be activated but hidden (e.g. maintenance mode).

---

### `GET /api/campground/public/:slug`

**No authentication required.**

Returns the publicly visible campground profile. Returns `404` if:
- No campsite with this slug exists, OR
- `isActive` is `false`, OR
- `isVisible` is `false`

The `slug` is returned by `POST /complete` and can be used to construct:
- **Shareable URL:** `https://yourdomain.com/c/:slug`
- **QR code:** Generate client-side from the shareable URL
- **Embed code:** `<iframe src="https://yourdomain.com/embed/:slug"></iframe>`

---

## 12. Validation Changes

### `campground.validation.js`

| Schema | Change |
|---|---|
| `updateBasicInfo` | Removed `setup_status` field entirely |
| `uploadMedia` (removed) | Replaced by `uploadChunk` |
| `uploadChunk` (new) | Validates `uploadId` (UUID), `chunkIndex`, `totalChunks`, `filename`, `mimeType`, `mediaType` |
| `createFaq` | Added optional `globalFaqId` (hex string, 24 chars / ObjectId) |
| `upsertAvailability` (new) | Validates `type` (required, `manual\|erp`), `erpProvider` (optional string), `erpConfig` (optional object) |
| `updateVisibility` (new) | Validates `is_visible` (required boolean) |

---

## 13. Other Fixes

### `auth.service.js` - Removed debug `console.log`

Line 432 had a stray `console.log(campsite)` left inside `campsiteForgotUsername`. Removed.

---

## 14. Migration Notes

### For existing CampsiteMedia records

Run this migration on the `campsite_media` collection to rename `"main"` → `"cover"`:

```js
db.campsite_media.updateMany(
  { type: "main" },
  { $set: { type: "cover" } }
)
```

Then add `mediaCategory: "image"` to all existing records (all were images before video support):

```js
db.campsite_media.updateMany(
  { mediaCategory: { $exists: false } },
  { $set: { mediaCategory: "image" } }
)
```

Remove the `isDeleted` field from all records (no longer used):

```js
db.campsite_media.updateMany(
  {},
  { $unset: { isDeleted: "" } }
)
```

---

### For existing CampsiteFaq records

Add `sourceType: "custom"` to all existing records (they were all owner-created):

```js
db.campsite_faqs.updateMany(
  { sourceType: { $exists: false } },
  { $set: { sourceType: "custom", globalFaqId: null } }
)
```

Remove `isDeleted`:

```js
db.campsite_faqs.updateMany(
  {},
  { $unset: { isDeleted: "" } }
)
```

---

### For existing Campsite records

Add missing new fields with their defaults:

```js
db.campsites.updateMany(
  { step1Status: { $exists: false } },
  {
    $set: {
      step1Status: "pending",
      step2Status: "pending",
      isVisible:   false,
      slug:        null
    }
  }
)
```

Update `setupStatus` for records that were previously `"complete"` (old enum value):

```js
db.campsites.updateMany(
  { setupStatus: "complete" },
  { $set: { setupStatus: "completed" } }
)
```

---

### Temp directory

Ensure `public/temp/chunks/` exists and is writable. The chunk upload system creates subdirectories dynamically but the base path must be accessible. Add it to `.gitignore`:

```
public/temp/
```
