# Cozsweet Private Room / Moments Frontend API

Last verified: 2026-07-08

This document is for the frontend integration of the Elio private room / Moments-like feed. The feature is backend-only in phase 1: posts come from existing schedule images, all posts are locked by default, and users spend credits to unlock the real image URLs.

## 1. Scope

The frontend needs to support three backend endpoints:

| Purpose | Method | Path |
| --- | --- | --- |
| Get private-room config | `GET` | `/api/private-room/config` |
| Get locked/unlocked Moments feed | `GET` | `/api/private-room/moments` |
| Unlock one Moment post | `POST` | `/api/private-room/moments/{momentId}/unlock` |

Recommended production base for cozsweet frontend:

```text
https://api.cozsweet.com
```

Project production API base is also:

```text
https://api.banlv-ai.com
```

Use the same API base already used by the cozsweet frontend login/chat client. Do not hardcode both in the same frontend flow.

## 2. Authentication

All three endpoints require the normal user Bearer token.

```http
Authorization: Bearer <TOKEN>
```

If the token is missing or invalid, production returns:

```json
{
  "code": 401,
  "message": "Not authenticated",
  "success": false,
  "data": null
}
```

Frontend action:

- If the user is a guest, use the existing guest token flow first.
- If the user is logged in, use the existing login token.
- If this API returns 401, refresh/re-login using the existing auth behavior.

## 3. Field Mapping vs Payment Plans

Do not treat private-room unlock price as a payment amount.

| Area | Price field | Currency field | Meaning |
| --- | --- | --- | --- |
| Payment plans | `amountCents` | `currency`, for example `PHP` or `USD` | Cash price in display cents. `14990` + `PHP` means `PHP 149.90`. |
| Payment plans | `originalAmountCents` | `currency` | Original crossed-out cash price. |
| Payment plans | `dailyPriceCents` | `currency` | VIP daily cash price, if applicable. |
| Private room | `unlockCost` | `currency: "credits"` | Total credits required to unlock this post. |
| Private room | `unlockCostPerImage` | `currency: "credits"` | Credits required per image. Currently `40`. |
| Private room | `requiredCredits` | `currency: "credits"` | Same unlock requirement, useful for lock/paywall UI. |

Unlock state fields:

| Field | Type | Meaning | Frontend usage |
| --- | --- | --- | --- |
| `locked` | boolean | `true` means the post is locked. | Primary field for locked UI. |
| `unlocked` | boolean | `true` means the post has been unlocked for this user. | Primary field for unlocked UI if preferred. |
| `lockDetail.locked` | boolean | Same lock decision in chat-compatible structure. | Useful if sharing lock/paywall components with chat. |
| `images[].locked` | boolean | Whether this image placeholder is locked. | Use for image tile state. |
| `images[].url` | string or null | Real image URL only after unlock. | Render `<img src={url}>` only when not null. |

Recommended frontend condition:

```ts
const isLocked = item.locked || item.lockDetail?.locked;
```

## 4. Data Source and Image Access

Phase 1 data source:

| Source | Meaning |
| --- | --- |
| Table | `elio_schedules` |
| Single image field | `image_url` |
| Multi-image fields | `image_urls`, `image_paths`, metadata inside `content` |
| Returned post id | `momentId`, for example `schedule:91` |

Locked posts deliberately do not expose real image URLs:

```json
{
  "images": [
    {
      "url": null,
      "type": "image",
      "locked": true,
      "index": 0
    }
  ]
}
```

After unlock, the same image item returns a real public Supabase Storage URL:

```json
{
  "images": [
    {
      "url": "https://lehwkihwnlqkavhcspel.supabase.co/storage/v1/object/public/elio-schedules/schedules/91/0502fee0-4755-460a-adac-8e4d71a72343.jpg",
      "type": "image",
      "locked": false,
      "index": 0
    }
  ]
}
```

Frontend can render it directly:

```tsx
{image.url ? <img src={image.url} alt="" /> : <LockedImageTile />}
```

Production image accessibility was verified on 2026-07-08:

```text
HTTP 200 OK
Content-Type: image/jpeg
Access-Control-Allow-Origin: *
```

So the frontend does not need an extra image proxy for the current Supabase public URLs.

## 5. Common Response Wrapper

All successful API calls use the standard wrapper:

```ts
interface ApiResponse<T> {
  code: number;
  message: string;
  success: boolean;
  data: T | null;
}
```

