Files
cozsweet-frontend-nextjs/docs/backend/FRONTEND_PRIVATE_ROOM_API.md
T
admin c46b9b4cdd
Docker Image / Build and Push Docker Image (push) Successful in 9m29s
feat(private-room): connect moments feed
2026-07-08 16:29:08 +08:00

26 KiB

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:

https://api.cozsweet.com

Project production API base is also:

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.

Authorization: Bearer <TOKEN>

If the token is missing or invalid, production returns:

{
  "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:

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:

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

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

{
  "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:

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

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

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:

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

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

Request Format

No query params and no body.

Request Example

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

Success Response

{
  "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

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

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

Locked Post Response Example

{
  "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:

{
  "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:

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 :.

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

Frontend example:

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

Request Format

Content-Type:

Content-Type: application/json

Body is optional, but recommended:

{
  "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

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

{
  "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:

{
  "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.

{
  "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:

{
  "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

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;
}
  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:
await api.post(
  `/api/private-room/moments/${encodeURIComponent(item.momentId)}/unlock`,
  { expectedCost: item.unlockCost },
);
  1. 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.
  2. 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:
`${item.unlockCost} credits`

Optionally show per-image detail:

`${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

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

Expected:

HTTP/1.1 401 Unauthorized

13.2 Get Config

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

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.

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:

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

Private-room endpoints require auth:

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

Backend generated locked item shape:

{
  "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:

{
  "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:

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

15. Database / Persistence Notes

Migration file:

database/private-room-migration.sql

Full local path:

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.