# 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 ``` 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 `` 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 ? : } ``` 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 { 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 " ``` ### 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 " ``` ### 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= ``` ## 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 " \ -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 " ``` 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 " ``` 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 " \ -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.