Important: unlock business failures can still return HTTP 200 with `success: true`, but `data.unlocked` is `false`. For unlock UI, check `data.unlocked`, `data.locked`, and `data.reason`, not only HTTP status.

## 6. GET /api/private-room/config

### Purpose

Get the current user's private-room config, default unlock price, current credit balance, and Elio profile info.

### Request URL

```http
GET https://api.cozsweet.com/api/private-room/config
```

### Request Format

No query params and no body.

### Request Example

```bash
curl -X GET "https://api.cozsweet.com/api/private-room/config" \
  -H "Authorization: Bearer <TOKEN>"
```

### Success Response

```json
{
  "code": 200,
  "message": "success",
  "success": true,
  "data": {
    "currency": "credits",
    "unlockCostDefault": 40,
    "unlockCostPerImage": 40,
    "source": "elio_schedules",
    "creditBalance": 100,
    "profile": {
      "characterId": "elio",
      "displayName": "Elio Silvestri",
      "authorName": "Elio Silvestri",
      "avatarUrl": null,
      "coverUrl": null,
      "title": "Elio Private room",
      "subtitle": "Join me, unlock my private room"
    }
  }
}
```

### Key Fields

| Field | Type | Meaning |
| --- | --- | --- |
| `currency` | string | Always `"credits"` for this feature. |
| `unlockCostDefault` | number | Default single-image unlock price. Currently `40`. |
| `unlockCostPerImage` | number | Price per image. Currently `40`. |
| `creditBalance` | number | User's current credits. |
| `profile` | object | Elio private-room profile. |

### Frontend Usage

Call it when entering the private room page if the frontend needs profile/header data before loading the feed. Otherwise, the feed endpoint also returns `profile`, `creditBalance`, and pricing fields.

## 7. GET /api/private-room/moments

### Purpose

Get the private-room feed. Locked posts return placeholders and unlock prices; already-unlocked posts return real image URLs.

### Request URL

```http
GET https://api.cozsweet.com/api/private-room/moments?character=elio&limit=20
```

### Query Parameters

| Field | Type | Required | Example | Meaning | Validation |
| --- | --- | --- | --- | --- | --- |
| `character` | string | No | `elio` | Character/private-room id. Phase 1 supports `elio`. | Defaults to `elio`. |
| `limit` | number | No | `20` | Page size. | Min `1`, max `50`, defaults to `20`. |
| `cursor` | string | No | `1783488000` | Pagination cursor from previous `data.nextCursor`. | Omit for first page. |

### Request Example

```bash
curl -X GET "https://api.cozsweet.com/api/private-room/moments?character=elio&limit=20" \
  -H "Authorization: Bearer <TOKEN>"
```

### Locked Post Response Example

```json
{
  "code": 200,
  "message": "success",
  "success": true,
  "data": {
    "profile": {
      "characterId": "elio",
      "displayName": "Elio Silvestri",
      "authorName": "Elio Silvestri",
      "avatarUrl": null,
      "coverUrl": null,
      "title": "Elio Private room",
      "subtitle": "Join me, unlock my private room"
    },
    "items": [
      {
        "momentId": "schedule:91",
        "source": "elio_schedules",
        "sourceId": "91",
        "characterId": "elio",
        "author": {
          "id": "elio",
          "name": "Elio Silvestri",
          "avatarUrl": null
        },
        "createdAt": "2026-07-01T08:11:35+00:00",
        "publishedAt": "2026-07-01T08:11:35+00:00",
        "timeText": "7天前",
        "title": "Paris morning",
        "content": null,
        "text": null,
        "textPreview": "Unlock to view private room photos",
        "mediaCount": 1,
        "images": [
          {
            "url": null,
            "type": "image",
            "locked": true,
            "index": 0
          }
        ],
        "locked": true,
        "unlocked": false,
        "unlockCost": 40,
        "unlockCostPerImage": 40,
        "requiredCredits": 40,
        "currency": "credits",
        "lockDetail": {
          "locked": true,
          "showContent": false,
          "showUpgrade": true,
          "reason": "private_room_moment",
          "hint": "40 credits · Unlock photo",
          "actionLabel": "Unlock",
          "type": "private_room_moment",
          "requiredCredits": 40,
          "currentCredits": 100,
          "shortfallCredits": 0,
          "mediaCount": 1,
          "unlockCostPerImage": 40,
          "detail": {
            "type": "private_room_moment",
            "requiredCredits": 40,
            "currentCredits": 100,
            "shortfallCredits": 0,
            "mediaCount": 1,
            "unlockCostPerImage": 40
          }
        }
      }
    ],
    "nextCursor": null,
    "hasMore": false,
    "creditBalance": 100,
    "unlockCostDefault": 40,
    "unlockCostPerImage": 40,
    "currency": "credits",
    "source": "elio_schedules"
  }
}
```

