974 lines
26 KiB
Markdown
974 lines
26 KiB
Markdown
# 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.
|