{"openapi":"3.0.0","paths":{"/api":{"get":{"operationId":"AppController_getHello","parameters":[],"responses":{"200":{"description":""}},"tags":["App"]}},"/api/health":{"get":{"operationId":"AppController_getHealth","parameters":[],"responses":{"200":{"description":""}},"tags":["App"]}},"/api/health/badge":{"get":{"operationId":"AppController_getHealthBadge","parameters":[],"responses":{"200":{"description":""}},"tags":["App"]}},"/api/is_following/batch":{"post":{"operationId":"UserController_followStatusBatch","parameters":[],"responses":{"201":{"description":""}},"tags":["Users"]}},"/api/account_info/batch":{"post":{"operationId":"UserController_getAccountInfoBatch","parameters":[],"responses":{"201":{"description":""}},"tags":["Users"]}},"/api/accounts/discovery-hidden":{"post":{"description":"Send up to 200 addresses; `hidden` lists the ones that are banned, deleted, adult or spam and must not be suggested.","operationId":"UserController_getDiscoveryHidden","parameters":[],"responses":{"201":{"description":""}},"summary":"Addresses to leave out of people lists","tags":["Users"]}},"/api/profile_counts/{address}":{"get":{"operationId":"UserController_getProfileContentCounts","parameters":[{"name":"address","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Users"]}},"/api/account/erase":{"post":{"operationId":"UserController_eraseOwnAccount","parameters":[],"responses":{"201":{"description":""}},"security":[{"bearer":[]}],"summary":"Permanently delete your account and associated data","tags":["Users"]}},"/api/account_info/{id}":{"get":{"description":"\nRetrieves complete profile information for a user. You can look up users by either their wallet address or username.\n\n### What's Included:\n- **Profile data**: username, display name, avatar, cover image, bio\n- **Stats**: followers count, likes, uploads, tips sent/received\n- **Wallet data**: token balances across supported chains\n- **Social data**: list of users they follow and who follows them (if not hidden)\n- **Follow relationship**: if you provide your address, includes whether you follow this user and if they follow you\n\n### Owner-Only Fields (only included when viewing your own profile)\n- **dmSettings**: direct message preferences\n- **notificationPreferences**: notification settings\n\n### Authentication Optional (JWT Bearer Token)\nThis is a public endpoint — anyone can view user profiles.\nTo include follow relationship info and see owner-only fields, send your **JWT Bearer token** in the `Authorization` header.\n\n> The `address` query param is **deprecated and ignored** — viewer identity is extracted from the JWT token.\n\n### Follow Status (when authenticated)\nWhen a valid JWT token is provided:\n- `isFollowing`: true if you follow this user (accepted follows only)\n- `followsYou`: true if this user follows you (accepted follows only)\n- `isFollowRequestPending`: true if you have a pending follow request to this private account\n- `pendingFollowRequests`: (own profile only) count of pending follow requests awaiting your approval\n\n### Block Status (when authenticated, other profiles only)\n- `youBlocked`: true if you have blocked this user\n- `blockedYou`: true if this user has blocked you\n- `isBlocked`: true if any block relationship exists between you and this user\n    ","operationId":"UserController_accountInfo","parameters":[{"name":"address","required":false,"in":"query","description":"**DEPRECATED — ignored.** Your identity is now extracted from the JWT Bearer token. Send `Authorization: Bearer <token>` to include isFollowing/followsYou.","deprecated":true,"schema":{"example":"0xabcdef1234567890abcdef1234567890abcdef12"}},{"name":"id","required":true,"in":"path","description":"Wallet address (0x...) or username of the user to look up","schema":{"example":"0x1234567890abcdef1234567890abcdef12345678"}}],"responses":{"200":{"description":"User profile retrieved successfully","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"object","properties":{"address":{"type":"string","example":"0x1234..."},"username":{"type":"string","example":"johndoe"},"displayName":{"type":"string","example":"John Doe"},"avatarImageUrl":{"type":"string","nullable":true},"coverImageUrl":{"type":"string","nullable":true},"aboutMe":{"type":"string","nullable":true},"followers":{"type":"number","description":"Total follower count","example":150},"followings":{"type":"number","description":"Total following count","example":75},"hideFollowers":{"type":"boolean","description":"If true, follower/following lists are hidden","example":false},"isPrivate":{"type":"boolean","description":"If true, user profile content is private","example":false},"badgeBalance":{"type":"number","description":"Denormalized walletBalance + staked across all DHB chains (for badge tier). Updated in real-time by blockchain webhooks. Can sit above what the wallet owns when a badge has been delegated to it — use ownBadgeBalance to show someone their own DHB.","example":75000},"ownBadgeBalance":{"type":"number","description":"What the chain says: DHB in the wallet on BSC and Base plus both stakes (legacy contract and transfer pool). The number to render as \"your DHB\". Null on pre-migration rows; fall back to badgeBalance.","example":75000},"likes":{"type":"number","description":"Total likes received on all content (updated daily)","example":1250},"dislikes":{"type":"number","description":"Total dislikes received on all content (updated daily)","example":45},"followersList":{"type":"array","items":{"type":"string"},"description":"Latest 10 follower addresses (only included if hideFollowers=false OR viewing own profile)"},"followingsList":{"type":"array","items":{"type":"string"},"description":"Latest 10 following addresses (only included if hideFollowers=false OR viewing own profile)"},"uploads":{"type":"number","example":5},"sentTips":{"type":"number","example":100},"receivedTips":{"type":"number","example":250},"balanceData":{"type":"array","items":{"type":"object","properties":{"chainId":{"type":"number"},"tokenAddress":{"type":"string"},"walletBalance":{"type":"number"},"staked":{"type":"number"}}}},"dmSettings":{"type":"object","description":"Direct message settings (owner-only). Use GET /dm/free-access/:address to manage the free-access whitelist separately.","properties":{"address":{"type":"string","description":"User wallet address (lowercase)"},"disables":{"type":"array","items":{"type":"string"},"description":"Opt-out flags: NEW_DM, ALL, ACTIVE_ALL"},"perMessageFee":{"type":"number","description":"DHB fee per incoming message (0 = free)"}}},"notificationPreferences":{"type":"object","description":"Notification preferences (owner-only)"},"isFollowing":{"type":"boolean","description":"Whether you follow this user — accepted follows only (only when authenticated via JWT)","example":true},"followsYou":{"type":"boolean","description":"Whether this user follows you — accepted follows only (only when authenticated via JWT)","example":false},"isFollowRequestPending":{"type":"boolean","description":"True if you have a pending follow request to this private account (only when authenticated via JWT)","example":false},"pendingFollowRequests":{"type":"number","description":"Number of pending follow requests awaiting approval (owner-only, own profile)","example":3},"youBlocked":{"type":"boolean","description":"You have blocked this user (only when authenticated and viewing another profile)","example":false},"blockedYou":{"type":"boolean","description":"This user has blocked you (only when authenticated and viewing another profile)","example":true},"isBlocked":{"type":"boolean","description":"Any block exists in either direction between you and this user","example":true}}}}}}}},"400":{"description":"Bad request - no wallet address provided"},"404":{"description":"User not found"}},"summary":"Get user profile information","tags":["Users"]}},"/api/update_profile":{"post":{"description":"\nUpdate your profile information including text fields and images.\n\n### Authentication Required\nYou must be logged in with a valid JWT token. The profile updated will be the one associated with your authenticated wallet address.\n\n### Updatable Fields:\n- **username**: Unique username. Up to 30 characters of letters, numbers, hyphens\n  and underscores. **No minimum** — one and two character names are allowed.\n  Lowercased on save, and a set of reserved names (site routes, plus `admin`,\n  `support`, `official` and similar) is refused.\n- **displayName**: Display name shown on profile\n- **aboutMe**: Bio/description text\n- **Social links**: Twitter, Discord, Instagram, TikTok, Telegram, YouTube, Facebook\n- **customs**: Free-form profile settings owned by the clients — see below\n- **hideFollowers**: Privacy setting - when true, hides follower/following lists from other users\n- **isPrivate**: Privacy setting - when true, only followers can see your content (like Instagram private accounts)\n- **notificationPreferences**: Controls in-app and push notification settings independently\n- **avatarImg**: Profile picture (image file)\n- **coverImg**: Cover/banner image (image file)\n\n### Notification Preferences Structure:\nSend as JSON string with this structure:\n```json\n{\n  \"inAppEnabled\": true,      // Master switch for in-app notifications\n  \"pushEnabled\": true,       // Master switch for push notifications\n  \"inApp\": {                 // In-app notification settings (shown in notification center)\n    \"likes\": true,\n    \"comments\": true,\n    \"commentReplies\": true,\n    \"mentions\": true,\n    \"newFollowers\": true,\n    \"tips\": true,\n    \"subscriptions\": true,\n    \"ppvPurchases\": true,\n    \"milestones\": true,\n    \"livestreamStart\": true,\n    \"accountAlerts\": true,\n    \"announcements\": true\n  },\n  \"push\": {                  // Push notification settings (sent to device)\n    \"likes\": false,          // Off by default (can be noisy)\n    \"comments\": true,\n    \"commentReplies\": true,\n    \"mentions\": true,\n    \"newFollowers\": true,\n    \"tips\": true,\n    \"subscriptions\": true,\n    \"ppvPurchases\": true,\n    \"milestones\": true,\n    \"livestreamStart\": true,\n    \"accountAlerts\": true,\n    \"announcements\": true\n  },\n  \"quietHours\": {            // Push notification quiet hours\n    \"enabled\": false,\n    \"start\": 22,             // Hour 0-23\n    \"end\": 8,                // Hour 0-23\n    \"timezone\": \"UTC\"\n  }\n}\n```\n\n**Partial Updates**: You only need to send the fields you want to change. For example:\n- Turn off push likes only: `{\"push\": {\"likes\": false}}`\n- Turn off all push: `{\"pushEnabled\": false}`\n- Enable quiet hours: `{\"quietHours\": {\"enabled\": true, \"start\": 22, \"end\": 7}}`\n\n### The `customs` Blob:\nA JSON string of profile settings the clients own. **Any key name persists** — the\nbackend keeps no list of blessed names, so a client can ship a new profile\nsetting without waiting on a backend release.\n\nSend the **whole blob** every time: it replaces the stored one rather than\nmerging, so read `customs` off `/account_info` and spread your changes over it.\n\nConstraints:\n- Keys: `[A-Za-z0-9_-]`, 1-40 chars, 32 keys max per account. `__proto__`,\n  `constructor` and `prototype` are rejected.\n- Values: string, number or boolean — no nested objects or arrays.\n- Strings: 500 characters max, full Unicode. Emoji, punycode and non-Latin\n  scripts are kept verbatim; control characters, zero-width padding, bidi\n  overrides and the BOM are stripped, and strings are NFC-normalised.\n- Values are **not** HTML-escaped. Render them as untrusted text, exactly as you\n  already render `aboutMe`.\n- Anything rejected is dropped silently; the rest of the blob still saves. A\n  blob where nothing survives leaves the stored one untouched.\n- **The blob is public.** `/account_info` returns `customs` to any viewer, which\n  is how a visitor's client knows to honour `followVisibility`. Never put\n  anything private in it.\n\nKeys in use today:\n\n| Key | Values | Meaning |\n|---|---|---|\n| `followVisibility` | `public`, `counts-only`, `hidden` | How much of the follower/following counts a visitor sees. The coarse `hideFollowers` boolean stays the source of truth for whether the lists themselves are served. |\n| `defaultPostVisibility` | `public`, `private` | Visibility a new post starts at. |\n| `defaultProfileTab` | tab id | Tab a visitor lands on. |\n| `dislikesVisibility` | `private`, `public` | Whether visitors see the profile Dislikes tab. Absent means private. |\n| `\"1\"` - `\"5\"` | any string | Legacy custom link slots. |\n\n### Image Upload:\nSend images as multipart/form-data with field names `avatarImg` and/or `coverImg`.\n\nAnimated GIF and WebP avatars keep their animation — they are re-encoded to\nanimated WebP rather than flattened to a still first frame. Every other format\nis still normalised to JPEG. The returned `avatarImageUrl` carries whichever\nextension was produced, so read it from the response instead of assuming `.jpg`.\n\n### Clearing Fields:\nTo clear a field, send an empty string or `null` as the value.\n    ","operationId":"UserController_updateProfile","parameters":[],"requestBody":{"required":true,"description":"Profile update data with optional image uploads","content":{"multipart/form-data":{"schema":{"type":"object","properties":{"username":{"type":"string","description":"Unique username: 1-30 characters of [a-zA-Z0-9_-], lowercased on save. No minimum length. Reserved names (site routes, admin, support, official, …) are refused.","maxLength":30,"example":"johndoe"},"displayName":{"type":"string","description":"Display name","example":"John Doe"},"aboutMe":{"type":"string","description":"Bio/description","example":"Web3 enthusiast and content creator"},"hideFollowers":{"type":"boolean","description":"When true, hides follower/following lists from other users","example":false},"isPrivate":{"type":"boolean","description":"When true, only followers can see your content (like Instagram private accounts)","example":false},"hideBadgeAndBalance":{"type":"boolean","description":"When true, hides badge and financial figures from other users and disables DeHub send/tip actions to this account","example":false},"notificationPreferences":{"type":"string","description":"JSON string of notification preferences with separate inApp and push settings","example":"{\"inAppEnabled\": true, \"pushEnabled\": true, \"inApp\": {\"likes\": true, \"comments\": true}, \"push\": {\"likes\": false, \"comments\": true}, \"quietHours\": {\"enabled\": true, \"start\": 22, \"end\": 8}}"},"twitterLink":{"type":"string","description":"Twitter profile URL","example":"https://twitter.com/johndoe"},"discordLink":{"type":"string","description":"Discord username","example":"johndoe#1234"},"instagramLink":{"type":"string","description":"Instagram profile URL"},"tiktokLink":{"type":"string","description":"TikTok profile URL"},"telegramLink":{"type":"string","description":"Telegram username"},"youtubeLink":{"type":"string","description":"YouTube channel URL"},"facebookLink":{"type":"string","description":"Facebook profile URL"},"customs":{"type":"string","description":"JSON string of free-form profile settings. Any key name persists — see the endpoint description for the shape limits. Replaces the stored blob rather than merging, so send the whole thing.","example":"{\"followVisibility\": \"counts-only\", \"defaultPostVisibility\": \"public\", \"defaultProfileTab\": \"posts\", \"1\": \"https://linktr.ee/me\"}"},"preferredLanguage":{"type":"string","description":"Preferred language for translation (ISO 639-1, 2-3 letters)","example":"en"},"showMatureContent":{"type":"boolean","description":"Include mature posts in the public feeds this viewer is served (home, shorts, search, suggestions). Off by default, and never applied to signed-out traffic. Says nothing about the viewer's own posts, which carry their own contentRating.","example":false},"avatarImg":{"type":"string","format":"binary","description":"Profile picture image file"},"coverImg":{"type":"string","format":"binary","description":"Cover/banner image file"}}}}}},"responses":{"200":{"description":"Profile updated successfully","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"boolean","example":true}}}}}},"400":{"description":"Invalid username format or validation error"},"401":{"description":"Unauthorized - invalid or missing token"},"500":{"description":"Server error during profile update"}},"security":[{"user-auth":[]}],"summary":"Update user profile","tags":["Users"]}},"/api/account/delete":{"post":{"description":"\nTakes the signed-in account out of the product immediately.\n\nThe profile stops resolving, the posts come off every feed, notifications stop\nand every session is revoked. The wallet is untouched — the address keeps its\nDHB, stakes, NFTs and on-chain subscriptions.\n\n**Nothing is erased.** Every row is retained, so a member who did not mean it\ngets everything back by signing in again. Permanent erasure is still the\nwritten request documented at https://riftn.com/delete-account.","operationId":"UserController_deleteOwnAccount","parameters":[],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"reason":{"type":"string","description":"Optional free text, kept with the tombstone"}}}}}},"responses":{"200":{"description":"Account deleted"},"401":{"description":"Unauthorized - invalid or missing token"},"404":{"description":"No account at this address, or already deleted"}},"security":[{"bearer":[]}],"summary":"Delete your account","tags":["Users"]}},"/api/users_count":{"get":{"description":"\nReturns the total number of registered users on the platform.\n\nPass `range` to also get how many of those signed up inside the window, plus a\ndaily series — for \"all users and new users\" dashboards.\n\n### No Authentication Required\nThis is a public endpoint for displaying platform statistics.\n\n### Backwards compatible\nWithout `range` the response is exactly `{ result: number }`, unchanged. The\nextra fields only appear when `range` is supplied, so existing callers that\nread `result` are unaffected.\n    ","operationId":"UserController_getNumberOfUsers","parameters":[{"name":"range","required":false,"in":"query","description":"Window for the new-user stats. Omit for the plain total.","schema":{"enum":["7d","30d","90d","1y","all"],"type":"string"}}],"responses":{"200":{"description":"User count retrieved successfully","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"number","description":"Total number of registered users","example":15420},"range":{"type":"string","description":"Echoed back when requested","example":"30d"},"newUsers":{"type":"number","description":"Users who signed up inside the window","example":842},"newUsersOverTime":{"type":"array","description":"Daily sign-ups inside the window, ascending by date","items":{"type":"object","properties":{"date":{"type":"string","example":"2026-07-01"},"count":{"type":"number","example":31}}}}}}}}}},"summary":"Get total user count, and optionally new-user growth","tags":["Users"]}},"/api/users_search":{"get":{"description":"\nSearch for users by username or wallet address.\n\n### How it Works:\n- If you provide a valid Ethereum address, it searches by exact address match\n- Otherwise, it performs a case-insensitive search on usernames\n\n### No Authentication Required\nThis is a public search endpoint.\n\n### Limits:\nReturns a maximum of 10 results per search.\n    ","operationId":"UserController_searchUsers","parameters":[{"name":"searchParam","required":false,"in":"query","description":"Username or wallet address to search for","schema":{"example":"john"}}],"responses":{"200":{"description":"Search results","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"array","items":{"type":"object","properties":{"address":{"type":"string"},"username":{"type":"string"},"displayName":{"type":"string"},"avatarImageUrl":{"type":"string"}}},"description":"List of matching users (max 10)"}}}}}}},"summary":"Search for users","tags":["Users"]}},"/api/users/mention_search":{"get":{"description":"\nLightweight search endpoint optimised for @mention typeahead in post/comment composers.\n\n### How it Works:\n- Matches usernames starting with the query (prefix match, case-insensitive)\n- Excludes blocked users (bidirectional)\n- Excludes private accounts the caller doesn't follow\n- Ranks the official assistant account first, then users the caller follows, then the rest\n- Returns at most 10 results with minimal fields\n\n### Authentication Optional (JWT Bearer Token)\nWhen authenticated, results are privacy-filtered and ranked by relationship.\nWithout auth, basic prefix search is returned.\n    ","operationId":"UserController_mentionSearch","parameters":[{"name":"q","required":true,"in":"query","description":"Username prefix to search for (min 1 char)","schema":{"example":"joh"}}],"responses":{"200":{"description":"Mention search results","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"array","items":{"type":"object","properties":{"username":{"type":"string","example":"john_doe"},"displayName":{"type":"string","example":"John Doe"},"avatarImageUrl":{"type":"string","example":"https://cdn.example.com/avatar.jpg"},"address":{"type":"string","example":"0x1234...abcd"},"isFollowing":{"type":"boolean","example":true,"description":"Whether the caller follows this user (false when unauthenticated)"},"isAssistant":{"type":"boolean","example":true,"description":"Present only on the official AI account, so clients can badge it"}}},"description":"Matching users (max 10), assistant first, then ranked by follow relationship"}}}}}}},"security":[{"user-auth":[]}],"summary":"Search users for @mention typeahead","tags":["Users"]}},"/api/is_following":{"get":{"description":"\nCheck if the authenticated user is following a specific target user.\n\n### Authentication Required\nYou must be logged in to check your following status.\n\n### Use Cases:\n- Display follow/unfollow button state on user profiles\n- Verify relationship before certain actions\n    ","operationId":"UserController_isFollowing","parameters":[{"name":"target","required":true,"in":"query","description":"Wallet address of the user to check","schema":{"example":"0x1234567890abcdef1234567890abcdef12345678"}}],"responses":{"200":{"description":"Following status retrieved","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"object","properties":{"isFollowing":{"type":"boolean","description":"Whether you follow this user","example":true}}}}}}}},"400":{"description":"Target address is required"},"401":{"description":"Unauthorized - invalid or missing token"}},"security":[{"user-auth":[]}],"summary":"Check if you follow a user","tags":["Users"]}},"/api/mutual_followers/{address}":{"get":{"description":"People the authenticated viewer follows who also follow this address. Returns `totalCount` and up to `limit` (default 5, max 20) users. Empty for anonymous requests, your own profile, or profiles that hide their followers.","operationId":"UserController_getMutualFollowers","parameters":[{"name":"limit","required":false,"in":"query","schema":{"type":"number"}},{"name":"address","required":true,"in":"path","description":"Wallet address of the profile being viewed","schema":{}}],"responses":{"200":{"description":""}},"summary":"Get mutual followers","tags":["Users"]}},"/api/follow_list/{address}":{"get":{"description":"\nGet a paginated list of followers or users that a specific address is following.\n\n### Privacy Control\nIf the user has `hideFollowers` enabled, this endpoint will return a 403 error unless:\n- The viewer is the account owner (authenticated and viewing their own list)\n\n### Type Parameter\n- `followers`: Get list of users who follow this address\n- `following`: Get list of users this address is following\n\n### Sorting Options\n- `createdAt`: Sort by when the follow relationship was created (default)\n- `username`: Sort alphabetically by username\n- `displayName`: Sort alphabetically by display name\n\n### Search\nSearch by username, display name, or wallet address (case-insensitive).\n\n### Pagination\nUses 1-based page numbering. Default 20 items per page, max 100.\n\n### Response\nEach item includes:\n- `followedAt`: When the follow relationship was created\n- `user`: User profile object with address, username, displayName, avatarImageUrl, followers count, etc.\n\nWhen the request carries a Bearer token every user object also carries the\nviewer's own relationship to that account: `isFollowing` (you follow them) and\n`followsYou` (they follow you). Both are omitted for anonymous requests and\nfor the viewer's own row.\n    ","operationId":"UserController_getFollowList","parameters":[{"name":"search","required":false,"in":"query","description":"Search by username, display name, or address","schema":{"example":"john"}},{"name":"sortOrder","required":false,"in":"query","description":"Sort order","schema":{"enum":["asc","desc"],"type":"string"}},{"name":"sortBy","required":false,"in":"query","description":"Field to sort by","schema":{"enum":["createdAt","username","displayName"],"type":"string"}},{"name":"limit","required":false,"in":"query","description":"Items per page (1-100, default 20)","schema":{"example":20,"type":"number"}},{"name":"page","required":false,"in":"query","description":"Page number (1-based)","schema":{"example":1,"type":"number"}},{"name":"viewerAddress","required":false,"in":"query","description":"**DEPRECATED — ignored.** Viewer identity is now extracted from the JWT Bearer token. Send `Authorization: Bearer <token>` to view your own hidden follow lists.","deprecated":true,"schema":{"example":"0x1234567890abcdef1234567890abcdef12345678"}},{"name":"type","required":true,"in":"query","description":"Type of list to retrieve","schema":{"enum":["followers","following"],"type":"string"}},{"name":"address","required":true,"in":"path","description":"Wallet address of the user whose followers/following to retrieve","schema":{"example":"0x1234567890abcdef1234567890abcdef12345678"}}],"responses":{"200":{"description":"Follow list retrieved successfully","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"result":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"followedAt":{"type":"string","format":"date-time","description":"When the follow relationship was created"},"user":{"type":"object","properties":{"address":{"type":"string","example":"0xabc..."},"username":{"type":"string","example":"johndoe"},"displayName":{"type":"string","example":"John Doe"},"avatarImageUrl":{"type":"string","example":"avatars/abc.jpg"},"followers":{"type":"number","example":150},"followings":{"type":"number","example":75},"sentTips":{"type":"number","example":1000},"receivedTips":{"type":"number","example":5000},"createdAt":{"type":"string","format":"date-time"},"isFollowing":{"type":"boolean","description":"Whether the authenticated viewer follows this user (omitted when anonymous)","example":true},"followsYou":{"type":"boolean","description":"Whether this user follows the authenticated viewer (omitted when anonymous)","example":false}}}}}},"pagination":{"type":"object","properties":{"page":{"type":"number","example":1},"limit":{"type":"number","example":20},"totalCount":{"type":"number","example":150},"totalPages":{"type":"number","example":8},"hasMore":{"type":"boolean","example":true}}}}}}}}}},"400":{"description":"Invalid address or missing type parameter"},"403":{"description":"User has hidden their followers/following list"}},"summary":"Get followers or following list","tags":["Users"]}},"/api/follow-requests":{"get":{"description":"Returns paginated list of users who have sent a follow request to the authenticated user. Only relevant for private accounts.","operationId":"UserController_getFollowRequests","parameters":[{"name":"limit","required":false,"in":"query","description":"Items per page (default: 20, max: 50)","schema":{"type":"number"}},{"name":"page","required":false,"in":"query","description":"Page number (default: 1)","schema":{"type":"number"}}],"responses":{"200":{"description":"Pending follow requests retrieved successfully","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"result":{"type":"array","items":{"type":"object","properties":{"_id":{"type":"string","description":"Follow request ID (use this for accept/reject)"},"address":{"type":"string","description":"Requester wallet address"},"followerUser":{"type":"object","description":"Populated user reference","properties":{"address":{"type":"string"},"username":{"type":"string"},"displayName":{"type":"string"},"avatarImageUrl":{"type":"string"}}},"createdAt":{"type":"string","format":"date-time"}}}},"pagination":{"type":"object","properties":{"page":{"type":"number"},"limit":{"type":"number"},"totalCount":{"type":"number"},"totalPages":{"type":"number"},"hasMore":{"type":"boolean"}}}}}}}}},"security":[{"bearer":[]}],"summary":"Get pending follow requests","tags":["Users"]}},"/api/follow-requests/accept-all":{"post":{"description":"Accepts every pending follow request at once. All requesters become followers and receive notifications.","operationId":"UserController_acceptAllFollowRequests","parameters":[],"responses":{"200":{"description":"All follow requests accepted","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"message":{"type":"string","example":"Accepted 5 follow requests"},"accepted":{"type":"number","example":5}}}}}}},"security":[{"bearer":[]}],"summary":"Accept all pending follow requests","tags":["Users"]}},"/api/follow-requests/reject-all":{"post":{"description":"Silently rejects (deletes) every pending follow request at once. No notifications are sent.","operationId":"UserController_rejectAllFollowRequests","parameters":[],"responses":{"200":{"description":"All follow requests rejected","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"message":{"type":"string","example":"Rejected 3 follow requests"},"rejected":{"type":"number","example":3}}}}}}},"security":[{"bearer":[]}],"summary":"Reject all pending follow requests","tags":["Users"]}},"/api/follow-requests/{id}/accept":{"post":{"description":"Accepts a pending follow request by its ID (from notification metadata.followId or GET /follow-requests response _id). The requester becomes a follower and receives a notification.","operationId":"UserController_acceptFollowRequest","parameters":[{"name":"id","required":true,"in":"path","description":"The follow request ID (from notification metadata.followId or GET /follow-requests response _id field)","schema":{"type":"string"}}],"responses":{"200":{"description":"Follow request accepted","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"message":{"type":"string","example":"Follow request accepted"}}}}}},"404":{"description":"Follow request not found or already processed"}},"security":[{"bearer":[]}],"summary":"Accept a follow request","tags":["Users"]}},"/api/follow-requests/{id}/reject":{"post":{"description":"Silently rejects a pending follow request by its ID (from notification metadata.followId or GET /follow-requests response _id). The request is deleted and no notification is sent to the requester.","operationId":"UserController_rejectFollowRequest","parameters":[{"name":"id","required":true,"in":"path","description":"The follow request ID (from notification metadata.followId or GET /follow-requests response _id field)","schema":{"type":"string"}}],"responses":{"200":{"description":"Follow request rejected","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"message":{"type":"string","example":"Follow request rejected"}}}}}},"404":{"description":"Follow request not found or already processed"}},"security":[{"bearer":[]}],"summary":"Reject a follow request","tags":["Users"]}},"/api/followers/remove":{"post":{"description":"Removes a user from your followers list. Works like Instagram's \"Remove Follower\" feature. The removed user is not notified. Works for both accepted followers and pending requests.","operationId":"UserController_removeFollower","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["address"],"properties":{"address":{"type":"string","description":"Wallet address of the follower to remove"}}}}}},"responses":{"200":{"description":"Follower removed successfully","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"message":{"type":"string","example":"Follower removed"}}}}}},"400":{"description":"Invalid address or trying to remove yourself"},"404":{"description":"User is not following you"}},"security":[{"bearer":[]}],"summary":"Remove a follower","tags":["Users"]}},"/api/users/{address}/comments":{"get":{"description":"\nReturns all comments and replies posted by a user — equivalent to the \"Replies\" tab on Twitter/X profiles.\n\n### Access Rules\n- **Blocked (either direction)**: returns `403`\n- **Private account, viewer not following**: returns `403`\n- **Own profile**: always accessible (requires JWT)\n- **Public profile**: accessible to anyone\n\n### Each item includes\n- Full comment content + media\n- `isReply`: `true` when this is a reply to another comment\n- `parentComment`: snippet of the comment being replied to (only when `isReply=true`)\n- `post`: lightweight post context — tokenId, title, thumbnail, postType\n- `isLiked`: whether the authenticated viewer liked this comment\n- `likeCount`: total like count\n\n### Type filter\nUse `type=comment` for top-level comments only, `type=reply` for replies only, or omit (default `all`).\n    ","operationId":"UserController_getUserComments","parameters":[{"name":"type","required":false,"in":"query","description":"Filter by comment type","schema":{"enum":["all","comment","reply"],"type":"string"}},{"name":"limit","required":false,"in":"query","description":"Items per page (1–50)","schema":{"example":20,"type":"number"}},{"name":"page","required":false,"in":"query","description":"Page number (1-indexed)","schema":{"example":1,"type":"number"}},{"name":"address","required":true,"in":"path","description":"Wallet address or username of the target user","schema":{"example":"0xabc..."}}],"responses":{"200":{"description":"Comments retrieved successfully","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"result":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"number","example":42},"tokenId":{"type":"number","example":123},"content":{"type":"string","example":"Great video!"},"imageUrl":{"type":"string","nullable":true},"address":{"type":"string","example":"0xabc..."},"parentId":{"type":"number","nullable":true,"example":null},"isReply":{"type":"boolean","example":false},"likeCount":{"type":"number","example":5},"isLiked":{"type":"boolean","example":false},"author":{"type":"object","properties":{"address":{"type":"string"},"username":{"type":"string"},"displayName":{"type":"string"},"avatarImageUrl":{"type":"string"}}},"parentComment":{"type":"object","nullable":true,"description":"The comment being replied to (only when isReply=true)","properties":{"id":{"type":"number"},"content":{"type":"string"},"address":{"type":"string"},"author":{"type":"object"}}},"post":{"type":"object","description":"Lightweight post context","properties":{"tokenId":{"type":"number"},"name":{"type":"string"},"imageUrl":{"type":"string"},"postType":{"type":"string"},"minter":{"type":"string"}}},"createdAt":{"type":"string","format":"date-time"}}}},"pagination":{"type":"object","properties":{"page":{"type":"number","example":1},"limit":{"type":"number","example":20},"totalCount":{"type":"number","example":84},"totalPages":{"type":"number","example":5},"hasMore":{"type":"boolean","example":true}}}}}}}}}},"403":{"description":"Account is private or blocked"},"404":{"description":"User not found"}},"summary":"Get a user's comments and replies","tags":["Users"]}},"/api/suggested-accounts":{"get":{"description":"\nReturns up to **10** personalised account suggestions in **random order**.\n\n### Authentication Required\nSend your JWT Bearer token in the `Authorization` header.\n\n### How It Works (tiered priority)\nSlots are filled in order of priority — once 10 are collected, the list is shuffled.\n\n| Priority | Signal | Extra data |\n|----------|--------|------------|\n| 1 | **Followers you don't follow back** — people who already follow you | `followsYou: true` |\n| 2 | **Friends-of-friends** — accounts followed by someone you follow | `mutualConnections`: up to 2 populated profiles of the people who connect you |\n| 3 | **Engagement overlap** — users who liked or commented on your content, or on the same content you interacted with | — |\n| 4 | **Fallback** — earliest-signup accounts that have posted at least once | — |\n\n### Exclusions\n- Your own account\n- Accounts you already follow (accepted or pending)\n- Accounts blocked in either direction\n- Banned accounts\n\n### Response\nEach item includes profile data plus a `reason` field explaining why the account was suggested:\n- `follows_you` — this person follows you\n- `followed_by_people_you_know` — mutual connections (includes `mutualConnections` array)\n- `engagement_overlap` — interacted with similar content\n- `suggested` — general suggestion (fallback)\n    ","operationId":"UserController_getSuggestedAccounts","parameters":[{"name":"limit","required":false,"in":"query","description":"Page size with `sort`, 1-30, default 10","schema":{}},{"name":"cursor","required":false,"in":"query","description":"`nextCursor` from the previous page (with `sort` only)","schema":{}},{"name":"sort","required":false,"in":"query","description":"Endless ranked list instead of the 10 random picks: `active` = profile set up, recently active, most complete first; `newest` = profile set up, newest sign-up first. Empty profiles come last.","schema":{"enum":["active","newest"],"type":"string"}}],"responses":{"200":{"description":"Suggested accounts retrieved successfully","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"result":{"type":"object","properties":{"items":{"type":"array","maxItems":10,"items":{"type":"object","properties":{"address":{"type":"string","example":"0x1234..."},"username":{"type":"string","nullable":true,"example":"janedoe"},"displayName":{"type":"string","nullable":true,"example":"Jane Doe"},"avatarImageUrl":{"type":"string","nullable":true},"coverImageUrl":{"type":"string","nullable":true},"aboutMe":{"type":"string","nullable":true},"followers":{"type":"number","example":250},"followings":{"type":"number","example":80},"uploads":{"type":"number","example":12},"badgeBalance":{"type":"number","example":50000},"isPrivate":{"type":"boolean","example":false},"createdAt":{"type":"string","format":"date-time"},"followsYou":{"type":"boolean","description":"Present and true when this user follows you (tier 1)","example":true},"mutualConnections":{"type":"array","maxItems":2,"description":"Up to 2 populated profiles of people you follow who also follow this account (tier 2 only)","items":{"type":"object","properties":{"address":{"type":"string"},"username":{"type":"string","nullable":true},"displayName":{"type":"string","nullable":true},"avatarImageUrl":{"type":"string","nullable":true}}}},"reason":{"type":"string","enum":["follows_you","followed_by_people_you_know","engagement_overlap","suggested"],"description":"Why this account was suggested","example":"follows_you"}}}}}}}}}}},"401":{"description":"Unauthorized — invalid or missing token"},"404":{"description":"Viewer account not found"}},"security":[{"user-auth":[]}],"summary":"Get suggested accounts to follow","tags":["Users"]}},"/api/my-analytics":{"get":{"operationId":"UserController_getMyAnalytics","parameters":[{"name":"range","required":false,"in":"query","description":"Time range (default: 30d). `all` drops the lower bound.","schema":{"enum":["7d","30d","90d","1y","all"],"type":"string"}}],"responses":{"200":{"description":"Analytics data retrieved successfully","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"boolean","example":true},"range":{"type":"string","example":"30d"},"totals":{"type":"object","properties":{"likes":{"type":"number","example":1250},"dislikes":{"type":"number","example":45},"followers":{"type":"number","example":320},"following":{"type":"number","example":80},"uploads":{"type":"number","example":15},"receivedTips":{"type":"number","example":500},"sentTips":{"type":"number","example":100},"subscribers":{"type":"number","description":"Currently active subscriptions to my plans","example":64}}},"likesOverTime":{"type":"array","items":{"type":"object","properties":{"date":{"type":"string","example":"2026-04-01"},"count":{"type":"number","example":12}}}},"followersOverTime":{"type":"array","items":{"type":"object","properties":{"date":{"type":"string","example":"2026-04-01"},"count":{"type":"number","example":5}}}},"subscribersOverTime":{"type":"array","description":"New subscriptions to my plans per day, ascending by date","items":{"type":"object","properties":{"date":{"type":"string","example":"2026-04-01"},"count":{"type":"number","example":2}}}}}}}}},"401":{"description":"Unauthorized — invalid or missing token"}},"security":[{"user-auth":[]}],"summary":"Creator analytics — likes and follower growth over time","tags":["Users"]}},"/api/resolve-username/{username}":{"get":{"operationId":"UserController_resolveUsername","parameters":[{"name":"username","required":true,"in":"path","description":"DeHub username to resolve","schema":{"example":"johndoe","type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"boolean"},"username":{"type":"string"},"displayName":{"type":"string","nullable":true},"avatarUrl":{"type":"string","nullable":true},"bio":{"type":"string","nullable":true},"address":{"type":"string"}}}}}},"404":{"description":"Username not found"}},"summary":"Resolve username to profile metadata for deep links (username.dehub.io)","tags":["Users"]}},"/api/account/email-link/request":{"post":{"operationId":"EmailLinkController_request","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email"],"properties":{"email":{"type":"string","example":"you@example.com"}}}}}},"responses":{"200":{"description":""}},"security":[{"bearer":[]}],"summary":"Mail a verification code to attach an email to the signed-in account","tags":["Account"]}},"/api/account/email-link/confirm":{"post":{"operationId":"EmailLinkController_confirm","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email","code"],"properties":{"email":{"type":"string","example":"you@example.com"},"code":{"type":"string","example":"123456"}}}}}},"responses":{"200":{"description":""}},"security":[{"bearer":[]}],"summary":"Verify the code and link the email as a login route for this account","tags":["Account"]}},"/api/account/email-link":{"delete":{"operationId":"EmailLinkController_unlink","parameters":[],"responses":{"200":{"description":""}},"security":[{"bearer":[]}],"summary":"Detach the email login from the signed-in account","tags":["Account"]}},"/api/account/email-link/status":{"get":{"operationId":"EmailLinkController_status","parameters":[],"responses":{"200":{"description":""}},"security":[{"bearer":[]}],"summary":"Whether the signed-in account already has an email login attached","tags":["Account"]}},"/api/account/email-link/sync":{"post":{"operationId":"EmailLinkController_sync","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"supabaseAccessToken":{"type":"string","description":"The caller’s current Supabase session. Must belong to the identity this account is already linked to. Omit it and this behaves exactly like GET status."}}}}}},"responses":{"200":{"description":""}},"security":[{"bearer":[]}],"summary":"Record the address this account’s own Supabase sign-in proved, then answer as status does","tags":["Account"]}},"/api/notification/community-chat":{"post":{"description":"Members get one grouped notification per community (\"James + 2 others sent messages in X\"), pinged at most once an hour while unread. Body: communityId, messageId, walletSession.","operationId":"NotificationsController_reportCommunityChat","parameters":[],"responses":{"201":{"description":""}},"security":[{"user-auth":[]}],"summary":"Report a community chat line you just posted","tags":["Notifications"]}},"/api/notification/community-chat/seen":{"post":{"description":"Clears that community's chat notification and holds new ones for 90 seconds. Clients send it on open and every minute while the chat is on screen. Body: communityId.","operationId":"NotificationsController_seenCommunityChat","parameters":[],"responses":{"201":{"description":""}},"security":[{"user-auth":[]}],"summary":"You have a community chat open","tags":["Notifications"]}},"/api/notification":{"get":{"description":"\nRetrieves notifications for the authenticated user with pagination and filtering support.\n\n## Content Types (postType)\n\nThe `postType` field indicates what type of content the notification relates to:\n\n| postType | Description | Example |\n|----------|-------------|---------|\n| `video` | A video post | \"CryptoKing liked your **video** \\\"My NFT Drop\\\"\" |\n| `feed-images` | An image post | \"CryptoKing liked your **post**\" |\n| `feed-simple` | A text-only post | \"CryptoKing commented on your **post**\" |\n\nUse this to display appropriate icons/UI and navigate to the correct content type.\n\n## Notification Types\n\n| Type | Category | Description | Example Content |\n|------|----------|-------------|-----------------|\n| `like` | engagement | Someone liked your content | \"CryptoKing liked your video \\\"My NFT Drop\\\"\" |\n| `comment` | engagement | Someone commented on your content | \"CryptoKing commented on your post\" |\n| `comment_reply` | engagement | Someone replied to your comment | \"CryptoKing replied to your comment: \\\"Thanks!\\\"\" |\n| `creator_comment_reply` | engagement | The post's creator replied to your comment (sent instead of `comment_reply`; carries `parentCommentPreview`) | \"CryptoKing replied to your comment on their video: \\\"Thanks!\\\"\" |\n| `comment_like` | engagement | Someone liked your comment (aggregated) | \"CryptoKing and 20 others liked your comment \\\"Cool video!\\\"\" |\n| `repost` | engagement | Someone reposted your content (aggregated) | \"CryptoKing reposted your video \\\"My NFT Drop\\\"\" |\n| `quote` | engagement | Someone quoted your content | \"CryptoKing quoted your video \\\"My NFT Drop\\\"\" |\n| `following` | social | Someone started following you | \"CryptoKing started following you\" |\n| `follow_request` | social | Someone requested to follow you (private account) | \"CryptoKing requested to follow you\" |\n| `follow_request_accepted` | social | Your follow request was accepted | \"CryptoKing accepted your follow request\" |\n| `tip` | monetization | Someone tipped you | \"CryptoKing tipped you 50 DHB on your video\" |\n| `subscription` | monetization | Someone subscribed to your plan | \"CryptoKing subscribed to your Premium plan\" |\n| `ppv_purchase` | monetization | PPV content purchase | \"CryptoKing purchased access to your video \\\"Exclusive\\\"\" |\n| `bounty_available` | monetization | Bounty available to claim | \"💰 You have a viewer bounty available to claim on \\\"My Video\\\"\" |\n| `bounty_claimed` | monetization | Someone claimed bounty on your video | \"CryptoKing claimed a viewer bounty of 100 DHB on your video\" |\n| `video_milestone` | content | Your content hit a view milestone | \"🎉 Your video \\\"My NFT Drop\\\" reached 10,000 views!\" |\n| `livestream_start` | content | Someone you follow went live | \"CryptoKing started streaming: \\\"Live Q&A Session\\\"\" |\n| `signal_flare` | content | Someone you follow spent a Signal Flare on a post | \"CryptoKing sent up a Signal Flare\" |\n| `badge_delegated` | social | Someone lent you their badge tier | \"CryptoKing lent you their Crab badge\" |\n| `badge_delegation_ended` | social | A badge loan ended, either side | \"CryptoKing took back the Crab badge they lent you\" |\n| `badge_delegation_changed` | social | A loan you are wearing moved tier | \"The badge CryptoKing lent you is now Ghost Lobster\" |\n| `video_removal` | system | Admin removed your content | \"Your video was removed due to policy violation.\" |\n| `account_warning` | system | Account warning/moderation | \"Your account has been warned for violating guidelines\" |\n| `system` | system | General system announcement | \"We have updated our terms of service\" |\n\n**Note:** Dislikes do NOT generate notifications. `mention` is reserved for a future release. `new_message` (DMs) does NOT create in-app notifications — DMs use push notifications only.\n\n## Aggregation\n\nNotifications of the same type for the same content are aggregated. For example:\n- \"CryptoKing, NFTQueen and 4 others liked your video\"\n- \"CryptoKing and 20 others liked your comment\"\n- \"CryptoKing and 2 others reposted your video\"\n- Check `aggregatedCount` for total count\n- Check `latestActorNames` for up to 3 most recent usernames\n\nAggregated notification types: `like`, `comment`, `following`, `comment_like`, `repost`\n\n## Frontend Data\n\nEach notification includes data for frontend navigation and display:\n- `tokenId`: Video/post ID to navigate to\n- `commentId`: Comment ID for comment-related notifications\n- `metadata.deepLink`: Suggested deep link path (e.g., \"/video/123?comment=456\")\n- `postType`: Content type for appropriate UI display\n\n## Pagination\n\n- Default: 30 items per page, max 100\n- Use `page` and `limit` query params\n- Returns newest notifications first (sorted by updatedAt descending)\n    ","operationId":"NotificationsController_getNotifications","parameters":[{"name":"unreadOnly","required":false,"in":"query","description":"If true (default), only returns unread notifications. Set to \"false\" to get all notifications.","schema":{"example":true,"type":"boolean"}},{"name":"category","required":false,"in":"query","description":"Filter by notification category:\n- **engagement**: likes, comments, replies, comment likes, reposts, quotes\n- **social**: follows, follow requests, follow request acceptances\n- **monetization**: tips, subscriptions, PPV purchases, bounties\n- **content**: video milestones, livestream starts\n- **messages**: direct messages\n- **system**: video removals, account warnings, announcements","schema":{"enum":["engagement","social","monetization","content","messages","system"],"type":"string"}},{"name":"types","required":false,"in":"query","description":"Filter by exact notification type. Comma-separated, e.g. `like,comment_like`.\n\nUse this for per-type tabs — `category` cannot express them, because likes and\ncomments both live in `engagement`. Combines with `category` (both are applied).\nUnrecognised type names are ignored; if none of the supplied names are valid the\nfilter is dropped rather than returning an empty page.","schema":{"example":"like,comment_like","type":"string"}},{"name":"filtered","required":false,"in":"query","description":"If true, returns only the notifications held back by your notification filters (see PATCH profile notificationPreferences.policy).","schema":{"type":"boolean"}},{"name":"page","required":false,"in":"query","description":"Page number for pagination (1-indexed). Default: 1","schema":{"example":1,"type":"number"}},{"name":"limit","required":false,"in":"query","description":"Number of notifications per page. Default: 30, Max: 100","schema":{"example":30,"type":"number"}}],"responses":{"200":{"description":"Successfully retrieved notifications","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"array","items":{"type":"object","properties":{"_id":{"type":"string","example":"507f1f77bcf86cd799439011","description":"Unique notification ID (MongoDB ObjectId)"},"address":{"type":"string","example":"0x1234567890abcdef1234567890abcdef12345678","description":"Recipient wallet address (normalized to lowercase)"},"type":{"type":"string","enum":["like","comment","comment_reply","creator_comment_reply","comment_like","following","mention","tip","subscription","ppv_purchase","bounty_available","bounty_claimed","video_milestone","livestream_start","signal_flare","badge_delegated","badge_delegation_ended","badge_delegation_changed","video_removal","account_warning","system"],"example":"like","description":"Notification type - determines the icon and behavior in UI"},"category":{"type":"string","enum":["engagement","social","monetization","content","system"],"example":"engagement","description":"Category for filtering and grouping notifications"},"content":{"type":"string","example":"CryptoKing liked your video \"My First NFT Drop\"","description":"Human-readable notification message. Content titles use the post name, or a truncated description, or a generic type label (video/post/content) — never raw token IDs."},"read":{"type":"boolean","example":false,"description":"Whether the notification has been read by the user"},"createdAt":{"type":"string","format":"date-time","example":"2026-01-21T15:30:00.000Z","description":"When the notification was created (ISO 8601)"},"updatedAt":{"type":"string","format":"date-time","example":"2026-01-21T15:30:00.000Z","description":"When the notification was last updated (ISO 8601)"},"actorAddress":{"type":"string","example":"0xabcdef1234567890abcdef1234567890abcdef12","description":"Wallet address of the user who triggered the notification","nullable":true},"actorUsername":{"type":"string","example":"CryptoKing","description":"Username of the actor (for display without extra API call)","nullable":true},"actorAvatar":{"type":"string","example":"https://cdn.dehub.com/avatars/abc123.jpg","description":"Avatar URL of the actor","nullable":true},"actor":{"type":"object","description":"Full actor user reference with profile info","nullable":true,"properties":{"address":{"type":"string","example":"0xabcdef1234..."},"username":{"type":"string","example":"CryptoKing"},"displayName":{"type":"string","example":"Crypto King"},"avatarImageUrl":{"type":"string","example":"avatars/abc123.jpg"},"followers":{"type":"number","example":1500},"followings":{"type":"number","example":250},"sentTips":{"type":"number","example":5000},"receivedTips":{"type":"number","example":10000},"createdAt":{"type":"string","format":"date-time"}}},"recipient":{"type":"object","description":"Full recipient user reference with profile info","nullable":true,"properties":{"address":{"type":"string","example":"0x1234567890..."},"username":{"type":"string","example":"VideoCreator"},"displayName":{"type":"string","example":"Video Creator"},"avatarImageUrl":{"type":"string","example":"avatars/def456.jpg"},"followers":{"type":"number","example":5000},"followings":{"type":"number","example":100},"sentTips":{"type":"number","example":1000},"receivedTips":{"type":"number","example":50000},"createdAt":{"type":"string","format":"date-time"}}},"tokenId":{"type":"number","example":12345,"description":"Token/Video ID that the notification relates to","nullable":true},"tokenTitle":{"type":"string","example":"My First NFT Drop","description":"Title of the video/token (for display without extra API call)","nullable":true},"tokenThumbnail":{"type":"string","example":"https://cdn.dehub.com/thumbnails/12345.jpg","description":"Thumbnail URL of the video/token","nullable":true},"postType":{"type":"string","enum":["video","short","feed-images","feed-simple","feed-audio"],"example":"video","description":"Type of content the notification relates to:\n- **video**: A video post\n- **feed-images**: An image post (can have multiple images)\n- **feed-audio**: An audio post\n- **feed-simple**: A text-only post","nullable":true},"commentId":{"type":"number","example":9876,"description":"Comment ID (for comment_reply notifications)","nullable":true},"commentPreview":{"type":"string","example":"Great video! I really enjoyed...","description":"Preview of the comment text (max 150 chars)","nullable":true},"parentCommentId":{"type":"number","example":9870,"description":"The comment that was replied to (comment_reply, creator_comment_reply)","nullable":true},"parentCommentPreview":{"type":"string","example":"How did you light this shot?","description":"creator_comment_reply only: the recipient's own comment the creator answered (max 150 chars). \"🎤 Voice note\", \"GIF\" or \"📷 Photo\" when it had no text.","nullable":true},"aggregatedCount":{"type":"number","example":6,"description":"Total count of similar actions (e.g., 6 people liked). Default is 1.","nullable":true},"latestActorNames":{"type":"array","items":{"type":"string"},"example":["CryptoKing","NFTQueen","Web3Dev"],"description":"Up to 3 most recent usernames for aggregated display","nullable":true},"amount":{"type":"number","example":50.5,"description":"Amount (for tips, purchases). In token units.","nullable":true},"currency":{"type":"string","example":"DHB","description":"Currency/token symbol (DHB, USDT, etc.)","nullable":true},"planName":{"type":"string","example":"Premium Monthly","description":"Subscription plan name (for subscription notifications)","nullable":true},"metadata":{"type":"object","description":"Extra metadata depending on notification type:\n- **bounty_available/bounty_claimed**: { bountyType: 'viewer' | 'commentor', deepLink: string }\n- **comment_like**: { deepLink: string, aggregatedCount: number }\n- **video_milestone**: { milestone: string }\n- All types may include: { deepLink: string } for in-app navigation","example":{"bountyType":"viewer","deepLink":"/video/12345?claimBounty=true"},"nullable":true}},"required":["_id","address","type","content","read","createdAt","updatedAt"]},"description":"Array of notification objects"}},"example":{"result":[{"_id":"507f1f77bcf86cd799439011","address":"0x1234567890abcdef1234567890abcdef12345678","type":"like","category":"engagement","content":"CryptoKing and 5 others liked your video \"My First NFT Drop\"","read":false,"createdAt":"2026-01-21T15:30:00.000Z","updatedAt":"2026-01-21T16:45:00.000Z","actorAddress":"0xabcdef1234567890abcdef1234567890abcdef12","actorUsername":"CryptoKing","actorAvatar":"https://cdn.dehub.com/avatars/abc123.jpg","tokenId":12345,"tokenTitle":"My First NFT Drop","tokenThumbnail":"https://cdn.dehub.com/thumbnails/12345.jpg","postType":"video","aggregatedCount":6,"latestActorNames":["CryptoKing","NFTQueen","Web3Dev"]},{"_id":"507f1f77bcf86cd799439014","address":"0x1234567890abcdef1234567890abcdef12345678","type":"comment","category":"engagement","content":"CryptoKing commented on your post","read":false,"createdAt":"2026-01-21T15:00:00.000Z","updatedAt":"2026-01-21T15:00:00.000Z","actorAddress":"0xabcdef1234567890abcdef1234567890abcdef12","actorUsername":"CryptoKing","actorAvatar":"https://cdn.dehub.com/avatars/abc123.jpg","tokenId":12346,"tokenTitle":"My Photo Gallery","tokenThumbnail":"https://cdn.dehub.com/thumbnails/12346.jpg","postType":"feed-images","aggregatedCount":1},{"_id":"507f1f77bcf86cd799439012","address":"0x1234567890abcdef1234567890abcdef12345678","type":"tip","category":"monetization","content":"CryptoKing tipped you 50 DHB on your video","read":false,"createdAt":"2026-01-21T14:00:00.000Z","updatedAt":"2026-01-21T14:00:00.000Z","actorAddress":"0xabcdef1234567890abcdef1234567890abcdef12","actorUsername":"CryptoKing","tokenId":12345,"tokenTitle":"My First NFT Drop","postType":"video","amount":50,"currency":"DHB"},{"_id":"507f1f77bcf86cd799439013","address":"0x1234567890abcdef1234567890abcdef12345678","type":"video_milestone","category":"content","content":"🎉 Your video \"My First NFT Drop\" reached 10,000 views!","read":false,"createdAt":"2026-01-21T10:00:00.000Z","updatedAt":"2026-01-21T10:00:00.000Z","tokenId":12345,"tokenTitle":"My First NFT Drop","postType":"video"}]}}}}},"401":{"description":"Unauthorized - Missing or invalid JWT token","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string","example":"Unauthorized"}}}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","example":"Failed to fetch notifications"}}}}}}},"security":[{"user-auth":[]}],"summary":"Get user notifications","tags":["Notifications"]}},"/api/notification/unread-count":{"get":{"description":"\nReturns the total count of unread notifications and a breakdown by category.\n\n## Use Cases\n\n1. **Badge Display**: Show a badge with the total unread count on the notification bell icon\n2. **Category Tabs**: Show unread counts on category filter tabs (e.g., \"Engagement (5)\")\n3. **Polling**: Call this endpoint periodically (e.g., every 30s) to update notification badges\n\n## Categories\n\n| Category | Includes |\n|----------|----------|\n| `engagement` | likes, comments, replies, comment likes, reposts, quotes |\n| `social` | follows, follow requests, follow request acceptances |\n| `monetization` | tips, subscriptions, PPV purchases, bounties |\n| `content` | video milestones, livestream starts |\n| `messages` | direct messages |\n| `system` | video removals, account warnings, announcements |\n\n## Performance\n\nThis endpoint is optimized for frequent calls and uses indexed queries.\n    ","operationId":"NotificationsController_getUnreadCount","parameters":[],"responses":{"200":{"description":"Successfully retrieved unread counts","content":{"application/json":{"schema":{"type":"object","properties":{"total":{"type":"number","example":15,"description":"Total number of unread notifications across all categories"},"byCategory":{"type":"object","description":"Breakdown of unread count by category","properties":{"engagement":{"type":"number","example":8,"description":"Likes, comments, replies"},"social":{"type":"number","example":3,"description":"Follows, mentions"},"monetization":{"type":"number","example":2,"description":"Tips, subscriptions"},"content":{"type":"number","example":1,"description":"Milestones, livestream starts"},"system":{"type":"number","example":1,"description":"Removals, warnings"}}}},"example":{"total":15,"byCategory":{"engagement":8,"social":3,"monetization":2,"content":1,"system":1}}}}}},"401":{"description":"Unauthorized - Missing or invalid JWT token","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string","example":"Unauthorized"}}}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","example":"Failed to fetch unread count"}}}}}}},"security":[{"user-auth":[]}],"summary":"Get unread notification count","tags":["Notifications"]}},"/api/notification/{notificationId}":{"patch":{"description":"\nMarks a specific notification as read by its ID.\n\n## Use Cases\n\n1. **Single Notification Tap**: When user taps/clicks on a specific notification\n2. **Notification Detail View**: When user views the detail of a notification\n3. **Swipe Actions**: When user swipes to mark as read\n\n## Behavior\n\n- Sets `read: true` on the notification\n- Sets `readAt` timestamp\n- Idempotent: calling multiple times on already-read notification is safe\n\n## Navigation\n\nAfter marking as read, the frontend should typically navigate to:\n| Type | Navigate To |\n|------|-------------|\n| `like`, `dislike`, `comment` | Video page (use `tokenId`) |\n| `comment_reply`, `creator_comment_reply`, `comment_like` | Video page scrolled to comment |\n| `repost` | Original video page (use `tokenId`) |\n| `quote` | Quote post (use `metadata.quoteTokenId`) |\n| `following`, `follow_request`, `follow_request_accepted` | Actor's profile (use `actorAddress`) |\n| `tip` | Video page or earnings dashboard |\n| `subscription` | Subscriber's profile or dashboard |\n| `ppv_purchase`, `bounty_claimed` | Video page (use `tokenId`) |\n| `bounty_available` | Video page with claim prompt |\n| `video_milestone` | Video analytics page |\n| `livestream_start` | Livestream page |\n| `signal_flare` | Post page |\n| `badge_delegated`, `badge_delegation_ended`, `badge_delegation_changed` | Settings, badge delegation panel |\n| `video_removal`, `account_warning`, `system` | Notifications screen |\n    ","operationId":"NotificationsController_markNotificationAsRead","parameters":[{"name":"notificationId","required":true,"in":"path","description":"MongoDB ObjectId of the notification to mark as read","schema":{"example":"507f1f77bcf86cd799439011","type":"string"}}],"responses":{"200":{"description":"Notification successfully marked as read","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string","example":"Notification marked as read"}}}}}},"401":{"description":"Unauthorized - Missing or invalid JWT token","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string","example":"Unauthorized"}}}}}},"403":{"description":"Forbidden - User account is suspended or banned","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string","example":"Account suspended"}}}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","example":"Failed to mark notification as read"}}}}}}},"security":[{"user-auth":[]}],"summary":"Mark a single notification as read","tags":["Notifications"]}},"/api/notification/mark-all-read":{"post":{"description":"\nMarks all unread notifications as read for the authenticated user.\n\n## Use Cases\n\n1. **\"Mark All as Read\" Button**: Common UX pattern in notification centers\n2. **Clear All Action**: When user wants to clear notification badges\n3. **Category-Specific Clear**: Pass `category` param to only clear specific types\n\n## Filtering by Category\n\nYou can optionally pass a `category` query parameter to only mark notifications of that category as read:\n\n| Category | Clears |\n|----------|--------|\n| `engagement` | likes, comments, replies, comment likes, reposts, quotes |\n| `social` | follows, follow requests, follow request acceptances |\n| `monetization` | tips, subscriptions, PPV purchases, bounties |\n| `content` | video milestones, livestream starts |\n| `messages` | direct messages |\n| `system` | video removals, account warnings |\n\nIf no category is provided, ALL unread notifications are marked as read.\n\n## Response\n\nReturns the count of notifications that were marked as read (useful for analytics or confirmation UI).\n    ","operationId":"NotificationsController_markAllAsRead","parameters":[{"name":"category","required":false,"in":"query","description":"Optional: Only mark notifications in this category as read. If omitted, marks ALL as read.","schema":{"enum":["engagement","social","monetization","content","system"],"type":"string"}},{"name":"types","required":false,"in":"query","description":"Optional: comma-separated exact types to mark as read, e.g. `like,comment_like`. Lets a per-type tab clear only its own rows. Combines with `category`.","schema":{"example":"like,comment_like","type":"string"}}],"responses":{"200":{"description":"Notifications successfully marked as read","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string","example":"All notifications marked as read"},"count":{"type":"number","description":"Number of notifications that were marked as read","example":12}},"example":{"message":"All notifications marked as read","count":12}}}}},"401":{"description":"Unauthorized - Missing or invalid JWT token","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string","example":"Unauthorized"}}}}}},"403":{"description":"Forbidden - User account is suspended or banned","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string","example":"Account suspended"}}}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","example":"Failed to mark all notifications as read"}}}}}}},"security":[{"user-auth":[]}],"summary":"Mark all notifications as read","tags":["Notifications"]}},"/api/notification/sms/status":{"get":{"description":"\nEverything a client needs to render the text-notification row: whether the\nchannel is available at all, whether this account has unlocked it, what is left\non the balance, which number texts go to (masked), where to send DHB to top up,\nand what a message costs.\n\n`available: false` means the platform has no SMS gateway or no treasury\nconfigured — the feature is off for everybody, not just this account.\n    ","operationId":"SmsNotificationController_status","parameters":[],"responses":{"200":{"description":"Status returned"},"401":{"description":"Unauthorized"}},"security":[{"user-auth":[]}],"summary":"Text notification credit, number and prices","tags":["Text notifications"]}},"/api/notification/sms/quote":{"get":{"description":"Prices a destination before anyone commits to it. `blocked: true` means we cannot deliver there at any price.","operationId":"SmsNotificationController_quote","parameters":[{"name":"phone","required":true,"in":"query","description":"E.164, e.g. +447700900123","schema":{"type":"string"}}],"responses":{"200":{"description":"Quote returned"}},"security":[{"user-auth":[]}],"summary":"What a text to this number would cost","tags":["Text notifications"]}},"/api/notification/sms/deposit":{"post":{"description":"\nHand over the hash of a DHB transfer to the address `sms/status` names. The\namount is read off the chain — nothing the client says about it is trusted — and\ncredited to the balance.\n\nSafe to call as many times as you like with the same hash: the transfer is\ncredited exactly once. `pending: true` means the chain has not caught up yet;\nthe hash is still good, so ask again in a moment rather than paying twice.\n\nThe **first** deposit has to clear the unlock minimum `sms/status` reports.\nTop-ups afterwards can be any size.\n    ","operationId":"SmsNotificationController_deposit","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["txHash","chainId"],"properties":{"txHash":{"type":"string","example":"0xabc…"},"chainId":{"type":"number","example":8453,"description":"Base or BNB Chain."}}}}}},"responses":{"200":{"description":"Credited, or pending confirmation"},"400":{"description":"Bad hash, wrong chain, or under the unlock minimum"}},"security":[{"user-auth":[]}],"summary":"Credit a DHB transfer against my text balance","tags":["Text notifications"]}},"/api/notification/sms/phone/request":{"post":{"description":"\nSends a six-digit code, valid for ten minutes.\n\n**This costs one message**, charged at the same price as a notification to that\ndestination. That is the point of doing it this way: it proves the number is\nreachable on the exact route the notifications will take, before anything is\npromised. Requires the channel to be unlocked and the balance to cover it.\n    ","operationId":"SmsNotificationController_requestPhone","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["phone"],"properties":{"phone":{"type":"string","example":"+447700900123"}}}}}},"responses":{"200":{"description":"Code sent"},"402":{"description":"Not unlocked, or not enough credit"},"429":{"description":"A code was already sent in the last minute"}},"security":[{"user-auth":[]}],"summary":"Text a verification code to my number","tags":["Text notifications"]}},"/api/notification/sms/phone/verify":{"post":{"operationId":"SmsNotificationController_verifyPhone","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["code"],"properties":{"code":{"type":"string","example":"123456"}}}}}},"responses":{"200":{"description":"Number verified; status returned"},"400":{"description":"Wrong or expired code"}},"security":[{"user-auth":[]}],"summary":"Confirm the code and save my number","tags":["Text notifications"]}},"/api/notification/sms/phone":{"delete":{"description":"Removes the number and turns the switch off with it. Credit is untouched — it is still yours for whenever you add a number again.","operationId":"SmsNotificationController_removePhone","parameters":[],"responses":{"200":{"description":"Number removed; status returned"}},"security":[{"user-auth":[]}],"summary":"Forget my number","tags":["Text notifications"]}},"/api/push/token":{"post":{"description":"\nRegister an Expo push token for the authenticated user's device.\n\n### When to Call\n- On app startup after successful login\n- When the push token refreshes (Expo provides a listener for this)\n- After granting notification permissions\n\n### Token Format\nExpo push tokens look like: `ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]`\n\n### Multiple Devices\nUsers can have multiple devices registered. Each device is identified by a unique `deviceId`.\nIf the same `deviceId` is registered again, the token is updated.\n\n### Request Body\n```json\n{\n  \"token\": \"ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]\",\n  \"platform\": \"ios\",\n  \"deviceId\": \"unique-device-id-from-expo\",\n  \"deviceName\": \"iPhone 15 Pro\"\n}\n```\n    ","operationId":"PushNotificationController_registerToken","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["token","platform","deviceId"],"properties":{"token":{"type":"string","example":"ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]","description":"Expo push token from Notifications.getExpoPushTokenAsync()"},"platform":{"type":"string","enum":["ios","android","web"],"example":"ios","description":"Device platform"},"deviceId":{"type":"string","example":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","description":"Unique device identifier (from expo-device or similar)"},"deviceName":{"type":"string","example":"iPhone 15 Pro","description":"Human-readable device name (optional)"}}}}}},"responses":{"200":{"description":"Token registered successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"message":{"type":"string","example":"Push token registered successfully"}}}}}},"400":{"description":"Invalid token format or missing required fields"},"401":{"description":"Unauthorized"}},"security":[{"user-auth":[]}],"summary":"Register a push notification token","tags":["Push Notifications"]}},"/api/push/vapid-public-key":{"get":{"description":"\nReturns the VAPID public key, or an empty string when web push is not\nconfigured on this deployment.\n\nDeliberately unauthenticated: the key is public by definition — it ships to\nevery browser that subscribes — and the page needs it before it can ask the\nreader for permission.\n\nAn empty `publicKey` means the feature is off; a client that treats that as\n\"web push unavailable\" degrades to in-tab notifications rather than failing.\n    ","operationId":"PushNotificationController_getVapidPublicKey","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"publicKey":{"type":"string","example":"BEl62iUYgUivxIkv69yViEuiBIa…"},"configured":{"type":"boolean","example":true}}}}}}},"summary":"Public key a browser needs to subscribe to web push","tags":["Push Notifications"]}},"/api/push/token/{deviceId}":{"delete":{"description":"\nRemove the push token for a specific device when the user logs out.\n\n### When to Call\n- On user logout from the app\n- When user disables notifications for this device specifically\n    ","operationId":"PushNotificationController_unregisterToken","parameters":[{"name":"deviceId","required":true,"in":"path","description":"The device ID that was used when registering the token","schema":{"example":"a1b2c3d4-e5f6-7890-abcd-ef1234567890"}}],"responses":{"200":{"description":"Token unregistered successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"message":{"type":"string","example":"Push token unregistered"}}}}}}},"security":[{"user-auth":[]}],"summary":"Unregister a push token (logout from device)","tags":["Push Notifications"]}},"/api/push/tokens":{"delete":{"description":"\nRemove all push tokens for the authenticated user.\n\n### When to Call\n- \"Logout from all devices\" feature\n- Account security reset\n    ","operationId":"PushNotificationController_unregisterAllTokens","parameters":[],"responses":{"200":{"description":"All tokens unregistered","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"message":{"type":"string","example":"All push tokens unregistered"},"count":{"type":"number","example":3,"description":"Number of tokens removed"}}}}}}},"security":[{"user-auth":[]}],"summary":"Unregister all push tokens (logout from all devices)","tags":["Push Notifications"]}},"/api/push/devices":{"get":{"description":"\nGet list of all devices that have push notifications registered.\n\n### Use Case\n- Show user which devices have notifications enabled\n- Allow user to manage notification devices in settings\n    ","operationId":"PushNotificationController_getRegisteredDevices","parameters":[],"responses":{"200":{"description":"List of registered devices","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"devices":{"type":"array","items":{"type":"object","properties":{"deviceId":{"type":"string","example":"a1b2c3d4-e5f6-7890-abcd-ef1234567890"},"deviceName":{"type":"string","example":"iPhone 15 Pro"},"platform":{"type":"string","example":"ios"},"isActive":{"type":"boolean","example":true},"lastUsed":{"type":"string","format":"date-time"}}}}}}}}}},"security":[{"user-auth":[]}],"summary":"Get registered devices","tags":["Push Notifications"]}},"/api/push/preferences":{"get":{"description":"\nGet the authenticated user's notification preferences.\n\n### Response Structure\n```json\n{\n  \"pushEnabled\": true,\n  \"engagement\": {\n    \"likes\": false,\n    \"comments\": true,\n    \"commentReplies\": true,\n    \"mentions\": true\n  },\n  \"social\": {\n    \"newFollowers\": true\n  },\n  \"monetization\": {\n    \"tips\": true,\n    \"subscriptions\": true,\n    \"ppvPurchases\": true\n  },\n  \"content\": {\n    \"milestones\": true,\n    \"livestreamStart\": true\n  },\n  \"system\": {\n    \"accountAlerts\": true,\n    \"announcements\": true\n  },\n  \"quietHours\": {\n    \"enabled\": false,\n    \"startHour\": 22,\n    \"endHour\": 8,\n    \"timezone\": \"UTC\"\n  },\n  \"digestMode\": {\n    \"enabled\": false,\n    \"frequency\": \"daily\"\n  }\n}\n```\n    ","operationId":"PushNotificationController_getPreferences","parameters":[],"responses":{"200":{"description":"Notification preferences","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"preferences":{"type":"object","properties":{"pushEnabled":{"type":"boolean"},"engagement":{"type":"object","properties":{"likes":{"type":"boolean"},"comments":{"type":"boolean"},"commentReplies":{"type":"boolean"},"mentions":{"type":"boolean"}}},"social":{"type":"object","properties":{"newFollowers":{"type":"boolean"}}},"monetization":{"type":"object","properties":{"tips":{"type":"boolean"},"subscriptions":{"type":"boolean"},"ppvPurchases":{"type":"boolean"}}},"content":{"type":"object","properties":{"milestones":{"type":"boolean"},"livestreamStart":{"type":"boolean"}}},"system":{"type":"object","properties":{"accountAlerts":{"type":"boolean"},"announcements":{"type":"boolean"}}},"quietHours":{"type":"object","properties":{"enabled":{"type":"boolean"},"startHour":{"type":"number"},"endHour":{"type":"number"},"timezone":{"type":"string"}}},"digestMode":{"type":"object","properties":{"enabled":{"type":"boolean"},"frequency":{"type":"string","enum":["hourly","daily"]}}}}}}}}}}},"security":[{"user-auth":[]}],"summary":"Get notification preferences","tags":["Push Notifications"]},"post":{"description":"\nUpdate notification preferences. You can update individual fields or entire categories.\n\n### Partial Updates Supported\nYou only need to send the fields you want to change:\n\n**Toggle all notifications:**\n```json\n{ \"pushEnabled\": false }\n```\n\n**Turn off like notifications:**\n```json\n{ \"engagement\": { \"likes\": false } }\n```\n\n**Enable quiet hours:**\n```json\n{\n  \"quietHours\": {\n    \"enabled\": true,\n    \"startHour\": 22,\n    \"endHour\": 8,\n    \"timezone\": \"America/New_York\"\n  }\n}\n```\n\n**Enable digest mode:**\n```json\n{\n  \"digestMode\": {\n    \"enabled\": true,\n    \"frequency\": \"daily\"\n  }\n}\n```\n    ","operationId":"PushNotificationController_updatePreferences","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"pushEnabled":{"type":"boolean","description":"Master switch for all push notifications"},"engagement":{"type":"object","properties":{"likes":{"type":"boolean"},"comments":{"type":"boolean"},"commentReplies":{"type":"boolean"},"mentions":{"type":"boolean"}}},"social":{"type":"object","properties":{"newFollowers":{"type":"boolean"}}},"monetization":{"type":"object","properties":{"tips":{"type":"boolean"},"subscriptions":{"type":"boolean"},"ppvPurchases":{"type":"boolean"}}},"content":{"type":"object","properties":{"milestones":{"type":"boolean"},"livestreamStart":{"type":"boolean"}}},"system":{"type":"object","properties":{"accountAlerts":{"type":"boolean"},"announcements":{"type":"boolean"}}},"quietHours":{"type":"object","properties":{"enabled":{"type":"boolean"},"startHour":{"type":"number","minimum":0,"maximum":23},"endHour":{"type":"number","minimum":0,"maximum":23},"timezone":{"type":"string","example":"America/New_York"}}},"digestMode":{"type":"object","properties":{"enabled":{"type":"boolean"},"frequency":{"type":"string","enum":["hourly","daily"]}}}}}}}},"responses":{"200":{"description":"Preferences updated successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"preferences":{"type":"object","description":"Updated preferences"}}}}}}},"security":[{"user-auth":[]}],"summary":"Update notification preferences","tags":["Push Notifications"]}},"/api/push/preferences/reset":{"post":{"description":"\nReset all notification preferences to their default values.\n\n### Default Values:\n- **pushEnabled**: true\n- **likes**: false (too noisy)\n- **comments, replies, mentions**: true\n- **newFollowers**: true\n- **tips, subscriptions, ppvPurchases**: true\n- **milestones, livestreamStart**: true\n- **accountAlerts, announcements**: true\n- **quietHours**: disabled\n- **digestMode**: disabled\n    ","operationId":"PushNotificationController_resetPreferences","parameters":[],"responses":{"200":{"description":"Preferences reset to defaults","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"preferences":{"type":"object","description":"Default preferences"}}}}}}},"security":[{"user-auth":[]}],"summary":"Reset notification preferences to defaults","tags":["Push Notifications"]}},"/api/push/test":{"post":{"description":"\nSend a raw test push notification to verify device setup. Bypasses all preference checks and uses `force: true`.\n\n**Only available when `RUN_MODE=dev` or `NODE_ENV` is not `production`.** Returns 403 in production.\n\nFor production self-notification, use `POST /push/notify-self` instead.\n    ","operationId":"PushNotificationController_sendTestNotification","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string","example":"Test Notification","description":"Optional custom title"},"body":{"type":"string","example":"This is a test!","description":"Optional custom body"}}}}}},"responses":{"200":{"description":"Test notification sent","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"message":{"type":"string","example":"Test notification sent"}}}}}},"403":{"description":"Not available in production"}},"security":[{"user-auth":[]}],"summary":"Send a test push notification (dev/staging only)","tags":["Push Notifications"]}},"/api/push/notify-self":{"post":{"description":"\nSend a push notification to all of your registered devices. Useful for:\n\n- **Reminders**: Set a reminder that fires as a push notification\n- **Cross-device alerts**: Notify your other devices about something (e.g. \"Check this on your phone\")\n- **Setup verification**: Confirm push notifications are working after registering a token\n\n### How It Works\n1. The notification is sent to **all** of your devices that have an active push token registered via `POST /push/token`.\n2. Standard rate limiting applies (max 5 self-notifications per hour).\n3. The notification respects your quiet hours and digest mode preferences unless `force: true` is set.\n4. A matching in-app notification record is created so it also appears in your notification feed.\n\n### Limitations\n- You can only send to yourself — the recipient is always the authenticated user.\n- Maximum title length: 100 characters.\n- Maximum body length: 500 characters.\n- Rate limited to 5 per hour to prevent abuse.\n    ","operationId":"PushNotificationController_notifySelf","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["title","body"],"properties":{"title":{"type":"string","example":"📌 Reminder","description":"Notification title (required, max 100 chars)","maxLength":100},"body":{"type":"string","example":"Check your analytics dashboard!","description":"Notification body text (required, max 500 chars)","maxLength":500},"data":{"type":"object","description":"Optional custom data payload (forwarded to the push notification)","properties":{"deepLink":{"type":"string","example":"dehub://profile","description":"Deep link URL for navigation when notification is tapped"}},"additionalProperties":true}}}}}},"responses":{"200":{"description":"Notification sent to your devices","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"message":{"type":"string","example":"Notification sent to 2 device(s)"},"deviceCount":{"type":"number","example":2,"description":"Number of devices the notification was sent to"}}}}}},"400":{"description":"Missing or invalid title/body, or input too long","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":false},"message":{"type":"string","example":"Title and body are required"}}}}}},"429":{"description":"Self-notification rate limit exceeded (5 per hour)","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":false},"message":{"type":"string","example":"Rate limit exceeded. Max 5 self-notifications per hour."}}}}}}},"security":[{"user-auth":[]}],"summary":"Send a push notification to your own devices","tags":["Push Notifications"]}},"/api/push/call-ring":{"post":{"description":"Sends a high-priority incoming-call push for a ringing call_sessions row under a minute old. One ring per session.","operationId":"PushNotificationController_ringCall","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["sessionId"],"properties":{"sessionId":{"type":"string","format":"uuid"}}}}}},"responses":{"201":{"description":""}},"security":[{"user-auth":[]}],"summary":"Ring the other side of a call you just placed","tags":["Push Notifications"]}},"/api/badge/delegations":{"get":{"operationId":"BadgeDelegationController_mine","parameters":[],"responses":{"200":{"description":""}},"tags":["Badges"]},"post":{"operationId":"BadgeDelegationController_grant","parameters":[],"responses":{"201":{"description":""}},"tags":["Badges"]}},"/api/badge/delegations/acceptance":{"put":{"operationId":"BadgeDelegationController_acceptance","parameters":[],"responses":{"200":{"description":""}},"tags":["Badges"]}},"/api/badge/delegations/{counterparty}":{"delete":{"operationId":"BadgeDelegationController_revoke","parameters":[{"name":"counterparty","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Badges"]}},"/api/badge/delegations/{id}":{"get":{"operationId":"BadgeDelegationController_patron","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Badges"]}},"/api/staking/refresh/{address}":{"post":{"operationId":"StakingController_refresh","parameters":[{"name":"address","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":""}},"tags":["Staking"]},"get":{"operationId":"StakingController_refreshGet","parameters":[{"name":"address","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Staking"]}},"/api/badge/refresh/{address}":{"post":{"operationId":"StakingController_refreshBadge","parameters":[{"name":"address","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":""}},"tags":["Staking"]}},"/api/badge/tip/{chainId}/{txHash}":{"post":{"operationId":"StakingController_refreshTip","parameters":[{"name":"chainId","required":true,"in":"path","schema":{"type":"string"}},{"name":"txHash","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":""}},"tags":["Staking"]}},"/api/getServerTime":{"get":{"description":"Returns the current server time in Unix timestamp (seconds). Useful for syncing client time with server.","operationId":"NFTController_getServerTime","parameters":[],"responses":{"200":{"description":"Server time retrieved","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"data":{"type":"number","example":1737158400,"description":"Unix timestamp in seconds"},"note":{"type":"string","example":"s","description":"Time unit"}}}}}}},"summary":"Get current server time","tags":["Videos"]}},"/api/get_categories":{"get":{"description":"Returns list of all available video categories for filtering and upload selection.","operationId":"NFTController_getCat","parameters":[],"responses":{"200":{"description":"Categories retrieved","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"result":{"type":"array","items":{"type":"object","properties":{"_id":{"type":"string"},"name":{"type":"string","example":"Entertainment"},"slug":{"type":"string","example":"entertainment"}}}}}}}}}},"summary":"Get all video categories","tags":["Videos"]}},"/api/search_nfts":{"get":{"deprecated":true,"operationId":"NFTController_Filtered","parameters":[],"responses":{"200":{"description":""}},"summary":"⚠️ DEPRECATED — Use GET /feed instead","tags":["Videos"]}},"/api/feed":{"get":{"description":"\n## Overview\nThe optimized, unified feed endpoint that fetches all content types (videos, image posts, text posts) in a single request.\nReturns content sorted by **likes by default**, creating an engagement-driven feed similar to major social platforms.\n\n## ✨ Key Features\n- **Mixed Content**: Returns videos, image posts, and text posts interleaved\n- **Default Sort**: Most liked content first (engagement-optimized)\n- **Proper Pagination**: Returns total count and page info\n- **Clean Response**: Consistent structure with pagination metadata\n- **Batch Optimization**: User interactions (likes, saves) fetched efficiently\n\n## 🚀 Quick Start\n\n**Basic feed (most liked):**\n```\nGET /feed\n```\n\n**Paginated:**\n```\nGET /feed?page=2&limit=20\n```\n\n**Newest first:**\n```\nGET /feed?sortBy=createdAt&sortOrder=desc\n```\n\n**Only videos:**\n```\nGET /feed?postType=video\n```\n\n**Content from specific creator:**\n```\nGET /feed?minter=0x1234...\n```\n\n**With viewer context (includes isLiked, isSaved):**\n```\nAuthorization: Bearer <jwt-token>\nGET /feed\n```\n> **Note:** Viewer address is extracted from the JWT token. The `address` query param is deprecated and ignored.\n\n## 📊 Sorting Options\n\n| sortBy | Description |\n|--------|-------------|\n| `likes` | Most liked first (default) |\n| `views` | Most viewed first |\n| `createdAt` | Newest first |\n| `tips` | Most tipped first |\n| `comments` | Most discussed first |\n| `random` | **Shuffled feed** with creator diversity (cached per session) |\n\n## 🎲 Random Feed Mode\nWhen `sortBy=random`, the backend:\n1. Fetches a pool of ~500 recent items\n2. Applies creator diversity (max 1 post per creator by default)\n3. Shuffles using a seeded random for stable pagination\n4. Interleaves text and media posts (3:1 ratio)\n5. Caches the order in Redis for 30 minutes\n\n**Example:**\n```\nGET /feed?sortBy=random\nGET /feed?sortBy=random&shuffleSeed=abc123  // Use same seed for pagination\nGET /feed?sortBy=random&maxPerCreator=2     // Allow 2 posts per creator\n```\n\nThe response includes `shuffleSeed` - use it for subsequent pages to maintain order.\n\n## 🎯 Content Type Filters\n\n| postType | Description |\n|----------|-------------|\n| `all` | All content types (default) |\n| `video` | Video content only |\n| `short` | Short-form vertical videos (max 90s) |\n| `feed-images` | Image posts only |\n| `feed-simple` | Text-only posts |\n| `article` | Articles (text posts with a long-form body) |\n| `live` | Livestream recordings only |\n\n## 💰 Monetization Filters\n\n| Filter | Description |\n|--------|-------------|\n| `isPPV=true` | Pay-per-view content |\n| `isLocked=true` | Token-gated content |\n| `hasBounty=true` | Content with bounty rewards |\n| `hasPlans=true` | Subscription-gated content |\n\n## 👥 Following Feed\n\nPass `followingOnly=true` with a valid JWT Bearer token to restrict the feed to content from users you follow (plus your own posts). Great for a \"Home\" feed tab.\n\n## ⏰ Time Range Options\n\n| range | Description |\n|-------|-------------|\n| `day` | Last 24 hours |\n| `week` | Last 7 days |\n| `month` | Last 30 days |\n| `year` | Last 365 days |\n\nAlternatively, use `from` and `to` for custom date ranges.\n\n## 📄 Response Structure\n```json\n{\n  \"status\": true,\n  \"result\": [...contentItems],\n  \"pagination\": {\n    \"page\": 1,\n    \"limit\": 20,\n    \"totalCount\": 150,\n    \"totalPages\": 8,\n    \"hasMore\": true\n  },\n  \"shuffleSeed\": \"abc123xyz\"  // Only present when sortBy=random\n}\n```\n\n## 🔐 User Context (Authentication)\nWhen a valid **JWT Bearer token** is sent in the `Authorization` header, each item includes:\n- `isLiked`: Whether the viewer has liked this content\n- `isDisliked`: Whether the viewer has disliked this content  \n- `isSaved`: Whether the viewer has saved this content\n- `isOwner`: Whether the viewer is the creator of this content\n- `isUnlocked`: Whether the viewer has unlocked this PPV content (only present on PPV items)\n- `plansDetails.alreadySubscribed`: Subscription status for gated content\n\n> The `address` query param is **deprecated and ignored**. Content from private accounts is only visible if the authenticated user follows the creator.\n    ","operationId":"NFTController_getFeed","parameters":[{"name":"followingOnly","required":false,"in":"query","description":"When `true`, only returns content from accounts the authenticated user follows (plus their own posts). Requires a valid JWT Bearer token — ignored if unauthenticated.","schema":{"example":false,"type":"boolean"}},{"name":"address","required":false,"in":"query","description":"**DEPRECATED — ignored.** Viewer address is now extracted from the JWT Bearer token in the Authorization header. Send `Authorization: Bearer <token>` to get isLiked, isDisliked, isSaved, subscription status, and to see private account content you follow.","deprecated":true,"schema":{"example":"0x1234567890abcdef1234567890abcdef12345678","type":"string"}},{"name":"search","required":false,"in":"query","description":"Text search across title, description, and tokenId","schema":{"example":"gaming tutorial","type":"string"}},{"name":"hasPlans","required":false,"in":"query","description":"Filter for subscription-gated content","schema":{"example":false,"type":"boolean"}},{"name":"hasBounty","required":false,"in":"query","description":"Filter for content with bounty rewards","schema":{"example":false,"type":"boolean"}},{"name":"isLocked","required":false,"in":"query","description":"Filter for token-gated content only","schema":{"example":false,"type":"boolean"}},{"name":"isPPV","required":false,"in":"query","description":"Filter for pay-per-view content only","schema":{"example":false,"type":"boolean"}},{"name":"range","required":false,"in":"query","description":"Quick time range filter (overrides from/to)","schema":{"enum":["day","week","month","year"],"type":"string"}},{"name":"to","required":false,"in":"query","description":"Filter content created before this date (ISO 8601)","schema":{"example":"2026-12-31T23:59:59Z","type":"string"}},{"name":"from","required":false,"in":"query","description":"Filter content created after this date (ISO 8601)","schema":{"example":"2026-01-01T00:00:00Z","type":"string"}},{"name":"owner","required":false,"in":"query","description":"Filter by current owner wallet address","schema":{"example":"0x1234567890abcdef1234567890abcdef12345678","type":"string"}},{"name":"minter","required":false,"in":"query","description":"Filter by creator wallet address","schema":{"example":"0x1234567890abcdef1234567890abcdef12345678","type":"string"}},{"name":"category","required":false,"in":"query","description":"Filter by category name (exact match)","schema":{"example":"Gaming","type":"string"}},{"name":"status","required":false,"in":"query","description":"Filter by token status:\n- **minted**: Only fully minted content\n- **signed**: Only signed (pending mint) content\n- **all**: Both minted and signed (default)","schema":{"enum":["minted","signed","all"],"type":"string"}},{"name":"postType","required":false,"in":"query","description":"Filter by content type:\n- **video**: Video content only\n- **short**: Short-form vertical videos (max 90s)\n- **feed-images**: Image posts only\n- **feed-audio**: Audio posts only\n- **feed-simple**: Text posts only\n- **live**: Livestream recordings only\n- **all**: All content types (default)","schema":{"enum":["video","short","feed-images","feed-simple","feed-audio","live","all"],"type":"string"}},{"name":"maxPerCreator","required":false,"in":"query","description":"Maximum posts per creator in random mode (1-10). Default: 1. Only used when sortBy=random.","schema":{"example":1,"type":"number"}},{"name":"shuffleSeed","required":false,"in":"query","description":"Seed for random shuffle (for pagination stability). Auto-generated if not provided. Only used when sortBy=random.","schema":{"example":"abc123xyz","type":"string"}},{"name":"sortOrder","required":false,"in":"query","description":"Sort direction. Default: desc (ignored when sortBy=random)","schema":{"enum":["asc","desc"],"type":"string"}},{"name":"sortBy","required":false,"in":"query","description":"Field to sort by. Default: views\n- **random**: Shuffled feed with creator diversity (max 1 post per creator), cached per session","schema":{"enum":["views","likes","createdAt","tips","comments","random"],"type":"string"}},{"name":"limit","required":false,"in":"query","description":"Items per page (1-100). Default: 20","schema":{"example":20,"type":"number"}},{"name":"page","required":false,"in":"query","description":"Page number (1-indexed). Default: 1","schema":{"example":1,"type":"number"}}],"responses":{"200":{"description":"Feed retrieved successfully","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"result":{"type":"array","description":"Array of content items (mixed types)","items":{"type":"object","properties":{"tokenId":{"type":"number","example":456},"name":{"type":"string","example":"Amazing Video Title"},"description":{"type":"string","example":"Description of the content"},"imageUrl":{"type":"string","example":"images/456.png"},"videoUrl":{"type":"string","nullable":true,"example":"videos/456"},"previewUrl":{"type":"string","nullable":true,"example":"previews/456.mp4","description":"Low-res silent 3s MP4 preview clip for grid views (shorts only)"},"minter":{"type":"string","example":"0x1234567890abcdef..."},"owner":{"type":"string","example":"0x1234567890abcdef..."},"postType":{"type":"string","enum":["video","short","feed-simple","feed-images","feed-audio","live"],"example":"video","description":"Type of content"},"audioUrl":{"type":"string","nullable":true,"description":"Audio file URL (feed-audio posts only)"},"audioDuration":{"type":"number","nullable":true,"description":"Audio duration in seconds (feed-audio posts only)"},"listens":{"type":"number","nullable":true,"description":"Unique listen count (feed-audio posts only)"},"status":{"type":"string","enum":["minted","signed"],"example":"minted"},"category":{"type":"array","items":{"type":"string"},"example":["Gaming","Entertainment"]},"views":{"type":"number","example":15420},"likes":{"type":"number","example":847},"dislikes":{"type":"number","example":12},"commentCount":{"type":"number","example":156},"videoDuration":{"type":"number","nullable":true,"example":345},"minterUsername":{"type":"string","example":"cryptoCreator"},"minterDisplayName":{"type":"string","example":"Crypto Creator"},"minterAvatarUrl":{"type":"string","example":"avatars/creator.jpg"},"minterAboutMe":{"type":"string","example":"Content creator bio"},"minterFollowers":{"type":"number","example":1500,"description":"Number of followers"},"minterFollowings":{"type":"number","example":250,"description":"Number of followings"},"minterStaked":{"type":"number","example":50000,"description":"Badge balance (walletBalance + staked across all DHB chains). Same as minterUser.badgeBalance."},"minterUser":{"type":"object","description":"Nested minter user object","properties":{"address":{"type":"string","example":"0x1234..."},"username":{"type":"string","example":"cryptoCreator"},"displayName":{"type":"string","example":"Crypto Creator"},"avatarImageUrl":{"type":"string","example":"avatars/creator.jpg"},"followers":{"type":"number","example":1500},"followings":{"type":"number","example":250},"sentTips":{"type":"number","example":5000},"receivedTips":{"type":"number","example":10000},"badgeBalance":{"type":"number","example":75000,"description":"Denormalized walletBalance + staked across all DHB chains (for badge tier)"},"createdAt":{"type":"string","format":"date-time"}}},"streamInfo":{"type":"object","properties":{"isLockContent":{"type":"boolean","example":false},"lockAmount":{"type":"number","example":1000},"isPayPerView":{"type":"boolean","example":true},"payPerViewAmount":{"type":"number","example":5},"isAddBounty":{"type":"boolean","example":false}}},"plansDetails":{"type":"array","items":{"type":"object","properties":{"id":{"type":"number"},"title":{"type":"string"},"price":{"type":"number"},"alreadySubscribed":{"type":"boolean"}}}},"ppvBuyerCount":{"type":"number","example":42,"description":"Number of unique addresses that purchased PPV access (only present for PPV content)"},"isLiked":{"type":"boolean","description":"Present when JWT Bearer token provided"},"isDisliked":{"type":"boolean","description":"Present when JWT Bearer token provided"},"isSaved":{"type":"boolean","description":"Present when JWT Bearer token provided"},"isFollowing":{"type":"boolean","description":"Whether the authenticated user follows the creator (present when JWT Bearer token provided)"},"isOwner":{"type":"boolean","description":"Whether the authenticated user is the creator of this content"},"isUnlocked":{"type":"boolean","description":"Whether the authenticated user has unlocked this PPV content (only present for PPV items)"},"isHidden":{"type":"boolean","description":"Whether the post is hidden from public view"},"isReposted":{"type":"boolean","description":"Whether the authenticated user has reposted this post (present when JWT Bearer token provided)"},"reposts":{"type":"number","example":5,"description":"Total repost count"},"quotes":{"type":"number","example":2,"description":"Total quote post count"},"isQuotePost":{"type":"boolean","description":"Whether this post is a quote post (repost with added commentary)"},"quotedTokenId":{"type":"number","nullable":true,"description":"Token ID of the quoted post (only present on quote posts)"},"quotedPost":{"type":"object","nullable":true,"description":"Embedded quoted post data (only present on quote posts). Contains a summary of the original post.","properties":{"tokenId":{"type":"number"},"name":{"type":"string"},"description":{"type":"string"},"imageUrl":{"type":"string"},"videoUrl":{"type":"string"},"minter":{"type":"string"},"minterDisplayName":{"type":"string"},"minterAvatarUrl":{"type":"string"},"postType":{"type":"string"},"likes":{"type":"number"},"views":{"type":"number"},"reposts":{"type":"number"},"quotes":{"type":"number"},"createdAt":{"type":"string","format":"date-time"}}},"stream":{"type":"object","nullable":true,"description":"LiveStream data (only present for live postType)","properties":{"status":{"type":"string","enum":["offline","live","scheduled","ended"],"example":"live"},"isActive":{"type":"boolean","example":true},"playbackId":{"type":"string","example":"abc123xyz"},"streamKey":{"type":"string","example":"stream-key-123"},"startedAt":{"type":"string","format":"date-time","nullable":true},"endedAt":{"type":"string","format":"date-time","nullable":true},"scheduledFor":{"type":"string","format":"date-time","nullable":true},"peakViewers":{"type":"number","example":150},"totalViews":{"type":"number","example":1200},"likes":{"type":"number","example":89},"totalTips":{"type":"number","example":5000},"duration":{"type":"number","example":3600,"description":"Duration in seconds"},"settings":{"type":"object"},"streamDelay":{"type":"number","example":0},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}}},"pagination":{"type":"object","description":"Pagination metadata","properties":{"page":{"type":"number","example":1,"description":"Current page number (1-indexed)"},"limit":{"type":"number","example":20,"description":"Items per page"},"totalCount":{"type":"number","example":150,"description":"Total number of matching items"},"totalPages":{"type":"number","example":8,"description":"Total number of pages"},"hasMore":{"type":"boolean","example":true,"description":"Whether more pages exist"}}}}}}}},"500":{"description":"Server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":false},"error":{"type":"string","example":"Failed to fetch feed"},"message":{"type":"string","example":"Database connection error"}}}}}}},"summary":"📱 Get unified content feed [RECOMMENDED]","tags":["Videos"]}},"/api/feed/shorts":{"get":{"description":"\n## Overview\nDedicated feed for short-form vertical videos (max 90 seconds).\nReturns shorts in randomized order with creator diversity, optimized for infinite vertical scroll.\n\n## Usage\n```\nGET /feed/shorts\nGET /feed/shorts?page=2&shuffleSeed=abc123\nGET /feed/shorts?followingOnly=true   (requires JWT)\n```\n\n## How It Works\n- Defaults to **random** sort with creator diversity (max 1 short per creator)\n- Use `shuffleSeed` from the first response for stable pagination across pages\n- Supports all the same filters as `/feed` (category, minter, followingOnly, etc.)\n- Use `sortBy=createdAt` for chronological order instead\n\n## Authentication\nOptional. When JWT is provided, response includes `isLiked`, `isSaved`, `isFollowing`, etc.\n    ","operationId":"NFTController_getShortsFeed","parameters":[{"name":"followingOnly","required":false,"in":"query","schema":{"type":"boolean"}},{"name":"minter","required":false,"in":"query","schema":{"type":"string"}},{"name":"category","required":false,"in":"query","schema":{"type":"string"}},{"name":"shuffleSeed","required":false,"in":"query","description":"Seed for stable random pagination","schema":{"type":"string"}},{"name":"sortBy","required":false,"in":"query","schema":{"enum":["random","createdAt","likes","views"],"type":"string"}},{"name":"limit","required":false,"in":"query","description":"Items per page (default 20, max 50)","schema":{"example":20,"type":"number"}},{"name":"page","required":false,"in":"query","schema":{"example":1,"type":"number"}}],"responses":{"200":{"description":"Shorts feed retrieved","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"result":{"type":"array","items":{"type":"object","properties":{"tokenId":{"type":"number","example":456},"name":{"type":"string","example":"Quick Tutorial"},"description":{"type":"string"},"postType":{"type":"string","example":"short"},"imageUrl":{"type":"string","example":"shorts/456.jpg","description":"Thumbnail (user-provided or auto-extracted from video)"},"videoUrl":{"type":"string","example":"videos/456.mp4"},"previewUrl":{"type":"string","example":"previews/456.mp4","description":"Low-res silent 3s MP4 clip for grid preview"},"videoDuration":{"type":"number","example":28.5,"description":"Duration in seconds (max 90)"},"views":{"type":"number"},"likes":{"type":"number"},"totalVotes":{"type":"object","properties":{"for":{"type":"number"},"against":{"type":"number"}}},"minter":{"type":"string","example":"0x..."},"mintername":{"type":"string"},"minterDisplayName":{"type":"string"},"minterAvatarUrl":{"type":"string"},"isLiked":{"type":"boolean","description":"Present when JWT provided"},"isSaved":{"type":"boolean"},"isFollowing":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time"}}}},"pagination":{"type":"object","properties":{"page":{"type":"number"},"limit":{"type":"number"},"totalCount":{"type":"number"},"totalPages":{"type":"number"},"hasMore":{"type":"boolean"}}},"shuffleSeed":{"type":"string","description":"Use this seed for subsequent pages"}}}}}}},"summary":"Get short-form video feed (TikTok-style)","tags":["Videos"]}},"/api/token_visibility":{"post":{"description":"\nToggle visibility of a video (show/hide from public feeds).\n\n### Authentication Required\nOnly the video owner/creator can change visibility of their own content.\n    ","operationId":"NFTController_changeVisibility","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["id","isHidden"],"properties":{"id":{"type":"number","example":123,"description":"Video token ID"},"isHidden":{"type":"boolean","example":true,"description":"true = hidden, false = visible"}}}}}},"responses":{"200":{"description":"Visibility updated successfully","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"boolean","example":true},"data":{"type":"object","description":"Updated token object"}}}}}},"400":{"description":"Invalid data provided (missing id or isHidden)"},"401":{"description":"Unauthorized — valid JWT required"},"403":{"description":"Forbidden — only the owner/creator can change visibility"},"404":{"description":"Token not found"}},"security":[{"user-auth":[]}],"summary":"Change video visibility","tags":["Videos"]}},"/api/nft/{tokenId}":{"patch":{"description":"\nEdit the title, description, and/or categories of your own post.\n\n### Authentication Required\nOnly the content creator (minter) can edit their own content.\n\n### Editable Fields\n- **name** — Post title (max 140 characters)\n- **description** — Post description (500 characters with no badge, rising per badge tier to 2,000 for Megalodon)\n- **category** — Array of category names\n\nAll fields are optional — only the fields you send will be updated.\n    ","operationId":"NFTController_editPost","parameters":[{"name":"tokenId","required":true,"in":"path","description":"Token ID of the content to edit","schema":{"example":"123"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","example":"Updated Title","description":"New title (max 140 chars)"},"description":{"type":"string","example":"Updated description","description":"New description (500 chars with no badge, up to 2,000 by badge tier)"},"articleBody":{"type":"string","description":"Article body (100–20,000 characters; articles only)"},"category":{"type":"array","items":{"type":"string"},"example":["Gaming","Entertainment"],"description":"New categories array"},"commentsDisabled":{"type":"boolean","example":true,"description":"true turns replies off for this post. Existing comments are kept and stay readable — only new ones are refused — so turning it back on restores the thread intact."},"shopLinks":{"type":"array","items":{"type":"object","properties":{"label":{"type":"string"},"url":{"type":"string"}}},"example":[{"label":"My mic","url":"https://amzn.to/3xYzAbc"}],"description":"Replaces the post's Shop board. Affiliate links are allowed. Send [] to clear it (which is how the Shop toggle is turned off). Sized by the editor's badge tier — see GET /api/shop-links/allowance — and refused with 400 if longer, or if a link is not http(s) or is a known-bad/DeHub-lookalike host."},"shopListingCount":{"type":"number","example":2,"description":"How many of your own DeHub store listings are on this post. A render hint the feed cards read so a Shop button can be drawn without a Supabase query — the listings themselves are attached through the stream-products edge function, which owns the ownership check. 0 says the listings came off."},"mediaDescriptions":{"type":"array","items":{"type":"string"},"example":["A red bike against a brick wall"],"description":"Alt text, one per piece of media in the order the post shows it (gallery images by position, the video or audio cover as the first entry). Up to 50, 1500 characters each. Replaces the whole list; [] clears it."},"spoilerText":{"type":"string","example":"Spoilers for the finale","description":"Content warning: one line every client shows in place of the post until the viewer opens it. Up to 200 characters. An empty string takes it off."},"contentRating":{"type":"string","enum":["safe","mature"],"example":"mature","description":"Re-rate a post after publishing. \"mature\" takes it off the public feeds and leaves it on the profile, the Following feed and its own link; \"safe\" puts it back. Refused with 403 once a moderator has rated the post."}}}}}},"responses":{"200":{"description":"Post updated successfully","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"boolean","example":true},"data":{"type":"object","properties":{"tokenId":{"type":"number","example":123},"name":{"type":"string","example":"Updated Title"},"description":{"type":"string","example":"Updated description"},"category":{"type":"array","items":{"type":"string"},"example":["Gaming"]},"contentRating":{"type":"string","example":"safe"}}}}}}}},"400":{"description":"No editable fields provided or validation error"},"401":{"description":"Authentication required"},"403":{"description":"You can only edit your own content"},"404":{"description":"Token not found"}},"security":[{"user-auth":[]}],"summary":"Edit post details","tags":["Videos"]},"delete":{"description":"\nSoft deletes a video/post by setting isDeleted to true.\nThe content will no longer appear in feeds or search results.\n\n### Authentication Required\nOnly the content owner or minter can delete their own content.\n\n### Note\nThis is a soft delete - the content is not permanently removed from the database,\nbut will be excluded from all public queries.\n    ","operationId":"NFTController_deleteToken","parameters":[{"name":"tokenId","required":true,"in":"path","description":"Token ID of the content to delete","schema":{"example":"123"}}],"responses":{"200":{"description":"Content deleted successfully","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"boolean","example":true},"message":{"type":"string","example":"Content deleted successfully"}}}}}},"400":{"description":"Token ID is required"},"401":{"description":"Authentication required"},"403":{"description":"You can only delete your own content"},"404":{"description":"Token not found"}},"security":[{"user-auth":[]}],"summary":"Delete content (soft delete)","tags":["Videos"]}},"/api/nft/{tokenId}/image-allowance":{"get":{"operationId":"NFTController_imageAllowance","parameters":[],"responses":{"200":{"description":""}},"tags":["Videos"]}},"/api/nft/{tokenId}/images":{"post":{"operationId":"NFTController_addImages","parameters":[],"responses":{"201":{"description":""}},"security":[{"user-auth":[]}],"summary":"Add gallery images up to the creator badge allowance","tags":["Videos"]}},"/api/nft/{tokenId}/images/{index}":{"post":{"operationId":"NFTController_replaceImage","parameters":[],"responses":{"201":{"description":""}},"security":[{"user-auth":[]}],"summary":"Replace one published gallery image (creator only, zero-based index)","tags":["Videos"]}},"/api/nft/{tokenId}/thumbnail":{"post":{"operationId":"NFTController_replaceCover","parameters":[],"responses":{"201":{"description":""}},"security":[{"user-auth":[]}],"summary":"Change the cover image on a video or short (creator only)","tags":["Videos"]}},"/api/nft/{tokenId}/retry-transcode":{"post":{"description":"\nPut a post whose processing failed back through the encoder.\n\n**Nothing is uploaded.** When a transcode gives up, the creator's original is\narchived to storage instead of deleted, so the same file can be tried again\nwithout them finding it a second time — which for a video recorded on a phone\nweeks ago often means it is gone for good.\n\n### Authentication Required\nThe creator (minter) only.\n\n### When this helps\nA transcode fails for reasons that have nothing to do with the file at least\nas often as for reasons that do — a full disk, an encoder that was briefly\nmissing, a machine under load. Those succeed on a second run unchanged. A\ngenuine file problem may also succeed later, once the encoder has learned a\nfallback for it.\n\nPosts that failed before their source was archived have nothing to retry from;\nthose return 409 and have to use Replace instead.\n    ","operationId":"NFTController_retryTranscode","parameters":[{"name":"tokenId","required":true,"in":"path","description":"Token ID of the post","schema":{"example":"123"}}],"responses":{"202":{"description":"Retry queued"},"401":{"description":"Unauthorized"},"403":{"description":"Not the creator of this post"},"404":{"description":"Token not found"},"409":{"description":"Already processing, or no archived source to retry from"}},"security":[{"user-auth":[]}],"summary":"Re-run a failed transcode from the file the creator already uploaded","tags":["Videos"]}},"/api/nft/{tokenId}/video":{"post":{"description":"\nSwap the file behind a post without losing the post.\n\nThe replacement is transcoded and written to the same storage key the original\nused, so **the URL, views, comments, reposts and every shared link survive** —\nthe alternative is deleting the post and starting from zero over a typo in the\nlast render.\n\n### Authentication Required\nThe creator (minter) only. Owning a bought post does not make the recording\nyours to re-cut.\n\n### Files\n- **file 1** — the replacement video (required, must be a video mimetype)\n- **file 2** — a new poster frame (optional). Omit it and the existing\n  thumbnail stays, which is usually right for a re-render of the same content.\n\n### While it processes\nThe post stays visible and keeps playing the old file until the new one lands;\n`transcodingStatus` moves pending → on → done. The CDN edge is purged at the\nend, so viewers stop being served the old bytes within seconds rather than\nwithin a day. A viewer who already downloaded the old file keeps it in their\nown browser cache — the URL is deliberately unchanged, and that is the trade.\n    ","operationId":"NFTController_replaceVideo","parameters":[{"name":"tokenId","required":true,"in":"path","description":"Token ID of the post","schema":{"example":"123"}}],"responses":{"202":{"description":"Replacement queued","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"boolean","example":true},"data":{"type":"object","properties":{"tokenId":{"type":"number","example":123},"status":{"type":"string","example":"queued"},"message":{"type":"string"}}}}}}}},"400":{"description":"Missing file, wrong mimetype, or a post type with no file to replace"},"401":{"description":"Unauthorized"},"403":{"description":"Not the creator of this post"},"404":{"description":"Token not found"},"409":{"description":"A video for this post is already processing"}},"security":[{"user-auth":[]}],"summary":"Replace the video file on an existing post","tags":["Videos"]}},"/api/my_watched_nfts":{"get":{"description":"\nRetrieves videos the authenticated user has watched, sorted by most recently watched.\nResponse format matches the feed/liked_videos/savedPosts endpoints.\n\n### Authentication Required\nReturns watch history for the logged-in user only.\n\n### Content Filter\n- Returns all content types by default (except live streams)\n- Filter by `postType` (video, feed-simple, feed-images, feed-audio, feed-all)\n- Filter by `category`\n- Excludes deleted and hidden content\n- Groups by token (shows each video once, ordered by last watch)\n\n### Pagination\nUses 1-based page numbering with `limit` for page size (max 100).\n    ","operationId":"NFTController_watchhistory","parameters":[{"name":"category","required":false,"in":"query","description":"Filter by category name","schema":{"type":"string"}},{"name":"postType","required":false,"in":"query","description":"Filter by content type (video, feed-simple, feed-images, feed-audio). Omit or pass \"all\" for all types.","schema":{"example":"video","type":"string"}},{"name":"limit","required":false,"in":"query","description":"Items per page (default 20, max 100)","schema":{"example":20,"type":"number"}},{"name":"page","required":false,"in":"query","description":"Page number (1-based)","schema":{"example":1,"type":"number"}}],"responses":{"200":{"description":"Watch history retrieved","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"result":{"type":"array","items":{"type":"object","properties":{"tokenId":{"type":"number","example":123},"name":{"type":"string","example":"My Video"},"description":{"type":"string"},"imageUrl":{"type":"string"},"videoUrl":{"type":"string"},"minter":{"type":"string"},"minterDisplayName":{"type":"string"},"minterAvatarUrl":{"type":"string"},"minterFollowers":{"type":"number"},"minterFollowings":{"type":"number"},"postType":{"type":"string","enum":["video","short","feed-simple","feed-images","feed-audio"]},"audioUrl":{"type":"string","nullable":true,"description":"Audio file URL (feed-audio only)"},"audioDuration":{"type":"number","nullable":true,"description":"Audio duration in seconds (feed-audio only)"},"listens":{"type":"number","nullable":true,"description":"Unique listen count (feed-audio only)"},"views":{"type":"number"},"likes":{"type":"number"},"isLiked":{"type":"boolean"},"isDisliked":{"type":"boolean"},"isSaved":{"type":"boolean"},"isFollowing":{"type":"boolean"},"isOwner":{"type":"boolean"},"isUnlocked":{"type":"boolean","description":"Only present for PPV content"},"isReposted":{"type":"boolean","description":"Whether the authenticated user has reposted this post"},"reposts":{"type":"number","description":"Total repost count"},"quotes":{"type":"number","description":"Total quote post count"},"isQuotePost":{"type":"boolean","description":"Whether this post is a quote post"},"quotedTokenId":{"type":"number","nullable":true,"description":"Token ID of the quoted post (only on quote posts)"},"quotedPost":{"type":"object","nullable":true,"description":"Embedded quoted post summary (only on quote posts)"},"lastWatchedAt":{"type":"string","format":"date-time","description":"When user last watched this"}}}},"pagination":{"type":"object","properties":{"page":{"type":"number","example":1},"limit":{"type":"number","example":20},"totalCount":{"type":"number","example":50,"description":"Total unique watched items"},"totalPages":{"type":"number","example":3},"hasMore":{"type":"boolean","example":true}}}}}}}}},"security":[{"user-auth":[]}],"summary":"Get watch history","tags":["Videos"]},"delete":{"description":"Deletes all watch history entries for the authenticated user.","operationId":"NFTController_clearWatchHistory","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"properties":{"result":{"type":"boolean","example":true},"deleted":{"type":"number","description":"Number of history entries removed","example":42}}}}}}},"security":[{"user-auth":[]}],"summary":"Clear watch history","tags":["Videos"]}},"/api/ppv-sales-count":{"get":{"description":"Returns the total number of times a PPV post has been unlocked/purchased. No auth required.","operationId":"NFTController_getPpvSalesCount","parameters":[{"name":"tokenId","required":true,"in":"query","description":"Token ID of the PPV post","schema":{"example":123,"type":"number"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"properties":{"result":{"type":"boolean","example":true},"tokenId":{"type":"number","example":123},"salesCount":{"type":"number","description":"Total number of PPV purchases","example":58}}}}}}},"summary":"PPV sales count for a post","tags":["Videos"]}},"/api/claim-bounty":{"get":{"description":"\nReturns a signature that can be used to claim bounty rewards from a video/post on-chain.\n\n## How Bounties Work\nCreators can attach bounty rewards to their content for:\n- **First X Viewers**: Rewards for the first N users who watch the content\n- **First X Commenters**: Rewards for the first N users who comment\n\n## Eligibility\nYou are eligible to claim a bounty if:\n1. The content has an active bounty (isAddBounty = true)\n2. You are NOT the content creator (minters can't claim their own bounty)\n3. You haven't already claimed this bounty type\n4. For viewer bounty: You are among the first X viewers\n5. For commenter bounty: You are among the first X commenters\n\n## Response\nThe response contains signatures for eligible bounty types:\n- `viewer`: Signature for viewer bounty (if eligible)\n- `commentor`: Signature for commenter bounty (if eligible)\n- `viewer_claimed`: true if you already claimed viewer bounty\n- `commentor_claimed`: true if you already claimed commenter bounty\n\n## On-Chain Claim\nUse the returned signature to call the smart contract:\n```javascript\nconst tx = await streamController.claimBounty(\n  tokenId,\n  signature.r,\n  signature.s,\n  signature.v,\n  bountyType, // 0 = viewer, 1 = commenter\n  { gasLimit }\n);\n```\n\n## Bounty Types\n- **0**: Viewer bounty\n- **1**: Commenter bounty\n    ","operationId":"NFTController_claimBounty","parameters":[{"name":"tokenId","required":true,"in":"query","description":"Token ID of the content with bounty","schema":{"example":123,"type":"number"}}],"responses":{"200":{"description":"Not eligible for any bounty\n\nBounty claim signature(s) returned","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"boolean","example":false},"result":{"type":"object","properties":{"viewer":{"type":"object","description":"Signature for viewer bounty (if eligible)","properties":{"v":{"type":"number","example":27,"description":"ECDSA recovery id"},"r":{"type":"string","example":"0x...","description":"ECDSA signature r value"},"s":{"type":"string","example":"0x...","description":"ECDSA signature s value"}}},"commentor":{"type":"object","description":"Signature for commenter bounty (if eligible)","properties":{"v":{"type":"number","example":28},"r":{"type":"string","example":"0x..."},"s":{"type":"string","example":"0x..."}}},"viewer_claimed":{"type":"boolean","example":false,"description":"Already claimed viewer bounty"},"commentor_claimed":{"type":"boolean","example":false,"description":"Already claimed commenter bounty"}}},"claimed":{"type":"object","description":"Deprecated - use result.viewer_claimed/commentor_claimed"}}}}}},"400":{"description":"Token ID is required"},"401":{"description":"Unauthorized - authentication required"}},"security":[{"user-auth":[]}],"summary":"Get signature to claim bounty reward","tags":["Videos"]}},"/api/draft_video":{"post":{"description":"\nUploads the video as soon as it is attached and starts processing it while the\ncreator writes the post. Pass the returned `draftId` as `draftVideoId` to\n`user_mint` (without the video file) to publish it. Add `trimStart` and\n`trimEnd` (seconds) to `user_mint` to post only that part of the clip.\nUnposted drafts are removed after 24 hours.\n\nMultipart fields: `file` (the video), `postType` (`video` or `short`).\n\nProgress is pushed on the socket event `draft:<draftId>` as\n`{ progress, stage: 'transcoding' | 'ready' | 'failed' }`.\n    ","operationId":"NFTController_draftVideo","parameters":[],"responses":{"200":{"description":"{ draftId, status: \"processing\" }"},"400":{"description":"Not a readable video"},"413":{"description":"Over the tier upload limit"},"429":{"description":"Too many unposted drafts"}},"security":[{"user-auth":[]}],"summary":"Upload a video from the composer before posting","tags":["Videos"]}},"/api/draft_image":{"post":{"description":"\nUploads a photo as soon as it is attached (or edited), while the creator writes\nthe post. Pass the returned ids, in display order, as `draftImageIds` (a JSON\narray) to `user_mint` with `postType: feed-images`; use `null` for a slot whose\nfile is sent with the post instead, in the order those files are sent.\nUnposted photos are removed after 6 hours. A draft that has expired makes\n`user_mint` answer 409 `DRAFT_UNAVAILABLE`; send the files instead.\n\nMultipart field: `file` (JPG, PNG, WebP, GIF or HEIC).\n    ","operationId":"NFTController_draftImage","parameters":[],"responses":{"200":{"description":"{ draftImageId }"},"400":{"description":"Not a photo"},"413":{"description":"Over the size limit"},"429":{"description":"Too many unposted photos"}},"security":[{"user-auth":[]}],"summary":"Upload a photo from the composer before posting","tags":["Videos"]}},"/api/article_image":{"post":{"description":"\nUploads one picture while an article is being written and returns its address.\nThe writer's app puts it in the body as `![caption](url)`; the caption is\nshown under the picture when the article is read. An article holds up to 40\npictures (the cover is separate).\n\nMultipart field: `file` (JPG, PNG, WebP, GIF or HEIC).\n    ","operationId":"NFTController_articleImage","parameters":[],"responses":{"200":{"description":"{ url }"},"400":{"description":"Not a photo"},"413":{"description":"Over the size limit"}},"security":[{"user-auth":[]}],"summary":"Upload a picture for the inside of an article","tags":["Videos"]}},"/api/draft_video/resumable":{"post":{"description":"\nThe same draft as `POST /draft_video`, sent in chunks so a dropped connection\nor an expired token resumes instead of starting over. Speaks the node-uploadx\nprotocol (the one PeerTube uses).\n\n1. `POST` with `X-Upload-Content-Length` (bytes), `X-Upload-Content-Type` and a\n   JSON body `{ name, mimeType, size, lastModified, postType }`. The tier size\n   limit is checked here, before any bytes are sent. Answers `201` with\n   `Location: /api/draft_video/resumable?upload_id=<id>`, or `200` when this\n   file already has an unfinished upload, which the client then resumes.\n2. `PUT` each chunk to that location with `Content-Range: bytes <start>-<end>/<size>`\n   (64 MB at most). Answers `308` with `Range: bytes=0-<last byte held>`, and\n   `200 { draftId, status }` once the last byte is in.\n3. `PUT` with `Content-Range: bytes */<size>` and no body asks where to resume.\n4. `DELETE` cancels. A session idle for an hour is dropped.\n    ","operationId":"NFTController_createResumableDraftVideo","parameters":[],"responses":{"201":{"description":"Session created; see Location"},"413":{"description":"Over the tier upload limit"},"429":{"description":"Too many unposted drafts or uploads in progress"}},"security":[{"user-auth":[]}],"summary":"Start a resumable composer video upload","tags":["Videos"]},"put":{"operationId":"NFTController_putResumableDraftVideo","parameters":[],"responses":{"200":{"description":"{ draftId, status } — the last byte is in"},"308":{"description":"Range: bytes=0-<last byte held>"},"404":{"description":"Unknown or expired upload; start again"}},"security":[{"user-auth":[]}],"summary":"Send one chunk of a resumable composer upload, or ask where to resume (see POST)","tags":["Videos"]},"delete":{"operationId":"NFTController_deleteResumableDraftVideo","parameters":[],"responses":{"204":{"description":"Cancelled"}},"security":[{"user-auth":[]}],"summary":"Cancel a resumable composer upload","tags":["Videos"]}},"/api/draft_video/{id}":{"get":{"operationId":"NFTController_draftVideoStatus","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"{ draftId, status: 'processing' | 'ready' | 'failed' | 'used', progress, error? }"}},"security":[{"user-auth":[]}],"summary":"Processing state of a composer draft video","tags":["Videos"]}},"/api/user_mint":{"post":{"description":"\nUpload a new video, image, or text post to the platform.\n\n### Authentication Required\nYou must be logged in and have posting permissions.\n\n### Post Types:\n- **video**: Video content (requires 2 files: video + thumbnail)\n- **short**: Vertical short-form video, max 90 seconds (requires 1 file: video; thumbnail auto-generated if omitted)\n- **feed-images**: Image post (1+ image files)\n- **feed-simple**: Text-only post (no media file required)\n- **feed-audio**: Audio post (1 audio file, max 1–10GB by badge tier). Supports MP3, WAV, AAC, OGG, M4A, WEBM\n- **live**: For livestreams (requires thumbnail image)\n\n### Shorts Details:\nShorts are vertical (9:16) short-form videos optimized for mobile scroll views.\n\n**File requirements:**\n- `files[0]` = video file (MP4, required)\n- `files[1]` = thumbnail image (JPG/PNG, optional — auto-extracted from video at 1s mark if omitted)\n\n**Processing (async via background job):**\n- Transcoded to H.264 (CRF 25, preset fast), max 1080×1920, AAC 128k audio\n- Hard duration cap: **90 seconds** — videos exceeding this are rejected after transcode\n- `movflags +faststart` enabled for instant playback during scroll\n\n**Auto-generated assets:**\n- **Thumbnail** (if not provided): High-quality JPEG extracted at 1s into the video\n- **Preview clip** (always generated): ~3 second silent MP4 at 360×640, CRF 32 — used for grid/feed thumbnail views (TikTok-style). Stored at `previewUrl`.\n\n**Shorts-specific response fields (on `GET` endpoints):**\n- `previewUrl`: Low-res silent MP4 preview clip path (e.g. `previews/123.mp4`)\n- `imageUrl`: Thumbnail path (auto-generated or provided, e.g. `shorts/123.jpg`)\n- `videoDuration`: Duration in seconds (always ≤ 90)\n- `transcodingStatus`: `\"on\"` → `\"done\"` or `\"failed\"`\n\n### File Upload:\nSend media files as multipart/form-data. Supported formats:\n- Video: MP4 (max 1–10GB by badge tier)\n- Image: JPG, PNG\n- Audio: MP3, WAV, AAC, OGG, M4A, WEBM (feed-audio only, max 1–10GB by badge tier)\n\n### streamInfo Object:\nJSON string containing monetization settings:\n```json\n{\n  \"isLockContent\": false,\n  \"lockContentContractAddress\": \"0x...\",\n  \"lockContentTokenSymbol\": \"DHB\",\n  \"lockContentAmount\": 100,\n  \"lockContentChainIds\": [8453],\n  \"isPayPerView\": false,\n  \"payPerViewContractAddress\": \"0x...\",\n  \"payPerViewTokenSymbol\": \"DHB\",\n  \"payPerViewAmount\": 5,\n  \"payPerViewChainIds\": [8453],\n  \"isAddBounty\": false,\n  \"addBountyTokenSymbol\": \"DHB\",\n  \"addBountyFirstXViewers\": 10,\n  \"addBountyFirstXComments\": 5,\n  \"addBountyAmount\": 1,\n  \"addBountyChainId\": 8453\n}\n```\n\n### Minting Flow (2-step process):\n\n**Step 1: Call this endpoint**\nUpload your content and receive a signature payload from the backend.\n\n**Step 2: Call the smart contract**\nUse the response values to call the `mint` function on the StreamCollection contract:\n\n```typescript\n// Extract values from backend response\nconst { createdTokenId, timestamp, v, r, s } = response;\n\n// Call the mint function on the StreamCollection contract\nconst tx = await streamCollectionContract.mint(\n  createdTokenId,    // Token ID assigned by backend\n  timestamp,         // Timestamp used in signature\n  v,                 // Signature V component\n  r,                 // Signature R component  \n  s,                 // Signature S component\n  [],                // Additional data (empty array)\n  1000,              // Total supply\n  `${createdTokenId}.json`  // Metadata path\n);\n```\n\nThe backend signature proves the token ID was legitimately assigned.\nThe blockchain transaction completes the NFT minting process.\n    ","operationId":"NFTController_userMint","parameters":[],"requestBody":{"required":true,"description":"Video/post upload data","content":{"multipart/form-data":{"schema":{"type":"object","required":["postType"],"properties":{"name":{"type":"string","description":"Title (required for video and live, optional for feed posts)","example":"My First Video"},"description":{"type":"string","description":"Description (optional)","example":"Check out this awesome content!"},"streamInfo":{"type":"string","description":"JSON string with monetization/access settings (defaults to {} if omitted)","example":"{\"isLockContent\":false,\"isPayPerView\":false,\"isAddBounty\":false}"},"chainId":{"type":"number","description":"Blockchain chain ID (defaults to BSC mainnet if omitted)","example":8453},"category":{"type":"string","description":"JSON array of category names (defaults to [] if omitted)","example":"[\"Entertainment\",\"Gaming\"]"},"postType":{"type":"string","enum":["video","short","feed-simple","feed-images","feed-audio","live"],"description":"Type of post (default: video). Use \"short\" for vertical short-form videos (max 90s).\n\n**File requirements by postType:**\n- `video`: 2 files required — files[0] = video, files[1] = thumbnail\n- `short`: 1 file minimum — files[0] = video. files[1] = thumbnail (optional; auto-generated from video if omitted)\n- `feed-images`: 1+ image files\n- `feed-audio`: 1 audio file\n- `live`: 0-1 files (optional thumbnail)\n- `feed-simple`: no files needed; articles may include files[0] = article image and files[1] = social share image","example":"video"},"plans":{"type":"string","description":"JSON string of subscription plan IDs for gated content (optional)","example":"null"},"shopLinks":{"type":"string","description":"JSON array of `{label, url}` — the Shop board shown behind the Shop button on the post or the live player. Affiliate links are allowed. Three for everybody plus one per badge tier; ask GET /api/shop-links/allowance for the number. Set at mint so a live stream is already carrying its board when it goes on air.","example":"[{\"label\":\"My mic\",\"url\":\"https://amzn.to/3xYzAbc\"}]"},"shopListingCount":{"type":"number","description":"How many of your own DeHub store listings the client is about to attach to this post in Supabase. A render hint only, so a feed card can draw the Shop button without a Supabase query; the attach itself happens after this call returns a tokenId, since there is nothing to attach to before then.","example":2},"mediaDescriptions":{"type":"string","description":"JSON array of alt text strings, one per piece of media in display order (gallery images by position, the video or audio cover first). Up to 50, 1500 characters each.","example":"[\"A red bike against a brick wall\"]"},"spoilerText":{"type":"string","description":"Content warning: one line every client shows in place of the post until the viewer opens it. Up to 200 characters.","example":"Spoilers for the finale"},"hideAssetCards":{"type":"boolean","description":"The poster closed the price card for the $TICKER or contract address in the text, so clients draw none.","example":true},"layout":{"type":"string","enum":["comic"],"description":"feed-images only. \"comic\" shows the images as a comic (collector cards): up to 50 pages for badge holders, 10 without. The title is `name`, the cover is the first image.","example":"comic"},"mediaCaptions":{"type":"string","description":"Comics only. JSON array of captions, one per page in display order, shown to everyone. Up to 50, 500 characters each. Alt text stays in mediaDescriptions.","example":"[\"Nemo was fast asleep when the bed began to float.\"]"},"mintOptOut":{"type":"boolean","description":"Publish without minting on-chain. The post appears in feeds immediately and stays at status \"signed\" indefinitely; skip step 2 entirely. Mint it later with POST /api/mint_existing.","example":false},"scheduledAt":{"type":"string","description":"ISO date to publish at. Upload and mint exactly as normal — the media has to be stored while the files are on this request — and the post is simply kept out of every feed until the date passes. A date in the past means publish now. The response carries `scheduled: true` and `scheduledAt` alongside the usual mint signature.","example":"2026-09-01T09:00:00.000Z"},"idempotencyKey":{"type":"string","description":"Optional, strongly recommended. A key that stays the SAME across every retry of one upload and differs between uploads (a uuid generated when the post is composed). Re-sending an upload with a key that already published a post returns that post — `duplicate: true`, same `createdTokenId`, plus a fresh mint signature if it has not been minted yet — instead of publishing a second copy. Scoped to the caller. Ignored for a post that was already swept to \"failed\", which correctly gets a new attempt.","example":"b6f3c0f2-8a1e-4a1f-9c7b-2f5f0f2f9a11"},"contentRating":{"type":"string","enum":["safe","mature"],"description":"The creator's own rating. \"mature\" marks the post adult or graphic: it stays off the public home, shorts, search and suggestion feeds, and still reaches the creator's followers, their profile and anyone with the link. Viewers who switch \"show mature content\" on in settings see it in the public feeds too. Defaults to \"safe\" when omitted. A moderator can override this, after which the creator can no longer change it.","example":"safe"},"file":{"type":"string","format":"binary","description":"Media file(s) to upload"}}}}}},"responses":{"200":{"description":"Mint signature created - use these values for blockchain transaction","content":{"application/json":{"schema":{"type":"object","properties":{"r":{"type":"string","description":"Signature R component","example":"0x..."},"s":{"type":"string","description":"Signature S component","example":"0x..."},"v":{"type":"number","description":"Signature V component","example":27},"createdTokenId":{"type":"string","description":"Assigned token ID","example":"123"},"timestamp":{"type":"number","description":"Timestamp used in signature","example":1737158400}}}}}},"400":{"description":"Missing required fields or files"},"401":{"description":"Unauthorized"},"403":{"description":"User does not have posting permissions"},"503":{"description":"Video uploads are temporarily disabled"}},"security":[{"user-auth":[]}],"summary":"Upload new video/post and livestream","tags":["Videos"]}},"/api/liked_videos":{"get":{"description":"\nRetrieves all videos/posts that the authenticated user has liked.\nResponse format matches getFeed endpoint.\n\n### Authentication Required\nReturns likes for the logged-in user only.\n\n### Content Filter\n- Returns all content types (videos, feed-simple, feed-images, feed-audio) except live streams\n- Excludes deleted content (isDeleted=true)\n- Excludes hidden content (isHidden=true)\n\n### Pagination\nUses 1-based page numbering with `limit` for page size.\n    ","operationId":"NFTController_getLikedVideos","parameters":[{"name":"limit","required":false,"in":"query","description":"Items per page (default 20, max 100)","schema":{"example":20,"type":"number"}},{"name":"page","required":false,"in":"query","description":"Page number (1-based)","schema":{"example":1,"type":"number"}}],"responses":{"200":{"description":"Liked posts retrieved","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"result":{"type":"array","items":{"type":"object","properties":{"tokenId":{"type":"number","example":123},"name":{"type":"string","example":"My Video"},"description":{"type":"string"},"imageUrl":{"type":"string"},"videoUrl":{"type":"string"},"minter":{"type":"string"},"minterDisplayName":{"type":"string"},"minterAvatarUrl":{"type":"string"},"minterFollowers":{"type":"number"},"minterFollowings":{"type":"number"},"postType":{"type":"string","enum":["video","short","feed-simple","feed-images","feed-audio"]},"views":{"type":"number"},"likes":{"type":"number"},"isLiked":{"type":"boolean","example":true},"isDisliked":{"type":"boolean","example":false},"isSaved":{"type":"boolean"},"isFollowing":{"type":"boolean"},"isReposted":{"type":"boolean","description":"Whether the authenticated user has reposted this post"},"reposts":{"type":"number","description":"Total repost count"},"quotes":{"type":"number","description":"Total quote post count"},"isQuotePost":{"type":"boolean","description":"Whether this post is a quote post"},"quotedTokenId":{"type":"number","nullable":true,"description":"Token ID of the quoted post (only on quote posts)"},"quotedPost":{"type":"object","nullable":true,"description":"Embedded quoted post summary (only on quote posts)"},"likedAt":{"type":"string","format":"date-time","description":"When user liked this"}}}},"pagination":{"type":"object","properties":{"page":{"type":"number","example":1},"limit":{"type":"number","example":20},"totalCount":{"type":"number","example":50,"description":"Total liked items"},"totalPages":{"type":"number","example":3},"hasMore":{"type":"boolean","example":true}}}}}}}}},"security":[{"user-auth":[]}],"summary":"Get liked posts","tags":["Videos"]}},"/api/disliked_posts":{"get":{"description":"\nPosts an account disliked, newest dislike first. Same item shape as `liked_videos`, plus `dislikedAt`.\n\n- `address` — whose dislikes. Defaults to the signed-in account.\n- Private by default: only the owner gets the list. Anyone else gets **403** unless the\n  owner set the profile setting `customs.dislikesVisibility` to `public`.\n- Disliked posts are also left out of that account's own feeds (home, Following, Shorts,\n  suggestions). Undoing the dislike brings the post back.\n    ","operationId":"NFTController_getDislikedPosts","parameters":[{"name":"limit","required":false,"in":"query","schema":{"example":20,"type":"number"}},{"name":"page","required":false,"in":"query","schema":{"example":1,"type":"number"}},{"name":"address","required":false,"in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"Disliked posts retrieved"},"403":{"description":"Dislikes are private"}},"security":[{"user-auth":[]}],"summary":"Get disliked posts (profile Dislikes tab)","tags":["Videos"]}},"/api/nft_info/{id}":{"get":{"description":"\nRetrieves complete details for a single video by token ID.\n\n### Visibility Rules\n- **Deleted posts**: Return 404 \"Post not found\" for everyone (including the owner)\n- **Hidden posts**: Return 404 \"Post not found\" for everyone except the owner/minter\n- **Private accounts**: Return 403 if the viewer doesn't follow the creator\n\n### Includes:\n- Full video metadata (title, description, etc.)\n- Creator info (minterDisplayName, minterAvatarUrl, minterStaked)\n- View/like/comment counts\n- Media URLs (imageUrl, videoUrl)\n- Monetization settings (streamInfo)\n- Subscription plans (plansDetails)\n- First page of comments (up to 50), same rows as GET /nft/:tokenId/comments — `creatorLiked` and `creatorReplied` included\n\n### 🔐 User-Specific Data (Authentication)\nTo get user-specific data (isLiked, isDisliked, isSaved, and isLiked on comments), send a **JWT Bearer token** in the `Authorization` header.\nThe `address` query param is **deprecated and ignored** — viewer identity is extracted from the token.\n\n### Comment Sharing\nPass `commentId` to highlight a specific comment. The highlighted comment will appear as the first item in the comments array.\nIf the comment doesn't exist, a placeholder with `notFound: true` is returned in its position.\n    ","operationId":"NFTController_nftinfo","parameters":[{"name":"sort","required":false,"in":"query","description":"Order of the embedded comments: `recent` (default, newest first) or `top` (ranked by reception) — same contract as GET /nft/:tokenId/comments.","schema":{"enum":["recent","top"],"type":"string"}},{"name":"topTipped","required":false,"in":"query","description":"Comma-separated comment ids, best-tipped first (max 5). Floated to the top of the comment list, under the creator's pinned comment — same contract as GET /nft/:tokenId/comments.","schema":{"example":"456,789","type":"string"}},{"name":"commentId","required":false,"in":"query","description":"Comment ID to highlight (optional) - for sharing specific comments","schema":{"example":456,"type":"number"}},{"name":"address","required":false,"in":"query","description":"**DEPRECATED — ignored.** Viewer address is now extracted from the JWT Bearer token. Send `Authorization: Bearer <token>` instead.","deprecated":true,"schema":{"example":"0x1234567890abcdef1234567890abcdef12345678","type":"string"}},{"name":"id","required":true,"in":"path","description":"Token ID of the video","schema":{"example":"123","type":"string"}}],"responses":{"200":{"description":"Video details retrieved","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"result":{"type":"object","properties":{"tokenId":{"type":"number","example":123},"name":{"type":"string","example":"My Awesome Video"},"description":{"type":"string"},"imageUrl":{"type":"string","example":"images/123.png"},"videoUrl":{"type":"string","example":"videos/123"},"minter":{"type":"string","example":"0x1234..."},"minterDisplayName":{"type":"string"},"minterAvatarUrl":{"type":"string"},"minterFollowers":{"type":"number","description":"Number of followers"},"minterFollowings":{"type":"number","description":"Number of followings"},"minterStaked":{"type":"number","description":"Badge balance (walletBalance + staked across all DHB chains). Same as minterUser.badgeBalance."},"minterUser":{"type":"object","description":"Nested minter user object","properties":{"address":{"type":"string"},"username":{"type":"string"},"displayName":{"type":"string"},"avatarImageUrl":{"type":"string"},"followers":{"type":"number"},"followings":{"type":"number"},"badgeBalance":{"type":"number","description":"Denormalized walletBalance + staked across all DHB chains (for badge tier)"},"sentTips":{"type":"number"},"receivedTips":{"type":"number"},"createdAt":{"type":"string","format":"date-time"}}},"postType":{"type":"string","enum":["video","short","feed-simple","feed-images","feed-audio","live"]},"category":{"type":"array","items":{"type":"string"}},"views":{"type":"number"},"likes":{"type":"number"},"commentCount":{"type":"number"},"videoDuration":{"type":"number","description":"Duration in seconds"},"totalVotes":{"type":"object","properties":{"for":{"type":"number"},"against":{"type":"number"}}},"streamInfo":{"type":"object","description":"Monetization settings"},"plansDetails":{"type":"array","items":{"type":"object"}},"status":{"type":"string","example":"minted"},"transcodingStatus":{"type":"string","example":"done"},"createdAt":{"type":"string","format":"date-time"},"ppvBuyerCount":{"type":"number","example":42,"description":"Number of unique addresses that purchased PPV access (only present for PPV content)"},"isLiked":{"type":"boolean","description":"Present when JWT Bearer token provided"},"isDisliked":{"type":"boolean","description":"Present when JWT Bearer token provided"},"isSaved":{"type":"boolean","description":"Present when JWT Bearer token provided"},"isFollowing":{"type":"boolean","description":"Whether the authenticated user follows the creator (present when JWT Bearer token provided)"},"isOwner":{"type":"boolean","description":"Whether the authenticated user is the creator of this content"},"isUnlocked":{"type":"boolean","description":"Whether the authenticated user has unlocked this PPV content (only present for PPV items)"},"isHidden":{"type":"boolean","description":"Whether the post is hidden from public view"},"isReposted":{"type":"boolean","description":"Whether the authenticated user has reposted this post (present when JWT Bearer token provided)"},"reposts":{"type":"number","example":5,"description":"Total repost count"},"quotes":{"type":"number","example":2,"description":"Total quote post count"},"isQuotePost":{"type":"boolean","description":"Whether this post is a quote post (repost with added commentary)"},"quotedTokenId":{"type":"number","nullable":true,"description":"Token ID of the quoted post (only present on quote posts)"},"quotedPost":{"type":"object","nullable":true,"description":"Embedded quoted post summary (only present on quote posts)","properties":{"tokenId":{"type":"number"},"name":{"type":"string"},"description":{"type":"string"},"imageUrl":{"type":"string"},"videoUrl":{"type":"string"},"minter":{"type":"string"},"minterDisplayName":{"type":"string"},"minterAvatarUrl":{"type":"string"},"postType":{"type":"string"},"likes":{"type":"number"},"views":{"type":"number"},"reposts":{"type":"number"},"quotes":{"type":"number"},"createdAt":{"type":"string","format":"date-time"}}}}}}}}}},"403":{"description":"Content is from a private account and viewer is not a follower"},"404":{"description":"Post not found — either does not exist, was deleted, or is hidden by the owner"}},"summary":"Get video details","tags":["Videos"]}},"/api/nft/{tokenId}/comments":{"get":{"description":"\nRetrieves comments for a specific video with pagination.\n\n### Comment Sharing\nPass `commentId` to highlight a specific comment (for sharing links).\nWhen provided on page 0, that comment will be the first item in the response.\nIf the comment doesn't exist, a placeholder with `notFound: true` is returned.\n\n### Like Information\n- `likeCount`: Number of likes on each comment\n- `isLiked`: Whether the viewer has liked the comment (requires `address` param)\n\n### Pagination\nSupports both `skip/limit` and `page/unit` styles.\n\n### Ordering\nNewest first, with four lifts above it, read in this order:\n1. The creator's **pinned** comment (`isPinned`), set by `POST /pin_comment`.\n2. A paid **Comment Anchor**, bought with the `comment_anchor` SuperPower.\n3. The **top tipped** comments, passed in as `topTipped`.\n4. Threads the **creator replied** in (`creatorReplied` on the top-level\n   comment), most recently answered first. The creator's replies in those\n   threads come on the same page so the answer is visible with the lift.\n\n### `sort=top`\nPass `sort=top` to rank by reception instead of age. The pin, the anchor and\nthe top tipped keep their spots (1–3 above); everything under them is ordered\nby a score, then newest first:\n\n```\nbase     = likes − dislikes\n         + 3 if the creator liked it (creatorLiked)\n         + 3 if the creator replied in its thread, or it is that reply\n         + 3 if it is one of topTipped\ntopScore = base / (ageHours + 2) ^ 1.5\n```\n\nA **folded** comment — at least 10 dislikes and at least 3 dislikes per like —\nsorts below every comment that is not, whatever its score. The creator-reply\nlift (4) is not applied under `top`; a creator reply counts toward the score\ninstead. `sort=recent`, or no `sort` at all, is the order above unchanged.\n\n### Creator's like\n`creatorLiked: true` marks a comment the post's creator reacted to\npositively. Absent otherwise. It is the only vote on a comment that is\npublic; who else voted stays private.\n\nIn Kids Mode only comments written from Kids Mode are returned.\n\n`topTipped` is a comma-separated list of comment ids, best-tipped first, max\n5. Comment tips are not held here — they are `tip_records` rows the client\nwrites after confirming the transaction — so the client sends the ids it has\nalready fetched and the server floats them onto page 0. Omitting it changes\nnothing else about the response.\n    ","operationId":"NFTController_getComments","parameters":[{"name":"address","required":false,"in":"query","description":"Viewer wallet address (for isLiked field)","schema":{}},{"name":"sort","required":false,"in":"query","description":"`recent` (default): newest first under the lifts. `top`: ranked by reception under the pin, anchor and tip lifts — see Ordering.","schema":{"enum":["recent","top"],"type":"string"}},{"name":"topTipped","required":false,"in":"query","description":"Comma-separated comment ids, best-tipped first (max 5), floated to the top of page 0","schema":{"example":"456,789"}},{"name":"commentId","required":false,"in":"query","description":"Highlight specific comment (for sharing)","schema":{"example":456}},{"name":"limit","required":false,"in":"query","description":"Items per page","schema":{"example":20}},{"name":"page","required":false,"in":"query","description":"Page number (0-based)","schema":{"example":0}},{"name":"tokenId","required":true,"in":"path","description":"Token ID of the video","schema":{"example":123}}],"responses":{"200":{"description":"Comments retrieved","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"number","example":456},"address":{"type":"string"},"content":{"type":"string"},"imageUrl":{"type":"string","nullable":true,"description":"Uploaded image URL (CDN-hosted)"},"gifUrl":{"type":"string","nullable":true,"description":"External GIF URL from Giphy/Tenor","example":"https://media.giphy.com/media/abc123/giphy.gif"},"audioUrl":{"type":"string","nullable":true,"description":"Voice note URL (CDN-hosted)"},"audioDuration":{"type":"number","nullable":true,"description":"Voice note duration in seconds (max 30)"},"videoUrl":{"type":"string","nullable":true,"description":"Attached video clip URL (CDN-hosted)"},"createdAt":{"type":"string"},"parentId":{"type":"number","nullable":true},"replyIds":{"type":"array","items":{"type":"number"}},"likeCount":{"type":"number","example":5},"isLiked":{"type":"boolean","description":"Present when JWT Bearer token provided"},"isPinned":{"type":"boolean","description":"The post creator pinned this comment to the top of the thread"},"creatorReplied":{"type":"boolean","description":"Top-level comment the post creator has replied to somewhere in its thread. Absent otherwise."},"creatorLiked":{"type":"boolean","description":"The post creator reacted positively to this comment. Absent otherwise."},"notFound":{"type":"boolean","description":"True if commentId was requested but not found"},"user":{"type":"object","description":"User reference with profile info","properties":{"address":{"type":"string"},"username":{"type":"string"},"displayName":{"type":"string"},"avatarImageUrl":{"type":"string"},"followers":{"type":"number"},"followings":{"type":"number"},"sentTips":{"type":"number"},"receivedTips":{"type":"number"},"createdAt":{"type":"string","format":"date-time"}}}}}},"totalCount":{"type":"number"},"skip":{"type":"number"},"limit":{"type":"number"},"hasMore":{"type":"boolean"}}}}}}}}},"summary":"Get video comments","tags":["Videos"]}},"/api/nft/{tokenId}/tip_wall":{"get":{"description":"Top-level comments that were sent with a tip, biggest tip first (DHB at the sale peg), earlier tip first on a tie. Each row has the comment shape plus `tip: { amount, symbol, chainId, txHash, value }`.","operationId":"NFTController_getTipWall","parameters":[{"name":"address","required":false,"in":"query","description":"Viewer wallet address (for isLiked field)","schema":{}}],"responses":{"200":{"description":""}},"summary":"Get the tip wall of a post","tags":["Videos"]}},"/api/top_comments":{"get":{"description":"\nFor a page of feed posts, the single comment worth showing under each one.\nOne request per page, not one per post.\n\n### Which comment\nPer post, the first of these that exists:\n1. `pinned` — the comment the creator pinned (top-level).\n2. `creatorReplied` — the top-level comment (not the creator's own) that the\n   creator most recently replied to anywhere in its thread. That reply comes\n   with it as `creatorReply`.\n3. `top` — the most-liked top-level comment, with at least 2 likes and not\n   folded (at least 10 dislikes and at least 3 dislikes per like).\n\nOnly visible comments with text are chosen, and Kids Mode applies exactly as\nit does on GET /nft/:tokenId/comments. Hidden and deleted posts, and posts\nwith nothing to show, are absent from `result` rather than null.\n\n### Shape\n`comment` and `creatorReply` carry the same field names as a row from\nGET /nft/:tokenId/comments (`id`, `likeCount`, `writor`, `user`), so the\nexisting comment mapper reads them. `id` is always a string.\n\nCached for up to a minute per post.\n    ","operationId":"NFTController_topComments","parameters":[{"name":"address","required":false,"in":"query","description":"Viewer wallet address — only used to resolve Kids Mode when no JWT is sent","schema":{}},{"name":"tokenIds","required":true,"in":"query","description":"Comma-separated post token ids, max 30. Non-numeric entries are ignored and duplicates collapse.","schema":{"example":"101,102,103"}}],"responses":{"200":{"description":"Map of token id to the comment to show. Posts with nothing to show are omitted.","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"object","example":{"101":{"kind":"creatorReplied","comment":{"id":"5521","content":"How did you light this?","createdAt":"2026-09-28T10:00:00.000Z","likeCount":14,"address":"0xfan…","creatorLiked":true,"writor":{"username":"fan","avatarUrl":"https://…"}},"creatorReply":{"id":"5530","content":"One softbox, left side","createdAt":"2026-09-28T11:00:00.000Z","likeCount":3,"address":"0xcreator…","creatorLiked":false,"writor":{"username":"creator"}}}},"additionalProperties":{"type":"object","properties":{"kind":{"type":"string","enum":["pinned","creatorReplied","top"]},"comment":{"type":"object","properties":{"id":{"type":"string","example":"5521"},"content":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"likeCount":{"type":"number","example":14},"address":{"type":"string"},"creatorLiked":{"type":"boolean","description":"The post creator reacted positively to this comment"},"writor":{"type":"object","properties":{"username":{"type":"string"},"displayName":{"type":"string"},"avatarUrl":{"type":"string","description":"Absolute avatar URL"}}},"user":{"type":"object","description":"Author reference — the same projection GET /nft/:tokenId/comments returns as `user`"}}},"creatorReply":{"type":"object","properties":{"id":{"type":"string","example":"5521"},"content":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"likeCount":{"type":"number","example":14},"address":{"type":"string"},"creatorLiked":{"type":"boolean","description":"The post creator reacted positively to this comment"},"writor":{"type":"object","properties":{"username":{"type":"string"},"displayName":{"type":"string"},"avatarUrl":{"type":"string","description":"Absolute avatar URL"}}},"user":{"type":"object","description":"Author reference — the same projection GET /nft/:tokenId/comments returns as `user`"}},"description":"Only on `creatorReplied`: the creator's reply in that thread"}}}}}}}}}},"summary":"One comment to show under each post in a feed","tags":["Videos"]}},"/api/unlocked_nfts/{id}":{"get":{"description":"\nRetrieves PPV (Pay-Per-View) videos that a user has purchased/unlocked.\n\n### Use Cases:\n- Display purchased content library\n- Check access to specific content\n    ","operationId":"NFTController_unlockedNft","parameters":[{"name":"id","required":true,"in":"path","description":"Wallet address of the user","schema":{"example":"0x1234567890abcdef1234567890abcdef12345678"}}],"responses":{"200":{"description":"Unlocked videos retrieved","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"result":{"type":"array","items":{"type":"number"},"description":"Array of unlocked token IDs"}}}}}}},"summary":"Get unlocked PPV videos","tags":["Videos"]}},"/api/record-view/{tokenId}":{"get":{"description":"\nRecords that a user has viewed content (video, image post, or text post). Used for:\n- **Unique view count** (`views` field) - counts once per user\n- **Total impressions** (`totalImpressions` field) - counts every view\n- Watch history tracking\n- Analytics\n\n### Rate Limiting\nOne counted view per user per content per 30 minutes. A repeat view after that counts again.\n\n### Response\n```json\n{\n  \"success\": true,\n  \"isNewView\": true,  // false if user has viewed before\n  \"views\": 150,       // unique view count\n  \"totalImpressions\": 420  // total impressions\n}\n```\n    ","operationId":"NFTController_recordWatchVideo","parameters":[{"name":"tokenId","required":true,"in":"path","description":"Token ID of the content being viewed","schema":{"example":"123","type":"string"}}],"responses":{"200":{"description":"View recorded successfully"},"429":{"description":"Rate limited - too many requests"}},"security":[{"user-auth":[]}],"summary":"Record content view","tags":["Videos"]}},"/api/record-listen/{tokenId}":{"get":{"description":"\nRecords that a user has listened to a feed-audio post. Used for:\n- **Unique listen count** (`listens` field) — counts once per user\n- Analytics / discovery ranking\n\nOnly works on tokens with `postType: 'feed-audio'`. Returns 404 for other post types.\n\n### Rate Limiting\nRate-limited to 1 per user per audio per 30 seconds.\n\n### Response\n```json\n{\n  \"success\": true,\n  \"isNewListen\": true,\n  \"listens\": 42\n}\n```\n    ","operationId":"NFTController_recordListen","parameters":[{"name":"tokenId","required":true,"in":"path","description":"Token ID of the audio post","schema":{"example":"123","type":"string"}}],"responses":{"200":{"description":"Listen recorded","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"isNewListen":{"type":"boolean","example":true,"description":"Whether this is the first listen by this user"},"listens":{"type":"number","example":42,"description":"Total unique listen count"},"rateLimited":{"type":"boolean","example":false,"description":"True if rate-limited (cached response)"}}}}}},"404":{"description":"Audio post not found"}},"security":[{"user-auth":[]}],"summary":"Record audio post listen","tags":["Videos"]}},"/api/view/batch":{"post":{"description":"\nRecord views for multiple content items in a single request.\n\n### Use Case\nOptimized for feed scrolling where users see multiple posts at once.\nInstead of calling /view/:tokenId for each post, send all token IDs in one batch.\n\n### Performance\n- Single API call instead of N calls\n- Redis pipeline for batch operations\n- MongoDB bulkWrite for efficient updates\n- Rate limiting still applies per token (30 minutes)\n\n### Limits\n- Maximum 50 token IDs per batch\n- Rate limited tokens are skipped (not counted as errors)\n\n### Request Body\n```json\n{\n  \"tokenIds\": [123, 456, 789]\n}\n```\n\n### Response\n```json\n{\n  \"success\": true,\n  \"processed\": 3,         // Number of tokens processed\n  \"newUniqueViews\": 2,    // First-time views recorded\n  \"rateLimited\": 0        // Tokens skipped due to rate limit\n}\n```\n    ","operationId":"NFTController_recordBatchViews","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tokenIds"],"properties":{"tokenIds":{"type":"array","items":{"type":"number"},"example":[123,456,789],"description":"Array of token IDs to record views for (max 50)"},"source":{"type":"string","enum":["agent"],"description":"Optional. Tags the views as agent traffic (Token.agentViews, WatchHistory.source). Counts are unchanged."}}}}}},"responses":{"200":{"description":"Batch views recorded successfully"}},"security":[{"user-auth":[]}],"summary":"Record batch views (optimized for feeds)","tags":["Videos"]}},"/api/view/anonymous":{"post":{"description":"\nServer-to-server ingest for views by visitors with no DeHub session.\n\n### Authentication\nNot a user route — there is no wallet behind these views. The caller presents\nthe shared secret `VIEW_SERVICE_KEY` in an `x-service-key` header. The only\ncaller is the `anon-views` Supabase edge function.\n\n### Deduplication\nDone by the caller, which identifies a signed-out viewer by a salted hash of\ndevice id + IP. It splits what it sends: `tokenIds` are posts that viewer had\nnot been counted on, `repeatTokenIds` are posts they had. With `viewerKey`\n(that hash), repeats get the same 30-minute per-viewer-per-post cooldown as\nsigned-in views.\n\n### Effect\n`tokenIds` increment `anonViews` and `totalViews`. `repeatTokenIds`\nincrement `repeatViews` and `totalViews` — a signed-out repeat is a view for\nthe same reason a signed-in one is. Both leave `views` (signed-in unique\nviewers) and `totalImpressions` alone.\n\n### Request Body\n```json\n{\n  \"tokenIds\": [123, 456],\n  \"repeatTokenIds\": [789],\n  \"viewerKey\": \"<64-char hex>\"\n}\n```\n    ","operationId":"NFTController_recordAnonymousViews","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tokenIds"],"properties":{"tokenIds":{"type":"array","items":{"type":"number"},"example":[123,456],"description":"Token IDs that gained a new anonymous viewer (max 50)"},"repeatTokenIds":{"type":"array","items":{"type":"number"},"example":[789],"description":"Token IDs viewed again by a viewer already counted there (max 50)"},"viewerKey":{"type":"string","description":"Salted hex SHA-256 identifying the signed-out viewer, for the repeat cooldown"}}}}}},"responses":{"200":{"description":"Anonymous views recorded"},"401":{"description":"Missing or invalid service key"},"503":{"description":"VIEW_SERVICE_KEY not configured"}},"summary":"Record views from signed-out visitors (service-to-service)","tags":["Videos"]}},"/api/savePost":{"post":{"description":"\nSave a video to your personal \"saved posts\" collection for later viewing.\n\n### Authentication Required\nThe user's wallet address is extracted from the JWT token (via AuthGuard).\nVideos are saved to the logged-in user's collection.\n\n### Toggle Behavior\nCalling this endpoint toggles the saved state:\n- If not saved → saves the post\n- If already saved → removes from saved\n    ","operationId":"NFTController_savePost","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tokenId"],"properties":{"tokenId":{"type":"number","example":123,"description":"Token ID of the video to save/unsave"}}}}}},"responses":{"200":{"description":"Post saved/unsaved successfully","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"saved":{"type":"boolean","description":"Current saved state (true=saved, false=removed)"}}}}}},"400":{"description":"Missing tokenId"},"401":{"description":"Unauthorized"}},"security":[{"user-auth":[]}],"summary":"Save video to collection","tags":["Videos"]}},"/api/savedPosts":{"get":{"description":"\nRetrieves all videos/posts the authenticated user has saved to their collection.\nResponse format matches getFeed endpoint.\n\n### Authentication Required\nReturns saved posts for the logged-in user only.\n\n### Content Filter\n- Returns all content types (videos, feed-simple, feed-images, feed-audio) except live streams\n- Excludes deleted content (isDeleted=true)\n- Excludes hidden content (isHidden=true)\n\n### Pagination\nUses 1-based page numbering with `limit` for page size.\n    ","operationId":"NFTController_getSaved","parameters":[{"name":"limit","required":false,"in":"query","description":"Items per page (default 20, max 100)","schema":{"example":20,"type":"number"}},{"name":"page","required":false,"in":"query","description":"Page number (1-based)","schema":{"example":1,"type":"number"}}],"responses":{"200":{"description":"Saved posts retrieved","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"result":{"type":"array","items":{"type":"object","properties":{"tokenId":{"type":"number","example":123},"name":{"type":"string","example":"My Video"},"description":{"type":"string"},"imageUrl":{"type":"string"},"videoUrl":{"type":"string"},"minter":{"type":"string"},"minterDisplayName":{"type":"string"},"minterAvatarUrl":{"type":"string"},"minterFollowers":{"type":"number"},"minterFollowings":{"type":"number"},"postType":{"type":"string","enum":["video","short","feed-simple","feed-images","feed-audio"]},"views":{"type":"number"},"likes":{"type":"number"},"isLiked":{"type":"boolean"},"isDisliked":{"type":"boolean"},"isSaved":{"type":"boolean","example":true,"description":"Always true for saved posts"},"isFollowing":{"type":"boolean"},"isReposted":{"type":"boolean","description":"Whether the authenticated user has reposted this post"},"reposts":{"type":"number","description":"Total repost count"},"quotes":{"type":"number","description":"Total quote post count"},"isQuotePost":{"type":"boolean","description":"Whether this post is a quote post"},"quotedTokenId":{"type":"number","nullable":true,"description":"Token ID of the quoted post (only on quote posts)"},"quotedPost":{"type":"object","nullable":true,"description":"Embedded quoted post summary (only on quote posts)"},"savedAt":{"type":"string","format":"date-time","description":"When user saved this"},"createdAt":{"type":"string","format":"date-time"}}}},"pagination":{"type":"object","properties":{"page":{"type":"number","example":1},"limit":{"type":"number","example":20},"totalCount":{"type":"number","example":50,"description":"Total saved items"},"totalPages":{"type":"number","example":3},"hasMore":{"type":"boolean","example":true}}}}}}}}},"security":[{"user-auth":[]}],"summary":"Get saved posts","tags":["Videos"]}},"/api/unlockedPosts":{"get":{"description":"\nRetrieves all PPV (pay-per-view) posts the authenticated user has purchased/unlocked.\nResponse format matches getFeed endpoint.\n\n### Authentication Required\nReturns unlocked posts for the logged-in user only.\n\n### Lifetime Unlock\nPPV unlocks are permanent — once purchased, access never expires.\n\n### Content Details\n- Each item includes an `unlockedAt` timestamp showing when the user purchased access\n- All items have `isUnlocked: true`\n- Sorted by most recently unlocked first\n- Duplicate purchases of the same post are deduplicated (most recent kept)\n\n### Pagination\nUses 1-based page numbering with `limit` for page size.\n    ","operationId":"NFTController_getUnlockedPosts","parameters":[{"name":"limit","required":false,"in":"query","description":"Items per page (default 20, max 100)","schema":{"example":20,"type":"number"}},{"name":"page","required":false,"in":"query","description":"Page number (1-based)","schema":{"example":1,"type":"number"}}],"responses":{"200":{"description":"Unlocked posts retrieved","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"result":{"type":"array","items":{"type":"object","properties":{"tokenId":{"type":"number","example":123},"name":{"type":"string","example":"Exclusive Content"},"description":{"type":"string"},"imageUrl":{"type":"string"},"videoUrl":{"type":"string"},"minter":{"type":"string"},"minterDisplayName":{"type":"string"},"minterAvatarUrl":{"type":"string"},"minterFollowers":{"type":"number"},"minterFollowings":{"type":"number"},"postType":{"type":"string","enum":["video","short","feed-simple","feed-images","feed-audio"]},"views":{"type":"number"},"likes":{"type":"number"},"isLiked":{"type":"boolean"},"isDisliked":{"type":"boolean"},"isSaved":{"type":"boolean"},"isFollowing":{"type":"boolean"},"isUnlocked":{"type":"boolean","example":true,"description":"Always true for unlocked posts"},"isReposted":{"type":"boolean"},"unlockedAt":{"type":"string","format":"date-time","description":"When user purchased/unlocked this post"},"createdAt":{"type":"string","format":"date-time"}}}},"pagination":{"type":"object","properties":{"page":{"type":"number","example":1},"limit":{"type":"number","example":20},"totalCount":{"type":"number","example":5,"description":"Total active unlocked posts"},"totalPages":{"type":"number","example":1},"hasMore":{"type":"boolean","example":false}}}}}}}}},"security":[{"user-auth":[]}],"summary":"Get unlocked (purchased) posts","tags":["Videos"]}},"/api/myPosts":{"get":{"description":"\nRetrieves all posts created by the authenticated user.\nResponse format matches getFeed endpoint.\n\n### Authentication Required\nReturns posts created by the logged-in user only.\n\n### Content Filter\n- Returns all content types (videos, feed-simple, feed-images, feed-audio) except live streams\n- Includes both **minted** and **pending** posts (so user can see uploads in progress)\n- Excludes deleted content (isDeleted=true)\n- **Includes hidden content** (so user can see their own hidden posts)\n\n### Pagination\nUses 1-based page numbering with `limit` for page size.\n    ","operationId":"NFTController_getMyPosts","parameters":[{"name":"limit","required":false,"in":"query","description":"Items per page (default 20, max 100)","schema":{"example":20,"type":"number"}},{"name":"page","required":false,"in":"query","description":"Page number (1-based)","schema":{"example":1,"type":"number"}}],"responses":{"200":{"description":"User posts retrieved","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"result":{"type":"array","items":{"type":"object","properties":{"tokenId":{"type":"number","example":123},"name":{"type":"string","example":"My Video"},"description":{"type":"string"},"imageUrl":{"type":"string"},"videoUrl":{"type":"string"},"minter":{"type":"string"},"minterDisplayName":{"type":"string"},"minterAvatarUrl":{"type":"string"},"minterFollowers":{"type":"number"},"minterFollowings":{"type":"number"},"postType":{"type":"string","enum":["video","short","feed-simple","feed-images","feed-audio"]},"status":{"type":"string","enum":["minted","pending"],"description":"Post status - pending for uploads in progress"},"isHidden":{"type":"boolean","description":"Whether the post is hidden from public view"},"views":{"type":"number"},"likes":{"type":"number"},"isLiked":{"type":"boolean"},"isDisliked":{"type":"boolean"},"isSaved":{"type":"boolean"},"isFollowing":{"type":"boolean","example":false,"description":"Always false for own posts"},"isReposted":{"type":"boolean","description":"Whether the authenticated user has reposted this post"},"reposts":{"type":"number","description":"Total repost count"},"quotes":{"type":"number","description":"Total quote post count"},"isQuotePost":{"type":"boolean","description":"Whether this post is a quote post"},"quotedTokenId":{"type":"number","nullable":true,"description":"Token ID of the quoted post (only on quote posts)"},"quotedPost":{"type":"object","nullable":true,"description":"Embedded quoted post summary (only on quote posts)"},"createdAt":{"type":"string","format":"date-time"}}}},"pagination":{"type":"object","properties":{"page":{"type":"number","example":1},"limit":{"type":"number","example":20},"totalCount":{"type":"number","example":50,"description":"Total user posts"},"totalPages":{"type":"number","example":3},"hasMore":{"type":"boolean","example":true}}}}}}}}},"security":[{"user-auth":[]}],"summary":"Get my posts","tags":["Videos"]}},"/api/suggested/{tokenId}":{"get":{"description":"\n## Overview\nReturns a curated list of suggested / \"Up Next\" content based on the post currently being viewed, similar to YouTube's sidebar or TikTok's autoplay queue.\nWorks for both authenticated and unauthenticated users — authenticated users get **personalised** recommendations.\n\n## Post Type\nBy default, suggestions return **videos only** (backward compatible). Pass the `postType` query parameter to get recommendations for a specific content type, or use `all` to get mixed content.\n\n| Value | Description |\n|-------|-------------|\n| `video` | Videos only (default when omitted) |\n| `feed-images` | Image posts |\n| `feed-simple` | Text posts |\n| `feed-audio` | Audio posts |\n| `live` | Livestreams |\n| `all` | All post types mixed |\n\n## 🧠 Algorithm\nThe endpoint scores candidates from four weighted lanes, then merges and ranks them:\n\n| Lane | Weight | Signal |\n|------|--------|--------|\n| **Same category** | 50 % | Videos sharing ≥ 1 category with the current video |\n| **Same creator** | 20 % | Other videos from the same creator |\n| **Personalised** | 20 % | Videos matching categories the viewer has liked or watched (auth-only) |\n| **Trending** | 10 % | Globally trending content for serendipity / diversity |\n\nVideos appearing in **multiple lanes** accumulate scores and rank higher.\nResults are deterministic and stable across pages.\n\n## 🔐 Authentication\n- **Optional.** Send `Authorization: Bearer <jwt-token>` for:\n  - Personalised recommendations based on watch history & likes\n  - Viewer interaction flags (`isLiked`, `isSaved`, `isFollowing`, etc.)\n  - Privacy-aware filtering (see private creators you follow)\n- Without auth, suggestions are based purely on the source video's attributes + trending.\n\n## 🚀 Quick Start\n\n**Basic (no auth):**\n```\nGET /suggested/456\n```\n\n**Audio suggestions:**\n```\nGET /suggested/456?postType=feed-audio\n```\n\n**All post types:**\n```\nGET /suggested/456?postType=all\n```\n\n**Paginated:**\n```\nGET /suggested/456?page=2&limit=15\n```\n\n**With viewer context:**\n```\nAuthorization: Bearer <jwt-token>\nGET /suggested/456\n```\n\n## 📄 Response Structure\nSame token shape as the `/feed` endpoint, including `minterUser`, `plansDetails`, `stream`, `quotedPost`, and all viewer flags when authenticated.\n    ","operationId":"NFTController_getSuggestedVideos","parameters":[{"name":"postType","required":false,"in":"query","description":"Filter suggestions by post type. Defaults to \"video\" when omitted (backward compatible). Use \"all\" for mixed content.","schema":{"enum":["video","short","feed-images","feed-simple","feed-audio","live","all"],"type":"string"}},{"name":"limit","required":false,"in":"query","description":"Items per page (1-100). Default: 20","schema":{"example":20,"type":"number"}},{"name":"page","required":false,"in":"query","description":"Page number (1-indexed). Default: 1","schema":{"example":1,"type":"number"}},{"name":"tokenId","required":true,"in":"path","description":"Token ID of the video currently being watched","schema":{"example":456,"type":"number"}}],"responses":{"200":{"description":"Suggested videos retrieved successfully","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"result":{"type":"array","description":"Ranked array of suggested videos","items":{"type":"object","properties":{"tokenId":{"type":"number","example":789},"name":{"type":"string","example":"Related Video Title"},"description":{"type":"string","example":"A related video description"},"imageUrl":{"type":"string","example":"images/789.png"},"videoUrl":{"type":"string","nullable":true,"example":"videos/789"},"minter":{"type":"string","example":"0x1234567890abcdef..."},"postType":{"type":"string","enum":["video","short","feed-simple","feed-images","feed-audio","live"],"example":"video"},"status":{"type":"string","enum":["minted"],"example":"minted"},"category":{"type":"array","items":{"type":"string"},"example":["gaming","entertainment"]},"views":{"type":"number","example":8200},"likes":{"type":"number","example":320},"commentCount":{"type":"number","example":45},"videoDuration":{"type":"number","nullable":true,"example":210},"totalVotes":{"type":"object","properties":{"for":{"type":"number","example":320},"against":{"type":"number","example":5}}},"totalTips":{"type":"number","example":1500},"ppvBuyerCount":{"type":"number","example":10},"streamInfo":{"type":"object"},"plansDetails":{"type":"array","items":{"type":"object","properties":{"id":{"type":"number"},"title":{"type":"string"},"price":{"type":"number"},"alreadySubscribed":{"type":"boolean"}}}},"minterUsername":{"type":"string","example":"gamer123"},"minterDisplayName":{"type":"string","example":"Gamer 123"},"minterAvatarUrl":{"type":"string","example":"avatars/gamer.jpg"},"minterAboutMe":{"type":"string","example":"Creator bio"},"minterFollowers":{"type":"number","example":2500},"minterFollowings":{"type":"number","example":300},"minterStaked":{"type":"number","example":75000},"minterUser":{"type":"object","description":"Nested minter user object","properties":{"address":{"type":"string"},"username":{"type":"string"},"displayName":{"type":"string"},"avatarImageUrl":{"type":"string"},"followers":{"type":"number"},"followings":{"type":"number"},"sentTips":{"type":"number"},"receivedTips":{"type":"number"},"badgeBalance":{"type":"number"},"createdAt":{"type":"string","format":"date-time"}}},"stream":{"type":"object","nullable":true,"description":"LiveStream data (only for live postType)","properties":{"status":{"type":"string","enum":["offline","live","scheduled","ended"]},"isActive":{"type":"boolean"},"playbackId":{"type":"string"},"peakViewers":{"type":"number"},"totalViews":{"type":"number"}}},"reposts":{"type":"number","example":3},"quotes":{"type":"number","example":1},"totalReposts":{"type":"number","example":4},"isQuotePost":{"type":"boolean"},"quotedTokenId":{"type":"number","nullable":true},"quotedPost":{"type":"object","nullable":true,"description":"Embedded quoted post summary (only on quote posts)"},"isLiked":{"type":"boolean","description":"Present when authenticated"},"isDisliked":{"type":"boolean","description":"Present when authenticated"},"isSaved":{"type":"boolean","description":"Present when authenticated"},"isFollowing":{"type":"boolean","description":"Present when authenticated"},"isOwner":{"type":"boolean","description":"Present when authenticated"},"isUnlocked":{"type":"boolean","description":"Present when authenticated (PPV only)"},"isReposted":{"type":"boolean","description":"Present when authenticated"},"isHidden":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time"}}}},"pagination":{"type":"object","description":"Pagination metadata","properties":{"page":{"type":"number","example":1},"limit":{"type":"number","example":20},"totalCount":{"type":"number","example":85,"description":"Total suggested candidates"},"totalPages":{"type":"number","example":5},"hasMore":{"type":"boolean","example":true}}}}}}}},"400":{"description":"Invalid tokenId","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":false},"error":{"type":"string","example":"Valid tokenId is required"}}}}}},"404":{"description":"Source video not found","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":false},"error":{"type":"string","example":"Video not found"}}}}}},"500":{"description":"Server error","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":false},"error":{"type":"string","example":"Failed to fetch suggestions"}}}}}}},"summary":"🎯 Get suggested content for a given post","tags":["Videos"]}},"/api/shop-links/allowance":{"get":{"description":"\nThree for everybody, plus one for every rung of the badge ladder — a Crab has\nfour, a Megalodon sixteen.\n\n**Served rather than derived.** The clients already hold a badge balance and\ncould do this arithmetic themselves, but they deliberately over-report a tier\nso a badge does not vanish mid-stake, and the ladder scales with the DHB price.\nA client-side count would therefore offer a creator a link slot the mint is\nabout to refuse. Ask here, and treat a failure as the base three.\n\nThe number is resolved from the authenticated account and honours a\ngrandfathered tier, a lent badge and a badge granted by name — the same\nresolution a reaction's weight uses. It is re-checked on every write, so this\nis a hint for the composer, never the enforcement.\n    ","operationId":"NFTController_getShopLinkAllowance","parameters":[],"responses":{"200":{"description":"Allowance","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"boolean","example":true},"allowance":{"type":"number","example":4},"base":{"type":"number","example":3},"max":{"type":"number","example":16},"tier":{"type":"string","nullable":true,"example":"Crab"}}}}}},"401":{"description":"Unauthorized"}},"security":[{"user-auth":[]}],"summary":"How many Shop links this account may publish","tags":["Videos"]}},"/api/mint_fee":{"get":{"description":"\nQuotes the fee for minting one post, in the token the creator pays it in.\n\nThe fee is the sponsored gas cost times a fixed markup, priced live — it moves\nwith gas rather than sitting in a table. Charged in DHB wherever DHB is\ndeployed, and in the chain's native gas token where it is not (Robinhood Chain\nuntil DHB is bridged there).\n\nOnly sponsored mints are charged. A creator on an external wallet sends the\nmint themselves and pays their own gas, so the clients skip this call entirely\nfor those sessions.\n\nQuotes are cached for `ttlSeconds`; re-quote rather than holding one open.\n    ","operationId":"NFTController_mintFee","parameters":[],"responses":{"200":{"description":"Fee quote"},"400":{"description":"Unsupported or non-minting chain"}},"summary":"What minting a post costs on a given chain","tags":["Videos"]}},"/api/newpost/{n}":{"get":{"description":"\nPosts published without minting live at dehub.io/newpost/<n> — their own\n1, 2, 3… sequence, separate from NFT token ids. This resolves that slug.\n\nThe mapping survives minting: a link shared while the post was off-chain keeps\nworking forever, and `minted: true` tells the client to land on\n/post/<tokenId> instead.\n    ","operationId":"NFTController_resolveNewPost","parameters":[],"responses":{"200":{"description":"Slug resolved"},"404":{"description":"No post with this slug"}},"summary":"Resolve an off-chain post slug to its post","tags":["Videos"]}},"/api/article/{slug}":{"get":{"description":"\nPosts live at dehub.io/app/post/<slug>, where the slug is the first ten\nwords of the title or description. This resolves that slug to the\ntokenId every other endpoint uses. Slugs are never rewritten, so a shared\nlink keeps working after a title edit.\n    ","operationId":"NFTController_resolveArticleSlug","parameters":[],"responses":{"200":{"description":"Slug resolved"},"404":{"description":"No article with this slug"}},"summary":"Resolve a post's readable URL to its post","tags":["Videos"]}},"/api/mint_existing":{"post":{"description":"\nRe-issues a mint signature for a post the caller already owns.\n\nPosts published with `mintOptOut` keep their tokenId and simply never went\non-chain, so minting one later needs nothing but a fresh signature — the\noriginal is timestamped and long expired. Submit the returned values exactly\nas you would for step 2 of /api/user_mint, then call /api/confirm-mint.\n\nPass `chainId` to mint on a different EVM chain than the post was published\non (GET /api/mint_fee prices each one). Solana is not offered here.\n\nThe post is left untouched until the mint confirms, so abandoning the\ntransaction is safe and can be retried.\n    ","operationId":"NFTController_mintExisting","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tokenId"],"properties":{"tokenId":{"type":"number","example":5123},"chainId":{"type":"number","example":8453,"description":"Chain to mint on. Defaults to the chain the post was published on."}}}}}},"responses":{"200":{"description":"Mint signature issued"},"403":{"description":"Caller does not own this post"},"409":{"description":"Post is already minted, or cannot be minted"}},"security":[{"user-auth":[]}],"summary":"Mint a post that was published off-chain","tags":["Videos"]}},"/api/keep_offchain":{"post":{"description":"\nMarks a post the caller owns as deliberately off-chain, so the expiry sweep\nleaves it alone.\n\nA post is live the moment /api/user_mint returns, at status `signed`, and the\nsweep marks any `signed` token older than three minutes failed unless it\ncarries `mintOptOut`. A creator whose wallet never answered therefore loses\nthe post as well as the mint. Clients call this when they stop waiting on the\nsignature; the post stays published and /api/mint_existing can put it on chain\nwhenever the creator wants.\n\nIdempotent, and safe to call on a mint that lands after all — a token past\n`signed` is left untouched, and the flag clears when a mint confirms.\n    ","operationId":"NFTController_keepOffChain","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tokenId"],"properties":{"tokenId":{"type":"number","example":5123},"chainId":{"type":"number","example":8453,"description":"Chain to mint on. Defaults to the chain the post was published on."}}}}}},"responses":{"200":{"description":"Post kept off-chain"},"403":{"description":"Caller does not own this post"},"404":{"description":"Post not found"}},"security":[{"user-auth":[]}],"summary":"Keep a published post whose mint never completed","tags":["Videos"]}},"/api/confirm-mint":{"post":{"description":"\nCall this immediately after submitting a mint transaction on-chain.\nThe backend sets the token to `status: 'checking'` and polls the RPC every minute\nto confirm the transaction — bypassing Alchemy webhook latency.\n\nOnce confirmed the token automatically moves to `status: 'minted'` and appears in feeds.\n    ","operationId":"NFTController_confirmMint","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tokenId","txHash"],"properties":{"tokenId":{"type":"number","example":123,"description":"Token ID returned from /user_mint"},"txHash":{"type":"string","example":"0xabc123...","description":"Transaction hash from the blockchain"},"chainId":{"type":"number","example":8453,"description":"Chain ID (default: 8453 Base)"}}}}}},"responses":{"200":{"description":"Token queued for confirmation polling"},"400":{"description":"Missing tokenId or txHash"},"404":{"description":"Token not found or not owned by caller"}},"security":[{"user-auth":[]}],"summary":"Notify backend that a mint tx was submitted","tags":["Videos"]}},"/api/nfts/audio/{id}/access":{"get":{"operationId":"AudioAccessController_grant","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["AudioAccess"]}},"/api/nfts/audio/{id}":{"head":{"operationId":"AudioAccessController_head","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["AudioAccess"]},"get":{"operationId":"AudioAccessController_stream","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["AudioAccess"]}},"/api/nfts/video/{id}/access":{"get":{"operationId":"VideoAccessController_grant","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["VideoAccess"]}},"/api/scheduled_posts":{"get":{"operationId":"ScheduledPostsController_list","parameters":[],"responses":{"200":{"description":""}},"security":[{"user-auth":[]}],"summary":"Your posts waiting to go out, soonest first","tags":["Scheduled posts"]}},"/api/scheduled_posts/{tokenId}":{"patch":{"description":"Body `{ scheduledAt }`: an ISO date between a minute and a year from now, or \"now\" to publish it within a minute.","operationId":"ScheduledPostsController_move","parameters":[{"name":"tokenId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"security":[{"user-auth":[]}],"summary":"Move a scheduled post","tags":["Scheduled posts"]}},"/api/scheduled_posts/live/{streamId}":{"patch":{"description":"Body `{ scheduledAt }`: an ISO date between a minute and a year from now. Going live early is the normal start flow.","operationId":"ScheduledPostsController_moveLive","parameters":[{"name":"streamId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"security":[{"user-auth":[]}],"summary":"Move a scheduled Live","tags":["Scheduled posts"]}},"/api/scheduled_posts/fans_online":{"get":{"description":"A 7 x 24 grid (UTC, Sunday first) of how many distinct followers watched something in each hour over the last 4 weeks. With too few active followers it falls back to everyone on DeHub (`basis: \"everyone\"`).","operationId":"ScheduledPostsController_fansOnline","parameters":[],"responses":{"200":{"description":""}},"security":[{"user-auth":[]}],"summary":"When your followers are on DeHub","tags":["Scheduled posts"]}},"/api/depin/stats":{"get":{"operationId":"DepinController_stats","parameters":[],"responses":{"200":{"description":""}},"tags":["Depin"]}},"/api/depin/me":{"get":{"operationId":"DepinController_me","parameters":[],"responses":{"200":{"description":""}},"tags":["Depin"]}},"/api/config/payments":{"get":{"description":"Returns DHB token, StreamController, and DEX router addresses per chain. Used by web/mobile for native-pay PPV (#44) and router atomic tx (#45). No API key required.","operationId":"PaymentController_getPaymentConfig","parameters":[],"responses":{"200":{"description":"Payment config returned"}},"summary":"Payment chain config for PPV / swap flows","tags":["Payments"]}},"/api/ppv/confirm":{"post":{"description":"\nCall immediately after submitting a PPV payment transaction on-chain.\nAccepts:\n- Direct `sendFundsForPPV` txs (#44)\n- `DeHubPaymentRouter.unlockPPVAndTip` txs (#45) — swap + PPV + optional tip in one tx\n\nBackend verifies `SendFunds(PPV)` on StreamController (resolves router-as-caller to the EOA payer),\noptionally validates `PaymentProcessed` from the payment router, then records unlock in `ppv_transactions`.\nTips in the same router tx are handled via Alchemy webhook `SendFunds(TIP)` with payer resolution.\n\nRPC polling bypasses webhook latency. The Graph is not used.\n    ","operationId":"PaymentController_confirmPPV","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tokenId","txHash"],"properties":{"tokenId":{"type":"number","example":12345,"description":"Stream/post token ID"},"txHash":{"type":"string","example":"0xabc123...","description":"sendFundsForPPV or unlockPPVAndTip transaction hash"},"chainId":{"type":"number","example":8453,"description":"Chain ID (default: 8453 Base)"}}}}}},"responses":{"200":{"description":"PPV confirmation queued or already unlocked"},"400":{"description":"Missing or invalid parameters"},"404":{"description":"Token not found"}},"security":[{"user-auth":[]}],"summary":"Confirm PPV unlock transaction","tags":["Payments"]}},"/api/support/meta":{"get":{"operationId":"SupportController_getMeta","parameters":[],"responses":{"200":{"description":""}},"security":[{"bearer":[]}],"summary":"Categories, severities and statuses a ticket can carry","tags":["Support"]}},"/api/support/tickets":{"get":{"description":"Every ticket this wallet has filed, open and closed, newest first. `resolution` is the note written for the reporter; internal admin notes are never selected.","operationId":"SupportController_listTickets","parameters":[{"name":"limit","required":false,"in":"query","description":"1–25, default 25","schema":{"type":"number"}}],"responses":{"200":{"description":"Tickets returned"}},"security":[{"bearer":[]}],"summary":"My support tickets","tags":["Support"]},"post":{"description":"Records the ticket first and mails it after, so a misconfigured mailer still leaves the reporter with a reference. Re-filing the same subject while an earlier ticket is still open returns that ticket rather than opening a second one.","operationId":"SupportController_createTicket","parameters":[],"responses":{"200":{"description":"Ticket filed (or an existing duplicate returned)"},"400":{"description":"Too thin to act on, or rate limited"}},"security":[{"bearer":[]}],"summary":"File a support ticket","tags":["Support"]}},"/api/dpay/price":{"get":{"operationId":"DehubPayController_getDehubPrice","parameters":[{"name":"currency","required":true,"in":"query","schema":{"type":"string"}},{"name":"chainId","required":true,"in":"query","schema":{"type":"number"}},{"name":"amount","required":true,"in":"query","schema":{"type":"number"}},{"name":"tokenSymbol","required":true,"in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Payments"]}},"/api/dpay/available/tokens":{"get":{"operationId":"DehubPayController_getAvailableTokens","parameters":[{"name":"token","required":true,"in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Payments"]}},"/api/dpay/available/gas":{"get":{"operationId":"DehubPayController_getAvailableGas","parameters":[{"name":"token","required":true,"in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Payments"]}},"/api/dpay/tnxs":{"get":{"operationId":"DehubPayController_getTnx","parameters":[],"responses":{"200":{"description":""}},"tags":["Payments"]}},"/api/dpay/checkout":{"post":{"operationId":"DehubPayController_checkout","parameters":[],"responses":{"201":{"description":""}},"tags":["Payments"]}},"/api/dpay/tk":{"post":{"operationId":"DehubPayController_createTicket","parameters":[],"responses":{"201":{"description":""}},"tags":["Payments"]}},"/api/dpay/webhook":{"post":{"operationId":"DehubPayController_stripeWebHook","parameters":[],"responses":{"201":{"description":""}},"tags":["Payments"]}},"/api/dpay/create-onramp-session":{"post":{"operationId":"DehubPayController_createOnrampSession","parameters":[],"responses":{"201":{"description":""}},"tags":["Payments"]}},"/api/dpay/price/{chainId}":{"get":{"operationId":"DehubPayController_fetchPriceByChain","parameters":[{"name":"chainId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Payments"]}},"/api/dpay/discount":{"get":{"operationId":"DehubPayController_fetchBadgeDiscount","parameters":[],"responses":{"200":{"description":""}},"tags":["Payments"]}},"/api/dpay/total":{"get":{"operationId":"DehubPayController_fetchTotal","parameters":[],"responses":{"200":{"description":""}},"tags":["Payments"]}},"/api/dpay/crypto/tokens":{"get":{"operationId":"DehubPayController_cryptoTokens","parameters":[],"responses":{"200":{"description":""}},"tags":["Payments"]}},"/api/dpay/crypto/quote":{"post":{"operationId":"DehubPayController_cryptoQuote","parameters":[],"responses":{"201":{"description":""}},"tags":["Payments"]}},"/api/dpay/crypto/intent":{"post":{"operationId":"DehubPayController_cryptoIntent","parameters":[],"responses":{"201":{"description":""}},"tags":["Payments"]}},"/api/dpay/crypto/intents":{"get":{"operationId":"DehubPayController_cryptoIntents","parameters":[],"responses":{"200":{"description":""}},"tags":["Payments"]}},"/api/dpay/crypto/payment-options":{"get":{"operationId":"DehubPayController_cryptoPaymentOptions","parameters":[],"responses":{"200":{"description":""}},"tags":["Payments"]}},"/api/dpay/crypto/intent/{id}":{"get":{"operationId":"DehubPayController_cryptoIntentStatus","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Payments"]}},"/api/dpay/crypto/direct/quote":{"post":{"operationId":"DehubPayController_directQuote","parameters":[],"responses":{"201":{"description":""}},"tags":["Payments"]}},"/api/dpay/crypto/direct/intent":{"post":{"operationId":"DehubPayController_directIntent","parameters":[],"responses":{"201":{"description":""}},"tags":["Payments"]}},"/api/dpay/crypto/direct/transaction":{"post":{"operationId":"DehubPayController_directTransaction","parameters":[],"responses":{"201":{"description":""}},"tags":["Payments"]}},"/api/dpay/crypto/direct/confirm":{"post":{"operationId":"DehubPayController_directConfirm","parameters":[],"responses":{"201":{"description":""}},"tags":["Payments"]}},"/api/dpay/subscription-grant":{"post":{"operationId":"DehubPayController_subscriptionGrant","parameters":[],"responses":{"201":{"description":""}},"tags":["Payments"]}},"/api/post_quota":{"get":{"description":"\nEverything a composer needs before anything is typed: how much of today's free\nallowance is spent, what the creator's badge tier buys them, what overage costs\nin DHB, and whether an earlier post is still unpaid.\n\nThe allowance resets at UTC midnight. Text posts and media are separate pools —\nrunning out of one does not touch the other.\n\n`chargingEnabled: false` means no treasury is configured and posting is free\nregardless of usage. Clients must honour that rather than pricing it themselves.\n    ","operationId":"PostQuotaController_getQuota","parameters":[],"responses":{"200":{"description":"Allowance returned"},"401":{"description":"Unauthorized"}},"security":[{"user-auth":[]}],"summary":"Today's posting allowance and what is left of it","tags":["Post quota"]}},"/api/post_quota/tiers":{"get":{"description":"Every badge tier, its daily allowance and its overage price. Text-post allowance: Starter 1; Crab, Ghost Lobster and Piranha 2; Giant Tortoise, King Cobra, Octopus and Crocodile 3; then Dolphin through Megalodon increase from 4 to 9. Public — it is what the pricing page renders.","operationId":"PostQuotaController_getTiers","parameters":[],"responses":{"200":{"description":"Ladder returned"}},"summary":"The published allowance and price ladder","tags":["Post quota"]}},"/api/post_quota/quote":{"post":{"description":"\nPrices a post that has not happened yet, against today's usage. Nothing is\nwritten and nothing is charged — call it as often as the composer needs, for\ninstance every time media is added or removed.\n\n`bytes` is the total size of every file the upload will carry, thumbnails\nincluded. Text posts ignore it.\n    ","operationId":"PostQuotaController_quote","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["postType"],"properties":{"postType":{"type":"string","enum":["video","short","feed-simple","feed-images","feed-audio","live"],"example":"feed-images"},"bytes":{"type":"number","example":5242880,"description":"Total upload size in bytes"}}}}}},"responses":{"200":{"description":"Cost returned"},"401":{"description":"Unauthorized"}},"security":[{"user-auth":[]}],"summary":"What one specific post would cost","tags":["Post quota"]}},"/api/post_quota/settle":{"post":{"description":"\nCall straight after sending the DHB transfer that pays for an over-allowance\npost. The backend reads the receipt, checks a `Transfer` of DHB from this\naccount to the DeHub treasury, and closes open charges oldest-first up to the\namount that actually moved.\n\nThe payer is matched on the ERC-20 event's `from`, not the transaction's, so\na sponsored transaction from a smart wallet settles the same as one sent from\nan external wallet.\n\nSafe to repeat: the hash is claimed once, so a retry after a lost response\nreports the same outcome instead of paying twice. A transaction that has not\nbeen mined yet answers `pending: true` — retry in a few seconds.\n    ","operationId":"PostQuotaController_settle","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["txHash","chainId"],"properties":{"txHash":{"type":"string","example":"0xabc…"},"chainId":{"type":"number","example":8453,"description":"Base (8453) or BNB (56)"}}}}}},"responses":{"200":{"description":"Settled, or still pending"},"400":{"description":"Missing or invalid parameters"},"401":{"description":"Unauthorized"}},"security":[{"user-auth":[]}],"summary":"Close the posting tab with a DHB transfer","tags":["Post quota"]}},"/api/live/twitch":{"get":{"operationId":"TwitchMirrorController_status","parameters":[],"responses":{"200":{"description":""}},"tags":["TwitchMirror"]},"patch":{"operationId":"TwitchMirrorController_update","parameters":[],"responses":{"200":{"description":""}},"tags":["TwitchMirror"]},"delete":{"operationId":"TwitchMirrorController_disconnect","parameters":[],"responses":{"200":{"description":""}},"tags":["TwitchMirror"]}},"/api/live/twitch/connect":{"post":{"operationId":"TwitchMirrorController_connect","parameters":[],"responses":{"201":{"description":""}},"tags":["TwitchMirror"]}},"/api/live/twitch/callback":{"get":{"operationId":"TwitchMirrorController_callback","parameters":[],"responses":{"200":{"description":""}},"tags":["TwitchMirror"]}},"/api/live/twitch/events":{"post":{"operationId":"TwitchMirrorController_events","parameters":[],"responses":{"200":{"description":""}},"tags":["TwitchMirror"]}},"/api/live/twitch/worker/claim":{"post":{"operationId":"TwitchMirrorController_claim","parameters":[{"name":"authorization","required":true,"in":"header","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["TwitchMirror"]}},"/api/live/twitch/worker/heartbeat":{"post":{"operationId":"TwitchMirrorController_heartbeat","parameters":[{"name":"authorization","required":true,"in":"header","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["TwitchMirror"]}},"/api/live/twitch/worker/finish":{"post":{"operationId":"TwitchMirrorController_finish","parameters":[{"name":"authorization","required":true,"in":"header","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["TwitchMirror"]}},"/api/live/destinations":{"get":{"operationId":"MultistreamController_list","parameters":[],"responses":{"200":{"description":""}},"tags":["Multistream"]},"post":{"operationId":"MultistreamController_create","parameters":[],"responses":{"201":{"description":""}},"tags":["Multistream"]}},"/api/live/destinations/worker/jobs":{"post":{"operationId":"MultistreamController_jobs","parameters":[{"name":"authorization","required":true,"in":"header","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Multistream"]}},"/api/live/destinations/worker/report":{"post":{"operationId":"MultistreamController_report","parameters":[{"name":"authorization","required":true,"in":"header","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Multistream"]}},"/api/live/destinations/{id}":{"patch":{"operationId":"MultistreamController_update","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Multistream"]},"delete":{"operationId":"MultistreamController_remove","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Multistream"]}},"/api/live":{"get":{"operationId":"LivestreamController_getLiveStreams","parameters":[],"responses":{"200":{"description":""}},"tags":["Livestream"]}},"/api/live/user/{address}":{"get":{"operationId":"LivestreamController_getMyLiveStreams","parameters":[{"name":"address","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Livestream"]}},"/api/live/user/{address}/scheduled":{"get":{"operationId":"LivestreamController_getUserScheduledStreams","parameters":[{"name":"address","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Livestream"]}},"/api/live/creator/{address}/progress":{"get":{"operationId":"LivestreamController_getCreatorProgress","parameters":[{"name":"address","required":true,"in":"path","schema":{"type":"string"}},{"name":"collection","required":true,"in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Livestream"]}},"/api/live/creator/badge":{"patch":{"operationId":"LivestreamController_selectCreatorBadge","parameters":[],"responses":{"200":{"description":""}},"tags":["Livestream"]}},"/api/live/turn-credentials":{"get":{"operationId":"LivestreamController_getTurnCredentials","parameters":[],"responses":{"200":{"description":""}},"tags":["Livestream"]}},"/api/live/ingest-key":{"get":{"operationId":"LivestreamController_getIngestKey","parameters":[],"responses":{"200":{"description":""}},"tags":["Livestream"]},"patch":{"operationId":"LivestreamController_updateIngestKey","parameters":[],"responses":{"200":{"description":""}},"tags":["Livestream"]}},"/api/live/ingest-key/rotate":{"post":{"operationId":"LivestreamController_rotateIngestKey","parameters":[],"responses":{"201":{"description":""}},"tags":["Livestream"]}},"/api/live/ingest-session":{"post":{"operationId":"LivestreamController_resolveIngestSession","parameters":[{"name":"authorization","required":true,"in":"header","schema":{"type":"string"}}],"responses":{"201":{"description":""}},"tags":["Livestream"]}},"/api/live/{streamId}":{"get":{"operationId":"LivestreamController_getStreamDetails","parameters":[{"name":"streamId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Livestream"]}},"/api/live/{streamId}/key":{"get":{"operationId":"LivestreamController_getStreamKey","parameters":[{"name":"streamId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Livestream"]}},"/api/live/{streamId}/audience":{"get":{"operationId":"LivestreamController_getStreamAudience","parameters":[{"name":"streamId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Livestream"]}},"/api/live/{streamId}/recap":{"get":{"operationId":"LivestreamController_getStreamRecap","parameters":[{"name":"streamId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Livestream"]}},"/api/live/{streamId}/activities":{"get":{"operationId":"LivestreamController_getStreamAtivities","parameters":[{"name":"streamId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Livestream"]}},"/api/live/{streamId}/like":{"post":{"operationId":"LivestreamController_likeStream","parameters":[{"name":"streamId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":""}},"tags":["Livestream"]}},"/api/live/{streamId}/share":{"post":{"operationId":"LivestreamController_shareStream","parameters":[{"name":"streamId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":""}},"tags":["Livestream"]}},"/api/live/{streamId}/gift":{"post":{"operationId":"LivestreamController_giftStream","parameters":[{"name":"streamId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":""}},"tags":["Livestream"]}},"/api/live/{streamId}/settings":{"patch":{"operationId":"LivestreamController_updateStreamSettings","parameters":[{"name":"streamId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Livestream"]}},"/api/live/webhook":{"post":{"operationId":"LivestreamController_handleWebhook","parameters":[{"name":"livepeer-signature","required":true,"in":"header","schema":{"type":"string"}}],"responses":{"201":{"description":""}},"tags":["Livestream"]}},"/api/live/ingest-auth":{"post":{"operationId":"LivestreamController_authorizeIngest","parameters":[],"responses":{"201":{"description":""}},"tags":["Livestream"]}},"/api/live/ingest-hook":{"post":{"operationId":"LivestreamController_handleIngestHook","parameters":[{"name":"authorization","required":true,"in":"header","schema":{"type":"string"}}],"responses":{"201":{"description":""}},"tags":["Livestream"]}},"/api/live/ingest-thumbnail":{"post":{"operationId":"LivestreamController_handleIngestThumbnail","parameters":[{"name":"authorization","required":true,"in":"header","schema":{"type":"string"}}],"responses":{"201":{"description":""}},"tags":["Livestream"]}},"/api/live/{streamId}/ingest-status":{"get":{"operationId":"LivestreamController_getIngestStatus","parameters":[{"name":"streamId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Livestream"]}},"/api/live/{streamId}/ingest-fallback":{"post":{"operationId":"LivestreamController_fallbackToLivepeer","parameters":[{"name":"streamId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":""}},"tags":["Livestream"]}},"/api/live/{streamId}/ingesturl":{"get":{"operationId":"LivestreamController_getStreamIngestUrl","parameters":[{"name":"streamId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Livestream"]}},"/api/live/{streamId}/thumbnail":{"post":{"operationId":"LivestreamController_updateStreamThumbnail","parameters":[{"name":"streamId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":""}},"tags":["Livestream"]}},"/api/youtube_import":{"post":{"operationId":"YoutubeImportController_importFromYoutube","parameters":[],"responses":{"201":{"description":""}},"tags":["YoutubeImport"]},"get":{"operationId":"YoutubeImportController_listImports","parameters":[],"responses":{"200":{"description":""}},"tags":["YoutubeImport"]}},"/api/youtube_import/preview":{"post":{"operationId":"YoutubeImportController_preview","parameters":[],"responses":{"201":{"description":""}},"tags":["YoutubeImport"]}},"/api/youtube_import/{jobId}":{"get":{"operationId":"YoutubeImportController_getImportStatus","parameters":[{"name":"jobId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["YoutubeImport"]}},"/api/youtube_import/{jobId}/file":{"get":{"operationId":"YoutubeImportController_getEditorFile","parameters":[{"name":"jobId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["YoutubeImport"]}},"/api/podcast_import/preview":{"post":{"operationId":"PodcastImportController_preview","parameters":[],"responses":{"201":{"description":""}},"tags":["PodcastImport"]}},"/api/podcast_import":{"post":{"operationId":"PodcastImportController_importFromPodcast","parameters":[],"responses":{"201":{"description":""}},"tags":["PodcastImport"]},"get":{"operationId":"PodcastImportController_listImports","parameters":[],"responses":{"200":{"description":""}},"tags":["PodcastImport"]}},"/api/podcast_import/{jobId}":{"get":{"operationId":"PodcastImportController_getImportStatus","parameters":[{"name":"jobId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["PodcastImport"]}},"/api/youtube_migration/pricing":{"get":{"operationId":"YoutubeMigrationController_pricing","parameters":[],"responses":{"200":{"description":""}},"tags":["YoutubeMigration"]}},"/api/youtube_migration/videos":{"get":{"operationId":"YoutubeMigrationController_videos","parameters":[{"name":"channelUrl","required":true,"in":"query","schema":{"type":"string"}},{"name":"ownershipConfirmed","required":true,"in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["YoutubeMigration"]}},"/api/youtube_migration/quote":{"post":{"operationId":"YoutubeMigrationController_quote","parameters":[],"responses":{"201":{"description":""}},"tags":["YoutubeMigration"]}},"/api/youtube_migration/settle":{"post":{"operationId":"YoutubeMigrationController_settle","parameters":[],"responses":{"201":{"description":""}},"tags":["YoutubeMigration"]}},"/api/youtube_migration/charge/active":{"get":{"operationId":"YoutubeMigrationController_activeCharge","parameters":[],"responses":{"200":{"description":""}},"tags":["YoutubeMigration"]}},"/api/youtube_migration/charge/{chargeId}":{"get":{"operationId":"YoutubeMigrationController_chargeStatus","parameters":[{"name":"chargeId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["YoutubeMigration"]}},"/api/auth/wallet-challenge":{"post":{"operationId":"AuthController_walletChallenge","parameters":[],"responses":{"200":{"description":""}},"tags":["Auth"]}},"/api/web/auth":{"post":{"description":"\n## Web Authentication Flow\n\nThis endpoint authenticates users by verifying their wallet signature and returns a JWT token for subsequent API requests.\n\n### How Authentication Works:\n\n1. **Generate the Sign Message** on the client:\n   ```\n   Welcome to DeHub!\n   \n   Click to sign in for authentication.\n   Signatures are valid for 24 hours.\n   Your wallet address is {address}.\n   It is {timestamp as UTC string}.\n   ```\n\n2. **Sign the message** using your wallet (MetaMask, WalletConnect, etc.)\n\n3. **Send the request** with the following parameters:\n   - `address`: Your wallet address (0x...)\n   - `sig`: The signature from signing the message\n   - `timestamp`: Unix timestamp (seconds) used when generating the message\n   - `chainId`: (Optional) Chain ID for smart contract wallets (default: 8453 for Base)\n\n4. **Receive JWT token** in response - use this in the `Authorization: Bearer <token>` header for all authenticated requests.\n\n### Token Expiration:\n- **Access tokens**: 15 minutes (use refresh token to renew)\n- **Refresh tokens**: 7 days (web)\n- Use `POST /auth/refresh` with the refresh token to get new tokens without re-signing\n\n### Supported Wallets:\n- EOA wallets (MetaMask, etc.)\n- Smart contract wallets (EIP-1271 signature verification)\n    ","operationId":"AuthController_webAuth","parameters":[],"requestBody":{"required":true,"description":"Authentication credentials from wallet signature","content":{"application/json":{"schema":{"type":"object","required":["address","sig","timestamp"],"properties":{"address":{"type":"string","description":"Ethereum wallet address","example":"0x1234567890abcdef1234567890abcdef12345678"},"sig":{"type":"string","description":"Signature from signing the authentication message with your wallet","example":"0x..."},"timestamp":{"type":"number","description":"Unix timestamp (in seconds) used when generating the sign message","example":1737158400},"chainId":{"type":"number","description":"Chain ID for signature verification (required for smart contract wallets)","example":8453,"default":8453},"web3AuthMeta":{"type":"object","nullable":true,"description":"Optional metadata from Web3Auth (social login info, provider details, etc.). Stored on the account and updated on each login when provided.","example":{"typeOfLogin":"google","verifier":"dehub-google","verifierId":"user@gmail.com","email":"user@gmail.com","name":"John Doe","profileImage":"https://lh3.googleusercontent.com/..."}}}}}}},"responses":{"200":{"description":"Authentication successful","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"token":{"type":"string","description":"Short-lived JWT access token (15 min)","example":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."},"refreshToken":{"type":"string","description":"Refresh token — store securely and use with POST /auth/refresh to get new tokens","example":"a1b2c3d4e5f6..."},"expiresIn":{"type":"number","description":"Access token lifetime in seconds","example":900},"user":{"type":"object","properties":{"address":{"type":"string","example":"0x1234..."},"username":{"type":"string","nullable":true,"example":"johndoe"},"displayName":{"type":"string","nullable":true,"example":"John Doe"},"avatarImageUrl":{"type":"string","nullable":true},"followers":{"type":"number","example":0,"description":"Total follower count"},"followings":{"type":"number","example":0,"description":"Total following count"},"hideFollowers":{"type":"boolean","example":false,"description":"If true, follower/following lists are hidden from other users"},"likes":{"type":"number","example":0}}},"result":{"type":"object","properties":{"address":{"type":"string"},"isMobile":{"type":"boolean","example":false},"lastLoginTimestamp":{"type":"number"},"tokenExpiry":{"type":"string","example":"15 minutes"},"refreshTokenExpiry":{"type":"string","example":"7 days"},"isNewAccount":{"type":"boolean","example":false,"description":"Indicates if this is a newly created account (first-time login)"}}},"message":{"type":"string","example":"Account authenticated successfully"}}}}}},"400":{"description":"Invalid signature or missing parameters"},"401":{"description":"Signature verification failed or expired"},"429":{"description":"Too many authentication attempts. Try again later."},"500":{"description":"Server error during authentication"}},"summary":"Authenticate web user with wallet signature","tags":["Auth"]}},"/api/mobile/auth":{"post":{"description":"\n## Mobile Authentication Flow\n\nSame authentication flow as web, but returns a long-lived token suitable for mobile apps.\n\n### How Authentication Works:\n\n1. **Generate the Sign Message** on the client:\n   ```\n   Welcome to DeHub!\n   \n   Click to sign in for authentication.\n   Signatures are valid until you log out.\n   Your wallet address is {address}.\n   It is {timestamp as UTC string}.\n   ```\n\n2. **Sign the message** using your wallet (WalletConnect, Coinbase Wallet, etc.)\n\n3. **Send the request** with:\n   - `address`: Your wallet address (0x...)\n   - `sig`: The signature from signing the message\n   - `timestamp`: Unix timestamp (seconds) used when generating the message\n   - `chainId`: (Optional) Chain ID for smart contract wallets\n   - `isMobile`: Set to `true` or include `X-Client-Type: mobile` header\n4. **Receive JWT token** - store securely and use in `Authorization: Bearer <token>` header.\n\n### Token Expiration:\n- **Access tokens**: 15 minutes (use refresh token to renew)\n- **Refresh tokens**: 30 days (mobile)\n- Use `POST /auth/refresh` with the refresh token to get new tokens without re-signing\n\n- Returns full user profile on successful authentication\n    ","operationId":"AuthController_mobileAuth","parameters":[{"name":"X-Client-Type","in":"header","description":"Set to \"mobile\" to indicate mobile client","required":false,"schema":{"type":"string","example":"mobile"}}],"requestBody":{"required":true,"description":"Mobile authentication credentials","content":{"application/json":{"schema":{"type":"object","required":["address","sig","timestamp"],"properties":{"address":{"type":"string","description":"Ethereum wallet address","example":"0x1234567890abcdef1234567890abcdef12345678"},"sig":{"type":"string","description":"Signature from signing the authentication message","example":"0x..."},"timestamp":{"type":"number","description":"Unix timestamp (in seconds) used when generating the sign message","example":1737158400},"chainId":{"type":"number","description":"Chain ID for signature verification","example":8453},"isMobile":{"type":"boolean","description":"Set to true to get a long-lived mobile token","example":true},"web3AuthMeta":{"type":"object","nullable":true,"description":"Optional metadata from Web3Auth (social login info, provider details, etc.). Stored on the account and updated on each login when provided.","example":{"typeOfLogin":"google","verifier":"dehub-google","verifierId":"user@gmail.com","email":"user@gmail.com","name":"John Doe","profileImage":"https://lh3.googleusercontent.com/..."}}}}}}},"responses":{"200":{"description":"Authentication successful","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"token":{"type":"string","description":"Short-lived JWT access token (15 min)","example":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."},"refreshToken":{"type":"string","description":"Refresh token — store securely and use with POST /auth/refresh","example":"a1b2c3d4e5f6..."},"expiresIn":{"type":"number","description":"Access token lifetime in seconds","example":900},"user":{"type":"object","description":"Full user profile","properties":{"address":{"type":"string"},"username":{"type":"string","nullable":true},"displayName":{"type":"string","nullable":true},"avatarImageUrl":{"type":"string","nullable":true},"coverImageUrl":{"type":"string","nullable":true},"aboutMe":{"type":"string","nullable":true},"followers":{"type":"number"},"likes":{"type":"number"},"uploads":{"type":"number"},"sentTips":{"type":"number"},"receivedTips":{"type":"number"}}},"result":{"type":"object","properties":{"address":{"type":"string"},"isMobile":{"type":"boolean","example":true},"lastLoginTimestamp":{"type":"number"},"tokenExpiry":{"type":"string","example":"15 minutes"},"refreshTokenExpiry":{"type":"string","example":"30 days"},"isNewAccount":{"type":"boolean","example":false,"description":"Indicates if this is a newly created account (first-time login)"}}},"message":{"type":"string","example":"Account authenticated successfully"}}}}}},"400":{"description":"Invalid signature or missing parameters"},"401":{"description":"Signature verification failed or expired"},"429":{"description":"Too many authentication attempts. Try again later."},"500":{"description":"Server error during authentication"}},"summary":"Authenticate mobile user with wallet signature","tags":["Auth"]}},"/api/web/auth/supabase":{"post":{"description":"\n## Supabase Session Exchange\n\nExchange a Supabase access token (magic link / Google sign-in) for a DeHub token pair.\n\n### Why this exists\nThe self-custody wallet's key is encrypted at rest and only decryptable with the\nuser's password or biometrics. Requiring a wallet signature to log in therefore\nforced an unlock prompt on the first screen, before the user had asked to do\nanything with their wallet. This endpoint lets login finish on the Supabase\nidentity alone, so the unlock can be deferred until the user actually signs\nsomething — a tip, a post, going live.\n\n### Requirements\n- The Supabase identity must ALREADY be linked to a DeHub account. Linking\n  happens on the signature routes, which store the Supabase user id as\n  `web3AuthMeta.verifierId`.\n- Send the Supabase access token as `Authorization: Bearer <token>`, or as\n  `supabaseAccessToken` in the body.\n\n### What it does NOT do\n- It does not create accounts and it does not link wallets.\n- It does not grant the ability to move funds — transactions still require the\n  user to unlock their wallet and sign.\n\n### Not linked yet?\nReturns **409** with `code: \"WALLET_NOT_LINKED\"`. Fall back to the signature\nflow (`POST web/auth`) so the user proves wallet control once.\n    ","operationId":"AuthController_supabaseAuth","parameters":[{"name":"Authorization","in":"header","description":"Bearer <supabase access token>","required":false,"schema":{"type":"string"}}],"requestBody":{"required":false,"description":"Supabase access token (omit if sent via the Authorization header)","content":{"application/json":{"schema":{"type":"object","properties":{"supabaseAccessToken":{"type":"string","description":"The Supabase access token (JWT) for the current session","example":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}}}}}},"responses":{"200":{"description":"Authentication successful — same shape as POST web/auth"},"400":{"description":"No Supabase access token supplied"},"401":{"description":"Invalid or expired Supabase session"},"403":{"description":"Account is banned"},"409":{"description":"Supabase identity is not linked to a wallet, or is linked ambiguously"},"429":{"description":"Too many authentication attempts. Try again later."},"503":{"description":"Supabase authentication is not configured on this server"}},"summary":"Authenticate with a Supabase session instead of a wallet signature","tags":["Auth"]}},"/api/auth/rotate-wallet":{"post":{"description":"\n## Replace a wallet without losing the account\n\nAccounts are keyed by wallet address, so replacing an unopenable wallet used to\nmean starting over — new username, no posts, no followers — while the old\naccount kept the handle forever. This moves the existing account onto a new\naddress instead.\n\n### Requirements\n- `supabaseAccessToken` (body) or `x-supabase-authorization: Bearer <token>`,\n  identifying the account via `web3AuthMeta.verifierId`.\n- `address`, `sig` and `timestamp` for the NEW wallet — the same sign-in\n  signature `POST web/auth` takes. This proves the caller controls the\n  destination.\n- The destination must have no DeHub account. Moving into an occupied address\n  would be a merge, which this is not.\n\n### What moves\nEverything the account is: handle, posts, comments, followers, engagement,\nbadges, balances, business receipts — and, unlike an account sale, everything\nthe PERSON has too, since it is still them: DMs, notification inbox and\npreferences, push registrations, bookmarks, saved posts, watch history.\n\n### What does not\nAnything on-chain. DHB, minted post NFTs and any other asset stay at the old\naddress, because the chain does not know this happened. Say so before calling.\n    ","operationId":"AuthController_rotateWallet","parameters":[],"responses":{"200":{"description":"Account moved — same shape as POST web/auth, for the new address"},"400":{"description":"Missing token, or the new address is malformed"},"401":{"description":"Invalid Supabase session, or a bad signature for the new wallet"},"403":{"description":"Account is banned"},"409":{"description":"Login is not linked, is linked ambiguously, or the new address already has an account"},"429":{"description":"Too many attempts. Try again later."}},"summary":"Move this login's account onto a new wallet","tags":["Auth"]}},"/api/auth/refresh":{"post":{"description":"\n## Token Refresh Flow\n\nExchange a valid refresh token for a new access token and a new refresh token (token rotation).\n\n### How It Works:\n1. Send the `refreshToken` you received during login / previous refresh.\n2. Receive a new `accessToken` (15 min) and a new `refreshToken`.\n3. **Discard the old refresh token** — it is now invalid.\n\n### Token Rotation Security:\n- Each refresh token can only be used **once**.\n- If a revoked token is reused (e.g. stolen token replay), **all tokens in that session are revoked** and the user must re-authenticate with their wallet.\n\n### When to Call:\n- When your access token expires (HTTP 401 from any authenticated endpoint).\n- Proactively before expiry using the `expiresIn` value from the previous response.\n    ","operationId":"AuthController_refresh","parameters":[],"requestBody":{"required":true,"description":"Refresh token from login or previous refresh","content":{"application/json":{"schema":{"type":"object","required":["refreshToken"],"properties":{"refreshToken":{"type":"string","description":"The refresh token received from login or the last refresh call","example":"a1b2c3d4e5f6..."}}}}}},"responses":{"200":{"description":"New token pair issued","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"accessToken":{"type":"string","description":"New short-lived JWT (15 min)"},"refreshToken":{"type":"string","description":"New refresh token (replaces the old one)"},"expiresIn":{"type":"number","example":900,"description":"Access token lifetime in seconds"}}}}}},"401":{"description":"Invalid, expired, or reused refresh token"},"429":{"description":"Too many refresh attempts"}},"summary":"Refresh access token","tags":["Auth"]}},"/api/auth/logout":{"post":{"description":"\nRevoke the refresh token for the current session. The access token will remain valid until it expires (max 15 min), but no new tokens can be issued for this session.\n\nSend the refresh token you want to revoke. If you don't have it, the endpoint is a no-op.\n    ","operationId":"AuthController_logout","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"refreshToken":{"type":"string","description":"The refresh token to revoke"}}}}}},"responses":{"200":{"description":"Logged out successfully"}},"security":[{"user-auth":[]}],"summary":"Logout (revoke refresh token)","tags":["Auth"]}},"/api/auth/logout-all":{"post":{"description":"\nRevoke **all** active refresh tokens for the authenticated user across all devices and sessions. Existing access tokens will expire naturally within 15 minutes.\n    ","operationId":"AuthController_logoutAll","parameters":[],"responses":{"200":{"description":"All sessions revoked"}},"security":[{"user-auth":[]}],"summary":"Logout from all devices","tags":["Auth"]}},"/api/auth/verify":{"get":{"description":"Returns the wallet address the presented token actually belongs to. 401 if the token is missing, invalid or expired. Intended for services that must authenticate a caller before acting on their behalf.","operationId":"AuthController_verifyToken","parameters":[],"responses":{"200":{"description":"Token is valid","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"address":{"type":"string","example":"0xc8acd6eeed02ea0da142d57941e1102e81cc0b77"}}}}}},"401":{"description":"Missing, invalid or expired token"}},"security":[{"user-auth":[]}],"summary":"Resolve the authenticated address from a token","tags":["Auth"]}},"/api/auth/sessions":{"get":{"description":"\nReturns all active sessions for the authenticated user, ordered by most recently active first.\n\nEach session includes device metadata (name, platform, OS, app version, IP) and a `current` flag indicating whether it matches the calling device.\n\nThe caller's device is identified via the `X-Device-Id` header (mobile) or defaults to `web` for web clients.\n    ","operationId":"AuthController_getActiveSessions","parameters":[],"responses":{"200":{"description":"Active sessions","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"sessions":{"type":"array","items":{"type":"object","properties":{"deviceId":{"type":"string","example":"A1B2C3D4-E5F6-7890-AB12-CD34EF567890"},"deviceName":{"type":"string","example":"iPhone 15 Pro","nullable":true},"platform":{"type":"string","enum":["ios","android","web"]},"appVersion":{"type":"string","example":"2.1.0","nullable":true},"osVersion":{"type":"string","example":"17.2","nullable":true},"ip":{"type":"string","example":"203.0.113.42","nullable":true},"lastActiveAt":{"type":"string","format":"date-time"},"createdAt":{"type":"string","format":"date-time"},"current":{"type":"boolean","description":"true if this is the calling device"}}}}}}}}}},"security":[{"user-auth":[]}],"summary":"List active sessions / devices","tags":["Auth"]}},"/api/auth/sessions/{deviceId}":{"delete":{"description":"\nRevoke all refresh tokens for the specified device and remove its session record. The user is effectively logged out on that device.\n\nYou cannot revoke the session you are currently calling from (use `POST /auth/logout` instead).\n    ","operationId":"AuthController_revokeSession","parameters":[{"name":"deviceId","required":true,"in":"path","description":"The device ID of the session to revoke","schema":{"type":"string"}}],"responses":{"200":{"description":"Session revoked"},"400":{"description":"Cannot revoke current device from this endpoint"},"404":{"description":"Session not found"}},"security":[{"user-auth":[]}],"summary":"Revoke a specific device session","tags":["Auth"]}},"/api/auth/connector-session":{"post":{"description":"Issues a new refresh family for the authenticated account, listed in Active sessions and revocable like any device.","operationId":"AuthController_connectorSession","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"clientName":{"type":"string","example":"ChatGPT"}}}}}},"responses":{"200":{"description":"token, refreshToken and expiresIn"}},"security":[{"user-auth":[]}],"summary":"Issue a session for a connected app","tags":["Auth"]}},"/api/auth/sessions/revoke-others":{"post":{"description":"\nRevoke all sessions except the current device. The caller remains logged in, but all other devices are logged out.\n\nThe current device is identified by the refresh token sent in the body.\n    ","operationId":"AuthController_revokeOtherSessions","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["refreshToken"],"properties":{"refreshToken":{"type":"string","description":"The current session refresh token to keep"}}}}}},"responses":{"200":{"description":"All other sessions revoked"}},"security":[{"user-auth":[]}],"summary":"Revoke all other sessions","tags":["Auth"]}},"/api/username/check":{"get":{"description":"\nCheck if a username is available for registration. This endpoint does not require authentication.\n\n### Username Rules:\n- Length: 1-30 characters. There is no minimum — single and double character\n  names are allowed.\n- Allowed characters: letters (a-z, A-Z), numbers (0-9), underscore (`_`),\n  hyphen (`-`)\n- Case-insensitive (\"JohnDoe\" and \"johndoe\" are considered the same)\n- Reserved names — site routes, and `admin`/`support`/`official` and similar —\n  report as unavailable rather than available-then-rejected on save.\n    ","operationId":"AuthController_checkUsernameGet","parameters":[{"name":"username","required":true,"in":"query","description":"The username to check for availability","schema":{"example":"johndoe","type":"string"}},{"name":"holdToken","required":true,"in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"Username availability check result","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"code":{"type":"number","example":200},"available":{"type":"boolean","description":"Whether the username is available","example":true},"username":{"type":"string","description":"The username that was checked","example":"johndoe"}}}}}},"400":{"description":"Invalid username format","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":false},"error":{"type":"boolean","example":true},"code":{"type":"number","example":400},"message":{"type":"string","example":"Username length must be 3-30 chars"}}}}}}},"summary":"Check if username is available","tags":["Auth"]},"post":{"description":"Same as GET /username/check but accepts username in request body. Kept for backward compatibility.","operationId":"AuthController_checkUsernamePost","parameters":[],"requestBody":{"required":true,"description":"Username to check","content":{"application/json":{"schema":{"type":"object","required":["username"],"properties":{"username":{"type":"string","description":"The username to check for availability","example":"johndoe"}}}}}},"responses":{"200":{"description":"Username availability check result","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"code":{"type":"number","example":200},"available":{"type":"boolean","example":true},"username":{"type":"string","example":"johndoe"}}}}}},"400":{"description":"Invalid username format"}},"summary":"Check if username is available (POST)","tags":["Auth"]}},"/api/username/hold":{"post":{"operationId":"AuthController_holdUsername","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["username","holdToken"],"properties":{"username":{"type":"string"},"holdToken":{"type":"string"}}}}}},"responses":{"200":{"description":""}},"summary":"Hold a free username for a signed-out visitor for one minute","tags":["Auth"]}},"/api/username/release":{"post":{"operationId":"AuthController_releaseUsername","parameters":[],"responses":{"200":{"description":""}},"summary":"Release the username this visitor was holding","tags":["Auth"]}},"/api/report/content":{"post":{"description":"\nReport a piece of content (video, feed post, etc.) for violating community guidelines.\n\n### Rules\n- One report per user per token (duplicate submissions return 409)\n- Cannot report your own content\n- Must provide a standardized reason from the available list\n- Optional `additionalInfo` (max 500 chars) for extra context\n\n### Available reasons\n`sexual_content`, `violent_content`, `hateful_content`, `harassment_bullying`,\n`harmful_dangerous`, `misinformation`, `child_abuse`, `spam_misleading`,\n`scam_fraud`, `infringes_rights`, `other`\n    ","operationId":"FeedReportController_reportContent","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tokenId","reason"],"properties":{"tokenId":{"type":"number","example":123,"description":"Token ID of the content to report"},"reason":{"type":"string","enum":["sexual_content","violent_content","hateful_content","harassment_bullying","harmful_dangerous","misinformation","child_abuse","spam_misleading","scam_fraud","infringes_rights","other"],"example":"spam_misleading","description":"Reason for reporting"},"additionalInfo":{"type":"string","maxLength":500,"example":"This video contains misleading health claims","description":"Optional details (max 500 chars)"}}}}}},"responses":{"201":{"description":"Report submitted successfully"},"400":{"description":"Invalid input (missing fields, invalid reason, or self-report)"},"401":{"description":"Unauthorized — JWT required"},"404":{"description":"Content or reporter account not found"},"409":{"description":"Duplicate — already reported this content"}},"security":[{"user-auth":[]}],"summary":"Report a video or post","tags":["Reports"]}},"/api/report/user":{"post":{"description":"\nReport a user for violating community guidelines.\n\n### Rules\n- One report per reporter per target user (duplicates return 409)\n- Cannot report yourself\n- `userId` accepts a MongoDB ObjectId, wallet address, or username\n- Must provide a standardized reason from the available list\n- Optional `additionalInfo` (max 500 chars) for extra context\n\n### Available reasons\n`impersonation`, `harassment_bullying`, `hateful_behavior`, `spam`,\n`scam_fraud`, `underage`, `inappropriate_profile`, `other`\n    ","operationId":"FeedReportController_reportUser","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["userId","reason"],"properties":{"userId":{"type":"string","example":"johndoe","description":"User to report — ObjectId, wallet address, or username"},"reason":{"type":"string","enum":["impersonation","harassment_bullying","hateful_behavior","spam","scam_fraud","underage","inappropriate_profile","other"],"example":"spam","description":"Reason for reporting"},"additionalInfo":{"type":"string","maxLength":500,"example":"This user is sending spam messages","description":"Optional details (max 500 chars)"}}}}}},"responses":{"201":{"description":"Report submitted successfully"},"400":{"description":"Invalid input (missing fields, invalid reason, or self-report)"},"401":{"description":"Unauthorized — JWT required"},"404":{"description":"User not found"},"409":{"description":"Duplicate — already reported this user"}},"security":[{"user-auth":[]}],"summary":"Report a user","tags":["Reports"]}},"/api/report/content/status/{tokenId}":{"get":{"description":"Check whether the authenticated user has already reported a specific piece of content.","operationId":"FeedReportController_contentReportStatus","parameters":[{"name":"tokenId","required":true,"in":"path","description":"Token ID to check","schema":{"example":123,"type":"number"}}],"responses":{"200":{"description":"Returns hasReported boolean and report details if exists"},"401":{"description":"Unauthorized — JWT required"}},"security":[{"user-auth":[]}],"summary":"Check if you reported a video","tags":["Reports"]}},"/api/report/user/status/{userId}":{"get":{"description":"Check whether the authenticated user has already reported a specific user. Accepts ObjectId, address, or username.","operationId":"FeedReportController_userReportStatus","parameters":[{"name":"userId","required":true,"in":"path","description":"User to check — ObjectId, address, or username","schema":{"example":"johndoe","type":"string"}}],"responses":{"200":{"description":"Returns hasReported boolean and report details if exists"},"401":{"description":"Unauthorized — JWT required"}},"security":[{"user-auth":[]}],"summary":"Check if you reported a user","tags":["Reports"]}},"/api/report/reasons/content":{"get":{"description":"Returns the list of standardized reasons available for reporting content. No auth required.","operationId":"FeedReportController_contentReportReasons","parameters":[],"responses":{"200":{"description":"List of content report reason values and labels"}},"summary":"Get content report reasons","tags":["Reports"]}},"/api/report/reasons/user":{"get":{"description":"Returns the list of standardized reasons available for reporting users. No auth required.","operationId":"FeedReportController_userReportReasons","parameters":[],"responses":{"200":{"description":"List of user report reason values and labels"}},"summary":"Get user report reasons","tags":["Reports"]}},"/api/report/comment":{"post":{"description":"\nReport a comment for violating community guidelines.\n\n### Rules\n- One report per user per comment (duplicate submissions return 409)\n- Cannot report your own comment\n- `commentId` accepts the comment's public id or its MongoDB ObjectId\n- Must provide a standardized reason from the available list\n- Optional `additionalInfo` (max 500 chars) for extra context\n\n### Available reasons\n`spam`, `harassment`, `hate_speech`, `sexual_content`, `violence`,\n`scam_or_fraud`, `misinformation`, `other`\n    ","operationId":"FeedReportController_reportComment","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["commentId","reason"],"properties":{"commentId":{"type":"string","example":"4821","description":"Comment to report — public id or ObjectId"},"reason":{"type":"string","enum":["spam","harassment","hate_speech","sexual_content","violence","scam_or_fraud","misinformation","other"],"example":"spam","description":"Reason for reporting"},"additionalInfo":{"type":"string","maxLength":500,"example":"This comment links to a phishing site","description":"Optional details (max 500 chars)"}}}}}},"responses":{"201":{"description":"Report submitted successfully"},"400":{"description":"Invalid input (missing fields, invalid reason, or self-report)"},"401":{"description":"Unauthorized — JWT required"},"404":{"description":"Comment or reporter account not found"},"409":{"description":"Duplicate — already reported this comment"}},"security":[{"user-auth":[]}],"summary":"Report a comment","tags":["Reports"]}},"/api/report/comment/status/{commentId}":{"get":{"description":"Check whether the authenticated user has already reported a specific comment.","operationId":"FeedReportController_commentReportStatus","parameters":[{"name":"commentId","required":true,"in":"path","description":"Comment to check — public id or ObjectId","schema":{"example":"4821","type":"string"}}],"responses":{"200":{"description":"Returns hasReported boolean and report details if exists"},"401":{"description":"Unauthorized — JWT required"}},"security":[{"user-auth":[]}],"summary":"Check if you reported a comment","tags":["Reports"]}},"/api/report/reasons/comment":{"get":{"description":"Returns the list of standardized reasons available for reporting comments. No auth required.","operationId":"FeedReportController_commentReportReasons","parameters":[],"responses":{"200":{"description":"List of comment report reason values and labels"}},"summary":"Get comment report reasons","tags":["Reports"]}},"/api/nft/reports":{"post":{"deprecated":true,"description":"Deprecated — use `POST /report/content` instead. Kept for backward compatibility.","operationId":"FeedReportController_addReport","parameters":[],"responses":{"201":{"description":""}},"summary":"[Legacy] Submit a content report","tags":["Reports"]},"get":{"deprecated":true,"description":"Deprecated — returns paginated list of all reports.","operationId":"FeedReportController_fetchFeedReport","parameters":[{"name":"limit","required":false,"in":"query","schema":{"example":10,"type":"number"}},{"name":"page","required":false,"in":"query","schema":{"example":1,"type":"number"}}],"responses":{"200":{"description":""}},"summary":"[Legacy] List all content reports","tags":["Reports"]}},"/api/reports/{tokenId}":{"get":{"deprecated":true,"description":"Deprecated — returns paginated reports for a specific tokenId.","operationId":"FeedReportController_fetchReportByTokenId","parameters":[{"name":"limit","required":false,"in":"query","schema":{"example":20,"type":"number"}},{"name":"page","required":false,"in":"query","schema":{"example":1,"type":"number"}},{"name":"tokenId","required":true,"in":"path","description":"Token ID","schema":{"type":"number"}}],"responses":{"200":{"description":""}},"summary":"[Legacy] Get reports for a token","tags":["Reports"]}},"/api/plans/{id}":{"get":{"operationId":"PlansController_getPlan","parameters":[],"responses":{"200":{"description":""}},"tags":["Subscriptions"]},"post":{"operationId":"PlansController_updatePlan","parameters":[],"responses":{"201":{"description":""}},"tags":["Subscriptions"]}},"/api/plans":{"get":{"operationId":"PlansController_getPlans","parameters":[],"responses":{"200":{"description":""}},"tags":["Subscriptions"]},"post":{"operationId":"PlansController_createPlan","parameters":[],"responses":{"201":{"description":""}},"tags":["Subscriptions"]}},"/api/plan/buy":{"post":{"operationId":"PlansController_buyPlan","parameters":[],"responses":{"201":{"description":""}},"tags":["Subscriptions"]}},"/api/subscription/me":{"get":{"operationId":"PlansController_getMySubscription","parameters":[],"responses":{"200":{"description":""}},"tags":["Subscriptions"]}},"/api/subscription/earnings":{"get":{"operationId":"PlansController_getSubscriptionEarnings","parameters":[],"responses":{"200":{"description":""}},"tags":["Subscriptions"]}},"/api/subscription/earnings/withdraw":{"post":{"operationId":"PlansController_withdrawSubscriptionEarnings","parameters":[],"responses":{"201":{"description":""}},"tags":["Subscriptions"]}},"/api/subscription-credits":{"get":{"operationId":"PlansController_getSubscriptionCredits","parameters":[],"responses":{"200":{"description":""}},"tags":["Subscriptions"]}},"/api/subscription-credits/topup":{"post":{"operationId":"PlansController_topUpSubscriptionCredits","parameters":[],"responses":{"201":{"description":""}},"tags":["Subscriptions"]}},"/api/subscription-credits/withdraw":{"post":{"operationId":"PlansController_withdrawSubscriptionCredits","parameters":[],"responses":{"201":{"description":""}},"tags":["Subscriptions"]}},"/api/internal/credits/debit":{"post":{"operationId":"PlansController_internalDebitCredits","parameters":[],"responses":{"201":{"description":""}},"tags":["Subscriptions"]}},"/api/internal/credits/refund":{"post":{"operationId":"PlansController_internalRefundCredits","parameters":[],"responses":{"201":{"description":""}},"tags":["Subscriptions"]}},"/api/subscription/{id}":{"get":{"operationId":"PlansController_getSubscription","parameters":[],"responses":{"200":{"description":""}},"tags":["Subscriptions"]}},"/api/plan/webhook/create":{"post":{"operationId":"PlansController_webhookPlanCreate","parameters":[],"responses":{"201":{"description":""}},"tags":["Subscriptions"]}},"/api/plan/buy/credits":{"post":{"operationId":"PlansController_buyPlanWithCredits","parameters":[],"responses":{"201":{"description":""}},"tags":["Subscriptions"]}},"/api/plan/webhook/purchased":{"post":{"operationId":"PlansController_webhookPlanPurchased","parameters":[],"responses":{"201":{"description":""}},"tags":["Subscriptions"]}},"/api/app-tokens/config":{"get":{"operationId":"AppTokensController_config","parameters":[],"responses":{"200":{"description":""}},"tags":["App tokens"]}},"/api/app-tokens/me":{"get":{"operationId":"AppTokensController_me","parameters":[{"name":"before","required":true,"in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["App tokens"]}},"/api/app-tokens/recipient":{"get":{"operationId":"AppTokensController_recipient","parameters":[{"name":"to","required":true,"in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["App tokens"]}},"/api/app-tokens/send":{"post":{"operationId":"AppTokensController_send","parameters":[],"responses":{"201":{"description":""}},"tags":["App tokens"]}},"/api/token-launch/notify":{"get":{"operationId":"TokenLaunchController_status","parameters":[],"responses":{"200":{"description":""}},"tags":["Token launch"]},"post":{"operationId":"TokenLaunchController_join","parameters":[],"responses":{"201":{"description":""}},"tags":["Token launch"]}},"/api/dm/search":{"get":{"deprecated":true,"description":"**Deprecated** — use `GET /users_search` instead. This endpoint will be removed in a future release.","operationId":"DMController_searchDm","parameters":[],"responses":{"200":{"description":""}},"summary":"Search users for DM","tags":["Direct Messages"]}},"/api/dm/messages/{id}":{"get":{"description":"Retrieve paginated messages for a conversation. Supports search with `q` query param.","operationId":"DMController_getMessagesDm","parameters":[{"name":"id","required":true,"in":"path","description":"Conversation ID","schema":{}}],"responses":{"200":{"description":""}},"summary":"Get paginated messages","tags":["Direct Messages"]}},"/api/dm/free-access":{"get":{"description":"Everyone **the caller** has granted free DM access to, taken from the auth token. The response echoes `address` — whose list it is.","operationId":"DMController_getMyFreeAccessUsers","parameters":[],"responses":{"200":{"description":""}},"summary":"List users I have given free DM access","tags":["Direct Messages"]},"post":{"description":"Adds `userAddress` to **the caller's own** free-access whitelist, exempting them from the caller's per-message fee. The creator is taken from the auth token; an `address` field in the body is ignored.","operationId":"DMController_addFreeAccess","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"userAddress":{"type":"string","description":"Wallet address to grant free access to"}},"required":["userAddress"]}}}},"responses":{"201":{"description":""}},"summary":"Grant free DM access to a user","tags":["Direct Messages"]},"delete":{"description":"Removes `userAddress` from **the caller's own** free-access whitelist. The creator is taken from the auth token; an `address` field in the body is ignored.","operationId":"DMController_removeFreeAccess","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"userAddress":{"type":"string","description":"Wallet address to revoke free access from"}},"required":["userAddress"]}}}},"responses":{"200":{"description":""}},"summary":"Revoke free DM access from a user","tags":["Direct Messages"]}},"/api/dm/{id}":{"get":{"description":"Retrieve a single DM conversation by ID.","operationId":"DMController_getContact","parameters":[{"name":"id","required":true,"in":"path","description":"Conversation ID","schema":{}}],"responses":{"200":{"description":""}},"summary":"Get single conversation","tags":["Direct Messages"]}},"/api/dm/contacts/{address}":{"get":{"description":"All of **the caller's own** conversations with the last 20 messages each, sorted by tip ranking. The account is taken from the auth token; the address in the path is ignored, as is any `address` query parameter. The `X-Subject-Address` response header echoes whose conversations these are.","operationId":"DMController_getContactsByAddress","parameters":[{"name":"address","required":true,"in":"path","description":"Ignored — kept so the URL is unchanged. The caller is taken from the token.","schema":{}}],"responses":{"200":{"description":""}},"summary":"Get my conversations","tags":["Direct Messages"]}},"/api/dm/create":{"post":{"description":"Find an existing DM or create a new one. Accepts recipientId (MongoDB ObjectId) or recipientAddress.","operationId":"DMController_createDm","parameters":[],"responses":{"201":{"description":""}},"summary":"Create or find a DM conversation","tags":["Direct Messages"]}},"/api/dm/upload":{"post":{"description":"Upload a single attachment as a DM message.\n\n- **Image** — JPG/PNG/GIF, max 10 MB\n- **Video** — MP4/MOV, max 50 MB\n- **Voice / audio** — MP3, M4A, OGG, WAV, WEBM, AAC, max 2 MB and 60s\n- **Document** — PDF, DOC(X), ODT, RTF, TXT, MD, XLS(X), ODS, CSV, PPT(X), ODP,\n  ZIP, RAR, 7Z, GZ, JSON, XML, EPUB, max 50 MB. Validated by extension **and**\n  declared MIME type, and stored with `Content-Disposition: attachment` so\n  nothing is ever rendered inline.","operationId":"DMController_uploadDm","parameters":[],"responses":{"201":{"description":""}},"summary":"Upload media message","tags":["Direct Messages"]}},"/api/dm/locked/{messageId}":{"get":{"description":"For a DM photo or video sent with `lockPriceUsd` (or the older DHB `lockPrice`). Returns `payTo` (the sender's address) and `lock`:\n`{ price, priceUsd, dhbPriceUsd?, symbol, kind, previewUrl, unlocked, mine, ready, unlockCount? , mediaUrl?, mediaUrlExpiresAt? }`.\n`priceUsd` is what the buyer pays; `price` is that in DHB at the live rate, the amount to tip.\n`mediaUrl` (a signed URL) is only present for the sender or someone who has paid.","operationId":"DMController_lockedStatus","parameters":[{"name":"messageId","required":true,"in":"path","description":"Message ID","schema":{}}],"responses":{"200":{"description":""}},"summary":"Locked photo or video: price, status and (if allowed) the real file","tags":["Direct Messages"]}},"/api/dm/locked/{messageId}/unlock":{"post":{"description":"Pay the sender first with an ordinary chat tip: `StreamController.sendTip(0, price, payTo, DHB)`, then post `{ txHash }` here.\nReturns `status: 'unlocked'` with `lock.mediaUrl`, or `status: 'pending'` while the payment confirms (a `dmMediaUnlocked` socket event follows; or call again).\nErrors: 402 PAYMENT_REJECTED (wrong amount, payer, recipient or token), 409 TX_ALREADY_USED.","operationId":"DMController_unlockLocked","parameters":[{"name":"messageId","required":true,"in":"path","description":"Message ID","schema":{}}],"responses":{"201":{"description":""}},"summary":"Unlock a locked photo or video with a payment","tags":["Direct Messages"]}},"/api/dm/block":{"post":{"deprecated":true,"description":"**Deprecated** — use `POST /block` instead. Delegates to the platform block system.","operationId":"DMController_blockDm","parameters":[],"responses":{"201":{"description":""}},"summary":"Block a user from a DM conversation","tags":["Direct Messages"]}},"/api/dm/verify-dm-fee":{"post":{"description":"Pre-verifies a txHash. **Prefer bundling txHash into the sendMessage socket event**. This endpoint is useful for pre-flight checks or REST-only clients.","operationId":"DMController_verifyDmFee","parameters":[],"responses":{"201":{"description":""}},"summary":"Verify a per-message fee payment against on-chain data","tags":["Direct Messages"]}},"/api/dm/tip-notify":{"post":{"description":"Cross-references a txHash against TransactionModel and creates a DmTips record that contributes to the sender's ranking in the creator's DM inbox.","operationId":"DMController_tipNotify","parameters":[],"responses":{"201":{"description":""}},"summary":"Record a verified in-chat tip for ranking","tags":["Direct Messages"]}},"/api/dm/free-access/{address}":{"get":{"description":"Everyone **the caller** has granted free DM access to. The account is taken from the auth token; the address in the path is ignored, as is any `address` query parameter. The response echoes `address` — whose list it is.","operationId":"DMController_getFreeAccessUsers","parameters":[{"name":"address","required":true,"in":"path","description":"Ignored — kept so the URL is unchanged. The caller is taken from the token.","schema":{}}],"responses":{"200":{"description":""}},"summary":"List users I have given free DM access","tags":["Direct Messages"]}},"/api/dm/un-block/{conversationId}":{"get":{"deprecated":true,"description":"**Deprecated** — use `DELETE /block/:address` instead.","operationId":"DMController_unBlock","parameters":[],"responses":{"200":{"description":""}},"summary":"Unblock a user from a DM conversation","tags":["Direct Messages"]}},"/api/dm/user-status/{address}":{"post":{"description":"Update DM settings for **the authenticated caller**: enable/disable DM access and optionally set a per-message fee. The account written is taken from the auth token; the address in the path is ignored, as is any `address` query parameter. The response echoes `address` — the account actually written.","operationId":"DMController_UserDMStatus","parameters":[{"name":"address","required":true,"in":"path","description":"Ignored — kept so the URL is unchanged. The caller is taken from the token.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["NEW_DM","ALL","ACTIVE_ALL"],"description":"DM opt-out flag to toggle"},"action":{"type":"string","enum":["enable","disable"],"description":"Whether to enable or disable the status flag"},"perMessageFee":{"type":"number","description":"DHB fee per incoming message (0 = free). Optional — only updates if provided.","example":5}},"required":["status","action"]}}}},"responses":{"200":{"description":"DM preferences updated successfully"},"400":{"description":"Invalid status, action, or perMessageFee value"}},"summary":"Update my DM preferences","tags":["Direct Messages"]}},"/api/dm/user-status/{targetAddress}":{"get":{"description":"Retrieve the DM settings for a given wallet address, including disables, perMessageFee, and freeAccessUsers.","operationId":"DMController_getUserDMStatus","parameters":[{"name":"targetAddress","required":true,"in":"path","description":"Wallet address to look up (lowercase)","schema":{"type":"string"}}],"responses":{"200":{"description":"DM settings returned"},"400":{"description":"Address is required"}},"summary":"Get DM preferences","tags":["Direct Messages"]}},"/api/dm/e2ee-key":{"post":{"description":"Store the caller's X25519 public key (base64, 32 bytes) so other users can encrypt direct messages to them. The caller is taken from the auth token; the private key never reaches the server.","operationId":"DMController_setE2eeKey","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"publicKey":{"type":"string","description":"Base64 X25519 public key (44 chars)"}},"required":["publicKey"]}}}},"responses":{"200":{"description":"Key stored"},"400":{"description":"Malformed key"}},"summary":"Publish the E2EE public key","tags":["Direct Messages"]}},"/api/dm/e2ee-key/{peer}":{"get":{"description":"Returns `{ address, publicKey }` for the address in the path; `publicKey` is null when that user has not enabled encrypted messages. `address` echoes back what was looked up, so a client can check it got an answer about the peer it asked about.","operationId":"DMController_getE2eeKey","parameters":[{"name":"peer","required":true,"in":"path","description":"Wallet address to look up (lowercase)","schema":{"type":"string"}}],"responses":{"200":{"description":"Key returned (possibly null)"}},"summary":"Get a user's E2EE public key","tags":["Direct Messages"]}},"/api/dm/message/{messageId}/viewed":{"post":{"description":"Called by the recipient after watching it once. The message and its file are then removed for both people.","operationId":"DMController_markViewed","parameters":[],"responses":{"201":{"description":""}},"summary":"Mark a view-once attachment as watched","tags":["Direct Messages"]}},"/api/dm/delete-messages":{"post":{"description":"Soft-delete all messages in a conversation for the authenticated user.","operationId":"DMController_deleteAllMessagesOneSide","parameters":[],"responses":{"201":{"description":""}},"summary":"Delete all messages (one side)","tags":["Direct Messages"]}},"/api/dm/forward":{"post":{"description":"\nForward a feed post (by tokenId) or an existing DM message (by messageId) to a conversation.\n\n### Authentication Required\nYou must be a participant of the target conversation.\n\n### Forwarding a feed post:\n```json\n{ \"conversationId\": \"...\", \"tokenId\": 123 }\n```\n\n### Forwarding an existing DM message:\n```json\n{ \"conversationId\": \"...\", \"messageId\": \"...\" }\n```\n\nThe forwarded message will have `isForwarded: true` and `forwardedFrom` set to the original source.\n    ","operationId":"DMController_forwardPostToDm","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["conversationId"],"properties":{"conversationId":{"type":"string","description":"Target conversation ID"},"messageId":{"type":"string","description":"ID of the DM message to forward (optional if tokenId provided)"},"tokenId":{"type":"number","description":"Token ID of the feed post to forward (optional if messageId provided)"}}}}}},"responses":{"200":{"description":"Message forwarded successfully"},"400":{"description":"Missing required fields"},"404":{"description":"Conversation, message, or post not found"}},"summary":"Forward a post or DM message to a conversation","tags":["Direct Messages"]}},"/api/dm/conversation/{dmId}":{"delete":{"description":"Permanently deletes a DM conversation for the authenticated user. Removes the other participant from the sender's contact list, hard-deletes all messages sent by the caller in this conversation, soft-deletes the remaining messages (from the other party), and removes the caller from the conversation's participants. The other user keeps their copy of the conversation intact.","operationId":"DMController_deleteConversation","parameters":[{"name":"dmId","required":true,"in":"path","description":"Conversation (DM) ID","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"address":{"type":"string","description":"Wallet address of the authenticated user (lowercase)"}},"required":["address"]}}}},"responses":{"200":{"description":"Conversation deleted successfully."},"400":{"description":"Missing or invalid parameters."},"404":{"description":"Conversation or user not found."}},"summary":"Delete a conversation permanently","tags":["Direct Messages"]}},"/api/dm/{dmId}/pin":{"post":{"description":"Pins a message to the conversation. The pinned message ID is stored on the DM document and returned in conversation data.","operationId":"DMController_pinMessage","parameters":[{"name":"dmId","required":true,"in":"path","description":"Conversation (DM) ID","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"address":{"type":"string","description":"Wallet address of the authenticated user (lowercase)"},"messageId":{"type":"string","description":"ID of the message to pin"}},"required":["address","messageId"]}}}},"responses":{"201":{"description":""}},"summary":"Pin a message in a DM conversation","tags":["Direct Messages"]},"delete":{"description":"Removes a pinned message from the conversation.","operationId":"DMController_unpinMessage","parameters":[{"name":"dmId","required":true,"in":"path","description":"Conversation (DM) ID","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"address":{"type":"string","description":"Wallet address of the authenticated user (lowercase)"},"messageId":{"type":"string","description":"ID of the message to unpin"}},"required":["address","messageId"]}}}},"responses":{"200":{"description":""}},"summary":"Unpin a message in a DM conversation","tags":["Direct Messages"]}},"/api/post-likers":{"get":{"description":"Paginated roll-call of everyone who reacted to a post, each row carrying its reaction key. Visible only to the post owner (or minter): any other caller — signed in or not — gets `canViewLikers: false`, an empty `data`, and `pagination.totalCount` holding the public positive-reaction count that the card already shows. Negative reactions and reactions from private-balance accounts never expose a person row; private holders are returned as one aggregate count.","operationId":"ReactionsController_getPostLikers","parameters":[{"name":"limit","required":false,"in":"query","description":"Max 50","schema":{"example":20,"type":"number"}},{"name":"page","required":false,"in":"query","description":"0-indexed page","schema":{"example":0,"type":"number"}},{"name":"tokenId","required":true,"in":"query","schema":{"example":123,"type":"number"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"properties":{"result":{"type":"boolean"},"canViewLikers":{"type":"boolean","description":"True only for the post owner/minter. False means `data` is empty by design, not because nobody reacted."},"data":{"type":"array","description":"Always empty unless the caller owns the post.","items":{"type":"object","properties":{"address":{"type":"string"},"username":{"type":"string","nullable":true},"displayName":{"type":"string","nullable":true},"avatarImageUrl":{"type":"string","nullable":true},"badgeBalance":{"type":"number"},"isFollowing":{"type":"boolean"},"reaction":{"type":"string","example":"love","description":"One of the ten reaction keys"},"likedAt":{"type":"string","format":"date-time"}}}},"reactionCounts":{"type":"object","nullable":true,"description":"Per-reaction totals for the whole post, or null when the caller is not the owner.","example":{"like":12,"love":3,"respect":0,"hot":1,"hundred":4,"lol":0,"sad":0,"cry":0,"poo":0,"dislike":2}},"anonymousBadgeHolderCount":{"type":"number","description":"Positive reactors hidden because private balance mode is enabled."},"pagination":{"type":"object","properties":{"page":{"type":"number"},"limit":{"type":"number"},"totalCount":{"type":"number"},"hasMore":{"type":"boolean"}}}}}}}}},"security":[{"user-auth":[]}],"summary":"Who reacted to a post (author only)","tags":["Reactions"]}},"/api/comment-likers":{"get":{"description":"Paginated roll-call of everyone who liked a comment or reply. Visible only to the comment's author: any other caller — signed in or not — gets `canViewLikers: false`, an empty `data`, and `pagination.totalCount` holding the public like count the comment row already shows. Rows come back newest first.","operationId":"ReactionsController_getCommentLikers","parameters":[{"name":"limit","required":false,"in":"query","description":"Max 50","schema":{"example":20,"type":"number"}},{"name":"page","required":false,"in":"query","description":"0-indexed page","schema":{"example":0,"type":"number"}},{"name":"commentId","required":true,"in":"query","schema":{"example":456,"type":"number"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"properties":{"result":{"type":"boolean"},"canViewLikers":{"type":"boolean","description":"True only for the comment author. False means `data` is empty by design, not because nobody liked it."},"data":{"type":"array","description":"Always empty unless the caller wrote the comment.","items":{"type":"object","properties":{"address":{"type":"string"},"username":{"type":"string","nullable":true},"displayName":{"type":"string","nullable":true},"avatarImageUrl":{"type":"string","nullable":true},"badgeBalance":{"type":"number"}}}},"anonymousBadgeHolderCount":{"type":"number","description":"Comment likers hidden because private balance mode is enabled."},"pagination":{"type":"object","properties":{"page":{"type":"number"},"limit":{"type":"number"},"totalCount":{"type":"number"},"hasMore":{"type":"boolean"}}}}}}}},"404":{"description":"Comment not found (or hidden/deleted)"}},"security":[{"user-auth":[]}],"summary":"Who liked a comment (author only)","tags":["Reactions"]}},"/api/comment_views":{"post":{"description":"Increments the view counter for each comment/reply ID in the array. Call this when a batch of comments becomes visible to the user. Max 100 IDs per call. No auth required.","operationId":"ReactionsController_recordCommentViews","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["commentIds"],"properties":{"commentIds":{"type":"array","items":{"type":"number"},"example":[42,43,44],"description":"Array of comment IDs (numeric) that were viewed"}}}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"properties":{"result":{"type":"boolean"},"updated":{"type":"number","description":"Number of comments whose view count was incremented"}}}}}}},"summary":"Record comment/reply views (batch)","tags":["Reactions"]}},"/api/request_comment":{"post":{"description":"\nAdd a text comment to a video or reply to an existing comment.\n\n### Authentication Required\nUser must be logged in and have commenting permissions.\n\n### Comment Types\nThis platform supports **four comment formats**:\n| Type | Endpoint | Required fields |\n|------|----------|------------------|\n| Text | `POST /request_comment` | `content` |\n| Image | `POST /comment_image` | `file` (content optional) |\n| Voice note | `POST /comment_audio` | `file` (content optional, max 30s) |\n| GIF | `POST /comment_gif` | `gifUrl` (content optional) |\n\n### Request Body:\n```json\n{\n  \"streamTokenId\": 123,\n  \"content\": \"Great video!\",\n  \"commentId\": 456  // Optional: parent comment ID for replies\n}\n```\n\n### Reply to Comment\nTo reply to an existing comment, pass the parent comment's `commentId`.\nReplies are nested under the parent comment via `replyIds` array.\n\n### Notifications\nThe video owner receives a notification when someone comments.\nThe parent comment author receives a notification for replies.\n    ","operationId":"ReactionsController_requestCommentPost","parameters":[],"requestBody":{"required":true,"description":"Comment data","content":{"application/json":{"schema":{"type":"object","required":["streamTokenId","content"],"properties":{"streamTokenId":{"type":"number","example":123,"description":"Token ID of the video to comment on"},"content":{"type":"string","example":"Great video! Really enjoyed this.","description":"Comment text content"},"commentId":{"type":"number","example":456,"description":"Parent comment ID (for replies only)"}}}}}},"responses":{"200":{"description":"Comment added successfully","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"boolean","example":true}}}}}},"400":{"description":"Comment content is required"},"401":{"description":"Unauthorized"},"404":{"description":"Token does not exist"}},"security":[{"user-auth":[]}],"summary":"Add a text comment to a video","tags":["Reactions"]}},"/api/comment_image":{"post":{"description":"\nAdd a comment with an optimized image attachment to a video.\n\n### Authentication Required\nUser must be logged in and have commenting permissions.\n\n### Image Optimization\nUploaded images are **automatically optimized** server-side before storage:\n- Resized to max **800px** width (never upscaled)\n- Converted to **JPEG** with mozjpeg compression at quality 70\n- EXIF/GPS metadata **stripped** for privacy\n- Typically **50-80% smaller** than the original\n\nThis means the frontend can send full-resolution photos from camera roll—the\nbackend handles compression. No client-side resizing needed.\n\n### File Upload\nSend as `multipart/form-data`. Supported input formats: JPG, PNG, WebP, HEIC.\nOutput is always JPEG.\n\n### Parameters (query string or body):\n| Param | Required | Description |\n|-------|----------|-------------|\n| `streamTokenId` | ✅ | Token ID of the video to comment on |\n| `content` | ❌ | Caption text (optional when image is provided) |\n| `commentId` | ❌ | Parent comment ID for replies |\n\n### Notifications\nThe video owner receives a `comment` notification.\nThe parent comment author receives a `comment_reply` notification for replies — `creator_comment_reply` instead when the reply is from the post creator.\n\n### Response:\n```json\n{\n  \"result\": true,\n  \"commentId\": 789\n}\n```\n    ","operationId":"ReactionsController_requestCommentImage","parameters":[{"name":"commentId","required":false,"in":"query","description":"Parent comment ID (for replies only)","schema":{"example":456,"type":"number"}},{"name":"content","required":false,"in":"query","description":"Caption text (optional when image is attached)","schema":{"example":"Check out this screenshot!","type":"string"}},{"name":"streamTokenId","required":true,"in":"query","description":"Token ID of the video to comment on","schema":{"example":123,"type":"number"}}],"requestBody":{"required":true,"description":"Image attachment (JPG, PNG, WebP, HEIC — auto-converted to optimized JPEG)","content":{"multipart/form-data":{"schema":{"type":"object","properties":{"file":{"type":"string","format":"binary","description":"Image file (max ~10 MB input, output ~100-300 KB after optimization)"}}}}}},"responses":{"200":{"description":"Comment with image added successfully","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"boolean","example":true},"commentId":{"type":"number","example":789}}}}}},"400":{"description":"Image file is required (unless content is provided)"},"401":{"description":"Unauthorized"},"404":{"description":"Token does not exist"}},"security":[{"user-auth":[]}],"summary":"Add a comment with an image attachment","tags":["Reactions"]}},"/api/comment_audio":{"post":{"description":"\nAdd a voice note comment to a video, like iMessage / WhatsApp / Instagram voice DMs.\n\n### Authentication Required\nUser must be logged in and have commenting permissions.\n\n### Voice Note Rules\n- **Max duration**: 30 seconds (validated server-side via ffprobe)\n- **Max file size**: 2 MB\n- **Supported formats**: M4A, AAC, MP3, OGG, Opus, WAV, WebM audio\n- **No transcoding** — the file is stored as-is for instant playback\n\n### File Upload\nSend as `multipart/form-data` with an audio file.\n\n### Parameters (query string or body):\n| Param | Required | Description |\n|-------|----------|-------------|\n| `streamTokenId` | ✅ | Token ID of the video to comment on |\n| `content` | ❌ | Optional text caption alongside the voice note |\n| `commentId` | ❌ | Parent comment ID for replies |\n\n### Notifications\n- Video owner receives a `comment` notification with \"🎤 Voice note\" preview\n- Parent comment author receives a `comment_reply` notification for replies (`creator_comment_reply` when the replier is the post creator)\n\n### Response:\n```json\n{\n  \"result\": true,\n  \"commentId\": 789,\n  \"audioUrl\": \"comments/voice-tk-123-1708300000000.m4a\",\n  \"audioDuration\": 12.5\n}\n```\n\n### Frontend Integration\n- **Record** using `MediaRecorder` (web) or native audio APIs (iOS/Android)\n- **Enforce 30s cap** client-side before uploading\n- **Show a waveform** player in the comment cell (like WhatsApp/iMessage)\n- Playback should use the `audioUrl` CDN link directly\n    ","operationId":"ReactionsController_requestCommentAudio","parameters":[{"name":"commentId","required":false,"in":"query","description":"Parent comment ID (for replies only)","schema":{"example":456,"type":"number"}},{"name":"content","required":false,"in":"query","description":"Optional text caption alongside the voice note","schema":{"example":"Listen to this!","type":"string"}},{"name":"streamTokenId","required":true,"in":"query","description":"Token ID of the video to comment on","schema":{"example":123,"type":"number"}}],"requestBody":{"required":true,"description":"Audio file (≤ 30s, ≤ 2 MB). Supported: M4A, AAC, MP3, OGG, Opus, WAV, WebM","content":{"multipart/form-data":{"schema":{"type":"object","properties":{"file":{"type":"string","format":"binary","description":"Audio file (voice note)"}}}}}},"responses":{"200":{"description":"Voice note comment added successfully","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"boolean","example":true},"commentId":{"type":"number","example":789},"audioUrl":{"type":"string","example":"comments/voice-tk-123-1708300000000.m4a"},"audioDuration":{"type":"number","example":12.5,"description":"Duration in seconds (max 30)"}}}}}},"400":{"description":"Audio file is required / unsupported format / file too large / voice note too long"},"401":{"description":"Unauthorized"},"404":{"description":"Token does not exist"}},"security":[{"user-auth":[]}],"summary":"Add a voice note comment (max 30 seconds)","tags":["Reactions"]}},"/api/comment_media":{"post":{"description":"\nOne attach button for comments and replies. Send `file` as multipart/form-data\nwith the same params as /comment_image (`streamTokenId`, optional `content`,\noptional `commentId` for a reply). The file's type picks the path:\n\n| Type | Limits | Stored as |\n|------|--------|-----------|\n| image/* | ~10 MB in | `imageUrl` (optimised, same as /comment_image) |\n| video/* | 50 MB, must be readable by ffprobe | `videoUrl` (as-is) |\n| audio/* | 20 MB, 15 min, m4a/aac/mp3/ogg/opus/wav/webm | `audioUrl` + `audioDuration` |\n    ","operationId":"ReactionsController_requestCommentMedia","parameters":[],"responses":{"200":{"description":"Comment added"},"400":{"description":"File missing, unreadable or over a limit"},"413":{"description":"Video too large"},"415":{"description":"Not an image, video or audio file"}},"security":[{"user-auth":[]}],"summary":"Add a comment with any media attachment (image, video or audio)","tags":["Reactions"]}},"/api/tip_message":{"post":{"description":"\nWrite the message **before** sending the tip, then call again with\n`messageId` and `txHash` once the tip confirms. The message posts only\nafter the tip is confirmed on-chain, as a top-level comment carrying `tip`\n(or, with `isPrivate`, only in the creator's notification).\n\nBody: `streamTokenId`, `amount`, `content` (≤ 280) and/or `gifUrl`,\noptional `isPrivate`. Second call: `messageId`, `txHash`.\n    ","operationId":"ReactionsController_tipMessage","parameters":[],"responses":{"201":{"description":""}},"security":[{"user-auth":[]}],"summary":"Send a message with a tip on a post","tags":["Reactions"]}},"/api/comment_gif":{"post":{"description":"\nAdd a GIF comment to a video or reply to an existing comment.\nWorks exactly like Twitter, Discord, and Instagram — the frontend uses a GIF picker\n(Giphy/Tenor SDK) and sends the selected GIF URL to the backend. No file upload needed.\n\n### Authentication Required\nUser must be logged in and have commenting permissions.\n\n### How It Works\n1. Frontend shows a GIF picker powered by **Giphy** or **Tenor** SDK\n2. User selects a GIF—the SDK gives you a URL\n3. Frontend sends the URL as `gifUrl` to this endpoint\n4. Backend validates the URL origin and stores it as-is (no re-hosting)\n\n### Allowed GIF Providers\n- **Giphy**: `media.giphy.com`, `media0-4.giphy.com`, `i.giphy.com`\n- **Tenor**: `tenor.com`, `media.tenor.com`, `c.tenor.com`\n\n### Parameters (body):\n| Param | Required | Description |\n|-------|----------|-------------|\n| `streamTokenId` | ✅ | Token ID of the video to comment on |\n| `gifUrl` | ✅ | Full GIF URL from Giphy or Tenor |\n| `content` | ❌ | Optional text caption alongside the GIF |\n| `commentId` | ❌ | Parent comment ID for replies |\n\n### Example Request:\n```json\n{\n  \"streamTokenId\": 123,\n  \"gifUrl\": \"https://media.giphy.com/media/abc123/giphy.gif\",\n  \"content\": \"This is so funny!\"\n}\n```\n\n### Notifications\n- Video owner receives a `comment` notification with \"GIF\" preview\n- Parent comment author receives a `comment_reply` notification for replies (`creator_comment_reply` when the replier is the post creator)\n\n### Frontend Integration (Giphy SDK)\n```typescript\nimport { GiphyFetch } from '@giphy/js-fetch-api';\nconst gf = new GiphyFetch('YOUR_GIPHY_API_KEY');\n\n// Search GIFs\nconst { data } = await gf.search('funny cat', { limit: 20 });\n\n// User selects a GIF → get the URL\nconst gifUrl = data[0].images.fixed_height.url;\n//  \"https://media4.giphy.com/media/abc123/giphy.gif\"\n\n// Send to backend\nawait api.post('/comment_gif', {\n  streamTokenId: 123,\n  gifUrl,\n  content: 'lol' // optional caption\n});\n```\n\n### Frontend Integration (Tenor API)\n```typescript\nconst res = await fetch(\n  'https://tenor.googleapis.com/v2/search?q=funny&key=YOUR_API_KEY&limit=20'\n);\nconst { results } = await res.json();\n\n// User selects a GIF → get the URL\nconst gifUrl = results[0].media_formats.gif.url;\n//  \"https://media.tenor.com/abc123/tenor.gif\"\n\nawait api.post('/comment_gif', { streamTokenId: 123, gifUrl });\n```\n    ","operationId":"ReactionsController_requestCommentGif","parameters":[],"requestBody":{"required":true,"description":"GIF comment data","content":{"application/json":{"schema":{"type":"object","required":["streamTokenId","gifUrl"],"properties":{"streamTokenId":{"type":"number","example":123,"description":"Token ID of the video to comment on"},"gifUrl":{"type":"string","example":"https://media.giphy.com/media/abc123/giphy.gif","description":"Full GIF URL from Giphy or Tenor"},"content":{"type":"string","example":"This is hilarious!","description":"Optional text caption alongside the GIF"},"commentId":{"type":"number","example":456,"description":"Parent comment ID (for replies only)"}}}}}},"responses":{"200":{"description":"GIF comment added successfully","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"boolean","example":true},"commentId":{"type":"number","example":789}}}}}},"400":{"description":"gifUrl is required / invalid URL / unsupported GIF provider"},"401":{"description":"Unauthorized"},"404":{"description":"Token does not exist"}},"security":[{"user-auth":[]}],"summary":"Add a comment with a GIF (Giphy/Tenor URL)","tags":["Reactions"]}},"/api/request_vote":{"post":{"description":"\nCast a vote (like or dislike) on a video. Votes can be toggled and changed.\n\n### Authentication Required\nUser must be logged in.\n\n### Vote Types:\n- **vote=true**: Like/upvote the video\n- **vote=false**: Dislike/downvote the video\n\n### Toggle Behavior:\n- If you send **vote=true** and you've already **liked** → removes your like\n- If you send **vote=true** and you've already **disliked** → removes dislike, adds like\n- If you send **vote=false** and you've already **disliked** → removes your dislike\n- If you send **vote=false** and you've already **liked** → removes like, adds dislike\n- If you haven't voted → adds your vote\n\n### Vote Weight:\nA vote counts for the voter's badge weight — 1 with no badge, 2 at Crab, up to\n14 at Megalodon. One vote per person either way; the badge multiplies what that\none vote counts for, and `weight` in the response is what was applied. The\nweight is frozen when the vote is cast, so removing it takes off exactly what it\nput on even if the voter has since changed tier.\n\n### Side Effects:\n1. Updates the video's `totalVotes.for` or `totalVotes.against` count by that weight\n2. If liking, adds to user's liked videos (appears in `/liked_videos`)\n3. If removing like or disliking, removes from liked videos\n4. Sends a notification to video owner on new likes only\n\n### Request Body:\n```json\n{\n  \"streamTokenId\": 123,\n  \"vote\": true\n}\n```\n\n### Response:\n```json\n{\n  \"result\": true,\n  \"action\": \"added\",     // \"added\", \"removed\", or \"changed\"\n  \"currentVote\": true,   // true (liked), false (disliked), or null (no vote)\n  \"weight\": 1            // what the vote counted for (badge tier)\n}\n```\n    ","operationId":"ReactionsController_requestVote","parameters":[],"requestBody":{"required":true,"description":"Vote data","content":{"application/json":{"schema":{"type":"object","required":["streamTokenId","vote"],"properties":{"streamTokenId":{"type":"number","example":123,"description":"Token ID of the video to vote on"},"vote":{"type":"boolean","example":true,"description":"true for like/upvote, false for dislike/downvote"}}}}}},"responses":{"200":{"description":"Vote recorded/toggled successfully","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"boolean","example":true},"action":{"type":"string","enum":["added","removed","changed"],"example":"added"},"currentVote":{"type":"boolean","nullable":true,"example":true,"description":"Current vote state: true=liked, false=disliked, null=no vote"},"weight":{"type":"number","example":1,"description":"What the vote counted for — the voter's badge weight, 1 with no badge up to 14 at Megalodon"}}}}}},"400":{"description":"Vote param is required"},"401":{"description":"Unauthorized"},"404":{"description":"Stream doesn't exist"}},"security":[{"user-auth":[]}],"summary":"Like or dislike a video (toggleable)","tags":["Reactions"]}},"/api/request_reaction":{"post":{"description":"\nCast, switch, or remove a reaction on a post: like, love, respect, hot, hundred, lol, sad, cry, poo, dislike.\n\n### Authentication Required\nUser must be logged in.\n\n### Toggle Behavior\n- Sending the reaction the viewer already holds **removes** it (same \"toggle off\" contract as `/request_vote`).\n- Sending a different reaction of the **same polarity** (e.g. like -> love) swaps it in place — `totalVotes` does not move, only the per-reaction breakdown.\n- Sending a reaction of the **opposite polarity** (e.g. like -> dislike) moves `totalVotes.for`/`against` across, exactly like `/request_vote`.\n\n### Reaction Weight\nA reaction counts for the reactor's badge weight — 1 with no badge, 2 at Crab,\nup to 14 at Megalodon — and `weight` in the response is what was applied. Still\none reaction per person: the badge multiplies the one they have, it does not\nbuy them another. The weight is frozen when the reaction is first cast, so a\nswap or a toggle off moves exactly what was originally added.\n\n### Request Body\n```json\n{ \"streamTokenId\": 123, \"reaction\": \"love\" }\n```\n    ","operationId":"ReactionsController_requestPostReactionRoute","parameters":[],"requestBody":{"required":true,"description":"Reaction data","content":{"application/json":{"schema":{"type":"object","required":["streamTokenId","reaction"],"properties":{"streamTokenId":{"type":"number","example":123,"description":"Token ID of the post to react to"},"reaction":{"type":"string","enum":["like","love","respect","hot","hundred","lol","sad","cry","poo","dislike"],"example":"love"}}}}}},"responses":{"200":{"description":"Reaction recorded/toggled successfully","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"boolean","example":true},"action":{"type":"string","enum":["added","removed","changed"],"example":"added"},"currentVote":{"type":"boolean","nullable":true,"example":true,"description":"Polarity now in force: true = positive, false = negative, null = none"},"reaction":{"type":"string","example":"love","description":"The reaction that was requested"},"currentReaction":{"type":"string","nullable":true,"example":"love","description":"The reaction now in force — null when the request toggled it off"},"previousReaction":{"type":"string","nullable":true,"example":"like"},"weight":{"type":"number","example":1,"description":"What the reaction counted for — the reactor's badge weight, 1 with no badge up to 14 at Megalodon"}}}}}},"400":{"description":"reaction must be one of the nine supported values"},"401":{"description":"Unauthorized"},"404":{"description":"Stream doesn't exist"}},"security":[{"user-auth":[]}],"summary":"Cast one of the ten reactions on a post (toggleable)","tags":["Reactions"]}},"/api/post-reactions":{"get":{"description":"Public — no auth required. `myReaction` is only populated when a valid JWT is supplied, otherwise null.","operationId":"ReactionsController_getPostReactionsRoute","parameters":[{"name":"tokenId","required":true,"in":"query","schema":{"example":123,"type":"number"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"properties":{"result":{"type":"boolean"},"tokenId":{"type":"number"},"counts":{"type":"object","example":{"like":10,"love":3,"hot":2}},"total":{"type":"number","description":"Every reaction, both polarities"},"topReaction":{"type":"string","nullable":true},"myReaction":{"type":"string","nullable":true}}}}}}},"security":[{"user-auth":[]}],"summary":"Per-post reaction breakdown","tags":["Reactions"]}},"/api/request_follow":{"post":{"description":"\nFollow or unfollow another user on the platform. Supports **Instagram-style follow requests** for private accounts.\n\n### Authentication Required\nUser must be logged in. The follower address is extracted from JWT.\n\n### Follow a User (public account — instant follow):\n```json\n// Request\n{ \"following\": \"0x1234...\" }\n// Response\n{ \"result\": true, \"status\": \"accepted\", \"isPrivateAccount\": false }\n```\n\n### Follow a User (private account — pending request):\n```json\n// Request\n{ \"following\": \"0x1234...\" }\n// Response\n{ \"result\": true, \"status\": \"pending\", \"isPrivateAccount\": true }\n```\n\n### Unfollow / Cancel Pending Request:\n```json\n// Request\n{ \"following\": \"0x1234...\", \"unFollowing\": true }\n// Response (was accepted follow)\n{ \"result\": true, \"wasPending\": false }\n// Response (was pending request — cancelled)\n{ \"result\": true, \"wasPending\": true }\n```\n\n### Notifications\n- **Public account follow**: recipient receives a `following` notification\n- **Private account follow request**: recipient receives a `follow_request` notification\n- **No notification** on unfollow or cancel\n\n### Side Effects\n- For public accounts: updates follower/following counts immediately\n- For private accounts: counts are NOT updated until the request is accepted\n- Creates activity record for follow actions\n    ","operationId":"ReactionsController_requestFollow","parameters":[],"requestBody":{"required":true,"description":"Follow/unfollow data","content":{"application/json":{"schema":{"type":"object","required":["following"],"properties":{"following":{"type":"string","example":"0x1234567890abcdef1234567890abcdef12345678","description":"Wallet address of the user to follow/unfollow"},"unFollowing":{"type":"boolean","example":false,"description":"Set to true to unfollow or cancel a pending request"}}}}}},"responses":{"200":{"description":"Follow/unfollow/request successful","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"boolean","example":true},"status":{"type":"string","enum":["accepted","pending"],"description":"Follow result status (only on follow, not unfollow)","example":"accepted"},"isPrivateAccount":{"type":"boolean","description":"Whether target is a private account (only on follow)","example":false},"wasPending":{"type":"boolean","description":"Whether the removed follow was a pending request (only on unfollow)","example":false}}}}}},"400":{"description":"Following param is required or invalid address"},"401":{"description":"Unauthorized"},"409":{"description":"Follow request already pending or already following user"}},"security":[{"user-auth":[]}],"summary":"Follow, unfollow, or request to follow a user","tags":["Reactions"]}},"/api/request_follow/batch":{"post":{"description":"\nUsed by \"Follow back all\". Follows (or sends follow requests to private accounts) up to 100 users per call,\nso a long follower list costs a handful of requests instead of one per person.\n\n```json\n// Request\n{ \"addresses\": [\"0x1234...\", \"0xabcd...\"] }\n// Response\n{ \"result\": true, \"results\": [{ \"address\": \"0x1234...\", \"ok\": true, \"status\": \"accepted\" }] }\n```\n    ","operationId":"ReactionsController_requestFollowBatch","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["addresses"],"properties":{"addresses":{"type":"array","items":{"type":"string"},"maxItems":100}}}}}},"responses":{"201":{"description":""}},"security":[{"user-auth":[]}],"summary":"Follow many users in one call","tags":["Reactions"]}},"/api/like_comment":{"post":{"description":"\nLike or unlike a comment. This endpoint toggles the like status.\n\n### Authentication Required\nUser must be logged in.\n\n### Toggle Behavior:\n- If you haven't liked the comment → adds your like\n- If you've already liked the comment → removes your like\n\n### Request Body:\n```json\n{\n  \"commentId\": 456\n}\n```\n\n### Response:\n```json\n{\n  \"result\": true,\n  \"liked\": true,        // Current like status\n  \"likes\": 15           // Total likes on the comment\n}\n```\n    ","operationId":"ReactionsController_likeComment","parameters":[],"requestBody":{"required":true,"description":"Comment like data","content":{"application/json":{"schema":{"type":"object","required":["commentId"],"properties":{"commentId":{"type":"number","example":456,"description":"ID of the comment to like/unlike"}}}}}},"responses":{"200":{"description":"Like toggled successfully","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"boolean","example":true},"liked":{"type":"boolean","example":true,"description":"Whether user now likes the comment"},"likes":{"type":"number","example":15,"description":"Total likes on the comment"}}}}}},"400":{"description":"commentId is required"},"401":{"description":"Unauthorized"},"404":{"description":"Comment not found"}},"security":[{"user-auth":[]}],"summary":"Like or unlike a comment (toggleable)","tags":["Reactions"]}},"/api/dislike_comment":{"post":{"description":"\nDislike or un-dislike a comment. This endpoint toggles the dislike status.\n\n### Authentication Required\nUser must be logged in.\n\n### Toggle Behavior:\n- If you haven't disliked the comment → adds your dislike\n- If you've already disliked the comment → removes your dislike\n- Disliking a comment you liked replaces your like (one vote per viewer)\n\n### Request Body:\n```json\n{\n  \"commentId\": 456\n}\n```\n\n### Response:\n```json\n{\n  \"result\": true,\n  \"disliked\": true,     // Current dislike status\n  \"dislikes\": 4         // Total dislikes on the comment\n}\n```\n    ","operationId":"ReactionsController_dislikeComment","parameters":[],"requestBody":{"required":true,"description":"Comment dislike data","content":{"application/json":{"schema":{"type":"object","required":["commentId"],"properties":{"commentId":{"type":"number","example":456,"description":"ID of the comment to dislike/un-dislike"}}}}}},"responses":{"200":{"description":"Dislike toggled successfully","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"boolean","example":true},"disliked":{"type":"boolean","example":true,"description":"Whether user now dislikes the comment"},"dislikes":{"type":"number","example":4,"description":"Total dislikes on the comment"}}}}}},"400":{"description":"commentId is required"},"401":{"description":"Unauthorized"},"404":{"description":"Comment not found"}},"security":[{"user-auth":[]}],"summary":"Dislike or un-dislike a comment (toggleable)","tags":["Reactions"]}},"/api/react_comment":{"post":{"description":"\nCast a reaction on a comment. Supersedes `/like_comment` and\n`/dislike_comment`, which remain as the plain-like and plain-dislike doors\nonto the same state machine.\n\n### Toggle Behavior\n- Sending the reaction you already hold removes it\n- Sending a different one replaces it\n\n`likes`/`dislikes` are the POLARITY rollup and only move when the side you\nare on changes — swapping 👍 for ❤️ leaves the comment's like count alone.\n\n### Request Body:\n```json\n{\n  \"commentId\": 456,\n  \"reaction\": \"love\"\n}\n```\n    ","operationId":"ReactionsController_reactComment","parameters":[],"requestBody":{"required":true,"description":"Comment reaction data","content":{"application/json":{"schema":{"type":"object","required":["commentId","reaction"],"properties":{"commentId":{"type":"number","example":456,"description":"ID of the comment to react to"},"reaction":{"type":"string","enum":["like","love","respect","hot","hundred","lol","sad","cry","poo","dislike"],"example":"love"}}}}}},"responses":{"200":{"description":"Reaction cast successfully","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"boolean","example":true},"action":{"type":"string","enum":["added","removed","changed"]},"currentReaction":{"type":"string","nullable":true,"example":"love"},"previousReaction":{"type":"string","nullable":true,"example":"like"},"liked":{"type":"boolean","example":true,"description":"Whether the viewer now holds a positive reaction"},"disliked":{"type":"boolean","example":false},"likes":{"type":"number","example":15},"dislikes":{"type":"number","example":0},"reactionCounts":{"type":"object","additionalProperties":{"type":"number"},"example":{"like":14,"love":1}}}}}}},"400":{"description":"commentId or reaction missing/invalid"},"401":{"description":"Unauthorized"},"404":{"description":"Comment not found"}},"security":[{"user-auth":[]}],"summary":"React to a comment or reply with one of the ten reactions","tags":["Reactions"]}},"/api/delete_comment":{"delete":{"description":"\nDelete a comment and all its nested replies.\n\n### Authentication Required\nUser must be logged in.\n\n### Who Can Delete:\n- **Comment author** — can always delete their own comment\n- **Video owner** — can delete any comment on their video (moderation)\n\n### Cascade Behavior:\nWhen a top-level comment is deleted, **all nested replies** are also deleted.\nWhen a reply is deleted, it is removed from its parent's `replyIds` array.\n\n### Side Effects:\n1. Recursively deletes all nested replies\n2. Removes the comment ID from the parent's `replyIds` (if a reply)\n3. Deletes attached CDN media (images and voice notes)\n4. Withdraws unread notifications (comment, comment_reply, creator_comment_reply, comment_like)\n5. Decrements the video's comment count\n\n### Request Body:\n```json\n{\n  \"commentId\": 456\n}\n```\n\n### Response:\n```json\n{\n  \"result\": true,\n  \"commentId\": 456,\n  \"deletedCount\": 3  // This comment + 2 nested replies\n}\n```\n    ","operationId":"ReactionsController_deleteComment","parameters":[],"requestBody":{"required":true,"description":"Comment to delete","content":{"application/json":{"schema":{"type":"object","required":["commentId"],"properties":{"commentId":{"type":"number","example":456,"description":"ID of the comment to delete"}}}}}},"responses":{"200":{"description":"Comment deleted successfully","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"boolean","example":true},"commentId":{"type":"number","example":456},"deletedCount":{"type":"number","example":3,"description":"Total comments deleted (including nested replies)"}}}}}},"400":{"description":"commentId is required"},"401":{"description":"Unauthorized"},"403":{"description":"You can only delete your own comments or comments on your videos"},"404":{"description":"Comment not found"}},"security":[{"user-auth":[]}],"summary":"Delete a comment or reply","tags":["Reactions"]}},"/api/pin_comment":{"post":{"description":"\nToggle the creator's pin on a top-level comment.\n\n### Authentication Required\nUser must be logged in.\n\n### Who Can Pin\n- **The post's creator only.** A comment's own author cannot pin it — that\n  would let anyone take the top of somebody else's thread for free. The paid\n  equivalent for a comment author is the `comment_anchor` SuperPower.\n\n### Rules\n- **Top-level comments only.** A reply sits in a subtree nothing re-orders.\n- **One pin per post.** Pinning a second comment moves the pin off the first.\n- Calling it again on the pinned comment removes the pin.\n\n### Where It Shows\n`GET /nft/:tokenId/comments` sorts a pinned comment first — above a paid\nComment Anchor, which is above everything else — and returns `isPinned` on\nevery row so clients can badge it.\n\n### Request Body\n```json\n{ \"commentId\": 456 }\n```\n    ","operationId":"ReactionsController_pinComment","parameters":[],"requestBody":{"required":true,"description":"Comment to pin or unpin","content":{"application/json":{"schema":{"type":"object","required":["commentId"],"properties":{"commentId":{"type":"number","example":456,"description":"ID of the top-level comment"}}}}}},"responses":{"200":{"description":"Pin toggled","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"boolean","example":true},"commentId":{"type":"number","example":456},"tokenId":{"type":"number","example":123},"pinned":{"type":"boolean","example":true,"description":"Whether the comment is now pinned"}}}}}},"400":{"description":"commentId is required, or the comment is a reply"},"401":{"description":"Unauthorized"},"403":{"description":"Only the creator of this post can pin a comment on it"},"404":{"description":"Comment not found"}},"security":[{"user-auth":[]}],"summary":"Pin a comment to the top of a post","tags":["Reactions"]}},"/api/edit_comment":{"post":{"description":"\nEdit the content of a comment you previously posted.\n\n### Authentication Required\nUser must be logged in.\n\n### Ownership Check\nYou can only edit comments that you authored. Attempting to edit another user's comment will return a 409 Conflict error.\n\n### Example:\n`POST /edit_comment?commentId=456&content=Updated%20comment%20text`\n\n### Response:\n```json\n{\n  \"result\": true,\n  \"commentId\": 456,\n  \"content\": \"Updated comment text\",\n  \"edited\": true\n}\n```\n    ","operationId":"ReactionsController_editComment","parameters":[{"name":"content","required":true,"in":"query","description":"New content for the comment","schema":{"example":"Updated comment text","type":"string"}},{"name":"commentId","required":true,"in":"query","description":"ID of the comment to edit","schema":{"example":456,"type":"number"}}],"responses":{"200":{"description":"Comment edited successfully","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"boolean","example":true},"commentId":{"type":"number","example":456},"content":{"type":"string","example":"Updated comment text"},"edited":{"type":"boolean","example":true}}}}}},"400":{"description":"commentId and content are required"},"401":{"description":"Unauthorized"},"404":{"description":"Comment not found"},"409":{"description":"You can only edit your own comments"}},"security":[{"user-auth":[]}],"summary":"Edit an existing comment","tags":["Reactions"]}},"/api/comment/{commentId}/replies":{"get":{"description":"Supports infinite nesting — pass the returned reply IDs as commentId to fetch their own replies. A reply the post creator reacted to positively carries `creatorLiked: true`; the field is absent otherwise.","operationId":"ReactionsController_getCommentReplies","parameters":[{"name":"commentId","required":true,"in":"path","schema":{"type":"string"}},{"name":"address","required":false,"in":"query","description":"Viewer address for isLiked field","schema":{"type":"string"}},{"name":"limit","required":false,"in":"query","description":"Max replies to return (default: 20, max: 50)","schema":{"type":"number"}},{"name":"skip","required":false,"in":"query","description":"Number of replies to skip (default: 0)","schema":{"type":"number"}}],"responses":{"200":{"description":""}},"summary":"Get paginated replies for a comment","tags":["Reactions"]}},"/api/leaderboard":{"get":{"description":"\nReturns the top DHB token holders, tippers, and most-followed / most-liked creators on the platform.\n\n## Sorting Options\nUse the `sort` query parameter to change ranking:\n\n| Value | Description |\n|---|---|\n| **holdings** (default) | Total DHB holdings (walletBalance + staked across BSC & Base) |\n| **sentTips** | Total tips sent to other users |\n| **receivedTips** | Total tips received from other users |\n| **followers** | Number of followers |\n| **likes** | Total likes received on all content |\n\n## Response Data\nEach entry includes:\n- **account**: Wallet address\n- **total**: Combined walletBalance + staked DHB (across BSC & Base chains)\n- **username**: User's username (if set)\n- **userDisplayName**: User's display name\n- **avatarUrl**: User's avatar image URL\n- **sentTips**: Total DHB sent as tips\n- **receivedTips**: Total DHB received as tips\n- **followers**: Number of followers\n- **likes**: Total likes received on all content\n\n## Filtering\n- Only users with holdings > 0, tips activity, followers > 0, or likes > 0 are included\n- Results limited to top 500 users per sort category\n- Some addresses are blacklisted from the leaderboard\n\n### Example\n`GET /leaderboard?sort=holdings`\n`GET /leaderboard?sort=followers`\n    ","operationId":"LeaderboardController_getUsernames","parameters":[{"name":"sort","required":false,"in":"query","description":"Sort/rank by criteria","schema":{"enum":["holdings","sentTips","receivedTips","followers","likes"],"type":"string"}}],"responses":{"200":{"description":"Leaderboard data retrieved","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"object","properties":{"byWalletBalance":{"type":"array","description":"Ranked list of users","items":{"type":"object","properties":{"account":{"type":"string","example":"0x1234...","description":"Wallet address"},"total":{"type":"number","example":150000,"description":"Total DHB (walletBalance + staked across BSC & Base)"},"username":{"type":"string","example":"whaleuser","description":"Username if set"},"userDisplayName":{"type":"string","example":"Big Whale","description":"Display name"},"avatarUrl":{"type":"string","example":"avatars/abc.jpg","description":"Avatar image URL"},"sentTips":{"type":"number","example":5000,"description":"Total DHB tips sent"},"receivedTips":{"type":"number","example":2500,"description":"Total DHB tips received"},"followers":{"type":"number","example":1500,"description":"Number of followers"},"likes":{"type":"number","example":8750,"description":"Total likes received on all content"}}}}}}}}}}}},"summary":"Get DHB token leaderboard","tags":["Leaderboard"]}},"/api/chat-image":{"post":{"description":"\nUpload up to 5 images for use in chat messages.\n\n### File Upload\nSend images as multipart/form-data with field name `images`.\n\n### Supported Formats\n- JPG/JPEG\n- PNG\n\n### Returns\nArray of uploaded image URLs that can be used in chat messages.\n    ","operationId":"AssetController_uploadChatImages","parameters":[],"requestBody":{"required":true,"description":"Images to upload (max 5)","content":{"multipart/form-data":{"schema":{"type":"object","properties":{"images":{"type":"array","items":{"type":"string","format":"binary"},"description":"Image files (max 5)"}}}}}},"responses":{"200":{"description":"Images uploaded successfully","content":{"application/json":{"schema":{"type":"object","properties":{"urls":{"type":"array","items":{"type":"string"},"example":["chat-images/abc123.jpg","chat-images/def456.jpg"]}}}}}},"400":{"description":"No images provided"}},"summary":"Upload images for chat/DM","tags":["Assets"]}},"/api/nft_metadata/{id}":{"get":{"description":"\nReturns the NFT metadata in standard format for marketplaces and wallets.\n\nUsed by NFT marketplaces to display token information.\n    ","operationId":"AssetController_fintMeta","parameters":[{"name":"id","required":true,"in":"path","description":"Token ID","schema":{"example":"123","type":"string"}}],"responses":{"200":{"description":"NFT metadata JSON","content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","example":"Video #123"},"description":{"type":"string"},"image":{"type":"string","example":"https://cdn.../images/123.jpg"},"animation_url":{"type":"string","example":"https://cdn.../videos/123.mp4"},"attributes":{"type":"array","items":{"type":"object"}}}}}}}},"summary":"Get NFT metadata JSON","tags":["Assets"]}},"/api/live-overlay/mine":{"get":{"operationId":"LiveOverlayController_mine","parameters":[],"responses":{"200":{"description":""}},"tags":["Live overlay"]},"put":{"operationId":"LiveOverlayController_save","parameters":[],"responses":{"200":{"description":""}},"tags":["Live overlay"]}},"/api/live-overlay/mine/controls":{"put":{"operationId":"LiveOverlayController_setControls","parameters":[],"responses":{"200":{"description":""}},"tags":["Live overlay"]}},"/api/live-overlay/mine/rotate":{"post":{"operationId":"LiveOverlayController_rotate","parameters":[],"responses":{"201":{"description":""}},"tags":["Live overlay"]}},"/api/live-overlay/mine/test":{"post":{"operationId":"LiveOverlayController_test","parameters":[],"responses":{"201":{"description":""}},"tags":["Live overlay"]}},"/api/live-overlay/mine/rules":{"get":{"operationId":"LiveOverlayController_rules","parameters":[],"responses":{"200":{"description":""}},"tags":["Live overlay"]},"put":{"operationId":"LiveOverlayController_saveRules","parameters":[],"responses":{"200":{"description":""}},"tags":["Live overlay"]}},"/api/live-overlay/mine/rules/{ruleId}/test":{"post":{"operationId":"LiveOverlayController_testRule","parameters":[{"name":"ruleId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":""}},"tags":["Live overlay"]}},"/api/live-overlay/mine/poll":{"get":{"operationId":"LiveOverlayController_poll","parameters":[],"responses":{"200":{"description":""}},"tags":["Live overlay"]},"post":{"operationId":"LiveOverlayController_startPoll","parameters":[],"responses":{"201":{"description":""}},"tags":["Live overlay"]}},"/api/live-overlay/mine/poll/close":{"post":{"operationId":"LiveOverlayController_closePoll","parameters":[],"responses":{"201":{"description":""}},"tags":["Live overlay"]}},"/api/live-overlay/mine/subathon":{"get":{"operationId":"LiveOverlayController_subathon","parameters":[],"responses":{"200":{"description":""}},"tags":["Live overlay"]},"put":{"operationId":"LiveOverlayController_saveSubathon","parameters":[],"responses":{"200":{"description":""}},"tags":["Live overlay"]}},"/api/live-overlay/mine/subathon/start":{"post":{"operationId":"LiveOverlayController_startSubathon","parameters":[],"responses":{"201":{"description":""}},"tags":["Live overlay"]}},"/api/live-overlay/mine/subathon/stop":{"post":{"operationId":"LiveOverlayController_stopSubathon","parameters":[],"responses":{"201":{"description":""}},"tags":["Live overlay"]}},"/api/live-overlay/mine/subathon/add":{"post":{"operationId":"LiveOverlayController_addSubathon","parameters":[],"responses":{"201":{"description":""}},"tags":["Live overlay"]}},"/api/live-overlay/stream/{streamId}/poll":{"get":{"operationId":"LiveOverlayController_streamPoll","parameters":[{"name":"streamId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Live overlay"]}},"/api/live-overlay/stream/{streamId}/poll/vote":{"post":{"operationId":"LiveOverlayController_vote","parameters":[{"name":"streamId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":""}},"tags":["Live overlay"]}},"/api/live-overlay/creator/{address}/points":{"get":{"operationId":"LiveOverlayController_pointsBoard","parameters":[{"name":"address","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Live overlay"]}},"/api/live-overlay/creator/{address}/points/me":{"get":{"operationId":"LiveOverlayController_myPoints","parameters":[{"name":"address","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Live overlay"]}},"/api/live-overlay/public/{token}":{"get":{"operationId":"LiveOverlayController_byToken","parameters":[{"name":"token","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Live overlay"]}},"/api/live-overlay/stream/{streamId}":{"get":{"operationId":"LiveOverlayController_byStream","parameters":[{"name":"streamId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Live overlay"]}},"/api/plan/gift":{"post":{"operationId":"LiveHypeController_quoteGift","parameters":[],"responses":{"201":{"description":""}},"tags":["Live hype"]}},"/api/plan/gift/confirm":{"post":{"operationId":"LiveHypeController_confirmGift","parameters":[],"responses":{"201":{"description":""}},"tags":["Live hype"]}},"/api/live-hype/stream/{streamId}":{"get":{"operationId":"LiveHypeController_hypeTrain","parameters":[{"name":"streamId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Live hype"]}},"/api/live-badges/mine":{"get":{"operationId":"LiveHypeController_myBadges","parameters":[],"responses":{"200":{"description":""}},"tags":["Live hype"]},"post":{"operationId":"LiveHypeController_createBadge","parameters":[],"responses":{"201":{"description":""}},"tags":["Live hype"]}},"/api/live-badges/mine/{id}":{"delete":{"operationId":"LiveHypeController_endBadge","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Live hype"]}},"/api/live-badges/creator/{address}":{"get":{"operationId":"LiveHypeController_activeBadges","parameters":[{"name":"address","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"tags":["Live hype"]}},"/api/search":{"get":{"description":"\nSearches across user accounts, videos, feed posts, and livestreams in a single request.\n\n## Search Modes\n\n| `type` param | Behavior |\n|---|---|\n| *(omitted)* | **Combined mode** — returns both `accounts` (top 5) and paginated `content` |\n| `accounts` | Only search user accounts (paginated) |\n| `content` | Only search content (paginated) |\n\n## Search Matching\n- **Case-insensitive** regex matching\n- **Accounts**: matches `username`, `displayName`, `name`, `address`\n- **Content**: matches `name`, `description`, and `tokenId` (if query is a number)\n\n## Post Type Filter (content only)\n| Value | Matches |\n|---|---|\n| `all` (default) | All content types |\n| `video` | Video content |\n| `live` | Livestream recordings |\n| `feed` / `feed-all` | Feed posts (text + images) |\n| `feed-simple` | Text-only posts |\n| `feed-images` | Image posts only |\n\n## Privacy\n- Content from **private accounts** is excluded unless the viewer is the owner or follows them.\n- Send a JWT `Authorization: Bearer <token>` header to unlock personalized data.\n\n## Authentication (optional)\nWhen authenticated, content items include interaction fields:\n`isLiked`, `isDisliked`, `isSaved`, `isFollowing`, `isOwner`, `isUnlocked`\n\n## Response Structure\nThe response always contains `accounts` and `content` keys.\n- In **combined** mode both are populated (`accounts` capped at 5 items).\n- In **single-type** mode, the unused key is `null`.\n\n```json\n{\n  \"status\": true,\n  \"accounts\": { \"items\": [...], \"pagination\": {...} } | null,\n  \"content\":  { \"items\": [...], \"pagination\": {...} } | null\n}\n```\n    ","operationId":"SearchQueryController_search","parameters":[{"name":"q","required":true,"in":"query","description":"Search query string","schema":{"example":"gaming","type":"string"}},{"name":"page","required":false,"in":"query","description":"Page number (1-indexed). Default: 1. Applies to the primary result type (content in combined mode).","schema":{"example":1,"type":"number"}},{"name":"limit","required":false,"in":"query","description":"Items per page (1-100). Default: 20","schema":{"example":20,"type":"number"}},{"name":"type","required":false,"in":"query","description":"Filter to search only a specific type. Omit for combined search (both accounts + content).","schema":{"enum":["accounts","content"],"type":"string"}},{"name":"postType","required":false,"in":"query","description":"Filter content by post type (ignored when type=accounts):\n- **all**: All content types (default)\n- **video**: Video content only\n- **live**: Livestream recordings only\n- **feed-all** / **feed**: Feed posts (text + images + audio)\n- **feed-simple**: Text-only posts\n- **feed-images**: Image posts only\n- **feed-audio**: Audio posts only","schema":{"enum":["all","video","live","feed-all","feed","feed-simple","feed-images","feed-audio"],"type":"string"}},{"name":"sortBy","required":false,"in":"query","description":"Sort order for results (default: **popular**):\n- **popular**: Accounts ranked by followers & badge balance; content ranked by views & likes\n- **recent**: Newest first (by creation date)","schema":{"enum":["popular","recent"],"type":"string"}}],"responses":{"200":{"description":"Search results. Both `accounts` and `content` are returned; one may be `null` depending on the `type` filter.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"accounts":{"nullable":true,"type":"object","description":"Account search results. `null` when `type=content`.","properties":{"items":{"type":"array","items":{"type":"object","properties":{"address":{"type":"string","example":"0xabc…"},"username":{"type":"string","example":"gamer_pro"},"displayName":{"type":"string","example":"Gamer Pro"},"avatarImageUrl":{"type":"string"},"followers":{"type":"number"},"followings":{"type":"number"},"badgeBalance":{"type":"number","description":"walletBalance + staked across all DHB chains"},"isPrivate":{"type":"boolean"},"isFollowing":{"type":"boolean","description":"Whether the authenticated viewer follows this account (authenticated only, defaults to false)"},"isFollowRequestPending":{"type":"boolean","description":"Whether the authenticated viewer has a pending follow request to this account (authenticated only, defaults to false)"},"createdAt":{"type":"string","format":"date-time"}}}},"pagination":{"type":"object","properties":{"page":{"type":"number","example":1},"limit":{"type":"number","example":5},"totalCount":{"type":"number","example":12},"totalPages":{"type":"number","example":3},"hasMore":{"type":"boolean","example":true}}}}},"content":{"nullable":true,"type":"object","description":"Content search results. `null` when `type=accounts`.","properties":{"items":{"type":"array","items":{"type":"object","properties":{"tokenId":{"type":"number","example":123},"name":{"type":"string","example":"Gaming Tutorial"},"description":{"type":"string"},"imageUrl":{"type":"string"},"videoUrl":{"type":"string"},"postType":{"type":"string","enum":["video","short","feed-simple","feed-images","feed-audio","live"]},"views":{"type":"number"},"totalVotes":{"type":"object","properties":{"for":{"type":"number"},"against":{"type":"number"}}},"createdAt":{"type":"string","format":"date-time"},"commentCount":{"type":"number"},"minterUser":{"type":"object","description":"Creator account info","properties":{"address":{"type":"string"},"username":{"type":"string"},"displayName":{"type":"string"},"avatarImageUrl":{"type":"string"},"followers":{"type":"number"},"followings":{"type":"number"},"badgeBalance":{"type":"number"}}},"stream":{"type":"object","nullable":true,"description":"Livestream data (only present for postType=live)","properties":{"status":{"type":"string","enum":["live","scheduled","ended","offline","paused"]},"playbackId":{"type":"string"},"peakViewers":{"type":"number"},"totalViews":{"type":"number"},"likes":{"type":"number"},"startedAt":{"type":"string","format":"date-time"},"endedAt":{"type":"string","format":"date-time"},"scheduledFor":{"type":"string","format":"date-time"}}},"plansDetails":{"type":"array","description":"Subscription plans (if any)"},"isLiked":{"type":"boolean","description":"Whether viewer liked this content (authenticated only)"},"isDisliked":{"type":"boolean","description":"Whether viewer disliked this content (authenticated only)"},"isSaved":{"type":"boolean","description":"Whether viewer saved this content (authenticated only)"},"ppvBuyerCount":{"type":"number","example":42,"description":"Number of unique addresses that purchased PPV access (only present for PPV content)"},"isFollowing":{"type":"boolean","description":"Whether viewer follows the creator (authenticated only)"},"isOwner":{"type":"boolean","description":"Whether viewer is the creator (authenticated only)"},"isUnlocked":{"type":"boolean","description":"Whether viewer unlocked this PPV content (PPV items only, authenticated only)"}}}},"pagination":{"type":"object","properties":{"page":{"type":"number","example":1},"limit":{"type":"number","example":20},"totalCount":{"type":"number","example":150},"totalPages":{"type":"number","example":8},"hasMore":{"type":"boolean","example":true}}}}}}}}}}},"summary":"Universal search across accounts and content","tags":["Search"]}},"/api/search/suggestions":{"get":{"description":"\nReturns popular search terms that start with the given prefix.\n\nSuggestions are ranked by:\n1. Search count (most searched first)\n2. Last searched date (most recent first)\n\nUsed for autocomplete/typeahead in search UI.\n\n### Example\n`GET /search/suggestions?q=gam`\n\nReturns: `[\"gaming\", \"game review\", \"gameplay\"]`\n    ","operationId":"SearchQueryController_getSuggestions","parameters":[{"name":"q","required":true,"in":"query","description":"Search prefix for suggestions","schema":{"example":"gam","type":"string"}}],"responses":{"200":{"description":"List of suggested search terms","content":{"application/json":{"schema":{"type":"array","items":{"type":"string"},"example":["gaming","game review","gameplay"]}}}}},"summary":"Get search suggestions based on prefix","tags":["Search"]}},"/api/search/log":{"post":{"description":"\nManually log a search term for analytics purposes.\n\nThis is typically called automatically by the main search endpoint,\nbut can be used to log search terms from client-side search implementations.\n\n### Use Case\nLog searches that happen before the user hits \"enter\" or when using\nlocal/cached search results.\n    ","operationId":"SearchQueryController_logSearch","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["term"],"properties":{"term":{"type":"string","example":"gaming tutorial","description":"Search term to log"}}}}}},"responses":{"200":{"description":"Search term logged","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true}}}}}}},"summary":"Log a search term for analytics","tags":["Search"]}},"/api/mobile/app/version":{"get":{"operationId":"AppController_getVersion","parameters":[],"responses":{"200":{"description":""}},"tags":["App"]}},"/api/mobile/app/check-update":{"get":{"operationId":"AppController_checkUpdate","parameters":[],"responses":{"200":{"description":""}},"tags":["App"]}},"/api/mobile/app/version-history":{"get":{"operationId":"AppController_getVersionHistory","parameters":[],"responses":{"200":{"description":""}},"tags":["App"]}},"/api/mobile/app/review":{"post":{"operationId":"AppController_submitReview","parameters":[],"responses":{"201":{"description":""}},"tags":["App"]}},"/api/mobile/app/review/stats":{"get":{"operationId":"AppController_getReviewStats","parameters":[],"responses":{"200":{"description":""}},"tags":["App"]}},"/api/mobile/app/review/negative-feedback":{"get":{"operationId":"AppController_getNegativeFeedback","parameters":[],"responses":{"200":{"description":""}},"tags":["App"]}},"/api/admin/livestreams":{"get":{"description":"\nPaginated list of all livestreams with optional filters.\n\n### Filters:\n- **status**: Filter by stream status (OFFLINE, LIVE, ENDED, SCHEDULED)\n- **address**: Filter by streamer wallet address (case-insensitive)\n- **search**: Search in title and description\n- **isTest**: Filter by test flag (\"true\" or \"false\")\n\n### Sorting:\nResults are sorted by `createdAt` descending (newest first).\n\n### Permissions:\nAccessible by all admin roles (SUPER_ADMIN, ADMIN, MODERATOR, VIEWER).\n    ","operationId":"AdminLivestreamController_listStreams","parameters":[{"name":"isTest","required":false,"in":"query","description":"Filter by test flag","schema":{"enum":["true","false"],"type":"string"}},{"name":"search","required":false,"in":"query","description":"Search in title and description","schema":{"type":"string"}},{"name":"address","required":false,"in":"query","description":"Filter by streamer wallet address","schema":{"type":"string"}},{"name":"status","required":false,"in":"query","description":"Filter by stream status","schema":{"enum":["OFFLINE","LIVE","ENDED","SCHEDULED","all"],"type":"string"}},{"name":"limit","required":false,"in":"query","description":"Items per page (default: 20, max: 100)","schema":{"example":20,"type":"number"}},{"name":"page","required":false,"in":"query","description":"Page number (default: 1)","schema":{"example":1,"type":"number"}}],"responses":{"200":{"description":"Paginated list of livestreams","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"_id":{"type":"string","example":"6507abc123def456"},"title":{"type":"string","example":"Friday Night Gaming"},"status":{"type":"string","enum":["OFFLINE","LIVE","ENDED","SCHEDULED"],"example":"LIVE"},"isTest":{"type":"boolean","example":false},"isDeleted":{"type":"boolean","example":false},"address":{"type":"string","example":"0x1234...abcd"},"tokenId":{"type":"number","example":42},"playbackId":{"type":"string","example":"abcd1234"},"likes":{"type":"number","example":150},"peakViewers":{"type":"number","example":80},"totalViews":{"type":"number","example":320},"totalTips":{"type":"number","example":5000},"duration":{"type":"number","example":3600},"createdAt":{"type":"string","format":"date-time"},"account":{"type":"object","properties":{"address":{"type":"string"},"username":{"type":"string"},"displayName":{"type":"string"},"avatarImageUrl":{"type":"string"}}}}}},"totalCount":{"type":"number","example":142},"page":{"type":"number","example":1},"limit":{"type":"number","example":20},"totalPages":{"type":"number","example":8}}}}}},"401":{"description":"Unauthorized — invalid or missing admin token"},"403":{"description":"Forbidden — insufficient permissions"}},"security":[{"admin-jwt":[]}],"summary":"List all livestreams","tags":["Admin - Livestreams"]}},"/api/admin/livestreams/{id}":{"get":{"description":"\nRetrieve full details of a specific livestream including admin-only fields\n(isTest, isDeleted, streamKey, livepeerId, settings).\n\nThe `id` parameter can be either the MongoDB ObjectId or the on-chain tokenId.\n    ","operationId":"AdminLivestreamController_getStream","parameters":[{"name":"id","required":true,"in":"path","description":"Stream ID (MongoDB ObjectId or tokenId)","schema":{"example":"6507abc123def456","type":"string"}}],"responses":{"200":{"description":"Full stream details with admin-only fields","content":{"application/json":{"schema":{"type":"object","properties":{"_id":{"type":"string"},"title":{"type":"string"},"description":{"type":"string"},"status":{"type":"string","enum":["OFFLINE","LIVE","ENDED","SCHEDULED"]},"isTest":{"type":"boolean"},"isDeleted":{"type":"boolean"},"streamKey":{"type":"string","description":"Livepeer stream key (admin-only)"},"livepeerId":{"type":"string","description":"Livepeer stream ID (admin-only)"},"settings":{"type":"object","description":"Stream settings (chat, minTip, etc.)"},"playbackId":{"type":"string"},"address":{"type":"string"},"tokenId":{"type":"number"},"likes":{"type":"number"},"totalViews":{"type":"number"},"peakViewers":{"type":"number"},"totalTips":{"type":"number"},"duration":{"type":"number"},"startedAt":{"type":"string","format":"date-time"},"endedAt":{"type":"string","format":"date-time"},"account":{"type":"object"},"activities":{"type":"array","items":{"type":"object"}}}}}}},"401":{"description":"Unauthorized"},"404":{"description":"Stream not found"}},"security":[{"admin-jwt":[]}],"summary":"Get livestream details","tags":["Admin - Livestreams"]},"delete":{"description":"\nSoft-delete a livestream by setting `isDeleted: true`.\n\n### What happens:\n1. If the stream is `LIVE` or `OFFLINE`, it is ended first (status → `ENDED`)\n2. `isDeleted` is set to `true`\n3. The Livepeer stream resource is permanently deleted\n4. The stream will no longer appear in public queries\n\n### Reversible:\nUse `PATCH /admin/livestreams/:id/restore` to undo (SUPER_ADMIN only).\n\n### Permissions:\nOnly **SUPER_ADMIN** and **ADMIN** roles.\n    ","operationId":"AdminLivestreamController_deleteStream","parameters":[{"name":"id","required":true,"in":"path","description":"Stream ID (MongoDB ObjectId or tokenId)","schema":{"type":"string"}}],"responses":{"200":{"description":"Stream soft-deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"message":{"type":"string","example":"Stream deleted"}}}}}},"400":{"description":"Stream is already deleted"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden — requires SUPER_ADMIN or ADMIN role"},"404":{"description":"Stream not found"}},"security":[{"admin-jwt":[]}],"summary":"Soft-delete a livestream","tags":["Admin - Livestreams"]}},"/api/admin/livestreams/{id}/test":{"patch":{"description":"\nToggle the test flag on a livestream.\n\n### Behavior:\n- **Test streams** reset to `OFFLINE` instead of `ENDED` when the broadcast stops,\n  allowing the same stream entity to be reused without creating a new one.\n- When enabling `isTest` on an **ENDED** stream, the status is automatically reset\n  to `OFFLINE` so it can go live again.\n- When disabling `isTest`, the stream follows normal lifecycle (LIVE → ENDED is terminal).\n\n### Permissions:\nOnly **SUPER_ADMIN** and **ADMIN** roles.\n    ","operationId":"AdminLivestreamController_setTestFlag","parameters":[{"name":"id","required":true,"in":"path","description":"Stream ID (MongoDB ObjectId or tokenId)","schema":{"type":"string"}}],"requestBody":{"required":true,"description":"Test flag toggle payload","content":{"application/json":{"schema":{"type":"object","required":["isTest"],"properties":{"isTest":{"type":"boolean","description":"Whether the stream should be marked as a test stream","example":true}}}}}},"responses":{"200":{"description":"Test flag updated","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"message":{"type":"string","example":"Stream marked as test — it will reset to OFFLINE instead of ENDED on idle"},"stream":{"type":"object","properties":{"_id":{"type":"string"},"tokenId":{"type":"number"},"isTest":{"type":"boolean","example":true},"status":{"type":"string","example":"OFFLINE"}}}}}}}},"400":{"description":"Invalid request body"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden — requires SUPER_ADMIN or ADMIN role"},"404":{"description":"Stream not found"}},"security":[{"admin-jwt":[]}],"summary":"Toggle test flag","tags":["Admin - Livestreams"]}},"/api/admin/livestreams/{id}/end":{"post":{"description":"\nForce-end a livestream regardless of ownership.\n\n### What happens:\n1. Stream status is set to `ENDED` (or `OFFLINE` if it's a test stream)\n2. The Livepeer session is terminated server-side\n3. A `stream.end` socket event is broadcast to all connected viewers\n4. Duration is calculated from `startedAt` to now\n\n### Restrictions:\n- Cannot end a stream that is already `ENDED`\n- Test streams (`isTest: true`) are reset to `OFFLINE` instead\n\n### Permissions:\nOnly **SUPER_ADMIN** and **ADMIN** roles.\n    ","operationId":"AdminLivestreamController_forceEndStream","parameters":[{"name":"id","required":true,"in":"path","description":"Stream ID (MongoDB ObjectId or tokenId)","schema":{"type":"string"}}],"responses":{"200":{"description":"Stream force-ended","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"message":{"type":"string","example":"Stream force-ended"},"stream":{"type":"object","properties":{"_id":{"type":"string"},"tokenId":{"type":"number"},"status":{"type":"string","example":"ENDED"},"duration":{"type":"number","example":3600,"description":"Duration in seconds"}}}}}}}},"400":{"description":"Stream is already ended"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden — requires SUPER_ADMIN or ADMIN role"},"404":{"description":"Stream not found"}},"security":[{"admin-jwt":[]}],"summary":"Force-end a livestream","tags":["Admin - Livestreams"]}},"/api/admin/livestreams/{id}/restore":{"patch":{"description":"\nRestore a previously soft-deleted livestream by setting `isDeleted: false`.\n\n> **Note:** The Livepeer stream resource was permanently deleted during soft-delete,\n> so the restored stream will need a new Livepeer session to go live again.\n\n### Permissions:\nOnly **SUPER_ADMIN** and **ADMIN** roles.\n    ","operationId":"AdminLivestreamController_restoreStream","parameters":[{"name":"id","required":true,"in":"path","description":"Stream ID (MongoDB ObjectId or tokenId)","schema":{"type":"string"}}],"responses":{"200":{"description":"Stream restored","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"message":{"type":"string","example":"Stream restored"}}}}}},"400":{"description":"Stream is not deleted"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden — requires SUPER_ADMIN or ADMIN role"},"404":{"description":"Stream not found"}},"security":[{"admin-jwt":[]}],"summary":"Restore a soft-deleted livestream","tags":["Admin - Livestreams"]}},"/api/admin/stages":{"get":{"operationId":"AdminStagesController_listStages","parameters":[],"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Stages"]}},"/api/admin/stages/{id}":{"get":{"operationId":"AdminStagesController_getStage","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Stages"]},"patch":{"operationId":"AdminStagesController_updateStage","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAdminStageDto"}}}},"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Stages"]},"delete":{"operationId":"AdminStagesController_deleteStage","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeleteAdminStageDto"}}}},"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Stages"]}},"/api/admin/stages/{id}/end":{"post":{"operationId":"AdminStagesController_forceEndStage","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Stages"]}},"/api/admin/stages/{id}/recording":{"delete":{"operationId":"AdminStagesController_deleteRecording","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Stages"]}},"/api/admin/transcripts":{"get":{"operationId":"AdminTranscriptsController_listTranscripts","parameters":[],"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Transcripts"]}},"/api/admin/transcripts/{id}":{"get":{"operationId":"AdminTranscriptsController_getTranscript","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Transcripts"]},"patch":{"operationId":"AdminTranscriptsController_updateTranscript","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAdminTranscriptDto"}}}},"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Transcripts"]},"delete":{"operationId":"AdminTranscriptsController_deleteTranscript","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeleteAdminTranscriptDto"}}}},"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Transcripts"]}},"/api/admin/transcripts/{id}/retranscribe":{"post":{"operationId":"AdminTranscriptsController_retranscribe","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Transcripts"]}},"/api/admin/reports/dashboard":{"get":{"description":"Returns aggregated counts by status and reason for both content and user reports. Use this to power the admin moderation dashboard landing page.","operationId":"AdminReportsController_getDashboard","parameters":[],"responses":{"200":{"description":"Dashboard data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportsDashboardResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"Reports dashboard overview","tags":["Admin — Reports & Moderation"]}},"/api/admin/reports/content":{"get":{"description":"Paginated list of content (video/post) reports. Filter by status, reason, or tokenId. Includes a status summary for dashboard badges.","operationId":"AdminReportsController_listContentReports","parameters":[{"name":"page","required":false,"in":"query","description":"Page number","schema":{"minimum":1,"default":1,"type":"number"}},{"name":"limit","required":false,"in":"query","description":"Items per page","schema":{"minimum":1,"maximum":200,"default":20,"type":"number"}},{"name":"status","required":false,"in":"query","description":"Filter by moderation status","schema":{"type":"string","enum":["pending","reviewed","action_taken","dismissed"]}},{"name":"reason","required":false,"in":"query","description":"Filter by reason category","schema":{"type":"string","enum":["sexual_content","violent_content","hateful_content","harassment_bullying","harmful_dangerous","misinformation","child_abuse","spam_misleading","scam_fraud","infringes_rights","other"]}},{"name":"tokenId","required":false,"in":"query","description":"Filter by tokenId","schema":{"type":"number"}},{"name":"sortBy","required":false,"in":"query","description":"Sort field","schema":{"type":"string","enum":["createdAt","status","reason"]}},{"name":"sortOrder","required":false,"in":"query","description":"Sort direction","schema":{"default":"desc","type":"string","enum":["asc","desc"]}}],"responses":{"200":{"description":"Paginated content reports","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListContentReportsResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"List content reports","tags":["Admin — Reports & Moderation"]}},"/api/admin/reports/content/{reportId}":{"get":{"description":"Returns the full report with reporter info, token info, reviewing admin, and how many total reports exist for the same token.","operationId":"AdminReportsController_getContentReport","parameters":[{"name":"reportId","required":true,"in":"path","description":"MongoDB ObjectId of the report","schema":{"type":"string"}}],"responses":{"200":{"description":"Report details","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetContentReportResponseDto"}}}},"400":{"description":"Invalid report ID format"},"404":{"description":"Report not found"}},"security":[{"bearer":[]}],"summary":"Get content report details","tags":["Admin — Reports & Moderation"]}},"/api/admin/reports/content/{reportId}/status":{"patch":{"description":"Change the moderation status of a content report. Does NOT take action on the content itself — use the /action endpoint for that.","operationId":"AdminReportsController_reviewContentReport","parameters":[{"name":"reportId","required":true,"in":"path","description":"MongoDB ObjectId of the report","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewReportDto"}}}},"responses":{"200":{"description":"Report status updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewContentReportResponseDto"}}}},"404":{"description":"Report not found"}},"security":[{"bearer":[]}],"summary":"Update content report status","tags":["Admin — Reports & Moderation"]}},"/api/admin/reports/content/{reportId}/action":{"patch":{"description":"One-click moderation: removes content, warns creator, or dismisses the report.\n\n**Actions:**\n- `remove` — Soft-deletes the video/post, auto-resolves all pending reports for that token, and sends the creator a \"content removed\" notification with appeal instructions.\n- `warn_creator` — Sends the creator a warning notification. Content stays up.\n- `dismiss` — Marks the report as invalid. No notification sent.\n\nThe optional `reason` field is included in the notification to the user.","operationId":"AdminReportsController_takeActionOnContentReport","parameters":[{"name":"reportId","required":true,"in":"path","description":"MongoDB ObjectId of the report","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TakeActionOnContentReportDto"}}}},"responses":{"200":{"description":"Action taken","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContentReportActionResponseDto"}}}},"404":{"description":"Report not found"}},"security":[{"bearer":[]}],"summary":"Take action on content report","tags":["Admin — Reports & Moderation"]}},"/api/admin/reports/users":{"get":{"description":"Paginated list of user reports. Filter by status, reason, or specific reported user. Each item includes both reporter and target user info.","operationId":"AdminReportsController_listUserReports","parameters":[{"name":"page","required":false,"in":"query","description":"Page number","schema":{"minimum":1,"default":1,"type":"number"}},{"name":"limit","required":false,"in":"query","description":"Items per page","schema":{"minimum":1,"maximum":200,"default":20,"type":"number"}},{"name":"status","required":false,"in":"query","description":"Filter by moderation status","schema":{"type":"string","enum":["pending","reviewed","action_taken","dismissed"]}},{"name":"reason","required":false,"in":"query","description":"Filter by reason category","schema":{"type":"string","enum":["impersonation","harassment_bullying","hateful_behavior","spam","scam_fraud","underage","inappropriate_profile","other"]}},{"name":"reportedUser","required":false,"in":"query","description":"Filter by reported user (_id, address, or username)","schema":{"type":"string"}},{"name":"sortBy","required":false,"in":"query","description":"Sort field","schema":{"type":"string","enum":["createdAt","status","reason"]}},{"name":"sortOrder","required":false,"in":"query","description":"Sort direction","schema":{"default":"desc","type":"string","enum":["asc","desc"]}}],"responses":{"200":{"description":"Paginated user reports","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListUserReportsResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"List user reports","tags":["Admin — Reports & Moderation"]}},"/api/admin/reports/users/{reportId}":{"get":{"description":"Returns the full report with reporter info, target user info (including ban/permissions status), reviewing admin, and total report count for the same user.","operationId":"AdminReportsController_getUserReport","parameters":[{"name":"reportId","required":true,"in":"path","description":"MongoDB ObjectId of the report","schema":{"type":"string"}}],"responses":{"200":{"description":"Report details","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetUserReportResponseDto"}}}},"400":{"description":"Invalid report ID format"},"404":{"description":"Report not found"}},"security":[{"bearer":[]}],"summary":"Get user report details","tags":["Admin — Reports & Moderation"]}},"/api/admin/reports/users/{reportId}/action":{"patch":{"description":"One-click moderation for user reports.\n\n**Actions:**\n- `ban` — Bans the user, disables posting/commenting, auto-resolves all pending reports for that user, sends \"account banned\" notification.\n- `warn` — Sends the user a warning notification. No restrictions applied.\n- `restrict_posting` — Disables posting for the user, sends notification.\n- `restrict_comments` — Disables commenting for the user, sends notification.\n- `dismiss` — Marks the report as invalid. No notification sent.\n\nAll notifications include appeal instructions (email dev@dehub.io).","operationId":"AdminReportsController_takeActionOnUserReport","parameters":[{"name":"reportId","required":true,"in":"path","description":"MongoDB ObjectId of the report","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TakeActionOnUserReportDto"}}}},"responses":{"200":{"description":"Action taken","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserReportActionResponseDto"}}}},"404":{"description":"Report not found"}},"security":[{"bearer":[]}],"summary":"Take action on user report","tags":["Admin — Reports & Moderation"]}},"/api/admin/support/tickets":{"get":{"description":"Paginated list of assistant-raised support tickets. Filter by status, category or severity, or search by reference, subject, username or wallet.","operationId":"AdminSupportController_listTickets","parameters":[{"name":"page","required":false,"in":"query","description":"Page number","schema":{"minimum":1,"default":1,"type":"number"}},{"name":"limit","required":false,"in":"query","description":"Items per page","schema":{"minimum":1,"maximum":200,"default":20,"type":"number"}},{"name":"status","required":false,"in":"query","description":"Filter by ticket status","schema":{"type":"string","enum":["open","in_progress","resolved","closed"]}},{"name":"category","required":false,"in":"query","description":"Filter by area of the platform","schema":{"type":"string","enum":["account_access","wallet_or_transactions","posting_or_uploads","payments_or_subscriptions","live_streaming","content_or_moderation","bug","feature_request","other"]}},{"name":"severity","required":false,"in":"query","description":"Filter by severity","schema":{"type":"string","enum":["low","normal","high","urgent"]}},{"name":"search","required":false,"in":"query","description":"Match on reference, subject, username or wallet","schema":{"type":"string"}},{"name":"sortBy","required":false,"in":"query","description":"Sort field","schema":{"type":"string","enum":["createdAt","status","severity"]}},{"name":"sortOrder","required":false,"in":"query","description":"Sort direction","schema":{"default":"desc","type":"string","enum":["asc","desc"]}}],"responses":{"200":{"description":"Paginated tickets with counts by status and open counts by severity"}},"security":[{"bearer":[]}],"summary":"List support tickets","tags":["Admin — Support"]}},"/api/admin/support/tickets/{idOrRef}":{"get":{"description":"Full ticket including the reporter, the diagnostics captured at filing time, and the reviewing admin.","operationId":"AdminSupportController_getTicket","parameters":[{"name":"idOrRef","required":true,"in":"path","description":"MongoDB ObjectId or the DH- reference from the ticket email","schema":{"type":"string"}}],"responses":{"200":{"description":"Ticket details"},"404":{"description":"Ticket not found"}},"security":[{"bearer":[]}],"summary":"Get one support ticket","tags":["Admin — Support"]},"patch":{"description":"Set the status, the reporter-facing resolution, and/or an internal note. A status change sends the reporter an in-app notification — most accounts have no email address, so this is the only reply they will reliably see.","operationId":"AdminSupportController_updateTicket","parameters":[{"name":"idOrRef","required":true,"in":"path","description":"MongoDB ObjectId or the DH- reference","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSupportTicketDto"}}}},"responses":{"200":{"description":"Updated ticket"},"400":{"description":"Nothing to update, or an unknown status"},"404":{"description":"Ticket not found"}},"security":[{"bearer":[]}],"summary":"Update a support ticket","tags":["Admin — Support"]}},"/api/admin/comments/dashboard":{"get":{"description":"Returns comprehensive comment analytics for the admin dashboard:\n\n- **Totals**: all-time, today, this week, this month\n- **Status breakdown**: visible, hidden, flagged, deleted, pinned\n- **Media breakdown**: text-only, image, audio\n- **Engagement**: avg likes, avg replies per comment\n- **Top commenters**: top 10 most active users (last 30 days)\n- **Most discussed**: top 10 most commented-on content (last 30 days)\n- **Daily trend**: comment count per day for the last 30 days","operationId":"AdminCommentsController_getDashboard","parameters":[{"name":"timezone","required":false,"in":"query","description":"Timezone for \"today\" calculation (IANA format)","schema":{"default":"UTC","example":"America/New_York","type":"string"}}],"responses":{"200":{"description":"Dashboard data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CommentDashboardResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"Comment dashboard overview","tags":["Admin — Comment Management"]}},"/api/admin/comments":{"get":{"description":"Paginated list of all comments across the platform with powerful filtering capabilities.\n\n**Filters:**\n- `search` — Free-text search through comment content (case-insensitive)\n- `tokenId` — Filter comments for a specific video/post\n- `address` — Filter by commenter wallet address\n- `userId` — Filter by commenter MongoDB ObjectId\n- `username` — Filter by commenter username\n- `commentType` — `top-level` (no parent), `reply` (has parent), or `all`\n- `mediaType` — `text`, `image`, or `audio`\n- `moderationStatus` — `visible`, `hidden`, `flagged`, or `deleted`\n- `pinnedOnly` — Show only pinned comments\n- `flaggedOnly` — Show only flagged comments\n- `minLikes` — Minimum like count\n- `startDate` / `endDate` — Date range (ISO 8601)\n\n**Sorting:**\n- `sortBy` — `createdAt` (default), `likes`, or `replyCount`\n- `sortOrder` — `asc` or `desc` (default)\n\nResponse includes a `summary` object with global moderation status counts for dashboard badges.","operationId":"AdminCommentsController_listComments","parameters":[{"name":"page","required":false,"in":"query","description":"Page number (1-based)","schema":{"minimum":1,"default":1,"type":"number"}},{"name":"limit","required":false,"in":"query","description":"Items per page","schema":{"minimum":1,"maximum":200,"default":20,"type":"number"}},{"name":"search","required":false,"in":"query","description":"Free-text search across comment content","schema":{"type":"string"}},{"name":"tokenId","required":false,"in":"query","description":"Filter by tokenId (video/post the comment belongs to)","schema":{"type":"number"}},{"name":"address","required":false,"in":"query","description":"Filter by commenter wallet address","schema":{"type":"string"}},{"name":"userId","required":false,"in":"query","description":"Filter by commenter user ID (MongoDB ObjectId)","schema":{"type":"string"}},{"name":"username","required":false,"in":"query","description":"Filter by commenter username","schema":{"type":"string"}},{"name":"commentType","required":false,"in":"query","description":"Filter by comment type","schema":{"default":"all","type":"string","enum":["top-level","reply","all"]}},{"name":"mediaType","required":false,"in":"query","description":"Filter by media type attached to the comment","schema":{"type":"string","enum":["text","image","audio","gif"]}},{"name":"moderationStatus","required":false,"in":"query","description":"Filter by moderation status","schema":{"type":"string","enum":["visible","hidden","flagged","deleted"]}},{"name":"pinnedOnly","required":false,"in":"query","description":"Only show pinned comments","schema":{"default":false,"type":"boolean"}},{"name":"flaggedOnly","required":false,"in":"query","description":"Only show flagged comments","schema":{"default":false,"type":"boolean"}},{"name":"minLikes","required":false,"in":"query","description":"Minimum number of likes","schema":{"type":"number"}},{"name":"startDate","required":false,"in":"query","description":"Start date filter (ISO 8601)","schema":{"example":"2025-01-01T00:00:00.000Z","type":"string"}},{"name":"endDate","required":false,"in":"query","description":"End date filter (ISO 8601)","schema":{"example":"2025-12-31T23:59:59.999Z","type":"string"}},{"name":"sortBy","required":false,"in":"query","description":"Sort field","schema":{"default":"createdAt","type":"string","enum":["createdAt","likes","replyCount"]}},{"name":"sortOrder","required":false,"in":"query","description":"Sort direction","schema":{"default":"desc","type":"string","enum":["asc","desc"]}}],"responses":{"200":{"description":"Paginated comments with summary","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListAdminCommentsResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"List & search all comments","tags":["Admin — Comment Management"]}},"/api/admin/comments/by-video/{tokenId}":{"get":{"description":"Returns all comments (and replies) for the given tokenId, ordered by creation date. Includes the same summary and enriched author data as the main list endpoint.","operationId":"AdminCommentsController_listByVideo","parameters":[{"name":"tokenId","required":true,"in":"path","description":"Token ID (numeric) of the video or post","schema":{"example":42,"type":"string"}},{"name":"page","required":false,"in":"query","description":"Page number (1-based)","schema":{"minimum":1,"default":1,"type":"number"}},{"name":"limit","required":false,"in":"query","description":"Items per page","schema":{"minimum":1,"maximum":200,"default":20,"type":"number"}},{"name":"commentType","required":false,"in":"query","description":"Filter by comment type","schema":{"default":"all","type":"string","enum":["top-level","reply","all"]}},{"name":"sortOrder","required":false,"in":"query","description":"Sort direction","schema":{"default":"desc","type":"string","enum":["asc","desc"]}}],"responses":{"200":{"description":"Comments for the video","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListAdminCommentsResponseDto"}}}}},"security":[{"bearer":[]}],"summary":"List comments for a specific video/post","tags":["Admin — Comment Management"]}},"/api/admin/comments/by-user/{identifier}":{"get":{"description":"Returns all comments authored by the specified user.\n\nThe `:identifier` parameter accepts any of:\n- MongoDB ObjectId (e.g. `665f1a2b3c4d5e6f7a8b9c0d`)\n- Wallet address (e.g. `0x1234...`)\n- Username (e.g. `johndoe`)","operationId":"AdminCommentsController_listByUser","parameters":[{"name":"identifier","required":true,"in":"path","description":"User identifier: MongoDB ObjectId, wallet address, or username","schema":{"type":"string"}},{"name":"page","required":false,"in":"query","description":"Page number (1-based)","schema":{"minimum":1,"default":1,"type":"number"}},{"name":"limit","required":false,"in":"query","description":"Items per page","schema":{"minimum":1,"maximum":200,"default":20,"type":"number"}},{"name":"sortOrder","required":false,"in":"query","description":"Sort direction","schema":{"default":"desc","type":"string","enum":["asc","desc"]}}],"responses":{"200":{"description":"Comments by the user","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListAdminCommentsResponseDto"}}}},"404":{"description":"User not found (when using username)"}},"security":[{"bearer":[]}],"summary":"List comments by a specific user","tags":["Admin — Comment Management"]}},"/api/admin/comments/{commentId}":{"get":{"description":"Returns the full detail view for a single comment, including:\n\n- Comment with enriched author info (username, avatar, ban status)\n- Associated video/post info (name, thumbnail, status)\n- Parent comment (if this is a reply)\n- Up to 10 most recent replies\n- Total comments by this user across all content\n- Total comments on this video/post\n\nAccepts either the auto-increment `id` (number) or MongoDB `_id` (ObjectId).","operationId":"AdminCommentsController_getComment","parameters":[{"name":"commentId","required":true,"in":"path","description":"Comment ID (auto-increment number or MongoDB ObjectId)","schema":{"type":"string"}}],"responses":{"200":{"description":"Comment details with context","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetAdminCommentResponseDto"}}}},"400":{"description":"Invalid comment ID format"},"404":{"description":"Comment not found"}},"security":[{"bearer":[]}],"summary":"Get comment details","tags":["Admin — Comment Management"]},"delete":{"description":"Soft-deletes the specified comment. The data is preserved for audit but hidden from all public APIs.\n\n**Options:**\n- `deleteReplies` — When `true`, also soft-deletes all direct replies to this comment.\n- `reason` — Stored on the comment. Can be surfaced to the user if needed.\n- `adminNote` — Internal note, visible only to admins.\n\nIf the deleted comment is a reply, it is automatically removed from the parent's `replyIds` array.","operationId":"AdminCommentsController_deleteComment","parameters":[{"name":"commentId","required":true,"in":"path","description":"Comment ID (auto-increment number or MongoDB ObjectId)","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeleteCommentDto"}}}},"responses":{"200":{"description":"Comment deleted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeleteCommentResponseDto"}}}},"404":{"description":"Comment not found"}},"security":[{"bearer":[]}],"summary":"Delete a comment","tags":["Admin — Comment Management"]}},"/api/admin/comments/{commentId}/moderate":{"patch":{"description":"Apply a moderation action to the specified comment.\n\n**Actions:**\n- `hide` — Removes the comment from public view. The comment is NOT deleted and can be restored with `unhide`.\n- `unhide` — Restores a previously hidden comment to public view.\n- `flag` — Marks the comment for review. Flagged comments appear in the moderation queue.\n- `unflag` — Removes the flag from a previously flagged comment.\n- `pin` — Pins the comment to the top of its video's comment section.\n- `unpin` — Removes the pin from a pinned comment.\n\nThe `reason` field is stored on the comment and can be surfaced to the user.\nThe `adminNote` field is internal-only and visible only to admins.","operationId":"AdminCommentsController_moderateComment","parameters":[{"name":"commentId","required":true,"in":"path","description":"Comment ID (auto-increment number or MongoDB ObjectId)","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ModerateCommentDto"}}}},"responses":{"200":{"description":"Moderation action applied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ModerateCommentResponseDto"}}}},"404":{"description":"Comment not found"}},"security":[{"bearer":[]}],"summary":"Moderate a comment","tags":["Admin — Comment Management"]}},"/api/admin/comments/bulk-delete":{"post":{"description":"Soft-deletes multiple comments in a single operation. Accepts up to 100 comment IDs.\n\n**Options:**\n- `commentIds` — Array of auto-increment comment IDs (as strings)\n- `deleteReplies` — Also delete all replies to the selected comments\n- `reason` — Shared reason applied to all deleted comments\n\nReturns `processedCount` + `failedCount` + `failedIds` for full visibility.\nAny comment IDs that don't exist are reported in `failedIds` but don't cause the operation to fail.","operationId":"AdminCommentsController_bulkDelete","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkDeleteCommentsDto"}}}},"responses":{"200":{"description":"Bulk delete result","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkActionResponseDto"}}}},"400":{"description":"No valid comment IDs provided"}},"security":[{"bearer":[]}],"summary":"Bulk delete comments","tags":["Admin — Comment Management"]}},"/api/admin/comments/bulk-moderate":{"post":{"description":"Apply a single moderation action to multiple comments. Accepts up to 100 comment IDs.\n\n**Actions:**\n- `hide` — Hide all selected comments from public view\n- `unhide` — Restore all selected hidden comments\n- `flag` — Flag all selected comments for review\n- `unflag` — Remove flags from all selected comments\n- `delete` — Soft-delete all selected comments\n\nReturns `processedCount` + `failedCount` + `failedIds` for operability.","operationId":"AdminCommentsController_bulkModerate","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkModerateCommentsDto"}}}},"responses":{"200":{"description":"Bulk moderation result","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkActionResponseDto"}}}},"400":{"description":"No valid comment IDs provided"}},"security":[{"bearer":[]}],"summary":"Bulk moderate comments","tags":["Admin — Comment Management"]}},"/api/admin/settings":{"get":{"description":"\nReturns the current state of all platform feature flags and maintenance mode.\n\n### Feature Flags\nEach flag controls whether the corresponding feature is available to users:\n| Flag | Controls |\n|------|----------|\n| `videoUploads` | User ability to upload videos, images, text posts, and create livestreams via `POST /user_mint` |\n| `livestreaming` | User ability to start/manage live broadcasts (stream key, ingest URL, settings) |\n| `comments` | User ability to post, edit, like, and delete comments (text, image, audio, GIF) |\n| `maintenanceMode` | When `true`, ALL non-admin endpoints return 503 |\n\n### Access\nAll admin roles can view settings.\n    ","operationId":"AdminSettingsController_getSettings","parameters":[],"responses":{"200":{"description":"Current platform settings","content":{"application/json":{"schema":{"type":"object","properties":{"videoUploads":{"type":"boolean","example":true,"description":"Whether video/post uploads are enabled"},"livestreaming":{"type":"boolean","example":true,"description":"Whether livestreaming is enabled"},"comments":{"type":"boolean","example":true,"description":"Whether comments are enabled"},"maintenanceMode":{"type":"boolean","example":false,"description":"Whether platform is in maintenance mode"},"maintenanceMessage":{"type":"string","example":"","description":"Optional message shown to users during maintenance"},"legacyUserCount":{"type":"number","example":2000,"description":"Old-platform members not yet imported into accounts; added to the dashboard user total"},"externalActiveUsers":{"type":"number","example":100,"description":"Average daily active users on affiliated community frontends; added to the public stats page's active-user counts"},"updatedBy":{"type":"string","example":"507f1f77bcf86cd799439011","description":"Admin ID who last changed settings"},"updatedAt":{"type":"string","format":"date-time","example":"2026-02-23T10:30:00.000Z","description":"When settings were last updated"}}}}}},"401":{"description":"Unauthorized — invalid or missing admin JWT"},"403":{"description":"Forbidden — insufficient admin role"}},"security":[{"admin-auth":[]}],"summary":"Get current platform settings","tags":["Admin — Platform Settings"]},"patch":{"description":"\nToggle platform feature flags and maintenance mode. Only fields included in the request body are updated — omitted fields remain unchanged.\n\n### Feature Flags\n| Flag | Effect when `false` |\n|------|---------------------|\n| `videoUploads` | `POST /user_mint` returns **503** — users cannot upload any content |\n| `livestreaming` | Stream key, ingest URL, and settings endpoints return **503** — users cannot start broadcasts |\n| `comments` | All comment endpoints (post, image, audio, GIF, like, edit, delete) return **503** |\n\n### Maintenance Mode\nWhen `maintenanceMode` is `true`:\n- **Every** non-admin endpoint returns **503** with the `maintenanceMessage`\n- Admin endpoints (`/api/admin/*`) continue to work so you can turn it off\n- The response includes `\"maintenance\": true` so the frontend can show a maintenance screen\n\n### Audit\nEvery settings change is recorded in admin activities with before/after values, so you can always see who changed what and when.\n\n### Example — Disable comments only:\n```json\n{ \"comments\": false }\n```\n\n### Example — Enter maintenance mode:\n```json\n{\n  \"maintenanceMode\": true,\n  \"maintenanceMessage\": \"Upgrading infrastructure. Back in 30 minutes!\"\n}\n```\n\n### Example — Exit maintenance and re-enable everything:\n```json\n{\n  \"videoUploads\": true,\n  \"livestreaming\": true,\n  \"comments\": true,\n  \"maintenanceMode\": false,\n  \"maintenanceMessage\": \"\"\n}\n```\n    ","operationId":"AdminSettingsController_updateSettings","parameters":[],"requestBody":{"required":true,"description":"Partial settings update — only include fields you want to change","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePlatformSettingsDto"},"examples":{"disable-comments":{"summary":"Disable comments","value":{"comments":false}},"enter-maintenance":{"summary":"Enter maintenance mode","value":{"maintenanceMode":true,"maintenanceMessage":"Back in 30 minutes!"}},"enable-all":{"summary":"Enable everything","value":{"videoUploads":true,"livestreaming":true,"comments":true,"maintenanceMode":false,"maintenanceMessage":""}}}}}},"responses":{"200":{"description":"Settings updated successfully — returns full current state","content":{"application/json":{"schema":{"type":"object","properties":{"videoUploads":{"type":"boolean","example":true},"livestreaming":{"type":"boolean","example":true},"comments":{"type":"boolean","example":false},"maintenanceMode":{"type":"boolean","example":false},"maintenanceMessage":{"type":"string","example":""},"legacyUserCount":{"type":"number","example":2000},"externalActiveUsers":{"type":"number","example":100},"updatedBy":{"type":"string","example":"507f1f77bcf86cd799439011"},"updatedAt":{"type":"string","format":"date-time","example":"2026-02-23T10:30:00.000Z"}}}}}},"400":{"description":"Invalid body — fields must be correct types (booleans, string)"},"401":{"description":"Unauthorized — invalid or missing admin JWT"},"403":{"description":"Forbidden — only SUPER_ADMIN can change settings"}},"security":[{"admin-auth":[]}],"summary":"Update platform settings (SUPER_ADMIN only)","tags":["Admin — Platform Settings"]}},"/api/admin/livechat/automod":{"get":{"description":"Settings for the global chat filters and anti-flood rule, the messages-in-a-row ladder per badge tier, counts per rule for the last 24 hours, and the most recent blocked messages.","operationId":"AdminLiveChatController_getAutoMod","parameters":[{"name":"limit","required":false,"in":"query","schema":{"type":"number"}}],"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"summary":"Automatic moderation settings and log","tags":["Admin - LiveChat"]},"patch":{"description":"Partial update. Fields: enabled, floodEnabled, floodBase, floodPerTier, floodWindowMinutes, bannedWordsEnabled, bannedWords[], duplicatesEnabled, scamsEnabled, linksEnabled, maxLinksPerMessage, newAccountLinkHours, charFloodEnabled.","operationId":"AdminLiveChatController_updateAutoMod","parameters":[],"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"summary":"Tune automatic moderation","tags":["Admin - LiveChat"]}},"/api/admin/livechat":{"get":{"description":"Returns a comprehensive overview of the global chat room including:\n- Room settings (slow mode, stake requirement)\n- Online user count and total message count\n- Full moderator list with user profiles\n- Full banned user list with user profiles\n- Pinned messages\n\nUse this as the main dashboard view for the livechat admin panel.","operationId":"AdminLiveChatController_getOverview","parameters":[],"responses":{"200":{"description":"Global chat room overview","content":{"application/json":{"schema":{"type":"object","properties":{"room":{"type":"object","properties":{"roomId":{"type":"string","example":"global"},"name":{"type":"string"},"activeUsers":{"type":"number"},"messageCount":{"type":"number"},"slowMode":{"type":"boolean"},"slowModeSeconds":{"type":"number"},"minStakeRequired":{"type":"number"},"moderators":{"type":"array","items":{"type":"string"}},"bannedUsers":{"type":"array","items":{"type":"string"}}}},"moderators":{"type":"array","items":{"type":"object","properties":{"address":{"type":"string"},"username":{"type":"string"},"displayName":{"type":"string"},"avatarUrl":{"type":"string"},"isModerator":{"type":"boolean"},"isBanned":{"type":"boolean"},"followers":{"type":"number"},"followings":{"type":"number"},"badgeBalance":{"type":"number"},"accountCreatedAt":{"type":"string","format":"date-time"}}}},"bannedUsers":{"type":"array","items":{"type":"object","properties":{"address":{"type":"string"},"username":{"type":"string"},"displayName":{"type":"string"},"avatarUrl":{"type":"string"},"isModerator":{"type":"boolean"},"isBanned":{"type":"boolean"},"followers":{"type":"number"},"followings":{"type":"number"},"badgeBalance":{"type":"number"},"accountCreatedAt":{"type":"string","format":"date-time"}}}}}}}}}},"security":[{"admin-jwt":[]}],"summary":"Get global chat overview","tags":["Admin - LiveChat"]}},"/api/admin/livechat/settings":{"patch":{"description":"Update slow mode, slow mode seconds, or minimum stake required.","operationId":"AdminLiveChatController_updateSettings","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"slowMode":{"type":"boolean","description":"Enable/disable slow mode"},"slowModeSeconds":{"type":"number","description":"Seconds between messages when slow mode is on","example":10},"minStakeRequired":{"type":"number","description":"Minimum DHB staked to chat","example":0}}}}}},"responses":{"200":{"description":"Updated room info"}},"security":[{"admin-jwt":[]}],"summary":"Update global chat settings","tags":["Admin - LiveChat"]}},"/api/admin/livechat/moderators":{"get":{"description":"Returns all moderators with profile info and badge.","operationId":"AdminLiveChatController_getModerators","parameters":[],"responses":{"200":{"description":"Moderator list"}},"security":[{"admin-jwt":[]}],"summary":"List chat moderators","tags":["Admin - LiveChat"]},"post":{"description":"Grant moderator privileges to a user by wallet address. The user must have a registered account.","operationId":"AdminLiveChatController_addModerator","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["address"],"properties":{"address":{"type":"string","description":"Wallet address of the user to make moderator"}}}}}},"responses":{"200":{"description":"Moderator added"},"404":{"description":"Account not found"}},"security":[{"admin-jwt":[]}],"summary":"Add a chat moderator","tags":["Admin - LiveChat"]}},"/api/admin/livechat/moderators/{address}":{"delete":{"description":"Revoke moderator privileges from a user.","operationId":"AdminLiveChatController_removeModerator","parameters":[{"name":"address","required":true,"in":"path","description":"Wallet address of the moderator to remove","schema":{"type":"string"}}],"responses":{"200":{"description":"Moderator removed"}},"security":[{"admin-jwt":[]}],"summary":"Remove a chat moderator","tags":["Admin - LiveChat"]}},"/api/admin/livechat/banned":{"get":{"description":"Returns all banned users with profile info.","operationId":"AdminLiveChatController_getBannedUsers","parameters":[],"responses":{"200":{"description":"Banned user list"}},"security":[{"admin-jwt":[]}],"summary":"List banned users","tags":["Admin - LiveChat"]}},"/api/admin/livechat/ban":{"post":{"description":"Ban a user from the global chat by wallet address.\n\n**Effects:**\n- User immediately receives a `livechat:userBanned` WebSocket event\n- User cannot send new messages (receives error with code `BANNED`)\n- User can still read messages\n- Ban persists until explicitly removed via DELETE /admin/livechat/ban/:address","operationId":"AdminLiveChatController_banUser","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["address"],"properties":{"address":{"type":"string","description":"Wallet address to ban"}}}}}},"responses":{"200":{"description":"User banned successfully"}},"security":[{"admin-jwt":[]}],"summary":"Ban a user from chat","tags":["Admin - LiveChat"]}},"/api/admin/livechat/ban/{address}":{"delete":{"description":"Lift the ban for a user, allowing them to chat again.","operationId":"AdminLiveChatController_unbanUser","parameters":[{"name":"address","required":true,"in":"path","description":"Wallet address to unban","schema":{"type":"string"}}],"responses":{"200":{"description":"User unbanned"}},"security":[{"admin-jwt":[]}],"summary":"Unban a user from chat","tags":["Admin - LiveChat"]}},"/api/admin/livechat/participants":{"get":{"description":"Returns all users who have sent at least one message in the global chat.\n\nEach participant includes:\n- Full user profile (username, displayName, avatar, followers, badge, etc.)\n- Message count in chat\n- Last message timestamp\n- Online/offline status\n- Moderator and ban status\n\n### Filters:\n- **filter=online** — only currently connected users\n- **filter=banned** — only banned users\n- **filter=moderators** — only moderators\n- **search** — search by username, displayName, or address","operationId":"AdminLiveChatController_getParticipants","parameters":[{"name":"filter","required":false,"in":"query","description":"Filter participants","schema":{"enum":["all","online","banned","moderators"],"type":"string"}},{"name":"search","required":false,"in":"query","description":"Search by username, displayName, or address","schema":{}},{"name":"limit","required":false,"in":"query","schema":{"example":50,"type":"number"}},{"name":"page","required":false,"in":"query","schema":{"example":1,"type":"number"}}],"responses":{"200":{"description":"Paginated participant list with user references","content":{"application/json":{"schema":{"type":"object","properties":{"participants":{"type":"array","items":{"type":"object","properties":{"user":{"type":"object","properties":{"address":{"type":"string"},"username":{"type":"string"},"displayName":{"type":"string"},"avatarUrl":{"type":"string"},"isModerator":{"type":"boolean"},"isBanned":{"type":"boolean"},"followers":{"type":"number"},"followings":{"type":"number"},"badgeBalance":{"type":"number"},"accountCreatedAt":{"type":"string","format":"date-time"}}},"messageCount":{"type":"number"},"lastMessageAt":{"type":"string","format":"date-time"},"isOnline":{"type":"boolean"},"isModerator":{"type":"boolean"},"isBanned":{"type":"boolean"}}}},"total":{"type":"number"},"page":{"type":"number"},"pages":{"type":"number"}}}}}}},"security":[{"admin-jwt":[]}],"summary":"List chat participants","tags":["Admin - LiveChat"]}},"/api/admin/livechat/messages":{"get":{"description":"Fetch messages with cursor-based pagination.\n\n**Admin view includes:**\n- Deleted messages (with deletion metadata: who deleted, when)\n- Full sender user reference details (username, displayName, avatar, followers, badge, etc.)\n- Filter by sender address\n\nAdmins can view but **cannot send** messages. Use DELETE endpoints to remove messages.","operationId":"AdminLiveChatController_getMessages","parameters":[{"name":"includeDeleted","required":false,"in":"query","description":"Include deleted messages (default: true)","schema":{"type":"boolean"}},{"name":"senderAddress","required":false,"in":"query","description":"Filter by sender wallet address","schema":{}},{"name":"limit","required":false,"in":"query","schema":{"example":50,"type":"number"}},{"name":"after","required":false,"in":"query","description":"Message ID cursor — get newer messages","schema":{}},{"name":"before","required":false,"in":"query","description":"Message ID cursor — get older messages","schema":{}}],"responses":{"200":{"description":"Messages with user references and deletion metadata","content":{"application/json":{"schema":{"type":"object","properties":{"messages":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"sender":{"type":"object","properties":{"address":{"type":"string"},"username":{"type":"string"},"displayName":{"type":"string"},"avatarUrl":{"type":"string"},"isModerator":{"type":"boolean"},"isBanned":{"type":"boolean"},"followers":{"type":"number"},"followings":{"type":"number"},"badgeBalance":{"type":"number"},"accountCreatedAt":{"type":"string","format":"date-time"}}},"content":{"type":"string"},"messageType":{"type":"string","enum":["text","media","gif","system"]},"reactions":{"type":"object"},"isPinned":{"type":"boolean"},"isDeleted":{"type":"boolean"},"deletedBy":{"type":"string"},"deletedAt":{"type":"string","format":"date-time"},"createdAt":{"type":"string"}}}},"hasMore":{"type":"boolean"},"totalCount":{"type":"number","description":"Total messages in room (all time)"}}}}}}},"security":[{"admin-jwt":[]}],"summary":"Get chat messages (admin view)","tags":["Admin - LiveChat"]}},"/api/admin/livechat/messages/{messageId}":{"delete":{"description":"Delete any message from the global chat.","operationId":"AdminLiveChatController_deleteMessage","parameters":[{"name":"messageId","required":true,"in":"path","description":"Message ID to delete","schema":{"type":"string"}}],"responses":{"200":{"description":"Message deleted"}},"security":[{"admin-jwt":[]}],"summary":"Delete a message (admin)","tags":["Admin - LiveChat"]}},"/api/admin/livechat/pin/{messageId}":{"post":{"operationId":"AdminLiveChatController_pinMessage","parameters":[{"name":"messageId","required":true,"in":"path","description":"Message ID to pin","schema":{"type":"string"}}],"responses":{"201":{"description":""}},"security":[{"admin-jwt":[]}],"summary":"Pin a message (admin)","tags":["Admin - LiveChat"]},"delete":{"operationId":"AdminLiveChatController_unpinMessage","parameters":[{"name":"messageId","required":true,"in":"path","description":"Message ID to unpin","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"summary":"Unpin a message (admin)","tags":["Admin - LiveChat"]}},"/api/admin/swarm":{"get":{"operationId":"AdminSwarmController_overview","parameters":[],"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/agents/{id}":{"get":{"operationId":"AdminSwarmController_agent","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/agents":{"post":{"operationId":"AdminSwarmController_register","parameters":[],"responses":{"201":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/op":{"post":{"operationId":"AdminSwarmController_op","parameters":[],"responses":{"201":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/mode":{"post":{"operationId":"AdminSwarmController_mode","parameters":[],"responses":{"201":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/wake":{"post":{"operationId":"AdminSwarmController_wake","parameters":[],"responses":{"201":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/engagement":{"post":{"operationId":"AdminSwarmController_startEngagement","parameters":[],"responses":{"201":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]},"delete":{"operationId":"AdminSwarmController_stopEngagement","parameters":[{"name":"restorePosting","required":true,"in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/settings":{"patch":{"operationId":"AdminSwarmController_settings","parameters":[],"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/fix":{"post":{"operationId":"AdminSwarmController_fix","parameters":[],"responses":{"201":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/stop-all":{"post":{"operationId":"AdminSwarmController_stopAll","parameters":[],"responses":{"201":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/roster":{"get":{"operationId":"AdminSwarmController_roster","parameters":[],"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/roster/{address}/log":{"get":{"operationId":"AdminSwarmController_agentLog","parameters":[{"name":"address","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/roster/{address}":{"patch":{"operationId":"AdminSwarmController_editAgent","parameters":[{"name":"address","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/roster/bulk":{"post":{"operationId":"AdminSwarmController_bulkEdit","parameters":[],"responses":{"201":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/crew":{"post":{"operationId":"AdminSwarmController_crew","parameters":[],"responses":{"201":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/crew/restyle":{"post":{"operationId":"AdminSwarmController_restyleCrew","parameters":[],"responses":{"201":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/act":{"post":{"operationId":"AdminSwarmController_act","parameters":[],"responses":{"201":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/auto-likes":{"patch":{"operationId":"AdminSwarmController_autoLikes","parameters":[],"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/auto-follows":{"patch":{"operationId":"AdminSwarmController_autoFollows","parameters":[],"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/follow-delay":{"patch":{"operationId":"AdminSwarmController_followDelay","parameters":[],"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/topic-repeat":{"patch":{"operationId":"AdminSwarmController_topicRepeat","parameters":[],"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/campaigns":{"get":{"operationId":"AdminSwarmController_listCampaigns","parameters":[],"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]},"post":{"operationId":"AdminSwarmController_startCampaign","parameters":[],"responses":{"201":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/campaigns/preview":{"post":{"operationId":"AdminSwarmController_preview","parameters":[],"responses":{"201":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/campaigns/{id}/actions":{"get":{"operationId":"AdminSwarmController_campaignActions","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/campaigns/{id}/{operation}":{"post":{"operationId":"AdminSwarmController_controlCampaign","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}},{"name":"operation","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/trending":{"get":{"operationId":"AdminSwarmController_trendingOverview","parameters":[],"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]},"patch":{"operationId":"AdminSwarmController_trendingSettings","parameters":[],"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/trending/curator":{"patch":{"operationId":"AdminSwarmController_curatorSettings","parameters":[],"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/trending/repick":{"post":{"operationId":"AdminSwarmController_trendingRepick","parameters":[],"responses":{"201":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/trending/{id}/{operation}":{"post":{"operationId":"AdminSwarmController_trendingControl","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}},{"name":"operation","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/replies":{"get":{"operationId":"AdminSwarmController_repliesOverview","parameters":[],"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]},"patch":{"operationId":"AdminSwarmController_repliesSettings","parameters":[],"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/replies/try":{"post":{"operationId":"AdminSwarmController_repliesTry","parameters":[],"responses":{"201":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/replies/{id}":{"patch":{"operationId":"AdminSwarmController_repliesEdit","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/replies/{id}/{operation}":{"post":{"operationId":"AdminSwarmController_repliesControl","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}},{"name":"operation","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/livechat":{"get":{"operationId":"AdminSwarmController_liveChatOverview","parameters":[],"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]},"patch":{"operationId":"AdminSwarmController_liveChatSettings","parameters":[],"responses":{"200":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/admin/swarm/livechat/{id}/{operation}":{"post":{"operationId":"AdminSwarmController_liveChatControl","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}},{"name":"operation","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":""}},"security":[{"admin-jwt":[]}],"tags":["Admin - Swarm"]}},"/api/livechat/room":{"get":{"description":"Returns room settings, online user count, moderator list, and pinned messages.","operationId":"LiveChatController_getRoom","parameters":[],"responses":{"200":{"description":"Global chat room info","content":{"application/json":{"schema":{"type":"object","properties":{"roomId":{"type":"string","example":"global"},"name":{"type":"string","example":"Global Chat"},"description":{"type":"string"},"activeUsers":{"type":"number","example":42},"messageCount":{"type":"number","example":15000},"slowMode":{"type":"boolean"},"slowModeSeconds":{"type":"number"},"minStakeRequired":{"type":"number"},"moderators":{"type":"array","items":{"type":"string"}},"pinnedMessages":{"type":"array","items":{"type":"object"}},"lastMessageAt":{"type":"string","format":"date-time"}}}}}}},"summary":"Get global chat room info","tags":["LiveChat"]}},"/api/livechat/messages":{"get":{"description":"Fetch messages with cursor-based pagination.\n\nUse `before` for older messages, `after` for newer.\nMax 100 messages per request, default 50.","operationId":"LiveChatController_getMessages","parameters":[{"name":"limit","required":false,"in":"query","schema":{"example":50,"type":"number"}},{"name":"after","required":false,"in":"query","description":"Message ID cursor — get newer messages","schema":{}},{"name":"before","required":false,"in":"query","description":"Message ID cursor — get older messages","schema":{}}],"responses":{"200":{"description":"Messages list with pagination","content":{"application/json":{"schema":{"type":"object","properties":{"messages":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"sender":{"type":"object","properties":{"address":{"type":"string"},"username":{"type":"string"},"displayName":{"type":"string"},"avatarUrl":{"type":"string"},"isModerator":{"type":"boolean"},"isBanned":{"type":"boolean"},"followers":{"type":"number"},"followings":{"type":"number"},"badgeBalance":{"type":"number"},"accountCreatedAt":{"type":"string","format":"date-time"}}},"content":{"type":"string"},"messageType":{"type":"string","enum":["text","media","gif","system"]},"reactions":{"type":"object"},"isPinned":{"type":"boolean"},"createdAt":{"type":"string"}}}},"hasMore":{"type":"boolean"}}}}}}},"summary":"Get chat messages","tags":["LiveChat"]}},"/api/livechat/user/{address}":{"get":{"description":"Returns badgeBalance, followers, and profile info. Frontend derives the badge tier from badgeBalance.","operationId":"LiveChatController_getUserProfile","parameters":[{"name":"address","required":true,"in":"path","description":"Wallet address","schema":{"type":"string"}}],"responses":{"200":{"description":"User profile"},"404":{"description":"User not found"}},"summary":"Get user chat profile","tags":["LiveChat"]}},"/api/livechat/online":{"get":{"operationId":"LiveChatController_getOnlineCount","parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"count":{"type":"number"}}}}}}},"summary":"Get online user count","tags":["LiveChat"]}},"/api/livechat/status":{"get":{"description":"Returns whether the authenticated user is banned, is a moderator, and whether they can send messages.\n\nUse this on app launch and after reconnecting to show the correct UI state.\n\n**Response fields:**\n- `isBanned`: true if user is banned from chat\n- `isModerator`: true if user has moderator privileges\n- `canChat.allowed`: whether the user can currently send a message\n- `canChat.reason`: human-readable reason if not allowed (e.g. \"You are banned from chat\", \"Slow mode: wait 3s\")","operationId":"LiveChatController_getStatus","parameters":[],"responses":{"200":{"description":"User chat status","content":{"application/json":{"schema":{"type":"object","properties":{"isBanned":{"type":"boolean","example":false},"isModerator":{"type":"boolean","example":false},"canChat":{"type":"object","properties":{"allowed":{"type":"boolean","example":true},"reason":{"type":"string","example":"You are banned from chat"}}}}}}}}},"security":[{"user-auth":[]}],"summary":"Get your chat status","tags":["LiveChat"]}},"/api/livechat/mod/ban":{"post":{"description":"Ban a user from the global chat. Moderators only.","operationId":"LiveChatController_modBanUser","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["address"],"properties":{"address":{"type":"string"}}}}}},"responses":{"200":{"description":"User banned"}},"security":[{"user-auth":[]}],"summary":"Ban a user from chat (mod)","tags":["LiveChat"]}},"/api/livechat/mod/ban/{userAddress}":{"delete":{"operationId":"LiveChatController_modUnbanUser","parameters":[{"name":"userAddress","required":true,"in":"path","description":"Address to unban","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"security":[{"user-auth":[]}],"summary":"Unban a user from chat (mod)","tags":["LiveChat"]}},"/api/livechat/mod/pin/{messageId}":{"post":{"operationId":"LiveChatController_modPinMessage","parameters":[{"name":"messageId","required":true,"in":"path","description":"Message ID to pin","schema":{"type":"string"}}],"responses":{"201":{"description":""}},"security":[{"user-auth":[]}],"summary":"Pin a message (mod)","tags":["LiveChat"]},"delete":{"operationId":"LiveChatController_modUnpinMessage","parameters":[{"name":"messageId","required":true,"in":"path","description":"Message ID to unpin","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"security":[{"user-auth":[]}],"summary":"Unpin a message (mod)","tags":["LiveChat"]}},"/api/livechat/mod/message/{messageId}":{"delete":{"operationId":"LiveChatController_modDeleteMessage","parameters":[{"name":"messageId","required":true,"in":"path","description":"Message ID to delete","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"security":[{"user-auth":[]}],"summary":"Delete a message (mod)","tags":["LiveChat"]}},"/api/livechat/stream/{tokenId}/timeout":{"post":{"operationId":"LiveChatController_streamTimeout","parameters":[{"name":"tokenId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":""}},"security":[{"user-auth":[]}],"summary":"Time someone out of a creator's stream chats","tags":["LiveChat"]}},"/api/livechat/stream/{tokenId}/ban":{"post":{"operationId":"LiveChatController_streamBan","parameters":[{"name":"tokenId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":""}},"security":[{"user-auth":[]}],"summary":"Ban someone from a creator's stream chats","tags":["LiveChat"]}},"/api/livechat/moderation/mine":{"get":{"operationId":"LiveChatController_myModeration","parameters":[],"responses":{"200":{"description":""}},"security":[{"user-auth":[]}],"tags":["LiveChat"]},"put":{"operationId":"LiveChatController_saveMyModeration","parameters":[],"responses":{"200":{"description":""}},"security":[{"user-auth":[]}],"tags":["LiveChat"]}},"/api/livechat/moderation/mine/moderators":{"post":{"operationId":"LiveChatController_addMyModerator","parameters":[],"responses":{"201":{"description":""}},"security":[{"user-auth":[]}],"tags":["LiveChat"]}},"/api/livechat/moderation/mine/moderators/{address}":{"delete":{"operationId":"LiveChatController_removeMyModerator","parameters":[{"name":"address","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"security":[{"user-auth":[]}],"tags":["LiveChat"]}},"/api/livechat/moderation/mine/bans/{address}":{"delete":{"operationId":"LiveChatController_unbanFromMine","parameters":[{"name":"address","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"security":[{"user-auth":[]}],"tags":["LiveChat"]}},"/api/livechat/upload-voice":{"post":{"operationId":"LiveChatController_uploadVoiceNote","parameters":[],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"audio":{"type":"string","format":"binary","description":"Audio file (m4a, aac, mp3, ogg, opus, wav, webm)"}},"required":["audio"]}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","description":"CDN URL for the uploaded voice note"},"duration":{"type":"number","description":"Duration in seconds"}}}}}}},"security":[{"user-auth":[]}],"summary":"Upload a voice note for live chat (max 2 MB, 30 s)","tags":["LiveChat"]}},"/api/tv/requests":{"post":{"description":"\nThe TV holds a session that cannot move funds — no wallet key ever reaches the\ndevice. This queues an intent for the owner's phone, which already holds the\nunlocked wallet, to approve and submit exactly as if they had tapped it there.\n\nThe server never signs and never holds a key. It is a postbox.\n\nThe address is taken from the caller's own token and is never read from the\nbody, so a request can only ever be raised against the account raising it.\n    ","operationId":"TvController_create","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["kind","payload"],"properties":{"kind":{"type":"string","enum":["tip","buy","mint","subscribe"],"example":"tip"},"payload":{"type":"object","example":{"tokenId":5194,"amount":100,"tokenSymbol":"DHB","recipient":"0xabc…"}}}}}}},"responses":{"200":{"description":"Request queued"},"400":{"description":"Unknown kind, or a payload the phone could not render"},"429":{"description":"Too many requests already waiting"}},"security":[{"bearer":[]}],"summary":"Ask the account owner’s phone to sign something for this television","tags":["TV"]}},"/api/tv/requests/pending":{"get":{"description":"Scoped to the caller. Ordered newest first, and never returns anything expired.","operationId":"TvController_listPending","parameters":[],"responses":{"200":{"description":""}},"security":[{"bearer":[]}],"summary":"What is waiting for this account’s phone to answer","tags":["TV"]}},"/api/tv/requests/{requestId}":{"get":{"description":"A request that expired is 404, not \"rejected\" — the television should offer to ask again rather than telling the user they refused.","operationId":"TvController_get","parameters":[{"name":"requestId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"security":[{"bearer":[]}],"summary":"Poll one request","tags":["TV"]}},"/api/tv/requests/{requestId}/resolve":{"post":{"description":"\nTerminal: a request leaves `pending` exactly once, enforced by a conditional\nupdate rather than read-then-write. A double tap or a retried request on a poor\nconnection therefore changes nothing the second time, which for a tip is the\ndifference between paying once and paying twice.\n\nAn `approved` resolution MUST carry the `txHash` of the submitted\ntransaction. Without it the television would tell the user their tip went\nthrough with nothing behind the claim.\n    ","operationId":"TvController_resolve","parameters":[{"name":"requestId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["status"],"properties":{"status":{"type":"string","enum":["approved","rejected","failed"]},"txHash":{"type":"string","description":"Required when status is approved"},"error":{"type":"string","description":"Optional detail when status is failed"}}}}}},"responses":{"200":{"description":"Resolved"},"409":{"description":"Already answered or expired"}},"security":[{"bearer":[]}],"summary":"Approve, reject or report failure — called by the phone","tags":["TV"]}},"/api/tv/pair/start":{"post":{"description":"Unauthenticated: the television has nothing to authenticate with yet. Returns a short code to display and a pairingId to poll with. The code expires in two minutes.","operationId":"TvPairController_start","parameters":[],"responses":{"200":{"description":"Code allocated"}},"summary":"Ask for a pairing code (called by the television)","tags":["TV"]}},"/api/tv/pair/status":{"get":{"description":"\nReturns `pending`, `expired`, `rejected`, `consumed`, or — exactly once —\n`approved` together with the session.\n\nThe tokens are minted at collection rather than at approval, so a live\ncredential never sits at rest in the pairing row. Two polls racing each other\ncannot both collect: the row moves to `consumed` in the same conditional\nupdate that claims it.\n    ","operationId":"TvPairController_status","parameters":[{"name":"pairingId","required":true,"in":"query","schema":{}}],"responses":{"200":{"description":""}},"summary":"Poll a pairing (called by the television)","tags":["TV"]}},"/api/tv/pair/lookup":{"get":{"description":"So the approval prompt can say which television it is authorising. A prompt that cannot name what it is authorising teaches people to authorise anything, which for a device-code flow is exactly the attack.","operationId":"TvPairController_lookup","parameters":[{"name":"code","required":true,"in":"query","schema":{}}],"responses":{"200":{"description":""}},"security":[{"bearer":[]}],"summary":"Name the device behind a code, before approving it","tags":["TV"]}},"/api/tv/pair/approve":{"post":{"description":"The address comes from the approver’s own token and is never accepted from the body — the code is readable off a screen, so trusting an address beside it would let anyone mint a session for anyone.","operationId":"TvPairController_approve","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["code"],"properties":{"code":{"type":"string","example":"WXYZ-42K8"},"reject":{"type":"boolean","description":"Refuse instead of approving"}}}}}},"responses":{"200":{"description":"Approved or rejected"},"404":{"description":"Expired or already used"}},"security":[{"bearer":[]}],"summary":"Approve or refuse a pairing code (called by the phone)","tags":["TV"]}},"/api/block":{"post":{"description":"\nBlock another user by their wallet address.\n\n### Effects of blocking\n| Area | Effect |\n|------|--------|\n| Feed | Their posts no longer appear in your feed. Your posts no longer appear in their feed. |\n| DMs | Neither party can send new DMs to the other. Existing DM threads remain read-only. |\n| Profile | Their profile is still publicly viewable, but `blockedYou: true` / `youBlocked: true` flags are returned on `GET /account_info/:id`. |\n| Follow | Existing follow relationships are **not** automatically removed. You can unfollow separately if desired. |\n| Search | Blocked users are hidden from DM user-search results. |\n\n### Idempotency\nBlocking someone you have already blocked returns `200` with `already blocked` message — it is safe to call multiple times.\n\n### Rate-limiting\n- 10 block actions per minute per account.\n    ","operationId":"BlockController_blockUser","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["address"],"properties":{"address":{"type":"string","description":"Wallet address of the user to block (0x…)","example":"0xAbCd1234567890abcdef1234567890abcdef1234"},"reason":{"type":"string","maxLength":500,"description":"Optional private reason for blocking (visible only to you, never to the blocked user)","example":"Spam"}}}}}},"responses":{"200":{"description":"User blocked (or was already blocked)","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"message":{"type":"string","example":"User blocked successfully"},"blockId":{"type":"string","example":"665f1a2b3c4d5e6f7a8b9c0d"},"blocked":{"type":"object","properties":{"address":{"type":"string"},"username":{"type":"string","nullable":true},"displayName":{"type":"string","nullable":true}}}}}}}},"400":{"description":"Invalid address or self-block attempt"},"401":{"description":"Authentication required"},"404":{"description":"Target user not found"}},"security":[{"bearer":[]}],"summary":"Block a user","tags":["Blocks"]},"get":{"description":"\nReturns a paginated list of users **you have blocked**.\n\nEach item includes the blocked user's address, username, display name, avatar, \nyour optional reason, and the timestamp.\n\n### Pagination\nQuery params: `page` (default 1), `limit` (default 20, max 50).\n    ","operationId":"BlockController_getBlocklist","parameters":[],"responses":{"200":{"description":"Paginated block list","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean"},"total":{"type":"number","example":3},"page":{"type":"number","example":1},"limit":{"type":"number","example":20},"pages":{"type":"number","example":1},"items":{"type":"array","items":{"type":"object","properties":{"blockId":{"type":"string"},"address":{"type":"string"},"username":{"type":"string","nullable":true},"displayName":{"type":"string","nullable":true},"avatarImageUrl":{"type":"string","nullable":true},"reason":{"type":"string","nullable":true,"description":"Your private reason for blocking"},"blockedAt":{"type":"string","format":"date-time"}}}}}}}}},"401":{"description":"Authentication required"}},"security":[{"bearer":[]}],"summary":"Get your block list","tags":["Blocks"]}},"/api/block/{targetAddress}":{"delete":{"description":"\nRemoves an existing block on a user.\n\nAll effects of the block (feed filtering, DM restrictions) are lifted immediately.\nReturns `404` if there was no active block to remove.\n    ","operationId":"BlockController_unblockUser","parameters":[{"name":"targetAddress","required":true,"in":"path","description":"Wallet address of the user to unblock","schema":{"example":"0xAbCd1234567890abcdef1234567890abcdef1234"}}],"responses":{"200":{"description":"User unblocked successfully","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"message":{"type":"string","example":"User unblocked successfully"},"address":{"type":"string"}}}}}},"401":{"description":"Authentication required"},"404":{"description":"No active block found for this user"}},"security":[{"bearer":[]}],"summary":"Unblock a user","tags":["Blocks"]}},"/api/block/blocked-by":{"get":{"description":"\nReturns a paginated list of users **who have blocked you**.\n\nNote: for privacy, the reason the other user gave is **not** exposed here.\n\n### Pagination\nQuery params: `page` (default 1), `limit` (default 20, max 50).\n    ","operationId":"BlockController_getBlockedByList","parameters":[],"responses":{"200":{"description":"Paginated blocked-by list","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean"},"total":{"type":"number"},"page":{"type":"number"},"limit":{"type":"number"},"pages":{"type":"number"},"items":{"type":"array","items":{"type":"object","properties":{"blockId":{"type":"string"},"address":{"type":"string"},"username":{"type":"string","nullable":true},"displayName":{"type":"string","nullable":true},"avatarImageUrl":{"type":"string","nullable":true},"blockedAt":{"type":"string","format":"date-time"}}}}}}}}},"401":{"description":"Authentication required"}},"security":[{"bearer":[]}],"summary":"Get accounts that have blocked you","tags":["Blocks"]}},"/api/block/status/{targetAddress}":{"get":{"description":"\nReturns the bidirectional block status between the authenticated user and a target address.\n\nUseful for quickly checking before rendering interaction buttons (follow, DM, tip, etc.).\n\n| Field | Meaning |\n|-------|---------|\n| `youBlocked` | You have blocked this user |\n| `blockedYou` | This user has blocked you |\n| `isBlocked` | Either direction — any block exists |\n    ","operationId":"BlockController_checkBlockStatus","parameters":[{"name":"targetAddress","required":true,"in":"path","description":"Target wallet address to check block status against","schema":{"example":"0xAbCd1234567890abcdef1234567890abcdef1234"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean"},"youBlocked":{"type":"boolean","description":"You blocked this user"},"blockedYou":{"type":"boolean","description":"This user blocked you"},"isBlocked":{"type":"boolean","description":"Block exists in either direction"}}}}}},"400":{"description":"Invalid address"},"401":{"description":"Authentication required"}},"security":[{"bearer":[]}],"summary":"Check block status between you and another user","tags":["Blocks"]}},"/api/mute":{"post":{"description":"\nHide another account's posts from your own feeds. Private and one-way.\n\n### Mute vs block\n| Area | Mute | Block |\n|------|------|-------|\n| Their posts in your feed | hidden | hidden |\n| Your posts in their feed | **still visible** | hidden |\n| DMs | **unaffected** | severed both ways |\n| Visible to them | **never** | surfaced on their profile |\n| Follow relationship | untouched | untouched |\n\nMute is the low-stakes option: nothing is severed and the other account is never\ntold, so it is safe to use liberally. Use `POST /block` when the relationship\nitself should be cut.\n\n### Idempotency\nMuting someone already muted returns `200` — safe to call repeatedly.\n    ","operationId":"MuteController_muteUser","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["address"],"properties":{"address":{"type":"string","description":"Wallet address of the user to mute (0x…)","example":"0xAbCd1234567890abcdef1234567890abcdef1234"}}}}}},"responses":{"200":{"description":"User muted (or was already muted)","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"message":{"type":"string","example":"User muted successfully"},"muteId":{"type":"string"},"muted":{"type":"object","properties":{"address":{"type":"string"},"username":{"type":"string","nullable":true},"displayName":{"type":"string","nullable":true}}}}}}}},"400":{"description":"Invalid address or self-mute attempt"},"401":{"description":"Authentication required"},"404":{"description":"Target user not found"}},"security":[{"bearer":[]}],"summary":"Mute a user","tags":["Mutes"]},"get":{"description":"Paginated list of accounts **you have muted**. There is deliberately no \"muted-by\" counterpart: a mute is private, and exposing it would make it detectable. Query params: `page` (default 1), `limit` (default 20, max 50).","operationId":"MuteController_getMuteList","parameters":[],"responses":{"200":{"description":"Paginated mute list","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean"},"total":{"type":"number"},"page":{"type":"number"},"limit":{"type":"number"},"pages":{"type":"number"},"items":{"type":"array","items":{"type":"object","properties":{"muteId":{"type":"string"},"address":{"type":"string"},"username":{"type":"string","nullable":true},"displayName":{"type":"string","nullable":true},"avatarImageUrl":{"type":"string","nullable":true},"mutedAt":{"type":"string","format":"date-time"}}}}}}}}},"401":{"description":"Authentication required"}},"security":[{"bearer":[]}],"summary":"Get your mute list","tags":["Mutes"]}},"/api/mute/{targetAddress}":{"delete":{"description":"Removes an existing mute. Their posts reappear in your feeds immediately. Returns `404` if there was no active mute.","operationId":"MuteController_unmuteUser","parameters":[{"name":"targetAddress","required":true,"in":"path","description":"Wallet address of the user to unmute","schema":{"example":"0xAbCd1234567890abcdef1234567890abcdef1234"}}],"responses":{"200":{"description":"User unmuted successfully"},"400":{"description":"Invalid address"},"401":{"description":"Authentication required"},"404":{"description":"No active mute found for this user"}},"security":[{"bearer":[]}],"summary":"Unmute a user","tags":["Mutes"]}},"/api/mute/status/{targetAddress}":{"get":{"description":"Returns `youMuted` only. There is no `mutedYou` by design — a mute must not be detectable by the account it targets.","operationId":"MuteController_checkMuteStatus","parameters":[{"name":"targetAddress","required":true,"in":"path","description":"Target wallet address","schema":{}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean"},"youMuted":{"type":"boolean"}}}}}},"400":{"description":"Invalid address"},"401":{"description":"Authentication required"}},"security":[{"bearer":[]}],"summary":"Check whether you have muted a user","tags":["Mutes"]}},"/api/keyword-filters":{"get":{"description":"\nA private list of words and phrases. Posts that contain one are hidden, or\nfolded behind a line naming the filter, in the places chosen (`home`,\n`public`, `notifications`, `thread`, `profile`). Matching is done by the\nclient, case-insensitively, as a whole word unless `wholeWord` is false.\n\nBody fields: `phrase` (1–100 characters), `wholeWord` (default true),\n`action` (`warn` or `hide`, default `warn`), `contexts` (default all),\n`expiresIn` (seconds, 30 minutes to a year, or null for no expiry).\n","operationId":"KeywordFilterController_list","parameters":[],"responses":{"200":{"description":""}},"security":[{"bearer":[]}],"summary":"List your keyword filters","tags":["Keyword filters"]},"post":{"description":"\nA private list of words and phrases. Posts that contain one are hidden, or\nfolded behind a line naming the filter, in the places chosen (`home`,\n`public`, `notifications`, `thread`, `profile`). Matching is done by the\nclient, case-insensitively, as a whole word unless `wholeWord` is false.\n\nBody fields: `phrase` (1–100 characters), `wholeWord` (default true),\n`action` (`warn` or `hide`, default `warn`), `contexts` (default all),\n`expiresIn` (seconds, 30 minutes to a year, or null for no expiry).\n","operationId":"KeywordFilterController_create","parameters":[],"responses":{"201":{"description":""}},"security":[{"bearer":[]}],"summary":"Add a keyword filter (or update the one on the same phrase)","tags":["Keyword filters"]}},"/api/keyword-filters/{id}":{"patch":{"description":"\nA private list of words and phrases. Posts that contain one are hidden, or\nfolded behind a line naming the filter, in the places chosen (`home`,\n`public`, `notifications`, `thread`, `profile`). Matching is done by the\nclient, case-insensitively, as a whole word unless `wholeWord` is false.\n\nBody fields: `phrase` (1–100 characters), `wholeWord` (default true),\n`action` (`warn` or `hide`, default `warn`), `contexts` (default all),\n`expiresIn` (seconds, 30 minutes to a year, or null for no expiry).\n","operationId":"KeywordFilterController_update","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"security":[{"bearer":[]}],"summary":"Change a keyword filter","tags":["Keyword filters"]},"delete":{"operationId":"KeywordFilterController_remove","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"security":[{"bearer":[]}],"summary":"Remove a keyword filter","tags":["Keyword filters"]}},"/api/followed-tags":{"get":{"description":"\nHashtags you follow. Posts that carry one join your Following feed beside the\npeople you follow. Tags are stored lowercase without the `#`; up to\n200. Private to you.\n","operationId":"TagFollowController_list","parameters":[],"responses":{"200":{"description":""}},"security":[{"bearer":[]}],"summary":"List the hashtags you follow","tags":["Followed tags"]},"post":{"description":"\nHashtags you follow. Posts that carry one join your Following feed beside the\npeople you follow. Tags are stored lowercase without the `#`; up to\n200. Private to you.\n","operationId":"TagFollowController_follow","parameters":[],"responses":{"201":{"description":""}},"security":[{"bearer":[]}],"summary":"Follow a hashtag","tags":["Followed tags"]}},"/api/followed-tags/{tag}":{"delete":{"description":"\nHashtags you follow. Posts that carry one join your Following feed beside the\npeople you follow. Tags are stored lowercase without the `#`; up to\n200. Private to you.\n","operationId":"TagFollowController_unfollow","parameters":[{"name":"tag","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"security":[{"bearer":[]}],"summary":"Unfollow a hashtag","tags":["Followed tags"]}},"/api/agent-welcome":{"get":{"description":"\nWelcome new members with your own agent. For each agent you own, switch on\n`follow` and/or `message` (with your own `text`; `{name}` becomes their\n@username). About every 10 minutes the agent follows / messages one more person\nfrom Explore's New members list, up to `perDay` (1-10, default 3).\nLimits: never the same person twice, all your agents together send at most\n20 messages a day, a new member gets at most 3\nagent welcome messages in total, no links, and nobody who blocked or muted you,\nturned off new messages or hid themselves from New members. Called with an\nagent's own token, it manages that agent.\n","operationId":"AgentWelcomeController_list","parameters":[],"responses":{"200":{"description":""}},"security":[{"bearer":[]}],"summary":"Your agents and their welcome settings","tags":["Agent welcomes"]}},"/api/agent-welcome/{agent}":{"post":{"description":"\nWelcome new members with your own agent. For each agent you own, switch on\n`follow` and/or `message` (with your own `text`; `{name}` becomes their\n@username). About every 10 minutes the agent follows / messages one more person\nfrom Explore's New members list, up to `perDay` (1-10, default 3).\nLimits: never the same person twice, all your agents together send at most\n20 messages a day, a new member gets at most 3\nagent welcome messages in total, no links, and nobody who blocked or muted you,\nturned off new messages or hid themselves from New members. Called with an\nagent's own token, it manages that agent.\n","operationId":"AgentWelcomeController_update","parameters":[{"name":"agent","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":""}},"security":[{"bearer":[]}],"summary":"Change an agent's welcome settings","tags":["Agent welcomes"]}},"/api/agent-welcome/{agent}/log":{"get":{"description":"\nWelcome new members with your own agent. For each agent you own, switch on\n`follow` and/or `message` (with your own `text`; `{name}` becomes their\n@username). About every 10 minutes the agent follows / messages one more person\nfrom Explore's New members list, up to `perDay` (1-10, default 3).\nLimits: never the same person twice, all your agents together send at most\n20 messages a day, a new member gets at most 3\nagent welcome messages in total, no links, and nobody who blocked or muted you,\nturned off new messages or hid themselves from New members. Called with an\nagent's own token, it manages that agent.\n","operationId":"AgentWelcomeController_log","parameters":[{"name":"agent","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"security":[{"bearer":[]}],"summary":"Who this agent welcomed, and who it skipped and why","tags":["Agent welcomes"]}},"/api/profile-fields":{"put":{"description":"\nReplace your profile fields: up to 4 label/value lines shown\non your profile. A value that is an https link is verified when that page links\nback to your DeHub profile (https://riftn.com/<username>) with rel=\"me\"; the\nanswer carries `verifiedAt` for each one that does. Saving checks the links\nagain, so save after adding the link on the other side.\n","operationId":"ProfileFieldsController_save","parameters":[],"responses":{"200":{"description":""}},"security":[{"bearer":[]}],"summary":"Set your profile fields","tags":["Profile fields"]}},"/api/github-link":{"get":{"operationId":"GithubLinkController_status","parameters":[],"responses":{"200":{"description":""}},"tags":["GithubLink"]},"delete":{"operationId":"GithubLinkController_disconnect","parameters":[],"responses":{"200":{"description":""}},"tags":["GithubLink"]}},"/api/github-link/connect":{"post":{"operationId":"GithubLinkController_connect","parameters":[],"responses":{"201":{"description":""}},"tags":["GithubLink"]}},"/api/github-link/callback":{"get":{"operationId":"GithubLinkController_callback","parameters":[],"responses":{"200":{"description":""}},"tags":["GithubLink"]}},"/api/repost":{"post":{"description":"\n## Overview\nToggle a repost on a post (like a retweet). If already reposted, removes the repost.\nReposts appear on the user's profile timeline. The original post's repost count is updated.\n\n## Rules\n- One repost per user per post (toggle on/off)\n- Reposting sends a notification to the original creator (except when reposting your own content)\n\n## Response\nReturns the new repost state and total repost count for the token.\n    ","operationId":"RepostController_toggleRepost","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tokenId"],"properties":{"tokenId":{"type":"number","description":"Token ID of the post to repost","example":123}}}}}},"responses":{"200":{"description":"Repost toggled","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"reposted":{"type":"boolean","description":"Whether the post is now reposted","example":true},"repostCount":{"type":"number","description":"Total repost count for this token","example":5},"message":{"type":"string","example":"Post reposted"}}}}}},"400":{"description":"Missing tokenId"},"401":{"description":"Unauthorized"},"404":{"description":"Post not found"}},"security":[{"user-auth":[]}],"summary":"Toggle repost on a post","tags":["Reposts & Quotes"]}},"/api/reposts":{"get":{"description":"\n## Overview\nReturns a paginated list of posts that a user has reposted.\nEach item includes the full original post data plus repost metadata.\n\n## Privacy\n- If the target user's account is **private**, only accepted followers can see their reposts.\n- Blocked users' content is filtered out.\n\n## Authentication\nOptional. If authenticated (JWT Bearer), enriches with isLiked, isSaved, isReposted for the viewer.\n    ","operationId":"RepostController_getUserReposts","parameters":[{"name":"limit","required":false,"in":"query","description":"Items per page (default: 20, max: 100)","schema":{"type":"number"}},{"name":"page","required":false,"in":"query","description":"Page number (default: 1)","schema":{"type":"number"}},{"name":"address","required":true,"in":"query","description":"Address of the user whose reposts to fetch","schema":{"type":"string"}}],"responses":{"200":{"description":"User reposts retrieved","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"result":{"type":"array","items":{"type":"object","properties":{"repostId":{"type":"string","description":"Repost record ID"},"repostedAt":{"type":"string","format":"date-time","description":"When the repost was created"},"repostedBy":{"type":"string","description":"Address of the reposter"},"isRepost":{"type":"boolean","example":true},"tokenId":{"type":"number"},"name":{"type":"string"},"description":{"type":"string"},"postType":{"type":"string","enum":["video","short","feed-simple","feed-images","feed-audio","live"]},"isReposted":{"type":"boolean","description":"Whether the viewer has reposted this"}}}},"pagination":{"type":"object","properties":{"page":{"type":"number"},"limit":{"type":"number"},"totalCount":{"type":"number"},"totalPages":{"type":"number"},"hasMore":{"type":"boolean"}}}}}}}},"403":{"description":"Private account - must follow to see reposts"},"404":{"description":"User not found"}},"summary":"Get reposts for a user","tags":["Reposts & Quotes"]}},"/api/repost/count":{"get":{"description":"Returns the total number of reposts for a given token.","operationId":"RepostController_getRepostCount","parameters":[{"name":"tokenId","required":true,"in":"query","description":"Token ID","schema":{"type":"number"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"count":{"type":"number","example":42}}}}}}},"summary":"Get repost count for a post","tags":["Reposts & Quotes"]}},"/api/repost/users":{"get":{"description":"Returns a paginated list of users who reposted a given token.","operationId":"RepostController_getReposters","parameters":[{"name":"limit","required":false,"in":"query","schema":{"type":"number"}},{"name":"page","required":false,"in":"query","schema":{"type":"number"}},{"name":"tokenId","required":true,"in":"query","description":"Token ID","schema":{"type":"number"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"result":{"type":"array","items":{"type":"object","properties":{"user":{"type":"object","properties":{"address":{"type":"string"},"username":{"type":"string"},"displayName":{"type":"string"},"avatarImageUrl":{"type":"string"}}},"repostedAt":{"type":"string","format":"date-time"}}}},"pagination":{"type":"object"}}}}}}},"summary":"Get users who reposted a post","tags":["Reposts & Quotes"]}},"/api/quote_post":{"post":{"description":"\n## Overview\nCreates a new minted post that quotes/references an existing post.\nThe quote post is a **full token** — it gets its own tokenId, can be liked, commented on, tipped, etc.\n\n## How it works\n1. Upload your content (text, images, or video) along with the `quotedTokenId`\n2. A new token is minted (same 2-step process as `user_mint`) with a reference to the quoted post\n3. The quoted post's quote count is incremented\n4. The original creator receives a notification\n\n## Rules\n- **Can quote any post type** (video, image, text, livestream)\n- **Cannot quote with a livestream** — only text, image, or video quote posts\n- The quote post is completely independent (has its own engagement, comments, etc.)\n- **No monetisation** — quote posts cannot have PPV, lock content, bounty, or subscription gating\n- The quoted post embed is resolved when viewing the quote post\n\n## File Upload\nSame as `user_mint` — send media as multipart/form-data based on postType.\n    ","operationId":"RepostController_createQuotePost","parameters":[],"requestBody":{"required":true,"description":"Quote post upload data","content":{"multipart/form-data":{"schema":{"type":"object","required":["postType","quotedTokenId"],"properties":{"quotedTokenId":{"type":"number","description":"Token ID of the post being quoted","example":456},"name":{"type":"string","description":"Title (optional for feed posts)"},"description":{"type":"string","description":"Description / text content"},"streamInfo":{"type":"string","description":"JSON string with monetization settings (defaults to {})"},"chainId":{"type":"number","description":"Blockchain chain ID (defaults to BSC mainnet if omitted)"},"category":{"type":"string","description":"JSON array of category names"},"postType":{"type":"string","enum":["video","feed-simple","feed-images","feed-audio"],"description":"Type of quote post (cannot be live)","example":"feed-simple"},"plans":{"type":"string","description":"JSON string of subscription plan IDs (optional)"},"file":{"type":"string","format":"binary","description":"Media file(s)"}}}}}},"responses":{"200":{"description":"Quote post created — use mint signature for blockchain transaction","content":{"application/json":{"schema":{"type":"object","properties":{"r":{"type":"string","description":"Signature R component"},"s":{"type":"string","description":"Signature S component"},"v":{"type":"number","description":"Signature V component"},"createdTokenId":{"type":"string","description":"New token ID for the quote post"},"timestamp":{"type":"number"},"quotedTokenId":{"type":"number","description":"Token ID of the quoted post"},"isQuotePost":{"type":"boolean","example":true}}}}}},"400":{"description":"Invalid post type (live not allowed) or missing quotedTokenId"},"401":{"description":"Unauthorized"},"403":{"description":"User does not have posting permissions"},"404":{"description":"Quoted post not found"}},"security":[{"user-auth":[]}],"summary":"Create a quote post (minted token referencing another post)","tags":["Reposts & Quotes"]}},"/api/quotes/{tokenId}/revoke":{"post":{"description":"Only the author of the quoted post may call this. The quote stays up as the quoter's own words; its embed of your post renders as unavailable and your quote count drops by one. Calling it twice is a no-op.","operationId":"RepostController_revokeQuotePost","parameters":[],"responses":{"200":{"description":"Removed (or already removed)"},"403":{"description":"Caller is not the author of the quoted post"},"404":{"description":"No such quote post"}},"security":[{"user-auth":[]}],"summary":"Remove your post from somebody else's quote","tags":["Reposts & Quotes"]}},"/api/quotes":{"get":{"description":"\nReturns a paginated list of quote posts that reference a given token.\nEach quote post is a full token item with all the standard post data.\n    ","operationId":"RepostController_getQuotesForToken","parameters":[{"name":"limit","required":false,"in":"query","schema":{"type":"number"}},{"name":"page","required":false,"in":"query","schema":{"type":"number"}},{"name":"tokenId","required":true,"in":"query","description":"Token ID of the original post","schema":{"type":"number"}}],"responses":{"200":{"description":"Quote posts retrieved","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"result":{"type":"array","description":"Array of quote post tokens","items":{"type":"object","properties":{"tokenId":{"type":"number"},"name":{"type":"string"},"description":{"type":"string"},"postType":{"type":"string"},"isQuotePost":{"type":"boolean","example":true},"quotedTokenId":{"type":"number","description":"The token being quoted"}}}},"pagination":{"type":"object"}}}}}}},"summary":"Get quote posts for a token","tags":["Reposts & Quotes"]}},"/api/repost/proposal":{"post":{"description":"Reposts a governance proposal, or removes the repost if it is already reposted. Returns the new state and the proposal's repost count.","operationId":"ProposalRepostController_toggle","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["proposalId"],"properties":{"proposalId":{"type":"string","description":"Governance proposal id (uuid)"},"title":{"type":"string","description":"Proposal title, kept as a display hint"},"author":{"type":"string","description":"Proposal author wallet address"}}}}}},"responses":{"201":{"description":""}},"security":[{"user-auth":[]}],"summary":"Toggle repost on a governance proposal","tags":["Reposts & Quotes"]},"get":{"operationId":"ProposalRepostController_state","parameters":[{"name":"proposalId","required":true,"in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"summary":"Repost count for a governance proposal, and whether the viewer reposted it","tags":["Reposts & Quotes"]}},"/api/repost/proposal/users":{"get":{"operationId":"ProposalRepostController_reposters","parameters":[{"name":"limit","required":false,"in":"query","schema":{"type":"number"}},{"name":"page","required":false,"in":"query","schema":{"type":"number"}},{"name":"proposalId","required":true,"in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"summary":"Users who reposted a governance proposal","tags":["Reposts & Quotes"]}},"/api/reposts/proposals":{"get":{"operationId":"ProposalRepostController_userReposts","parameters":[{"name":"limit","required":false,"in":"query","schema":{"type":"number"}},{"name":"page","required":false,"in":"query","schema":{"type":"number"}},{"name":"address","required":true,"in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"summary":"Governance proposals a user reposted, newest first","tags":["Reposts & Quotes"]}},"/api/pin":{"post":{"description":"\n## Overview\nToggle a pin on a post (like a pinned tweet). If already pinned, removes the pin.\nPinned posts appear first on the user's profile timeline.\n\n## Rules\n- One pin per user per post (toggle on/off)\n\n## Response\nReturns the new pin state.\n    ","operationId":"PinController_togglePin","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tokenId"],"properties":{"tokenId":{"type":"number","description":"Token ID of the post to pin","example":123}}}}}},"responses":{"200":{"description":"Pin toggled","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"pinned":{"type":"boolean","description":"Whether the post is now pinned","example":true},"message":{"type":"string","example":"Post pinned"}}}}}},"400":{"description":"Missing tokenId"},"401":{"description":"Unauthorized"},"404":{"description":"Post not found"}},"security":[{"user-auth":[]}],"summary":"Toggle pin on a post","tags":["Pins"]}},"/api/pins":{"get":{"description":"\n## Overview\nReturns a paginated list of posts that a user has pinned to their profile.\nEach item includes the full original post data plus pin metadata.\n\n## Privacy\n- If the target user's account is **private**, only accepted followers can see their pins.\n- Blocked users' content is filtered out.\n\n## Authentication\nOptional. If authenticated (JWT Bearer), enriches with isLiked, isSaved, isReposted for the viewer.\n    ","operationId":"PinController_getUserPins","parameters":[{"name":"limit","required":false,"in":"query","description":"Items per page (default: 20, max: 100)","schema":{"type":"number"}},{"name":"page","required":false,"in":"query","description":"Page number (default: 1)","schema":{"type":"number"}},{"name":"address","required":true,"in":"query","description":"Address of the user whose pins to fetch","schema":{"type":"string"}}],"responses":{"200":{"description":"User pins retrieved","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"result":{"type":"array","items":{"type":"object","properties":{"pinId":{"type":"string","description":"Pin record ID"},"pinnedAt":{"type":"string","format":"date-time","description":"When the pin was created"},"pinnedBy":{"type":"string","description":"Address of the pinner"},"isPinned":{"type":"boolean","example":true},"tokenId":{"type":"number"},"name":{"type":"string"},"description":{"type":"string"},"postType":{"type":"string","enum":["video","short","feed-simple","feed-images","feed-audio","live"]},"isReposted":{"type":"boolean","description":"Whether the viewer has reposted this"}}}},"pagination":{"type":"object","properties":{"page":{"type":"number"},"limit":{"type":"number"},"totalCount":{"type":"number"},"totalPages":{"type":"number"},"hasMore":{"type":"boolean"}}}}}}}},"403":{"description":"Private account - must follow to see pins"},"404":{"description":"User not found"}},"summary":"Get pinned posts for a user","tags":["Pins"]}},"/api/pin/count":{"get":{"description":"Returns the total number of times a post has been pinned.","operationId":"PinController_getPinCount","parameters":[{"name":"tokenId","required":true,"in":"query","description":"Token ID","schema":{"type":"number"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"count":{"type":"number","example":5}}}}}}},"summary":"Get pin count for a post","tags":["Pins"]}},"/api/pin/users":{"get":{"description":"Returns a paginated list of users who pinned a given token.","operationId":"PinController_getPinners","parameters":[{"name":"limit","required":false,"in":"query","schema":{"type":"number"}},{"name":"page","required":false,"in":"query","schema":{"type":"number"}},{"name":"tokenId","required":true,"in":"query","description":"Token ID","schema":{"type":"number"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"result":{"type":"array","items":{"type":"object","properties":{"user":{"type":"object","properties":{"address":{"type":"string"},"username":{"type":"string"},"displayName":{"type":"string"},"avatarImageUrl":{"type":"string"}}},"pinnedAt":{"type":"string","format":"date-time"}}}},"pagination":{"type":"object"}}}}}}},"summary":"Get users who pinned a post","tags":["Pins"]}},"/api/post-stats/best-times":{"get":{"description":"Views on all of your posts over the last 30 days, members and visitors together, as 7 rows (Sunday first, UTC) of 24 hourly counts.","operationId":"PostStatsController_bestTimes","parameters":[],"responses":{"200":{"description":"{ days, total, grid: number[7][24] }"}},"security":[{"user-auth":[]}],"summary":"When your viewers watch","tags":["Post stats"]}},"/api/post-stats/{tokenId}":{"get":{"description":"\nCreator only (the post's minter or owner).\n\nEvery number is split into **signedIn** (DeHub members) and **signedOut**\n(visitors) where we can tell them apart:\n- `reach`: people, plays, repeat views, badge weight, agent views\n- `badges`: members who saw the post, grouped by the badge they held when they first saw it (highest first, `tier: -1` = no badge)\n- `hourly`: first-time viewers per hour for the first 48 hours, members and visitors\n- `watching`: average watch, % finished, still-watching curve (every 5%) for all, members, visitors\n- `engagement`: likes, comments, saves, reposts, quotes, reactions, shares, link clicks, profile taps\n- `followers`: follows within 7 days of watching, and members who already followed vs new to you\n- `money`: tips, top supporters, pay-per-view\n- `usual`: your averages over your last 20 posts, for \"x times your usual\"\n    ","operationId":"PostStatsController_postStats","parameters":[{"name":"tokenId","required":true,"in":"path","schema":{"example":123,"type":"string"}}],"responses":{"403":{"description":"Not your post"},"404":{"description":"Post not found"}},"security":[{"user-auth":[]}],"summary":"Stats for one of your posts","tags":["Post stats"]}},"/api/post-stats/watch-time":{"post":{"description":"Sent by the video and audio players every 15 seconds and on pause or leave. Works signed in or signed out. One row per sessionId; numbers only ever go up.","operationId":"PostStatsController_watchTime","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tokenId","sessionId","seconds","position"],"properties":{"tokenId":{"type":"number","example":123},"sessionId":{"type":"string","example":"k3j2h4g5f6d7"},"seconds":{"type":"number","description":"Seconds actually played this session","example":42},"position":{"type":"number","description":"Furthest point reached, in seconds","example":61},"duration":{"type":"number","description":"Length of the media, in seconds","example":190}}}}}},"responses":{"201":{"description":""}},"summary":"Report how long a play lasted","tags":["Post stats"]}},"/api/post-stats/event":{"post":{"description":"Works signed in or signed out. `type` is one of share, link, profile.","operationId":"PostStatsController_event","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tokenId","type"],"properties":{"tokenId":{"type":"number","example":123},"type":{"type":"string","enum":["share","link","profile"]}}}}}},"responses":{"201":{"description":""}},"summary":"Record a share, link click or profile tap on a post","tags":["Post stats"]}},"/api/translate":{"post":{"description":"Translate post descriptions, comment content, user bios, or chat messages into a target language. Results are cached in MongoDB with a 30-day TTL and auto-invalidated when the source content is edited.","operationId":"TranslationController_translate","parameters":[],"requestBody":{"required":true,"description":"Translation request payload","content":{"application/json":{"schema":{"type":"object","required":["contentType","contentId","targetLanguage"],"properties":{"contentType":{"type":"string","enum":["post","comment","bio","chat"],"description":"Type of content to translate","example":"post"},"contentId":{"type":"string","description":"ID of the content (tokenId for posts, _id for comments/chat, wallet address for bios)","example":"12345"},"targetLanguage":{"type":"string","description":"ISO 639-1 language code (2-3 letters)","example":"es"}}}}}},"responses":{"200":{"description":"Translation successful","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"result":{"type":"object","properties":{"translatedFields":{"type":"object","description":"Translated text fields","example":{"description":"Hola mundo"}},"sourceLanguage":{"type":"string","example":"en"},"targetLanguage":{"type":"string","example":"es"},"cached":{"type":"boolean","example":false}}}}}}}},"400":{"description":"Missing or invalid fields"},"401":{"description":"Unauthorized - invalid or missing token"},"404":{"description":"Content not found"}},"security":[{"user-auth":[]}],"summary":"Translate content on-demand","tags":["Translation"]}},"/api/bookmark-folders":{"post":{"description":"Create a new folder for organizing saved posts. Max 50 folders per user.","operationId":"BookmarkController_createFolder","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string","example":"Watch Later"},"description":{"type":"string","example":"Videos to watch later"},"isPublic":{"type":"boolean","example":false}}}}}},"responses":{"200":{"description":"Folder created"}},"security":[{"user-auth":[]}],"summary":"Create a bookmark folder","tags":["Bookmark Folders"]},"get":{"description":"Returns all bookmark folders for the authenticated user with item counts.","operationId":"BookmarkController_getFolders","parameters":[],"responses":{"200":{"description":"Folders retrieved"}},"security":[{"user-auth":[]}],"summary":"Get all bookmark folders","tags":["Bookmark Folders"]}},"/api/bookmark-folders/{folderId}":{"put":{"description":"Rename, reorder, change description, or make a folder a public playlist.","operationId":"BookmarkController_updateFolder","parameters":[{"name":"folderId","required":true,"in":"path","description":"Folder ID","schema":{}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"description":{"type":"string"},"order":{"type":"number"},"isPublic":{"type":"boolean","description":"Show this folder as a playlist on the profile"}}}}}},"responses":{"200":{"description":"Folder updated"}},"security":[{"user-auth":[]}],"summary":"Update a bookmark folder","tags":["Bookmark Folders"]},"delete":{"description":"Deletes the folder and removes all items from it. Posts remain in savedPosts.","operationId":"BookmarkController_deleteFolder","parameters":[{"name":"folderId","required":true,"in":"path","description":"Folder ID","schema":{}}],"responses":{"200":{"description":"Folder deleted"}},"security":[{"user-auth":[]}],"summary":"Delete a bookmark folder","tags":["Bookmark Folders"]}},"/api/bookmark-folders/{folderId}/items":{"post":{"description":"Add a single post to a bookmark folder. Idempotent — silently succeeds if already in folder.","operationId":"BookmarkController_addItem","parameters":[{"name":"folderId","required":true,"in":"path","description":"Folder ID","schema":{}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tokenId"],"properties":{"tokenId":{"type":"number","example":123}}}}}},"responses":{"200":{"description":"Item added"}},"security":[{"user-auth":[]}],"summary":"Add a post to a folder","tags":["Bookmark Folders"]},"get":{"description":"Returns paginated posts within a folder, enriched with post data.","operationId":"BookmarkController_getFolderItems","parameters":[{"name":"limit","required":false,"in":"query","schema":{"example":20,"type":"number"}},{"name":"page","required":false,"in":"query","schema":{"example":1,"type":"number"}},{"name":"folderId","required":true,"in":"path","description":"Folder ID","schema":{}}],"responses":{"200":{"description":"Folder items retrieved"}},"security":[{"user-auth":[]}],"summary":"Get posts in a folder","tags":["Bookmark Folders"]}},"/api/bookmark-folders/{folderId}/items/bulk":{"post":{"description":"Batch add up to 50 posts to a folder at once.","operationId":"BookmarkController_addItemsBulk","parameters":[{"name":"folderId","required":true,"in":"path","description":"Folder ID","schema":{}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tokenIds"],"properties":{"tokenIds":{"type":"array","items":{"type":"number"},"example":[123,456,789]}}}}}},"responses":{"200":{"description":"Items added"}},"security":[{"user-auth":[]}],"summary":"Add multiple posts to a folder","tags":["Bookmark Folders"]}},"/api/bookmark-folders/{folderId}/items/{tokenId}":{"delete":{"description":"Removes a single post from a folder. Does not unsave the post.","operationId":"BookmarkController_removeItem","parameters":[{"name":"tokenId","required":true,"in":"path","description":"Token ID of the post","schema":{}},{"name":"folderId","required":true,"in":"path","description":"Folder ID","schema":{}}],"responses":{"200":{"description":"Item removed"}},"security":[{"user-auth":[]}],"summary":"Remove a post from a folder","tags":["Bookmark Folders"]}},"/api/users/{address}/playlists":{"get":{"description":"Bookmark folders the owner made public, in their order. No auth.","operationId":"PublicPlaylistsController_list","parameters":[{"name":"address","required":true,"in":"path","description":"Wallet address of the profile","schema":{"type":"string"}}],"responses":{"200":{"description":"Public playlists"}},"summary":"List a user's public playlists","tags":["Bookmark Folders"]}},"/api/users/{address}/playlists/{folderId}":{"get":{"description":"404 unless the folder belongs to that address and is public. Newest save first, cursor-paginated. No auth.","operationId":"PublicPlaylistsController_items","parameters":[{"name":"address","required":true,"in":"path","description":"Wallet address of the profile","schema":{"type":"string"}},{"name":"folderId","required":true,"in":"path","description":"Playlist (folder) id","schema":{"type":"string"}},{"name":"limit","required":false,"in":"query","schema":{"example":20,"type":"number"}},{"name":"cursor","required":false,"in":"query","description":"nextCursor from the previous page","schema":{"type":"string"}}],"responses":{"200":{"description":"Playlist items"},"404":{"description":"No public playlist with that id under that address"}},"summary":"Posts in a public playlist","tags":["Bookmark Folders"]}},"/api/poll":{"post":{"description":"\nAttach a poll to your own post. Only the post creator can add a poll.\n\n### Request:\n```json\n{\n  \"tokenId\": 123,\n  \"question\": \"Which platform do you prefer?\",\n  \"options\": [\"iOS\", \"Android\", \"Web\"],\n  \"expiresAt\": \"2026-06-01T00:00:00Z\",\n  \"isMultipleChoice\": false\n}\n```\n\n### Options:\n- Min 2 options, max 10\n- `expiresAt` is optional — poll never expires if omitted\n- `isMultipleChoice` allows voting for multiple options\n    ","operationId":"PollController_createPoll","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tokenId","question","options"],"properties":{"tokenId":{"type":"number","example":123},"question":{"type":"string","example":"Which platform?"},"options":{"type":"array","items":{"type":"string"},"example":["iOS","Android"]},"expiresAt":{"type":"string","example":"2026-06-01T00:00:00Z"},"isMultipleChoice":{"type":"boolean","example":false}}}}}},"responses":{"200":{"description":"Poll created"},"400":{"description":"Validation error"},"403":{"description":"Not the post creator"}},"security":[{"user-auth":[]}],"summary":"Create a poll on a post","tags":["Polls"]}},"/api/polls":{"get":{"description":"\nLook up every poll on a feed page in one request.\n\nFeed cards draw a poll but the feed payload does not say which posts have one,\nso the per-post route turned a single page into 10-20 requests and spent the\nwhole rate-limit budget on posts that have no poll. Ask once instead.\n\nIds with no poll are absent from the result — a poll can only be attached when\na post is created, so that answer never changes.\n    ","operationId":"PollController_getPolls","parameters":[{"name":"address","required":false,"in":"query","description":"Viewer address, to include their vote","schema":{}},{"name":"tokenIds","required":true,"in":"query","description":"Comma-separated token ids, max 50","schema":{"example":"5477,5478,5479"}}],"responses":{"200":{"description":"Polls keyed by tokenId. Ids without a poll are omitted.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"result":{"type":"object","additionalProperties":{"type":"object"}}}}}}},"400":{"description":"tokenIds missing or over the cap"}},"summary":"Get polls for a page of posts","tags":["Polls"]}},"/api/poll/{tokenId}":{"get":{"description":"Returns the poll with vote counts per option. Includes userVote if the viewer has voted.","operationId":"PollController_getPoll","parameters":[{"name":"tokenId","required":true,"in":"path","description":"Token ID of the post","schema":{"type":"number"}}],"responses":{"200":{"description":"Poll retrieved","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"boolean","example":true},"result":{"type":"object","properties":{"_id":{"type":"string"},"tokenId":{"type":"number"},"question":{"type":"string"},"options":{"type":"array","items":{"type":"object","properties":{"index":{"type":"number"},"text":{"type":"string"},"voteCount":{"type":"number"}}}},"totalVotes":{"type":"number"},"isActive":{"type":"boolean"},"isExpired":{"type":"boolean"},"userVote":{"type":"object","nullable":true,"properties":{"optionIndexes":{"type":"array","items":{"type":"number"}},"votedAt":{"type":"string"}}}}}}}}}},"404":{"description":"Poll not found"}},"summary":"Get poll results for a post","tags":["Polls"]}},"/api/poll/{tokenId}/vote":{"post":{"description":"Cast your vote on a poll. Previous votes are replaced. For single-choice polls, send one option index.","operationId":"PollController_vote","parameters":[{"name":"tokenId","required":true,"in":"path","description":"Token ID of the post","schema":{"type":"number"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["optionIndexes"],"properties":{"optionIndexes":{"type":"array","items":{"type":"number"},"example":[0],"description":"Array of option indexes to vote for"}}}}}},"responses":{"200":{"description":"Vote recorded"},"400":{"description":"Validation error or poll expired"},"404":{"description":"Poll not found"}},"security":[{"user-auth":[]}],"summary":"Vote on a poll","tags":["Polls"]},"delete":{"operationId":"PollController_removeVote","parameters":[{"name":"tokenId","required":true,"in":"path","description":"Token ID of the post","schema":{"type":"number"}}],"responses":{"200":{"description":"Vote removed"}},"security":[{"user-auth":[]}],"summary":"Remove your vote from a poll","tags":["Polls"]}},"/api/poll/{tokenId}/close":{"post":{"description":"Closes the poll so no more votes can be cast. Only the poll creator can close it.","operationId":"PollController_closePoll","parameters":[{"name":"tokenId","required":true,"in":"path","description":"Token ID of the post","schema":{"type":"number"}}],"responses":{"200":{"description":"Poll closed"},"404":{"description":"Poll not found or not authorized"}},"security":[{"user-auth":[]}],"summary":"Close a poll","tags":["Polls"]}},"/api/og-image/{tokenId}":{"get":{"description":"Returns a 1200×630 PNG share card for the given token ID. Used by social media previews and in-app share-as-image.","operationId":"OgImageController_getOgImage","parameters":[{"name":"tokenId","required":true,"in":"path","description":"Post token ID","schema":{"type":"string"}},{"name":"width","required":false,"in":"query","description":"Override output width (default: 1200)","schema":{"type":"string"}},{"name":"height","required":false,"in":"query","description":"Override output height (default: 630)","schema":{"type":"string"}}],"responses":{"200":{"description":"PNG share image"},"404":{"description":"Post not found"}},"summary":"Generate Open Graph share image for a post","tags":["OG Image"]}},"/api/fraction/listing":{"post":{"description":"List your fractions for sale at a fixed DHB price per fraction.","operationId":"FractionController_createListing","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tokenId","quantity","pricePerFraction"],"properties":{"tokenId":{"type":"number","example":1234},"chainId":{"type":"number","example":8453},"quantity":{"type":"number","example":10},"pricePerFraction":{"type":"number","example":5.5}}}}}},"responses":{"200":{"description":"Listing created"}},"security":[{"user-auth":[]}],"summary":"Create a fraction listing","tags":["Fractions"]}},"/api/fraction/listings/{tokenId}":{"get":{"description":"Returns all active fraction listings sorted by lowest price.","operationId":"FractionController_getListings","parameters":[{"name":"tokenId","required":true,"in":"path","description":"Post token ID","schema":{}}],"responses":{"200":{"description":"Listings retrieved"}},"summary":"Get active listings for a token","tags":["Fractions"]}},"/api/fraction/listing/{listingId}":{"delete":{"description":"Cancel your active listing and mark any pending offers on it as cancelled.","operationId":"FractionController_cancelListing","parameters":[{"name":"listingId","required":true,"in":"path","description":"Listing ID","schema":{}}],"responses":{"200":{"description":"Listing cancelled"}},"security":[{"user-auth":[]}],"summary":"Cancel a fraction listing","tags":["Fractions"]}},"/api/fraction/offer":{"post":{"description":"Submit a buy offer for fractions at a custom price. Can target a specific seller or be open to all holders.","operationId":"FractionController_createOffer","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tokenId","quantity","pricePerFraction"],"properties":{"tokenId":{"type":"number","example":1234},"chainId":{"type":"number","example":8453},"quantity":{"type":"number","example":5},"pricePerFraction":{"type":"number","example":4},"targetSellerAddress":{"type":"string","example":"0xabc..."},"listingId":{"type":"string","example":"64f..."}}}}}},"responses":{"200":{"description":"Offer created"}},"security":[{"user-auth":[]}],"summary":"Make a fraction offer","tags":["Fractions"]}},"/api/fraction/offers/{tokenId}":{"get":{"description":"Returns all pending fraction offers sorted by highest price.","operationId":"FractionController_getOffers","parameters":[{"name":"tokenId","required":true,"in":"path","description":"Post token ID","schema":{}}],"responses":{"200":{"description":"Offers retrieved"}},"summary":"Get pending offers for a token","tags":["Fractions"]}},"/api/fraction/offer/{offerId}/accept":{"patch":{"description":"Accept an incoming offer. Sends push notification to the buyer.","operationId":"FractionController_acceptOffer","parameters":[{"name":"offerId","required":true,"in":"path","description":"Offer ID","schema":{}}],"responses":{"200":{"description":"Offer accepted"}},"security":[{"user-auth":[]}],"summary":"Accept a fraction offer","tags":["Fractions"]}},"/api/fraction/offer/{offerId}/reject":{"patch":{"description":"Reject an incoming offer. Sends push notification to the buyer.","operationId":"FractionController_rejectOffer","parameters":[{"name":"offerId","required":true,"in":"path","description":"Offer ID","schema":{}}],"responses":{"200":{"description":"Offer rejected"}},"security":[{"user-auth":[]}],"summary":"Reject a fraction offer","tags":["Fractions"]}},"/api/fraction/offer/{offerId}":{"delete":{"description":"Cancel a pending offer you made.","operationId":"FractionController_cancelOffer","parameters":[{"name":"offerId","required":true,"in":"path","description":"Offer ID","schema":{}}],"responses":{"200":{"description":"Offer cancelled"}},"security":[{"user-auth":[]}],"summary":"Cancel your fraction offer","tags":["Fractions"]}},"/api/fraction/trade":{"post":{"description":"Called after the buyer has sent DHB to the seller on-chain. The buyer is the signed-in caller; seller, token, chain and price come from the listing or the accepted offer. The receipt must have succeeded within the last hour and show DHB moving from the caller to the seller worth at least price × quantity. Then records the trade, updates the listing filled quantity, and notifies the seller. sellerAddress, buyerAddress, tokenId and chainId are optional and only checked against the record.","operationId":"FractionController_recordTrade","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["txHash","quantity"],"properties":{"listingId":{"type":"string","description":"Required unless offerId is given"},"offerId":{"type":"string","description":"An accepted offer; required unless listingId is given"},"quantity":{"type":"number"},"txHash":{"type":"string"},"tokenId":{"type":"number"},"chainId":{"type":"number","example":8453},"sellerAddress":{"type":"string"},"buyerAddress":{"type":"string"},"pricePerFraction":{"type":"number","description":"Ignored; the stored price is used"}}}}}},"responses":{"200":{"description":"Trade recorded"},"400":{"description":"Payment missing, reverted, too old, underpaid, or not from the caller to the seller"},"403":{"description":"buyerAddress is not the caller, or the offer is not the caller's"},"409":{"description":"Payment already used, not mined yet (retryable), or the listing no longer has the quantity"}},"security":[{"user-auth":[]}],"summary":"Record a completed fraction trade","tags":["Fractions"]}},"/api/fraction/trades/{tokenId}":{"get":{"description":"Returns recent completed trades sorted by newest first.","operationId":"FractionController_getTrades","parameters":[{"name":"limit","required":false,"in":"query","schema":{"example":50,"type":"number"}},{"name":"tokenId","required":true,"in":"path","description":"Post token ID","schema":{}}],"responses":{"200":{"description":"Trades retrieved"}},"summary":"Get trade history for a token","tags":["Fractions"]}},"/api/fraction/holdings":{"get":{"description":"Returns all tokens where the authenticated user holds fractions, with net quantity.","operationId":"FractionController_getHoldings","parameters":[],"responses":{"200":{"description":"Holdings retrieved"}},"security":[{"user-auth":[]}],"summary":"Get my fraction holdings","tags":["Fractions"]}},"/api/market/config":{"get":{"operationId":"MarketController_config","parameters":[],"responses":{"200":{"description":""}},"summary":"Market chains, payment tokens, fees and the Seaport address","tags":["NFT market"]}},"/api/market/terms":{"get":{"operationId":"MarketController_terms","parameters":[],"responses":{"200":{"description":""}},"summary":"DeHub fee and creator royalty an order must include (chainId, contract, tokenId, price, currency)","tags":["NFT market"]}},"/api/market/orders":{"get":{"operationId":"MarketController_list","parameters":[],"responses":{"200":{"description":""}},"summary":"Browse listings or offers (kind, status, chainId, contract, tokenId, maker, currency, sort, limit, offset)","tags":["NFT market"]},"post":{"operationId":"MarketController_create","parameters":[],"responses":{"201":{"description":""}},"security":[{"user-auth":[]}],"summary":"Post a signed Seaport listing or offer","tags":["NFT market"]}},"/api/market/collections":{"get":{"operationId":"MarketController_collections","parameters":[],"responses":{"200":{"description":""}},"summary":"Collections with active listings, busiest first, with floor price (chainId, dehub, limit)","tags":["NFT market"]}},"/api/market/showcase":{"get":{"operationId":"MarketController_showcase","parameters":[],"responses":{"200":{"description":""}},"summary":"DeHub posts minted on chain, newest first, listed or not (chainId, limit)","tags":["NFT market"]}},"/api/market/orders/{orderHash}":{"get":{"operationId":"MarketController_get","parameters":[],"responses":{"200":{"description":""}},"summary":"One order, with what a buyer needs to fill it","tags":["NFT market"]}},"/api/market/orders/{orderHash}/sync":{"post":{"operationId":"MarketController_sync","parameters":[],"responses":{"201":{"description":""}},"summary":"Re-read an order from chain after a buy or cancel (optional txHash)","tags":["NFT market"]}},"/api/market/wallet/{address}":{"get":{"operationId":"MarketController_wallet","parameters":[],"responses":{"200":{"description":""}},"summary":"NFTs a wallet holds on one chain (chainId, pageKey)","tags":["NFT market"]}},"/api/market/nft/{chainId}/{contract}/{tokenId}":{"get":{"operationId":"MarketController_nft","parameters":[],"responses":{"200":{"description":""}},"summary":"One NFT with its live listings, offers and recent sales","tags":["NFT market"]}},"/api/market/alerts":{"get":{"operationId":"MarketController_alerts","parameters":[],"responses":{"200":{"description":""}},"security":[{"user-auth":[]}],"summary":"Your NFT price alerts","tags":["NFT market"]},"post":{"operationId":"MarketController_addAlert","parameters":[],"responses":{"201":{"description":""}},"security":[{"user-auth":[]}],"summary":"Alert me when this collection or NFT is listed (chainId, contract, tokenId?, maxPrice?, currency?)","tags":["NFT market"]}},"/api/market/alerts/{id}":{"delete":{"operationId":"MarketController_removeAlert","parameters":[],"responses":{"200":{"description":""}},"security":[{"user-auth":[]}],"summary":"Remove one of your price alerts","tags":["NFT market"]}},"/api/moderation/appeal":{"post":{"description":"\nAsk a human to look again at a removal, a warning or a restriction.\n\nUntil now the appeals process was one sentence in the notification asking the\ncreator to email dev@dehub.io: no record, no reference number, and nothing\ntying the mail back to the decision. An appeal now becomes a support ticket\nwith the moderation context attached, so it lands in the queue a human already\nworks and the reviewer can see what was done and what the creator was told\nwithout going and looking it up.\n\n### What can be appealed\nThe `notificationId` of a moderation notification addressed to you —\n`video_removal` or `account_warning` — filed within 90 days of the decision.\nThe notification is the proof a decision was made; without that check this\nendpoint would be an open ticket generator.\n\n### One appeal per decision\nA second appeal for the same post is handed the first one's reference\n(`duplicateOf`) rather than adding a second row to the queue.\n    ","operationId":"ModerationController_appeal","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["notificationId","reason"],"properties":{"notificationId":{"type":"string","description":"The moderation notification being appealed","example":"66c1f0a2e4b0a1c2d3e4f5a6"},"reason":{"type":"string","description":"Why the creator believes the decision was wrong (20–4000 characters)","example":"The clip is a news report about the incident, not an endorsement of it."}}}}}},"responses":{"201":{"description":"Appeal filed","content":{"application/json":{"schema":{"type":"object","properties":{"result":{"type":"boolean","example":true},"data":{"type":"object","properties":{"ref":{"type":"string","example":"DH-K3M7QP"},"status":{"type":"string","example":"open"},"duplicateOf":{"type":"string","nullable":true,"example":null}}}}}}}},"400":{"description":"Not a moderation notification, outside the window, or too thin to act on"},"401":{"description":"Unauthorized"},"404":{"description":"No such moderation decision"},"429":{"description":"Too many tickets filed recently"}},"security":[{"user-auth":[]}],"summary":"Appeal a moderation decision","tags":["Moderation"]}},"/api/moderation/appeals":{"get":{"description":"\nThe caller's own appeals, newest first, with the reviewer's answer where there\nis one.\n\n`resolution` is what a reviewer wrote for the appellant to read. The internal\nnote on the ticket is deliberately not returned.\n    ","operationId":"ModerationController_listAppeals","parameters":[],"responses":{"200":{"description":"Appeals listed"},"401":{"description":"Unauthorized"}},"security":[{"user-auth":[]}],"summary":"Your appeals and what came of them","tags":["Moderation"]}},"/api/kids-mode":{"get":{"description":"One boolean. The PIN and its salt are `select: false` on the model and are never returned by any route.","operationId":"KidsModeController_status","parameters":[],"responses":{"200":{"description":"{ enabled: boolean }"}},"security":[{"user-auth":[]}],"summary":"Whether Kids Mode is on for this account","tags":["Kids Mode"]}},"/api/kids-mode/enable":{"post":{"description":"\nBody: `{ \"pin\": \"1234\" }` — 4 to 8 digits.\n\nTurning it on needs nothing but the account: a parent handing over a tablet\nshould not have to authenticate to make it safer. Turning it off needs the PIN.\n\nRefused when Kids Mode is already on. Changing the PIN means turning it off\nwith the current one first — otherwise re-arming would be a way to replace a\nPIN without knowing it, which is the one thing it exists to prevent.\n    ","operationId":"KidsModeController_enable","parameters":[],"responses":{"200":{"description":"Kids Mode is on"},"400":{"description":"PIN is not 4–8 digits, or Kids Mode is already on"}},"security":[{"user-auth":[]}],"summary":"Turn Kids Mode on and set the PIN","tags":["Kids Mode"]}},"/api/kids-mode/disable":{"post":{"description":"\nBody: `{ \"pin\": \"1234\" }`.\n\nFive wrong PINs lock the pad for fifteen minutes. The counter lives on the\naccount, not in Redis, so it survives a restart — a lockout that clears itself\nwhen a process recycles is not a lockout.\n    ","operationId":"KidsModeController_disable","parameters":[],"responses":{"200":{"description":"Kids Mode is off"},"403":{"description":"Wrong PIN, or the pad is locked"}},"security":[{"user-auth":[]}],"summary":"Turn Kids Mode off with the PIN","tags":["Kids Mode"]}},"/api/kids-mode/reset":{"post":{"description":"\nNo PIN. The caller must arrive on the **raw signature** path rather than with a\nBearer token, which means signing with the wallet or passkey at the moment of\nthe request.\n\nThat asymmetry is the recovery story: a session token is enough to browse and\nis not enough to sign, so a child holding an unlocked device cannot use this,\nand a parent who has their wallet always can. There is no email step, no\nsecurity question and nothing to talk a support agent out of.\n    ","operationId":"KidsModeController_reset","parameters":[],"responses":{"200":{"description":"Kids Mode is off"},"403":{"description":"Arrived with a session token rather than a fresh signature"}},"security":[{"user-auth":[]}],"summary":"Turn Kids Mode off with a fresh wallet signature — the forgotten PIN","tags":["Kids Mode"]}},"/api/account/children":{"get":{"description":"\nConfirmed children and invitations still waiting, plus how many more may be\nadded.\n\nEach child's **full address** is returned, because it is what the parent sends\nfunds to. Emails are masked — the parent already knows them, and this response\nis the one place a child's address and their email would otherwise sit\ntogether.\n    ","operationId":"ChildAccountController_list","parameters":[],"responses":{"200":{"description":"Children and pending invitations"}},"security":[{"user-auth":[]}],"summary":"The kids accounts this account set up","tags":["Kids Mode"]},"post":{"description":"\nBody: `{ displayName, email, childAddress, walletPayload }`.\n\nThe **caller's browser mints the wallet**, derives `childAddress` from it and\nwraps the seed under a passcode the parent chooses — only the ciphertext is\nsent here, and only the child's own browser ever unwraps it. That passcode\ncannot be reset by anybody, including us, so the parent has to be told to keep\nit before this is called.\n\nNothing exists on the child's side until the emailed code is confirmed: no\naccount, no address in use, no wallet row. An unconfirmed invitation expires\nafter a day and the minted key is discarded with it.\n\nRefused when the caller is itself a kids account, when the email is already\nused on DeHub, or past eight children.\n    ","operationId":"ChildAccountController_invite","parameters":[],"responses":{"200":{"description":"Invitation sent"},"403":{"description":"A kids account cannot add a kids account"},"409":{"description":"Email already used, or the limit is reached"}},"security":[{"user-auth":[]}],"summary":"Set up a kids account and email the confirmation","tags":["Kids Mode"]}},"/api/account/children/confirm":{"post":{"description":"\nBody: `{ email, code }`. No authentication — see the note on this controller.\n\nOn success the account exists, is in Kids Mode with the parent named as its\nmanager, and its email is a working DeHub login. The child signs in the\nordinary way from here on; their first session collects the wallet through\n`/account/children/wallet-claim`.\n    ","operationId":"ChildAccountController_confirm","parameters":[],"responses":{"200":{"description":"The kids account now exists"},"400":{"description":"Wrong or expired code"},"409":{"description":"That email is already used on DeHub"}},"summary":"Confirm a kids account with the code from its email","tags":["Kids Mode"]}},"/api/account/children/wallet-claim":{"post":{"description":"\nCalled by the child's own client once it has a Supabase session, so it can\nwrite its `user_wallets` row itself — the same save every other wallet uses.\n\nOne-shot: the claim is cleared as it is handed over, so the encrypted seed ends\nup in one place rather than two. Every later call answers\n`{ claimed: false }`, which is not an error — it means the row already exists.\n    ","operationId":"ChildAccountController_claimWallet","parameters":[],"responses":{"200":{"description":"{ claimed, walletPayload }"},"403":{"description":"Not a kids account"}},"security":[{"user-auth":[]}],"summary":"Collect the wallet a parent minted for this kids account","tags":["Kids Mode"]}},"/api/account/children/{childAddress}/kids-mode":{"post":{"description":"\nBody: `{ \"enabled\": false }`. The **only** way Kids Mode comes off a child\naccount: the child's own `/kids-mode/disable` refuses whatever PIN it is\ngiven, because the check reads `kidsMode.managedBy` and not the PIN.\n\nTurning it off does not unmake the kids account — posting and messaging stay\nrefused, because `parentAddress` is still there. This is \"let them see the\nordinary feed\", not \"they are an adult now\".\n    ","operationId":"ChildAccountController_setKidsMode","parameters":[{"name":"childAddress","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"{ address, enabled }"},"403":{"description":"Not your kids account"}},"security":[{"user-auth":[]}],"summary":"Turn Kids Mode on or off for one of your kids accounts","tags":["Kids Mode"]}},"/api/superpowers/team-up":{"get":{"operationId":"SuperpowerController_getMyTeam","parameters":[],"responses":{"200":{"description":""}},"security":[{"user-auth":[]}],"tags":["SuperPowers"]},"post":{"operationId":"SuperpowerController_createTeam","parameters":[],"responses":{"201":{"description":""}},"security":[{"user-auth":[]}],"tags":["SuperPowers"]},"patch":{"operationId":"SuperpowerController_updateTeam","parameters":[],"responses":{"200":{"description":""}},"security":[{"user-auth":[]}],"tags":["SuperPowers"]},"delete":{"operationId":"SuperpowerController_leaveTeam","parameters":[],"responses":{"200":{"description":""}},"security":[{"user-auth":[]}],"tags":["SuperPowers"]}},"/api/superpowers/team-up/teams":{"get":{"operationId":"SuperpowerController_listTeams","parameters":[],"responses":{"200":{"description":""}},"tags":["SuperPowers"]}},"/api/superpowers/team-up/{id}/join":{"post":{"operationId":"SuperpowerController_joinTeam","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":""}},"security":[{"user-auth":[]}],"tags":["SuperPowers"]},"delete":{"operationId":"SuperpowerController_cancelJoinRequest","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"security":[{"user-auth":[]}],"tags":["SuperPowers"]}},"/api/superpowers/team-up/{id}/requests/{requester}/approve":{"post":{"operationId":"SuperpowerController_approveJoinRequest","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}},{"name":"requester","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":""}},"security":[{"user-auth":[]}],"tags":["SuperPowers"]}},"/api/superpowers/team-up/{id}/requests/{requester}/deny":{"post":{"operationId":"SuperpowerController_denyJoinRequest","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}},{"name":"requester","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"201":{"description":""}},"security":[{"user-auth":[]}],"tags":["SuperPowers"]}},"/api/superpowers/team-up/{id}/members/{memberAddress}":{"delete":{"operationId":"SuperpowerController_removeTeamMember","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}},{"name":"memberAddress","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":""}},"security":[{"user-auth":[]}],"tags":["SuperPowers"]}},"/api/superpowers":{"get":{"description":"\nEverything the SuperPowers page and the boost sheet need: the badge tier this\naccount spends at, how many boosts the cycle grants and how many are left, how\nlong each one runs, and every booking made this cycle.\n\nAllowances refill on a single platform-wide clock — `cycleEndsAt` is when the\nnext one lands, and it is the same moment for everybody.\n\n`powers` lists all twelve whether or not this account has them, so the page\ncan show the ladder above as well as below. `unlocked` is whether the tier\nreaches it; `available` is whether it is built yet.\n    ","operationId":"SuperpowerController_getStatus","parameters":[],"responses":{"200":{"description":"Allowance returned"},"401":{"description":"Unauthorized"}},"security":[{"user-auth":[]}],"summary":"Your tier, your allowance, and what you have spent it on","tags":["SuperPowers"]}},"/api/superpowers/tiers":{"get":{"description":"Every badge tier, the boosts it grants per cycle and how long each runs, plus the twelve powers and the tier each unlocks at. Public — it is what the docs page renders.","operationId":"SuperpowerController_getTiers","parameters":[],"responses":{"200":{"description":"Ladder returned"}},"summary":"The published ladder","tags":["SuperPowers"]}},"/api/superpowers/slot":{"get":{"description":"\nOne post, or `null` when nothing is boosted right now. Called on every home\nfeed load.\n\nWhen several boosts are running the viewer is dealt one weighted by the\nbooster's badge tier, so a Megalodon's boost is shown fourteen times as often as\na Crab's over the same window. Each call is an independent draw — cache it for\na few minutes and a viewer sees one boost per refresh, which is the rotation.\n\nEvery draw is re-checked against the post as it is *now*, not as it was when\nthe boost was booked: a post deleted, hidden, blocked or re-rated `mature`\nmid-window stops being dealt immediately. A boosted post is held to exactly the\nstandard the ordinary home feed holds an organic one to, including the viewer's\nown mature-content setting and block list.\n\nAuthentication is optional, and is used for three things: to avoid showing\nsomeone their own boost, to apply their block list, and to apply their\nmature-content setting. A signed-out viewer gets the safe feed.\n\nThe response carries a tokenId rather than a post: fetch it the same way any\nother single post is fetched.\n    ","operationId":"SuperpowerController_getSlot","parameters":[],"responses":{"200":{"description":"A boosted post, or null"}},"summary":"The boosted post to show this viewer","tags":["SuperPowers"]}},"/api/superpowers/slots":{"get":{"description":"\nAll live boosts this viewer may see, ordered by when they started. The home feed\nshows the first at the top and each next one three posts further down; when the\noldest ends the rest move up. Held to the same content, block and audience\nchecks as `/superpowers/slot`. The viewer's own boosts are included.\n    ","operationId":"SuperpowerController_getSlots","parameters":[],"responses":{"200":{"description":"Boosted posts, oldest first"}},"summary":"Every boosted post to show this viewer, oldest first","tags":["SuperPowers"]}},"/api/superpowers/boost":{"post":{"description":"\nPuts the post in the home feed's boost slot for the number of minutes your tier\ngrants. One boost comes off the cycle's allowance.\n\n`power` picks which one is spent. **Boost** is for a post under a week old;\n**Second Wind** is for anything older and unlocks at Ghost Lobster. Sending the wrong\none for the post's age is refused rather than silently corrected, because the\ntwo cost the same boost and mean different things.\n\n`startAt` schedules it up to a week ahead. Omit it to start now.\n\nCancelling before the window opens gives the boost back; once it has been in\nthe slot it has been seen, and it does not.\n    ","operationId":"SuperpowerController_book","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tokenId"],"properties":{"tokenId":{"type":"number","example":2008},"power":{"type":"string","enum":["boost","second_wind","comment_anchor","timeline_bomber","signal_flare","flak_jacket","precision_strike","harpoon","trend_jacker","crew_boost","front_row","deep_current"],"example":"boost"},"targetAccount":{"type":"string","description":"precision_strike only — the account whose followers to reach. Must exist, must not be private, and must not have blocked you or been blocked by you."},"targetTiers":{"type":"array","items":{"type":"string"},"description":"harpoon only — badge tier NAMES to aim at, e.g. [\"Blue Whale\",\"Megalodon\"]."},"commentId":{"type":"string","description":"comment_anchor only — the id of YOUR comment to hold at the top. The thread must be somebody else’s post; on your own you already have a pin, free and permanent."},"category":{"type":"string","description":"trend_jacker only — a category that already exists and that you have at least one published post in. Case-insensitive."},"stageId":{"type":"string","description":"front_row only — the uuid of a Stage you host, live or scheduled. Verified against dehubweb’s Supabase project; a stage that cannot be read is refused rather than sold."},"startAt":{"type":"string","format":"date-time","description":"ISO time to start. Defaults to now."}}}}}},"responses":{"200":{"description":"Booked"},"400":{"description":"Allowance spent, wrong power for the post, or already boosted"},"401":{"description":"Unauthorized"},"403":{"description":"No badge, tier too low, or not your post"}},"security":[{"user-auth":[]}],"summary":"Spend a boost on one of your posts","tags":["SuperPowers"]}},"/api/superpowers/trending-topic":{"get":{"description":"\nPublic. Put this category at the top of the trending list and label it as paid;\neverything else keeps its order.\n\nA third read alongside `/superpowers/slot` and `/superpowers/front-row`, for\nthe third surface. The trending list is not in this database at all — it is\ncomputed in the browser from `category_post_log` — so all this answers is\nwhich category was paid for, and the client splices it in.\n\nWeighted by tier when several are running, exactly as the feed slot is.\n","operationId":"SuperpowerController_trendingTopic","parameters":[],"responses":{"200":{"description":"The category, or null when nothing is running"}},"summary":"The category holding the trending slot right now, or null","tags":["SuperPowers"]}},"/api/superpowers/front-row":{"get":{"description":"\nPublic. Sort this stage first in the rail; everything else keeps its order.\n\nDeliberately a separate read from `/superpowers/slot`. That one deals a post\nand this deals a stage id from dehubweb's Supabase project, and folding them\ntogether would put a branch for a surface it never serves into the hottest\nread in the product.\n\nWeighted by tier when several are running, exactly as the feed slot is —\notherwise two Blue Whales booking the same hour would be settled by whoever\ngot there first, which makes the power a race rather than a purchase.\n","operationId":"SuperpowerController_frontRow","parameters":[],"responses":{"200":{"description":"The stage, or null when nothing is running"}},"summary":"The stage holding the front row right now, or null","tags":["SuperPowers"]}},"/api/superpowers/boost/{id}":{"delete":{"description":"The boost comes back onto your allowance only if the window has not opened yet. `refunded` says which happened.","operationId":"SuperpowerController_cancel","parameters":[{"name":"id","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"Cancelled"},"401":{"description":"Unauthorized"},"404":{"description":"No such boost"}},"security":[{"user-auth":[]}],"summary":"Cancel a boost","tags":["SuperPowers"]}},"/api/username_market/config":{"get":{"description":"\nEverything a client needs to render prices and build a payment, so no client\ncarries its own DHB address table.\n\n`chains` is the set a handle may be paid for on, each with the DHB contract\nfor that chain. `dhbUsdPeg` is the current USD value of one token. Sellers\nset a fixed `priceUsd`; the required token amount follows the current rate.\n    ","operationId":"UsernameMarketController_getConfig","parameters":[],"responses":{"200":{"description":"Config returned"}},"summary":"Price limits and DHB contracts","tags":["Username marketplace"]}},"/api/username_market/listings":{"get":{"description":"\nThe shop window. Public — no authentication needed to look.\n\nEvery listing returned has been re-checked against its seller's live account,\nso a handle whose owner has since renamed themselves never appears; those rows\nare retired as they are found.\n\nWhen `search` looks like a single handle rather than a filter, the response\nalso carries `exact`, saying what that name actually is:\n\n- `available` — nobody holds it. Take it free from Settings; do not buy it.\n- `listed` — it is for sale, and it is in `listings`.\n- `taken` — somebody holds it and has not listed it.\n- `reserved` — a site route or vanity name. Nobody can hold it.\n    ","operationId":"UsernameMarketController_browse","parameters":[{"name":"limit","required":false,"in":"query","description":"Up to 60. Defaults to 24.","schema":{"type":"number"}},{"name":"page","required":false,"in":"query","schema":{"type":"number"}},{"name":"maxPriceUsd","required":false,"in":"query","schema":{"type":"number"}},{"name":"minPriceUsd","required":false,"in":"query","schema":{"type":"number"}},{"name":"sort","required":false,"in":"query","schema":{"enum":["newest","price_asc","price_desc","shortest"],"type":"string"}},{"name":"search","required":false,"in":"query","description":"Substring of the handle. A leading @ is ignored.","schema":{}}],"responses":{"200":{"description":"Listings returned"}},"summary":"Browse usernames for sale","tags":["Username marketplace"]},"post":{"description":"\nPuts a name this account owns up for sale. `username` is optional and defaults\nto the handle you are **currently wearing**; naming one of your held names\ninstead is the resale path.\n\nWhich one it is decides whether a replacement is needed:\n\n- **The handle you are wearing.** `replacementUsername` is required — selling\n  it moves you out of it, and there is no valid moment where an account has no\n  handle. It is validated here against the same rules `/update_profile`\n  enforces, because being told your new name is invalid while somebody is trying\n  to pay you is not a recoverable position. If it has been taken by the time the\n  sale completes, numbered variants are tried and then a generated handle; you\n  are told which you got. A name already in your own vault is a valid choice.\n- **A held name.** No replacement, and the sale never touches your profile. This\n  is the ordinary case for anything you bought and are not using.\n\nOne live listing per name. Calling this again for the same name replaces it,\nwhich is also how you reprice.\n    ","operationId":"UsernameMarketController_create","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["priceUsd"],"properties":{"username":{"type":"string","example":"satoshi","description":"A name you own. Defaults to the one you are wearing."},"priceUsd":{"type":"number","example":250,"description":"Fixed USD asking price, with up to two decimal places."},"replacementUsername":{"type":"string","example":"satoshi_2","description":"Required only when selling the handle you are wearing."},"description":{"type":"string","example":"Original 2021 handle. Clean history."}}}}}},"responses":{"200":{"description":"Listing created or replaced"},"400":{"description":"No username set, bad price, or the replacement is unavailable"},"401":{"description":"Unauthorized"},"403":{"description":"You do not own that username"}},"security":[{"user-auth":[]}],"summary":"List a username I own for sale","tags":["Username marketplace"]}},"/api/username_market/mine":{"get":{"description":"\nEverything this account has listed, sold and bought.\n\nEach listing carries `live`, which is false once the seller has renamed away\nfrom the handle they listed — the listing is dead at that point and the seller\nis the only person who can see why.\n    ","operationId":"UsernameMarketController_mine","parameters":[],"responses":{"200":{"description":"History returned"},"401":{"description":"Unauthorized"}},"security":[{"user-auth":[]}],"summary":"My listings, sales and purchases","tags":["Username marketplace"]}},"/api/username_market/holdings":{"get":{"description":"\nThe vault: the handle this account is wearing, plus every name it has bought or\nkept, active one first.\n\nAn account owns a name beyond the one it wears only because it paid for one —\na completed purchase records the name bought *and* keeps the buyer's previous\nhandle, so buying does not cost you what you built something under. A free\nrename releases the name you leave, which is the whole of the anti-hoard rule:\nnobody can walk a profile through a hundred handles and keep them.\n\n`acquiredVia` says which is which:\n\n- `purchase` — bought on the marketplace.\n- `retained` — the handle you were wearing when you bought another one.\n- `original` — the free name you have never paid for. Rename away and it goes\n  back in the pool. There is at most one, and it is always the active handle.\n\nEvery entry can be listed for sale, and `listing` is filled when it already is.\n    ","operationId":"UsernameMarketController_holdings","parameters":[],"responses":{"200":{"description":"Holdings returned"},"401":{"description":"Unauthorized"}},"security":[{"user-auth":[]}],"summary":"Every username I own","tags":["Username marketplace"]}},"/api/username_market/holdings/activate":{"post":{"description":"\nSwitches the profile onto a name from the vault. Free, and reversible by calling\nit again with the other name — the vault is the same size afterwards, so this is\nnot a way to accumulate anything.\n\nThe name you switch *away from* is kept if you own it and released if it was the\nfree handle you signed up with. A name currently listed for sale cannot be worn;\nwithdraw the listing first.\n    ","operationId":"UsernameMarketController_activateHolding","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["username"],"properties":{"username":{"type":"string","example":"satoshi"}}}}}},"responses":{"200":{"description":"Now wearing that username"},"403":{"description":"You do not own that username"},"409":{"description":"That username is listed for sale"}},"security":[{"user-auth":[]}],"summary":"Wear one of the usernames I own","tags":["Username marketplace"]}},"/api/username_market/holdings/{username}":{"delete":{"description":"\nReleases a name you own but are not wearing — it is free for anyone the moment\nthis returns, and there is no undo. Any live listing for it is withdrawn.\n\nYou cannot release the name you are wearing; an account with no handle is not a\nstate that exists. Switch to another one first.\n    ","operationId":"UsernameMarketController_releaseHolding","parameters":[{"name":"username","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"Released"},"403":{"description":"You do not own that username"},"409":{"description":"That is the username you are using"}},"security":[{"user-auth":[]}],"summary":"Give a held username back","tags":["Username marketplace"]}},"/api/username_market/listings/{listingId}":{"patch":{"description":"Partial update. Send only what changed.","operationId":"UsernameMarketController_update","parameters":[{"name":"listingId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"priceUsd":{"type":"number","example":180},"replacementUsername":{"type":"string","example":"satoshi_2"},"description":{"type":"string"}}}}}},"responses":{"200":{"description":"Listing updated"},"403":{"description":"Not your listing"}},"security":[{"user-auth":[]}],"summary":"Reprice or re-describe my listing","tags":["Username marketplace"]},"delete":{"description":"Takes the handle off the market. The row is kept as history.","operationId":"UsernameMarketController_cancel","parameters":[{"name":"listingId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"Listing withdrawn"},"403":{"description":"Not your listing"}},"security":[{"user-auth":[]}],"summary":"Withdraw my listing","tags":["Username marketplace"]}},"/api/username_market/quote":{"post":{"description":"\nPrices a listing and hands back the seller's address plus the DHB contract per\npayable chain. The quote is saved for five minutes; the handle is not reserved.\n\nThe buyer pays the **seller directly**, wallet to wallet — DeHub takes no cut\nand is not a party to the transfer. Send exactly `priceDhb` and then call\n`/username_market/claim` with the hash and `quoteId`.\n    ","operationId":"UsernameMarketController_quote","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["listingId"],"properties":{"listingId":{"type":"string","example":"66b1f0c2e4b0a91234567890"}}}}}},"responses":{"200":{"description":"Quote returned"},"404":{"description":"Listing gone"},"409":{"description":"The seller no longer holds that handle"}},"security":[{"user-auth":[]}],"summary":"What a handle costs and where to send the DHB","tags":["Username marketplace"]}},"/api/username_market/claim":{"post":{"description":"\nCall straight after the DHB transfer lands. The backend reads the receipt,\nrequires a DHB `Transfer` from you to the seller covering the asking price,\nand only then moves the handle: the seller goes to their chosen replacement and\nyou take the name.\n\nThe payer is matched on the ERC-20 event's `from`, not the transaction's, so\na sponsored transaction from a DeHub smart wallet settles exactly like one sent\nfrom an external wallet.\n\nSafe to repeat. The hash is claimed before the swap is attempted, so a retry\nafter a lost response reports the first outcome rather than starting a second\nsale. `pending: true` means the receipt is not visible yet — retry in a few\nseconds. A **409** means the payment is recorded but the handle could not move;\nthe response says why and the sale row holds the evidence for a refund.\n    ","operationId":"UsernameMarketController_claim","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["listingId","txHash","chainId"],"properties":{"listingId":{"type":"string","example":"66b1f0c2e4b0a91234567890"},"quoteId":{"type":"string","description":"The saved checkout quote."},"txHash":{"type":"string","example":"0xabc…"},"chainId":{"type":"number","example":8453,"description":"Base (8453) or BSC (56)."}}}}}},"responses":{"200":{"description":"Handle transferred, or still pending"},"400":{"description":"Bad hash, wrong chain, reverted, or underpaid"},"409":{"description":"Paid, but the handle could not be transferred"}},"security":[{"user-auth":[]}],"summary":"Take the handle with a confirmed DHB transfer","tags":["Username marketplace"]}},"/api/username_market/offers":{"get":{"description":"\n`incoming` is what people have bid for the handle you are wearing;\n`outgoing` is what you have bid for other people's.\n\n`priceUsd` is what the offer is, fixed for its lifetime. `priceDhb` is what\nthat converts to right now — what it would actually cost to settle — and\n`offeredDhb` is what it was worth when it was made.\n\nAn `accepted` row carries `listingId`: the reserved listing to quote and\nclaim against, exactly as if it were an ordinary purchase. It is nobody else's\nto buy.\n    ","operationId":"UsernameMarketController_myOffers","parameters":[],"responses":{"200":{"description":"Offers returned"},"401":{"description":"Unauthorized"}},"security":[{"user-auth":[]}],"summary":"Offers for my handle, and offers I have made","tags":["Username marketplace"]},"post":{"description":"\nThe other direction of the market: a price for a handle whose owner has not\nlisted it and may never have thought about selling.\n\n**Nothing is taken and nothing is held.** An offer is a stated intention — the\nDHB does not move until the owner accepts and you then pay. Your balance is\nnot checked here, so make sure you can cover it when they say yes.\n\nOffering again for the same handle raises your existing offer rather than\nopening a second one. A handle nobody holds is refused with a 404: take it for\nfree from Settings instead.\n    ","operationId":"UsernameMarketController_createOffer","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["username","priceUsd"],"properties":{"username":{"type":"string","example":"satoshi"},"priceUsd":{"type":"number","example":1000,"description":"$1 to $1,000,000, up to two decimal places."},"message":{"type":"string","example":"Long-time holder. Happy to move fast."}}}}}},"responses":{"200":{"description":"Offer made or raised"},"400":{"description":"Bad price, your own handle, or a reserved name"},"404":{"description":"Nobody holds that handle"},"409":{"description":"You already have an offer open, or too many"}},"security":[{"user-auth":[]}],"summary":"Offer to buy a handle somebody else is wearing","tags":["Username marketplace"]}},"/api/username_market/offers/{username}":{"get":{"description":"\nPublic, like an asking price. Amounts and bidders only — the note a bidder\nwrites is between them and the owner, so it is not returned here.\n    ","operationId":"UsernameMarketController_offersFor","parameters":[{"name":"username","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"Offers returned"}},"summary":"Live offers for one handle","tags":["Username marketplace"]}},"/api/username_market/offers/{offerId}/accept":{"post":{"description":"\nSays yes, and opens a listing reserved for that one buyer. Nothing has moved\nyet: they still have to pay, and they have two days to do it before the\nreservation lapses and your handle goes back to being yours alone.\n\n`replacementUsername` is where you land when it sells, validated here rather\nthan at the moment of payment — being told your new name is invalid while\nsomebody is paying you is not a recoverable position.\n\nWhile an offer is accepted your handle cannot be listed publicly or repriced.\nDecline it to get that back.\n    ","operationId":"UsernameMarketController_acceptOffer","parameters":[{"name":"offerId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["replacementUsername"],"properties":{"replacementUsername":{"type":"string","example":"satoshi_2"}}}}}},"responses":{"200":{"description":"Offer accepted; the buyer can now pay"},"400":{"description":"The replacement username is unavailable"},"403":{"description":"Not your offer"},"409":{"description":"Already answered, already lapsed, or another offer is accepted"}},"security":[{"user-auth":[]}],"summary":"Accept an offer for my handle","tags":["Username marketplace"]}},"/api/username_market/offers/{offerId}/decline":{"post":{"description":"\nWorks on an offer you have already accepted, too, right up until the buyer\npays — otherwise a buyer who goes quiet locks your handle away for two days.\nReleasing an acceptance frees the handle immediately and tells them.\n    ","operationId":"UsernameMarketController_declineOffer","parameters":[{"name":"offerId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"Offer declined"},"403":{"description":"Not your offer"}},"security":[{"user-auth":[]}],"summary":"Decline an offer for my handle","tags":["Username marketplace"]}},"/api/username_market/offers/{offerId}":{"delete":{"description":"Allowed after acceptance too — hand the reservation back rather than letting it time out.","operationId":"UsernameMarketController_withdrawOffer","parameters":[{"name":"offerId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"Offer withdrawn"},"403":{"description":"Not your offer"}},"security":[{"user-auth":[]}],"summary":"Withdraw an offer I made","tags":["Username marketplace"]}},"/api/ens/preview":{"get":{"description":"\nResolves a name on Ethereum mainnet and says whether a DeHub account is already\nwearing it. Public — it reads the chain and reveals nothing the chain does not\nalready publish.\n\nResolution is **forward** (name → address), never reverse. The reverse record\nmost people picture is a second transaction that most holders never send, so a\nreverse-based check reports nothing for a large share of genuine owners.\n\nThe name is normalised to ENSIP-15 before anything reads it, so a name mixing\ncharacter sets — the `vitalik.eth` homograph with a Cyrillic а — is rejected\nhere rather than stored.\n    ","operationId":"EnsController_preview","parameters":[{"name":"name","required":true,"in":"query","schema":{"example":"mal.eth","type":"string"}}],"responses":{"200":{"description":"Resolution and availability"},"400":{"description":"Not a usable .eth name"},"503":{"description":"Ethereum unreachable — retry"}},"summary":"What an ENS name currently is","tags":["ENS handles"]}},"/api/ens/suggest":{"get":{"description":"\nReads the primary (reverse) record on the caller's own address, purely so the\nclaim screen can pre-fill. It proves nothing — reverse records are\nself-declared — so whatever comes back still goes through the full signature\nclaim. Usually `null`: most holders never set one.\n    ","operationId":"EnsController_suggest","parameters":[],"responses":{"200":{"description":"A suggested name, or null"}},"security":[{"user-auth":[]}],"summary":"A name to offer this wallet, if it set one","tags":["ENS handles"]}},"/api/ens/challenge":{"post":{"description":"\nReturns the exact text to put in front of the wallet that the name points at,\nplus the `issuedAt` to send back with the signature.\n\nThere is no nonce to store: the message carries everything needed to rebuild\nand check it — the DeHub account, the name, the address the name resolved to,\nand when it was issued — which is the same shape the wallet login uses. It is\nsignable for 15 minutes.\n\n**The signing wallet is the one the name points at, which is usually not the\nwallet you are logged in with.** That is the point: it is what proves you hold\nthe name.\n    ","operationId":"EnsController_challenge","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","example":"mal.eth"}}}}}},"responses":{"200":{"description":"Challenge issued"},"400":{"description":"Not a usable .eth name"},"401":{"description":"Unauthorized"}},"security":[{"user-auth":[]}],"summary":"Get the message to sign","tags":["ENS handles"]}},"/api/ens/link":{"post":{"description":"\nVerifies the signature and takes the name. The profile is then reachable at\n`dehub.io/<name>` in addition to its username, which does not change.\n\nThe name is re-resolved here rather than trusted from the challenge, and the\nsignature is checked against that fresh resolution — so a name whose address\nrecord moved mid-flow fails rather than linking to whoever used to hold it.\n\nSignatures are verified through ERC-1271/6492 as well as `ecrecover`, so a\nname held by a Safe or other contract wallet works.\n\n**If another account holds the name but the chain no longer agrees with their\nlink, they lose it and you take it.** ENS names are traded and they expire;\nthe handle follows the name.\n    ","operationId":"EnsController_link","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name","issuedAt","signature"],"properties":{"name":{"type":"string","example":"mal.eth"},"issuedAt":{"type":"number","example":1756142400},"signature":{"type":"string"}}}}}},"responses":{"200":{"description":"Linked"},"401":{"description":"Signature is not from the name’s address"},"409":{"description":"Another account holds it and still proves out"}},"security":[{"user-auth":[]}],"summary":"Link a proven ENS name to this account","tags":["ENS handles"]},"get":{"operationId":"EnsController_mine","parameters":[],"responses":{"200":{"description":"The link, or null"}},"security":[{"user-auth":[]}],"summary":"The ENS name this account currently wears, if any","tags":["ENS handles"]},"delete":{"description":"Drops the name from this account. The username is untouched — it was never replaced, so there is nothing to restore.","operationId":"EnsController_unlink","parameters":[],"responses":{"200":{"description":"Unlinked"}},"security":[{"user-auth":[]}],"summary":"Unlink","tags":["ENS handles"]}},"/api/account_market/config":{"get":{"description":"\nEverything a client needs to render prices and build a payment. `chains` is\nthe set an account may be paid for on, each with the DHB contract for that\nchain. `dhbUsdPeg` is display only.\n    ","operationId":"AccountMarketController_getConfig","parameters":[],"responses":{"200":{"description":"Config returned"}},"summary":"Price limits and DHB contracts","tags":["Account marketplace"]}},"/api/account_market/listings":{"get":{"description":"\nThe shop window. Public — no authentication needed to look.\n\nEvery listing is re-checked against its live account: card numbers (followers,\nuploads, badge) are read live, and a listing whose account has been banned,\ndeleted or stripped of its handle is retired as it is found. A rename does NOT\nretire a listing — the account is the same account under any name, and the card\nsimply shows the new one.\n    ","operationId":"AccountMarketController_browse","parameters":[{"name":"limit","required":false,"in":"query","description":"Up to 60. Defaults to 24.","schema":{"type":"number"}},{"name":"page","required":false,"in":"query","schema":{"type":"number"}},{"name":"maxPriceDhb","required":false,"in":"query","schema":{"type":"number"}},{"name":"minPriceDhb","required":false,"in":"query","schema":{"type":"number"}},{"name":"sort","required":false,"in":"query","schema":{"enum":["newest","price_asc","price_desc","followers","uploads"],"type":"string"}},{"name":"search","required":false,"in":"query","description":"Substring of the handle. A leading @ is ignored.","schema":{}}],"responses":{"200":{"description":"Listings returned"}},"summary":"Browse accounts for sale","tags":["Account marketplace"]},"post":{"description":"\nPuts the account you are **signed in as** up for sale — the handle, the posts,\nthe followers, the tips history, the badge entitlements: the whole thing. There\nis no field naming the account; you can only sell what you are.\n\nWhat does NOT sell: your wallet and everything in it. DHB, staked badge\nbalance, minted collectibles and any other on-chain assets stay with your\nwallet, and after the sale that same wallet signs in to a brand-new, blank\naccount.\n\nOne live listing per account. Calling this again replaces it.\n    ","operationId":"AccountMarketController_create","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["priceDhb"],"properties":{"priceDhb":{"type":"number","example":500000,"description":"Whole DHB. 1,000 DHB = $1 at the peg."},"description":{"type":"string","example":"Established 2021. 4k followers, clean history."}}}}}},"responses":{"200":{"description":"Listing created or replaced"},"400":{"description":"No username set or bad price"},"401":{"description":"Unauthorized"}},"security":[{"user-auth":[]}],"summary":"List my account for sale","tags":["Account marketplace"]}},"/api/account_market/mine":{"get":{"description":"Everything this account has listed, sold and bought on the account market.","operationId":"AccountMarketController_mine","parameters":[],"responses":{"200":{"description":"History returned"},"401":{"description":"Unauthorized"}},"security":[{"user-auth":[]}],"summary":"My listings, sales and purchases","tags":["Account marketplace"]}},"/api/account_market/listings/{listingId}":{"patch":{"description":"Partial update. Send only what changed.","operationId":"AccountMarketController_update","parameters":[{"name":"listingId","required":true,"in":"path","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"priceDhb":{"type":"number","example":350000},"description":{"type":"string"}}}}}},"responses":{"200":{"description":"Listing updated"},"403":{"description":"Not your listing"}},"security":[{"user-auth":[]}],"summary":"Reprice or re-describe my listing","tags":["Account marketplace"]},"delete":{"description":"Takes the account off the market. The row is kept as history.","operationId":"AccountMarketController_cancel","parameters":[{"name":"listingId","required":true,"in":"path","schema":{"type":"string"}}],"responses":{"200":{"description":"Listing withdrawn"},"403":{"description":"Not your listing"}},"security":[{"user-auth":[]}],"summary":"Withdraw my listing","tags":["Account marketplace"]}},"/api/account_market/quote":{"post":{"description":"\nPrices a listing and hands back the seller's address plus the DHB contract per\npayable chain. Nothing is written and nothing is reserved. The buyer pays the\n**seller directly**, wallet to wallet — DeHub takes no cut.\n\nDelivery: the account arrives on a **vacant wallet that has signed in to DeHub\nat least once** — that prior sign-in is how the platform knows the address is\nreally the buyer's, and it means a mistyped address can never take delivery.\n`selfReceivable` says whether the wallet asking for this quote qualifies\n(true when it is fresh — the clean path is to sign in with a new wallet, fund\nit, and buy from it). Otherwise pass `receiveAddress` at claim time and check\nit first with `/account_market/check_receive`.\n    ","operationId":"AccountMarketController_quote","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["listingId"],"properties":{"listingId":{"type":"string","example":"66b1f0c2e4b0a91234567890"}}}}}},"responses":{"200":{"description":"Quote returned"},"404":{"description":"Listing gone"}},"security":[{"user-auth":[]}],"summary":"What an account costs and where to send the DHB","tags":["Account marketplace"]}},"/api/account_market/check_receive":{"post":{"description":"\nRuns exactly the checks `claim` will run against a receiving wallet, before\nany money moves. `ok: false` comes with `problem`, written for the buyer.\n    ","operationId":"AccountMarketController_checkReceive","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["listingId","receiveAddress"],"properties":{"listingId":{"type":"string"},"receiveAddress":{"type":"string","example":"0xabc…"}}}}}},"responses":{"200":{"description":"Verdict returned"}},"security":[{"user-auth":[]}],"summary":"Would this wallet be accepted as the delivery address?","tags":["Account marketplace"]}},"/api/account_market/claim":{"post":{"description":"\nCall straight after the DHB transfer lands. The backend reads the receipt,\nrequires a DHB `Transfer` from you to the seller covering the asking price,\nand only then moves the account to `receiveAddress` (defaults to the wallet\ncalling, when it qualifies as vacant).\n\nThe payer is matched on the ERC-20 event's `from`, not the transaction's, so\na sponsored transaction from a DeHub smart wallet settles exactly like one sent\nfrom an external wallet.\n\nSafe to repeat — and repeating is how an interrupted transfer FINISHES. The\nhash is claimed before the transfer starts; a retry after a lost response or a\nmid-transfer crash resumes the same sale until the account lives at the\nreceiving wallet. `pending: true` means the receipt is not visible yet. A\n**409** means the payment is recorded but the transfer has not completed;\nretry the claim to resume it.\n    ","operationId":"AccountMarketController_claim","parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["listingId","txHash","chainId"],"properties":{"listingId":{"type":"string","example":"66b1f0c2e4b0a91234567890"},"txHash":{"type":"string","example":"0xabc…"},"chainId":{"type":"number","example":8453,"description":"Base (8453) or BSC (56)."},"receiveAddress":{"type":"string","description":"Vacant wallet to deliver to. Defaults to the caller."}}}}}},"responses":{"200":{"description":"Account transferred, or still pending"},"400":{"description":"Bad hash, wrong chain, reverted, underpaid, or the receiving wallet is not vacant"},"409":{"description":"Paid, but the transfer has not completed — retry to resume"}},"security":[{"user-auth":[]}],"summary":"Take the account with a confirmed DHB transfer","tags":["Account marketplace"]}},"/api/stats/users":{"get":{"description":"\nPublic and unauthenticated. Returns the same user figures the admin dashboard\nreports — total, active, new per period and percent growth — plus the daily\nseries the chart is drawn from.\n\nNothing here identifies anybody: every field is a count. Signup history is\nrecomputed from account creation dates on each read, so it is always correct\neven if the recorder misses a run. Active-user history cannot be recomputed\nafter the day it describes, so it comes from stored daily captures and reads\n`null` for any day before capturing began.\n    ","operationId":"PublicStatsController_users","parameters":[],"responses":{"200":{"description":"User statistics"}},"summary":"Community numbers behind dehub.io/stats","tags":["Stats"]}},"/api/stats/revenue":{"get":{"description":"\nPublic and unauthenticated. Daily totals, per source, in the currency each\npayment was made in: DHB bought by card or crypto, the platform fee on fan\nsubscriptions, extra-post and YouTube-import settlements, SMS credits, and the\ncard processor's fee as a cost.\n\nNothing here identifies anybody: every row is a sum for one UTC day. Pricing\nDHB and fiat in USD is left to the page's finance-stats function, so the same\nDHB is never valued two ways.\n    ","operationId":"PublicStatsController_revenueRows","parameters":[],"responses":{"200":{"description":"Revenue rows"}},"summary":"Revenue rows behind the money section of dehub.io/stats","tags":["Stats"]}},"/api/sitemap/profiles":{"get":{"description":"\nPublic and unauthenticated. Returns the usernames the Cloudflare worker turns\ninto the profile sitemap, one page at a time.\n\nOnly accounts with at least one post a signed-out visitor can see are listed —\nregistering does not by itself produce a page worth submitting to a search\nengine. Private and banned accounts are excluded. Nothing here is not already\non the public profile it points at.\n\nThe order is stable, so an account keeps landing in the same chunk as the\nplatform grows. A page past the end returns an empty list rather than an error.\n    ","operationId":"SitemapController_profiles","parameters":[{"name":"page","required":false,"in":"query","description":"1-based chunk number. Default 1.","schema":{"type":"string"}},{"name":"limit","required":false,"in":"query","description":"URLs per chunk. Default and maximum 50000, the sitemap spec's cap.","schema":{"type":"string"}}],"responses":{"200":{"description":"A page of profile URLs"}},"summary":"Profile URLs for dehub.io/sitemap-profiles-<n>.xml","tags":["Sitemap"]}},"/api/buy-crypto":{"post":{"operationId":"BuySellCryptoController_buyCryptoMoonpayUrl","parameters":[],"responses":{"201":{"description":""}},"tags":["BuySellCrypto"]}},"/api/sell-crypto":{"post":{"operationId":"BuySellCryptoController_sellCryptoMoonpayUrl","parameters":[],"responses":{"201":{"description":""}},"tags":["BuySellCrypto"]}},"/api/moonpay/webhook":{"post":{"operationId":"BuySellCryptoController_moonpayWebhook","parameters":[{"name":"moonpay-signature","required":true,"in":"header","schema":{"type":"string"}}],"responses":{"201":{"description":""}},"tags":["BuySellCrypto"]}}},"info":{"title":"DeHub API","description":"DeHub Backend API Documentation","version":"1.0","contact":{}},"tags":[{"name":"App","description":"App version and update endpoints"},{"name":"Auth","description":"User authentication endpoints"},{"name":"Users","description":"User profile and account endpoints"},{"name":"Videos","description":"Video content endpoints [A bunch of these are useless and were written by the previous devs, didn't remove for backward compatibility]"},{"name":"Reactions","description":"Likes, comments, tips, and follows"},{"name":"Search","description":"Search endpoints"},{"name":"Leaderboard","description":"Leaderboard endpoints"},{"name":"Assets","description":"Media asset endpoints"},{"name":"Livestream","description":"Live streaming endpoints"},{"name":"Notifications","description":"User notification endpoints"},{"name":"Push Notifications","description":"Mobile push notification endpoints"},{"name":"Direct Messages","description":"Chat and messaging endpoints"},{"name":"Subscriptions","description":"Creator subscription plans"},{"name":"Payments","description":"Payment and checkout endpoints"},{"name":"Reports","description":"Content reporting endpoints"},{"name":"Stats","description":"Public platform statistics — no authentication"}],"servers":[],"components":{"securitySchemes":{"user-auth":{"scheme":"bearer","bearerFormat":"JWT","type":"http","name":"Authorization","description":"Enter JWT token(Gotten from web/auth or mobile/auth)","in":"header"},"admin-auth":{"scheme":"bearer","bearerFormat":"JWT","type":"http","name":"Authorization","description":"Enter Admin JWT token","in":"header"}},"schemas":{"UpdateAdminStageDto":{"type":"object","properties":{}},"DeleteAdminStageDto":{"type":"object","properties":{}},"UpdateAdminTranscriptDto":{"type":"object","properties":{}},"DeleteAdminTranscriptDto":{"type":"object","properties":{}},"ReportCategoryBreakdownDto":{"type":"object","properties":{"byStatus":{"type":"object","additionalProperties":{"type":"number"},"example":{"pending":12,"reviewed":3,"action_taken":5,"dismissed":2},"description":"Count per status"},"byReason":{"type":"object","additionalProperties":{"type":"number"},"example":{"spam_misleading":8,"sexual_content":4},"description":"Count per reason category"},"pendingCount":{"type":"number","example":12,"description":"Number of reports in pending status"}},"required":["byStatus","byReason","pendingCount"]},"ReportsDashboardResponseDto":{"type":"object","properties":{"content":{"description":"Content (video/post) report statistics","allOf":[{"$ref":"#/components/schemas/ReportCategoryBreakdownDto"}]},"user":{"description":"User report statistics","allOf":[{"$ref":"#/components/schemas/ReportCategoryBreakdownDto"}]},"totalPending":{"type":"number","example":16,"description":"Total pending reports across both categories"}},"required":["content","user","totalPending"]},"ReportStatusSummaryDto":{"type":"object","properties":{"pending":{"type":"number","example":12},"reviewed":{"type":"number","example":3},"action_taken":{"type":"number","example":5},"dismissed":{"type":"number","example":2}},"required":["pending","reviewed","action_taken","dismissed"]},"UserReferenceDto":{"type":"object","properties":{"address":{"type":"string","example":"0x1234abcd5678ef90..."},"username":{"type":"string","example":"johndoe"},"displayName":{"type":"string","example":"John Doe"},"avatarImageUrl":{"type":"string","example":"https://cdn.dehub.io/avatars/abc.webp"},"followers":{"type":"number","example":120},"followings":{"type":"number","example":45},"sentTips":{"type":"number","example":50000},"receivedTips":{"type":"number","example":120000},"createdAt":{"type":"string","example":"2024-06-15T10:00:00.000Z"},"isPrivate":{"type":"boolean","example":false},"hideFollowers":{"type":"boolean","example":false},"badgeBalance":{"type":"number","example":250000}},"required":["address"]},"ReportedTokenDto":{"type":"object","properties":{"tokenId":{"type":"number","example":42},"name":{"type":"string","example":"My awesome video"},"description":{"type":"string","example":"A short description of the video"},"image":{"type":"string","example":"https://cdn.dehub.io/images/thumb.webp"},"minter":{"type":"string","example":"0xabcdef..."},"owner":{"type":"string","example":"0xabcdef..."},"postType":{"type":"string","example":"video","enum":["video","feedImages","feedSimple","live"]},"status":{"type":"string","example":"minted","enum":["minted","pending","deleted"]},"views":{"type":"number","example":1500},"createdAt":{"type":"string","example":"2025-01-20T14:30:00.000Z"}},"required":["tokenId"]},"AdminReferenceDto":{"type":"object","properties":{"displayName":{"type":"string","example":"Jane"},"role":{"type":"string","example":"admin","enum":["super_admin","admin","moderator","viewer"]}},"required":["role"]},"ContentReportItemDto":{"type":"object","properties":{"_id":{"type":"string","example":"665f1a2b3c4d5e6f7a8b9c0d"},"tokenId":{"type":"number","example":42},"reason":{"type":"string","enum":["sexual_content","violent_content","hateful_content","harassment_bullying","harmful_dangerous","misinformation","child_abuse","spam_misleading","scam_fraud","infringes_rights","other"],"example":"spam_misleading","description":"Standardized reason category"},"additionalInfo":{"type":"string","example":"This video is a duplicate of another post","description":"Additional details from the reporter"},"status":{"type":"string","enum":["pending","reviewed","action_taken","dismissed"],"example":"pending","description":"Moderation status"},"description":{"type":"string","example":"Original report text (legacy)","description":"Legacy description field"},"reviewedAt":{"type":"string","example":"2025-12-20T15:00:00.000Z"},"createdAt":{"type":"string","example":"2025-12-18T10:30:00.000Z"},"updatedAt":{"type":"string","example":"2025-12-20T15:00:00.000Z"},"reporter":{"description":"Account that filed the report","allOf":[{"$ref":"#/components/schemas/UserReferenceDto"}]},"token":{"description":"Reported token/video details","allOf":[{"$ref":"#/components/schemas/ReportedTokenDto"}]},"reviewedByAdmin":{"description":"Admin who reviewed this report","allOf":[{"$ref":"#/components/schemas/AdminReferenceDto"}]}},"required":["_id","tokenId","status","createdAt"]},"ListContentReportsResponseDto":{"type":"object","properties":{"page":{"type":"number","example":1},"limit":{"type":"number","example":20},"total":{"type":"number","example":47,"description":"Total reports matching the filters"},"summary":{"description":"Global counts by status (for dashboard badges)","allOf":[{"$ref":"#/components/schemas/ReportStatusSummaryDto"}]},"items":{"type":"array","items":{"$ref":"#/components/schemas/ContentReportItemDto"}}},"required":["page","limit","total","summary","items"]},"GetContentReportResponseDto":{"type":"object","properties":{"report":{"$ref":"#/components/schemas/ContentReportItemDto"},"totalReportsForToken":{"type":"number","example":3,"description":"Total reports (including this one) for the same tokenId"}},"required":["report","totalReportsForToken"]},"ReviewReportDto":{"type":"object","properties":{"status":{"type":"string","enum":["reviewed","action_taken","dismissed"],"description":"New moderation status"},"adminNote":{"type":"string","description":"Internal admin note (max 1000 chars)","maxLength":1000}},"required":["status"]},"ReviewContentReportResponseDto":{"type":"object","properties":{"report":{"$ref":"#/components/schemas/ContentReportItemDto"},"message":{"type":"string","example":"Report status updated to reviewed"}},"required":["report","message"]},"TakeActionOnContentReportDto":{"type":"object","properties":{"action":{"type":"string","description":"Action to take on the reported content","enum":["remove","warn_creator","dismiss"]},"reason":{"type":"string","description":"Reason / note to include in the notification to the user (max 500 chars)","maxLength":500},"adminNote":{"type":"string","description":"Internal admin note (max 1000 chars)","maxLength":1000}},"required":["action"]},"ContentReportActionResponseDto":{"type":"object","properties":{"report":{"$ref":"#/components/schemas/ContentReportItemDto"},"message":{"type":"string","example":"Content removed and creator notified","description":"Human-readable summary of the action taken"}},"required":["report","message"]},"TargetUserReferenceDto":{"type":"object","properties":{"address":{"type":"string","example":"0x1234abcd5678ef90..."},"username":{"type":"string","example":"johndoe"},"displayName":{"type":"string","example":"John Doe"},"avatarImageUrl":{"type":"string","example":"https://cdn.dehub.io/avatars/abc.webp"},"followers":{"type":"number","example":120},"followings":{"type":"number","example":45},"sentTips":{"type":"number","example":50000},"receivedTips":{"type":"number","example":120000},"createdAt":{"type":"string","example":"2024-06-15T10:00:00.000Z"},"isPrivate":{"type":"boolean","example":false},"hideFollowers":{"type":"boolean","example":false},"badgeBalance":{"type":"number","example":250000},"isBanned":{"type":"boolean","example":false},"canPost":{"type":"boolean","example":true},"canComment":{"type":"boolean","example":true},"bannedAt":{"type":"string","example":"2025-12-01T09:00:00.000Z","description":"When the user was banned (if applicable)"},"bannedReason":{"type":"string","example":"Spam / Misleading","description":"Reason for ban (if applicable)"}},"required":["address"]},"UserReportItemDto":{"type":"object","properties":{"_id":{"type":"string","example":"665f1a2b3c4d5e6f7a8b9c0d"},"reason":{"type":"string","enum":["impersonation","harassment_bullying","hateful_behavior","spam","scam_fraud","underage","inappropriate_profile","other"],"example":"spam","description":"Reason category"},"additionalInfo":{"type":"string","example":"This user keeps sending spam links in DMs"},"status":{"type":"string","enum":["pending","reviewed","action_taken","dismissed"],"example":"pending"},"reviewedAt":{"type":"string","example":"2025-12-20T15:00:00.000Z"},"createdAt":{"type":"string","example":"2025-12-18T10:30:00.000Z"},"updatedAt":{"type":"string","example":"2025-12-20T15:00:00.000Z"},"reporter":{"description":"Account that filed the report","allOf":[{"$ref":"#/components/schemas/UserReferenceDto"}]},"targetUser":{"description":"The reported user (includes ban/permissions status)","allOf":[{"$ref":"#/components/schemas/TargetUserReferenceDto"}]},"reviewedByAdmin":{"description":"Admin who reviewed this report","allOf":[{"$ref":"#/components/schemas/AdminReferenceDto"}]}},"required":["_id","reason","status","createdAt"]},"ListUserReportsResponseDto":{"type":"object","properties":{"page":{"type":"number","example":1},"limit":{"type":"number","example":20},"total":{"type":"number","example":14,"description":"Total reports matching the filters"},"summary":{"description":"Global counts by status","allOf":[{"$ref":"#/components/schemas/ReportStatusSummaryDto"}]},"items":{"type":"array","items":{"$ref":"#/components/schemas/UserReportItemDto"}}},"required":["page","limit","total","summary","items"]},"GetUserReportResponseDto":{"type":"object","properties":{"report":{"$ref":"#/components/schemas/UserReportItemDto"},"totalReportsForUser":{"type":"number","example":5,"description":"Total reports (including this one) for the same user"}},"required":["report","totalReportsForUser"]},"TakeActionOnUserReportDto":{"type":"object","properties":{"action":{"type":"string","description":"Action to take on the reported user","enum":["ban","warn","restrict_posting","restrict_comments","dismiss"]},"reason":{"type":"string","description":"Reason / note to include in the notification to the user (max 500 chars)","maxLength":500},"adminNote":{"type":"string","description":"Internal admin note (max 1000 chars)","maxLength":1000}},"required":["action"]},"UserReportActionResponseDto":{"type":"object","properties":{"report":{"$ref":"#/components/schemas/UserReportItemDto"},"message":{"type":"string","example":"User banned and notified","description":"Human-readable summary of the action taken"}},"required":["report","message"]},"UpdateSupportTicketDto":{"type":"object","properties":{"status":{"type":"string","enum":["open","in_progress","resolved","closed"],"description":"New status. Changing it notifies the reporter."},"resolution":{"type":"string","description":"Shown to the reporter — goes into their notification and is what the assistant reads back. Keep it plain.","maxLength":2000},"adminNote":{"type":"string","description":"Internal only. Never shown to the reporter and never read out by the assistant.","maxLength":2000}}},"CommentStatusSummaryDto":{"type":"object","properties":{"visible":{"type":"number","example":1500,"description":"Total visible comments"},"hidden":{"type":"number","example":23,"description":"Total hidden comments"},"flagged":{"type":"number","example":8,"description":"Total flagged comments"},"deleted":{"type":"number","example":45,"description":"Total deleted comments"},"pinned":{"type":"number","example":12,"description":"Total pinned comments"}},"required":["visible","hidden","flagged","deleted","pinned"]},"TopCommenterDto":{"type":"object","properties":{"address":{"type":"string","example":"0x1234..."},"username":{"type":"string","example":"johndoe"},"displayName":{"type":"string","example":"John Doe"},"avatarImageUrl":{"type":"string","example":"https://cdn.dehub.io/avatars/abc.webp"},"commentCount":{"type":"number","example":150}},"required":["address","commentCount"]},"MostCommentedTokenDto":{"type":"object","properties":{"tokenId":{"type":"number","example":42},"name":{"type":"string","example":"My awesome video"},"image":{"type":"string","example":"https://cdn.dehub.io/images/thumb.webp"},"postType":{"type":"string","example":"video"},"commentCount":{"type":"number","example":320}},"required":["tokenId","commentCount"]},"DailyCommentCountDto":{"type":"object","properties":{"date":{"type":"string","example":"2025-12-18","description":"Date in YYYY-MM-DD format"},"count":{"type":"number","example":45}},"required":["date","count"]},"CommentDashboardResponseDto":{"type":"object","properties":{"totalComments":{"type":"number","example":15230,"description":"Total comments across all content"},"commentsToday":{"type":"number","example":78,"description":"Comments posted today (in requested timezone)"},"commentsThisWeek":{"type":"number","example":523,"description":"Comments posted in the last 7 days"},"commentsThisMonth":{"type":"number","example":2100,"description":"Comments posted in the last 30 days"},"statusBreakdown":{"description":"Breakdown by moderation status","allOf":[{"$ref":"#/components/schemas/CommentStatusSummaryDto"}]},"mediaBreakdown":{"type":"object","additionalProperties":{"type":"number"},"example":{"text":12000,"image":2500,"audio":730},"description":"Breakdown by media type"},"topLevelCount":{"type":"number","example":8500,"description":"Number of top-level comments (not replies)"},"replyCount":{"type":"number","example":6730,"description":"Number of reply comments"},"avgRepliesPerComment":{"type":"number","example":1.8,"description":"Average replies per top-level comment"},"avgLikesPerComment":{"type":"number","example":3.2,"description":"Average likes per comment"},"topCommenters":{"description":"Top 10 commenters by volume (last 30 days)","type":"array","items":{"$ref":"#/components/schemas/TopCommenterDto"}},"mostCommentedContent":{"description":"Top 10 most commented-on content (last 30 days)","type":"array","items":{"$ref":"#/components/schemas/MostCommentedTokenDto"}},"dailyTrend":{"description":"Daily comment counts for the last 30 days","type":"array","items":{"$ref":"#/components/schemas/DailyCommentCountDto"}}},"required":["totalComments","commentsToday","commentsThisWeek","commentsThisMonth","statusBreakdown","mediaBreakdown","topLevelCount","replyCount","avgRepliesPerComment","avgLikesPerComment","topCommenters","mostCommentedContent","dailyTrend"]},"CommentAuthorDto":{"type":"object","properties":{"_id":{"type":"string","example":"665f1a2b3c4d5e6f7a8b9c0d"},"address":{"type":"string","example":"0x1234abcd5678ef90..."},"username":{"type":"string","example":"johndoe"},"displayName":{"type":"string","example":"John Doe"},"avatarImageUrl":{"type":"string","example":"https://cdn.dehub.io/avatars/abc.webp"},"followers":{"type":"number","example":120},"badgeBalance":{"type":"number","example":250000},"isBanned":{"type":"boolean","example":false},"canComment":{"type":"boolean","example":true},"createdAt":{"type":"string","example":"2024-06-15T10:00:00.000Z"}},"required":["address"]},"CommentTokenDto":{"type":"object","properties":{"tokenId":{"type":"number","example":42},"name":{"type":"string","example":"My awesome video"},"image":{"type":"string","example":"https://cdn.dehub.io/images/thumb.webp"},"minter":{"type":"string","example":"0xabcdef..."},"postType":{"type":"string","example":"video","enum":["video","short","feed-images","feed-simple","feed-audio","live"]},"status":{"type":"string","example":"minted","enum":["minted","pending","deleted"]}},"required":["tokenId"]},"CommentAdminReferenceDto":{"type":"object","properties":{"displayName":{"type":"string","example":"Jane"},"role":{"type":"string","example":"admin","enum":["super_admin","admin","moderator","viewer"]}},"required":["role"]},"AdminCommentItemDto":{"type":"object","properties":{"_id":{"type":"string","example":"665f1a2b3c4d5e6f7a8b9c0d","description":"MongoDB ObjectId"},"id":{"type":"number","example":42,"description":"Auto-increment comment ID"},"tokenId":{"type":"number","example":100,"description":"Token/video this comment belongs to"},"address":{"type":"string","example":"0x1234...","description":"Commenter wallet address"},"content":{"type":"string","example":"Great video! Keep it up 🔥","description":"Comment text content"},"imageUrl":{"type":"string","example":"https://cdn.dehub.io/comments/img.webp","description":"Attached image URL"},"videoUrl":{"type":"string","example":"https://cdn.dehub.io/comments/audio.mp3","description":"Attached audio URL"},"audioDuration":{"type":"number","example":15,"description":"Audio duration in seconds (max 30)"},"parentId":{"type":"number","example":null,"description":"Parent comment ID (null for top-level comments)"},"likes":{"type":"number","example":5,"description":"Total like count"},"replyCount":{"type":"number","example":3,"description":"Number of replies"},"moderationStatus":{"type":"string","example":"visible","enum":["visible","hidden","flagged","deleted"],"description":"Current moderation status"},"isPinned":{"type":"boolean","example":true,"description":"Whether the comment is pinned"},"isFlagged":{"type":"boolean","example":true,"description":"Whether the comment is flagged for review"},"isHidden":{"type":"boolean","example":true,"description":"Whether the comment is hidden from public"},"moderationReason":{"type":"string","example":"Spam content","description":"Moderation reason (if moderated)"},"moderationNote":{"type":"string","example":"Internal: repeated offender","description":"Internal admin note"},"moderatedAt":{"type":"string","example":"2025-12-20T15:00:00.000Z"},"createdAt":{"type":"string","example":"2025-12-18T10:30:00.000Z"},"updatedAt":{"type":"string","example":"2025-12-20T15:00:00.000Z"},"author":{"description":"Comment author info","allOf":[{"$ref":"#/components/schemas/CommentAuthorDto"}]},"token":{"description":"Associated video/post info","allOf":[{"$ref":"#/components/schemas/CommentTokenDto"}]},"moderatedByAdmin":{"description":"Admin who last moderated this comment","allOf":[{"$ref":"#/components/schemas/CommentAdminReferenceDto"}]}},"required":["_id","id","tokenId","address","likes","replyCount","moderationStatus","createdAt"]},"ListAdminCommentsResponseDto":{"type":"object","properties":{"page":{"type":"number","example":1},"limit":{"type":"number","example":20},"total":{"type":"number","example":1576,"description":"Total comments matching the filters"},"summary":{"description":"Global counts by moderation status","allOf":[{"$ref":"#/components/schemas/CommentStatusSummaryDto"}]},"items":{"type":"array","items":{"$ref":"#/components/schemas/AdminCommentItemDto"}}},"required":["page","limit","total","summary","items"]},"GetAdminCommentResponseDto":{"type":"object","properties":{"comment":{"$ref":"#/components/schemas/AdminCommentItemDto"},"parentComment":{"description":"Parent comment (if this is a reply)","allOf":[{"$ref":"#/components/schemas/AdminCommentItemDto"}]},"recentReplies":{"description":"Direct replies to this comment (most recent first, max 10)","type":"array","items":{"$ref":"#/components/schemas/AdminCommentItemDto"}},"totalReplies":{"type":"number","example":3,"description":"Total number of replies to this comment"},"totalCommentsByUser":{"type":"number","example":15,"description":"Total comments by this user across all content"},"totalCommentsOnToken":{"type":"number","example":45,"description":"Total comments on this video/post"},"reportCount":{"type":"number","example":2,"description":"How many people have reported this comment"}},"required":["comment","recentReplies","totalReplies","totalCommentsByUser","totalCommentsOnToken","reportCount"]},"ModerateCommentDto":{"type":"object","properties":{"action":{"type":"string","description":"Moderation action to take on the comment","enum":["hide","unhide","flag","unflag","pin","unpin"]},"reason":{"type":"string","description":"Reason for the moderation action (max 500 chars)","maxLength":500},"adminNote":{"type":"string","description":"Internal admin note (max 1000 chars)","maxLength":1000}},"required":["action"]},"ModerateCommentResponseDto":{"type":"object","properties":{"comment":{"$ref":"#/components/schemas/AdminCommentItemDto"},"message":{"type":"string","example":"Comment hidden successfully"}},"required":["comment","message"]},"DeleteCommentDto":{"type":"object","properties":{"reason":{"type":"string","description":"Reason for deleting the comment (sent to the user if notifyUser is true)","maxLength":500},"deleteReplies":{"type":"boolean","description":"Whether to also delete all replies to this comment","default":false},"adminNote":{"type":"string","description":"Internal admin note (not visible to user)","maxLength":1000}}},"DeleteCommentResponseDto":{"type":"object","properties":{"deletedCount":{"type":"number","example":1,"description":"Number of comments deleted"},"repliesDeleted":{"type":"number","example":2,"description":"Number of replies deleted (if deleteReplies was true)"},"message":{"type":"string","example":"Comment and 2 replies deleted"}},"required":["deletedCount","repliesDeleted","message"]},"BulkDeleteCommentsDto":{"type":"object","properties":{"commentIds":{"description":"Array of comment IDs to delete (auto-increment IDs as strings)","example":["1","2","3"],"minItems":1,"maxItems":100,"type":"array","items":{"type":"string"}},"deleteReplies":{"type":"boolean","description":"Whether to also delete all replies to these comments","default":false},"reason":{"type":"string","description":"Reason for deletion","maxLength":500},"adminNote":{"type":"string","description":"Internal admin note","maxLength":1000}},"required":["commentIds"]},"BulkActionResponseDto":{"type":"object","properties":{"processedCount":{"type":"number","example":5,"description":"Number of comments successfully processed"},"failedCount":{"type":"number","example":0,"description":"Number of comments that failed to process"},"failedIds":{"example":["1","2"],"description":"IDs of comments that failed (if any)","type":"array","items":{"type":"string"}},"message":{"type":"string","example":"5 comments hidden successfully"}},"required":["processedCount","failedCount","failedIds","message"]},"BulkModerateCommentsDto":{"type":"object","properties":{"commentIds":{"description":"Array of comment IDs to moderate","example":["1","2","3"],"minItems":1,"maxItems":100,"type":"array","items":{"type":"string"}},"action":{"type":"string","description":"Moderation action to apply to all selected comments","enum":["hide","unhide","flag","unflag","delete"]},"reason":{"type":"string","description":"Reason for the bulk action","maxLength":500},"adminNote":{"type":"string","description":"Internal admin note","maxLength":1000}},"required":["commentIds","action"]},"UpdatePlatformSettingsDto":{"type":"object","properties":{"videoUploads":{"type":"boolean","description":"Allow users to upload new videos, images, text posts, and create livestreams","example":true},"livestreaming":{"type":"boolean","description":"Allow users to start and manage live broadcasts","example":true},"comments":{"type":"boolean","description":"Allow users to post, edit, like, and delete comments","example":true},"maintenanceMode":{"type":"boolean","description":"Put the platform in maintenance mode — all non-admin endpoints return 503","example":false},"maintenanceMessage":{"type":"string","description":"Optional message shown to users when maintenance mode is active (max 500 chars)","example":"We are upgrading our systems. Back shortly!","maxLength":500},"legacyUserCount":{"type":"number","description":"Old-platform members who have never been imported into accounts. Added to the dashboard user total; growth stays measured against registered accounts. Set to 0 once the real rows exist.","example":2000,"minimum":0,"maximum":10000000},"externalActiveUsers":{"type":"number","description":"Average daily active users on DeHub-affiliated community frontends, run independently on their own infrastructure and invisible to this platform's own activity log. Set by hand from what that frontend reports; added on top of the public stats page's measured active-user counts (today/week/month alike). Set to 0 if that frontend goes away.","example":100,"minimum":0,"maximum":10000000}}}}}}