### Already-Unlocked Post Shape

When the user has already unlocked the post, the list endpoint returns real image URLs directly:

```json
{
  "momentId": "schedule:91",
  "locked": false,
  "unlocked": true,
  "content": "A quiet morning in Paris.",
  "text": "A quiet morning in Paris.",
  "mediaCount": 1,
  "images": [
    {
      "url": "https://lehwkihwnlqkavhcspel.supabase.co/storage/v1/object/public/elio-schedules/schedules/91/0502fee0-4755-460a-adac-8e4d71a72343.jpg",
      "type": "image",
      "locked": false,
      "index": 0
    }
  ],
  "lockDetail": {
    "locked": false,
    "showContent": true,
    "showUpgrade": false
  }
}
```

### Key Item Fields

| Field | Type | Meaning |
| --- | --- | --- |
| `momentId` | string | Post id. Use this for unlock. Example: `schedule:91`. |
| `source` | string | Current source table, `elio_schedules`. |
| `sourceId` | string | Schedule row id. |
| `title` | string | Optional post title. |
| `content` | string or null | Real text content. Null when locked. |
| `text` | string or null | Same as content, for frontend naming compatibility. |
| `textPreview` | string | Preview text shown while locked. |
| `mediaCount` | number | Number of images in this post. |
| `images` | array | Image placeholders when locked; real URLs when unlocked. |
| `locked` | boolean | Whether this post is locked. |
| `unlocked` | boolean | Whether this post is unlocked. |
| `unlockCost` | number | Total credits required for this post. |
| `unlockCostPerImage` | number | Credits per image. |
| `requiredCredits` | number | Required credits for lock UI. |
| `currency` | string | Always `"credits"`. |
| `lockDetail` | object | Chat-compatible lock detail. |

### Pagination

If `data.hasMore` is true, call the same endpoint with:

```text
cursor=<data.nextCursor>
```

## 8. POST /api/private-room/moments/{momentId}/unlock

### Purpose

Unlock one private-room post, deduct credits, persist the unlock state, and return real image URLs.

### Request URL

`momentId` must be URL encoded because it contains `:`.

```http
POST https://api.cozsweet.com/api/private-room/moments/schedule%3A91/unlock
```

Frontend example:

```ts
const url = `/api/private-room/moments/${encodeURIComponent(momentId)}/unlock`;
```

### Request Format

Content-Type:

```http
Content-Type: application/json
```

Body is optional, but recommended:

```json
{
  "expectedCost": 40
}
```

### Body Parameters

| Field | Type | Required | Example | Meaning |
| --- | --- | --- | --- | --- |
| `expectedCost` | number | No | `40` | The price shown to the user. If backend price changed, unlock returns `reason: "cost_changed"` instead of charging. |

### Request Example

```bash
curl -X POST "https://api.cozsweet.com/api/private-room/moments/schedule%3A91/unlock" \
  -H "Authorization: Bearer <TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{"expectedCost":40}'
```

### Unlock Success Response

```json
{
  "code": 200,
  "message": "success",
  "success": true,
  "data": {
    "momentId": "schedule:91",
    "source": "elio_schedules",
    "sourceId": "91",
    "characterId": "elio",
    "content": "A quiet morning in Paris.",
    "text": "A quiet morning in Paris.",
    "mediaCount": 1,
    "images": [
      {
        "url": "https://lehwkihwnlqkavhcspel.supabase.co/storage/v1/object/public/elio-schedules/schedules/91/0502fee0-4755-460a-adac-8e4d71a72343.jpg",
        "type": "image",
        "locked": false,
        "index": 0
      }
    ],
    "locked": false,
    "unlocked": true,
    "unlockCost": 40,
    "unlockCostPerImage": 40,
    "requiredCredits": 40,
    "currency": "credits",
    "reason": "ok",
    "creditsCharged": 40,
    "creditBalance": 60,
    "previousCreditBalance": 100,
    "persisted": true,
    "lockDetail": {
      "locked": false,
      "showContent": true,
      "showUpgrade": false,
      "reason": null,
      "hint": null,
      "actionLabel": null,
      "type": "private_room_moment",
      "requiredCredits": 40,
      "currentCredits": 60,
      "shortfallCredits": 0,
      "mediaCount": 1,
      "unlockCostPerImage": 40
    }
  }
}
```

