diff --git a/openapi.yaml b/openapi.yaml index 67683ee..ce44e78 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -114,6 +114,48 @@ paths: '429': $ref: '#/components/responses/RateLimited' + /auth/check-email: + post: + tags: [Auth] + summary: Check if an email is registered + description: | + Checks whether the provided email address is already registered in the system. + Returns a boolean indicating existence. + operationId: checkEmailExists + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - email + properties: + email: + type: string + format: email + description: The email address to check. + responses: + '200': + description: Email existence check successful. + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + exists: + type: boolean + message: + type: string + '400': + $ref: '#/components/responses/ValidationError' + '429': + $ref: '#/components/responses/RateLimited' + /auth/verify-email: post: description: Confirms a user's email address using the token sent at registration. @@ -353,7 +395,9 @@ paths: '429': $ref: '#/components/responses/RateLimited' - # [FIX-13] Added — was referenced in disconnect endpoint but missing from spec + # UNVERIFIED — no matching route found in src/routes/ as of audit date + # This endpoint (POST /auth/change-password) is referenced in the disconnect flow + # but does not exist in auth.routes.js. The controller has no changePassword handler. /auth/change-password: post: tags: [Auth] @@ -503,6 +547,63 @@ paths: '429': $ref: '#/components/responses/RateLimited' + # GitHub OAuth — implemented in auth.routes.js (controllers/auth.controller.js) + /auth/oauth/github: + get: + description: | + Redirects the browser to the GitHub OAuth consent screen. + The server constructs the GitHub authorization URL and issues a 302 redirect. + tags: [Auth] + summary: Start GitHub OAuth authorization + operationId: startGithubOAuth + security: [] + responses: + '302': + description: Redirect to GitHub consent screen. + '429': + $ref: '#/components/responses/RateLimited' + + /auth/oauth/github/callback: + get: + description: | + Handles the GitHub OAuth redirect. Exchanges the code for a Rythmify access token + and refresh token, then redirects the browser to the frontend callback URL with + tokens and user data as query parameters. + tags: [Auth] + summary: GitHub OAuth callback + operationId: githubOAuthCallback + security: [] + parameters: + - name: code + in: query + required: true + schema: + type: string + description: Authorization code from GitHub. + - name: state + in: query + required: true + schema: + type: string + description: CSRF state token. + - name: error + in: query + required: false + schema: + type: string + description: Present when the user denied access on GitHub. + responses: + '302': + description: | + Redirect to frontend callback URL. On success, query params include: + `access_token`, `expires_in`, `is_new_user`, `user_id`, `email`, + `display_name`, `role`, `is_verified`. On error, redirects with + `?error=github_denied` or `?error=invalid_params`. + '429': + $ref: '#/components/responses/RateLimited' + + # UNVERIFIED — no matching route found in src/routes/ as of audit date + # SoundCloud OAuth does not exist in auth.routes.js; only GitHub OAuth is implemented. /auth/oauth/soundcloud: get: description: Redirects the browser to the SoundCloud OAuth consent screen. @@ -565,6 +666,8 @@ paths: '429': $ref: '#/components/responses/RateLimited' + # UNVERIFIED — no matching route found in src/routes/ as of audit date + # No disconnectOAuthProvider route exists in auth.routes.js. /auth/connections/{provider}: delete: tags: [Auth] @@ -609,10 +712,87 @@ paths: '500': $ref: '#/components/responses/InternalError' + # DELETE /auth/me — implemented in auth.routes.js → auth.controller.deleteAccount + /auth/me: + delete: + description: | + Permanently deletes the authenticated user's account. + Requires the user's current password as confirmation. + Clears both `refreshToken` and `refresh_token` cookies on success. + tags: [Auth] + summary: Delete own account + operationId: deleteAccount + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - password + properties: + password: + type: string + description: Current password, required as confirmation before deletion. + responses: + '200': + description: Account deleted successfully. + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + success: + type: boolean + example: true + message: + type: string + example: Account deleted successfully. + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/Unauthorized' + '429': + $ref: '#/components/responses/RateLimited' + # ========================================================== # Module 2 — User Profile & Social Identity # ========================================================== + # GET /users/by-id/{user_id} — alias path for getUserById; same controller as /{user_id} + /users/by-id/{user_id}: + get: + description: | + Returns the public profile for a given user by UUID. This is an alias for + `GET /users/{user_id}` that avoids ambiguity with slug-based lookup. + Optional authentication allows the service to apply privacy rules. + tags: [Users] + summary: Get a user profile by UUID (alias path) + operationId: getUserByIdAlias + security: [] + parameters: + - $ref: '#/components/parameters/UserId' + responses: + '200': + description: User profile returned. + content: + application/json: + schema: + $ref: '#/components/schemas/PublicUserResponse' + '403': + description: Profile is private. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalError' + /users/{user_id}: get: description: Returns the public profile for a given user, respecting privacy settings. @@ -1108,48 +1288,6 @@ paths: '500': $ref: '#/components/responses/InternalError' - /users/me/content-settings: - get: - description: Returns the authenticated user's RSS and upload default settings. - tags: [Users] - summary: Get content settings - operationId: getMyContentSettings - responses: - '200': - description: Content settings returned. - content: - application/json: - schema: - $ref: '#/components/schemas/ContentSettingsResponse' - '401': - $ref: '#/components/responses/Unauthorized' - '500': - $ref: '#/components/responses/InternalError' - patch: - description: Partially updates the authenticated user's RSS and upload defaults. - tags: [Users] - summary: Update content settings - operationId: updateMyContentSettings - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateContentSettingsRequest' - responses: - '200': - description: Content settings updated. - content: - application/json: - schema: - $ref: '#/components/schemas/ContentSettingsResponse' - '400': - $ref: '#/components/responses/ValidationError' - '401': - $ref: '#/components/responses/Unauthorized' - '500': - $ref: '#/components/responses/InternalError' - /users/me/privacy-settings: get: description: Returns all five privacy toggle flags for the authenticated user. @@ -2929,6 +3067,9 @@ paths: # ========================================================== # Module 7 — Sets & Playlists # ========================================================== + # UNVERIFIED — no matching route found in src/routes/ as of audit date + # getUserPlaylists handler exists in playlists.controller.js but is not mounted + # in any route file. The GET /playlists endpoint (with ?mine&owner_user_id) covers this. /users/{user_id}/playlists: get: description: | @@ -2963,6 +3104,9 @@ paths: '429': $ref: '#/components/responses/RateLimited' + # UNVERIFIED — no matching route found in src/routes/ as of audit date + # getUserAlbums handler exists in playlists.controller.js but is not mounted + # in any route file. The GET /playlists endpoint (with ?is_album_view=true) covers this. /users/{user_id}/albums: get: description: | @@ -4715,54 +4859,60 @@ paths: - name: limit in: query schema: { type: integer, default: 20, minimum: 1, maximum: 100 } - - name: offset + description: Maximum number of items to return. + - name: cursor in: query - schema: { type: integer, default: 0, minimum: 0 } + schema: { type: string } + description: Opaque cursor for pagination. Pass the `nextCursor` value from the previous response to fetch the next page. responses: '200': description: Following feed returned. content: application/json: schema: - $ref: '#/components/schemas/FeedResponse' + $ref: '#/components/schemas/CursorFeedResponse' '401': $ref: '#/components/responses/Unauthorized' '429': $ref: '#/components/responses/RateLimited' - # [FIX-1] Added — Discover/Recommended tab - /feed/discover: + # Feed discovery tab — matches GET /feed/discovery in feed.routes.js + /feed/discovery: get: tags: [Feed] - summary: Discover tab — recommended and trending content stream + summary: Discover tab — personalised recommended content stream description: | - Returns a stream of recommended tracks based on the user's listening - history, followed artists, and genre preferences. For unauthenticated - users, returns globally trending content. + Returns a stream of recommended tracks based on the authenticated user's + listening history, followed artists, and genre preferences. Unlike `GET /home` which returns structured sections, this endpoint - returns a flat, paginated, mixed stream suitable for infinite scroll. + returns a flat, cursor-paginated stream suitable for infinite scroll. - Only public, non-hidden, ready tracks are included. + Only public, non-hidden, ready tracks are included. Authentication is required. operationId: getDiscoverFeed - security: [] parameters: - name: limit in: query schema: { type: integer, default: 20, minimum: 1, maximum: 100 } - - name: offset + description: Maximum number of items to return. + - name: cursor in: query - schema: { type: integer, default: 0, minimum: 0 } + schema: { type: string } + description: Opaque cursor for pagination. Pass the `nextCursor` value from the previous response. responses: '200': - description: Discover feed returned. + description: Discovery feed returned. content: application/json: schema: - $ref: '#/components/schemas/FeedResponse' + $ref: '#/components/schemas/CursorFeedResponse' + '401': + $ref: '#/components/responses/Unauthorized' '429': $ref: '#/components/responses/RateLimited' + # UNVERIFIED — no matching route found in src/routes/ as of audit date + # /feed/trending does not exist in feed.routes.js. Only /feed and /feed/discovery are defined. /feed/trending: get: tags: [Feed] @@ -4883,7 +5033,9 @@ paths: '429': $ref: '#/components/responses/RateLimited' - /search/suggestions: + # Route is GET /suggestions mounted at /api/v1 (not /search/suggestions) + # See search.routes.js: router.get('/suggestions', ...) + app.use(`${API}`, searchRoutes) + /suggestions: get: tags: [Search] summary: Search autocomplete suggestions @@ -5377,6 +5529,8 @@ paths: '500': $ref: '#/components/responses/InternalError' + # UNVERIFIED — no matching route found in src/routes/ as of audit date + # markAllNotificationsRead does not exist in notifications.routes.js. /notifications/read-all: post: description: Marks all unread notifications as read and resets the unread counter. @@ -5422,6 +5576,9 @@ paths: '500': $ref: '#/components/responses/InternalError' + # UNVERIFIED — no matching route found in src/routes/ as of audit date + # DELETE /notifications/{notification_id} does not exist in notifications.routes.js. + # Only PATCH /{notification_id}/read is implemented. /notifications/{notification_id}: delete: description: Permanently removes a single notification from the user's feed. @@ -10887,9 +11044,32 @@ components: nullable: true example: https://cdn.rythmify.com/avatars/ahmed.jpg + # Cursor-based feed response — used by GET /feed and GET /feed/discovery + # Controllers return { data, hasMore, nextCursor, message } — NOT offset-based pagination + CursorFeedResponse: + type: object + description: Cursor-paginated response for GET /feed and GET /feed/discovery. + required: [data, hasMore] + properties: + data: + type: array + items: + $ref: '#/components/schemas/FeedItem' + hasMore: + type: boolean + description: Whether more items are available after this page. + nextCursor: + type: string + nullable: true + description: Opaque cursor to pass as `cursor` param to fetch the next page. Null when no more pages. + message: + type: string + example: Activity feed fetched successfully. + + # Legacy alias kept for /feed/trending (UNVERIFIED — route does not exist in code) FeedResponse: type: object - description: Response for GET /feed and GET /feed/discover. + description: Offset-paginated feed response. Only referenced by the UNVERIFIED /feed/trending path. required: [data, pagination] properties: data: diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js index e8d994e..1191947 100644 --- a/src/controllers/auth.controller.js +++ b/src/controllers/auth.controller.js @@ -51,6 +51,22 @@ exports.register = async (req, res) => { return success(res, data, 'Account created. Please verify your email.', 201); }; +exports.checkEmailExists = async (req, res) => { + const { email } = req.body; + + if (!email || typeof email !== 'string' || !isValidEmail(email)) { + return error(res, 'VALIDATION_FAILED', 'Validation failed', 400, [ + { field: 'email', issue: 'Must be a valid email address' }, + ]); + } + + const data = await authService.checkEmailExists({ + email: email.trim().toLowerCase(), + }); + + return success(res, data, 'Email availability checked successfully.'); +}; + exports.verifyEmail = async (req, res) => { const { token } = req.body; diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js index 7817812..cd6f6be 100644 --- a/src/routes/auth.routes.js +++ b/src/routes/auth.routes.js @@ -7,6 +7,7 @@ const asyncHandler = require('../utils/async-handler'); const { authenticate } = require('../middleware/auth'); router.post('/register', authLimiter, validateRegister, asyncHandler(controller.register)); +router.post('/check-email', authLimiter, asyncHandler(controller.checkEmailExists)); router.post('/verify-email', authLimiter, asyncHandler(controller.verifyEmail)); router.post('/resend-verification', authLimiter, asyncHandler(controller.resendVerification)); router.post('/login', authLimiter, asyncHandler(controller.login)); diff --git a/src/services/auth.service.js b/src/services/auth.service.js index b65b6fb..219a04e 100644 --- a/src/services/auth.service.js +++ b/src/services/auth.service.js @@ -135,6 +135,19 @@ exports.register = async ({ created_at: user.created_at, }; }; + +exports.checkEmailExists = async ({ email }) => { + const normalizedEmail = email?.trim().toLowerCase(); + if (!normalizedEmail) { + throw new AppError('Email is required', 400, 'VALIDATION_FAILED'); + } + + const user = await userModel.findByEmail(normalizedEmail); + + return { + exists: Boolean(user), + }; +}; //===================================== // Login and token refresh //=====================================