diff --git a/docs/artist-dashboard-api.md b/docs/artist-dashboard-api.md new file mode 100644 index 0000000..7c3318d --- /dev/null +++ b/docs/artist-dashboard-api.md @@ -0,0 +1,121 @@ +# Artist Dashboard API + +This endpoint powers the Flutter artist profile dashboard for the currently authenticated user. + +## Endpoint + +`GET /api/v1/users/me/dashboard` + +## Headers + +```http +Authorization: Bearer +``` + +## Notes + +- This endpoint is private to the authenticated user. +- It does not accept a `userId` parameter. +- It does not expose another user's private analytics. +- It keeps the existing `GET /api/v1/users/:id/profile-overview` endpoint unchanged. +- Earnings, audience non-followers, and chart data are not faked. They return safe empty/default values until dedicated analytics storage exists. + +## Success Response Example + +```json +{ + "profile": { + "_id": "665f00000000000000000001", + "coverImage": "", + "avatar": "", + "name": "Artist Name", + "stageName": "Stage Name", + "bio": "Short artist bio", + "isVerified": false + }, + "stats": { + "followersCount": 120, + "followingCount": 45, + "postsCount": 12, + "collaborationsCount": 2, + "viewCount": 3000, + "playCount": 980, + "listenCount": 980, + "likesCount": 220, + "commentsCount": 40, + "savesCount": 18, + "sharesCount": 11, + "engagementRate": 7.26, + "scorePercentage": 0, + "earnings": 0 + }, + "audience": { + "followers": 120, + "nonFollowers": 0, + "newFollowersThisWeek": 4, + "newFollowersThisMonth": 19 + }, + "engagementChart": [], + "topContent": [ + { + "_id": "665f00000000000000000002", + "postType": "audio", + "thumbnailUrl": "", + "displayUrl": "", + "content": "New track", + "viewCount": 1000, + "playCount": 600, + "likesCount": 90, + "commentsCount": 10, + "savesCount": 8, + "engagementScore": 113, + "createdAt": "2026-06-08T00:00:00.000Z" + } + ], + "recentActivity": [], + "meta": { + "generatedAt": "2026-06-08T00:00:00.000Z", + "chartAvailable": false, + "earningsAvailable": false, + "audienceAnalyticsAvailable": false + } +} +``` + +## Flutter Model Suggestion + +- `ArtistDashboard` +- `ArtistDashboardProfile` +- `ArtistDashboardStats` +- `ArtistDashboardAudience` +- `ArtistDashboardTopContent` +- `ArtistDashboardRecentActivity` +- `ArtistDashboardMeta` + +## UI Sections + +1. Profile header: use `profile.coverImage`, `profile.avatar`, `profile.name`, `profile.stageName`, `profile.bio`, `profile.isVerified`. +2. Stats cards: use `stats.followersCount`, `stats.followingCount`, `stats.viewCount`, `stats.listenCount`, `stats.engagementRate`. +3. Engagement chart: render only when `meta.chartAvailable === true`; otherwise show the empty state. +4. Top content list: use `topContent`. +5. Audience summary: use `audience.followers`, `audience.nonFollowers`, `audience.newFollowersThisWeek`, `audience.newFollowersThisMonth`. +6. Recent activity: use `recentActivity`. +7. Empty states: show clear UI for no posts, no analytics, chart unavailable, and earnings unavailable. + +## Empty States + +- No posts yet: `stats.postsCount === 0`. +- No analytics yet: `stats.viewCount === 0 && stats.playCount === 0`. +- Chart not available: `meta.chartAvailable === false`. +- Earnings not available: `meta.earningsAvailable === false`. + +## Future Analytics Upgrade + +Daily chart data requires event-level or snapshot storage. Recommended future collections: + +- `post_view_events` +- `post_play_events` +- `post_engagement_events` +- `daily_artist_analytics` + +Audience split requires viewer identity tracking per view/play event. Earnings requires a monetization/revenue module. Until those exist, the backend intentionally returns safe defaults instead of fake values. diff --git a/postman/Oudelaa-Auth-Users-Posts.postman_collection.json b/postman/Oudelaa-Auth-Users-Posts.postman_collection.json index c8f6c3e..b94e0ae 100644 --- a/postman/Oudelaa-Auth-Users-Posts.postman_collection.json +++ b/postman/Oudelaa-Auth-Users-Posts.postman_collection.json @@ -5599,6 +5599,382 @@ } ] }, + { + "name": "Realtime Socket.IO", + "description": "Socket.IO realtime contract for Flutter. These examples document emits/listeners; they are not REST endpoints.", + "item": [ + { + "name": "Connection - Chat Namespace", + "request": { + "method": "GET", + "header": [ + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket type: Socket.IO\nNamespace: /chat\nLocal URL: {{socketBaseUrl}}/chat\nProduction URL: https://YOUR_DOMAIN/chat\nFlutter auth: auth: { token: accessToken }\nOn connect, backend joins user room user:{userId}." + } + }, + { + "name": "Connection - Notifications Namespace", + "request": { + "method": "GET", + "header": [ + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{notificationsSocketUrl}}", + "description": "Socket type: Socket.IO\nNamespace: /notifications\nLocal URL: {{socketBaseUrl}}/notifications\nProduction URL: https://YOUR_DOMAIN/notifications\nFlutter auth: auth: { token: accessToken }\nOn connect, backend joins user room user:{userId}." + } + }, + { + "name": "Emit join_conversation", + "request": { + "method": "POST", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: join_conversation\nDirection: Client -> Backend\nServer confirms with joined_conversation.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\"\n}\n```", + "body": { + "mode": "raw", + "raw": "{\n \"conversationId\": \"{{conversationId}}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Emit leave_conversation", + "request": { + "method": "POST", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: leave_conversation\nDirection: Client -> Backend\nServer confirms with left_conversation. Socket remains connected.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\"\n}\n```", + "body": { + "mode": "raw", + "raw": "{\n \"conversationId\": \"{{conversationId}}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Emit send_message", + "request": { + "method": "POST", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: send_message\nDirection: Client -> Backend\nServer emits new_message to conversation:{conversationId}. REST send also emits new_message once.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\",\n \"content\": \"Hello\",\n \"messageType\": \"text\"\n}\n```", + "body": { + "mode": "raw", + "raw": "{\n \"conversationId\": \"{{conversationId}}\",\n \"content\": \"Hello\",\n \"messageType\": \"text\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Emit typing", + "request": { + "method": "POST", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: typing\nDirection: Client -> Backend\nBackend validates membership and broadcasts typing to the conversation room excluding sender.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\",\n \"isTyping\": true\n}\n```", + "body": { + "mode": "raw", + "raw": "{\n \"conversationId\": \"{{conversationId}}\",\n \"isTyping\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Emit mark_delivered", + "request": { + "method": "POST", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: mark_delivered\nDirection: Client -> Backend\nBackend stores deliveredBy once per user and emits message_delivered.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\",\n \"messageId\": \"{{messageId}}\"\n}\n```", + "body": { + "mode": "raw", + "raw": "{\n \"conversationId\": \"{{conversationId}}\",\n \"messageId\": \"{{messageId}}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Emit mark_seen", + "request": { + "method": "POST", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: mark_seen\nDirection: Client -> Backend\nBackend stores seenBy, clears unread count, and emits message_seen.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\",\n \"messageId\": \"{{messageId}}\"\n}\n```", + "body": { + "mode": "raw", + "raw": "{\n \"conversationId\": \"{{conversationId}}\",\n \"messageId\": \"{{messageId}}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Listen joined_conversation", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: joined_conversation\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\"\n}\n```" + } + }, + { + "name": "Listen left_conversation", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: left_conversation\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\"\n}\n```" + } + }, + { + "name": "Listen new_message", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: new_message\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"_id\": \"{{messageId}}\",\n \"conversationId\": \"{{conversationId}}\",\n \"senderId\": \"{{userId}}\",\n \"content\": \"Hello\",\n \"messageType\": \"text\"\n}\n```" + } + }, + { + "name": "Listen message_delivered", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: message_delivered\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\",\n \"messageId\": \"{{messageId}}\",\n \"userId\": \"{{targetUserId}}\",\n \"deliveredAt\": \"2026-06-07T10:00:00.000Z\"\n}\n```" + } + }, + { + "name": "Listen message_seen", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: message_seen\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\",\n \"messageId\": \"{{messageId}}\",\n \"userId\": \"{{targetUserId}}\",\n \"seenAt\": \"2026-06-07T10:00:00.000Z\"\n}\n```" + } + }, + { + "name": "Listen presence", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: presence\nDirection: Backend -> Client\nonline is kept for backward compatibility; prefer isOnline.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"userId\": \"{{targetUserId}}\",\n \"isOnline\": true,\n \"online\": true,\n \"lastSeenAt\": null\n}\n```" + } + }, + { + "name": "Listen socket_error", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: socket_error\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"event\": \"join_conversation\",\n \"message\": \"Not allowed\",\n \"code\": \"FORBIDDEN\"\n}\n```" + } + }, + { + "name": "Listen notification:new", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{notificationsSocketUrl}}", + "description": "Socket.IO event: notification:new\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"_id\": \"{{notificationId}}\",\n \"type\": \"message\",\n \"resourceType\": \"conversation\",\n \"deepLink\": \"/chat/conversations/{{conversationId}}\"\n}\n```" + } + }, + { + "name": "Listen notification:unread_counts", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{notificationsSocketUrl}}", + "description": "Socket.IO event: notification:unread_counts\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"total\": 10,\n \"interactions\": 4,\n \"messages\": 3,\n \"follows\": 1,\n \"followRequests\": 2,\n \"collaboration\": 1,\n \"system\": 0\n}\n```" + } + }, + { + "name": "Legacy Notification Events", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{notificationsSocketUrl}}", + "description": "Socket.IO event: notification_created / notifications_unread_count\nDirection: Backend -> Client\nKept for backward compatibility. New Flutter code should prefer notification:new and notification:unread_counts.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"notification_created\": {\n \"_id\": \"{{notificationId}}\"\n },\n \"notifications_unread_count\": {\n \"unreadCount\": 10\n }\n}\n```" + } + } + ] + }, { "name": "Feed", "item": [ @@ -9782,6 +10158,22 @@ { "key": "notificationCategory", "value": "interactions" + }, + { + "key": "socketBaseUrl", + "value": "http://localhost:4000" + }, + { + "key": "chatSocketUrl", + "value": "{{socketBaseUrl}}/chat" + }, + { + "key": "notificationsSocketUrl", + "value": "{{socketBaseUrl}}/notifications" + }, + { + "key": "socketAccessToken", + "value": "{{accessToken}}" } ] } diff --git a/postman/Oudelaa-Dashboard.postman_collection.json b/postman/Oudelaa-Dashboard.postman_collection.json index f4270c6..5743050 100644 --- a/postman/Oudelaa-Dashboard.postman_collection.json +++ b/postman/Oudelaa-Dashboard.postman_collection.json @@ -4684,6 +4684,382 @@ } ] }, + { + "name": "Realtime Socket.IO", + "description": "Socket.IO realtime contract for Flutter. These examples document emits/listeners; they are not REST endpoints.", + "item": [ + { + "name": "Connection - Chat Namespace", + "request": { + "method": "GET", + "header": [ + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket type: Socket.IO\nNamespace: /chat\nLocal URL: {{socketBaseUrl}}/chat\nProduction URL: https://YOUR_DOMAIN/chat\nFlutter auth: auth: { token: accessToken }\nOn connect, backend joins user room user:{userId}." + } + }, + { + "name": "Connection - Notifications Namespace", + "request": { + "method": "GET", + "header": [ + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{notificationsSocketUrl}}", + "description": "Socket type: Socket.IO\nNamespace: /notifications\nLocal URL: {{socketBaseUrl}}/notifications\nProduction URL: https://YOUR_DOMAIN/notifications\nFlutter auth: auth: { token: accessToken }\nOn connect, backend joins user room user:{userId}." + } + }, + { + "name": "Emit join_conversation", + "request": { + "method": "POST", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: join_conversation\nDirection: Client -> Backend\nServer confirms with joined_conversation.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\"\n}\n```", + "body": { + "mode": "raw", + "raw": "{\n \"conversationId\": \"{{conversationId}}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Emit leave_conversation", + "request": { + "method": "POST", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: leave_conversation\nDirection: Client -> Backend\nServer confirms with left_conversation. Socket remains connected.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\"\n}\n```", + "body": { + "mode": "raw", + "raw": "{\n \"conversationId\": \"{{conversationId}}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Emit send_message", + "request": { + "method": "POST", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: send_message\nDirection: Client -> Backend\nServer emits new_message to conversation:{conversationId}. REST send also emits new_message once.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\",\n \"content\": \"Hello\",\n \"messageType\": \"text\"\n}\n```", + "body": { + "mode": "raw", + "raw": "{\n \"conversationId\": \"{{conversationId}}\",\n \"content\": \"Hello\",\n \"messageType\": \"text\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Emit typing", + "request": { + "method": "POST", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: typing\nDirection: Client -> Backend\nBackend validates membership and broadcasts typing to the conversation room excluding sender.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\",\n \"isTyping\": true\n}\n```", + "body": { + "mode": "raw", + "raw": "{\n \"conversationId\": \"{{conversationId}}\",\n \"isTyping\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Emit mark_delivered", + "request": { + "method": "POST", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: mark_delivered\nDirection: Client -> Backend\nBackend stores deliveredBy once per user and emits message_delivered.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\",\n \"messageId\": \"{{messageId}}\"\n}\n```", + "body": { + "mode": "raw", + "raw": "{\n \"conversationId\": \"{{conversationId}}\",\n \"messageId\": \"{{messageId}}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Emit mark_seen", + "request": { + "method": "POST", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: mark_seen\nDirection: Client -> Backend\nBackend stores seenBy, clears unread count, and emits message_seen.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\",\n \"messageId\": \"{{messageId}}\"\n}\n```", + "body": { + "mode": "raw", + "raw": "{\n \"conversationId\": \"{{conversationId}}\",\n \"messageId\": \"{{messageId}}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Listen joined_conversation", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: joined_conversation\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\"\n}\n```" + } + }, + { + "name": "Listen left_conversation", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: left_conversation\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\"\n}\n```" + } + }, + { + "name": "Listen new_message", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: new_message\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"_id\": \"{{messageId}}\",\n \"conversationId\": \"{{conversationId}}\",\n \"senderId\": \"{{userId}}\",\n \"content\": \"Hello\",\n \"messageType\": \"text\"\n}\n```" + } + }, + { + "name": "Listen message_delivered", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: message_delivered\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\",\n \"messageId\": \"{{messageId}}\",\n \"userId\": \"{{targetUserId}}\",\n \"deliveredAt\": \"2026-06-07T10:00:00.000Z\"\n}\n```" + } + }, + { + "name": "Listen message_seen", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: message_seen\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\",\n \"messageId\": \"{{messageId}}\",\n \"userId\": \"{{targetUserId}}\",\n \"seenAt\": \"2026-06-07T10:00:00.000Z\"\n}\n```" + } + }, + { + "name": "Listen presence", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: presence\nDirection: Backend -> Client\nonline is kept for backward compatibility; prefer isOnline.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"userId\": \"{{targetUserId}}\",\n \"isOnline\": true,\n \"online\": true,\n \"lastSeenAt\": null\n}\n```" + } + }, + { + "name": "Listen socket_error", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: socket_error\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"event\": \"join_conversation\",\n \"message\": \"Not allowed\",\n \"code\": \"FORBIDDEN\"\n}\n```" + } + }, + { + "name": "Listen notification:new", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{notificationsSocketUrl}}", + "description": "Socket.IO event: notification:new\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"_id\": \"{{notificationId}}\",\n \"type\": \"message\",\n \"resourceType\": \"conversation\",\n \"deepLink\": \"/chat/conversations/{{conversationId}}\"\n}\n```" + } + }, + { + "name": "Listen notification:unread_counts", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{notificationsSocketUrl}}", + "description": "Socket.IO event: notification:unread_counts\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"total\": 10,\n \"interactions\": 4,\n \"messages\": 3,\n \"follows\": 1,\n \"followRequests\": 2,\n \"collaboration\": 1,\n \"system\": 0\n}\n```" + } + }, + { + "name": "Legacy Notification Events", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{notificationsSocketUrl}}", + "description": "Socket.IO event: notification_created / notifications_unread_count\nDirection: Backend -> Client\nKept for backward compatibility. New Flutter code should prefer notification:new and notification:unread_counts.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"notification_created\": {\n \"_id\": \"{{notificationId}}\"\n },\n \"notifications_unread_count\": {\n \"unreadCount\": 10\n }\n}\n```" + } + } + ] + }, { "name": "Support Tickets", "item": [ @@ -5003,6 +5379,382 @@ ] } ] + }, + { + "name": "Realtime Socket.IO", + "description": "Socket.IO realtime contract for Flutter. These examples document emits/listeners; they are not REST endpoints.", + "item": [ + { + "name": "Connection - Chat Namespace", + "request": { + "method": "GET", + "header": [ + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket type: Socket.IO\nNamespace: /chat\nLocal URL: {{socketBaseUrl}}/chat\nProduction URL: https://YOUR_DOMAIN/chat\nFlutter auth: auth: { token: accessToken }\nOn connect, backend joins user room user:{userId}." + } + }, + { + "name": "Connection - Notifications Namespace", + "request": { + "method": "GET", + "header": [ + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{notificationsSocketUrl}}", + "description": "Socket type: Socket.IO\nNamespace: /notifications\nLocal URL: {{socketBaseUrl}}/notifications\nProduction URL: https://YOUR_DOMAIN/notifications\nFlutter auth: auth: { token: accessToken }\nOn connect, backend joins user room user:{userId}." + } + }, + { + "name": "Emit join_conversation", + "request": { + "method": "POST", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: join_conversation\nDirection: Client -> Backend\nServer confirms with joined_conversation.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\"\n}\n```", + "body": { + "mode": "raw", + "raw": "{\n \"conversationId\": \"{{conversationId}}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Emit leave_conversation", + "request": { + "method": "POST", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: leave_conversation\nDirection: Client -> Backend\nServer confirms with left_conversation. Socket remains connected.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\"\n}\n```", + "body": { + "mode": "raw", + "raw": "{\n \"conversationId\": \"{{conversationId}}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Emit send_message", + "request": { + "method": "POST", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: send_message\nDirection: Client -> Backend\nServer emits new_message to conversation:{conversationId}. REST send also emits new_message once.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\",\n \"content\": \"Hello\",\n \"messageType\": \"text\"\n}\n```", + "body": { + "mode": "raw", + "raw": "{\n \"conversationId\": \"{{conversationId}}\",\n \"content\": \"Hello\",\n \"messageType\": \"text\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Emit typing", + "request": { + "method": "POST", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: typing\nDirection: Client -> Backend\nBackend validates membership and broadcasts typing to the conversation room excluding sender.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\",\n \"isTyping\": true\n}\n```", + "body": { + "mode": "raw", + "raw": "{\n \"conversationId\": \"{{conversationId}}\",\n \"isTyping\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Emit mark_delivered", + "request": { + "method": "POST", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: mark_delivered\nDirection: Client -> Backend\nBackend stores deliveredBy once per user and emits message_delivered.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\",\n \"messageId\": \"{{messageId}}\"\n}\n```", + "body": { + "mode": "raw", + "raw": "{\n \"conversationId\": \"{{conversationId}}\",\n \"messageId\": \"{{messageId}}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Emit mark_seen", + "request": { + "method": "POST", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: mark_seen\nDirection: Client -> Backend\nBackend stores seenBy, clears unread count, and emits message_seen.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\",\n \"messageId\": \"{{messageId}}\"\n}\n```", + "body": { + "mode": "raw", + "raw": "{\n \"conversationId\": \"{{conversationId}}\",\n \"messageId\": \"{{messageId}}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Listen joined_conversation", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: joined_conversation\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\"\n}\n```" + } + }, + { + "name": "Listen left_conversation", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: left_conversation\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\"\n}\n```" + } + }, + { + "name": "Listen new_message", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: new_message\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"_id\": \"{{messageId}}\",\n \"conversationId\": \"{{conversationId}}\",\n \"senderId\": \"{{userId}}\",\n \"content\": \"Hello\",\n \"messageType\": \"text\"\n}\n```" + } + }, + { + "name": "Listen message_delivered", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: message_delivered\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\",\n \"messageId\": \"{{messageId}}\",\n \"userId\": \"{{targetUserId}}\",\n \"deliveredAt\": \"2026-06-07T10:00:00.000Z\"\n}\n```" + } + }, + { + "name": "Listen message_seen", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: message_seen\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\",\n \"messageId\": \"{{messageId}}\",\n \"userId\": \"{{targetUserId}}\",\n \"seenAt\": \"2026-06-07T10:00:00.000Z\"\n}\n```" + } + }, + { + "name": "Listen presence", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: presence\nDirection: Backend -> Client\nonline is kept for backward compatibility; prefer isOnline.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"userId\": \"{{targetUserId}}\",\n \"isOnline\": true,\n \"online\": true,\n \"lastSeenAt\": null\n}\n```" + } + }, + { + "name": "Listen socket_error", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: socket_error\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"event\": \"join_conversation\",\n \"message\": \"Not allowed\",\n \"code\": \"FORBIDDEN\"\n}\n```" + } + }, + { + "name": "Listen notification:new", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{notificationsSocketUrl}}", + "description": "Socket.IO event: notification:new\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"_id\": \"{{notificationId}}\",\n \"type\": \"message\",\n \"resourceType\": \"conversation\",\n \"deepLink\": \"/chat/conversations/{{conversationId}}\"\n}\n```" + } + }, + { + "name": "Listen notification:unread_counts", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{notificationsSocketUrl}}", + "description": "Socket.IO event: notification:unread_counts\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"total\": 10,\n \"interactions\": 4,\n \"messages\": 3,\n \"follows\": 1,\n \"followRequests\": 2,\n \"collaboration\": 1,\n \"system\": 0\n}\n```" + } + }, + { + "name": "Legacy Notification Events", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{notificationsSocketUrl}}", + "description": "Socket.IO event: notification_created / notifications_unread_count\nDirection: Backend -> Client\nKept for backward compatibility. New Flutter code should prefer notification:new and notification:unread_counts.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"notification_created\": {\n \"_id\": \"{{notificationId}}\"\n },\n \"notifications_unread_count\": {\n \"unreadCount\": 10\n }\n}\n```" + } + } + ] } ], "variable": [ @@ -5576,6 +6328,22 @@ { "key": "notificationCategory", "value": "interactions" + }, + { + "key": "socketBaseUrl", + "value": "http://localhost:4000" + }, + { + "key": "chatSocketUrl", + "value": "{{socketBaseUrl}}/chat" + }, + { + "key": "notificationsSocketUrl", + "value": "{{socketBaseUrl}}/notifications" + }, + { + "key": "socketAccessToken", + "value": "{{accessToken}}" } ] } diff --git a/postman/Oudelaa-Mobile.postman_collection.json b/postman/Oudelaa-Mobile.postman_collection.json index 0358bb0..5159f65 100644 --- a/postman/Oudelaa-Mobile.postman_collection.json +++ b/postman/Oudelaa-Mobile.postman_collection.json @@ -5025,6 +5025,382 @@ } ] }, + { + "name": "Realtime Socket.IO", + "description": "Socket.IO realtime contract for Flutter. These examples document emits/listeners; they are not REST endpoints.", + "item": [ + { + "name": "Connection - Chat Namespace", + "request": { + "method": "GET", + "header": [ + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket type: Socket.IO\nNamespace: /chat\nLocal URL: {{socketBaseUrl}}/chat\nProduction URL: https://YOUR_DOMAIN/chat\nFlutter auth: auth: { token: accessToken }\nOn connect, backend joins user room user:{userId}." + } + }, + { + "name": "Connection - Notifications Namespace", + "request": { + "method": "GET", + "header": [ + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{notificationsSocketUrl}}", + "description": "Socket type: Socket.IO\nNamespace: /notifications\nLocal URL: {{socketBaseUrl}}/notifications\nProduction URL: https://YOUR_DOMAIN/notifications\nFlutter auth: auth: { token: accessToken }\nOn connect, backend joins user room user:{userId}." + } + }, + { + "name": "Emit join_conversation", + "request": { + "method": "POST", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: join_conversation\nDirection: Client -> Backend\nServer confirms with joined_conversation.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\"\n}\n```", + "body": { + "mode": "raw", + "raw": "{\n \"conversationId\": \"{{conversationId}}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Emit leave_conversation", + "request": { + "method": "POST", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: leave_conversation\nDirection: Client -> Backend\nServer confirms with left_conversation. Socket remains connected.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\"\n}\n```", + "body": { + "mode": "raw", + "raw": "{\n \"conversationId\": \"{{conversationId}}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Emit send_message", + "request": { + "method": "POST", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: send_message\nDirection: Client -> Backend\nServer emits new_message to conversation:{conversationId}. REST send also emits new_message once.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\",\n \"content\": \"Hello\",\n \"messageType\": \"text\"\n}\n```", + "body": { + "mode": "raw", + "raw": "{\n \"conversationId\": \"{{conversationId}}\",\n \"content\": \"Hello\",\n \"messageType\": \"text\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Emit typing", + "request": { + "method": "POST", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: typing\nDirection: Client -> Backend\nBackend validates membership and broadcasts typing to the conversation room excluding sender.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\",\n \"isTyping\": true\n}\n```", + "body": { + "mode": "raw", + "raw": "{\n \"conversationId\": \"{{conversationId}}\",\n \"isTyping\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Emit mark_delivered", + "request": { + "method": "POST", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: mark_delivered\nDirection: Client -> Backend\nBackend stores deliveredBy once per user and emits message_delivered.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\",\n \"messageId\": \"{{messageId}}\"\n}\n```", + "body": { + "mode": "raw", + "raw": "{\n \"conversationId\": \"{{conversationId}}\",\n \"messageId\": \"{{messageId}}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Emit mark_seen", + "request": { + "method": "POST", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: mark_seen\nDirection: Client -> Backend\nBackend stores seenBy, clears unread count, and emits message_seen.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\",\n \"messageId\": \"{{messageId}}\"\n}\n```", + "body": { + "mode": "raw", + "raw": "{\n \"conversationId\": \"{{conversationId}}\",\n \"messageId\": \"{{messageId}}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Listen joined_conversation", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: joined_conversation\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\"\n}\n```" + } + }, + { + "name": "Listen left_conversation", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: left_conversation\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\"\n}\n```" + } + }, + { + "name": "Listen new_message", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: new_message\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"_id\": \"{{messageId}}\",\n \"conversationId\": \"{{conversationId}}\",\n \"senderId\": \"{{userId}}\",\n \"content\": \"Hello\",\n \"messageType\": \"text\"\n}\n```" + } + }, + { + "name": "Listen message_delivered", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: message_delivered\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\",\n \"messageId\": \"{{messageId}}\",\n \"userId\": \"{{targetUserId}}\",\n \"deliveredAt\": \"2026-06-07T10:00:00.000Z\"\n}\n```" + } + }, + { + "name": "Listen message_seen", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: message_seen\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"conversationId\": \"{{conversationId}}\",\n \"messageId\": \"{{messageId}}\",\n \"userId\": \"{{targetUserId}}\",\n \"seenAt\": \"2026-06-07T10:00:00.000Z\"\n}\n```" + } + }, + { + "name": "Listen presence", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: presence\nDirection: Backend -> Client\nonline is kept for backward compatibility; prefer isOnline.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"userId\": \"{{targetUserId}}\",\n \"isOnline\": true,\n \"online\": true,\n \"lastSeenAt\": null\n}\n```" + } + }, + { + "name": "Listen socket_error", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{chatSocketUrl}}", + "description": "Socket.IO event: socket_error\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"event\": \"join_conversation\",\n \"message\": \"Not allowed\",\n \"code\": \"FORBIDDEN\"\n}\n```" + } + }, + { + "name": "Listen notification:new", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{notificationsSocketUrl}}", + "description": "Socket.IO event: notification:new\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"_id\": \"{{notificationId}}\",\n \"type\": \"message\",\n \"resourceType\": \"conversation\",\n \"deepLink\": \"/chat/conversations/{{conversationId}}\"\n}\n```" + } + }, + { + "name": "Listen notification:unread_counts", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{notificationsSocketUrl}}", + "description": "Socket.IO event: notification:unread_counts\nDirection: Backend -> Client\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"total\": 10,\n \"interactions\": 4,\n \"messages\": 3,\n \"follows\": 1,\n \"followRequests\": 2,\n \"collaboration\": 1,\n \"system\": 0\n}\n```" + } + }, + { + "name": "Legacy Notification Events", + "request": { + "method": "GET", + "header": [ + { + "key": "Socket.IO", + "value": "Use socket_io_client / Socket.IO, not HTTP REST" + }, + { + "key": "Auth", + "value": "auth: { token: '{{socketAccessToken}}' }" + } + ], + "url": "{{notificationsSocketUrl}}", + "description": "Socket.IO event: notification_created / notifications_unread_count\nDirection: Backend -> Client\nKept for backward compatibility. New Flutter code should prefer notification:new and notification:unread_counts.\nConnection auth:\nauth: { token: '{{socketAccessToken}}' }\nPayload example:\n```json\n{\n \"notification_created\": {\n \"_id\": \"{{notificationId}}\"\n },\n \"notifications_unread_count\": {\n \"unreadCount\": 10\n }\n}\n```" + } + } + ] + }, { "name": "Feed", "item": [ @@ -6998,6 +7374,22 @@ { "key": "notificationCategory", "value": "interactions" + }, + { + "key": "socketBaseUrl", + "value": "http://localhost:4000" + }, + { + "key": "chatSocketUrl", + "value": "{{socketBaseUrl}}/chat" + }, + { + "key": "notificationsSocketUrl", + "value": "{{socketBaseUrl}}/notifications" + }, + { + "key": "socketAccessToken", + "value": "{{accessToken}}" } ] } diff --git a/src/modules/users/dto/artist-dashboard-response.dto.ts b/src/modules/users/dto/artist-dashboard-response.dto.ts new file mode 100644 index 0000000..30b7fd7 --- /dev/null +++ b/src/modules/users/dto/artist-dashboard-response.dto.ts @@ -0,0 +1,76 @@ +export type ArtistDashboardProfile = { + _id: string; + coverImage: string; + avatar: string; + name: string; + stageName: string; + bio: string; + isVerified: boolean; +}; + +export type ArtistDashboardStats = { + followersCount: number; + followingCount: number; + postsCount: number; + collaborationsCount: number; + viewCount: number; + playCount: number; + listenCount: number; + likesCount: number; + commentsCount: number; + savesCount: number; + sharesCount: number; + engagementRate: number; + scorePercentage: number; + earnings: number; +}; + +export type ArtistDashboardAudience = { + followers: number; + nonFollowers: number; + newFollowersThisWeek: number; + newFollowersThisMonth: number; +}; + +export type ArtistDashboardTopContentItem = { + _id: string; + postType: string; + thumbnailUrl: string; + displayUrl: string; + content: string; + viewCount: number; + playCount: number; + likesCount: number; + commentsCount: number; + savesCount: number; + engagementScore: number; + createdAt: string | Date; +}; + +export type ArtistDashboardRecentActivityItem = { + _id: string; + type: string; + title: string; + previewText: string; + resourceType: string; + referenceId: string | null; + deepLink: string; + createdAt: string | Date; +}; + +export type ArtistDashboardMeta = { + generatedAt: string; + chartAvailable: boolean; + earningsAvailable: boolean; + audienceAnalyticsAvailable: boolean; +}; + +export type ArtistDashboardResponse = { + profile: ArtistDashboardProfile; + stats: ArtistDashboardStats; + audience: ArtistDashboardAudience; + engagementChart: unknown[]; + topContent: ArtistDashboardTopContentItem[]; + recentActivity: ArtistDashboardRecentActivityItem[]; + meta: ArtistDashboardMeta; +}; diff --git a/src/modules/users/users.controller.spec.ts b/src/modules/users/users.controller.spec.ts new file mode 100644 index 0000000..1cb29a2 --- /dev/null +++ b/src/modules/users/users.controller.spec.ts @@ -0,0 +1,15 @@ +import { UsersController } from './users.controller'; + +describe('UsersController artist dashboard', () => { + it('uses the authenticated user id for the private dashboard endpoint', async () => { + const usersService = { + getMyDashboard: jest.fn().mockResolvedValue({ profile: { _id: 'user-1' } }), + }; + const controller = new UsersController(usersService as any); + + const result = await controller.getMyDashboard({ sub: 'user-1' } as any); + + expect(usersService.getMyDashboard).toHaveBeenCalledWith('user-1'); + expect(result).toEqual({ profile: { _id: 'user-1' } }); + }); +}); diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index 0d64103..b9c400b 100644 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -281,6 +281,13 @@ export class UsersController { return this.usersService.discoverTalents(query); } + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Get('me/dashboard') + async getMyDashboard(@CurrentUser() user: JwtPayload) { + return this.usersService.getMyDashboard(user.sub); + } + @ApiBearerAuth() @UseGuards(JwtAuthGuard) @Get(':id/profile-overview') diff --git a/src/modules/users/users.service.spec.ts b/src/modules/users/users.service.spec.ts new file mode 100644 index 0000000..d09d157 --- /dev/null +++ b/src/modules/users/users.service.spec.ts @@ -0,0 +1,185 @@ +import { BadRequestException } from '@nestjs/common'; +import { Types } from 'mongoose'; +import { UsersService } from './users.service'; + +const chain = (value: T) => ({ + sort: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + project: jest.fn().mockReturnThis(), + toArray: jest.fn().mockResolvedValue(value), +}); + +const createService = (options: { + user?: Record | null; + postStats?: Record; + topContent?: Record[]; + collaborationsCount?: number; + newFollowersThisWeek?: number; + newFollowersThisMonth?: number; + recentActivity?: Record[]; +} = {}) => { + const userId = new Types.ObjectId().toString(); + const user = options.user ?? { + _id: new Types.ObjectId(userId), + name: 'Artist', + stageName: 'Stage', + bio: 'Bio', + avatar: '', + coverImage: '', + followersCount: 10, + followingCount: 4, + isVerified: true, + isDisabled: false, + toObject() { + return { + _id: userId, + name: this.name, + stageName: this.stageName, + bio: this.bio, + avatar: this.avatar, + coverImage: this.coverImage, + followersCount: this.followersCount, + followingCount: this.followingCount, + isVerified: this.isVerified, + }; + }, + }; + + const postsAggregate = jest + .fn() + .mockReturnValueOnce({ + toArray: jest.fn().mockResolvedValue(options.postStats ? [options.postStats] : []), + }) + .mockReturnValueOnce({ + toArray: jest.fn().mockResolvedValue(options.topContent ?? []), + }); + + const postsCollection = { + aggregate: postsAggregate, + countDocuments: jest.fn().mockResolvedValue(options.collaborationsCount ?? 0), + }; + const followsCollection = { + countDocuments: jest + .fn() + .mockResolvedValueOnce(options.newFollowersThisWeek ?? 0) + .mockResolvedValueOnce(options.newFollowersThisMonth ?? 0), + }; + const notificationsCollection = { + find: jest.fn().mockReturnValue(chain(options.recentActivity ?? [])), + }; + const connection = { + collection: jest.fn((name: string) => { + if (name === 'posts') { + return postsCollection; + } + if (name === 'follows') { + return followsCollection; + } + if (name === 'notifications') { + return notificationsCollection; + } + throw new Error(`Unexpected collection ${name}`); + }), + }; + const usersRepository = { + findById: jest.fn().mockResolvedValue(user), + }; + const service = new UsersService( + connection as any, + usersRepository as any, + { logSuperAdminAction: jest.fn() } as any, + { get: jest.fn() } as any, + { saveFile: jest.fn(), deleteFile: jest.fn() } as any, + ); + + return { service, userId, usersRepository, postsCollection, followsCollection }; +}; + +describe('UsersService artist dashboard', () => { + it('returns zeros and safe empty arrays for an authenticated user with no posts', async () => { + const { service, userId } = createService(); + + const result = await service.getMyDashboard(userId); + + expect(result.profile._id).toBe(userId); + expect(result.stats.postsCount).toBe(0); + expect(result.stats.viewCount).toBe(0); + expect(result.stats.playCount).toBe(0); + expect(result.stats.listenCount).toBe(0); + expect(result.stats.engagementRate).toBe(0); + expect(result.stats.earnings).toBe(0); + expect(result.stats.scorePercentage).toBe(0); + expect(result.audience.followers).toBe(10); + expect(result.audience.nonFollowers).toBe(0); + expect(result.engagementChart).toEqual([]); + expect(result.topContent).toEqual([]); + expect(result.meta.chartAvailable).toBe(false); + expect(result.meta.earningsAvailable).toBe(false); + }); + + it('aggregates owned posts and calculates engagement rate safely', async () => { + const postId = new Types.ObjectId(); + const { service, userId } = createService({ + postStats: { + postsCount: 2, + viewCount: 100, + playCount: 50, + likesCount: 12, + commentsCount: 3, + savesCount: 2, + sharesCount: 1, + }, + topContent: [ + { + _id: postId, + postType: 'audio', + content: 'Track', + thumbnailUrl: '', + audioUrl: '/uploads/posts/audios/audio.mp3', + viewCount: 100, + playCount: 50, + likesCount: 12, + commentsCount: 3, + savesCount: 2, + engagementScore: 18, + createdAt: new Date('2026-06-08T00:00:00.000Z'), + }, + ], + collaborationsCount: 2, + newFollowersThisWeek: 1, + newFollowersThisMonth: 3, + }); + + const result = await service.getMyDashboard(userId); + + expect(result.stats.postsCount).toBe(2); + expect(result.stats.viewCount).toBe(100); + expect(result.stats.playCount).toBe(50); + expect(result.stats.listenCount).toBe(50); + expect(result.stats.likesCount).toBe(12); + expect(result.stats.commentsCount).toBe(3); + expect(result.stats.savesCount).toBe(2); + expect(result.stats.sharesCount).toBe(1); + expect(result.stats.collaborationsCount).toBe(2); + expect(result.stats.engagementRate).toBe(12); + expect(result.audience.newFollowersThisWeek).toBe(1); + expect(result.audience.newFollowersThisMonth).toBe(3); + expect(result.topContent).toHaveLength(1); + expect(result.topContent[0]).toEqual( + expect.objectContaining({ + _id: postId.toString(), + postType: 'audio', + content: 'Track', + engagementScore: 18, + }), + ); + }); + + it('rejects invalid current user ids', async () => { + const { service } = createService(); + + await expect(service.getMyDashboard('not-object-id')).rejects.toBeInstanceOf( + BadRequestException, + ); + }); +}); diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index 46864ae..2990afa 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -13,12 +13,18 @@ import { ExperienceLevel } from '../../common/enums/experience-level.enum'; import { MusicRole } from '../../common/enums/music-role.enum'; import { hashValue } from '../../common/utils/hash.util'; import { buildPaginatedResponse } from '../../common/utils/pagination.util'; +import { resolveManagedFileUrl } from '../../common/utils/public-url.util'; import { resolveMongoSortDirection } from '../../common/utils/sort.util'; import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service'; import { AuditService } from '../audit/audit.service'; import { UserRole } from '../../common/enums/user-role.enum'; import { CreateUserDto } from './dto/create-user.dto'; import { AdminDisableUserDto } from './dto/admin-disable-user.dto'; +import { + ArtistDashboardRecentActivityItem, + ArtistDashboardResponse, + ArtistDashboardTopContentItem, +} from './dto/artist-dashboard-response.dto'; import { CreateAdminDto } from './dto/create-admin.dto'; import { MusicSetupDto } from './dto/music-setup.dto'; import { ProfileSetupDto } from './dto/profile-setup.dto'; @@ -36,6 +42,16 @@ type UploadedImageFile = { originalname?: string; }; +type UserPostStats = { + postsCount: number; + viewCount: number; + playCount: number; + likesCount: number; + commentsCount: number; + savesCount: number; + sharesCount: number; +}; + @Injectable() export class UsersService { constructor( @@ -790,6 +806,94 @@ export class UsersService { }; } + async getMyDashboard(currentUserId: string): Promise { + if (!Types.ObjectId.isValid(currentUserId)) { + throw new BadRequestException('Invalid user id'); + } + + const user = await this.findByIdOrFail(currentUserId); + if (user.isDisabled) { + throw new NotFoundException('User not found'); + } + + const objectUserId = new Types.ObjectId(currentUserId); + const now = new Date(); + const weekAgo = new Date(now); + weekAgo.setDate(weekAgo.getDate() - 7); + const monthAgo = new Date(now); + monthAgo.setDate(monthAgo.getDate() - 30); + + const [postStats, collaborationsCount, topContent, recentActivity, newFollowersThisWeek, newFollowersThisMonth] = + await Promise.all([ + this.aggregateUserPostStats(objectUserId), + this.countUserCollaborations(objectUserId), + this.findTopDashboardContent(objectUserId, 5), + this.findArtistRecentActivity(objectUserId, 10), + this.connection.collection('follows').countDocuments({ + followingId: objectUserId, + createdAt: { $gte: weekAgo }, + }), + this.connection.collection('follows').countDocuments({ + followingId: objectUserId, + createdAt: { $gte: monthAgo }, + }), + ]); + + const followersCount = this.toSafeNumber(user.followersCount); + const followingCount = this.toSafeNumber(user.followingCount); + const totalEngagement = + postStats.likesCount + postStats.commentsCount + postStats.savesCount + postStats.sharesCount; + const totalReach = postStats.viewCount + postStats.playCount; + const engagementRate = this.roundPercent((totalEngagement / Math.max(totalReach, 1)) * 100); + const userObject = typeof user.toObject === 'function' ? (user.toObject() as Record) : (user as any); + + return { + profile: { + _id: currentUserId, + coverImage: userObject.coverImage ?? '', + avatar: userObject.avatar ?? '', + name: userObject.name ?? '', + stageName: userObject.stageName ?? '', + bio: userObject.bio ?? '', + isVerified: userObject.isVerified ?? false, + }, + stats: { + followersCount, + followingCount, + postsCount: postStats.postsCount, + collaborationsCount, + viewCount: postStats.viewCount, + playCount: postStats.playCount, + listenCount: postStats.playCount, + likesCount: postStats.likesCount, + commentsCount: postStats.commentsCount, + savesCount: postStats.savesCount, + sharesCount: postStats.sharesCount, + engagementRate, + // TODO: Replace with real artist score logic when product scoring rules exist. + scorePercentage: 0, + // TODO: Replace with real monetization calculation when earnings module exists. + earnings: 0, + }, + audience: { + followers: followersCount, + // TODO: Requires viewer-level analytics/events to calculate non-followers. + nonFollowers: 0, + newFollowersThisWeek, + newFollowersThisMonth, + }, + engagementChart: [], + topContent, + recentActivity, + meta: { + generatedAt: now.toISOString(), + chartAvailable: false, + earningsAvailable: false, + audienceAnalyticsAvailable: false, + }, + }; + } + async searchUsersForSuperAdmin(query: UserQueryDto): Promise<{ items: UserDocument[]; page: number; @@ -819,6 +923,193 @@ export class UsersService { }); } + private async aggregateUserPostStats(authorId: Types.ObjectId): Promise { + const [row] = await this.connection + .collection('posts') + .aggregate([ + { + $match: { + authorId, + isDeleted: { $ne: true }, + isArchived: { $ne: true }, + moderationStatus: { $ne: ModerationStatus.HIDDEN }, + }, + }, + { + $group: { + _id: null, + postsCount: { $sum: 1 }, + viewCount: { $sum: { $ifNull: ['$viewCount', 0] } }, + playCount: { $sum: { $ifNull: ['$playCount', 0] } }, + likesCount: { $sum: { $ifNull: ['$likesCount', 0] } }, + commentsCount: { $sum: { $ifNull: ['$commentsCount', 0] } }, + savesCount: { $sum: { $ifNull: ['$savesCount', 0] } }, + sharesCount: { $sum: { $ifNull: ['$shareCount', 0] } }, + }, + }, + { $project: { _id: 0 } }, + ]) + .toArray(); + + return { + postsCount: this.toSafeNumber(row?.postsCount), + viewCount: this.toSafeNumber(row?.viewCount), + playCount: this.toSafeNumber(row?.playCount), + likesCount: this.toSafeNumber(row?.likesCount), + commentsCount: this.toSafeNumber(row?.commentsCount), + savesCount: this.toSafeNumber(row?.savesCount), + sharesCount: this.toSafeNumber(row?.sharesCount), + }; + } + + private async countUserCollaborations(userId: Types.ObjectId): Promise { + return this.connection.collection('posts').countDocuments({ + isDeleted: { $ne: true }, + isArchived: { $ne: true }, + moderationStatus: { $ne: ModerationStatus.HIDDEN }, + $or: [ + { authorId: userId, 'taggedUserIds.0': { $exists: true } }, + { authorId: { $ne: userId }, taggedUserIds: userId }, + { collaboratorIds: userId }, + ], + }); + } + + private async findTopDashboardContent( + authorId: Types.ObjectId, + limit: number, + ): Promise { + const rows = await this.connection + .collection('posts') + .aggregate>([ + { + $match: { + authorId, + isDeleted: { $ne: true }, + isArchived: { $ne: true }, + moderationStatus: { $ne: ModerationStatus.HIDDEN }, + }, + }, + { + $addFields: { + engagementScore: { + $add: [ + { $ifNull: ['$likesCount', 0] }, + { $ifNull: ['$commentsCount', 0] }, + { $ifNull: ['$savesCount', 0] }, + { $ifNull: ['$shareCount', 0] }, + ], + }, + }, + }, + { $sort: { engagementScore: -1, viewCount: -1, playCount: -1, createdAt: -1 } }, + { $limit: Math.max(1, limit) }, + { + $project: { + postType: 1, + thumbnailUrl: 1, + imageUrls: 1, + imageItems: 1, + videoUrl: 1, + audioUrl: 1, + content: 1, + viewCount: 1, + playCount: 1, + likesCount: 1, + commentsCount: 1, + savesCount: 1, + engagementScore: 1, + createdAt: 1, + }, + }, + ]) + .toArray(); + + return rows.map((post) => { + const thumbnailUrl = this.resolveManagedStringUrl(post.thumbnailUrl); + const fallbackDisplayUrl = this.resolveTopContentDisplayUrl(post); + return { + _id: post._id?.toString?.() ?? '', + postType: post.postType ?? '', + thumbnailUrl, + displayUrl: thumbnailUrl || fallbackDisplayUrl, + content: post.content ?? '', + viewCount: this.toSafeNumber(post.viewCount), + playCount: this.toSafeNumber(post.playCount), + likesCount: this.toSafeNumber(post.likesCount), + commentsCount: this.toSafeNumber(post.commentsCount), + savesCount: this.toSafeNumber(post.savesCount), + engagementScore: this.toSafeNumber(post.engagementScore), + createdAt: post.createdAt ?? '', + }; + }); + } + + private async findArtistRecentActivity( + userId: Types.ObjectId, + limit: number, + ): Promise { + const rows = await this.connection + .collection('notifications') + .find({ + $or: [{ recipientId: userId }, { actorId: userId }], + }) + .sort({ createdAt: -1 }) + .limit(Math.max(1, limit)) + .project({ + type: 1, + title: 1, + previewText: 1, + resourceType: 1, + referenceId: 1, + deepLink: 1, + createdAt: 1, + }) + .toArray(); + + return rows.map((activity) => ({ + _id: activity._id?.toString?.() ?? '', + type: activity.type ?? '', + title: activity.title ?? '', + previewText: activity.previewText ?? '', + resourceType: activity.resourceType ?? '', + referenceId: activity.referenceId?.toString?.() ?? null, + deepLink: activity.deepLink ?? '', + createdAt: activity.createdAt ?? '', + })); + } + + private resolveTopContentDisplayUrl(post: Record): string { + const imageItems = Array.isArray(post.imageItems) ? post.imageItems : []; + const imageUrls = Array.isArray(post.imageUrls) ? post.imageUrls : []; + const candidate = + imageItems.find((item) => typeof item?.url === 'string' && item.url)?.url ?? + imageUrls.find((url) => typeof url === 'string' && url) ?? + post.videoUrl ?? + post.audioUrl ?? + ''; + + return this.resolveManagedStringUrl(candidate); + } + + private resolveManagedStringUrl(value: unknown): string { + const resolved = resolveManagedFileUrl(typeof value === 'string' ? value : ''); + return typeof resolved === 'string' ? resolved : ''; + } + + private toSafeNumber(value: unknown): number { + const numeric = Number(value ?? 0); + return Number.isFinite(numeric) && numeric > 0 ? numeric : 0; + } + + private roundPercent(value: number): number { + if (!Number.isFinite(value)) { + return 0; + } + + return Math.round(value * 100) / 100; + } + private async assertTargetIsAdmin(userId: string): Promise { const user = await this.findByIdOrFail(userId); if (user.role !== UserRole.ADMIN) {