### Already Unlocked Response

If the same user unlocks the same post again, backend does not charge again:

```json
{
  "code": 200,
  "message": "success",
  "success": true,
  "data": {
    "momentId": "schedule:91",
    "locked": false,
    "unlocked": true,
    "reason": "already_unlocked",
    "creditsCharged": 0,
    "creditBalance": 60,
    "images": [
      {
        "url": "https://lehwkihwnlqkavhcspel.supabase.co/storage/v1/object/public/elio-schedules/schedules/91/0502fee0-4755-460a-adac-8e4d71a72343.jpg",
        "type": "image",
        "locked": false,
        "index": 0
      }
    ]
  }
}
```

### Insufficient Credits Response

This is a business failure, but it still returns HTTP 200 and `success: true`.

```json
{
  "code": 200,
  "message": "success",
  "success": true,
  "data": {
    "momentId": "schedule:91",
    "locked": true,
    "unlocked": false,
    "reason": "insufficient_credits",
    "requiredCredits": 40,
    "currentCredits": 10,
    "shortfallCredits": 30,
    "creditBalance": 10,
    "images": [
      {
        "url": null,
        "type": "image",
        "locked": true,
        "index": 0
      }
    ],
    "lockDetail": {
      "locked": true,
      "showContent": false,
      "showUpgrade": true,
      "reason": "insufficient_credits",
      "hint": "Not enough credits to unlock this private room post.",
      "actionLabel": "Unlock",
      "requiredCredits": 40,
      "currentCredits": 10,
      "shortfallCredits": 30,
      "mediaCount": 1,
      "unlockCostPerImage": 40
    }
  }
}
```

Frontend action:

- Keep the post locked.
- Show recharge / insufficient credits UI.
- Do not try to render `images[].url` because it remains null.

### Cost Changed Response

If `expectedCost` does not match the backend price:

```json
{
  "code": 200,
  "message": "success",
  "success": true,
  "data": {
    "momentId": "schedule:91",
    "locked": true,
    "unlocked": false,
    "reason": "cost_changed",
    "requiredCredits": 40,
    "lockDetail": {
      "locked": true,
      "reason": "cost_changed",
      "hint": "Unlock price changed. Please refresh and try again."
    }
  }
}
```

Frontend action:

- Refresh the feed item.
- Ask the user to confirm again with the latest `unlockCost`.

### Other Business Reasons

| `data.reason` | Meaning | Frontend action |
| --- | --- | --- |
| `ok` | Unlock succeeded and credits were charged. | Show real images and refresh credit balance. |
| `already_unlocked` | User had unlocked before. No charge. | Show real images. |
| `insufficient_credits` | Not enough credits. No charge. | Keep locked and open recharge UI. |
| `cost_changed` | Frontend expected cost differs from backend. No charge. | Refresh and ask user to confirm again. |
| `not_found` | Post no longer exists. | Remove item or show unavailable state. |
| `no_media` | Post has no images to unlock. | Hide unlock button. |
| `deduct_failed` | Credit deduction failed. | Keep locked and allow retry. |
| `ok_persist_failed` | Credits charged and images returned, but unlock record fallback may have been used. | Show images; optionally refresh list. |

## 9. TypeScript Types

