From 1e6a2f588ac0c1fd6cb5959a57518533a66bd0cc Mon Sep 17 00:00:00 2001 From: Omar Hamza Date: Sat, 2 May 2026 15:48:50 +0300 Subject: [PATCH 1/2] feat(auth): add auth check-email endpoint implemented the POST /auth/check-email endpoint in the OpenAPI specification. This endpoint allows the frontend to check if an email address is already registered before proceeding with registration. Included the request schema for the email field and the response schema for the exists boolean. --- openapi.yaml | 42 ++++++++++++++++++++++++++++++ src/controllers/auth.controller.js | 16 ++++++++++++ src/routes/auth.routes.js | 1 + src/services/auth.service.js | 13 +++++++++ 4 files changed, 72 insertions(+) diff --git a/openapi.yaml b/openapi.yaml index 67683eec..091bfd48 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. diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js index e8d994ea..11919472 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 78178125..cd6f6be5 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 b65b6fbd..219a04eb 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 //===================================== From e912c7ed576b3941583732ac8c92d3ace1f68719 Mon Sep 17 00:00:00 2001 From: Omar Hamza Date: Sat, 2 May 2026 16:25:50 +0300 Subject: [PATCH 2/2] docs(api): audit and sync openapi spec with backend implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit aligns the `openapi.yaml` specification with the actual implementation in the Node.js backend based on a comprehensive source-code audit. **Key Changes:** - **Path Corrections**: Fixed discrepancies where spec paths did not match route mounts: - `/feed/discover` → `/feed/discovery` - `/search/suggestions` → `/suggestions` - **Missing Endpoints**: Documented endpoints that were implemented in the backend but missing from the spec: - **GitHub OAuth**: Added `/auth/oauth/github` and its callback. - **Account Deletion**: Added `DELETE /auth/me` with password confirmation. - **User Lookup**: Added `/users/by-id/{user_id}` alias path. - **Authentication & Pagination Sync**: - Updated `/feed` and `/feed/discovery` to reflect mandatory authentication (previously marked as optional/public). - Switched feed endpoints from `offset` to `cursor`-based pagination to match controller logic. - Added `CursorFeedResponse` schema to align with the actual response shape: `{ data, hasMore, nextCursor, message }`. - **Audit Housekeeping**: - Cataloged and marked legacy or spec-only routes (e.g., SoundCloud OAuth, `/notifications/read-all`) as `# --- openapi.yaml | 260 +++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 199 insertions(+), 61 deletions(-) diff --git a/openapi.yaml b/openapi.yaml index 091bfd48..ce44e78e 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -395,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] @@ -545,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. @@ -607,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] @@ -651,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. @@ -1150,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. @@ -2971,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: | @@ -3005,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: | @@ -4757,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] @@ -4925,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 @@ -5419,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. @@ -5464,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. @@ -10929,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: