feat(private-room): connect moments feed
Docker Image / Build and Push Docker Image (push) Successful in 9m29s
Docker Image / Build and Push Docker Image (push) Successful in 9m29s
This commit is contained in:
@@ -0,0 +1,973 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user