```ts
export interface PrivateRoomProfile {
  characterId: string;
  displayName: string;
  authorName: string;
  avatarUrl: string | null;
  coverUrl: string | null;
  title: string;
  subtitle: string;
}

export interface PrivateRoomImage {
  url: string | null;
  type: "image";
  locked: boolean;
  index: number;
}

export interface PrivateRoomLockDetail {
  locked: boolean;
  showContent: boolean;
  showUpgrade: boolean;
  reason?: string | null;
  hint?: string | null;
  actionLabel?: string | null;
  type?: "private_room_moment";
  requiredCredits?: number;
  currentCredits?: number | null;
  shortfallCredits?: number;
  mediaCount?: number;
  unlockCostPerImage?: number;
  detail?: {
    type: "private_room_moment";
    requiredCredits: number;
    currentCredits: number | null;
    shortfallCredits: number;
    mediaCount: number;
    unlockCostPerImage: number;
  };
}

export interface PrivateRoomMoment {
  momentId: string;
  source: "elio_schedules";
  sourceId: string;
  characterId: string;
  author: {
    id: string;
    name: string;
    avatarUrl: string | null;
  };
  createdAt: string | null;
  publishedAt: string | null;
  timeText: string;
  title: string;
  content: string | null;
  text: string | null;
  textPreview: string;
  mediaCount: number;
  images: PrivateRoomImage[];
  locked: boolean;
  unlocked: boolean;
  unlockCost: number;
  unlockCostPerImage: number;
  requiredCredits: number;
  currency: "credits";
  lockDetail: PrivateRoomLockDetail;
}

export interface PrivateRoomMomentsData {
  profile: PrivateRoomProfile;
  items: PrivateRoomMoment[];
  nextCursor: string | null;
  hasMore: boolean;
  creditBalance: number;
  unlockCostDefault: number;
  unlockCostPerImage: number;
  currency: "credits";
  source: "elio_schedules";
}

export interface PrivateRoomUnlockData extends PrivateRoomMoment {
  reason:
    | "ok"
    | "already_unlocked"
    | "insufficient_credits"
    | "cost_changed"
    | "not_found"
    | "no_media"
    | "deduct_failed"
    | "ok_persist_failed";
  creditsCharged?: number;
  creditBalance?: number;
  previousCreditBalance?: number;
  currentCredits?: number;
  shortfallCredits?: number;
  persisted?: boolean;
}
```

## 10. Recommended Frontend Flow

1. User enters private room page.
2. Call `GET /api/private-room/moments?character=elio&limit=20`.
3. Render each item:
   - If `item.locked === true`, show locked image tiles and unlock price.
   - If `item.locked === false`, render `images[].url`.
4. User taps unlock.
5. Show confirm UI with `item.unlockCost` and `item.currency`.
6. Call:

```ts
await api.post(
  `/api/private-room/moments/${encodeURIComponent(item.momentId)}/unlock`,
  { expectedCost: item.unlockCost },
);
```

7. On response:
   - If `data.unlocked === true`, replace the current card with response `data`.
   - If `data.reason === "insufficient_credits"`, keep locked and open recharge UI.
   - If `data.reason === "cost_changed"`, refresh list and ask for confirmation again.
   - For other failure reasons, show a retry/unavailable message.
8. Update visible credit balance from `data.creditBalance` if present.

## 11. UI Rules

- Locked state must never render `images[].url` if it is null.
- Do not show broken images for locked posts; use lock tiles based on `mediaCount`.
- Use `unlockCost` for the button price, not `amountCents`.
- Use `currency === "credits"` to display "credits", not PHP/USD.
- For multi-image posts, display total price:

```ts
`${item.unlockCost} credits`
```

Optionally show per-image detail:

```ts
`${item.unlockCostPerImage} credits/photo`
```

- After successful unlock, do not call any separate image API. Use `data.images`.
- To avoid double tapping, disable the unlock button while the POST request is pending.

## 12. Failure Handling

| Case | How to detect | Frontend handling |
| --- | --- | --- |
| Not logged in | HTTP 401 or wrapper `code: 401` | Refresh token, guest token, or login. |
| Validation error | HTTP 422 | Check path/body format, especially encoded `momentId`. |
| Insufficient credits | HTTP 200 with `data.reason === "insufficient_credits"` | Open recharge UI. |
| Price changed | HTTP 200 with `data.reason === "cost_changed"` | Refresh item and reconfirm. |
| Already unlocked | HTTP 200 with `data.reason === "already_unlocked"` | Show returned images, no charge. |
| Deduct failed | HTTP 200 with `data.reason === "deduct_failed"` | Keep locked and allow retry. |
| Network/API error | request failed or 5xx | Keep current state and show retry. |

## 13. Production-Safe Test Method

These tests do not require creating posts.

### 13.1 Auth Required

```bash
curl -i "https://api.cozsweet.com/api/private-room/moments?character=elio&limit=1"
```

Expected:

```text
HTTP/1.1 401 Unauthorized
```

### 13.2 Get Config

```bash
curl -X GET "https://api.cozsweet.com/api/private-room/config" \
  -H "Authorization: Bearer <TOKEN>"
```

Expected:

- `data.currency === "credits"`
- `data.unlockCostPerImage === 40`
- `data.creditBalance` is a number

### 13.3 Get Locked Feed

```bash
curl -X GET "https://api.cozsweet.com/api/private-room/moments?character=elio&limit=1" \
  -H "Authorization: Bearer <TOKEN>"
```

Expected for a not-yet-unlocked post:

- `data.items[0].locked === true`
- `data.items[0].unlocked === false`
- `data.items[0].unlockCost === data.items[0].mediaCount * 40`
- `data.items[0].images[0].url === null`

### 13.4 Unlock With Insufficient Credits

Use a test/guest account with fewer credits than `unlockCost`.

```bash
curl -X POST "https://api.cozsweet.com/api/private-room/moments/schedule%3A91/unlock" \
  -H "Authorization: Bearer <TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{"expectedCost":40}'
```

Expected:

- HTTP 200
- `data.unlocked === false`
- `data.locked === true`
- `data.reason === "insufficient_credits"`
- `data.images[0].url === null`

### 13.5 Unlock Success

Use only a test account that intentionally has enough credits. This deducts real credits.

Expected:

- `data.unlocked === true`
- `data.locked === false`
- `data.creditsCharged === data.unlockCost`
- `data.images[0].url` is a non-empty HTTPS URL
- Repeating the same unlock should return `reason: "already_unlocked"` and `creditsCharged: 0`

## 14. Confirmed Production Evidence

Verified on 2026-07-08:

```text
GET https://api.cozsweet.com/health
HTTP 200
```

Private-room endpoints require auth:

```text
GET /api/private-room/moments without Bearer token
HTTP 401 Not authenticated
```

Backend generated locked item shape:

```json
{
  "momentId": "schedule:91",
  "mediaCount": 1,
  "unlockCost": 40,
  "unlockCostPerImage": 40,
  "currency": "credits",
  "locked": true,
  "unlocked": false,
  "requiredCredits": 40,
  "images": [
    {
      "url": null,
      "type": "image",
      "locked": true,
      "index": 0
    }
  ],
  "lockDetail": {
    "locked": true,
    "requiredCredits": 40
  }
}
```

Backend generated unlocked image shape:

```json
{
  "momentId": "schedule:91",
  "locked": false,
  "unlocked": true,
  "unlockCost": 40,
  "currency": "credits",
  "images": [
    {
      "url": "https://lehwkihwnlqkavhcspel.supabase.co/storage/v1/object/public/elio-schedules/schedules/91/0502fee0-4755-460a-adac-8e4d71a72343.jpg",
      "type": "image",
      "locked": false,
      "index": 0
    }
  ],
  "lockDetail": {
    "locked": false
  }
}
```

The sample real image URL returned:

```text
HTTP 200 OK
Content-Type: image/jpeg
Access-Control-Allow-Origin: *
```

## 15. Database / Persistence Notes

Migration file:

```text
database/private-room-migration.sql
```

Full local path:

```text
C:\windows_banlv\ai-boyfriend\ai-boyfriend-unified\database\private-room-migration.sql
```

Purpose:

- Create `private_room_unlocks`
- Persist `user_id + moment_key`
- Prevent charging again for already-unlocked posts

Current production backend also has a fallback to `users.preferences.private_room_unlocked_moment_keys` if the unlock table is missing, so the feature can work before/without the migration. Running the migration is still recommended for cleaner persistence and future analytics.

## 16. Frontend Integration Checklist

- Use Bearer token for all three endpoints.
- Encode `momentId` in unlock path with `encodeURIComponent`.
- Render locked placeholders when `locked === true`.
- Render real images only when `images[].url` is not null.
- Use `unlockCost` and `currency: "credits"` for unlock price.
- Send `{ expectedCost: item.unlockCost }` when unlocking.
- Treat `data.unlocked === false` as an unlock business failure even if HTTP is 200.
- Update displayed credit balance from `data.creditBalance`.
- On insufficient credits, open recharge flow.
- On success, replace the card with returned `data` or reload the feed.

## 17. Rollback / Compatibility Notes

This document describes the currently deployed backend behavior. Frontend can integrate it without changing existing payment plan fields.

Compatibility:

- Existing payment `/api/payment/plans` is unchanged.
- Existing chat lock UI can reuse `lockDetail.locked`, `requiredCredits`, and `shortfallCredits`.
- Locked private-room items do not leak image URLs.

Rollback if frontend needs to disable the feature:

- Hide the private-room entry point.
- Do not call `/api/private-room/*`.
- No existing chat/payment flow needs to be rolled back.

## 18. Needs Confirmation

- `avatarUrl` and `coverUrl` currently return `null`; frontend should use local/default Elio assets until final profile images are provided.
- Phase 1 content source is `elio_schedules`; if product wants separate Moments content, backend can keep the API contract and change only the source implementation later.
