From b539200734f74d258a583962af5ee6c16dfd486e Mon Sep 17 00:00:00 2001
From: basboosa05 <166862890+basboosa05@users.noreply.github.com>
Date: Fri, 13 Mar 2026 10:49:24 +0200
Subject: [PATCH 001/535] add user model queries & mailer
connectionString from DATABASE_URL and update env exports (add DATABASE_URL, GMAIL_USER, GMAIL_APP_PASSWORD, EMAIL_FROM; remove legacy DB_HOST/DB_* and SMTP_* vars). Implement several user model helpers (findByEmail, findByUsername, findByEmailOrUsername, findById, create, markVerified, updateLastLogin). Add utils/mailer.js: a Nodemailer Gmail transporter and HTML templates with functions to send verification, resend verification and password reset emails.
---
src/config/db.js | 6 +-
src/config/env.js | 16 ++-
src/models/user.model.js | 71 ++++++++++++--
src/utils/mailer.js | 203 +++++++++++++++++++++++++++++++++++++++
4 files changed, 275 insertions(+), 21 deletions(-)
create mode 100644 src/utils/mailer.js
diff --git a/src/config/db.js b/src/config/db.js
index 82c2c85..122c399 100644
--- a/src/config/db.js
+++ b/src/config/db.js
@@ -5,11 +5,7 @@ const { Pool } = require('pg');
const env = require('./env');
const pool = new Pool({
- host: env.DB_HOST,
- port: env.DB_PORT,
- database: env.DB_NAME,
- user: env.DB_USER,
- password: env.DB_PASSWORD,
+ connectionString: env.DATABASE_URL,
});
pool.on('connect', () => console.log('PostgreSQL connected'));
diff --git a/src/config/env.js b/src/config/env.js
index 8ca521a..513263c 100644
--- a/src/config/env.js
+++ b/src/config/env.js
@@ -8,11 +8,11 @@ module.exports = {
PORT: process.env.PORT || 8080,
NODE_ENV: process.env.NODE_ENV || 'development',
- DB_HOST: process.env.DB_HOST,
- DB_PORT: process.env.DB_PORT || 5432,
- DB_NAME: process.env.DB_NAME,
- DB_USER: process.env.DB_USER,
- DB_PASSWORD: process.env.DB_PASSWORD,
+ GMAIL_USER: process.env.GMAIL_USER,
+ GMAIL_APP_PASSWORD: process.env.GMAIL_APP_PASSWORD,
+ EMAIL_FROM: process.env.EMAIL_FROM,
+
+ DATABASE_URL: process.env.DATABASE_URL,
JWT_SECRET: process.env.JWT_SECRET,
JWT_ACCESS_EXPIRES_IN: process.env.JWT_ACCESS_EXPIRES_IN || '15m',
@@ -34,11 +34,7 @@ module.exports = {
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
- SMTP_HOST: process.env.SMTP_HOST,
- SMTP_PORT: process.env.SMTP_PORT || 587,
- SMTP_USER: process.env.SMTP_USER,
- SMTP_PASS: process.env.SMTP_PASS,
- EMAIL_FROM: process.env.EMAIL_FROM,
+
BASE_URL: process.env.BASE_URL || 'http://localhost:8080',
CLIENT_URL: process.env.CLIENT_URL || 'http://localhost:3000',
diff --git a/src/models/user.model.js b/src/models/user.model.js
index 31e942a..0fc5b06 100644
--- a/src/models/user.model.js
+++ b/src/models/user.model.js
@@ -5,9 +5,68 @@
// ============================================================
const db = require('../config/db');
-// TODO: Implement query methods
-// Example:
-// exports.findById = async (id) => {
-// const { rows } = await db.query('SELECT * FROM users WHERE id = $1', [id]);
-// return rows[0] || null;
-// };
+// find user by email used for login and register (to check if email already exists)
+exports.findByEmail = async (email) => {
+ const { rows } = await db.query(
+ `SELECT * FROM users WHERE email = $1 AND deleted_at IS NULL`,
+ [email]
+ );
+ return rows[0] || null;
+};
+
+// find user by username used for login (when identifier is a username)
+exports.findByUsername = async (username) => {
+ const { rows } = await db.query(
+ `SELECT * FROM users WHERE username = $1 AND deleted_at IS NULL`,
+ [username]
+ );
+ return rows[0] || null;
+};
+
+// find user by email OR username in one query
+exports.findByEmailOrUsername = async (identifier) => {
+ const { rows } = await db.query(
+ `SELECT * FROM users
+ WHERE (email = $1 OR username = $1)
+ AND deleted_at IS NULL`,
+ [identifier]
+ );
+ return rows[0] || null;
+};
+
+// find user by UUID used for auth and other operations where we have the user ID
+exports.findById = async (id) => {
+ const { rows } = await db.query(
+ `SELECT * FROM users WHERE id = $1 AND deleted_at IS NULL`,
+ [id]
+ );
+ return rows[0] || null;
+};
+
+
+// create new user (called during registration)
+exports.create = async ({ email, password_hashed, display_name }) => {
+ const { rows } = await db.query(
+ `INSERT INTO users (email, password_hashed, display_name)
+ VALUES ($1, $2, $3)
+ RETURNING id, email, display_name, role, is_verified, created_at`,
+ [email, password_hashed, display_name]
+ );
+ return rows[0];
+};
+
+// mark user as verified
+exports.markVerified = async (userId) => {
+ await db.query(
+ `UPDATE users SET is_verified = true WHERE id = $1`,
+ [userId]
+ );
+};
+
+// update last login timestamp (called after successful login)
+exports.updateLastLogin = async (userId) => {
+ await db.query(
+ `UPDATE users SET last_login_at = now() WHERE id = $1`,
+ [userId]
+ );
+};
diff --git a/src/utils/mailer.js b/src/utils/mailer.js
new file mode 100644
index 0000000..f8a1dc1
--- /dev/null
+++ b/src/utils/mailer.js
@@ -0,0 +1,203 @@
+// ============================================================
+// utils/mailer.js — Nodemailer transporter + email templates
+// Uses Gmail App Password
+// ============================================================
+const nodemailer = require('nodemailer');
+const env = require('../config/env');
+
+const transporter = nodemailer.createTransport({
+ service: 'gmail',
+ auth: {
+ user: env.GMAIL_USER,
+ pass: env.GMAIL_APP_PASSWORD,
+ },
+});
+
+
+const baseTemplate = ({ title, previewText, bodyContent }) => `
+
+
+
+
+
+ ${title}
+
+
+
+
+ ${previewText}
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+ |
+
+
+
+
+ |
+ ${bodyContent}
+ |
+
+
+
+
+ |
+
+ If you didn't create a Rythmify account, you can safely ignore this email.
+ © ${new Date().getFullYear()} Rythmify. All rights reserved.
+
+ |
+
+
+
+
+
+ |
+
+
+
+
+
+`;
+
+// Reusable call to action button component for all emails
+const ctaButton = (href, label) => `
+
+
+ Button not working? Copy and paste this link into your browser:
+ ${href}
+
+`;
+
+// verify email
+const sendVerificationEmail = async (to, { displayName, token }) => {
+ const link = `${env.CLIENT_URL}/verify-email?token=${token}`;
+
+ const bodyContent = `
+
+ Verify your email
+
+
+ Hello ${displayName},
+
+
+ Welcome to Rythmify! To finish setting up your account, please verify your
+ email address by clicking the button below.
+
+ ${ctaButton(link, 'Verify Email Address')}
+
+ This link expires in 24 hours.
+
+ `;
+
+ await transporter.sendMail({
+ from: env.EMAIL_FROM,
+ to,
+ subject: 'Verify your Rythmify account',
+ html: baseTemplate({
+ title: 'Verify your Rythmify account',
+ previewText: 'One click away — verify your email to start listening.',
+ bodyContent,
+ }),
+ });
+};
+
+// resend verification email
+const sendResendVerificationEmail = async (to, { displayName, token }) => {
+ const link = `${env.CLIENT_URL}/verify-email?token=${token}`;
+
+ const bodyContent = `
+
+ New verification link
+
+
+ Hello ${displayName},
+
+
+ Here's your new email verification link. Your previous link has been invalidated.
+
+ ${ctaButton(link, 'Verify Email Address')}
+
+ This link expires in 24 hours.
+
+ `;
+
+ await transporter.sendMail({
+ from: env.EMAIL_FROM,
+ to,
+ subject: 'New verification link — Rythmify',
+ html: baseTemplate({
+ title: 'New verification link',
+ previewText: 'Your new Rythmify verification link is ready.',
+ bodyContent,
+ }),
+ });
+};
+
+// password reset email
+const sendPasswordResetEmail = async (to, { displayName, token }) => {
+ const link = `${env.CLIENT_URL}/reset-password?token=${token}`;
+
+ const bodyContent = `
+
+ Reset your password
+
+
+ Hello ${displayName},
+
+
+ We received a request to reset your Rythmify password.
+ Click the button below to choose a new one.
+
+ ${ctaButton(link, 'Reset Password')}
+
+ This link expires in 1 hour.
+ If you didn't request this, no action is needed.
+
+ `;
+
+ await transporter.sendMail({
+ from: env.EMAIL_FROM,
+ to,
+ subject: 'Reset your Rythmify password',
+ html: baseTemplate({
+ title: 'Reset your Rythmify password',
+ previewText: 'Reset your Rythmify password — link expires in 1 hour.',
+ bodyContent,
+ }),
+ });
+};
+
+module.exports = {
+ sendVerificationEmail,
+ sendResendVerificationEmail,
+ sendPasswordResetEmail,
+};
\ No newline at end of file
From a0a4c9ef009c97938a726c3c3b945d11b6d1f78d Mon Sep 17 00:00:00 2001
From: basboosa05 <166862890+basboosa05@users.noreply.github.com>
Date: Fri, 13 Mar 2026 11:37:59 +0200
Subject: [PATCH 002/535] Add DB methods for refresh & verification tokens
Implement database query helpers for token models. For refresh tokens: add create, findValid (non-revoked and not expired), revoke (single token) and revokeAllForUser. For verification tokens: add create, findValidToken (matches token+type, not revoked, unused, not expired), markUsed (set used_at) and revokeAllForUser. These methods support auth flows like register, resend-verification, verify-email, refresh, logout and reset-password.
---
src/models/refresh-token.model.js | 52 +++++++++++++++++++++---
src/models/verification-token.model.js | 55 +++++++++++++++++++++++---
2 files changed, 95 insertions(+), 12 deletions(-)
diff --git a/src/models/refresh-token.model.js b/src/models/refresh-token.model.js
index f37a6b3..7e43232 100644
--- a/src/models/refresh-token.model.js
+++ b/src/models/refresh-token.model.js
@@ -5,9 +5,49 @@
// ============================================================
const db = require('../config/db');
-// TODO: Implement query methods
-// Example:
-// exports.findById = async (id) => {
-// const { rows } = await db.query('SELECT * FROM refresh-tokens WHERE id = $1', [id]);
-// return rows[0] || null;
-// };
+
+
+// Store a new refresh token in the database
+exports.create = async ({ user_id, refresh_token, expires_at }) => {
+ const { rows } = await db.query(
+ `INSERT INTO refresh_tokens (user_id, refresh_token, expires_at)
+ VALUES ($1, $2, $3)
+ RETURNING id, user_id, refresh_token, expires_at, created_at`,
+ [user_id, refresh_token, expires_at]
+ );
+ return rows[0];
+};
+
+
+// Find a valid refresh token must pass ALL conditions Used in: refresh and logout endpoints
+exports.findValid = async (token) => {
+ const { rows } = await db.query(
+ `SELECT * FROM refresh_tokens
+ WHERE refresh_token = $1
+ AND revoked = false
+ AND expires_at > now()`,
+ [token]
+ );
+ return rows[0] || null;
+};
+
+// Revoke a single refresh token by its token string Used in: logout endpoint (revoke current token)
+exports.revoke = async (token) => {
+ await db.query(
+ `UPDATE refresh_tokens
+ SET revoked = true
+ WHERE refresh_token = $1`,
+ [token]
+ );
+};
+
+// Revoke ALL refresh tokens for a user Used in: reset-password (logout all devices), account suspension (admin action)
+exports.revokeAllForUser = async (userId) => {
+ await db.query(
+ `UPDATE refresh_tokens
+ SET revoked = true
+ WHERE user_id = $1
+ AND revoked = false`,
+ [userId]
+ );
+};
\ No newline at end of file
diff --git a/src/models/verification-token.model.js b/src/models/verification-token.model.js
index 02ff2ac..c00fcdd 100644
--- a/src/models/verification-token.model.js
+++ b/src/models/verification-token.model.js
@@ -5,9 +5,52 @@
// ============================================================
const db = require('../config/db');
-// TODO: Implement query methods
-// Example:
-// exports.findById = async (id) => {
-// const { rows } = await db.query('SELECT * FROM verification-tokens WHERE id = $1', [id]);
-// return rows[0] || null;
-// };
+
+// Create a new verification token Used in: register, resend-verification, forgot-password
+exports.create = async ({ user_id, token, type, expires_at }) => {
+ const { rows } = await db.query(
+ `INSERT INTO verification_tokens (user_id, token, type, expires_at)
+ VALUES ($1, $2, $3, $4)
+ RETURNING id, user_id, token, type, expires_at, created_at`,
+ [user_id, token, type, expires_at]
+ );
+ return rows[0];
+};
+
+
+// Find a valid token must staisfy all (4 conditions) Used in: verify-email, reset-password
+exports.findValidToken = async (token, type) => {
+ const { rows } = await db.query(
+ `SELECT * FROM verification_tokens
+ WHERE token = $1
+ AND type = $2
+ AND expires_at > now()
+ AND revoked = false
+ AND used_at IS NULL`,
+ [token, type]
+ );
+ return rows[0] || null;
+};
+
+// Mark a token as used (set used_at timestamp) Used in: verify-email, reset-password
+exports.markUsed = async (tokenId) => {
+ await db.query(
+ `UPDATE verification_tokens
+ SET used_at = now()
+ WHERE id = $1`,
+ [tokenId]
+ );
+};
+
+// Revoke ALL tokens of a given type for a user called before creating a new token so old links stop working
+exports.revokeAllForUser = async (userId, type) => {
+ await db.query(
+ `UPDATE verification_tokens
+ SET revoked = true
+ WHERE user_id = $1
+ AND type = $2
+ AND revoked = false
+ AND used_at IS NULL`,
+ [userId, type]
+ );
+};
\ No newline at end of file
From f838606ef10111b30130b2a0412913fc7422d6fd Mon Sep 17 00:00:00 2001
From: basboosa05 <166862890+basboosa05@users.noreply.github.com>
Date: Fri, 13 Mar 2026 12:50:37 +0200
Subject: [PATCH 003/535] Implement user registration + API response changes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add a registration flow and related infra, plus adjust response/error formats.
Highlights:
- New POST /api/v1/auth/register route (auth.routes) protected by auth rate limiter.
- Implemented register controller and service: validates input, hashes password, creates user, creates verification token and sends verification email. CAPTCHA support added (RECAPTCHA_SECRET env var) but verification call is currently commented out.
- Extended user model to store gender and date_of_birth and return them on create.
- Added AppError custom error class for consistent error handling (src/utils/app-error.js).
- Reworked unified API response format: success -> { data, message }, error -> { error: { code, message, details? } } and updated error-handler to return error.code and message.
- Added input validators (password, display name, DOB, gender) in utils/validators.js.
- Added cookie-parser dependency and middleware registration in app.js.
Notes / breaking changes:
- API success/error envelope changed — update clients consuming responses.
- New dependency: cookie-parser (package.json & lock updated).
- CAPTCHA requires setting RECAPTCHA_SECRET in env to enable verification.
---
.env.example | 2 +
app.js | 2 +
package-lock.json | 20 +++++++
package.json | 1 +
src/config/env.js | 2 +
src/controllers/auth.controller.js | 22 +++++---
src/middleware/error-handler.js | 10 ++--
src/models/user.model.js | 10 ++--
src/routes/auth.routes.js | 26 ++++++---
src/services/auth.service.js | 88 ++++++++++++++++++++++++++----
src/utils/api-response.js | 18 +++---
src/utils/app-error.js | 16 ++++++
src/utils/validators.js | 30 +++++++++-
13 files changed, 201 insertions(+), 46 deletions(-)
create mode 100644 src/utils/app-error.js
diff --git a/.env.example b/.env.example
index f40690a..a252a4f 100644
--- a/.env.example
+++ b/.env.example
@@ -27,6 +27,8 @@ GMAIL_USER=rythmify.noreply@gmail.com
GMAIL_APP_PASSWORD=wqouswwukowchgwh
EMAIL_FROM=Rythmify
+# captcha
+RECAPTCHA_SECRET=your_secret_key_from_google
# JWT
diff --git a/app.js b/app.js
index 7331fdf..0caaf97 100644
--- a/app.js
+++ b/app.js
@@ -7,6 +7,7 @@ const helmet = require('helmet');
const env = require('./src/config/env');
const { generalLimiter } = require('./src/middleware/rate-limiter');
const errorHandler = require('./src/middleware/error-handler');
+const cookieParser = require('cookie-parser');
// ── Route imports ──────────────────────────────────────────
const authRoutes = require('./src/routes/auth.routes');
@@ -30,6 +31,7 @@ app.use(cors({ origin: env.CLIENT_URL, credentials: true }));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(generalLimiter);
+app.use(cookieParser());
// ── Health check ───────────────────────────────────────────
app.get('/health', (req, res) => res.json({ status: 'ok', env: env.NODE_ENV }));
diff --git a/package-lock.json b/package-lock.json
index cef8316..a1c2d00 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"@aws-sdk/s3-request-presigner": "^3.1008.0",
"bcryptjs": "^2.4.3",
"cloudinary": "^1.41.3",
+ "cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"db-migrate": "^0.11.14",
"db-migrate-pg": "^1.5.2",
@@ -3621,6 +3622,25 @@
"node": ">= 0.6"
}
},
+ "node_modules/cookie-parser": {
+ "version": "1.4.7",
+ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
+ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "0.7.2",
+ "cookie-signature": "1.0.6"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/cookie-parser/node_modules/cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+ "license": "MIT"
+ },
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
diff --git a/package.json b/package.json
index 8b90bba..685585a 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"@aws-sdk/s3-request-presigner": "^3.1008.0",
"bcryptjs": "^2.4.3",
"cloudinary": "^1.41.3",
+ "cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"db-migrate": "^0.11.14",
"db-migrate-pg": "^1.5.2",
diff --git a/src/config/env.js b/src/config/env.js
index 513263c..f69bb66 100644
--- a/src/config/env.js
+++ b/src/config/env.js
@@ -14,6 +14,8 @@ module.exports = {
DATABASE_URL: process.env.DATABASE_URL,
+
+ RECAPTCHA_SECRET: process.env.RECAPTCHA_SECRET,
JWT_SECRET: process.env.JWT_SECRET,
JWT_ACCESS_EXPIRES_IN: process.env.JWT_ACCESS_EXPIRES_IN || '15m',
JWT_REFRESH_EXPIRES_IN: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js
index f6135c2..b4cc15d 100644
--- a/src/controllers/auth.controller.js
+++ b/src/controllers/auth.controller.js
@@ -4,11 +4,19 @@
// Receives validated requests → calls service → returns HTTP response
// ============================================================
const authService = require('../services/auth.service');
-const { success, error } = require('../utils/api-response');
+const { success } = require('../utils/api-response');
-// TODO: Implement controller methods
-// Example:
-// exports.getAll = async (req, res) => {
-// const data = await authService.getAll(req.query);
-// return success(res, data);
-// };
+exports.register = async (req, res) => {
+ const { email, password, display_name, gender, date_of_birth, captcha_token } = req.body;
+
+ const data = await authService.register({
+ email,
+ password,
+ display_name,
+ gender,
+ date_of_birth,
+ captcha_token,
+ });
+
+ return success(res, data, 'Account created. Please verify your email.', 201);
+};
\ No newline at end of file
diff --git a/src/middleware/error-handler.js b/src/middleware/error-handler.js
index d5ee618..13a47d9 100644
--- a/src/middleware/error-handler.js
+++ b/src/middleware/error-handler.js
@@ -1,14 +1,14 @@
// ============================================================
-// middleware/error-handler.js — Centralised error handling
-// Must be registered LAST in app.js after all routes
+// middleware/error-handler.js
// ============================================================
const { error } = require('../utils/api-response');
const errorHandler = (err, req, res, next) => {
console.error(`[ERROR] ${req.method} ${req.path}:`, err.message);
const statusCode = err.statusCode || 500;
- const message = err.message || 'Internal Server Error';
- return error(res, message, statusCode);
+ const message = err.message || 'Internal Server Error';
+ const code = err.code || 'INTERNAL_ERROR';
+ return error(res, code, message, statusCode);
};
-module.exports = errorHandler;
+module.exports = errorHandler;
\ No newline at end of file
diff --git a/src/models/user.model.js b/src/models/user.model.js
index 0fc5b06..e42893a 100644
--- a/src/models/user.model.js
+++ b/src/models/user.model.js
@@ -45,12 +45,12 @@ exports.findById = async (id) => {
// create new user (called during registration)
-exports.create = async ({ email, password_hashed, display_name }) => {
+exports.create = async ({ email, password_hashed, display_name, gender, date_of_birth }) => {
const { rows } = await db.query(
- `INSERT INTO users (email, password_hashed, display_name)
- VALUES ($1, $2, $3)
- RETURNING id, email, display_name, role, is_verified, created_at`,
- [email, password_hashed, display_name]
+ `INSERT INTO users (email, password_hashed, display_name, gender, date_of_birth)
+ VALUES ($1, $2, $3, $4, $5)
+ RETURNING id, email, display_name, gender, date_of_birth, role, is_verified, created_at`,
+ [email, password_hashed, display_name, gender, date_of_birth]
);
return rows[0];
};
diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js
index b0c7eb1..9b526dd 100644
--- a/src/routes/auth.routes.js
+++ b/src/routes/auth.routes.js
@@ -1,16 +1,26 @@
// ============================================================
// routes/auth.routes.js
// Owner : Omar Hamdy (BE-1)
-// Modules: Module 1 — Authentication
+// Module 1 — Authentication
// ============================================================
-const express = require('express');
-const router = express.Router();
+const express = require('express');
+const router = express.Router();
const controller = require('../controllers/auth.controller');
-const { authenticate } = require('../middleware/auth');
-const asyncHandler = require('../utils/async-handler');
+const { authLimiter } = require('../middleware/rate-limiter');
+const asyncHandler = require('../utils/async-handler');
-// TODO: Add route definitions here
-// Example:
-// router.get('/', authenticate, asyncHandler(controller.getAll));
+// Apply auth rate limiter to ALL routes in this file
+router.use(authLimiter);
+
+// POST /api/v1/auth/register
+router.post('/register', asyncHandler(controller.register));
+
+// i didn't implement the controllers yet
+// router.post('/register', asyncHandler(controller.register));
+// router.post('/verify-email', asyncHandler(controller.verifyEmail));
+// router.post('/resend-verification', asyncHandler(controller.resendVerification));
+// router.post('/login', asyncHandler(controller.login));
+// router.post('/refresh', asyncHandler(controller.refresh));
+// router.post('/logout', asyncHandler(controller.logout));
module.exports = router;
diff --git a/src/services/auth.service.js b/src/services/auth.service.js
index e048b71..2d24761 100644
--- a/src/services/auth.service.js
+++ b/src/services/auth.service.js
@@ -1,12 +1,76 @@
-// ============================================================
-// services/auth.service.js
-// Owner : Omar Hamdy (BE-1)
-// All business logic, rules & cross-module orchestration
-// No direct SQL here — delegate to models/
-// ============================================================
-
-// TODO: Implement service methods
-// Example:
-// exports.getAll = async (query) => {
-// return await authModel.findAll(query);
-// };
+const bcrypt = require('bcryptjs');
+const userModel = require('../models/user.model');
+const verificationTokenModel = require('../models/verification-token.model');
+const { generateSecureToken }= require('../utils/token-generator');
+const { sendVerificationEmail } = require('../utils/mailer');
+const AppError = require('../utils/app-error');
+const env = require('../config/env');
+
+// CAPTCHA verification
+const verifyCaptcha = async (captchaToken) => {
+ if (!env.RECAPTCHA_SECRET) {
+ console.warn('[CAPTCHA] Skipping — RECAPTCHA_SECRET not set');
+ return;
+ }
+ const res = await fetch(
+ `https://www.google.com/recaptcha/api/siteverify?secret=${env.RECAPTCHA_SECRET}&response=${captchaToken}`,
+ { method: 'POST' }
+ );
+ const data = await res.json();
+ if (!data.success || data.score < 0.5) {
+ throw new AppError('CAPTCHA verification failed', 400, 'CAPTCHA_FAILED');
+ }
+};
+
+// Register
+exports.register = async ({ email, password, display_name, gender, date_of_birth, captcha_token }) => {
+ // Verify CAPTCHA i can't get a token to test with it now so imma comment it out for now :)
+ //await verifyCaptcha(captcha_token);
+
+ // Check duplicate email
+ const existing = await userModel.findByEmail(email);
+ if (existing) {
+ throw new AppError('Email already registered', 409, 'AUTH_EMAIL_ALREADY_EXISTS');
+ }
+
+ // Hash password
+ const password_hashed = await bcrypt.hash(password, 12);
+
+ // Create user
+ const user = await userModel.create({
+ email,
+ password_hashed,
+ display_name,
+ gender,
+ date_of_birth,
+ });
+
+ // Create verification token — 24h expiry
+ const token = generateSecureToken();
+ const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
+
+ await verificationTokenModel.create({
+ user_id: user.id,
+ token,
+ type: 'verify_email',
+ expires_at: expiresAt,
+ });
+
+ // Send verification email
+ await sendVerificationEmail(email, {
+ displayName: display_name,
+ token,
+ });
+
+ // Return
+ return {
+ user_id: user.id,
+ email: user.email,
+ display_name: user.display_name,
+ gender: user.gender,
+ role: user.role,
+ is_verified: user.is_verified,
+ date_of_birth: user.date_of_birth,
+ created_at: user.created_at,
+ };
+};
\ No newline at end of file
diff --git a/src/utils/api-response.js b/src/utils/api-response.js
index 99d73ca..741e53b 100644
--- a/src/utils/api-response.js
+++ b/src/utils/api-response.js
@@ -1,15 +1,17 @@
// ============================================================
// utils/api-response.js
-// Unified response envelope: { success, data, message, meta }
+// Success: { data, message }
+// Error: { error: { code, message, details? } }
// ============================================================
-const success = (res, data = null, message = 'OK', statusCode = 200, meta = null) => {
- const body = { success: true, message, data };
- if (meta) body.meta = meta;
- return res.status(statusCode).json(body);
+
+const success = (res, data = null, message = 'OK', statusCode = 200) => {
+ return res.status(statusCode).json({ data, message });
};
-const error = (res, message = 'Something went wrong', statusCode = 500, data = null) => {
- return res.status(statusCode).json({ success: false, message, data });
+const error = (res, code = 'INTERNAL_ERROR', message = 'Something went wrong', statusCode = 500, details = null) => {
+ const body = { error: { code, message } };
+ if (details) body.error.details = details;
+ return res.status(statusCode).json(body);
};
-module.exports = { success, error };
+module.exports = { success, error };
\ No newline at end of file
diff --git a/src/utils/app-error.js b/src/utils/app-error.js
new file mode 100644
index 0000000..05e425e
--- /dev/null
+++ b/src/utils/app-error.js
@@ -0,0 +1,16 @@
+// ============================================================
+// utils/app-error.js — Custom error class with HTTP status code
+// Usage: throw new AppError('message', statusCode)
+// Caught by middleware/error-handler.js
+// ============================================================
+class AppError extends Error {
+ constructor(message, statusCode, code) {
+ super(message);
+ this.statusCode = statusCode;
+ this.code = code;
+ this.isOperational = true;
+ Error.captureStackTrace(this, this.constructor);
+ }
+}
+
+module.exports = AppError;
\ No newline at end of file
diff --git a/src/utils/validators.js b/src/utils/validators.js
index b515409..e5b0b08 100644
--- a/src/utils/validators.js
+++ b/src/utils/validators.js
@@ -1,10 +1,38 @@
// ============================================================
// utils/validators.js — Shared input validation helpers
// ============================================================
+
const isValidEmail = (email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
const isValidUrl = (url) => {
try { new URL(url); return true; } catch { return false; }
};
-module.exports = { isValidEmail, isValidUrl };
+// Password: min 8 chars, at least 1 uppercase, 1 lowercase, 1 number
+const isValidPassword = (password) =>
+ /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/.test(password);
+
+// display_name: 1–50 chars, not empty
+const isValidDisplayName = (name) =>
+ typeof name === 'string' && name.trim().length >= 1 && name.trim().length <= 50;
+
+// date_of_birth: valid date, user must be at least 13 years old
+const isValidDateOfBirth = (dob) => {
+ const date = new Date(dob);
+ if (isNaN(date.getTime())) return false;
+ const ageDiff = Date.now() - date.getTime();
+ const age = Math.floor(ageDiff / (1000 * 60 * 60 * 24 * 365.25));
+ return age >= 13;
+};
+
+// gender: must match your DB enum
+const isValidGender = (gender) => ['male', 'female'].includes(gender);
+
+module.exports = {
+ isValidEmail,
+ isValidUrl,
+ isValidPassword,
+ isValidDisplayName,
+ isValidDateOfBirth,
+ isValidGender,
+};
\ No newline at end of file
From ea73b4ae96aae5207e607a282851a39a224e8bdb Mon Sep 17 00:00:00 2001
From: basboosa05 <166862890+basboosa05@users.noreply.github.com>
Date: Fri, 13 Mar 2026 13:02:52 +0200
Subject: [PATCH 004/535] Add registration validation middleware
Introduce src/middleware/validate-register.js to validate POST /auth/register payloads and return a structured 400 VALIDATION_FAILED response with field-level errors. The middleware checks email, password, display_name, gender, and date_of_birth (including 13+ age check) using existing validators and api-response helper. Also wire the middleware into src/routes/auth.routes.js so register requests run validation before the controller.
---
src/middleware/validate-register.js | 41 +++++++++++++++++++++++++++++
src/routes/auth.routes.js | 3 ++-
2 files changed, 43 insertions(+), 1 deletion(-)
create mode 100644 src/middleware/validate-register.js
diff --git a/src/middleware/validate-register.js b/src/middleware/validate-register.js
new file mode 100644
index 0000000..41cf6bc
--- /dev/null
+++ b/src/middleware/validate-register.js
@@ -0,0 +1,41 @@
+// ============================================================
+// middleware/validate-register.js
+// Validates POST /auth/register request body
+// Returns 400 with field-level errors if invalid
+// ============================================================
+const { error } = require('../utils/api-response');
+const {
+ isValidEmail,
+ isValidPassword,
+ isValidDisplayName,
+ isValidDateOfBirth,
+ isValidGender,
+} = require('../utils/validators');
+
+const validateRegister = (req, res, next) => {
+ const { email, password, display_name, gender, date_of_birth } = req.body;
+ const details = [];
+
+ if (!email || !isValidEmail(email))
+ details.push({ field: 'email', issue: 'Must be a valid email address' });
+
+ if (!password || !isValidPassword(password))
+ details.push({ field: 'password', issue: 'Min 8 characters, must include uppercase, lowercase, and a number' });
+
+ if (!display_name || !isValidDisplayName(display_name))
+ details.push({ field: 'display_name', issue: 'Must be between 1 and 50 characters' });
+
+ if (!gender || !isValidGender(gender))
+ details.push({ field: 'gender', issue: 'Must be male or female' });
+
+ if (!date_of_birth || !isValidDateOfBirth(date_of_birth))
+ details.push({ field: 'date_of_birth', issue: 'Must be a valid date and user must be at least 13 years old' });
+
+ if (details.length > 0) {
+ return error(res, 'VALIDATION_FAILED', 'Validation failed', 400, details);
+ }
+
+ next();
+};
+
+module.exports = { validateRegister };
\ No newline at end of file
diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js
index 9b526dd..bb49030 100644
--- a/src/routes/auth.routes.js
+++ b/src/routes/auth.routes.js
@@ -7,13 +7,14 @@ const express = require('express');
const router = express.Router();
const controller = require('../controllers/auth.controller');
const { authLimiter } = require('../middleware/rate-limiter');
+const { validateRegister } = require('../middleware/validate-register');
const asyncHandler = require('../utils/async-handler');
// Apply auth rate limiter to ALL routes in this file
router.use(authLimiter);
// POST /api/v1/auth/register
-router.post('/register', asyncHandler(controller.register));
+router.post('/register', validateRegister, asyncHandler(controller.register));
// i didn't implement the controllers yet
// router.post('/register', asyncHandler(controller.register));
From 89dbc7838654273b480747ff782c71fe144650d6 Mon Sep 17 00:00:00 2001
From: Omar Hamza
Date: Fri, 13 Mar 2026 19:29:27 +0200
Subject: [PATCH 005/535] feat(auth): implement login and refresh token flow
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- add login and refresh endpoints to auth routes
- implement login flow in auth service:
- identifier lookup (email or username)
- password verification
- email verification enforcement
- suspended account check
- access token issuance
- refresh token creation and persistence
- update user's last login timestamp
- implement refresh token flow:
- read refresh token from cookie or request body
- verify JWT refresh token
- validate token against database
- rotate refresh tokens (revoke old, issue new)
- update auth controller:
- validate login request input
- set refresh_token as HttpOnly cookie
(SameSite=Strict, Secure in production)
- return access token payload for login and refresh responses
- align authentication error codes with API specification:
- AUTH_INVALID_CREDENTIALS → 401
- AUTH_EMAIL_NOT_VERIFIED → 422
- AUTH_ACCOUNT_SUSPENDED → 403
- AUTH_REFRESH_TOKEN_INVALID → 401
- VALIDATION_FAILED → 400
- move `parseDurationToSeconds` helper to token utilities
- development improvements:
- change pgAdmin port mapping to 5050
- relax auth rate limiter in non-production environments
---
docker-compose.yml | 2 +-
src/controllers/auth.controller.js | 63 ++++++++++-
src/middleware/rate-limiter.js | 2 +-
src/routes/auth.routes.js | 11 +-
src/services/auth.service.js | 168 +++++++++++++++++++++++++----
src/utils/token-generator.js | 13 ++-
6 files changed, 231 insertions(+), 28 deletions(-)
diff --git a/docker-compose.yml b/docker-compose.yml
index d3cb946..679f134 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -24,7 +24,7 @@ services:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL}
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD}
ports:
- - "8080:80"
+ - "5050:80"
depends_on:
- db
diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js
index b4cc15d..5acfe9d 100644
--- a/src/controllers/auth.controller.js
+++ b/src/controllers/auth.controller.js
@@ -5,6 +5,7 @@
// ============================================================
const authService = require('../services/auth.service');
const { success } = require('../utils/api-response');
+const { error } = require('../utils/api-response');
exports.register = async (req, res) => {
const { email, password, display_name, gender, date_of_birth, captcha_token } = req.body;
@@ -19,4 +20,64 @@ exports.register = async (req, res) => {
});
return success(res, data, 'Account created. Please verify your email.', 201);
-};
\ No newline at end of file
+};
+
+exports.login = async (req, res) => {
+ const { identifier, password } = req.body;
+
+ // ── Validate input (field presence check only) ──────────
+ if (!identifier || typeof identifier !== 'string' || !identifier.trim()) {
+ return error(res, 'VALIDATION_FAILED', 'Validation failed', 400, [
+ { field: 'identifier', issue: 'Email or username is required' },
+ ]);
+ }
+
+ if (!password || typeof password !== 'string') {
+ return error(res, 'VALIDATION_FAILED', 'Validation failed', 400, [
+ { field: 'password', issue: 'Password is required' },
+ ]);
+ }
+
+ // ── Call service ────────────────────────────────────────
+ const result = await authService.login({
+ identifier: identifier.trim().toLowerCase(),
+ password,
+ });
+
+ // ── Set refresh token as httpOnly cookie ────────────────
+ res.cookie('refresh_token', result.refresh_token, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'strict',
+ maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in ms
+ });
+
+ // ── Send response ───────────────────────────────────────
+ return success(
+ res,
+ {
+ access_token: result.access_token,
+ token_type: result.token_type,
+ expires_in: result.expires_in,
+ user: result.user,
+ is_new_user: result.is_new_user,
+ },
+ 'Logged in successfully'
+ );
+};
+
+exports.refresh = async (req, res) => {
+ const refresh_token = req.cookies?.refresh_token || req.body?.refresh_token;
+
+ const data = await authService.refresh({ refresh_token });
+ const { refresh_token: newRefreshToken, ...responseData } = data;
+
+ res.cookie('refresh_token', newRefreshToken, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'strict',
+ maxAge: 7 * 24 * 60 * 60 * 1000,
+ });
+
+ return success(res, responseData, 'Token refreshed successfully.');
+};
diff --git a/src/middleware/rate-limiter.js b/src/middleware/rate-limiter.js
index dfeb0b5..c8617fb 100644
--- a/src/middleware/rate-limiter.js
+++ b/src/middleware/rate-limiter.js
@@ -12,7 +12,7 @@ const generalLimiter = rateLimit({
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
- max: 5,
+ max: process.env.NODE_ENV === 'production' ? 5 : 100,
message: { success: false, message: 'Too many auth attempts, please try again later.' },
});
diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js
index bb49030..ee33e21 100644
--- a/src/routes/auth.routes.js
+++ b/src/routes/auth.routes.js
@@ -3,12 +3,12 @@
// Owner : Omar Hamdy (BE-1)
// Module 1 — Authentication
// ============================================================
-const express = require('express');
-const router = express.Router();
+const express = require('express');
+const router = express.Router();
const controller = require('../controllers/auth.controller');
const { authLimiter } = require('../middleware/rate-limiter');
const { validateRegister } = require('../middleware/validate-register');
-const asyncHandler = require('../utils/async-handler');
+const asyncHandler = require('../utils/async-handler');
// Apply auth rate limiter to ALL routes in this file
router.use(authLimiter);
@@ -16,12 +16,13 @@ router.use(authLimiter);
// POST /api/v1/auth/register
router.post('/register', validateRegister, asyncHandler(controller.register));
-// i didn't implement the controllers yet
+router.post('/login', asyncHandler(controller.login));
+router.post('/refresh', asyncHandler(controller.refresh));
+// i didn't implement the controllers yet
// router.post('/register', asyncHandler(controller.register));
// router.post('/verify-email', asyncHandler(controller.verifyEmail));
// router.post('/resend-verification', asyncHandler(controller.resendVerification));
// router.post('/login', asyncHandler(controller.login));
-// router.post('/refresh', asyncHandler(controller.refresh));
// router.post('/logout', asyncHandler(controller.logout));
module.exports = router;
diff --git a/src/services/auth.service.js b/src/services/auth.service.js
index 2d24761..bf7ba89 100644
--- a/src/services/auth.service.js
+++ b/src/services/auth.service.js
@@ -1,18 +1,24 @@
-const bcrypt = require('bcryptjs');
-const userModel = require('../models/user.model');
+const bcrypt = require('bcryptjs');
+const userModel = require('../models/user.model');
const verificationTokenModel = require('../models/verification-token.model');
-const { generateSecureToken }= require('../utils/token-generator');
+const refreshTokenModel = require('../models/refresh-token.model');
+const { generateSecureToken, parseDurationToSeconds } = require('../utils/token-generator');
const { sendVerificationEmail } = require('../utils/mailer');
-const AppError = require('../utils/app-error');
-const env = require('../config/env');
+const AppError = require('../utils/app-error');
+const { signAccessToken, signRefreshToken, verifyToken } = require('../config/jwt');
+const env = require('../config/env');
-// CAPTCHA verification
+//=====================================
+// Registration and Email verification
+//=====================================
+
+// CAPTCHA verification
const verifyCaptcha = async (captchaToken) => {
if (!env.RECAPTCHA_SECRET) {
console.warn('[CAPTCHA] Skipping — RECAPTCHA_SECRET not set');
return;
}
- const res = await fetch(
+ const res = await fetch(
`https://www.google.com/recaptcha/api/siteverify?secret=${env.RECAPTCHA_SECRET}&response=${captchaToken}`,
{ method: 'POST' }
);
@@ -23,7 +29,14 @@ const verifyCaptcha = async (captchaToken) => {
};
// Register
-exports.register = async ({ email, password, display_name, gender, date_of_birth, captcha_token }) => {
+exports.register = async ({
+ email,
+ password,
+ display_name,
+ gender,
+ date_of_birth,
+ captcha_token,
+}) => {
// Verify CAPTCHA i can't get a token to test with it now so imma comment it out for now :)
//await verifyCaptcha(captcha_token);
@@ -46,13 +59,13 @@ exports.register = async ({ email, password, display_name, gender, date_of_birth
});
// Create verification token — 24h expiry
- const token = generateSecureToken();
+ const token = generateSecureToken();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
await verificationTokenModel.create({
- user_id: user.id,
+ user_id: user.id,
token,
- type: 'verify_email',
+ type: 'verify_email',
expires_at: expiresAt,
});
@@ -62,15 +75,132 @@ exports.register = async ({ email, password, display_name, gender, date_of_birth
token,
});
- // Return
+ // Return
return {
- user_id: user.id,
- email: user.email,
+ user_id: user.id,
+ email: user.email,
display_name: user.display_name,
- gender: user.gender,
- role: user.role,
- is_verified: user.is_verified,
+ gender: user.gender,
+ role: user.role,
+ is_verified: user.is_verified,
date_of_birth: user.date_of_birth,
- created_at: user.created_at,
+ created_at: user.created_at,
};
-};
\ No newline at end of file
+};
+
+//=====================================
+// Login and token refresh
+//=====================================
+
+const buildAuthResponse = ({ user, accessToken, refreshToken }) => ({
+ access_token: accessToken,
+ refresh_token: refreshToken,
+ token_type: 'Bearer',
+ expires_in: parseDurationToSeconds(env.JWT_ACCESS_EXPIRES_IN),
+ user: {
+ user_id: user.id,
+ email: user.email,
+ display_name: user.display_name,
+ gender: user.gender,
+ role: user.role,
+ is_verified: user.is_verified,
+ },
+ is_new_user: false,
+});
+
+const createAndStoreRefreshToken = async (userId) => {
+ const refreshToken = signRefreshToken({ sub: userId });
+ const refreshExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
+
+ await refreshTokenModel.create({
+ user_id: userId,
+ refresh_token: refreshToken,
+ expires_at: refreshExpiresAt,
+ });
+
+ return refreshToken;
+};
+
+exports.login = async ({ identifier, password }) => {
+ const user = await userModel.findByEmailOrUsername(identifier);
+
+ if (!user) {
+ throw new AppError('Invalid email or password', 401, 'AUTH_INVALID_CREDENTIALS');
+ }
+
+ const passwordOk = await bcrypt.compare(password, user.password_hashed);
+
+ if (!passwordOk) {
+ throw new AppError('Invalid email or password', 401, 'AUTH_INVALID_CREDENTIALS');
+ }
+
+ if (!user.is_verified) {
+ throw new AppError('Email must be verified before login', 422, 'AUTH_EMAIL_NOT_VERIFIED');
+ }
+
+ if (user.is_suspended) {
+ throw new AppError(
+ 'Your account is suspended. Please contact support.',
+ 403,
+ 'AUTH_ACCOUNT_SUSPENDED'
+ );
+ }
+
+ const accessToken = signAccessToken({ sub: user.id, role: user.role });
+ const refreshToken = await createAndStoreRefreshToken(user.id);
+
+ await userModel.updateLastLogin(user.id);
+
+ return buildAuthResponse({ user, accessToken, refreshToken });
+};
+
+exports.refresh = async ({ refresh_token }) => {
+ if (!refresh_token) {
+ throw new AppError(
+ 'Refresh token is missing, invalid, or expired',
+ 401,
+ 'AUTH_REFRESH_TOKEN_INVALID'
+ );
+ }
+
+ let payload;
+ try {
+ payload = verifyToken(refresh_token);
+ } catch {
+ throw new AppError(
+ 'Refresh token is missing, invalid, or expired',
+ 401,
+ 'AUTH_REFRESH_TOKEN_INVALID'
+ );
+ }
+
+ const storedToken = await refreshTokenModel.findValid(refresh_token);
+ if (!storedToken) {
+ throw new AppError(
+ 'Refresh token is missing, invalid, or expired',
+ 401,
+ 'AUTH_REFRESH_TOKEN_INVALID'
+ );
+ }
+
+ const user = await userModel.findById(payload.sub);
+ if (!user) {
+ throw new AppError('User not found', 404, 'AUTH_USER_NOT_FOUND');
+ }
+
+ if (user.is_suspended) {
+ throw new AppError('Account suspended by admin', 403, 'AUTH_ACCOUNT_SUSPENDED');
+ }
+
+ await refreshTokenModel.revoke(refresh_token);
+
+ const accessToken = signAccessToken({ sub: user.id, role: user.role });
+ const newRefreshToken = await createAndStoreRefreshToken(user.id);
+
+ return {
+ access_token: accessToken,
+ token_type: 'Bearer',
+ expires_in: parseDurationToSeconds(env.JWT_ACCESS_EXPIRES_IN),
+ refresh_token: newRefreshToken,
+ };
+};
diff --git a/src/utils/token-generator.js b/src/utils/token-generator.js
index f16f184..2bdc91e 100644
--- a/src/utils/token-generator.js
+++ b/src/utils/token-generator.js
@@ -6,4 +6,15 @@ const crypto = require('crypto');
const generateSecureToken = () => crypto.randomBytes(32).toString('hex');
-module.exports = { generateSecureToken };
+const parseDurationToSeconds = (duration) => {
+ if (!duration) return 900;
+ const match = /^([0-9]+)([smhd])$/.exec(duration.trim());
+ if (!match) return 900;
+
+ const value = Number(match[1]);
+ const unit = match[2];
+ const multipliers = { s: 1, m: 60, h: 3600, d: 86400 };
+ return value * (multipliers[unit] || 1);
+};
+
+module.exports = { generateSecureToken, parseDurationToSeconds };
From d3ffcbd781c1cdfd6e57bed4ebec5720c61d1524 Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Fri, 13 Mar 2026 21:14:50 +0200
Subject: [PATCH 006/535] run migrations
changes pgadmin port to 5050
---
docker-compose.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docker-compose.yml b/docker-compose.yml
index d3cb946..679f134 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -24,7 +24,7 @@ services:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL}
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD}
ports:
- - "8080:80"
+ - "5050:80"
depends_on:
- db
From cdeec85edbf1af478787f63895b35ec3df6f3549 Mon Sep 17 00:00:00 2001
From: basboosa05 <166862890+basboosa05@users.noreply.github.com>
Date: Sat, 14 Mar 2026 00:49:54 +0200
Subject: [PATCH 007/535] Add email verification and logout endpoints
Implement email verification and logout flows: add verifyEmail and logout controllers that validate input, call authService, set/clear an HttpOnly refresh_token cookie (secure in production, sameSite strict, 7d maxAge), and return access tokens. Add auth.service methods: verifyEmail (validate token, mark used, mark user verified, generate access & refresh tokens) and logout (revoke refresh token).
---
src/controllers/auth.controller.js | 42 ++++++++++++++++++++++++++++++
src/routes/auth.routes.js | 15 ++++++-----
src/services/auth.service.js | 36 +++++++++++++++++++++++++
3 files changed, 86 insertions(+), 7 deletions(-)
diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js
index 5acfe9d..8757c3a 100644
--- a/src/controllers/auth.controller.js
+++ b/src/controllers/auth.controller.js
@@ -22,6 +22,34 @@ exports.register = async (req, res) => {
return success(res, data, 'Account created. Please verify your email.', 201);
};
+
+exports.verifyEmail = async (req, res) => {
+ const { token } = req.body;
+
+ if (!token || typeof token !== 'string') {
+ return error(res, 'VALIDATION_FAILED', 'Validation failed', 400, [
+ { field: 'token', issue: 'Verification token is required' },
+ ]);
+ }
+
+ const data = await authService.verifyEmail({ token });
+
+ // Set refresh token as HttpOnly cookie
+ res.cookie('refresh_token', data.refreshToken, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'strict',
+ maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
+ });
+
+
+ return success(res, {
+ access_token: data.accessToken,
+ token_type: 'Bearer',
+ expires_in: data.expires_in,
+ }, 'Email verified successfully.');
+};
+
exports.login = async (req, res) => {
const { identifier, password } = req.body;
@@ -81,3 +109,17 @@ exports.refresh = async (req, res) => {
return success(res, responseData, 'Token refreshed successfully.');
};
+
+exports.logout = async (req, res) => {
+ const refresh_token = req.cookies?.refresh_token;
+
+ await authService.logout({ refresh_token });
+
+ res.clearCookie('refresh_token', {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'strict',
+ });
+
+ return success(res, { success: true }, 'Logged out successfully.');
+};
diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js
index ee33e21..e31c49c 100644
--- a/src/routes/auth.routes.js
+++ b/src/routes/auth.routes.js
@@ -14,15 +14,16 @@ const asyncHandler = require('../utils/async-handler');
router.use(authLimiter);
// POST /api/v1/auth/register
-router.post('/register', validateRegister, asyncHandler(controller.register));
+router.post('/register', validateRegister, asyncHandler(controller.register));
+router.post('/verify-email', asyncHandler(controller.verifyEmail));
+router.post('/login', asyncHandler(controller.login));
+router.post('/refresh', asyncHandler(controller.refresh));
+router.post('/logout', asyncHandler(controller.logout));
-router.post('/login', asyncHandler(controller.login));
-router.post('/refresh', asyncHandler(controller.refresh));
// i didn't implement the controllers yet
-// router.post('/register', asyncHandler(controller.register));
-// router.post('/verify-email', asyncHandler(controller.verifyEmail));
+
+
// router.post('/resend-verification', asyncHandler(controller.resendVerification));
-// router.post('/login', asyncHandler(controller.login));
-// router.post('/logout', asyncHandler(controller.logout));
+
module.exports = router;
diff --git a/src/services/auth.service.js b/src/services/auth.service.js
index bf7ba89..c26ab60 100644
--- a/src/services/auth.service.js
+++ b/src/services/auth.service.js
@@ -204,3 +204,39 @@ exports.refresh = async ({ refresh_token }) => {
refresh_token: newRefreshToken,
};
};
+
+
+// ============================================================
+// Verify Email
+// ============================================================
+exports.verifyEmail = async ({ token }) => {
+ const tokenRow = await verificationTokenModel.findValidToken(token, 'verify_email');
+ if (!tokenRow) {
+ throw new AppError('Invalid or expired verification token', 400, 'AUTH_TOKEN_INVALID');
+ }
+
+ await verificationTokenModel.markUsed(tokenRow.id);
+ await userModel.markVerified(tokenRow.user_id);
+
+
+ const user = await userModel.findById(tokenRow.user_id);
+
+
+ const accessToken = signAccessToken({ sub: user.id, role: user.role });
+ const refreshToken = await createAndStoreRefreshToken(user.id);
+
+ return {
+ accessToken,
+ refreshToken,
+ expires_in: parseDurationToSeconds(env.JWT_ACCESS_EXPIRES_IN),
+ };
+};
+
+// ============================================================
+// Logout
+// ============================================================
+exports.logout = async ({ refresh_token }) => {
+ if (refresh_token) {
+ await refreshTokenModel.revoke(refresh_token);
+ }
+};
\ No newline at end of file
From 2acbdb305b4c418a7a5f676fc5225f50c6864dc7 Mon Sep 17 00:00:00 2001
From: Omar Hamza
Date: Sat, 14 Mar 2026 09:12:54 +0200
Subject: [PATCH 008/535] feat(auth): implement forgot/reset password flow and
add enum constants
Add authentication endpoints for password recovery.
Forgot password:
- Lookup user by email
- Generate reset token with expiry
- Store token via verification token model
- Send password reset email
- Return generic response to prevent account enumeration
Reset password:
- Validate token state (valid, unused, unexpired)
- Update user password
- Mark reset token as used
- Support logout_all by revoking refresh tokens
- Return AUTH_TOKEN_EXPIRED (422) for invalid or expired tokens
Validation:
- Add controller validation for forgot/reset requests
- Validate email, token, password, confirm_password, logout_all
Refactor:
- Introduce enum constants in src/constants
- Replace hardcoded enum values in auth services and validators
---
.eslintrc.js | 2 +-
src/constants/activity-types.js | 7 +++
src/constants/audio-qualities.js | 5 ++
src/constants/embed-types.js | 4 ++
src/constants/gender-types.js | 4 ++
src/constants/geo-restriction-types.js | 5 ++
src/constants/license-types.js | 4 ++
src/constants/messages-from-types.js | 4 ++
src/constants/notification-types.js | 9 ++++
src/constants/oauth-providers.js | 6 +++
src/constants/payment-methods.js | 3 ++
src/constants/payment-statuses.js | 5 ++
src/constants/playlist-types.js | 5 ++
src/constants/reference-types.js | 7 +++
src/constants/report-resource-types.js | 4 ++
src/constants/report-statuses.js | 5 ++
src/constants/search-types.js | 6 +++
src/constants/subscription-plans.js | 4 ++
src/constants/subscription-statuses.js | 6 +++
src/constants/token-types.js | 5 ++
src/constants/track-statuses.js | 5 ++
src/constants/user-roles.js | 5 ++
src/constants/web-profile-platforms.js | 9 ++++
src/controllers/auth.controller.js | 65 +++++++++++++++++++++++---
src/models/user.model.js | 8 ++++
src/routes/auth.routes.js | 4 +-
src/services/auth.service.js | 64 ++++++++++++++++++++-----
src/utils/validators.js | 16 +++++--
28 files changed, 250 insertions(+), 26 deletions(-)
create mode 100644 src/constants/activity-types.js
create mode 100644 src/constants/audio-qualities.js
create mode 100644 src/constants/embed-types.js
create mode 100644 src/constants/gender-types.js
create mode 100644 src/constants/geo-restriction-types.js
create mode 100644 src/constants/license-types.js
create mode 100644 src/constants/messages-from-types.js
create mode 100644 src/constants/notification-types.js
create mode 100644 src/constants/oauth-providers.js
create mode 100644 src/constants/payment-methods.js
create mode 100644 src/constants/payment-statuses.js
create mode 100644 src/constants/playlist-types.js
create mode 100644 src/constants/reference-types.js
create mode 100644 src/constants/report-resource-types.js
create mode 100644 src/constants/report-statuses.js
create mode 100644 src/constants/search-types.js
create mode 100644 src/constants/subscription-plans.js
create mode 100644 src/constants/subscription-statuses.js
create mode 100644 src/constants/token-types.js
create mode 100644 src/constants/track-statuses.js
create mode 100644 src/constants/user-roles.js
create mode 100644 src/constants/web-profile-platforms.js
diff --git a/.eslintrc.js b/.eslintrc.js
index 2cb046d..a0ea858 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -1,5 +1,5 @@
module.exports = {
- env: { node: true, es2021: true },
+ env: { node: true, es2021: true, jest: true },
extends: ['eslint:recommended'],
parserOptions: { ecmaVersion: 'latest', sourceType: 'commonjs' },
rules: {
diff --git a/src/constants/activity-types.js b/src/constants/activity-types.js
new file mode 100644
index 0000000..e6cd6c7
--- /dev/null
+++ b/src/constants/activity-types.js
@@ -0,0 +1,7 @@
+module.exports = {
+ LIKE: 'like',
+ REPOST: 'repost',
+ FOLLOW: 'follow',
+ UPLOAD: 'upload',
+ COMMENT: 'comment',
+};
diff --git a/src/constants/audio-qualities.js b/src/constants/audio-qualities.js
new file mode 100644
index 0000000..63d4ac0
--- /dev/null
+++ b/src/constants/audio-qualities.js
@@ -0,0 +1,5 @@
+module.exports = {
+ LOW: 'low',
+ NORMAL: 'normal',
+ HIGH: 'high',
+};
diff --git a/src/constants/embed-types.js b/src/constants/embed-types.js
new file mode 100644
index 0000000..67470f6
--- /dev/null
+++ b/src/constants/embed-types.js
@@ -0,0 +1,4 @@
+module.exports = {
+ TRACK: 'track',
+ PLAYLIST: 'playlist',
+};
diff --git a/src/constants/gender-types.js b/src/constants/gender-types.js
new file mode 100644
index 0000000..29910a5
--- /dev/null
+++ b/src/constants/gender-types.js
@@ -0,0 +1,4 @@
+module.exports = {
+ MALE: 'male',
+ FEMALE: 'female',
+};
diff --git a/src/constants/geo-restriction-types.js b/src/constants/geo-restriction-types.js
new file mode 100644
index 0000000..9bd3c25
--- /dev/null
+++ b/src/constants/geo-restriction-types.js
@@ -0,0 +1,5 @@
+module.exports = {
+ WORLDWIDE: 'worldwide',
+ EXCLUSIVE_REGIONS: 'exclusive_regions',
+ BLOCKED_REGIONS: 'blocked_regions',
+};
diff --git a/src/constants/license-types.js b/src/constants/license-types.js
new file mode 100644
index 0000000..e811961
--- /dev/null
+++ b/src/constants/license-types.js
@@ -0,0 +1,4 @@
+module.exports = {
+ ALL_RIGHTS_RESERVED: 'all_rights_reserved',
+ CREATIVE_COMMONS: 'creative_commons',
+};
diff --git a/src/constants/messages-from-types.js b/src/constants/messages-from-types.js
new file mode 100644
index 0000000..052e821
--- /dev/null
+++ b/src/constants/messages-from-types.js
@@ -0,0 +1,4 @@
+module.exports = {
+ EVERYONE: 'everyone',
+ FOLLOWERS_ONLY: 'followers_only',
+};
diff --git a/src/constants/notification-types.js b/src/constants/notification-types.js
new file mode 100644
index 0000000..6ba4d35
--- /dev/null
+++ b/src/constants/notification-types.js
@@ -0,0 +1,9 @@
+module.exports = {
+ LIKE: 'like',
+ COMMENT: 'comment',
+ FOLLOW: 'follow',
+ REPOST: 'repost',
+ NEW_MESSAGE: 'new_message',
+ RECOMMENDED_CONTENT: 'recommended_content',
+ NEW_POST_BY_FOLLOWED: 'new_post_by_followed',
+};
diff --git a/src/constants/oauth-providers.js b/src/constants/oauth-providers.js
new file mode 100644
index 0000000..2a18ed0
--- /dev/null
+++ b/src/constants/oauth-providers.js
@@ -0,0 +1,6 @@
+module.exports = {
+ GOOGLE: 'google',
+ FACEBOOK: 'facebook',
+ APPLE: 'apple',
+ SOUNDCLOUD: 'soundcloud',
+};
diff --git a/src/constants/payment-methods.js b/src/constants/payment-methods.js
new file mode 100644
index 0000000..6fd0076
--- /dev/null
+++ b/src/constants/payment-methods.js
@@ -0,0 +1,3 @@
+module.exports = {
+ MOCK: 'mock',
+};
diff --git a/src/constants/payment-statuses.js b/src/constants/payment-statuses.js
new file mode 100644
index 0000000..46b9bdb
--- /dev/null
+++ b/src/constants/payment-statuses.js
@@ -0,0 +1,5 @@
+module.exports = {
+ PENDING: 'pending',
+ PAID: 'paid',
+ FAILED: 'failed',
+};
diff --git a/src/constants/playlist-types.js b/src/constants/playlist-types.js
new file mode 100644
index 0000000..5cc5f65
--- /dev/null
+++ b/src/constants/playlist-types.js
@@ -0,0 +1,5 @@
+module.exports = {
+ REGULAR: 'regular',
+ LIKED_SONGS: 'liked_songs',
+ AUTO_GENERATED: 'auto_generated',
+};
diff --git a/src/constants/reference-types.js b/src/constants/reference-types.js
new file mode 100644
index 0000000..8aaea9f
--- /dev/null
+++ b/src/constants/reference-types.js
@@ -0,0 +1,7 @@
+module.exports = {
+ TRACK: 'track',
+ PLAYLIST: 'playlist',
+ ALBUM: 'album',
+ COMMENT: 'comment',
+ USER: 'user',
+};
diff --git a/src/constants/report-resource-types.js b/src/constants/report-resource-types.js
new file mode 100644
index 0000000..541d1df
--- /dev/null
+++ b/src/constants/report-resource-types.js
@@ -0,0 +1,4 @@
+module.exports = {
+ TRACK: 'track',
+ USER: 'user',
+};
diff --git a/src/constants/report-statuses.js b/src/constants/report-statuses.js
new file mode 100644
index 0000000..00b41b8
--- /dev/null
+++ b/src/constants/report-statuses.js
@@ -0,0 +1,5 @@
+module.exports = {
+ PENDING: 'pending',
+ RESOLVED: 'resolved',
+ DISMISSED: 'dismissed',
+};
diff --git a/src/constants/search-types.js b/src/constants/search-types.js
new file mode 100644
index 0000000..0ea5fb4
--- /dev/null
+++ b/src/constants/search-types.js
@@ -0,0 +1,6 @@
+module.exports = {
+ TRACK: 'track',
+ USER: 'user',
+ PLAYLIST: 'playlist',
+ ALBUM: 'album',
+};
diff --git a/src/constants/subscription-plans.js b/src/constants/subscription-plans.js
new file mode 100644
index 0000000..97b31a8
--- /dev/null
+++ b/src/constants/subscription-plans.js
@@ -0,0 +1,4 @@
+module.exports = {
+ FREE: 'free',
+ PREMIUM: 'premium',
+};
diff --git a/src/constants/subscription-statuses.js b/src/constants/subscription-statuses.js
new file mode 100644
index 0000000..f20dc3e
--- /dev/null
+++ b/src/constants/subscription-statuses.js
@@ -0,0 +1,6 @@
+module.exports = {
+ PENDING: 'pending',
+ ACTIVE: 'active',
+ EXPIRED: 'expired',
+ CANCELED: 'canceled',
+};
diff --git a/src/constants/token-types.js b/src/constants/token-types.js
new file mode 100644
index 0000000..8edef97
--- /dev/null
+++ b/src/constants/token-types.js
@@ -0,0 +1,5 @@
+module.exports = {
+ VERIFY_EMAIL: 'verify_email',
+ RESET_PASSWORD: 'reset_password',
+ CHANGE_EMAIL: 'change_email',
+};
diff --git a/src/constants/track-statuses.js b/src/constants/track-statuses.js
new file mode 100644
index 0000000..26c9672
--- /dev/null
+++ b/src/constants/track-statuses.js
@@ -0,0 +1,5 @@
+module.exports = {
+ PROCESSING: 'processing',
+ READY: 'ready',
+ FAILED: 'failed',
+};
diff --git a/src/constants/user-roles.js b/src/constants/user-roles.js
new file mode 100644
index 0000000..91b1899
--- /dev/null
+++ b/src/constants/user-roles.js
@@ -0,0 +1,5 @@
+module.exports = {
+ ARTIST: 'artist',
+ LISTENER: 'listener',
+ ADMIN: 'admin',
+};
diff --git a/src/constants/web-profile-platforms.js b/src/constants/web-profile-platforms.js
new file mode 100644
index 0000000..79d2354
--- /dev/null
+++ b/src/constants/web-profile-platforms.js
@@ -0,0 +1,9 @@
+module.exports = {
+ INSTAGRAM: 'instagram',
+ TWITTER: 'twitter',
+ YOUTUBE: 'youtube',
+ TIKTOK: 'tiktok',
+ SOUNDCLOUD: 'soundcloud',
+ WEBSITE: 'website',
+ OTHER: 'other',
+};
diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js
index 8757c3a..02e3ed7 100644
--- a/src/controllers/auth.controller.js
+++ b/src/controllers/auth.controller.js
@@ -6,6 +6,7 @@
const authService = require('../services/auth.service');
const { success } = require('../utils/api-response');
const { error } = require('../utils/api-response');
+const { isValidEmail, isValidPassword } = require('../utils/validators');
exports.register = async (req, res) => {
const { email, password, display_name, gender, date_of_birth, captcha_token } = req.body;
@@ -22,7 +23,6 @@ exports.register = async (req, res) => {
return success(res, data, 'Account created. Please verify your email.', 201);
};
-
exports.verifyEmail = async (req, res) => {
const { token } = req.body;
@@ -42,12 +42,15 @@ exports.verifyEmail = async (req, res) => {
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
-
- return success(res, {
- access_token: data.accessToken,
- token_type: 'Bearer',
- expires_in: data.expires_in,
- }, 'Email verified successfully.');
+ return success(
+ res,
+ {
+ access_token: data.accessToken,
+ token_type: 'Bearer',
+ expires_in: data.expires_in,
+ },
+ 'Email verified successfully.'
+ );
};
exports.login = async (req, res) => {
@@ -123,3 +126,51 @@ exports.logout = async (req, res) => {
return success(res, { success: true }, 'Logged out successfully.');
};
+
+exports.requestPasswordReset = 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' },
+ ]);
+ }
+
+ await authService.requestPasswordReset({ email });
+ return success(
+ res,
+ { success: true },
+ 'If an account with that email exists, a password reset link has been sent.'
+ );
+};
+
+exports.resetPassword = async (req, res) => {
+ const { token, new_password, confirm_password, logout_all = true } = req.body;
+ const details = [];
+
+ if (!token || typeof token !== 'string' || !token.trim()) {
+ details.push({ field: 'token', issue: 'Reset token is required' });
+ }
+
+ if (!new_password || typeof new_password !== 'string' || !isValidPassword(new_password)) {
+ details.push({
+ field: 'new_password',
+ issue: 'Min 8 characters, must include uppercase, lowercase, and a number',
+ });
+ }
+
+ if (confirm_password !== new_password) {
+ details.push({ field: 'confirm_password', issue: 'Must exactly match new_password' });
+ }
+
+ if (typeof logout_all !== 'boolean') {
+ details.push({ field: 'logout_all', issue: 'Must be a boolean' });
+ }
+
+ if (details.length > 0) {
+ return error(res, 'VALIDATION_FAILED', 'Validation failed', 400, details);
+ }
+
+ await authService.resetPassword({ token: token.trim(), new_password, logout_all });
+ return success(res, { success: true }, 'Password has been reset successfully.');
+};
diff --git a/src/models/user.model.js b/src/models/user.model.js
index e42893a..fb96617 100644
--- a/src/models/user.model.js
+++ b/src/models/user.model.js
@@ -70,3 +70,11 @@ exports.updateLastLogin = async (userId) => {
[userId]
);
};
+
+// update password (called during password reset)
+exports.updatePassword = async (userId, newPasswordHashed) => {
+ await db.query(
+ `UPDATE users SET password_hashed = $1, updated_at = now() WHERE id = $2`,
+ [newPasswordHashed, userId]
+ );
+};
diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js
index e31c49c..ae14428 100644
--- a/src/routes/auth.routes.js
+++ b/src/routes/auth.routes.js
@@ -18,7 +18,9 @@ router.post('/register', validateRegister, asyncHandler(controller.register))
router.post('/verify-email', asyncHandler(controller.verifyEmail));
router.post('/login', asyncHandler(controller.login));
router.post('/refresh', asyncHandler(controller.refresh));
-router.post('/logout', asyncHandler(controller.logout));
+router.post('/logout', asyncHandler(controller.logout));
+router.post('/forgot-password', asyncHandler(controller.requestPasswordReset));
+router.post('/reset-password', asyncHandler(controller.resetPassword));
// i didn't implement the controllers yet
diff --git a/src/services/auth.service.js b/src/services/auth.service.js
index c26ab60..1adbb5f 100644
--- a/src/services/auth.service.js
+++ b/src/services/auth.service.js
@@ -3,7 +3,8 @@ const userModel = require('../models/user.model');
const verificationTokenModel = require('../models/verification-token.model');
const refreshTokenModel = require('../models/refresh-token.model');
const { generateSecureToken, parseDurationToSeconds } = require('../utils/token-generator');
-const { sendVerificationEmail } = require('../utils/mailer');
+const TOKEN_TYPES = require('../constants/token-types');
+const { sendVerificationEmail, sendPasswordResetEmail } = require('../utils/mailer');
const AppError = require('../utils/app-error');
const { signAccessToken, signRefreshToken, verifyToken } = require('../config/jwt');
const env = require('../config/env');
@@ -65,7 +66,7 @@ exports.register = async ({
await verificationTokenModel.create({
user_id: user.id,
token,
- type: 'verify_email',
+ type: TOKEN_TYPES.VERIFY_EMAIL,
expires_at: expiresAt,
});
@@ -198,19 +199,18 @@ exports.refresh = async ({ refresh_token }) => {
const newRefreshToken = await createAndStoreRefreshToken(user.id);
return {
- access_token: accessToken,
- token_type: 'Bearer',
- expires_in: parseDurationToSeconds(env.JWT_ACCESS_EXPIRES_IN),
- refresh_token: newRefreshToken,
- };
+ access_token: accessToken,
+ token_type: 'Bearer',
+ expires_in: parseDurationToSeconds(env.JWT_ACCESS_EXPIRES_IN),
+ refresh_token: newRefreshToken,
+ };
};
-
// ============================================================
// Verify Email
// ============================================================
exports.verifyEmail = async ({ token }) => {
- const tokenRow = await verificationTokenModel.findValidToken(token, 'verify_email');
+ const tokenRow = await verificationTokenModel.findValidToken(token, TOKEN_TYPES.VERIFY_EMAIL);
if (!tokenRow) {
throw new AppError('Invalid or expired verification token', 400, 'AUTH_TOKEN_INVALID');
}
@@ -218,10 +218,8 @@ exports.verifyEmail = async ({ token }) => {
await verificationTokenModel.markUsed(tokenRow.id);
await userModel.markVerified(tokenRow.user_id);
-
const user = await userModel.findById(tokenRow.user_id);
-
const accessToken = signAccessToken({ sub: user.id, role: user.role });
const refreshToken = await createAndStoreRefreshToken(user.id);
@@ -239,4 +237,46 @@ exports.logout = async ({ refresh_token }) => {
if (refresh_token) {
await refreshTokenModel.revoke(refresh_token);
}
-};
\ No newline at end of file
+};
+
+// ============================================================
+// Forgot Password and Reset Password
+// ============================================================
+exports.requestPasswordReset = async ({ email }) => {
+ const user = await userModel.findByEmail(email);
+ if (!user) {
+ return;
+ }
+
+ // Create password reset token — 1h expiry
+ const token = generateSecureToken();
+ const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
+
+ await verificationTokenModel.create({
+ user_id: user.id,
+ token,
+ type: TOKEN_TYPES.RESET_PASSWORD,
+ expires_at: expiresAt,
+ });
+
+ // Send password reset email
+ await sendPasswordResetEmail(email, {
+ displayName: user.display_name,
+ token,
+ });
+};
+
+exports.resetPassword = async ({ token, new_password, logout_all = true }) => {
+ const tokenRow = await verificationTokenModel.findValidToken(token, TOKEN_TYPES.RESET_PASSWORD);
+ if (!tokenRow) {
+ throw new AppError('Password reset token expired', 422, 'AUTH_TOKEN_EXPIRED');
+ }
+
+ const password_hashed = await bcrypt.hash(new_password, 12);
+ await userModel.updatePassword(tokenRow.user_id, password_hashed);
+ await verificationTokenModel.markUsed(tokenRow.id);
+
+ if (logout_all) {
+ await refreshTokenModel.revokeAllForUser(tokenRow.user_id);
+ }
+};
diff --git a/src/utils/validators.js b/src/utils/validators.js
index e5b0b08..3791363 100644
--- a/src/utils/validators.js
+++ b/src/utils/validators.js
@@ -2,15 +2,21 @@
// utils/validators.js — Shared input validation helpers
// ============================================================
+const GENDER_TYPES = require('../constants/gender-types');
+
const isValidEmail = (email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
const isValidUrl = (url) => {
- try { new URL(url); return true; } catch { return false; }
+ try {
+ new URL(url);
+ return true;
+ } catch {
+ return false;
+ }
};
// Password: min 8 chars, at least 1 uppercase, 1 lowercase, 1 number
-const isValidPassword = (password) =>
- /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/.test(password);
+const isValidPassword = (password) => /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/.test(password);
// display_name: 1–50 chars, not empty
const isValidDisplayName = (name) =>
@@ -26,7 +32,7 @@ const isValidDateOfBirth = (dob) => {
};
// gender: must match your DB enum
-const isValidGender = (gender) => ['male', 'female'].includes(gender);
+const isValidGender = (gender) => Object.values(GENDER_TYPES).includes(gender);
module.exports = {
isValidEmail,
@@ -35,4 +41,4 @@ module.exports = {
isValidDisplayName,
isValidDateOfBirth,
isValidGender,
-};
\ No newline at end of file
+};
From b5bf1fb4a0d8a1a8ca58fc0f1e25cd4e4ba0d448 Mon Sep 17 00:00:00 2001
From: Omar Hamza
Date: Sat, 14 Mar 2026 15:56:11 +0200
Subject: [PATCH 009/535] feat(users): implement user profile endpoints with
privacy rules
Add users profile endpoints:
GET /users/me (authenticated)
PATCH /users/me (authenticated)
GET /users/{user_id} (optional authentication)
Controllers:
- implement getMe, getUserById, updateMe handlers
Services:
- fetch full authenticated profile
- fetch public profile by id
- enforce private profile visibility (owner or follower only)
- update own profile fields
- validate username uniqueness
Models:
- add profile-related queries:
findFullById
findPublicById
isFollowing
updateProfile
Middleware:
- add optional authentication middleware for public profile endpoint
Error handling aligned with API spec:
- 403 RESOURCE_PRIVATE for inaccessible private profiles
- 404 RESOURCE_NOT_FOUND for missing users
- 409 RESOURCE_ALREADY_EXISTS for username conflicts
- 401 AUTH_TOKEN_MISSING / AUTH_TOKEN_INVALID for protected routes
---
src/controllers/users.controller.js | 40 ++++++++++++----
src/middleware/auth.js | 22 +++++++--
src/models/user.model.js | 71 +++++++++++++++++++++++++++++
src/routes/users.routes.js | 11 +++--
src/services/users.service.js | 65 +++++++++++++++++++++-----
5 files changed, 181 insertions(+), 28 deletions(-)
diff --git a/src/controllers/users.controller.js b/src/controllers/users.controller.js
index b915d32..7b05d01 100644
--- a/src/controllers/users.controller.js
+++ b/src/controllers/users.controller.js
@@ -4,11 +4,35 @@
// Receives validated requests → calls service → returns HTTP response
// ============================================================
const usersService = require('../services/users.service');
-const { success, error } = require('../utils/api-response');
-
-// TODO: Implement controller methods
-// Example:
-// exports.getAll = async (req, res) => {
-// const data = await usersService.getAll(req.query);
-// return success(res, data);
-// };
+const { success } = require('../utils/api-response');
+
+exports.getMe = async (req, res) => {
+ const data = await usersService.getMe(req.user.sub);
+ return success(res, data, 'Own profile returned successfully.', 200);
+};
+
+exports.getUserById = async (req, res) => {
+ const targetId = req.params.user_id;
+ const requesterId = req.user?.sub || null; // requesterId is optional for this endpoint
+
+ const data = await usersService.getUserById(targetId, requesterId);
+
+ return success(res, data, 'User profile returned successfully.', 200);
+};
+
+exports.updateMe = async (req, res) => {
+ const userId = req.user.sub;
+ const { display_name, username, first_name, last_name, bio, city, country } = req.body;
+
+ const data = await usersService.updateMe(userId, {
+ display_name,
+ username,
+ first_name,
+ last_name,
+ bio,
+ city,
+ country,
+ });
+
+ return success(res, data, 'Profile updated successfully.');
+};
diff --git a/src/middleware/auth.js b/src/middleware/auth.js
index d0ba3ac..22f595c 100644
--- a/src/middleware/auth.js
+++ b/src/middleware/auth.js
@@ -8,15 +8,31 @@ const { error } = require('../utils/api-response');
const authenticate = (req, res, next) => {
const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
- return error(res, 'Access token required', 401);
+ return error(res, 'AUTH_TOKEN_MISSING', 'Authorization header missing', 401);
}
const token = authHeader.split(' ')[1];
try {
req.user = verifyToken(token);
next();
} catch {
- return error(res, 'Invalid or expired token', 401);
+ return error(res, 'AUTH_TOKEN_INVALID', 'Invalid or expired access token', 401);
}
};
-module.exports = { authenticate };
+// Optional authentication middleware for routes that can be accessed with or without a token
+const optionalAuthenticate = (req, res, next) => {
+ const authHeader = req.headers['authorization'];
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
+ req.user = null;
+ return next();
+ }
+ const token = authHeader.split(' ')[1];
+ try {
+ req.user = verifyToken(token);
+ } catch {
+ req.user = null;
+ }
+ next();
+};
+
+module.exports = { authenticate, optionalAuthenticate };
diff --git a/src/models/user.model.js b/src/models/user.model.js
index e42893a..1a8b229 100644
--- a/src/models/user.model.js
+++ b/src/models/user.model.js
@@ -70,3 +70,74 @@ exports.updateLastLogin = async (userId) => {
[userId]
);
};
+
+// get user profile (called in GET /users/me)
+exports.findFullById = async (id) => {
+ const { rows } = await db.query(
+ `SELECT
+ id, email, username, display_name, first_name, last_name,
+ bio, city, country, gender, date_of_birth, role,
+ profile_picture, cover_photo, is_private, is_verified,
+ is_suspended, twofa_enabled, followers_count, following_count,
+ last_login_at, created_at, updated_at
+ FROM users
+ WHERE id = $1 AND deleted_at IS NULL`,
+ [id]
+ );
+ return rows[0] || null;
+}
+
+// get public user profile (called in GET /users/:id)
+exports.findPublicById = async (id) => {
+ const { rows } = await db.query(
+ `SELECT
+ id, display_name, username, bio, city, country,
+ gender, role, profile_picture, cover_photo,
+ is_private, is_verified, followers_count, following_count,
+ created_at
+ FROM users
+ WHERE id = $1 AND deleted_at IS NULL`,
+ [id]
+ );
+ return rows[0] || null;
+}
+
+// check if user A is following user B (used in GET /users/:id to determine if we can show private profile)
+exports.isFollowing = async (followerId, followingId) => {
+ const { rows } = await db.query(
+ `SELECT 1 FROM follows WHERE follower_id = $1 AND following_id = $2`,
+ [followerId, followingId]
+ );
+ return rows.length > 0;
+};
+
+exports.updateProfile = async (userId, fields) => {
+ const allowed =['display_name', 'username', 'first_name', 'last_name', 'bio', 'city', 'country'];
+ const updates = [];
+ const values = [];
+ let i = 1;
+
+ for (const key in fields) {
+ if (allowed.includes(key)) {
+ updates.push(`${key} = $${i}`);
+ values.push(fields[key]);
+ i++;
+ }
+ }
+
+ if (updates.length === 0) return null;
+
+ updates.push(`updated_at = now()`);
+ values.push(userId);
+
+ const { rows } = await db.query(
+ `UPDATE users SET ${updates.join(', ')} WHERE id = $${i}
+ RETURNING
+ id, email, username, display_name, first_name, last_name,
+ bio, city, country, gender, date_of_birth, role,
+ profile_picture, cover_photo, is_private, is_verified,
+ followers_count, following_count, created_at, updated_at`,
+ values
+ );
+ return rows[0]||null;
+};
\ No newline at end of file
diff --git a/src/routes/users.routes.js b/src/routes/users.routes.js
index 11b0c69..7297688 100644
--- a/src/routes/users.routes.js
+++ b/src/routes/users.routes.js
@@ -6,11 +6,12 @@
const express = require('express');
const router = express.Router();
const controller = require('../controllers/users.controller');
-const { authenticate } = require('../middleware/auth');
+const { authenticate, optionalAuthenticate } = require('../middleware/auth');
const asyncHandler = require('../utils/async-handler');
-// TODO: Add route definitions here
-// Example:
-// router.get('/', authenticate, asyncHandler(controller.getAll));
-module.exports = router;
+router.get('/me', authenticate ,asyncHandler(controller.getMe));
+router.patch('/me', authenticate, asyncHandler(controller.updateMe));
+router.get('/:user_id', optionalAuthenticate, asyncHandler(controller.getUserById));
+
+module.exports = router;
\ No newline at end of file
diff --git a/src/services/users.service.js b/src/services/users.service.js
index 3ea3d44..cee1abe 100644
--- a/src/services/users.service.js
+++ b/src/services/users.service.js
@@ -1,12 +1,53 @@
-// ============================================================
-// services/users.service.js
-// Owner : Omar Hamdy (BE-1)
-// All business logic, rules & cross-module orchestration
-// No direct SQL here — delegate to models/
-// ============================================================
-
-// TODO: Implement service methods
-// Example:
-// exports.getAll = async (query) => {
-// return await usersModel.findAll(query);
-// };
+const userModel = require('../models/user.model');
+const AppError = require('../utils/app-error');
+
+// full private profile for authenticated user
+exports.getMe = async (userId) => {
+ const user = await userModel.findFullById(userId);
+ if (!user) {
+ throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+ return user;
+};
+
+exports.getUserById = async (targetId, requesterId) => {
+ const user = await userModel.findPublicById(targetId);
+ if (!user) {
+ throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+
+ if (!user.is_private) {
+ return user;
+ }
+
+ if (!requesterId) {
+ throw new AppError('This profile is private.', 403, 'RESOURCE_PRIVATE');
+ }
+
+ if (requesterId === targetId) {
+ return user;
+ }
+
+ const following = await userModel.isFollowing(requesterId, targetId);
+ if (!following) {
+ throw new AppError('This profile is private.', 403, 'RESOURCE_PRIVATE');
+ }
+
+ return user;
+};
+
+exports.updateMe = async (userId, fields) => {
+ if (fields.username) {
+ const existing = await userModel.findByUsername(fields.username);
+ if (existing && existing.id !== userId) {
+ throw new AppError('Username already taken.', 409, 'RESOURCE_ALREADY_EXISTS');
+ }
+ }
+
+ const updatedUser = await userModel.updateProfile(userId, fields);
+ if (!updatedUser) {
+ throw new AppError('Nothing to update', 400, 'VALIDATION_FAILED');
+ }
+
+ return updatedUser;
+};
From a0290514a728029070208cd6092d5c02b5d03137 Mon Sep 17 00:00:00 2001
From: Omar Hamza
Date: Sat, 14 Mar 2026 16:38:58 +0200
Subject: [PATCH 010/535] feat(users): add account settings and role-switch
endpoints
Add authenticated user endpoints:
PATCH /users/me/account
- update account-level fields (date_of_birth, gender)
PATCH /users/me/role
- switch role between artist and listener
Controllers:
- add updateMyAccount handler for partial account updates
- add switchRole handler for self role switching
- improve updateMe to update only provided fields
Services:
- implement validation for gender and date_of_birth
- enforce minimum age requirement (13+)
- validate role switching (artist/listener only)
- return conflict when requested role already assigned
Models:
- add updateAccount query for date_of_birth and gender
- add updateRole query for role updates
Routes:
- wire new endpoints in users routes module
Error handling aligned with API contract:
- 400 for validation failures
- 404 when user not found
- 409 when role already assigned
---
src/controllers/users.controller.js | 43 +++++++++++++++------
src/models/user.model.js | 45 +++++++++++++++++++++-
src/routes/users.routes.js | 3 ++
src/services/users.service.js | 59 +++++++++++++++++++++++++++++
4 files changed, 137 insertions(+), 13 deletions(-)
diff --git a/src/controllers/users.controller.js b/src/controllers/users.controller.js
index 7b05d01..9c62d0b 100644
--- a/src/controllers/users.controller.js
+++ b/src/controllers/users.controller.js
@@ -21,18 +21,37 @@ exports.getUserById = async (req, res) => {
};
exports.updateMe = async (req, res) => {
- const userId = req.user.sub;
- const { display_name, username, first_name, last_name, bio, city, country } = req.body;
-
- const data = await usersService.updateMe(userId, {
- display_name,
- username,
- first_name,
- last_name,
- bio,
- city,
- country,
- });
+ const fields = {};
+
+ if (req.body.display_name !== undefined) fields.display_name = req.body.display_name;
+ if (req.body.username !== undefined) fields.username = req.body.username;
+ if (req.body.first_name !== undefined) fields.first_name = req.body.first_name;
+ if (req.body.last_name !== undefined) fields.last_name = req.body.last_name;
+ if (req.body.bio !== undefined) fields.bio = req.body.bio;
+ if (req.body.city !== undefined) fields.city = req.body.city;
+ if (req.body.country !== undefined) fields.country = req.body.country;
+ const data = await usersService.updateMe(req.user.sub, fields);
return success(res, data, 'Profile updated successfully.');
};
+
+
+exports.updateMyAccount = async (req, res) => {
+ const fields = {};
+
+ if (req.body.date_of_birth !== undefined) fields.date_of_birth = req.body.date_of_birth;
+ if (req.body.gender !== undefined) fields.gender = req.body.gender;
+
+ const data = await usersService.updateMyAccount(req.user.sub, fields);
+ return success(res, data, 'Account updated successfully.');
+};
+
+
+exports.switchRole = async (req, res) => {
+ const userId = req.user.sub;
+ const {role} = req.body;
+
+ const data = await usersService.switchRole(userId, role);
+
+ return success(res, data, 'Role switched successfully.');
+};
\ No newline at end of file
diff --git a/src/models/user.model.js b/src/models/user.model.js
index 1a8b229..3a03421 100644
--- a/src/models/user.model.js
+++ b/src/models/user.model.js
@@ -140,4 +140,47 @@ exports.updateProfile = async (userId, fields) => {
values
);
return rows[0]||null;
-};
\ No newline at end of file
+};
+
+exports.updateAccount = async (userId, fields) => {
+ const allowed = ['date_of_birth', 'gender'];
+ const updates = [];
+ const values = [];
+ let i = 1;
+
+ for (const key in fields) {
+ if (allowed.includes(key)) {
+ updates.push(`${key} = $${i}`);
+ values.push(fields[key]);
+ i++;
+ }
+ }
+
+ if (updates.length === 0) return null;
+
+ updates.push(`updated_at = now()`);
+ values.push(userId);
+
+ const { rows } = await db.query(
+ `UPDATE users SET ${updates.join(', ')} WHERE id = $${i}
+ RETURNING
+ id, email, username, display_name, first_name, last_name,
+ bio, city, country, gender, date_of_birth, role,
+ profile_picture, cover_photo, is_private, is_verified,
+ followers_count, following_count, created_at, updated_at`,
+ values
+ );
+ return rows[0] || null;
+}
+
+exports.updateRole = async (userId, newRole) => {
+ const { rows } = await db.query(
+ `UPDATE users SET role = $1, updated_at = now()
+ WHERE id = $2 AND deleted_at IS NULL
+ RETURNING
+ id, email, username, display_name, role,
+ is_verified, followers_count, following_count, updated_at`,
+ [newRole, userId]
+ );
+ return rows[0] || null;
+}
\ No newline at end of file
diff --git a/src/routes/users.routes.js b/src/routes/users.routes.js
index 7297688..016982e 100644
--- a/src/routes/users.routes.js
+++ b/src/routes/users.routes.js
@@ -12,6 +12,9 @@ const asyncHandler = require('../utils/async-handler');
router.get('/me', authenticate ,asyncHandler(controller.getMe));
router.patch('/me', authenticate, asyncHandler(controller.updateMe));
+router.patch('/me/account', authenticate, asyncHandler(controller.updateMyAccount));
+router.patch('/me/role', authenticate, asyncHandler(controller.switchRole));
+
router.get('/:user_id', optionalAuthenticate, asyncHandler(controller.getUserById));
module.exports = router;
\ No newline at end of file
diff --git a/src/services/users.service.js b/src/services/users.service.js
index cee1abe..bab68cb 100644
--- a/src/services/users.service.js
+++ b/src/services/users.service.js
@@ -51,3 +51,62 @@ exports.updateMe = async (userId, fields) => {
return updatedUser;
};
+
+exports.updateMyAccount = async (userId, fields) => {
+ // validate gender if provided
+
+ if (fields.gender && !['male', 'female'].includes(fields.gender)) {
+ throw new AppError('Validation failed', 400, 'VALIDATION_FAILED', [
+ { field: 'gender', issue: 'Must be one of male, female' }
+ ]);
+ }
+
+ if (fields.date_of_birth) {
+ const date = new Date(fields.date_of_birth);
+
+ if (isNaN(date.getTime())) {
+ throw new AppError('Validation failed', 400, 'VALIDATION_FAILED', [
+ { field: 'date_of_birth', issue: 'Must be a valid date (YYYY-MM-DD)' }
+ ]);
+ }
+
+ const minAge = new Date();
+ minAge.setFullYear(minAge.getFullYear() - 13);
+
+ if (date > minAge) {
+ throw new AppError('Validation failed', 400, 'VALIDATION_FAILED', [
+ { field: 'date_of_birth', issue: 'You must be at least 13 years old' }
+ ]);
+ }
+ }
+
+ const updated = await userModel.updateAccount(userId, fields);
+ if (!updated) {
+ throw new AppError('Nothing to update', 400, 'VALIDATION_FAILED');
+ }
+
+ return updated;
+};
+
+
+// PATCH /users/me/role
+exports.switchRole = async (userId, role) => {
+ // only artist and listener are self-assignable
+ if (!['artist', 'listener'].includes(role)) {
+ throw new AppError('Validation failed', 400, 'VALIDATION_FAILED', [
+ { field: 'role', issue: 'Must be one of artist, listener' }
+ ]);
+ }
+
+ // get current user to check existing role
+ const user = await userModel.findById(userId);
+ if (!user) {
+ throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+
+ if (user.role === role) {
+ throw new AppError(`User already has the ${role} role.`, 409, 'RESOURCE_ALREADY_EXISTS');
+ }
+
+ return await userModel.updateRole(userId, role);
+};
\ No newline at end of file
From c3dd3ce4bfc05e12c68b390095c7ae9a774cbb4c Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Sat, 14 Mar 2026 17:01:31 +0200
Subject: [PATCH 011/535] feat(messages): add POST /messages/new endpoint
-Creates a new 1-to-1 conversation and sends the first message.
-Appends to existing conversation if one already exists.
-Includes block check, messages_from preference, and input validation.
---
src/controllers/messages.controller.js | 34 ++++---
src/models/message.model.js | 126 ++++++++++++++++++++++---
src/routes/messages.routes.js | 13 +--
src/services/messages.service.js | 101 +++++++++++++++++---
4 files changed, 231 insertions(+), 43 deletions(-)
diff --git a/src/controllers/messages.controller.js b/src/controllers/messages.controller.js
index 3994dad..4eb102b 100644
--- a/src/controllers/messages.controller.js
+++ b/src/controllers/messages.controller.js
@@ -1,14 +1,26 @@
-// ============================================================
-// controllers/messages.controller.js
-// Owner : Alyaa Mohamed (BE-4)
-// Receives validated requests → calls service → returns HTTP response
-// ============================================================
+
const messagesService = require('../services/messages.service');
const { success, error } = require('../utils/api-response');
-// TODO: Implement controller methods
-// Example:
-// exports.getAll = async (req, res) => {
-// const data = await messagesService.getAll(req.query);
-// return success(res, data);
-// };
+// POST /messages/new
+exports.startConversation = async (req, res) => {
+ const senderId = req.user.sub;
+ const { recipient_id, body, resource } = req.body;
+
+ if (!recipient_id) {
+ return error(res, 'VALIDATION_FAILED', 'recipient_id is required.', 400);
+ }
+
+ const { conversation, message, isNew } = await messagesService.startConversation({
+ senderId,
+ recipientId: recipient_id,
+ body,
+ resource,
+ });
+
+ if (isNew) {
+ return success(res, { conversation, message }, 'Conversation created and first message sent.', 201);
+ }
+
+ return success(res, { message }, 'Message sent.', 200);
+};
\ No newline at end of file
diff --git a/src/models/message.model.js b/src/models/message.model.js
index 7f6936c..bb3cc2f 100644
--- a/src/models/message.model.js
+++ b/src/models/message.model.js
@@ -1,13 +1,117 @@
-// ============================================================
-// models/message.model.js — PostgreSQL queries for Message
-// Entity attributes: Message_id, Conversation_id, Sender_Id, Content, Is_read, Created_at
-// All SQL lives HERE — no SQL outside models/
-// ============================================================
+
+
const db = require('../config/db');
-// TODO: Implement query methods
-// Example:
-// exports.findById = async (id) => {
-// const { rows } = await db.query('SELECT * FROM messages WHERE id = $1', [id]);
-// return rows[0] || null;
-// };
+// ------------------------------------------------------------
+// Conversation queries
+// ------------------------------------------------------------
+
+/**
+ * Find an existing conversation between two users.
+ * Always pass (MIN(id), MAX(id)) — enforced by DB CHECK constraint.
+ */
+exports.findConversationByPair = async (userAId, userBId) => {
+ const { rows } = await db.query(
+ `SELECT * FROM conversations
+ WHERE user_a_id = $1 AND user_b_id = $2`, //$1, $2 for security because it prevents SQL injection
+ [userAId, userBId]
+ );
+ return rows[0] || null;
+};
+
+/**
+ * Create a new conversation between two users.
+ * Caller must guarantee userAId < userBId (UUID string comparison).
+ */
+exports.createConversation = async (userAId, userBId) => {
+ const { rows } = await db.query(
+ `INSERT INTO conversations (user_a_id, user_b_id)
+ VALUES ($1, $2)
+ RETURNING *`,
+ [userAId, userBId]
+ );
+ return rows[0];
+};
+
+// ------------------------------------------------------------
+// Message queries
+// ------------------------------------------------------------
+
+/**
+ * Insert a new message into a conversation.
+ * content = body (DB stores as 'content', API exposes as 'body') [v3-FIX-21]
+ */
+exports.createMessage = async ({ conversationId, senderId, body, embedType, embedId }) => {
+ const { rows } = await db.query(
+ `INSERT INTO messages (conversation_id, sender_id, content, embed_type, embed_id)
+ VALUES ($1, $2, $3, $4, $5)
+ RETURNING
+ id,
+ conversation_id,
+ sender_id,
+ content AS body,
+ embed_type,
+ embed_id,
+ is_read,
+ created_at`,
+ [conversationId, senderId, body ?? null, embedType ?? null, embedId ?? null]
+ );
+ return rows[0];
+};
+
+
+// Block & preference checks
+
+
+/**
+ * Returns true if blockerID has blocked blockedId. ////another module responisbility?? BESHOY
+ */
+exports.isBlocked = async (blockerId, blockedId) => {
+ const { rows } = await db.query(
+ `SELECT 1 FROM blocks
+ WHERE blocker_id = $1 AND blocked_id = $2`,
+ [blockerId, blockedId]
+ );
+ return rows.length > 0;
+};
+
+/**
+ * Returns the recipient's messages_from preference.
+ * Possible values: 'everyone' | 'followers_only'
+ * Returns 'everyone' as default if no preference row exists.
+ */
+exports.getMessagesFromPreference = async (userId) => {
+ const { rows } = await db.query(
+ `SELECT messages_from FROM notification_preferences
+ WHERE user_id = $1`,
+ [userId]
+ );
+ return rows[0]?.messages_from ?? 'everyone';
+};
+
+/**
+ * Returns true if followerId is following followingId.
+ */
+exports.isFollowing = async (followerId, followingId) => { //another module responisbility?? BESHOY
+ const { rows } = await db.query(
+ `SELECT 1 FROM follows
+ WHERE follower_id = $1 AND following_id = $2`,
+ [followerId, followingId]
+ );
+ return rows.length > 0;
+};
+
+
+/**
+ * Returns the user row (id only) if the user exists and is not deleted/suspended.
+ */
+exports.findActiveUserById = async (userId) => {
+ const { rows } = await db.query(
+ `SELECT id FROM users
+ WHERE id = $1
+ AND deleted_at IS NULL
+ AND is_suspended = false`,
+ [userId]
+ );
+ return rows[0] || null;
+};
\ No newline at end of file
diff --git a/src/routes/messages.routes.js b/src/routes/messages.routes.js
index 46fc626..1689aa4 100644
--- a/src/routes/messages.routes.js
+++ b/src/routes/messages.routes.js
@@ -1,16 +1,11 @@
-// ============================================================
-// routes/messages.routes.js
-// Owner : Alyaa Mohamed (BE-4)
-// Modules: Module 9 — Messaging
-// ============================================================
+
const express = require('express');
const router = express.Router();
const controller = require('../controllers/messages.controller');
const { authenticate } = require('../middleware/auth');
const asyncHandler = require('../utils/async-handler');
-// TODO: Add route definitions here
-// Example:
-// router.get('/', authenticate, asyncHandler(controller.getAll));
+// POST /messages/new — Start a new conversation (or append to existing)
+router.post('/new', authenticate, asyncHandler(controller.startConversation));
-module.exports = router;
+module.exports = router;
\ No newline at end of file
diff --git a/src/services/messages.service.js b/src/services/messages.service.js
index 25d3a23..53d2e9d 100644
--- a/src/services/messages.service.js
+++ b/src/services/messages.service.js
@@ -1,12 +1,89 @@
-// ============================================================
-// services/messages.service.js
-// Owner : Alyaa Mohamed (BE-4)
-// All business logic, rules & cross-module orchestration
-// No direct SQL here — delegate to models/
-// ============================================================
-
-// TODO: Implement service methods
-// Example:
-// exports.getAll = async (query) => {
-// return await messagesModel.findAll(query);
-// };
+
+const messageModel = require('../models/message.model');
+const AppError = require('../utils/app-error');
+
+// ------------------------------------------------------------
+// Helpers
+// ------------------------------------------------------------
+
+/**
+ * Returns the correctly ordered pair [userAId, userBId]
+ * satisfying the DB CHECK (user_a_id < user_b_id).
+ */
+const orderPair = (idOne, idTwo) => {
+ return idOne < idTwo ? [idOne, idTwo] : [idTwo, idOne];
+};
+
+// ------------------------------------------------------------
+// Endpoint 1 — Start a new conversation
+// ------------------------------------------------------------
+
+exports.startConversation = async ({ senderId, recipientId, body, resource }) => {
+ // 1. Prevent self-messaging
+ if (senderId === recipientId) {
+ throw new AppError('You cannot send a message to yourself.', 400, 'MESSAGES_SELF_MESSAGE');
+ }
+
+ // 2. Verify recipient exists and is active
+ // [DEPENDS: users module] — queries users table
+ const recipient = await messageModel.findActiveUserById(recipientId);
+ if (!recipient) {
+ throw new AppError('Recipient not found.', 404, 'USER_NOT_FOUND');
+ }
+
+ // 3. Check if recipient has blocked the sender
+ const recipientBlockedSender = await messageModel.isBlocked(recipientId, senderId);
+ if (recipientBlockedSender) {
+ throw new AppError('You cannot send a message to this user.', 403, 'MESSAGES_BLOCKED');
+ }
+
+ // 4. Check recipient's messages_from preference
+ // 'followers_only' means the recipient must be following the sender
+ const messagesFrom = await messageModel.getMessagesFromPreference(recipientId);
+ if (messagesFrom === 'followers_only') {
+ const recipientFollowsSender = await messageModel.isFollowing(recipientId, senderId);
+ if (!recipientFollowsSender) {
+ throw new AppError('This user only accepts messages from people they follow.', 403, 'MESSAGES_FOLLOWERS_ONLY');
+ }
+ }
+
+ // 5. Validate message content — at least body or resource required
+ const hasBody = typeof body === 'string' && body.trim().length > 0;
+ const hasResource = resource && resource.type && resource.id;
+ if (!hasBody && !hasResource) {
+ throw new AppError('A message must contain a body or an embedded resource.', 400, 'MESSAGES_EMPTY');
+ }
+
+ // 6. Validate body length
+ if (hasBody && body.length > 2000) {
+ throw new AppError('Message body must not exceed 2000 characters.', 400, 'MESSAGES_BODY_TOO_LONG');
+ }
+
+ // 7. Validate resource type if provided
+ // [DEPENDS: tracks/playlists module] — embed_id references tracks or playlists table
+ if (hasResource && !['track', 'playlist'].includes(resource.type)) {
+ throw new AppError('Embedded resource type must be "track" or "playlist".', 400, 'MESSAGES_INVALID_EMBED_TYPE');
+ }
+
+ // 8. Order the pair for the DB constraint (user_a_id < user_b_id)
+ const [userAId, userBId] = orderPair(senderId, recipientId);
+
+ // 9. Find or create the conversation
+ let conversation = await messageModel.findConversationByPair(userAId, userBId);
+ const isNew = !conversation;
+
+ if (isNew) {
+ conversation = await messageModel.createConversation(userAId, userBId); //userA is recipient if senderId > recipientId, else userA is sender
+ }
+
+ // 10. Insert the message
+ const message = await messageModel.createMessage({
+ conversationId: conversation.id,
+ senderId,
+ body: hasBody ? body.trim() : null,
+ embedType: hasResource ? resource.type : null,
+ embedId: hasResource ? resource.id : null,
+ });
+
+ return { conversation, message, isNew };
+};
\ No newline at end of file
From f0dcb78185f7a248daddd5fc539b7ff1c057ed9d Mon Sep 17 00:00:00 2001
From: Omar Hamza
Date: Sat, 14 Mar 2026 17:40:01 +0200
Subject: [PATCH 012/535] refactor(users): replace enum literals with shared
constants
Replace hardcoded enum string literals in the users service with centralized constants.
Changes:
- Use shared enum constants instead of inline string values
- Import and apply:
- GENDER_TYPES from gender-types.js
- USER_ROLES from user-roles.js
Account validation:
- update updateMyAccount gender validation to use Object.values(GENDER_TYPES)
- keep existing validation behavior (400 VALIDATION_FAILED)
Role switching:
- update switchRole validation to use USER_ROLES constants
- restrict allowed roles to ARTIST and LISTENER
- preserve existing behavior (400 for invalid role, 409 when role already assigned)
Consistency:
- derive validation error messages from enum constants
- align validation logic with shared constants used across the backend
---
src/services/users.service.js | 78 +++++++++++++++++++----------------
1 file changed, 43 insertions(+), 35 deletions(-)
diff --git a/src/services/users.service.js b/src/services/users.service.js
index bab68cb..f7b014b 100644
--- a/src/services/users.service.js
+++ b/src/services/users.service.js
@@ -1,5 +1,7 @@
const userModel = require('../models/user.model');
const AppError = require('../utils/app-error');
+const GENDER_TYPES = require('../constants/gender-types');
+const USER_ROLES = require('../constants/user-roles');
// full private profile for authenticated user
exports.getMe = async (userId) => {
@@ -53,60 +55,66 @@ exports.updateMe = async (userId, fields) => {
};
exports.updateMyAccount = async (userId, fields) => {
- // validate gender if provided
-
- if (fields.gender && !['male', 'female'].includes(fields.gender)) {
+ // validate gender if provided
+
+ if (fields.gender && !Object.values(GENDER_TYPES).includes(fields.gender)) {
throw new AppError('Validation failed', 400, 'VALIDATION_FAILED', [
- { field: 'gender', issue: 'Must be one of male, female' }
+ {
+ field: 'gender',
+ issue: `Must be one of ${GENDER_TYPES.MALE}, ${GENDER_TYPES.FEMALE}`,
+ },
]);
- }
+ }
- if (fields.date_of_birth) {
+ if (fields.date_of_birth) {
const date = new Date(fields.date_of_birth);
-
+
if (isNaN(date.getTime())) {
- throw new AppError('Validation failed', 400, 'VALIDATION_FAILED', [
- { field: 'date_of_birth', issue: 'Must be a valid date (YYYY-MM-DD)' }
- ]);
+ throw new AppError('Validation failed', 400, 'VALIDATION_FAILED', [
+ { field: 'date_of_birth', issue: 'Must be a valid date (YYYY-MM-DD)' },
+ ]);
}
-
+
const minAge = new Date();
minAge.setFullYear(minAge.getFullYear() - 13);
if (date > minAge) {
- throw new AppError('Validation failed', 400, 'VALIDATION_FAILED', [
- { field: 'date_of_birth', issue: 'You must be at least 13 years old' }
- ]);
- }
+ throw new AppError('Validation failed', 400, 'VALIDATION_FAILED', [
+ { field: 'date_of_birth', issue: 'You must be at least 13 years old' },
+ ]);
}
+ }
- const updated = await userModel.updateAccount(userId, fields);
- if (!updated) {
- throw new AppError('Nothing to update', 400, 'VALIDATION_FAILED');
- }
+ const updated = await userModel.updateAccount(userId, fields);
+ if (!updated) {
+ throw new AppError('Nothing to update', 400, 'VALIDATION_FAILED');
+ }
- return updated;
+ return updated;
};
-
// PATCH /users/me/role
exports.switchRole = async (userId, role) => {
- // only artist and listener are self-assignable
- if (!['artist', 'listener'].includes(role)) {
+ // only artist and listener are self-assignable
+ const allowedRoles = [USER_ROLES.ARTIST, USER_ROLES.LISTENER];
+ if (!allowedRoles.includes(role)) {
throw new AppError('Validation failed', 400, 'VALIDATION_FAILED', [
- { field: 'role', issue: 'Must be one of artist, listener' }
+ {
+ field: 'role',
+ issue: `Must be one of ${USER_ROLES.ARTIST}, ${USER_ROLES.LISTENER}`,
+ },
]);
- }
+ }
- // get current user to check existing role
- const user = await userModel.findById(userId);
- if (!user) {
- throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
- }
+ // get current user to check existing role
+ const user = await userModel.findById(userId);
+ if (!user) {
+ throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
+ }
- if (user.role === role) {
- throw new AppError(`User already has the ${role} role.`, 409, 'RESOURCE_ALREADY_EXISTS');
- }
+ if (user.role === role) {
+ throw new AppError(`User already has the ${role} role.`, 409, 'RESOURCE_ALREADY_EXISTS');
+ }
- return await userModel.updateRole(userId, role);
-};
\ No newline at end of file
+ return await userModel.updateRole(userId, role);
+};
From 9e04d11364ee204cad2be74bd1a44a84427fc652 Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Sat, 14 Mar 2026 19:28:25 +0200
Subject: [PATCH 013/535] feat(messages): add GET /messages/conversations
endpoint
-Returns paginated list of conversations sorted by most recent message.
-Includes partner info, last message preview, and per-conversation unread count.
-Excludes soft-deleted conversations for the current user.
---
src/controllers/messages.controller.js | 10 +++
src/models/message.model.js | 106 ++++++++++++++++++++++++-
src/routes/messages.routes.js | 3 +
src/services/messages.service.js | 48 +++++++++++
4 files changed, 164 insertions(+), 3 deletions(-)
diff --git a/src/controllers/messages.controller.js b/src/controllers/messages.controller.js
index 4eb102b..b7d1fe0 100644
--- a/src/controllers/messages.controller.js
+++ b/src/controllers/messages.controller.js
@@ -23,4 +23,14 @@ exports.startConversation = async (req, res) => {
}
return success(res, { message }, 'Message sent.', 200);
+};
+
+// GET /messages/conversations
+exports.listConversations = async (req, res) => {
+ const userId = req.user.sub;
+ const { page, limit } = req.query;
+
+ const data = await messagesService.listConversations({ userId, page, limit });
+
+ return success(res, data, 'Conversations fetched successfully.');
};
\ No newline at end of file
diff --git a/src/models/message.model.js b/src/models/message.model.js
index bb3cc2f..f06d872 100644
--- a/src/models/message.model.js
+++ b/src/models/message.model.js
@@ -1,11 +1,13 @@
-
const db = require('../config/db');
// ------------------------------------------------------------
-// Conversation queries
+//Endpoint 1 — Start a new conversation
// ------------------------------------------------------------
+// Conversation queries
+
+
/**
* Find an existing conversation between two users.
* Always pass (MIN(id), MAX(id)) — enforced by DB CHECK constraint.
@@ -114,4 +116,102 @@ exports.findActiveUserById = async (userId) => {
[userId]
);
return rows[0] || null;
-};
\ No newline at end of file
+};
+
+// ------------------------------------------------------------
+//Endpoint 2 — List all conversations GET /messages/conversations
+// ------------------------------------------------------------
+
+/**
+ * Returns paginated conversations for a user, sorted by most recent message.
+ * Excludes conversations soft-deleted by this user.
+ * Joins partner user info for the response.
+ * [DEPENDS: users module] — joins users table for participant info
+ */
+exports.findConversationsByUserId = async (userId, limit, offset) => {
+ const { rows } = await db.query(
+ `SELECT
+ c.id,
+ c.created_at,
+ c.last_message_at AS updated_at,
+
+ -- Partner user info
+ p.id AS participant_id,
+ p.display_name AS participant_display_name,
+ p.profile_picture AS participant_avatar,
+ p.username AS participant_username,
+
+ -- Unread count: messages sent by the partner that the current user hasn't read
+ COUNT(m_unread.id)::int AS unread_count
+
+ FROM conversations c
+
+ -- Join the other participant
+ JOIN users p ON p.id = CASE
+ WHEN c.user_a_id = $1 THEN c.user_b_id
+ ELSE c.user_a_id
+ END
+
+ -- Count unread messages from the partner
+ LEFT JOIN messages m_unread
+ ON m_unread.conversation_id = c.id
+ AND m_unread.is_read = false
+ AND m_unread.sender_id != $1
+
+ WHERE
+ (c.user_a_id = $1 OR c.user_b_id = $1)
+
+ -- Exclude soft-deleted conversations for this user
+ AND NOT (c.user_a_id = $1 AND c.deleted_by_a = true)
+ AND NOT (c.user_b_id = $1 AND c.deleted_by_b = true)
+
+ AND p.deleted_at IS NULL
+
+ GROUP BY c.id, p.id, p.display_name, p.profile_picture, p.username
+ ORDER BY c.last_message_at DESC
+ LIMIT $2 OFFSET $3`,
+ [userId, limit, offset]
+ );
+ return rows;
+};
+
+/**
+ * Returns the total count of active conversations for a user.
+ * Used for pagination metadata.
+ */
+exports.countConversationsByUserId = async (userId) => {
+ const { rows } = await db.query(
+ `SELECT COUNT(*)::int AS total
+ FROM conversations c
+ WHERE
+ (c.user_a_id = $1 OR c.user_b_id = $1)
+ AND NOT (c.user_a_id = $1 AND c.deleted_by_a = true)
+ AND NOT (c.user_b_id = $1 AND c.deleted_by_b = true)`,
+ [userId]
+ );
+ return rows[0].total;
+};
+
+/**
+ * Returns the last message of a conversation.
+ * content aliased as body [v3-FIX-21]
+ */
+exports.findLastMessageByConversationId = async (conversationId) => {
+ const { rows } = await db.query(
+ `SELECT
+ id,
+ conversation_id,
+ sender_id,
+ content AS body,
+ embed_type,
+ embed_id,
+ is_read,
+ created_at
+ FROM messages
+ WHERE conversation_id = $1
+ ORDER BY created_at DESC
+ LIMIT 1`,
+ [conversationId]
+ );
+ return rows[0] || null;
+};
diff --git a/src/routes/messages.routes.js b/src/routes/messages.routes.js
index 1689aa4..540755f 100644
--- a/src/routes/messages.routes.js
+++ b/src/routes/messages.routes.js
@@ -8,4 +8,7 @@ const asyncHandler = require('../utils/async-handler');
// POST /messages/new — Start a new conversation (or append to existing)
router.post('/new', authenticate, asyncHandler(controller.startConversation));
+// GET /messages/conversations — List all conversations
+router.get('/conversations', authenticate, asyncHandler(controller.listConversations));
+
module.exports = router;
\ No newline at end of file
diff --git a/src/services/messages.service.js b/src/services/messages.service.js
index 53d2e9d..8b92c1b 100644
--- a/src/services/messages.service.js
+++ b/src/services/messages.service.js
@@ -86,4 +86,52 @@ exports.startConversation = async ({ senderId, recipientId, body, resource }) =>
});
return { conversation, message, isNew };
+};
+
+// ------------------------------------------------------------
+// Endpoint 2 — List all conversations GET /messages/conversations
+// ------------------------------------------------------------
+
+exports.listConversations = async ({ userId, page, limit }) => {
+
+ // Sanitize pagination inputs
+ const safePage = Math.max(1, parseInt(page) || 1);
+ const safeLimit = Math.min(8, Math.max(1, parseInt(limit) || 8));
+ const offset = (safePage - 1) * safeLimit;
+
+ const [rows, total] = await Promise.all([
+ messageModel.findConversationsByUserId(userId, safeLimit, offset),
+ messageModel.countConversationsByUserId(userId),
+ ]);
+
+ // Fetch last message for each conversation
+ const conversations = await Promise.all(
+ rows.map(async (row) => {
+ const lastMessage = await messageModel.findLastMessageByConversationId(row.id);
+
+ return {
+ id: row.id,
+ participant: {
+ id: row.participant_id,
+ display_name: row.participant_display_name,
+ avatar: row.participant_avatar ?? null,
+ username: row.participant_username ?? null,
+ },
+ last_message: lastMessage ?? null,
+ unread_count: row.unread_count,
+ created_at: row.created_at,
+ updated_at: row.updated_at,
+ };
+ })
+ );
+
+ return {
+ items: conversations,
+ pagination: {
+ page: safePage,
+ limit: safeLimit,
+ total,
+ total_pages: Math.ceil(total / safeLimit),
+ },
+ };
};
\ No newline at end of file
From a99e7b8858fb751ecc922e1df00203dbca7e471f Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Sat, 14 Mar 2026 20:53:49 +0200
Subject: [PATCH 014/535] feat(messages): add GET
/messages/conversations/:conversationId endpoint
-Returns full message thread for a single conversation with pagination.
-Includes participant info, unread count, and access control checks.
-Treats soft-deleted conversations as not found for the requesting user
---
src/controllers/messages.controller.js | 17 +++++
src/models/message.model.js | 93 ++++++++++++++++++++++++++
src/routes/messages.routes.js | 3 +
src/services/messages.service.js | 59 +++++++++++++++-
4 files changed, 171 insertions(+), 1 deletion(-)
diff --git a/src/controllers/messages.controller.js b/src/controllers/messages.controller.js
index b7d1fe0..6e666c5 100644
--- a/src/controllers/messages.controller.js
+++ b/src/controllers/messages.controller.js
@@ -33,4 +33,21 @@ exports.listConversations = async (req, res) => {
const data = await messagesService.listConversations({ userId, page, limit });
return success(res, data, 'Conversations fetched successfully.');
+};
+
+
+// GET /messages/conversations/:conversationId
+exports.getConversation = async (req, res) => {
+ const userId = req.user.sub;
+ const { conversationId } = req.params;
+ const { page, limit } = req.query;
+
+ const data = await messagesService.getConversation({
+ conversationId,
+ userId,
+ page,
+ limit,
+ });
+
+ return success(res, data, 'Conversation fetched successfully.');
};
\ No newline at end of file
diff --git a/src/models/message.model.js b/src/models/message.model.js
index f06d872..e1149c2 100644
--- a/src/models/message.model.js
+++ b/src/models/message.model.js
@@ -215,3 +215,96 @@ exports.findLastMessageByConversationId = async (conversationId) => {
);
return rows[0] || null;
};
+
+// ------------------------------------------------------------
+// Endpoint 3 — Get a single conversation GET /messages/conversations/:conversationId
+// ------------------------------------------------------------
+
+/**
+ * Find a single conversation by its ID.
+ * Returns null if not found.
+ */
+exports.findConversationById = async (conversationId) => {
+ const { rows } = await db.query(
+ `SELECT * FROM conversations
+ WHERE id = $1`,
+ [conversationId]
+ );
+ return rows[0] || null;
+};
+
+/**
+ * Returns paginated messages for a conversation, oldest to newest.
+ * content aliased as body [v3-FIX-21]
+ */
+exports.findMessagesByConversationId = async (conversationId, limit, offset) => {
+ const { rows } = await db.query(
+ `SELECT
+ id,
+ conversation_id,
+ sender_id,
+ content AS body,
+ embed_type,
+ embed_id,
+ is_read,
+ created_at
+ FROM messages
+ WHERE conversation_id = $1
+ ORDER BY created_at ASC
+ LIMIT $2 OFFSET $3`,
+ [conversationId, limit, offset]
+ );
+ return rows;
+};
+
+/**
+ * Returns total message count for a conversation.
+ * Used for pagination metadata.
+ */
+exports.countMessagesByConversationId = async (conversationId) => {
+ const { rows } = await db.query(
+ `SELECT COUNT(*)::int AS total
+ FROM messages
+ WHERE conversation_id = $1`,
+ [conversationId]
+ );
+ return rows[0].total;
+};
+
+/**
+ * Returns partner user info for a conversation.
+ * [DEPENDS: users module] — joins users table
+ */
+exports.findConversationPartner = async (conversationId, userId) => {
+ const { rows } = await db.query(
+ `SELECT
+ p.id,
+ p.display_name,
+ p.profile_picture AS avatar,
+ p.username
+ FROM conversations c
+ JOIN users p ON p.id = CASE
+ WHEN c.user_a_id = $2 THEN c.user_b_id
+ ELSE c.user_a_id
+ END
+ WHERE c.id = $1
+ AND p.deleted_at IS NULL`,
+ [conversationId, userId]
+ );
+ return rows[0] || null;
+};
+
+/**
+ * Returns unread message count for a conversation for the current user.
+ */
+exports.countUnreadMessages = async (conversationId, userId) => {
+ const { rows } = await db.query(
+ `SELECT COUNT(*)::int AS unread_count
+ FROM messages
+ WHERE conversation_id = $1
+ AND is_read = false
+ AND sender_id != $2`,
+ [conversationId, userId]
+ );
+ return rows[0].unread_count;
+};
diff --git a/src/routes/messages.routes.js b/src/routes/messages.routes.js
index 540755f..62248d2 100644
--- a/src/routes/messages.routes.js
+++ b/src/routes/messages.routes.js
@@ -11,4 +11,7 @@ router.post('/new', authenticate, asyncHandler(controller.startConversation));
// GET /messages/conversations — List all conversations
router.get('/conversations', authenticate, asyncHandler(controller.listConversations));
+// GET /messages/conversations/:conversationId — Get a single conversation with messages
+router.get('/conversations/:conversationId', authenticate, asyncHandler(controller.getConversation));
+
module.exports = router;
\ No newline at end of file
diff --git a/src/services/messages.service.js b/src/services/messages.service.js
index 8b92c1b..db7399c 100644
--- a/src/services/messages.service.js
+++ b/src/services/messages.service.js
@@ -96,7 +96,7 @@ exports.listConversations = async ({ userId, page, limit }) => {
// Sanitize pagination inputs
const safePage = Math.max(1, parseInt(page) || 1);
- const safeLimit = Math.min(8, Math.max(1, parseInt(limit) || 8));
+ const safeLimit = Math.min(50, Math.max(1, parseInt(limit) ||20));
const offset = (safePage - 1) * safeLimit;
const [rows, total] = await Promise.all([
@@ -134,4 +134,61 @@ exports.listConversations = async ({ userId, page, limit }) => {
total_pages: Math.ceil(total / safeLimit),
},
};
+};
+
+// ------------------------------------------------------------
+// Endpoint 3 — Get a single conversation with messages GET /messages/conversations/:conversationId
+// ------------------------------------------------------------
+
+exports.getConversation = async ({ conversationId, userId, page, limit }) => {
+
+ // 1. Find conversation
+ const conversation = await messageModel.findConversationById(conversationId);
+ if (!conversation) {
+ throw new AppError('Conversation not found.', 404, 'CONVERSATION_NOT_FOUND');
+ }
+
+ // 2. Verify the user is a participant
+ const isParticipant = conversation.user_a_id === userId || conversation.user_b_id === userId;
+ if (!isParticipant) {
+ throw new AppError('You do not have access to this conversation.', 403, 'FORBIDDEN');
+ }
+
+ // 3. Check if the user has soft-deleted this conversation
+ const deletedByUser =
+ (conversation.user_a_id === userId && conversation.deleted_by_a) ||
+ (conversation.user_b_id === userId && conversation.deleted_by_b);
+ if (deletedByUser) {
+ throw new AppError('Conversation not found.', 404, 'CONVERSATION_NOT_FOUND');
+ }
+
+ // 4. Sanitize pagination inputs
+ const safePage = Math.max(1, parseInt(page) || 1);
+ const safeLimit = Math.min(100, Math.max(1, parseInt(limit) || 50));
+ const offset = (safePage - 1) * safeLimit;
+
+ // 5. Fetch messages, partner info, and counts in parallel
+ const [messages, total, partner, unreadCount] = await Promise.all([
+ messageModel.findMessagesByConversationId(conversationId, safeLimit, offset),
+ messageModel.countMessagesByConversationId(conversationId),
+ messageModel.findConversationPartner(conversationId, userId),
+ messageModel.countUnreadMessages(conversationId, userId),
+ ]);
+
+ return {
+ conversation: {
+ id: conversation.id,
+ participant: partner,
+ unread_count: unreadCount,
+ created_at: conversation.created_at,
+ updated_at: conversation.last_message_at,
+ },
+ messages,
+ pagination: {
+ page: safePage,
+ limit: safeLimit,
+ total,
+ total_pages: Math.ceil(total / safeLimit),
+ },
+ };
};
\ No newline at end of file
From 812ce8fba1169ec8b20bea7163619236c4e9a8e6 Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Sat, 14 Mar 2026 23:18:43 +0200
Subject: [PATCH 015/535] feat(messages): add POST
/messages/conversations/:conversationId/messages endpoint
-Sends a message into an existing conversation.
-Reuses block, followers_only, and content validation from endpoint 1.
-Treats soft-deleted conversations as not found for the sender
---
src/controllers/messages.controller.js | 17 ++++++
src/models/message.model.js | 6 ++
src/routes/messages.routes.js | 3 +
src/services/messages.service.js | 81 ++++++++++++++++++++++++++
4 files changed, 107 insertions(+)
diff --git a/src/controllers/messages.controller.js b/src/controllers/messages.controller.js
index 6e666c5..bca600c 100644
--- a/src/controllers/messages.controller.js
+++ b/src/controllers/messages.controller.js
@@ -50,4 +50,21 @@ exports.getConversation = async (req, res) => {
});
return success(res, data, 'Conversation fetched successfully.');
+};
+
+
+// POST /messages/conversations/:conversationId/messages
+exports.sendMessage = async (req, res) => {
+ const senderId = req.user.sub;
+ const { conversationId } = req.params;
+ const { body, resource } = req.body;
+
+ const message = await messagesService.sendMessage({
+ conversationId,
+ senderId,
+ body,
+ resource,
+ });
+
+ return success(res, message, 'Message sent.', 201);
};
\ No newline at end of file
diff --git a/src/models/message.model.js b/src/models/message.model.js
index e1149c2..b943db9 100644
--- a/src/models/message.model.js
+++ b/src/models/message.model.js
@@ -308,3 +308,9 @@ exports.countUnreadMessages = async (conversationId, userId) => {
);
return rows[0].unread_count;
};
+
+// --------------------------------------------------------------
+//Endpoint 4 — Send a message in an existing conversation
+//POST /messages/conversations/:conversationId/messages
+// ------------------------------------------------------------
+//no new models for this endpoint
\ No newline at end of file
diff --git a/src/routes/messages.routes.js b/src/routes/messages.routes.js
index 62248d2..3f25802 100644
--- a/src/routes/messages.routes.js
+++ b/src/routes/messages.routes.js
@@ -14,4 +14,7 @@ router.get('/conversations', authenticate, asyncHandler(controller.listConversat
// GET /messages/conversations/:conversationId — Get a single conversation with messages
router.get('/conversations/:conversationId', authenticate, asyncHandler(controller.getConversation));
+// POST /messages/conversations/:conversationId/messages — Send a message in a conversation
+router.post('/conversations/:conversationId/messages', authenticate, asyncHandler(controller.sendMessage));
+
module.exports = router;
\ No newline at end of file
diff --git a/src/services/messages.service.js b/src/services/messages.service.js
index db7399c..f56a42d 100644
--- a/src/services/messages.service.js
+++ b/src/services/messages.service.js
@@ -191,4 +191,85 @@ exports.getConversation = async ({ conversationId, userId, page, limit }) => {
total_pages: Math.ceil(total / safeLimit),
},
};
+};
+
+// ------------------------------------------------------------
+// Endpoint 4 — Send a message in an existing conversation
+// POST /messages/conversations/:conversationId/messages
+// ------------------------------------------------------------
+
+exports.sendMessage = async ({ conversationId, senderId, body, resource }) => {
+
+ // 1. Find conversation
+ const conversation = await messageModel.findConversationById(conversationId);
+ if (!conversation) {
+ throw new AppError('Conversation not found.', 404, 'CONVERSATION_NOT_FOUND');
+ }
+
+ // 2. Verify sender is a participant
+ const isParticipant =
+ conversation.user_a_id === senderId ||
+ conversation.user_b_id === senderId;
+ if (!isParticipant) {
+ throw new AppError('You do not have access to this conversation.', 403, 'FORBIDDEN');
+ }
+
+ // 3. Check if sender has soft-deleted this conversation
+ const deletedBySender =
+ (conversation.user_a_id === senderId && conversation.deleted_by_a) ||
+ (conversation.user_b_id === senderId && conversation.deleted_by_b);
+ if (deletedBySender) {
+ throw new AppError('Conversation not found.', 404, 'CONVERSATION_NOT_FOUND');
+ }
+
+ // 4. Identify the recipient
+ const recipientId =
+ conversation.user_a_id === senderId
+ ? conversation.user_b_id
+ : conversation.user_a_id;
+
+ // 5. Check if recipient has blocked the sender
+ const recipientBlockedSender = await messageModel.isBlocked(recipientId, senderId);
+ if (recipientBlockedSender) {
+ throw new AppError('You cannot send a message to this user.', 403, 'MESSAGES_BLOCKED');
+ }
+
+ // 6. Check recipient's messages_from preference
+ // 'followers_only' means the recipient must be following the sender
+ const messagesFrom = await messageModel.getMessagesFromPreference(recipientId);
+ if (messagesFrom === 'followers_only') {
+ const recipientFollowsSender = await messageModel.isFollowing(recipientId, senderId);
+ if (!recipientFollowsSender) {
+ throw new AppError('This user only accepts messages from people they follow.', 403, 'MESSAGES_FOLLOWERS_ONLY');
+ }
+ }
+
+ // 7. Validate message content — at least body or resource required
+ const hasBody = typeof body === 'string' && body.trim().length > 0;
+ const hasResource = resource && resource.type && resource.id;
+ if (!hasBody && !hasResource) {
+ throw new AppError('A message must contain a body or an embedded resource.', 400, 'MESSAGES_EMPTY');
+ }
+
+ // 8. Validate body length
+ if (hasBody && body.length > 2000) {
+ throw new AppError('Message body must not exceed 2000 characters.', 400, 'MESSAGES_BODY_TOO_LONG');
+ }
+
+ // 9. Validate resource type if provided
+ // [DEPENDS: tracks/playlists module] — embed_id references tracks or playlists table
+ if (hasResource && !['track', 'playlist'].includes(resource.type)) {
+ throw new AppError('Embedded resource type must be "track" or "playlist".', 400, 'MESSAGES_INVALID_EMBED_TYPE');
+ }
+
+ // 10. Insert the message
+ const message = await messageModel.createMessage({
+ conversationId,
+ senderId,
+ body: hasBody ? body.trim() : null,
+ embedType: hasResource ? resource.type : null,
+ embedId: hasResource ? resource.id : null,
+ });
+
+ return message;
};
\ No newline at end of file
From e930ae33c44280cd40d66ec98186c639940f153c Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Sun, 15 Mar 2026 00:04:34 +0200
Subject: [PATCH 016/535] feat(messages): add GET /messages/unread-count
endpoint
- Model: countTotalUnreadMessages queries messages joined with
conversations, filtering is_read = false and sender_id != userId,
excluding soft-deleted conversations for the current user
- Service: getUnreadCount calls the model and wraps result in
{ unread_count } object
- Controller: extracts userId from req.user.sub, calls service,
returns result via success response
- Route: GET /messages/unread-count registered with authenticate
and asyncHandler middleware
---
src/controllers/messages.controller.js | 10 ++++++++++
src/models/message.model.js | 26 +++++++++++++++++++++++++-
src/routes/messages.routes.js | 3 +++
src/services/messages.service.js | 10 ++++++++++
4 files changed, 48 insertions(+), 1 deletion(-)
diff --git a/src/controllers/messages.controller.js b/src/controllers/messages.controller.js
index bca600c..8f8beb9 100644
--- a/src/controllers/messages.controller.js
+++ b/src/controllers/messages.controller.js
@@ -67,4 +67,14 @@ exports.sendMessage = async (req, res) => {
});
return success(res, message, 'Message sent.', 201);
+};
+
+
+// GET /messages/unread-count
+exports.getUnreadCount = async (req, res) => {
+ const userId = req.user.sub;
+
+ const data = await messagesService.getUnreadCount({ userId });
+
+ return success(res, data, 'Unread message count fetched successfully.');
};
\ No newline at end of file
diff --git a/src/models/message.model.js b/src/models/message.model.js
index b943db9..b52e224 100644
--- a/src/models/message.model.js
+++ b/src/models/message.model.js
@@ -313,4 +313,28 @@ exports.countUnreadMessages = async (conversationId, userId) => {
//Endpoint 4 — Send a message in an existing conversation
//POST /messages/conversations/:conversationId/messages
// ------------------------------------------------------------
-//no new models for this endpoint
\ No newline at end of file
+//no new models for this endpoint
+
+// ------------------------------------------------------------
+// Endpoint 5 — Get total unread message count GET /messages/unread-count
+// ------------------------------------------------------------
+
+/**
+ * Returns total unread message count across all active conversations for a user.
+ * Excludes soft-deleted conversations for this user.
+ */
+exports.countTotalUnreadMessages = async (userId) => {
+ const { rows } = await db.query(
+ `SELECT COUNT(m.id)::int AS unread_count
+ FROM messages m
+ JOIN conversations c ON c.id = m.conversation_id
+ WHERE
+ m.is_read = false
+ AND m.sender_id != $1
+ AND (c.user_a_id = $1 OR c.user_b_id = $1)
+ AND NOT (c.user_a_id = $1 AND c.deleted_by_a = true)
+ AND NOT (c.user_b_id = $1 AND c.deleted_by_b = true)`,
+ [userId]
+ );
+ return rows[0].unread_count;
+};
\ No newline at end of file
diff --git a/src/routes/messages.routes.js b/src/routes/messages.routes.js
index 3f25802..86edea4 100644
--- a/src/routes/messages.routes.js
+++ b/src/routes/messages.routes.js
@@ -17,4 +17,7 @@ router.get('/conversations/:conversationId', authenticate, asyncHandler(controll
// POST /messages/conversations/:conversationId/messages — Send a message in a conversation
router.post('/conversations/:conversationId/messages', authenticate, asyncHandler(controller.sendMessage));
+// GET /messages/unread-count — Get total unread message count
+router.get('/unread-count', authenticate, asyncHandler(controller.getUnreadCount));
+
module.exports = router;
\ No newline at end of file
diff --git a/src/services/messages.service.js b/src/services/messages.service.js
index f56a42d..ef2521c 100644
--- a/src/services/messages.service.js
+++ b/src/services/messages.service.js
@@ -272,4 +272,14 @@ exports.sendMessage = async ({ conversationId, senderId, body, resource }) => {
});
return message;
+};
+
+// ------------------------------------------------------------
+// Endpoint 5 — Get total unread message count
+// GET /messages/unread-count
+// ------------------------------------------------------------
+
+exports.getUnreadCount = async ({ userId }) => {
+ const unreadCount = await messageModel.countTotalUnreadMessages(userId);
+ return { unread_count: unreadCount };
};
\ No newline at end of file
From d600b3f255a8748458208eb3d2eac52d8df1b6eb Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Sun, 15 Mar 2026 01:18:23 +0200
Subject: [PATCH 017/535] feat(messages): add PATCH
/messages/conversations/:conversationId/messages/:messageId/read endpoint
-Toggles read state of a message. Only the recipient can change read state.
-Returns updated unread count for the conversation.
-Returns 409 if message is already in the requested state
---
src/controllers/messages.controller.js | 25 ++++++++++++
src/models/message.model.js | 50 ++++++++++++++++++++++++
src/routes/messages.routes.js | 3 ++
src/services/messages.service.js | 54 ++++++++++++++++++++++++++
4 files changed, 132 insertions(+)
diff --git a/src/controllers/messages.controller.js b/src/controllers/messages.controller.js
index 8f8beb9..1840e95 100644
--- a/src/controllers/messages.controller.js
+++ b/src/controllers/messages.controller.js
@@ -77,4 +77,29 @@ exports.getUnreadCount = async (req, res) => {
const data = await messagesService.getUnreadCount({ userId });
return success(res, data, 'Unread message count fetched successfully.');
+};
+
+
+// PATCH /messages/conversations/:conversationId/messages/:messageId/read
+exports.markMessageReadState = async (req, res) => {
+ const userId = req.user.sub;
+ const { conversationId, messageId } = req.params;
+ const { is_read } = req.body;
+
+ if (is_read === undefined || is_read === null) {
+ return error(res, 'VALIDATION_FAILED', 'is_read is required.', 400);
+ }
+
+ if (typeof is_read !== 'boolean') {
+ return error(res, 'VALIDATION_FAILED', 'is_read must be a boolean.', 400);
+ }
+
+ const data = await messagesService.markMessageReadState({
+ conversationId,
+ messageId,
+ userId,
+ isRead: is_read,
+ });
+
+ return success(res, data, 'Message read state updated successfully.');
};
\ No newline at end of file
diff --git a/src/models/message.model.js b/src/models/message.model.js
index b52e224..6bbee27 100644
--- a/src/models/message.model.js
+++ b/src/models/message.model.js
@@ -337,4 +337,54 @@ exports.countTotalUnreadMessages = async (userId) => {
[userId]
);
return rows[0].unread_count;
+};
+
+// ------------------------------------------------------------
+// Endpoint 6 — Mark a message as read/unread
+// PATCH /messages/conversations/:conversationId/messages/:messageId/read
+// ------------------------------------------------------------
+
+/**
+ * Find a single message by its ID and conversation ID.
+ * content aliased as body [v3-FIX-21]
+ */
+exports.findMessageById = async (messageId, conversationId) => {
+ const { rows } = await db.query(
+ `SELECT
+ id,
+ conversation_id,
+ sender_id,
+ content AS body,
+ embed_type,
+ embed_id,
+ is_read,
+ created_at
+ FROM messages
+ WHERE id = $1
+ AND conversation_id = $2`,
+ [messageId, conversationId]
+ );
+ return rows[0] || null;
+};
+
+/**
+ * Update the is_read state of a message.
+ */
+exports.updateMessageReadState = async (messageId, isRead) => {
+ const { rows } = await db.query(
+ `UPDATE messages
+ SET is_read = $2
+ WHERE id = $1
+ RETURNING
+ id,
+ conversation_id,
+ sender_id,
+ content AS body,
+ embed_type,
+ embed_id,
+ is_read,
+ created_at`,
+ [messageId, isRead]
+ );
+ return rows[0] || null;
};
\ No newline at end of file
diff --git a/src/routes/messages.routes.js b/src/routes/messages.routes.js
index 86edea4..ff99764 100644
--- a/src/routes/messages.routes.js
+++ b/src/routes/messages.routes.js
@@ -20,4 +20,7 @@ router.post('/conversations/:conversationId/messages', authenticate, asyncHandle
// GET /messages/unread-count — Get total unread message count
router.get('/unread-count', authenticate, asyncHandler(controller.getUnreadCount));
+// PATCH /messages/conversations/:conversationId/messages/:messageId/read — Mark a message as read/unread
+router.patch('/conversations/:conversationId/messages/:messageId/read', authenticate, asyncHandler(controller.markMessageReadState));
+
module.exports = router;
\ No newline at end of file
diff --git a/src/services/messages.service.js b/src/services/messages.service.js
index ef2521c..33142b5 100644
--- a/src/services/messages.service.js
+++ b/src/services/messages.service.js
@@ -282,4 +282,58 @@ exports.sendMessage = async ({ conversationId, senderId, body, resource }) => {
exports.getUnreadCount = async ({ userId }) => {
const unreadCount = await messageModel.countTotalUnreadMessages(userId);
return { unread_count: unreadCount };
+};
+
+// ------------------------------------------------------------
+// Endpoint 6 — Mark a message as read/unread
+// PATCH /messages/conversations/:conversationId/messages/:messageId/read
+// ------------------------------------------------------------
+
+exports.markMessageReadState = async ({ conversationId, messageId, userId, isRead }) => {
+
+ // 1. Find conversation
+ const conversation = await messageModel.findConversationById(conversationId);
+ if (!conversation) {
+ throw new AppError('Conversation not found.', 404, 'CONVERSATION_NOT_FOUND');
+ }
+
+ // 2. Verify user is a participant
+ const isParticipant =
+ conversation.user_a_id === userId ||
+ conversation.user_b_id === userId;
+ if (!isParticipant) {
+ throw new AppError('You do not have access to this conversation.', 403, 'FORBIDDEN');
+ }
+
+ // 3. Find the message
+ const message = await messageModel.findMessageById(messageId, conversationId);
+ if (!message) {
+ throw new AppError('Message not found.', 404, 'MESSAGE_NOT_FOUND');
+ }
+
+ // 4. Only the recipient can change read state — not the sender
+ if (message.sender_id === userId) {
+ throw new AppError('You cannot change the read state of your own message.', 403, 'FORBIDDEN');
+ }
+
+ // 5. Check if already in the requested state — 409 conflict
+ if (message.is_read === isRead) {
+ throw new AppError(
+ `Message is already marked as ${isRead ? 'read' : 'unread'}.`,
+ 409,
+ 'MESSAGES_READ_STATE_CONFLICT'
+ );
+ }
+
+ // 6. Update the read state
+ await messageModel.updateMessageReadState(messageId, isRead);
+
+ // 7. Recalculate unread count for this conversation for the response
+ const unreadCount = await messageModel.countUnreadMessages(conversationId, userId);
+
+ return {
+ message_id: messageId,
+ is_read: isRead,
+ conversation_unread_count: unreadCount,
+ };
};
\ No newline at end of file
From 26bc3af6032873495b79c9bfc47717960300ed8d Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Sun, 15 Mar 2026 01:45:49 +0200
Subject: [PATCH 018/535] feat(messages): add DELETE
/messages/conversations/:conversationId/messages/:messageId endpoint
- Model: deleteMessageById performs a hard delete (DELETE FOR EVERYONE) removing the message
permanently from the database for both participants
- Service: deleteMessage verifies conversation exists, checks user is
a participant, fetches the message, and ensures only the original
sender can delete their own messages
---
src/controllers/messages.controller.js | 11 ++++++++
src/models/message.model.js | 16 ++++++++++++
src/routes/messages.routes.js | 3 +++
src/services/messages.service.js | 36 ++++++++++++++++++++++++++
4 files changed, 66 insertions(+)
diff --git a/src/controllers/messages.controller.js b/src/controllers/messages.controller.js
index 1840e95..6139d99 100644
--- a/src/controllers/messages.controller.js
+++ b/src/controllers/messages.controller.js
@@ -102,4 +102,15 @@ exports.markMessageReadState = async (req, res) => {
});
return success(res, data, 'Message read state updated successfully.');
+};
+
+
+// DELETE /messages/conversations/:conversationId/messages/:messageId
+exports.deleteMessage = async (req, res) => {
+ const userId = req.user.sub;
+ const { conversationId, messageId } = req.params;
+
+ await messagesService.deleteMessage({ conversationId, messageId, userId });
+
+ return success(res, null, 'Message deleted.');
};
\ No newline at end of file
diff --git a/src/models/message.model.js b/src/models/message.model.js
index 6bbee27..186f2b5 100644
--- a/src/models/message.model.js
+++ b/src/models/message.model.js
@@ -387,4 +387,20 @@ exports.updateMessageReadState = async (messageId, isRead) => {
[messageId, isRead]
);
return rows[0] || null;
+};
+
+// ------------------------------------------------------------
+// Endpoint 7 — Delete a specific message (delete for eveyone feature --only to ur own messages--)
+// DELETE /messages/conversations/:conversationId/messages/:messageId
+// ------------------------------------------------------------
+
+/**
+ * Hard deletes a message by ID.
+ */
+exports.deleteMessageById = async (messageId) => {
+ await db.query(
+ `DELETE FROM messages
+ WHERE id = $1`,
+ [messageId]
+ );
};
\ No newline at end of file
diff --git a/src/routes/messages.routes.js b/src/routes/messages.routes.js
index ff99764..eeae636 100644
--- a/src/routes/messages.routes.js
+++ b/src/routes/messages.routes.js
@@ -23,4 +23,7 @@ router.get('/unread-count', authenticate, asyncHandler(controller.getUnreadCount
// PATCH /messages/conversations/:conversationId/messages/:messageId/read — Mark a message as read/unread
router.patch('/conversations/:conversationId/messages/:messageId/read', authenticate, asyncHandler(controller.markMessageReadState));
+// DELETE /messages/conversations/:conversationId/messages/:messageId — Delete a specific message
+router.delete('/conversations/:conversationId/messages/:messageId', authenticate, asyncHandler(controller.deleteMessage));
+
module.exports = router;
\ No newline at end of file
diff --git a/src/services/messages.service.js b/src/services/messages.service.js
index 33142b5..35f19ad 100644
--- a/src/services/messages.service.js
+++ b/src/services/messages.service.js
@@ -336,4 +336,40 @@ exports.markMessageReadState = async ({ conversationId, messageId, userId, isRea
is_read: isRead,
conversation_unread_count: unreadCount,
};
+};
+
+// ------------------------------------------------------------
+// Endpoint 7 — Delete a specific message (delete for eveyone feature --only to ur own messages--)
+// DELETE /messages/conversations/:conversationId/messages/:messageId
+// ------------------------------------------------------------
+
+exports.deleteMessage = async ({ conversationId, messageId, userId }) => {
+
+ // 1. Find conversation
+ const conversation = await messageModel.findConversationById(conversationId);
+ if (!conversation) {
+ throw new AppError('Conversation not found.', 404, 'CONVERSATION_NOT_FOUND');
+ }
+
+ // 2. Verify user is a participant
+ const isParticipant =
+ conversation.user_a_id === userId ||
+ conversation.user_b_id === userId;
+ if (!isParticipant) {
+ throw new AppError('You do not have access to this conversation.', 403, 'FORBIDDEN');
+ }
+
+ // 3. Find the message
+ const message = await messageModel.findMessageById(messageId, conversationId);
+ if (!message) {
+ throw new AppError('Message not found.', 404, 'MESSAGE_NOT_FOUND');
+ }
+
+ // 4. Only the original sender can delete their own message
+ if (message.sender_id !== userId) {
+ throw new AppError('You can only delete your own messages.', 403, 'FORBIDDEN');
+ }
+
+ // 5. Hard delete the message
+ await messageModel.deleteMessageById(messageId);
};
\ No newline at end of file
From 739f093ee5a78feedf7ec6f6f51a61105815e342 Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Sun, 15 Mar 2026 01:58:23 +0200
Subject: [PATCH 019/535] feat(messages): add DELETE
/messages/conversations/:conversationId endpoint
- Model: softDeleteConversation sets deleted_by_a or deleted_by_b flag
depending on which participant is deleting
- Service: deleteConversation verifies conversation exists, checks user
is a participant, prevents double deletion, and identifies correct
flag to set based on user_a_id or user_b_id
-if both flags deleted_by_a , deleted_by_b are true.>>row or conversation is dropped from tables
---
src/controllers/messages.controller.js | 11 +++++++++
src/models/message.model.js | 23 +++++++++++++++++
src/routes/messages.routes.js | 3 +++
src/services/messages.service.js | 34 ++++++++++++++++++++++++++
4 files changed, 71 insertions(+)
diff --git a/src/controllers/messages.controller.js b/src/controllers/messages.controller.js
index 6139d99..98f90bf 100644
--- a/src/controllers/messages.controller.js
+++ b/src/controllers/messages.controller.js
@@ -113,4 +113,15 @@ exports.deleteMessage = async (req, res) => {
await messagesService.deleteMessage({ conversationId, messageId, userId });
return success(res, null, 'Message deleted.');
+};
+
+
+// DELETE /messages/conversations/:conversationId
+exports.deleteConversation = async (req, res) => {
+ const userId = req.user.sub;
+ const { conversationId } = req.params;
+
+ await messagesService.deleteConversation({ conversationId, userId });
+
+ return success(res, null, 'Conversation deleted.');
};
\ No newline at end of file
diff --git a/src/models/message.model.js b/src/models/message.model.js
index 186f2b5..158b0f8 100644
--- a/src/models/message.model.js
+++ b/src/models/message.model.js
@@ -403,4 +403,27 @@ exports.deleteMessageById = async (messageId) => {
WHERE id = $1`,
[messageId]
);
+};
+
+// ------------------------------------------------------------
+// Endpoint 8 — Delete a conversation (soft delete)
+// DELETE /messages/conversations/:conversationId
+// ------------------------------------------------------------
+
+/**
+ * Soft deletes a conversation for one user by setting their
+ * deleted_by_a or deleted_by_b flag to true.
+ * DB trigger trg_conversation_purge_on_both_deleted will permanently
+ * purge the conversation and all messages if both flags are true.
+ */
+exports.softDeleteConversation = async (conversationId, isUserA) => {
+ const column = isUserA ? 'deleted_by_a' : 'deleted_by_b';
+ const { rows } = await db.query(
+ `UPDATE conversations
+ SET ${column} = true
+ WHERE id = $1
+ RETURNING *`,
+ [conversationId]
+ );
+ return rows[0] || null;
};
\ No newline at end of file
diff --git a/src/routes/messages.routes.js b/src/routes/messages.routes.js
index eeae636..49bd489 100644
--- a/src/routes/messages.routes.js
+++ b/src/routes/messages.routes.js
@@ -26,4 +26,7 @@ router.patch('/conversations/:conversationId/messages/:messageId/read', authenti
// DELETE /messages/conversations/:conversationId/messages/:messageId — Delete a specific message
router.delete('/conversations/:conversationId/messages/:messageId', authenticate, asyncHandler(controller.deleteMessage));
+// DELETE /messages/conversations/:conversationId — Soft delete a conversation
+router.delete('/conversations/:conversationId', authenticate, asyncHandler(controller.deleteConversation));
+
module.exports = router;
\ No newline at end of file
diff --git a/src/services/messages.service.js b/src/services/messages.service.js
index 35f19ad..e2a4ae0 100644
--- a/src/services/messages.service.js
+++ b/src/services/messages.service.js
@@ -372,4 +372,38 @@ exports.deleteMessage = async ({ conversationId, messageId, userId }) => {
// 5. Hard delete the message
await messageModel.deleteMessageById(messageId);
+};
+
+// ------------------------------------------------------------
+// Endpoint 8 — Delete a conversation (soft delete)
+// DELETE /messages/conversations/:conversationId
+// ------------------------------------------------------------
+
+exports.deleteConversation = async ({ conversationId, userId }) => {
+
+ // 1. Find conversation
+ const conversation = await messageModel.findConversationById(conversationId);
+ if (!conversation) {
+ throw new AppError('Conversation not found.', 404, 'CONVERSATION_NOT_FOUND');
+ }
+
+ // 2. Verify user is a participant
+ const isUserA = conversation.user_a_id === userId;
+ const isUserB = conversation.user_b_id === userId;
+ if (!isUserA && !isUserB) {
+ throw new AppError('You do not have access to this conversation.', 403, 'FORBIDDEN');
+ }
+
+ // 3. Check if user has already soft-deleted this conversation
+ const alreadyDeleted =
+ (isUserA && conversation.deleted_by_a) ||
+ (isUserB && conversation.deleted_by_b);
+ if (alreadyDeleted) {
+ throw new AppError('Conversation not found.', 404, 'CONVERSATION_NOT_FOUND');
+ }
+
+ // 4. Soft delete for this user
+ // If both users have now deleted, DB trigger trg_conversation_purge_on_both_deleted
+ // will automatically purge all messages and the conversation row
+ await messageModel.softDeleteConversation(conversationId, isUserA);
};
\ No newline at end of file
From 660c20d368dad27d8db1e66807e57448cc83d74f Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Sun, 15 Mar 2026 15:43:47 +0200
Subject: [PATCH 020/535] refactor(messages): address code review fixes for
module 9
- model: findConversationByPair uses LEAST/GREATEST for safe UUID ordering
- model: createConversation uses INSERT ... ON CONFLICT DO NOTHING for
atomic create-or-get, preventing race conditions on simultaneous requests
- model: findConversationsByUserId fetches last message via LATERAL join
in the same query, eliminating N+1 queries
- model: restoreConversationForUser resets deleted_by flag for recipient
when a new message arrives in a conversation they had deleted
- service: startConversation removed orderPair helper, delegates UUID
ordering entirely to SQL via LEAST/GREATEST in model layer
- service: listConversations maps last message directly from row columns
instead of making a separate DB call per conversation
- service: sendMessage swaps recipientId definition before soft-delete
restore check to fix reference-before-definition bug, restores
conversation for recipient if they had previously deleted it
---
src/models/message.model.js | 87 ++++++++++++++++++-----------
src/services/messages.service.js | 95 ++++++++++++++------------------
2 files changed, 96 insertions(+), 86 deletions(-)
diff --git a/src/models/message.model.js b/src/models/message.model.js
index 158b0f8..836fa65 100644
--- a/src/models/message.model.js
+++ b/src/models/message.model.js
@@ -10,12 +10,13 @@ const db = require('../config/db');
/**
* Find an existing conversation between two users.
- * Always pass (MIN(id), MAX(id)) — enforced by DB CHECK constraint.
+ * Uses LEAST/GREATEST to normalize pair order in SQL
*/
exports.findConversationByPair = async (userAId, userBId) => {
const { rows } = await db.query(
`SELECT * FROM conversations
- WHERE user_a_id = $1 AND user_b_id = $2`, //$1, $2 for security because it prevents SQL injection
+ WHERE user_a_id = LEAST($1::uuid, $2::uuid)
+ AND user_b_id = GREATEST($1::uuid, $2::uuid)`, //userA is recipient if senderId > recipientId, else userA is sender
[userAId, userBId]
);
return rows[0] || null;
@@ -28,10 +29,22 @@ exports.findConversationByPair = async (userAId, userBId) => {
exports.createConversation = async (userAId, userBId) => {
const { rows } = await db.query(
`INSERT INTO conversations (user_a_id, user_b_id)
- VALUES ($1, $2)
+ VALUES (LEAST($1::uuid, $2::uuid), GREATEST($1::uuid, $2::uuid))
+ ON CONFLICT (user_a_id, user_b_id) DO NOTHING
RETURNING *`,
[userAId, userBId]
);
+
+ if (rows.length === 0) {
+ const existing = await db.query(
+ `SELECT * FROM conversations
+ WHERE user_a_id = LEAST($1::uuid, $2::uuid)
+ AND user_b_id = GREATEST($1::uuid, $2::uuid)`,
+ [userAId, userBId]
+ );
+ return existing.rows[0];
+ }
+
return rows[0];
};
@@ -141,8 +154,17 @@ exports.findConversationsByUserId = async (userId, limit, offset) => {
p.profile_picture AS participant_avatar,
p.username AS participant_username,
- -- Unread count: messages sent by the partner that the current user hasn't read
- COUNT(m_unread.id)::int AS unread_count
+ -- Unread count
+ COUNT(m_unread.id)::int AS unread_count,
+
+ -- Last message (fetched in same query via subquery)
+ lm.id AS last_message_id,
+ lm.sender_id AS last_message_sender_id,
+ lm.content AS last_message_body,
+ lm.embed_type AS last_message_embed_type,
+ lm.embed_id AS last_message_embed_id,
+ lm.is_read AS last_message_is_read,
+ lm.created_at AS last_message_created_at
FROM conversations c
@@ -152,7 +174,16 @@ exports.findConversationsByUserId = async (userId, limit, offset) => {
ELSE c.user_a_id
END
- -- Count unread messages from the partner
+ -- Last message per conversation using DISTINCT ON
+ LEFT JOIN LATERAL (
+ SELECT id, sender_id, content, embed_type, embed_id, is_read, created_at
+ FROM messages
+ WHERE conversation_id = c.id
+ ORDER BY created_at DESC
+ LIMIT 1
+ ) lm ON true
+
+ -- Unread messages from partner
LEFT JOIN messages m_unread
ON m_unread.conversation_id = c.id
AND m_unread.is_read = false
@@ -160,14 +191,13 @@ exports.findConversationsByUserId = async (userId, limit, offset) => {
WHERE
(c.user_a_id = $1 OR c.user_b_id = $1)
-
- -- Exclude soft-deleted conversations for this user
AND NOT (c.user_a_id = $1 AND c.deleted_by_a = true)
AND NOT (c.user_b_id = $1 AND c.deleted_by_b = true)
-
AND p.deleted_at IS NULL
- GROUP BY c.id, p.id, p.display_name, p.profile_picture, p.username
+ GROUP BY c.id, p.id, p.display_name, p.profile_picture, p.username,
+ lm.id, lm.sender_id, lm.content, lm.embed_type,
+ lm.embed_id, lm.is_read, lm.created_at
ORDER BY c.last_message_at DESC
LIMIT $2 OFFSET $3`,
[userId, limit, offset]
@@ -192,29 +222,6 @@ exports.countConversationsByUserId = async (userId) => {
return rows[0].total;
};
-/**
- * Returns the last message of a conversation.
- * content aliased as body [v3-FIX-21]
- */
-exports.findLastMessageByConversationId = async (conversationId) => {
- const { rows } = await db.query(
- `SELECT
- id,
- conversation_id,
- sender_id,
- content AS body,
- embed_type,
- embed_id,
- is_read,
- created_at
- FROM messages
- WHERE conversation_id = $1
- ORDER BY created_at DESC
- LIMIT 1`,
- [conversationId]
- );
- return rows[0] || null;
-};
// ------------------------------------------------------------
// Endpoint 3 — Get a single conversation GET /messages/conversations/:conversationId
@@ -426,4 +433,18 @@ exports.softDeleteConversation = async (conversationId, isUserA) => {
[conversationId]
);
return rows[0] || null;
+};
+
+/**
+ * Resets the soft-delete flag for a user in a conversation.
+ * Called when a user sends a new message after having deleted the conversation.
+ */
+exports.restoreConversationForUser = async (conversationId, isUserA) => {
+ const column = isUserA ? 'deleted_by_a' : 'deleted_by_b';
+ await db.query(
+ `UPDATE conversations
+ SET ${column} = false
+ WHERE id = $1`,
+ [conversationId]
+ );
};
\ No newline at end of file
diff --git a/src/services/messages.service.js b/src/services/messages.service.js
index e2a4ae0..36ea2bb 100644
--- a/src/services/messages.service.js
+++ b/src/services/messages.service.js
@@ -2,18 +2,6 @@
const messageModel = require('../models/message.model');
const AppError = require('../utils/app-error');
-// ------------------------------------------------------------
-// Helpers
-// ------------------------------------------------------------
-
-/**
- * Returns the correctly ordered pair [userAId, userBId]
- * satisfying the DB CHECK (user_a_id < user_b_id).
- */
-const orderPair = (idOne, idTwo) => {
- return idOne < idTwo ? [idOne, idTwo] : [idTwo, idOne];
-};
-
// ------------------------------------------------------------
// Endpoint 1 — Start a new conversation
// ------------------------------------------------------------
@@ -66,17 +54,13 @@ exports.startConversation = async ({ senderId, recipientId, body, resource }) =>
}
// 8. Order the pair for the DB constraint (user_a_id < user_b_id)
- const [userAId, userBId] = orderPair(senderId, recipientId);
+ const conversationBefore = await messageModel.findConversationByPair(senderId, recipientId);
+ const isNew = !conversationBefore;
+ const conversation = isNew
+ ? await messageModel.createConversation(senderId, recipientId)
+ : conversationBefore;
- // 9. Find or create the conversation
- let conversation = await messageModel.findConversationByPair(userAId, userBId);
- const isNew = !conversation;
-
- if (isNew) {
- conversation = await messageModel.createConversation(userAId, userBId); //userA is recipient if senderId > recipientId, else userA is sender
- }
-
- // 10. Insert the message
+ // 9. Insert the message
const message = await messageModel.createMessage({
conversationId: conversation.id,
senderId,
@@ -94,9 +78,8 @@ exports.startConversation = async ({ senderId, recipientId, body, resource }) =>
exports.listConversations = async ({ userId, page, limit }) => {
- // Sanitize pagination inputs
const safePage = Math.max(1, parseInt(page) || 1);
- const safeLimit = Math.min(50, Math.max(1, parseInt(limit) ||20));
+ const safeLimit = Math.min(8, Math.max(1, parseInt(limit) || 8));
const offset = (safePage - 1) * safeLimit;
const [rows, total] = await Promise.all([
@@ -104,26 +87,29 @@ exports.listConversations = async ({ userId, page, limit }) => {
messageModel.countConversationsByUserId(userId),
]);
- // Fetch last message for each conversation
- const conversations = await Promise.all(
- rows.map(async (row) => {
- const lastMessage = await messageModel.findLastMessageByConversationId(row.id);
-
- return {
- id: row.id,
- participant: {
- id: row.participant_id,
- display_name: row.participant_display_name,
- avatar: row.participant_avatar ?? null,
- username: row.participant_username ?? null,
- },
- last_message: lastMessage ?? null,
- unread_count: row.unread_count,
- created_at: row.created_at,
- updated_at: row.updated_at,
- };
- })
- );
+ // No N+1 — last message is already included in each row
+ const conversations = rows.map((row) => ({
+ id: row.id,
+ participant: {
+ id: row.participant_id,
+ display_name: row.participant_display_name,
+ avatar: row.participant_avatar ?? null,
+ username: row.participant_username ?? null,
+ },
+ last_message: row.last_message_id ? {
+ id: row.last_message_id,
+ conversation_id: row.id,
+ sender_id: row.last_message_sender_id,
+ body: row.last_message_body ?? null,
+ embed_type: row.last_message_embed_type ?? null,
+ embed_id: row.last_message_embed_id ?? null,
+ is_read: row.last_message_is_read,
+ created_at: row.last_message_created_at,
+ } : null,
+ unread_count: row.unread_count,
+ created_at: row.created_at,
+ updated_at: row.updated_at,
+ }));
return {
items: conversations,
@@ -214,20 +200,23 @@ exports.sendMessage = async ({ conversationId, senderId, body, resource }) => {
throw new AppError('You do not have access to this conversation.', 403, 'FORBIDDEN');
}
- // 3. Check if sender has soft-deleted this conversation
- const deletedBySender =
- (conversation.user_a_id === senderId && conversation.deleted_by_a) ||
- (conversation.user_b_id === senderId && conversation.deleted_by_b);
- if (deletedBySender) {
- throw new AppError('Conversation not found.', 404, 'CONVERSATION_NOT_FOUND');
- }
-
- // 4. Identify the recipient
+ // 3. Identify the recipient first — needed for restore check below
const recipientId =
conversation.user_a_id === senderId
? conversation.user_b_id
: conversation.user_a_id;
+ // 4. Restore conversation for recipient if they had soft-deleted it
+ const recipientIsUserA = conversation.user_a_id === recipientId;
+ const deletedByRecipient =
+ (recipientIsUserA && conversation.deleted_by_a) ||
+ (!recipientIsUserA && conversation.deleted_by_b);
+
+ if (deletedByRecipient) {
+ await messageModel.restoreConversationForUser(conversationId, recipientIsUserA);
+ }
+
+
// 5. Check if recipient has blocked the sender
const recipientBlockedSender = await messageModel.isBlocked(recipientId, senderId);
if (recipientBlockedSender) {
From ab16b1411da868dcf4a45479a3d584a1a164ff48 Mon Sep 17 00:00:00 2001
From: Beshoy205
Date: Mon, 16 Mar 2026 00:07:34 +0200
Subject: [PATCH 021/535] Delete Avatar
---
src/controllers/users.controller.js | 5 +++++
src/models/user.model.js | 12 +++++++++++-
src/routes/users.routes.js | 1 +
src/services/users.service.js | 11 +++++++++++
4 files changed, 28 insertions(+), 1 deletion(-)
diff --git a/src/controllers/users.controller.js b/src/controllers/users.controller.js
index 9c62d0b..8bb25ba 100644
--- a/src/controllers/users.controller.js
+++ b/src/controllers/users.controller.js
@@ -54,4 +54,9 @@ exports.switchRole = async (req, res) => {
const data = await usersService.switchRole(userId, role);
return success(res, data, 'Role switched successfully.');
+};
+
+exports.deleteMyAvatar= async(req,res) => {
+ const data = await usersService.deleteMyAvatar(req.user.sub);
+ return success(res, data, 'Your avatar deleted successfully.');
};
\ No newline at end of file
diff --git a/src/models/user.model.js b/src/models/user.model.js
index ac94a76..69aa34d 100644
--- a/src/models/user.model.js
+++ b/src/models/user.model.js
@@ -199,4 +199,14 @@ exports.updateRole = async (userId, newRole) => {
[newRole, userId]
);
return rows[0] || null;
-}
\ No newline at end of file
+}
+exports.deleteAvatar = async (userId) => {
+ const { rows } = await db.query(
+ `UPDATE users SET profile_picture = NULL, updated_at = now()
+ WHERE id = $1 AND deleted_at IS NULL
+ RETURNING profile_picture`,
+ [userId]
+ );
+ return rows[0] || null;
+};
+
\ No newline at end of file
diff --git a/src/routes/users.routes.js b/src/routes/users.routes.js
index 016982e..c46c330 100644
--- a/src/routes/users.routes.js
+++ b/src/routes/users.routes.js
@@ -16,5 +16,6 @@ router.patch('/me/account', authenticate, asyncHandler(controller.updateMyAccoun
router.patch('/me/role', authenticate, asyncHandler(controller.switchRole));
router.get('/:user_id', optionalAuthenticate, asyncHandler(controller.getUserById));
+router.delete('/me/avatar', authenticate, asyncHandler(controller.deleteMyAvatar));
module.exports = router;
\ No newline at end of file
diff --git a/src/services/users.service.js b/src/services/users.service.js
index f7b014b..11239ab 100644
--- a/src/services/users.service.js
+++ b/src/services/users.service.js
@@ -118,3 +118,14 @@ exports.switchRole = async (userId, role) => {
return await userModel.updateRole(userId, role);
};
+
+exports.deleteMyAvatar = async (userId) => {
+ const user = await userModel.findById(userId);
+ if(!user){
+ throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+ if (!user.profile_picture) {
+ throw new AppError('No avatar to delete', 404, 'RESOURCE_NOT_FOUND');
+ }
+ return await userModel.deleteAvatar(userId);
+}
From f7ffb219c42ddb252f8f6a8263b2dd937c4f7d08 Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Mon, 16 Mar 2026 00:53:17 +0200
Subject: [PATCH 022/535] added socket.io
- Added verifyToken import in server.js
- Added io.use() middleware in server.js- Reads token from socket.handshake.auth.token with Bearer prefix
- Rejects invalid/expired tokens
---
server.js | 15 ++++
src/sockets/messages.socket.js | 139 ++++++++++++++++++++++++++++++++-
2 files changed, 150 insertions(+), 4 deletions(-)
diff --git a/server.js b/server.js
index 11b1864..9879f14 100644
--- a/server.js
+++ b/server.js
@@ -5,6 +5,7 @@ const http = require('http');
const { Server } = require('socket.io');
const app = require('./app');
const env = require('./src/config/env');
+const { verifyToken } = require('./src/config/jwt');
const { registerNotificationHandlers } = require('./src/sockets/notifications.socket');
const { registerMessageHandlers } = require('./src/sockets/messages.socket');
@@ -14,6 +15,20 @@ const io = new Server(server, {
cors: { origin: env.CLIENT_URL, methods: ['GET', 'POST'], credentials: true },
});
+io.use((socket, next) => {
+ const authHeader = socket.handshake.auth?.token;
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
+ return next(new Error('Access token required'));
+ }
+ const token = authHeader.split(' ')[1];
+ try {
+ socket.user = verifyToken(token);
+ next();
+ } catch {
+ return next(new Error('Invalid or expired token'));
+ }
+});
+
io.on('connection', (socket) => {
console.log(`[Socket.IO] Client connected: ${socket.id}`);
diff --git a/src/sockets/messages.socket.js b/src/sockets/messages.socket.js
index a804a4d..d4f56c7 100644
--- a/src/sockets/messages.socket.js
+++ b/src/sockets/messages.socket.js
@@ -3,12 +3,143 @@
// Owner : Alyaa Mohamed (BE-4) — Module 9
// Real-time 1-to-1 messaging via Socket.IO
// Handles: message delivery, read-status, typing indicators
+//
+// ARCHITECTURE:
+// - HTTP endpoints handle DB persistence (source of truth)
+// - Socket events handle real-time UI updates only
+// - Client must call HTTP endpoint first, then emit socket event
+//
+// Room naming convention: conversation:
// ============================================================
+const db = require('../config/db');
+
+// ------------------------------------------------------------
+// Helper — validate UUID format
+// Only used where a DB call is made to prevent unhandled errors
+// ------------------------------------------------------------
+const isValidUUID = (val) =>
+ typeof val === 'string' &&
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(val);
+
+// ------------------------------------------------------------
+// Helper — verify user is a participant in a conversation
+// Prevents users from joining rooms they don't belong to
+// [DEPENDS: conversations table]
+// ------------------------------------------------------------
+const isParticipant = async (conversationId, userId) => {
+ const { rows } = await db.query(
+ `SELECT 1 FROM conversations
+ WHERE id = $1
+ AND (user_a_id = $2 OR user_b_id = $2)
+ AND NOT (user_a_id = $2 AND deleted_by_a = true)
+ AND NOT (user_b_id = $2 AND deleted_by_b = true)`,
+ [conversationId, userId]
+ );
+ return rows.length > 0;
+};
+
+// ------------------------------------------------------------
const registerMessageHandlers = (io, socket) => {
- // TODO: Implement real-time messaging event handlers
- // Example:
- // socket.on('message:send', async ({ conversationId, content }) => { ... });
+
+ // userId extracted from JWT-verified payload set by server.js middleware
+ const userId = socket.user?.sub;
+
+ // ----------------------------------------------------------
+ // Join a conversation room
+ // Client emits when user opens a conversation
+ // Security: UUID validated and participant check done here
+ // because this is a socket-only event with no HTTP equivalent
+ // ----------------------------------------------------------
+ socket.on('message:join', async ({ conversationId }) => {
+
+ if (!isValidUUID(conversationId) || !isValidUUID(userId)) {
+ return socket.emit('error', { message: 'Invalid conversationId.' });
+ }
+
+ try {
+ const allowed = await isParticipant(conversationId, userId);
+ if (!allowed) {
+ return socket.emit('error', { message: 'You are not a participant in this conversation.' });
+ }
+ } catch (err) {
+ console.error(`[Socket.IO] message:join DB error:`, err.message);
+ return socket.emit('error', { message: 'Something went wrong. Please try again.' });
+ }
+
+ const room = `conversation:${conversationId}`;
+ socket.join(room);
+ console.log(`[Socket.IO] ${socket.id} (user: ${userId}) joined ${room}`);
+ });
+
+ // ----------------------------------------------------------
+ // Leave a conversation room
+ // Client emits when user closes a conversation
+ // ----------------------------------------------------------
+ socket.on('message:leave', ({ conversationId }) => {
+ const room = `conversation:${conversationId}`;
+ socket.leave(room);
+ console.log(`[Socket.IO] ${socket.id} (user: ${userId}) left ${room}`);
+ });
+
+ // ----------------------------------------------------------
+ // New message delivered in real time
+ // Client emits AFTER POST /messages/new or
+ // POST /messages/conversations/:conversationId/messages succeeds
+ // Passes the full message object from the HTTP response
+ // ----------------------------------------------------------
+ socket.on('message:send', ({ conversationId, message }) => {
+ const room = `conversation:${conversationId}`;
+ socket.to(room).emit('message:received', { conversationId, message });
+ console.log(`[Socket.IO] message:send in ${room} by user: ${userId}`);
+ });
+
+ // ----------------------------------------------------------
+ // Message deleted in real time
+ // Client emits AFTER
+ // DELETE /messages/conversations/:conversationId/messages/:messageId succeeds
+ // ----------------------------------------------------------
+ socket.on('message:deleted', ({ conversationId, messageId }) => {
+ const room = `conversation:${conversationId}`;
+ socket.to(room).emit('message:removed', { conversationId, messageId });
+ console.log(`[Socket.IO] message:deleted in ${room} by user: ${userId}`);
+ });
+
+ // ----------------------------------------------------------
+ // Message read state updated in real time
+ // Client emits AFTER
+ // PATCH /messages/conversations/:conversationId/messages/:messageId/read succeeds
+ // ----------------------------------------------------------
+ socket.on('message:read', ({ conversationId, messageId, isRead, conversationUnreadCount }) => {
+ const room = `conversation:${conversationId}`;
+ socket.to(room).emit('message:read_updated', {
+ conversationId,
+ messageId,
+ isRead,
+ conversationUnreadCount,
+ });
+ console.log(`[Socket.IO] message:read in ${room} by user: ${userId}`);
+ });
+
+ // ----------------------------------------------------------
+ // Typing indicator
+ // Client emits while user is typing
+ // NOTE: Frontend should throttle this to once per ~1000ms
+ // ----------------------------------------------------------
+ socket.on('message:typing', ({ conversationId }) => {
+ const room = `conversation:${conversationId}`;
+ socket.to(room).emit('message:typing', { conversationId, userId });
+ });
+
+ // ----------------------------------------------------------
+ // Stop typing indicator
+ // Client emits when user stops typing or sends the message
+ // ----------------------------------------------------------
+ socket.on('message:stop_typing', ({ conversationId }) => {
+ const room = `conversation:${conversationId}`;
+ socket.to(room).emit('message:stop_typing', { conversationId, userId });
+ });
+
};
-module.exports = { registerMessageHandlers };
+module.exports = { registerMessageHandlers };
\ No newline at end of file
From fb95bc300e4bf356cb6e669c2a2985a94544eb8e Mon Sep 17 00:00:00 2001
From: Beshoy205
Date: Mon, 16 Mar 2026 04:03:59 +0200
Subject: [PATCH 023/535] feat(users): add avatar, cover, and web profile
endpoints
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- POST /me/avatar — upload profile picture
- DELETE /me/avatar — remove profile picture
- POST /me/cover — upload cover photo
- DELETE /me/cover — remove cover photo
- GET /me/web-profiles — list web profile links
---
src/controllers/users.controller.js | 28 ++++++++++++++++++--
src/models/user.model.js | 41 ++++++++++++++++++++++++++++-
src/routes/users.routes.js | 7 +++++
src/services/users.service.js | 34 ++++++++++++++++++++++++
4 files changed, 107 insertions(+), 3 deletions(-)
diff --git a/src/controllers/users.controller.js b/src/controllers/users.controller.js
index 8bb25ba..bd45647 100644
--- a/src/controllers/users.controller.js
+++ b/src/controllers/users.controller.js
@@ -58,5 +58,29 @@ exports.switchRole = async (req, res) => {
exports.deleteMyAvatar= async(req,res) => {
const data = await usersService.deleteMyAvatar(req.user.sub);
- return success(res, data, 'Your avatar deleted successfully.');
-};
\ No newline at end of file
+ return success(res, data, 'Your profile picture deleted successfully.');
+};
+
+exports.uploadMyAvatar = async (req, res) => {
+ if (!req.file) {
+ throw new AppError('No image file provided.', 400, 'VALIDATION_FAILED');
+ }
+ const data = await usersService.uploadMyAvatar(req.user.sub, req.file);
+ return success(res, data, 'Avatar uploaded successfully.');
+};
+exports.uploadMyCoverPhoto = async (req, res) => {
+ if (!req.file) {
+ throw new AppError('No image file provided.', 400, 'VALIDATION_FAILED');
+ }
+ const data = await usersService.uploadMyCoverPhoto(req.user.sub, req.file);
+ return success(res, data, 'Cover photo uploaded successfully.');
+};
+exports.deleteMyCoverPhoto = async(req,res) => {
+ const data = await usersService.deleteMyCoverPhoto(req.user.sub);
+ return success(res, data, 'Your cover photo deleted successfully.');
+};
+exports.getMyWebProfile = async (req, res) => {
+ const data = await usersService.getMyWebProfile(req.user.sub);
+ return success(res, data, 'Web profile returned successfully.');
+};
+
diff --git a/src/models/user.model.js b/src/models/user.model.js
index 69aa34d..bbe5350 100644
--- a/src/models/user.model.js
+++ b/src/models/user.model.js
@@ -209,4 +209,43 @@ exports.deleteAvatar = async (userId) => {
);
return rows[0] || null;
};
-
\ No newline at end of file
+
+exports.updateAvatar = async (userId, imagePath) => {
+ const { rows } = await db.query(
+ `UPDATE users SET profile_picture = $1, updated_at = now()
+ WHERE id = $2 AND deleted_at IS NULL
+ RETURNING profile_picture`,
+ [imagePath, userId]
+ );
+ return rows[0] || null;
+};
+exports.updateCoverPhoto = async (userId, imagePath) => {
+ const { rows } = await db.query(
+ `UPDATE users SET cover_photo = $1, updated_at = now()
+ WHERE id = $2 AND deleted_at IS NULL
+ RETURNING cover_photo`,
+ [imagePath, userId]
+ );
+ return rows[0] || null;
+};
+
+exports.deleteCoverPhoto = async (userId) => {
+ const { rows } = await db.query(
+ `UPDATE users SET cover_photo = NULL, updated_at = now()
+ WHERE id = $1 AND deleted_at IS NULL
+ RETURNING cover_photo`,
+ [userId]
+ );
+ return rows[0] || null;
+};
+
+exports.findWebProfilesByUserId = async (userId) => {
+ const { rows } = await db.query(
+ `SELECT id, platform, url FROM web_profiles WHERE user_id = $1`,
+ [userId]
+ );
+ return rows || [];
+};
+
+
+
\ No newline at end of file
diff --git a/src/routes/users.routes.js b/src/routes/users.routes.js
index c46c330..c8cda42 100644
--- a/src/routes/users.routes.js
+++ b/src/routes/users.routes.js
@@ -8,6 +8,7 @@ const router = express.Router();
const controller = require('../controllers/users.controller');
const { authenticate, optionalAuthenticate } = require('../middleware/auth');
const asyncHandler = require('../utils/async-handler');
+const { uploadImage } = require('../middleware/multer');
router.get('/me', authenticate ,asyncHandler(controller.getMe));
@@ -17,5 +18,11 @@ router.patch('/me/role', authenticate, asyncHandler(controller.switchRole));
router.get('/:user_id', optionalAuthenticate, asyncHandler(controller.getUserById));
router.delete('/me/avatar', authenticate, asyncHandler(controller.deleteMyAvatar));
+router.post('/me/avatar', authenticate, uploadImage.single('avatar'), asyncHandler(controller.uploadMyAvatar));
+router.post('/me/cover', authenticate, uploadImage.single('cover'), asyncHandler(controller.uploadMyCoverPhoto));
+router.delete('/me/cover', authenticate, asyncHandler(controller.deleteMyCoverPhoto));
+router.get('/me/web-profile', authenticate, asyncHandler(controller.getMyWebProfile));
+
+
module.exports = router;
\ No newline at end of file
diff --git a/src/services/users.service.js b/src/services/users.service.js
index 11239ab..1b30deb 100644
--- a/src/services/users.service.js
+++ b/src/services/users.service.js
@@ -129,3 +129,37 @@ exports.deleteMyAvatar = async (userId) => {
}
return await userModel.deleteAvatar(userId);
}
+exports.uploadMyAvatar = async (userId, file) => {
+ const user = await userModel.findById(userId);
+ if (!user) {
+ throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+ // upload file to CDN and get back real URL
+ const avatarUrl = `https://cdn.rythmify.com/avatars/${userId}.jpg`;
+ return await userModel.updateAvatar(userId, avatarUrl);
+};
+
+exports.uploadMyCoverPhoto = async (userId, file) => {
+ const user = await userModel.findById(userId);
+ if (!user) {
+ throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+ // upload file to CDN and get back real URL
+ const coverUrl = `https://cdn.rythmify.com/covers/${userId}.jpg`;
+ return await userModel.updateCoverPhoto(userId, coverUrl);
+};
+exports.deleteMyCoverPhoto = async (userId) => {
+ const user = await userModel.findById(userId);
+ if (!user) {
+ throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+ if (!user.cover_photo) {
+ throw new AppError('No cover photo to delete', 404, 'RESOURCE_NOT_FOUND');
+ }
+ return await userModel.deleteCoverPhoto(userId);
+};
+
+exports.getMyWebProfiles = async (userId) => {
+ return await userModel.findWebProfilesByUserId(userId);
+};
+
From 5311a1578f9d6e105735fe29675f6041c2abbc45 Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Mon, 16 Mar 2026 05:16:52 +0200
Subject: [PATCH 024/535] feat(tracks): implement POST /tracks with B2 upload
---
.gitignore | 4 +
package-lock.json | 633 ++++++++++++++-------------
package.json | 2 +-
src/config/b2.js | 13 +
src/config/env.js | 6 +-
src/controllers/tracks.controller.js | 40 +-
src/middleware/multer.js | 77 +++-
src/models/track.model.js | 69 ++-
src/routes/tracks.routes.js | 15 +-
src/services/storage.service.js | 50 +++
src/services/tracks.service.js | 101 ++++-
11 files changed, 661 insertions(+), 349 deletions(-)
create mode 100644 src/config/b2.js
create mode 100644 src/services/storage.service.js
diff --git a/.gitignore b/.gitignore
index 4904a7f..871692b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -146,3 +146,7 @@ dist
# Vite logs files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+
+# postman
+postman
+.postman
diff --git a/package-lock.json b/package-lock.json
index a1c2d00..4d4cf9c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,7 +8,7 @@
"name": "rythmify-backend",
"version": "1.0.0",
"dependencies": {
- "@aws-sdk/client-s3": "^3.1008.0",
+ "@aws-sdk/client-s3": "^3.1009.0",
"@aws-sdk/s3-request-presigner": "^3.1008.0",
"bcryptjs": "^2.4.3",
"cloudinary": "^1.41.3",
@@ -241,65 +241,65 @@
}
},
"node_modules/@aws-sdk/client-s3": {
- "version": "3.1008.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1008.0.tgz",
- "integrity": "sha512-w/SIRD25v2zVMbkn8CYIxUsac8yf5Jghkhw5j7EsNWdJhl56m/nWpUX7t1etFUW1cnzpFjZV0lXt0dNFSnbXwA==",
+ "version": "3.1009.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1009.0.tgz",
+ "integrity": "sha512-luy8CxallkoiGWTqU86ca/BbvkWJjs0oala7uIIRN1JtQxMb5i4Yl/PBZVcQFhbK9kQi0PK0GfD8gIpLkI91fw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha1-browser": "5.2.0",
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
- "@aws-sdk/core": "^3.973.19",
- "@aws-sdk/credential-provider-node": "^3.972.20",
- "@aws-sdk/middleware-bucket-endpoint": "^3.972.7",
- "@aws-sdk/middleware-expect-continue": "^3.972.7",
- "@aws-sdk/middleware-flexible-checksums": "^3.973.5",
- "@aws-sdk/middleware-host-header": "^3.972.7",
- "@aws-sdk/middleware-location-constraint": "^3.972.7",
- "@aws-sdk/middleware-logger": "^3.972.7",
- "@aws-sdk/middleware-recursion-detection": "^3.972.7",
- "@aws-sdk/middleware-sdk-s3": "^3.972.19",
- "@aws-sdk/middleware-ssec": "^3.972.7",
- "@aws-sdk/middleware-user-agent": "^3.972.20",
- "@aws-sdk/region-config-resolver": "^3.972.7",
- "@aws-sdk/signature-v4-multi-region": "^3.996.7",
- "@aws-sdk/types": "^3.973.5",
- "@aws-sdk/util-endpoints": "^3.996.4",
- "@aws-sdk/util-user-agent-browser": "^3.972.7",
- "@aws-sdk/util-user-agent-node": "^3.973.6",
- "@smithy/config-resolver": "^4.4.10",
- "@smithy/core": "^3.23.9",
- "@smithy/eventstream-serde-browser": "^4.2.11",
- "@smithy/eventstream-serde-config-resolver": "^4.3.11",
- "@smithy/eventstream-serde-node": "^4.2.11",
- "@smithy/fetch-http-handler": "^5.3.13",
- "@smithy/hash-blob-browser": "^4.2.12",
- "@smithy/hash-node": "^4.2.11",
- "@smithy/hash-stream-node": "^4.2.11",
- "@smithy/invalid-dependency": "^4.2.11",
- "@smithy/md5-js": "^4.2.11",
- "@smithy/middleware-content-length": "^4.2.11",
- "@smithy/middleware-endpoint": "^4.4.23",
- "@smithy/middleware-retry": "^4.4.40",
- "@smithy/middleware-serde": "^4.2.12",
- "@smithy/middleware-stack": "^4.2.11",
- "@smithy/node-config-provider": "^4.3.11",
- "@smithy/node-http-handler": "^4.4.14",
- "@smithy/protocol-http": "^5.3.11",
- "@smithy/smithy-client": "^4.12.3",
- "@smithy/types": "^4.13.0",
- "@smithy/url-parser": "^4.2.11",
+ "@aws-sdk/core": "^3.973.20",
+ "@aws-sdk/credential-provider-node": "^3.972.21",
+ "@aws-sdk/middleware-bucket-endpoint": "^3.972.8",
+ "@aws-sdk/middleware-expect-continue": "^3.972.8",
+ "@aws-sdk/middleware-flexible-checksums": "^3.973.6",
+ "@aws-sdk/middleware-host-header": "^3.972.8",
+ "@aws-sdk/middleware-location-constraint": "^3.972.8",
+ "@aws-sdk/middleware-logger": "^3.972.8",
+ "@aws-sdk/middleware-recursion-detection": "^3.972.8",
+ "@aws-sdk/middleware-sdk-s3": "^3.972.20",
+ "@aws-sdk/middleware-ssec": "^3.972.8",
+ "@aws-sdk/middleware-user-agent": "^3.972.21",
+ "@aws-sdk/region-config-resolver": "^3.972.8",
+ "@aws-sdk/signature-v4-multi-region": "^3.996.8",
+ "@aws-sdk/types": "^3.973.6",
+ "@aws-sdk/util-endpoints": "^3.996.5",
+ "@aws-sdk/util-user-agent-browser": "^3.972.8",
+ "@aws-sdk/util-user-agent-node": "^3.973.7",
+ "@smithy/config-resolver": "^4.4.11",
+ "@smithy/core": "^3.23.11",
+ "@smithy/eventstream-serde-browser": "^4.2.12",
+ "@smithy/eventstream-serde-config-resolver": "^4.3.12",
+ "@smithy/eventstream-serde-node": "^4.2.12",
+ "@smithy/fetch-http-handler": "^5.3.15",
+ "@smithy/hash-blob-browser": "^4.2.13",
+ "@smithy/hash-node": "^4.2.12",
+ "@smithy/hash-stream-node": "^4.2.12",
+ "@smithy/invalid-dependency": "^4.2.12",
+ "@smithy/md5-js": "^4.2.12",
+ "@smithy/middleware-content-length": "^4.2.12",
+ "@smithy/middleware-endpoint": "^4.4.25",
+ "@smithy/middleware-retry": "^4.4.42",
+ "@smithy/middleware-serde": "^4.2.14",
+ "@smithy/middleware-stack": "^4.2.12",
+ "@smithy/node-config-provider": "^4.3.12",
+ "@smithy/node-http-handler": "^4.4.16",
+ "@smithy/protocol-http": "^5.3.12",
+ "@smithy/smithy-client": "^4.12.5",
+ "@smithy/types": "^4.13.1",
+ "@smithy/url-parser": "^4.2.12",
"@smithy/util-base64": "^4.3.2",
"@smithy/util-body-length-browser": "^4.2.2",
"@smithy/util-body-length-node": "^4.2.3",
- "@smithy/util-defaults-mode-browser": "^4.3.39",
- "@smithy/util-defaults-mode-node": "^4.2.42",
- "@smithy/util-endpoints": "^3.3.2",
- "@smithy/util-middleware": "^4.2.11",
- "@smithy/util-retry": "^4.2.11",
- "@smithy/util-stream": "^4.5.17",
+ "@smithy/util-defaults-mode-browser": "^4.3.41",
+ "@smithy/util-defaults-mode-node": "^4.2.44",
+ "@smithy/util-endpoints": "^3.3.3",
+ "@smithy/util-middleware": "^4.2.12",
+ "@smithy/util-retry": "^4.2.12",
+ "@smithy/util-stream": "^4.5.19",
"@smithy/util-utf8": "^4.2.2",
- "@smithy/util-waiter": "^4.2.12",
+ "@smithy/util-waiter": "^4.2.13",
"tslib": "^2.6.2"
},
"engines": {
@@ -307,22 +307,22 @@
}
},
"node_modules/@aws-sdk/core": {
- "version": "3.973.19",
- "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.19.tgz",
- "integrity": "sha512-56KePyOcZnKTWCd89oJS1G6j3HZ9Kc+bh/8+EbvtaCCXdP6T7O7NzCiPuHRhFLWnzXIaXX3CxAz0nI5My9spHQ==",
+ "version": "3.973.20",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.20.tgz",
+ "integrity": "sha512-i3GuX+lowD892F3IuJf8o6AbyDupMTdyTxQrCJGcn71ni5hTZ82L4nQhcdumxZ7XPJRJJVHS/CR3uYOIIs0PVA==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/types": "^3.973.5",
- "@aws-sdk/xml-builder": "^3.972.10",
- "@smithy/core": "^3.23.9",
- "@smithy/node-config-provider": "^4.3.11",
- "@smithy/property-provider": "^4.2.11",
- "@smithy/protocol-http": "^5.3.11",
- "@smithy/signature-v4": "^5.3.11",
- "@smithy/smithy-client": "^4.12.3",
- "@smithy/types": "^4.13.0",
+ "@aws-sdk/types": "^3.973.6",
+ "@aws-sdk/xml-builder": "^3.972.11",
+ "@smithy/core": "^3.23.11",
+ "@smithy/node-config-provider": "^4.3.12",
+ "@smithy/property-provider": "^4.2.12",
+ "@smithy/protocol-http": "^5.3.12",
+ "@smithy/signature-v4": "^5.3.12",
+ "@smithy/smithy-client": "^4.12.5",
+ "@smithy/types": "^4.13.1",
"@smithy/util-base64": "^4.3.2",
- "@smithy/util-middleware": "^4.2.11",
+ "@smithy/util-middleware": "^4.2.12",
"@smithy/util-utf8": "^4.2.2",
"tslib": "^2.6.2"
},
@@ -331,12 +331,12 @@
}
},
"node_modules/@aws-sdk/crc64-nvme": {
- "version": "3.972.4",
- "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.4.tgz",
- "integrity": "sha512-HKZIZLbRyvzo/bXZU7Zmk6XqU+1C9DjI56xd02vwuDIxedxBEqP17t9ExhbP9QFeNq/a3l9GOcyirFXxmbDhmw==",
+ "version": "3.972.5",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.5.tgz",
+ "integrity": "sha512-2VbTstbjKdT+yKi8m7b3a9CiVac+pL/IY2PHJwsaGkkHmuuqkJZIErPck1h6P3T9ghQMLSdMPyW6Qp7Di5swFg==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.13.0",
+ "@smithy/types": "^4.13.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -344,15 +344,15 @@
}
},
"node_modules/@aws-sdk/credential-provider-env": {
- "version": "3.972.17",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.17.tgz",
- "integrity": "sha512-MBAMW6YELzE1SdkOniqr51mrjapQUv8JXSGxtwRjQV0mwVDutVsn22OPAUt4RcLRvdiHQmNBDEFP9iTeSVCOlA==",
+ "version": "3.972.18",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.18.tgz",
+ "integrity": "sha512-X0B8AlQY507i5DwjLByeU2Af4ARsl9Vr84koDcXCbAkplmU+1xBFWxEPrWRAoh56waBne/yJqEloSwvRf4x6XA==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.973.19",
- "@aws-sdk/types": "^3.973.5",
- "@smithy/property-provider": "^4.2.11",
- "@smithy/types": "^4.13.0",
+ "@aws-sdk/core": "^3.973.20",
+ "@aws-sdk/types": "^3.973.6",
+ "@smithy/property-provider": "^4.2.12",
+ "@smithy/types": "^4.13.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -360,20 +360,20 @@
}
},
"node_modules/@aws-sdk/credential-provider-http": {
- "version": "3.972.19",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.19.tgz",
- "integrity": "sha512-9EJROO8LXll5a7eUFqu48k6BChrtokbmgeMWmsH7lBb6lVbtjslUYz/ShLi+SHkYzTomiGBhmzTW7y+H4BxsnA==",
+ "version": "3.972.20",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.20.tgz",
+ "integrity": "sha512-ey9Lelj001+oOfrbKmS6R2CJAiXX7QKY4Vj9VJv6L2eE6/VjD8DocHIoYqztTm70xDLR4E1jYPTKfIui+eRNDA==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.973.19",
- "@aws-sdk/types": "^3.973.5",
- "@smithy/fetch-http-handler": "^5.3.13",
- "@smithy/node-http-handler": "^4.4.14",
- "@smithy/property-provider": "^4.2.11",
- "@smithy/protocol-http": "^5.3.11",
- "@smithy/smithy-client": "^4.12.3",
- "@smithy/types": "^4.13.0",
- "@smithy/util-stream": "^4.5.17",
+ "@aws-sdk/core": "^3.973.20",
+ "@aws-sdk/types": "^3.973.6",
+ "@smithy/fetch-http-handler": "^5.3.15",
+ "@smithy/node-http-handler": "^4.4.16",
+ "@smithy/property-provider": "^4.2.12",
+ "@smithy/protocol-http": "^5.3.12",
+ "@smithy/smithy-client": "^4.12.5",
+ "@smithy/types": "^4.13.1",
+ "@smithy/util-stream": "^4.5.19",
"tslib": "^2.6.2"
},
"engines": {
@@ -381,24 +381,24 @@
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
- "version": "3.972.19",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.19.tgz",
- "integrity": "sha512-pVJVjWqVrPqjpFq7o0mCmeZu1Y0c94OCHSYgivdCD2wfmYVtBbwQErakruhgOD8pcMcx9SCqRw1pzHKR7OGBcA==",
+ "version": "3.972.20",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.20.tgz",
+ "integrity": "sha512-5flXSnKHMloObNF+9N0cupKegnH1Z37cdVlpETVgx8/rAhCe+VNlkcZH3HDg2SDn9bI765S+rhNPXGDJJPfbtA==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.973.19",
- "@aws-sdk/credential-provider-env": "^3.972.17",
- "@aws-sdk/credential-provider-http": "^3.972.19",
- "@aws-sdk/credential-provider-login": "^3.972.19",
- "@aws-sdk/credential-provider-process": "^3.972.17",
- "@aws-sdk/credential-provider-sso": "^3.972.19",
- "@aws-sdk/credential-provider-web-identity": "^3.972.19",
- "@aws-sdk/nested-clients": "^3.996.9",
- "@aws-sdk/types": "^3.973.5",
- "@smithy/credential-provider-imds": "^4.2.11",
- "@smithy/property-provider": "^4.2.11",
- "@smithy/shared-ini-file-loader": "^4.4.6",
- "@smithy/types": "^4.13.0",
+ "@aws-sdk/core": "^3.973.20",
+ "@aws-sdk/credential-provider-env": "^3.972.18",
+ "@aws-sdk/credential-provider-http": "^3.972.20",
+ "@aws-sdk/credential-provider-login": "^3.972.20",
+ "@aws-sdk/credential-provider-process": "^3.972.18",
+ "@aws-sdk/credential-provider-sso": "^3.972.20",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.20",
+ "@aws-sdk/nested-clients": "^3.996.10",
+ "@aws-sdk/types": "^3.973.6",
+ "@smithy/credential-provider-imds": "^4.2.12",
+ "@smithy/property-provider": "^4.2.12",
+ "@smithy/shared-ini-file-loader": "^4.4.7",
+ "@smithy/types": "^4.13.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -406,18 +406,18 @@
}
},
"node_modules/@aws-sdk/credential-provider-login": {
- "version": "3.972.19",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.19.tgz",
- "integrity": "sha512-jOXdZ1o+CywQKr6gyxgxuUmnGwTTnY2Kxs1PM7fI6AYtDWDnmW/yKXayNqkF8KjP1unflqMWKVbVt5VgmE3L0g==",
+ "version": "3.972.20",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.20.tgz",
+ "integrity": "sha512-gEWo54nfqp2jABMu6HNsjVC4hDLpg9HC8IKSJnp0kqWtxIJYHTmiLSsIfI4ScQjxEwpB+jOOH8dOLax1+hy/Hw==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.973.19",
- "@aws-sdk/nested-clients": "^3.996.9",
- "@aws-sdk/types": "^3.973.5",
- "@smithy/property-provider": "^4.2.11",
- "@smithy/protocol-http": "^5.3.11",
- "@smithy/shared-ini-file-loader": "^4.4.6",
- "@smithy/types": "^4.13.0",
+ "@aws-sdk/core": "^3.973.20",
+ "@aws-sdk/nested-clients": "^3.996.10",
+ "@aws-sdk/types": "^3.973.6",
+ "@smithy/property-provider": "^4.2.12",
+ "@smithy/protocol-http": "^5.3.12",
+ "@smithy/shared-ini-file-loader": "^4.4.7",
+ "@smithy/types": "^4.13.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -425,22 +425,22 @@
}
},
"node_modules/@aws-sdk/credential-provider-node": {
- "version": "3.972.20",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.20.tgz",
- "integrity": "sha512-0xHca2BnPY0kzjDYPH7vk8YbfdBPpWVS67rtqQMalYDQUCBYS37cZ55K6TuFxCoIyNZgSCFrVKr9PXC5BVvQQw==",
+ "version": "3.972.21",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.21.tgz",
+ "integrity": "sha512-hah8if3/B/Q+LBYN5FukyQ1Mym6PLPDsBOBsIgNEYD6wLyZg0UmUF/OKIVC3nX9XH8TfTPuITK+7N/jenVACWA==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/credential-provider-env": "^3.972.17",
- "@aws-sdk/credential-provider-http": "^3.972.19",
- "@aws-sdk/credential-provider-ini": "^3.972.19",
- "@aws-sdk/credential-provider-process": "^3.972.17",
- "@aws-sdk/credential-provider-sso": "^3.972.19",
- "@aws-sdk/credential-provider-web-identity": "^3.972.19",
- "@aws-sdk/types": "^3.973.5",
- "@smithy/credential-provider-imds": "^4.2.11",
- "@smithy/property-provider": "^4.2.11",
- "@smithy/shared-ini-file-loader": "^4.4.6",
- "@smithy/types": "^4.13.0",
+ "@aws-sdk/credential-provider-env": "^3.972.18",
+ "@aws-sdk/credential-provider-http": "^3.972.20",
+ "@aws-sdk/credential-provider-ini": "^3.972.20",
+ "@aws-sdk/credential-provider-process": "^3.972.18",
+ "@aws-sdk/credential-provider-sso": "^3.972.20",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.20",
+ "@aws-sdk/types": "^3.973.6",
+ "@smithy/credential-provider-imds": "^4.2.12",
+ "@smithy/property-provider": "^4.2.12",
+ "@smithy/shared-ini-file-loader": "^4.4.7",
+ "@smithy/types": "^4.13.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -448,16 +448,16 @@
}
},
"node_modules/@aws-sdk/credential-provider-process": {
- "version": "3.972.17",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.17.tgz",
- "integrity": "sha512-c8G8wT1axpJDgaP3xzcy+q8Y1fTi9A2eIQJvyhQ9xuXrUZhlCfXbC0vM9bM1CUXiZppFQ1p7g0tuUMvil/gCPg==",
+ "version": "3.972.18",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.18.tgz",
+ "integrity": "sha512-Tpl7SRaPoOLT32jbTWchPsn52hYYgJ0kpiFgnwk8pxTANQdUymVSZkzFvv1+oOgZm1CrbQUP9MBeoMZ9IzLZjA==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.973.19",
- "@aws-sdk/types": "^3.973.5",
- "@smithy/property-provider": "^4.2.11",
- "@smithy/shared-ini-file-loader": "^4.4.6",
- "@smithy/types": "^4.13.0",
+ "@aws-sdk/core": "^3.973.20",
+ "@aws-sdk/types": "^3.973.6",
+ "@smithy/property-provider": "^4.2.12",
+ "@smithy/shared-ini-file-loader": "^4.4.7",
+ "@smithy/types": "^4.13.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -465,18 +465,18 @@
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
- "version": "3.972.19",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.19.tgz",
- "integrity": "sha512-kVjQsEU3b///q7EZGrUzol9wzwJFKbEzqJKSq82A9ShrUTEO7FNylTtby3sPV19ndADZh1H3FB3+5ZrvKtEEeg==",
+ "version": "3.972.20",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.20.tgz",
+ "integrity": "sha512-p+R+PYR5Z7Gjqf/6pvbCnzEHcqPCpLzR7Yf127HjJ6EAb4hUcD+qsNRnuww1sB/RmSeCLxyay8FMyqREw4p1RA==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.973.19",
- "@aws-sdk/nested-clients": "^3.996.9",
- "@aws-sdk/token-providers": "3.1008.0",
- "@aws-sdk/types": "^3.973.5",
- "@smithy/property-provider": "^4.2.11",
- "@smithy/shared-ini-file-loader": "^4.4.6",
- "@smithy/types": "^4.13.0",
+ "@aws-sdk/core": "^3.973.20",
+ "@aws-sdk/nested-clients": "^3.996.10",
+ "@aws-sdk/token-providers": "3.1009.0",
+ "@aws-sdk/types": "^3.973.6",
+ "@smithy/property-provider": "^4.2.12",
+ "@smithy/shared-ini-file-loader": "^4.4.7",
+ "@smithy/types": "^4.13.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -484,17 +484,17 @@
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
- "version": "3.972.19",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.19.tgz",
- "integrity": "sha512-BV1BlTFdG4w4tAihxN7iXDBoNcNewXD4q8uZlNQiUrnqxwGWUhKHODIQVSPlQGxXClEj+63m+cqZskw+ESmeZg==",
+ "version": "3.972.20",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.20.tgz",
+ "integrity": "sha512-rWCmh8o7QY4CsUj63qopzMzkDq/yPpkrpb+CnjBEFSOg/02T/we7sSTVg4QsDiVS9uwZ8VyONhq98qt+pIh3KA==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.973.19",
- "@aws-sdk/nested-clients": "^3.996.9",
- "@aws-sdk/types": "^3.973.5",
- "@smithy/property-provider": "^4.2.11",
- "@smithy/shared-ini-file-loader": "^4.4.6",
- "@smithy/types": "^4.13.0",
+ "@aws-sdk/core": "^3.973.20",
+ "@aws-sdk/nested-clients": "^3.996.10",
+ "@aws-sdk/types": "^3.973.6",
+ "@smithy/property-provider": "^4.2.12",
+ "@smithy/shared-ini-file-loader": "^4.4.7",
+ "@smithy/types": "^4.13.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -502,16 +502,16 @@
}
},
"node_modules/@aws-sdk/middleware-bucket-endpoint": {
- "version": "3.972.7",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.7.tgz",
- "integrity": "sha512-goX+axlJ6PQlRnzE2bQisZ8wVrlm6dXJfBzMJhd8LhAIBan/w1Kl73fJnalM/S+18VnpzIHumyV6DtgmvqG5IA==",
+ "version": "3.972.8",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.8.tgz",
+ "integrity": "sha512-WR525Rr2QJSETa9a050isktyWi/4yIGcmY3BQ1kpHqb0LqUglQHCS8R27dTJxxWNZvQ0RVGtEZjTCbZJpyF3Aw==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/types": "^3.973.5",
+ "@aws-sdk/types": "^3.973.6",
"@aws-sdk/util-arn-parser": "^3.972.3",
- "@smithy/node-config-provider": "^4.3.11",
- "@smithy/protocol-http": "^5.3.11",
- "@smithy/types": "^4.13.0",
+ "@smithy/node-config-provider": "^4.3.12",
+ "@smithy/protocol-http": "^5.3.12",
+ "@smithy/types": "^4.13.1",
"@smithy/util-config-provider": "^4.2.2",
"tslib": "^2.6.2"
},
@@ -520,14 +520,14 @@
}
},
"node_modules/@aws-sdk/middleware-expect-continue": {
- "version": "3.972.7",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.7.tgz",
- "integrity": "sha512-mvWqvm61bmZUKmmrtl2uWbokqpenY3Mc3Jf4nXB/Hse6gWxLPaCQThmhPBDzsPSV8/Odn8V6ovWt3pZ7vy4BFQ==",
+ "version": "3.972.8",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.8.tgz",
+ "integrity": "sha512-5DTBTiotEES1e2jOHAq//zyzCjeMB78lEHd35u15qnrid4Nxm7diqIf9fQQ3Ov0ChH1V3Vvt13thOnrACmfGVQ==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/types": "^3.973.5",
- "@smithy/protocol-http": "^5.3.11",
- "@smithy/types": "^4.13.0",
+ "@aws-sdk/types": "^3.973.6",
+ "@smithy/protocol-http": "^5.3.12",
+ "@smithy/types": "^4.13.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -535,23 +535,23 @@
}
},
"node_modules/@aws-sdk/middleware-flexible-checksums": {
- "version": "3.973.5",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.973.5.tgz",
- "integrity": "sha512-Dp3hqE5W6hG8HQ3Uh+AINx9wjjqYmFHbxede54sGj3akx/haIQrkp85lNdTdC+ouNUcSYNiuGkzmyDREfHX1Gg==",
+ "version": "3.973.6",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.973.6.tgz",
+ "integrity": "sha512-0nYEgkJH7Yt9k+nZJyllTghnkKaz17TWFcr5Mi0XMVMzYlF4ytDZADQpF2/iJo36cKL5AYSzRsvlykE4M/ErTA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/crc32": "5.2.0",
"@aws-crypto/crc32c": "5.2.0",
"@aws-crypto/util": "5.2.0",
- "@aws-sdk/core": "^3.973.19",
- "@aws-sdk/crc64-nvme": "^3.972.4",
- "@aws-sdk/types": "^3.973.5",
+ "@aws-sdk/core": "^3.973.20",
+ "@aws-sdk/crc64-nvme": "^3.972.5",
+ "@aws-sdk/types": "^3.973.6",
"@smithy/is-array-buffer": "^4.2.2",
- "@smithy/node-config-provider": "^4.3.11",
- "@smithy/protocol-http": "^5.3.11",
- "@smithy/types": "^4.13.0",
- "@smithy/util-middleware": "^4.2.11",
- "@smithy/util-stream": "^4.5.17",
+ "@smithy/node-config-provider": "^4.3.12",
+ "@smithy/protocol-http": "^5.3.12",
+ "@smithy/types": "^4.13.1",
+ "@smithy/util-middleware": "^4.2.12",
+ "@smithy/util-stream": "^4.5.19",
"@smithy/util-utf8": "^4.2.2",
"tslib": "^2.6.2"
},
@@ -560,14 +560,14 @@
}
},
"node_modules/@aws-sdk/middleware-host-header": {
- "version": "3.972.7",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.7.tgz",
- "integrity": "sha512-aHQZgztBFEpDU1BB00VWCIIm85JjGjQW1OG9+98BdmaOpguJvzmXBGbnAiYcciCd+IS4e9BEq664lhzGnWJHgQ==",
+ "version": "3.972.8",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.8.tgz",
+ "integrity": "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/types": "^3.973.5",
- "@smithy/protocol-http": "^5.3.11",
- "@smithy/types": "^4.13.0",
+ "@aws-sdk/types": "^3.973.6",
+ "@smithy/protocol-http": "^5.3.12",
+ "@smithy/types": "^4.13.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -575,13 +575,13 @@
}
},
"node_modules/@aws-sdk/middleware-location-constraint": {
- "version": "3.972.7",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.7.tgz",
- "integrity": "sha512-vdK1LJfffBp87Lj0Bw3WdK1rJk9OLDYdQpqoKgmpIZPe+4+HawZ6THTbvjhJt4C4MNnRrHTKHQjkwBiIpDBoig==",
+ "version": "3.972.8",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.8.tgz",
+ "integrity": "sha512-KaUoFuoFPziIa98DSQsTPeke1gvGXlc5ZGMhy+b+nLxZ4A7jmJgLzjEF95l8aOQN2T/qlPP3MrAyELm8ExXucw==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/types": "^3.973.5",
- "@smithy/types": "^4.13.0",
+ "@aws-sdk/types": "^3.973.6",
+ "@smithy/types": "^4.13.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -589,13 +589,13 @@
}
},
"node_modules/@aws-sdk/middleware-logger": {
- "version": "3.972.7",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.7.tgz",
- "integrity": "sha512-LXhiWlWb26txCU1vcI9PneESSeRp/RYY/McuM4SpdrimQR5NgwaPb4VJCadVeuGWgh6QmqZ6rAKSoL1ob16W6w==",
+ "version": "3.972.8",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.8.tgz",
+ "integrity": "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/types": "^3.973.5",
- "@smithy/types": "^4.13.0",
+ "@aws-sdk/types": "^3.973.6",
+ "@smithy/types": "^4.13.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -603,15 +603,15 @@
}
},
"node_modules/@aws-sdk/middleware-recursion-detection": {
- "version": "3.972.7",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.7.tgz",
- "integrity": "sha512-l2VQdcBcYLzIzykCHtXlbpiVCZ94/xniLIkAj0jpnpjY4xlgZx7f56Ypn+uV1y3gG0tNVytJqo3K9bfMFee7SQ==",
+ "version": "3.972.8",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.8.tgz",
+ "integrity": "sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/types": "^3.973.5",
+ "@aws-sdk/types": "^3.973.6",
"@aws/lambda-invoke-store": "^0.2.2",
- "@smithy/protocol-http": "^5.3.11",
- "@smithy/types": "^4.13.0",
+ "@smithy/protocol-http": "^5.3.12",
+ "@smithy/types": "^4.13.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -619,23 +619,23 @@
}
},
"node_modules/@aws-sdk/middleware-sdk-s3": {
- "version": "3.972.19",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.19.tgz",
- "integrity": "sha512-/CtOHHVFg4ZuN6CnLnYkrqWgVEnbOBC4kNiKa+4fldJ9cioDt3dD/f5vpq0cWLOXwmGL2zgVrVxNhjxWpxNMkg==",
+ "version": "3.972.20",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.20.tgz",
+ "integrity": "sha512-yhva/xL5H4tWQgsBjwV+RRD0ByCzg0TcByDCLp3GXdn/wlyRNfy8zsswDtCvr1WSKQkSQYlyEzPuWkJG0f5HvQ==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.973.19",
- "@aws-sdk/types": "^3.973.5",
+ "@aws-sdk/core": "^3.973.20",
+ "@aws-sdk/types": "^3.973.6",
"@aws-sdk/util-arn-parser": "^3.972.3",
- "@smithy/core": "^3.23.9",
- "@smithy/node-config-provider": "^4.3.11",
- "@smithy/protocol-http": "^5.3.11",
- "@smithy/signature-v4": "^5.3.11",
- "@smithy/smithy-client": "^4.12.3",
- "@smithy/types": "^4.13.0",
+ "@smithy/core": "^3.23.11",
+ "@smithy/node-config-provider": "^4.3.12",
+ "@smithy/protocol-http": "^5.3.12",
+ "@smithy/signature-v4": "^5.3.12",
+ "@smithy/smithy-client": "^4.12.5",
+ "@smithy/types": "^4.13.1",
"@smithy/util-config-provider": "^4.2.2",
- "@smithy/util-middleware": "^4.2.11",
- "@smithy/util-stream": "^4.5.17",
+ "@smithy/util-middleware": "^4.2.12",
+ "@smithy/util-stream": "^4.5.19",
"@smithy/util-utf8": "^4.2.2",
"tslib": "^2.6.2"
},
@@ -644,13 +644,13 @@
}
},
"node_modules/@aws-sdk/middleware-ssec": {
- "version": "3.972.7",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.7.tgz",
- "integrity": "sha512-G9clGVuAml7d8DYzY6DnRi7TIIDRvZ3YpqJPz/8wnWS5fYx/FNWNmkO6iJVlVkQg9BfeMzd+bVPtPJOvC4B+nQ==",
+ "version": "3.972.8",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.8.tgz",
+ "integrity": "sha512-wqlK0yO/TxEC2UsY9wIlqeeutF6jjLe0f96Pbm40XscTo57nImUk9lBcw0dPgsm0sppFtAkSlDrfpK+pC30Wqw==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/types": "^3.973.5",
- "@smithy/types": "^4.13.0",
+ "@aws-sdk/types": "^3.973.6",
+ "@smithy/types": "^4.13.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -658,18 +658,18 @@
}
},
"node_modules/@aws-sdk/middleware-user-agent": {
- "version": "3.972.20",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.20.tgz",
- "integrity": "sha512-3kNTLtpUdeahxtnJRnj/oIdLAUdzTfr9N40KtxNhtdrq+Q1RPMdCJINRXq37m4t5+r3H70wgC3opW46OzFcZYA==",
+ "version": "3.972.21",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.21.tgz",
+ "integrity": "sha512-62XRl1GDYPpkt7cx1AX1SPy9wgNE9Iw/NPuurJu4lmhCWS7sGKO+kS53TQ8eRmIxy3skmvNInnk0ZbWrU5Dpyg==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.973.19",
- "@aws-sdk/types": "^3.973.5",
- "@aws-sdk/util-endpoints": "^3.996.4",
- "@smithy/core": "^3.23.9",
- "@smithy/protocol-http": "^5.3.11",
- "@smithy/types": "^4.13.0",
- "@smithy/util-retry": "^4.2.11",
+ "@aws-sdk/core": "^3.973.20",
+ "@aws-sdk/types": "^3.973.6",
+ "@aws-sdk/util-endpoints": "^3.996.5",
+ "@smithy/core": "^3.23.11",
+ "@smithy/protocol-http": "^5.3.12",
+ "@smithy/types": "^4.13.1",
+ "@smithy/util-retry": "^4.2.12",
"tslib": "^2.6.2"
},
"engines": {
@@ -677,47 +677,47 @@
}
},
"node_modules/@aws-sdk/nested-clients": {
- "version": "3.996.9",
- "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.9.tgz",
- "integrity": "sha512-+RpVtpmQbbtzFOKhMlsRcXM/3f1Z49qTOHaA8gEpHOYruERmog6f2AUtf/oTRLCWjR9H2b3roqryV/hI7QMW8w==",
+ "version": "3.996.10",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.10.tgz",
+ "integrity": "sha512-SlDol5Z+C7Ivnc2rKGqiqfSUmUZzY1qHfVs9myt/nxVwswgfpjdKahyTzLTx802Zfq0NFRs7AejwKzzzl5Co2w==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
- "@aws-sdk/core": "^3.973.19",
- "@aws-sdk/middleware-host-header": "^3.972.7",
- "@aws-sdk/middleware-logger": "^3.972.7",
- "@aws-sdk/middleware-recursion-detection": "^3.972.7",
- "@aws-sdk/middleware-user-agent": "^3.972.20",
- "@aws-sdk/region-config-resolver": "^3.972.7",
- "@aws-sdk/types": "^3.973.5",
- "@aws-sdk/util-endpoints": "^3.996.4",
- "@aws-sdk/util-user-agent-browser": "^3.972.7",
- "@aws-sdk/util-user-agent-node": "^3.973.6",
- "@smithy/config-resolver": "^4.4.10",
- "@smithy/core": "^3.23.9",
- "@smithy/fetch-http-handler": "^5.3.13",
- "@smithy/hash-node": "^4.2.11",
- "@smithy/invalid-dependency": "^4.2.11",
- "@smithy/middleware-content-length": "^4.2.11",
- "@smithy/middleware-endpoint": "^4.4.23",
- "@smithy/middleware-retry": "^4.4.40",
- "@smithy/middleware-serde": "^4.2.12",
- "@smithy/middleware-stack": "^4.2.11",
- "@smithy/node-config-provider": "^4.3.11",
- "@smithy/node-http-handler": "^4.4.14",
- "@smithy/protocol-http": "^5.3.11",
- "@smithy/smithy-client": "^4.12.3",
- "@smithy/types": "^4.13.0",
- "@smithy/url-parser": "^4.2.11",
+ "@aws-sdk/core": "^3.973.20",
+ "@aws-sdk/middleware-host-header": "^3.972.8",
+ "@aws-sdk/middleware-logger": "^3.972.8",
+ "@aws-sdk/middleware-recursion-detection": "^3.972.8",
+ "@aws-sdk/middleware-user-agent": "^3.972.21",
+ "@aws-sdk/region-config-resolver": "^3.972.8",
+ "@aws-sdk/types": "^3.973.6",
+ "@aws-sdk/util-endpoints": "^3.996.5",
+ "@aws-sdk/util-user-agent-browser": "^3.972.8",
+ "@aws-sdk/util-user-agent-node": "^3.973.7",
+ "@smithy/config-resolver": "^4.4.11",
+ "@smithy/core": "^3.23.11",
+ "@smithy/fetch-http-handler": "^5.3.15",
+ "@smithy/hash-node": "^4.2.12",
+ "@smithy/invalid-dependency": "^4.2.12",
+ "@smithy/middleware-content-length": "^4.2.12",
+ "@smithy/middleware-endpoint": "^4.4.25",
+ "@smithy/middleware-retry": "^4.4.42",
+ "@smithy/middleware-serde": "^4.2.14",
+ "@smithy/middleware-stack": "^4.2.12",
+ "@smithy/node-config-provider": "^4.3.12",
+ "@smithy/node-http-handler": "^4.4.16",
+ "@smithy/protocol-http": "^5.3.12",
+ "@smithy/smithy-client": "^4.12.5",
+ "@smithy/types": "^4.13.1",
+ "@smithy/url-parser": "^4.2.12",
"@smithy/util-base64": "^4.3.2",
"@smithy/util-body-length-browser": "^4.2.2",
"@smithy/util-body-length-node": "^4.2.3",
- "@smithy/util-defaults-mode-browser": "^4.3.39",
- "@smithy/util-defaults-mode-node": "^4.2.42",
- "@smithy/util-endpoints": "^3.3.2",
- "@smithy/util-middleware": "^4.2.11",
- "@smithy/util-retry": "^4.2.11",
+ "@smithy/util-defaults-mode-browser": "^4.3.41",
+ "@smithy/util-defaults-mode-node": "^4.2.44",
+ "@smithy/util-endpoints": "^3.3.3",
+ "@smithy/util-middleware": "^4.2.12",
+ "@smithy/util-retry": "^4.2.12",
"@smithy/util-utf8": "^4.2.2",
"tslib": "^2.6.2"
},
@@ -726,15 +726,15 @@
}
},
"node_modules/@aws-sdk/region-config-resolver": {
- "version": "3.972.7",
- "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.7.tgz",
- "integrity": "sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA==",
+ "version": "3.972.8",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.8.tgz",
+ "integrity": "sha512-1eD4uhTDeambO/PNIDVG19A6+v4NdD7xzwLHDutHsUqz0B+i661MwQB2eYO4/crcCvCiQG4SRm1k81k54FEIvw==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/types": "^3.973.5",
- "@smithy/config-resolver": "^4.4.10",
- "@smithy/node-config-provider": "^4.3.11",
- "@smithy/types": "^4.13.0",
+ "@aws-sdk/types": "^3.973.6",
+ "@smithy/config-resolver": "^4.4.11",
+ "@smithy/node-config-provider": "^4.3.12",
+ "@smithy/types": "^4.13.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -761,16 +761,16 @@
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
- "version": "3.996.7",
- "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.7.tgz",
- "integrity": "sha512-mYhh7FY+7OOqjkYkd6+6GgJOsXK1xBWmuR+c5mxJPj2kr5TBNeZq+nUvE9kANWAux5UxDVrNOSiEM/wlHzC3Lg==",
+ "version": "3.996.8",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.8.tgz",
+ "integrity": "sha512-n1qYFD+tbqZuyskVaxUE+t10AUz9g3qzDw3Tp6QZDKmqsjfDmZBd4GIk2EKJJNtcCBtE5YiUjDYA+3djFAFBBg==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/middleware-sdk-s3": "^3.972.19",
- "@aws-sdk/types": "^3.973.5",
- "@smithy/protocol-http": "^5.3.11",
- "@smithy/signature-v4": "^5.3.11",
- "@smithy/types": "^4.13.0",
+ "@aws-sdk/middleware-sdk-s3": "^3.972.20",
+ "@aws-sdk/types": "^3.973.6",
+ "@smithy/protocol-http": "^5.3.12",
+ "@smithy/signature-v4": "^5.3.12",
+ "@smithy/types": "^4.13.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -778,17 +778,17 @@
}
},
"node_modules/@aws-sdk/token-providers": {
- "version": "3.1008.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1008.0.tgz",
- "integrity": "sha512-TulwlHQBWcJs668kNUDMZHN51DeLrDsYT59Ux4a/nbvr025gM6HjKJJ3LvnZccam7OS/ZKUVkWomCneRQKJbBg==",
+ "version": "3.1009.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1009.0.tgz",
+ "integrity": "sha512-KCPLuTqN9u0Rr38Arln78fRG9KXpzsPWmof+PZzfAHMMQq2QED6YjQrkrfiH7PDefLWEposY1o4/eGwrmKA4JA==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "^3.973.19",
- "@aws-sdk/nested-clients": "^3.996.9",
- "@aws-sdk/types": "^3.973.5",
- "@smithy/property-provider": "^4.2.11",
- "@smithy/shared-ini-file-loader": "^4.4.6",
- "@smithy/types": "^4.13.0",
+ "@aws-sdk/core": "^3.973.20",
+ "@aws-sdk/nested-clients": "^3.996.10",
+ "@aws-sdk/types": "^3.973.6",
+ "@smithy/property-provider": "^4.2.12",
+ "@smithy/shared-ini-file-loader": "^4.4.7",
+ "@smithy/types": "^4.13.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -796,12 +796,12 @@
}
},
"node_modules/@aws-sdk/types": {
- "version": "3.973.5",
- "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.5.tgz",
- "integrity": "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ==",
+ "version": "3.973.6",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.6.tgz",
+ "integrity": "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.13.0",
+ "@smithy/types": "^4.13.1",
"tslib": "^2.6.2"
},
"engines": {
@@ -821,15 +821,15 @@
}
},
"node_modules/@aws-sdk/util-endpoints": {
- "version": "3.996.4",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.4.tgz",
- "integrity": "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA==",
+ "version": "3.996.5",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.5.tgz",
+ "integrity": "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/types": "^3.973.5",
- "@smithy/types": "^4.13.0",
- "@smithy/url-parser": "^4.2.11",
- "@smithy/util-endpoints": "^3.3.2",
+ "@aws-sdk/types": "^3.973.6",
+ "@smithy/types": "^4.13.1",
+ "@smithy/url-parser": "^4.2.12",
+ "@smithy/util-endpoints": "^3.3.3",
"tslib": "^2.6.2"
},
"engines": {
@@ -864,27 +864,27 @@
}
},
"node_modules/@aws-sdk/util-user-agent-browser": {
- "version": "3.972.7",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.7.tgz",
- "integrity": "sha512-7SJVuvhKhMF/BkNS1n0QAJYgvEwYbK2QLKBrzDiwQGiTRU6Yf1f3nehTzm/l21xdAOtWSfp2uWSddPnP2ZtsVw==",
+ "version": "3.972.8",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.8.tgz",
+ "integrity": "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/types": "^3.973.5",
- "@smithy/types": "^4.13.0",
+ "@aws-sdk/types": "^3.973.6",
+ "@smithy/types": "^4.13.1",
"bowser": "^2.11.0",
"tslib": "^2.6.2"
}
},
"node_modules/@aws-sdk/util-user-agent-node": {
- "version": "3.973.6",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.6.tgz",
- "integrity": "sha512-iF7G0prk7AvmOK64FcLvc/fW+Ty1H+vttajL7PvJFReU8urMxfYmynTTuFKDTA76Wgpq3FzTPKwabMQIXQHiXQ==",
+ "version": "3.973.7",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.7.tgz",
+ "integrity": "sha512-Hz6EZMUAEzqUd7e+vZ9LE7mn+5gMbxltXy18v+YSFY+9LBJz15wkNZvw5JqfX3z0FS9n3bgUtz3L5rAsfh4YlA==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/middleware-user-agent": "^3.972.20",
- "@aws-sdk/types": "^3.973.5",
- "@smithy/node-config-provider": "^4.3.11",
- "@smithy/types": "^4.13.0",
+ "@aws-sdk/middleware-user-agent": "^3.972.21",
+ "@aws-sdk/types": "^3.973.6",
+ "@smithy/node-config-provider": "^4.3.12",
+ "@smithy/types": "^4.13.1",
"@smithy/util-config-provider": "^4.2.2",
"tslib": "^2.6.2"
},
@@ -901,12 +901,12 @@
}
},
"node_modules/@aws-sdk/xml-builder": {
- "version": "3.972.10",
- "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.10.tgz",
- "integrity": "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA==",
+ "version": "3.972.11",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.11.tgz",
+ "integrity": "sha512-iitV/gZKQMvY9d7ovmyFnFuTHbBAtrmLnvaSb/3X8vOKyevwtpmEtyc8AdhVWZe0pI/1GsHxlEvQeOePFzy7KQ==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.13.0",
+ "@smithy/types": "^4.13.1",
"fast-xml-parser": "5.4.1",
"tslib": "^2.6.2"
},
@@ -3489,6 +3489,7 @@
"version": "1.41.3",
"resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-1.41.3.tgz",
"integrity": "sha512-4o84y+E7dbif3lMns+p3UW6w6hLHEifbX/7zBJvaih1E9QNMZITENQ14GPYJC4JmhygYXsuuBb9bRA3xWEoOfg==",
+ "license": "MIT",
"dependencies": {
"cloudinary-core": "^2.13.0",
"core-js": "^3.30.1",
@@ -4566,9 +4567,9 @@
"dev": true
},
"node_modules/fast-xml-builder": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.2.tgz",
- "integrity": "sha512-NJAmiuVaJEjVa7TjLZKlYd7RqmzOC91EtPFXHvlTcqBVo50Qh7XV5IwvXi1c7NRz2Q/majGX9YLcwJtWgHjtkA==",
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.3.tgz",
+ "integrity": "sha512-1o60KoFw2+LWKQu3IdcfcFlGTW4dpqEWmjhYec6H82AYZU2TVBXep6tMl8Z1Y+wM+ZrzCwe3BZ9Vyd9N2rIvmg==",
"funding": [
{
"type": "github",
diff --git a/package.json b/package.json
index 685585a..410a0fe 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,7 @@
"testEnvironment": "node"
},
"dependencies": {
- "@aws-sdk/client-s3": "^3.1008.0",
+ "@aws-sdk/client-s3": "^3.1009.0",
"@aws-sdk/s3-request-presigner": "^3.1008.0",
"bcryptjs": "^2.4.3",
"cloudinary": "^1.41.3",
diff --git a/src/config/b2.js b/src/config/b2.js
new file mode 100644
index 0000000..81f4d0a
--- /dev/null
+++ b/src/config/b2.js
@@ -0,0 +1,13 @@
+const { S3Client } = require('@aws-sdk/client-s3');
+const env = require('./env');
+
+const b2 = new S3Client({
+ credentials: {
+ accessKeyId: env.B2_KEY_ID,
+ secretAccessKey: env.B2_APP_KEY,
+ },
+ region: env.B2_REGION,
+ endpoint: env.B2_ENDPOINT,
+});
+
+module.exports = b2;
\ No newline at end of file
diff --git a/src/config/env.js b/src/config/env.js
index f69bb66..06804d5 100644
--- a/src/config/env.js
+++ b/src/config/env.js
@@ -36,7 +36,11 @@ module.exports = {
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
-
+ B2_KEY_ID: process.env.B2_KEY_ID,
+ B2_APP_KEY: process.env.B2_APP_KEY,
+ B2_REGION: process.env.B2_REGION,
+ B2_ENDPOINT: process.env.B2_ENDPOINT,
+ B2_BUCKET_NAME: process.env.B2_BUCKET_NAME,
BASE_URL: process.env.BASE_URL || 'http://localhost:8080',
CLIENT_URL: process.env.CLIENT_URL || 'http://localhost:3000',
diff --git a/src/controllers/tracks.controller.js b/src/controllers/tracks.controller.js
index 108a7e9..31b791b 100644
--- a/src/controllers/tracks.controller.js
+++ b/src/controllers/tracks.controller.js
@@ -1,14 +1,34 @@
// ============================================================
// controllers/tracks.controller.js
-// Owner : Saja Aboulmagd (BE-2)
-// Receives validated requests → calls service → returns HTTP response
// ============================================================
+const AppError = require('../utils/app-error');
+const { success } = require('../utils/api-response');
const tracksService = require('../services/tracks.service');
-const { success, error } = require('../utils/api-response');
-
-// TODO: Implement controller methods
-// Example:
-// exports.getAll = async (req, res) => {
-// const data = await tracksService.getAll(req.query);
-// return success(res, data);
-// };
+
+const uploadTrack = async (req, res) => {
+ const { title } = req.body;
+
+ const audioFile = req.files?.audio_file?.[0];
+ const coverImageFile = req.files?.cover_image?.[0] || null;
+
+ if (!audioFile) {
+ throw new AppError('Audio file is required', 400, 'VALIDATION_FAILED');
+ }
+
+ if (!title || !title.trim()) {
+ throw new AppError('Title is required', 400, 'VALIDATION_FAILED');
+ }
+
+ const track = await tracksService.uploadTrack({
+ user: req.user,
+ audioFile,
+ coverImageFile,
+ body: req.body,
+ });
+
+ return success(res, track, 'Track created and queued for processing.', 201);
+};
+
+module.exports = {
+ uploadTrack,
+};
diff --git a/src/middleware/multer.js b/src/middleware/multer.js
index 39af3f0..f14a660 100644
--- a/src/middleware/multer.js
+++ b/src/middleware/multer.js
@@ -3,21 +3,84 @@
// Audio: 100 MB (MP3, WAV, FLAC, AAC) | Images: 5 MB (JPG, PNG, WEBP)
// ============================================================
const multer = require('multer');
+const path = require('path');
const env = require('../config/env');
const storage = multer.memoryStorage();
+const audioTypes = [
+ 'audio/mpeg',
+ 'audio/mp3',
+ 'audio/wav',
+ 'audio/x-wav',
+ 'audio/wave',
+ 'audio/vnd.wave',
+ 'audio/flac',
+ 'audio/x-flac',
+ 'audio/aac',
+ 'audio/mp4'
+];
+const imageTypes = ['image/jpeg', 'image/png', 'image/webp'];
+
+const audioExts = ['.mp3', '.wav', '.flac', '.aac', '.m4a'];
+const imageExts = ['.jpg', '.jpeg', '.png', '.webp'];
+
+const isAllowedAudio = (file) => {
+ const ext = path.extname(file.originalname).toLowerCase();
+ return audioTypes.includes(file.mimetype) ||
+ (file.mimetype === 'application/octet-stream' && audioExts.includes(ext));
+};
+
+const isAllowedImage = (file) => {
+ const ext = path.extname(file.originalname).toLowerCase();
+ return imageTypes.includes(file.mimetype) ||
+ (file.mimetype === 'application/octet-stream' && imageExts.includes(ext));
+};
+
const audioFilter = (req, file, cb) => {
- const allowed = ['audio/mpeg', 'audio/wav', 'audio/flac', 'audio/aac', 'audio/x-flac'];
- allowed.includes(file.mimetype) ? cb(null, true) : cb(new Error('Invalid audio format'), false);
+ isAllowedAudio(file)
+ ? cb(null, true)
+ : cb(new Error('Invalid audio format'), false);
};
const imageFilter = (req, file, cb) => {
- const allowed = ['image/jpeg', 'image/png', 'image/webp'];
- allowed.includes(file.mimetype) ? cb(null, true) : cb(new Error('Invalid image format'), false);
+ isAllowedImage(file)
+ ? cb(null, true)
+ : cb(new Error('Invalid image format'), false);
};
-const uploadAudio = multer({ storage, fileFilter: audioFilter, limits: { fileSize: env.MAX_FILE_SIZE_AUDIO } });
-const uploadImage = multer({ storage, fileFilter: imageFilter, limits: { fileSize: env.MAX_FILE_SIZE_IMAGE } });
+const trackFilesFilter = (req, file, cb) => {
+ if (file.fieldname === 'audio_file') {
+ return isAllowedAudio(file)
+ ? cb(null, true)
+ : cb(new Error('Invalid audio format'), false);
+ }
+
+ if (file.fieldname === 'cover_image') {
+ return isAllowedImage(file)
+ ? cb(null, true)
+ : cb(new Error('Invalid image format'), false);
+ }
+
+ return cb(new Error('Unexpected file field'), false);
+};
+
+const uploadAudio = multer({
+ storage,
+ fileFilter: audioFilter,
+ limits: { fileSize: env.MAX_FILE_SIZE_AUDIO },
+});
+
+const uploadImage = multer({
+ storage,
+ fileFilter: imageFilter,
+ limits: { fileSize: env.MAX_FILE_SIZE_IMAGE },
+});
+
+const uploadTrackFiles = multer({
+ storage,
+ fileFilter: trackFilesFilter,
+ limits: { fileSize: env.MAX_FILE_SIZE_AUDIO },
+});
-module.exports = { uploadAudio, uploadImage };
+module.exports = { uploadAudio, uploadImage, uploadTrackFiles };
\ No newline at end of file
diff --git a/src/models/track.model.js b/src/models/track.model.js
index bf8a4d9..7cf1521 100644
--- a/src/models/track.model.js
+++ b/src/models/track.model.js
@@ -5,9 +5,66 @@
// ============================================================
const db = require('../config/db');
-// TODO: Implement query methods
-// Example:
-// exports.findById = async (id) => {
-// const { rows } = await db.query('SELECT * FROM tracks WHERE id = $1', [id]);
-// return rows[0] || null;
-// };
+const createTrack = async (t) => {
+ const query = `
+ INSERT INTO tracks (
+ title, description, genre_id, cover_image, waveform_url, audio_url, stream_url, preview_url,
+ duration, file_size, bitrate, status, is_public, user_id,
+ release_date, isrc, p_line, buy_link, record_label, publisher,
+ explicit_content, license_type,
+ enable_downloads, enable_offline_listening, include_in_rss_feed, display_embed_code, enable_app_playback,
+ allow_comments, show_comments_public, show_insights_public, geo_restriction_type, geo_regions
+ )
+ VALUES (
+ $1,$2,$3,$4,NULL,$5,NULL,NULL,
+ NULL,$6,NULL,$7,$8,$9,
+ $10,$11,$12,$13,$14,$15,
+ $16,$17,
+ $18,$19,$20,$21,$22,
+ $23,$24,$25,$26,$27
+ )
+ RETURNING *;
+ `;
+
+ const values = [
+ t.title, t.description, t.genre_id, t.cover_image, t.audio_url, t.file_size, t.status, t.is_public, t.user_id,
+ t.release_date, t.isrc, t.p_line, t.buy_link, t.record_label, t.publisher,
+ t.explicit_content, t.license_type,
+ t.enable_downloads, t.enable_offline_listening, t.include_in_rss_feed, t.display_embed_code, t.enable_app_playback,
+ t.allow_comments, t.show_comments_public, t.show_insights_public, t.geo_restriction_type, JSON.stringify(t.geo_regions || []),
+ ];
+
+ const result = await db.query(query, values);
+ return result.rows[0];
+};
+
+const addTrackTags = async (trackId, tagIds) => {
+ for (const tagId of tagIds) {
+ await db.query(
+ `INSERT INTO track_tags (track_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
+ [trackId, tagId]
+ );
+ }
+};
+
+const addTrackArtists = async (trackId, artistIds) => {
+ for (let i = 0; i < artistIds.length; i++) {
+ await db.query(
+ `INSERT INTO track_artists (track_id, artist_id, position) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
+ [trackId, artistIds[i], i + 1]
+ );
+ }
+};
+
+const getGenreIdByName = async (genreName) => {
+ if (!genreName) return null;
+
+ const result = await db.query(
+ `SELECT id FROM genres WHERE LOWER(name) = LOWER($1) LIMIT 1`,
+ [genreName]
+ );
+
+ return result.rows[0]?.id || null;
+};
+
+module.exports = { createTrack, addTrackTags, addTrackArtists, getGenreIdByName };
\ No newline at end of file
diff --git a/src/routes/tracks.routes.js b/src/routes/tracks.routes.js
index e5a06b0..f5807f9 100644
--- a/src/routes/tracks.routes.js
+++ b/src/routes/tracks.routes.js
@@ -7,10 +7,19 @@ const express = require('express');
const router = express.Router();
const controller = require('../controllers/tracks.controller');
const { authenticate } = require('../middleware/auth');
+const { uploadTrackFiles } = require('../middleware/multer.js');
const asyncHandler = require('../utils/async-handler');
-// TODO: Add route definitions here
-// Example:
-// router.get('/', authenticate, asyncHandler(controller.getAll));
+// upload track
+router.post(
+ '/',
+ authenticate,
+ uploadTrackFiles .fields([
+ { name: 'audio_file', maxCount: 1 },
+ { name: 'cover_image', maxCount: 1 },
+ ]),
+ asyncHandler(controller.uploadTrack)
+);
+
module.exports = router;
diff --git a/src/services/storage.service.js b/src/services/storage.service.js
new file mode 100644
index 0000000..5eaeca4
--- /dev/null
+++ b/src/services/storage.service.js
@@ -0,0 +1,50 @@
+const { PutObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
+const b2 = require('../config/b2');
+const env = require('../config/env');
+
+const uploadTrack = async (file, key) => {
+ await b2.send(
+ new PutObjectCommand({
+ Bucket: env.B2_BUCKET_NAME,
+ Key: key,
+ Body: file.buffer,
+ ContentType: file.mimetype,
+ })
+ );
+
+ return {
+ key,
+ url: `${env.B2_ENDPOINT}/${env.B2_BUCKET_NAME}/${key}`,
+ };
+};
+
+const uploadImage = async (file, key) => {
+ await b2.send(
+ new PutObjectCommand({
+ Bucket: env.B2_BUCKET_NAME,
+ Key: key,
+ Body: file.buffer,
+ ContentType: file.mimetype,
+ })
+ );
+
+ return {
+ key,
+ url: `${env.B2_ENDPOINT}/${env.B2_BUCKET_NAME}/${key}`,
+ };
+};
+
+const deleteObject = async (key) => {
+ await b2.send(
+ new DeleteObjectCommand({
+ Bucket: env.B2_BUCKET_NAME,
+ Key: key,
+ })
+ );
+};
+
+module.exports = {
+ uploadTrack,
+ uploadImage,
+ deleteObject,
+};
\ No newline at end of file
diff --git a/src/services/tracks.service.js b/src/services/tracks.service.js
index 0622113..2cb05ee 100644
--- a/src/services/tracks.service.js
+++ b/src/services/tracks.service.js
@@ -5,8 +5,99 @@
// No direct SQL here — delegate to models/
// ============================================================
-// TODO: Implement service methods
-// Example:
-// exports.getAll = async (query) => {
-// return await tracksModel.findAll(query);
-// };
+const AppError = require('../utils/app-error.js');
+const tracksModel = require('../models/track.model.js');
+const storageService = require('./storage.service.js');
+
+const toBool = (v, d) => {
+ if (v === undefined || v === null || v === '') return d;
+ if (typeof v === 'boolean') return v;
+ return String(v).toLowerCase() === 'true';
+};
+
+const parseArray = (v) => {
+ if (!v) return [];
+ if (Array.isArray(v)) return v;
+ try { return JSON.parse(v); } catch { return []; }
+};
+
+const clean = (v) => (v === undefined || v === null || v === '' ? null : v);
+
+const uploadTrack = async ({ user, audioFile, coverImageFile, body }) => {
+ const userId = user?.user_id || user?.id || user?.sub;
+ if (!userId) throw new AppError('Authenticated user not found', 401, 'AUTH_TOKEN_INVALID');
+
+ const tagIds = parseArray(body.tags || body.tag_ids);
+ if (tagIds.length > 10) {
+ throw new AppError('Maximum 10 tags allowed', 400, 'VALIDATION_FAILED');
+ }
+
+ let genreId = null;
+ if (body.genre) {
+ genreId = await tracksModel.getGenreIdByName(body.genre);
+ if (!genreId) {
+ throw new AppError('Invalid genre', 400, 'VALIDATION_FAILED');
+ }
+ }
+
+
+ const audioKey = `tracks/${userId}/${Date.now()}-${audioFile.originalname}`;
+ const uploadedAudio = await storageService.uploadTrack(audioFile, audioKey);
+
+ let coverImageUrl = null;
+ if (coverImageFile) {
+ const coverKey = `tracks/${userId}/covers/${Date.now()}-${coverImageFile.originalname}`;
+ const uploadedCover = await storageService.uploadImage(coverImageFile, coverKey);
+ coverImageUrl = uploadedCover.url;
+ }
+
+
+
+ const trackData = {
+ title: body.title.trim(),
+ description: clean(body.description),
+ genre_id: genreId,
+ cover_image: coverImageUrl,
+ audio_url: uploadedAudio.url,
+ file_size: audioFile.size,
+ status: 'processing',
+ is_public: toBool(body.is_public, true),
+
+ release_date: clean(body.release_date),
+ isrc: clean(body.isrc),
+ p_line: clean(body.p_line),
+ buy_link: clean(body.buy_link),
+ record_label: clean(body.record_label),
+ publisher: clean(body.publisher),
+ explicit_content: toBool(body.explicit_content, false),
+ license_type: clean(body.license_type) || 'all_rights_reserved',
+
+ enable_downloads: toBool(body.enable_downloads, false),
+ enable_offline_listening: toBool(body.enable_offline_listening, false),
+ include_in_rss_feed: toBool(body.include_in_rss_feed, true),
+ display_embed_code: toBool(body.display_embed_code, true),
+ enable_app_playback: toBool(body.enable_app_playback, true),
+
+ allow_comments: toBool(body.allow_comments, true),
+ show_comments_public: toBool(body.show_comments_public, true),
+ show_insights_public: toBool(body.show_insights_public, true),
+ geo_restriction_type: clean(body.geo_restriction_type) || 'worldwide',
+ geo_regions: parseArray(body.geo_regions || '[]'),
+
+ user_id: userId,
+ };
+
+ const createdTrack = await tracksModel.createTrack(trackData);
+
+
+ if (tagIds.length) {
+ await tracksModel.addTrackTags(createdTrack.id, tagIds);
+ }
+
+ // until docs/DB are aligned, make owner the first artist
+ await tracksModel.addTrackArtists(createdTrack.id, [userId]);
+
+ return createdTrack;
+};
+
+module.exports = { uploadTrack };
From c69b7affe3bf0247929757c16fed33bc03c64b6a Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Mon, 16 Mar 2026 06:17:30 +0200
Subject: [PATCH 025/535] feat(tracks): implement initial track upload flow
---
src/middleware/error-handler.js | 17 +++++++++++++++--
src/models/track.model.js | 11 ++++++++++-
src/services/tracks.service.js | 14 +++++++++++---
3 files changed, 36 insertions(+), 6 deletions(-)
diff --git a/src/middleware/error-handler.js b/src/middleware/error-handler.js
index 13a47d9..0a5904c 100644
--- a/src/middleware/error-handler.js
+++ b/src/middleware/error-handler.js
@@ -5,9 +5,22 @@ const { error } = require('../utils/api-response');
const errorHandler = (err, req, res, next) => {
console.error(`[ERROR] ${req.method} ${req.path}:`, err.message);
+
+ if (err.code === 'LIMIT_FILE_SIZE') {
+ return error(res, 'UPLOAD_FILE_TOO_LARGE', 'File size exceeds the allowed limit', 413);
+ }
+
+ if (err.message === 'Invalid audio format' || err.message === 'Invalid image format') {
+ return error(res, 'UPLOAD_INVALID_FILE_TYPE', 'Unsupported file format', 415);
+ }
+
+ if (err.message === 'Unexpected file field') {
+ return error(res, 'VALIDATION_FAILED', 'Unexpected file field', 400);
+ }
+
const statusCode = err.statusCode || 500;
- const message = err.message || 'Internal Server Error';
- const code = err.code || 'INTERNAL_ERROR';
+ const message = err.message || 'Internal Server Error';
+ const code = err.code || 'INTERNAL_ERROR';
return error(res, code, message, statusCode);
};
diff --git a/src/models/track.model.js b/src/models/track.model.js
index 7cf1521..cd8c62b 100644
--- a/src/models/track.model.js
+++ b/src/models/track.model.js
@@ -67,4 +67,13 @@ const getGenreIdByName = async (genreName) => {
return result.rows[0]?.id || null;
};
-module.exports = { createTrack, addTrackTags, addTrackArtists, getGenreIdByName };
\ No newline at end of file
+const getTagIdsByTrackId = async (trackId) => {
+ const result = await db.query(
+ `SELECT tag_id FROM track_tags WHERE track_id = $1 ORDER BY created_at ASC`,
+ [trackId]
+ );
+
+ return result.rows.map((row) => row.tag_id);
+};
+
+module.exports = { createTrack, addTrackTags, addTrackArtists, getGenreIdByName, getTagIdsByTrackId };
\ No newline at end of file
diff --git a/src/services/tracks.service.js b/src/services/tracks.service.js
index 2cb05ee..ff91d5c 100644
--- a/src/services/tracks.service.js
+++ b/src/services/tracks.service.js
@@ -93,11 +93,19 @@ const uploadTrack = async ({ user, audioFile, coverImageFile, body }) => {
if (tagIds.length) {
await tracksModel.addTrackTags(createdTrack.id, tagIds);
}
-
- // until docs/DB are aligned, make owner the first artist
+
await tracksModel.addTrackArtists(createdTrack.id, [userId]);
+
+ const tags = await tracksModel.getTagIdsByTrackId(createdTrack.id);
- return createdTrack;
+ return {
+ ...createdTrack,
+ tags,
+ };
};
module.exports = { uploadTrack };
+
+//
+// TODO Add track upload limit validations based on subscription plan
+//
From 06e743da3bd57174421a7bc2fb2a7cb50de3e435 Mon Sep 17 00:00:00 2001
From: Beshoy205
Date: Mon, 16 Mar 2026 08:59:04 +0200
Subject: [PATCH 026/535] feat(users): add web profiles, privacy, and content
settings routes and controllers
- POST /me/web-profiles
- DELETE /me/web-profiles/:profile_id
- PATCH /me/privacy
- GET /me/content-settings
- PATCH /me/content-settings
---
src/controllers/users.controller.js | 38 +++++++++++++++++++++++++++++
src/routes/users.routes.js | 7 ++++--
2 files changed, 43 insertions(+), 2 deletions(-)
diff --git a/src/controllers/users.controller.js b/src/controllers/users.controller.js
index bd45647..266da84 100644
--- a/src/controllers/users.controller.js
+++ b/src/controllers/users.controller.js
@@ -83,4 +83,42 @@ exports.getMyWebProfile = async (req, res) => {
const data = await usersService.getMyWebProfile(req.user.sub);
return success(res, data, 'Web profile returned successfully.');
};
+exports.addWebProfile = async (req, res) => {
+ const { platform, url } = req.body;
+ const data = await usersService.addWebProfile(req.user.sub, platform, url);
+ return success(res, data, 'Web profile link created.', 201);
+};
+
+exports.deleteWebProfile = async (req, res) => {
+ const profileId = req.params.profile_id;
+ const data = await usersService.deleteWebProfile(req.user.sub, profileId);
+ return success(res, data, 'Web profile link deleted successfully.');
+};
+exports.updatePrivacy = async (req, res) => {
+ const { is_private } = req.body;
+ if (is_private === undefined) {
+ throw new AppError('is_private field is required.', 400, 'VALIDATION_FAILED');
+ }
+ const data = await usersService.updatePrivacy(req.user.sub, is_private);
+ return success(res, data, `Profile is now ${is_private ? 'private' : 'public'}.`);
+};
+
+exports.getMyContentSettings = async (req, res) => {
+ const data = await usersService.getMyContentSettings(req.user.sub);
+ return success(res, data, 'Content settings returned successfully.');
+};
+
+exports.updateMyContentSettings = async (req, res) => {
+ const fields = {};
+ if (req.body.rss_title !== undefined) fields.rss_title = req.body.rss_title;
+ if (req.body.rss_language !== undefined) fields.rss_language = req.body.rss_language;
+ if (req.body.rss_category !== undefined) fields.rss_category = req.body.rss_category;
+ if (req.body.rss_explicit !== undefined) fields.rss_explicit = req.body.rss_explicit;
+ if (req.body.rss_show_email !== undefined) fields.rss_show_email = req.body.rss_show_email;
+ if (req.body.default_include_in_rss !== undefined) fields.default_include_in_rss = req.body.default_include_in_rss;
+ if (req.body.default_license_type !== undefined) fields.default_license_type = req.body.default_license_type;
+
+ const data = await usersService.updateMyContentSettings(req.user.sub, fields);
+ return success(res, data, 'Content settings updated successfully.');
+};
\ No newline at end of file
diff --git a/src/routes/users.routes.js b/src/routes/users.routes.js
index c8cda42..d00dd30 100644
--- a/src/routes/users.routes.js
+++ b/src/routes/users.routes.js
@@ -22,7 +22,10 @@ router.post('/me/avatar', authenticate, uploadImage.single('avatar'), asyncHandl
router.post('/me/cover', authenticate, uploadImage.single('cover'), asyncHandler(controller.uploadMyCoverPhoto));
router.delete('/me/cover', authenticate, asyncHandler(controller.deleteMyCoverPhoto));
router.get('/me/web-profile', authenticate, asyncHandler(controller.getMyWebProfile));
-
-
+router.post('/me/web-profiles', authenticate, asyncHandler(controller.addWebProfile));
+router.delete('/me/web-profiles/:profile_id', authenticate, asyncHandler(controller.deleteWebProfile));
+router.patch('/me/privacy', authenticate, asyncHandler(controller.updatePrivacy));
+router.get('/me/content-settings', authenticate, asyncHandler(controller.getMyContentSettings));
+router.patch('/me/content-settings', authenticate, asyncHandler(controller.updateMyContentSettings));
module.exports = router;
\ No newline at end of file
From 39dc06f08e6ead27d514c961dc607548000b038c Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Mon, 16 Mar 2026 14:47:10 +0200
Subject: [PATCH 027/535] feat(tracks): implement GET /tracks/:track_id with
owner/private access rules
---
src/controllers/tracks.controller.js | 10 ++++
src/models/track.model.js | 74 +++++++++++++++++++++++++++-
src/routes/tracks.routes.js | 9 ++--
src/services/tracks.service.js | 30 ++++++++++-
4 files changed, 114 insertions(+), 9 deletions(-)
diff --git a/src/controllers/tracks.controller.js b/src/controllers/tracks.controller.js
index 31b791b..a2a5164 100644
--- a/src/controllers/tracks.controller.js
+++ b/src/controllers/tracks.controller.js
@@ -29,6 +29,16 @@ const uploadTrack = async (req, res) => {
return success(res, track, 'Track created and queued for processing.', 201);
};
+const getTrackById = async (req, res) => {
+ const {track_id} = req.params;
+ const requesterUserId = req.user?.id ?? req.user?.sub ?? req.user?.user_id ?? null;
+
+ const track = await tracksService.getTrackById(track_id, requesterUserId);
+
+ return success(res, track, 'Track fetched successfully', 200);
+}
+
module.exports = {
uploadTrack,
+ getTrackById
};
diff --git a/src/models/track.model.js b/src/models/track.model.js
index cd8c62b..d1eb7ce 100644
--- a/src/models/track.model.js
+++ b/src/models/track.model.js
@@ -76,4 +76,76 @@ const getTagIdsByTrackId = async (trackId) => {
return result.rows.map((row) => row.tag_id);
};
-module.exports = { createTrack, addTrackTags, addTrackArtists, getGenreIdByName, getTagIdsByTrackId };
\ No newline at end of file
+const findTrackByIdWithDetails = async (trackId) => {
+ const query = `
+ SELECT
+ t.id,
+ t.title,
+ t.description,
+ g.name AS genre,
+ t.cover_image,
+ t.waveform_url,
+ t.audio_url,
+ t.stream_url,
+ t.preview_url,
+ t.duration,
+ t.file_size,
+ t.bitrate,
+ t.status,
+ t.is_public,
+ t.is_trending,
+ t.is_featured,
+ t.is_hidden,
+ t.user_id,
+ t.release_date,
+ t.isrc,
+ t.p_line,
+ t.buy_link,
+ t.record_label,
+ t.publisher,
+ t.explicit_content,
+ t.license_type,
+ t.enable_downloads,
+ t.enable_offline_listening,
+ t.include_in_rss_feed,
+ t.display_embed_code,
+ t.enable_app_playback,
+ t.allow_comments,
+ t.show_comments_public,
+ t.show_insights_public,
+ t.geo_restriction_type,
+ t.geo_regions,
+ t.play_count,
+ t.like_count,
+ t.comment_count,
+ t.repost_count,
+ t.created_at,
+ t.updated_at,
+ COALESCE(tag_data.tags, ARRAY[]::text[]) AS tags
+ FROM tracks t
+ LEFT JOIN genres g
+ ON g.id = t.genre_id
+ LEFT JOIN LATERAL (
+ SELECT array_agg(tag.id::text ORDER BY tag.id::text) AS tags
+ FROM track_tags tt
+ JOIN tags tag
+ ON tag.id = tt.tag_id
+ WHERE tt.track_id = t.id
+ ) tag_data ON true
+ WHERE t.id = $1
+ AND t.deleted_at IS NULL
+ LIMIT 1
+ `;
+
+ const { rows } = await db.query(query, [trackId]);
+ return rows[0] || null;
+};
+
+module.exports = {
+ createTrack,
+ addTrackTags,
+ addTrackArtists,
+ getGenreIdByName,
+ getTagIdsByTrackId,
+ findTrackByIdWithDetails
+};
\ No newline at end of file
diff --git a/src/routes/tracks.routes.js b/src/routes/tracks.routes.js
index f5807f9..fc15764 100644
--- a/src/routes/tracks.routes.js
+++ b/src/routes/tracks.routes.js
@@ -6,20 +6,17 @@
const express = require('express');
const router = express.Router();
const controller = require('../controllers/tracks.controller');
-const { authenticate } = require('../middleware/auth');
+const { authenticate, optionalAuthenticate } = require('../middleware/auth');
const { uploadTrackFiles } = require('../middleware/multer.js');
const asyncHandler = require('../utils/async-handler');
// upload track
-router.post(
- '/',
- authenticate,
- uploadTrackFiles .fields([
+router.post('/', authenticate, uploadTrackFiles .fields([
{ name: 'audio_file', maxCount: 1 },
{ name: 'cover_image', maxCount: 1 },
]),
asyncHandler(controller.uploadTrack)
);
-
+router.get('/:track_id', optionalAuthenticate, asyncHandler(controller.getTrackById));
module.exports = router;
diff --git a/src/services/tracks.service.js b/src/services/tracks.service.js
index ff91d5c..d591d63 100644
--- a/src/services/tracks.service.js
+++ b/src/services/tracks.service.js
@@ -24,7 +24,7 @@ const parseArray = (v) => {
const clean = (v) => (v === undefined || v === null || v === '' ? null : v);
const uploadTrack = async ({ user, audioFile, coverImageFile, body }) => {
- const userId = user?.user_id || user?.id || user?.sub;
+ const userId = user?.sub || user?.id || user?.user_id;
if (!userId) throw new AppError('Authenticated user not found', 401, 'AUTH_TOKEN_INVALID');
const tagIds = parseArray(body.tags || body.tag_ids);
@@ -104,7 +104,33 @@ const uploadTrack = async ({ user, audioFile, coverImageFile, body }) => {
};
};
-module.exports = { uploadTrack };
+const getTrackById = async (trackId, requesterUserId = null) => {
+ const track = await tracksModel.findTrackByIdWithDetails(trackId);
+
+ if (!track) {
+ throw new AppError('Track not found', 404, 'TRACK_NOT_FOUND');
+ }
+
+ const isOwner = requesterUserId === track.user_id;
+
+ // Hidden tracks should look nonexistent to non-owners
+ if (track.is_hidden && !isOwner) {
+ throw new AppError('Track not found', 404, 'TRACK_NOT_FOUND');
+ }
+
+ // Private tracks are owner-only
+ if (!track.is_public && !isOwner) {
+ throw new AppError('This track is private', 403, 'RESOURCE_PRIVATE');
+ }
+
+ return track;
+};
+
+module.exports = {
+ uploadTrack,
+ getTrackById
+};
+
//
// TODO Add track upload limit validations based on subscription plan
From a338f802cc1c1b007b65c747f14075ba8f1ba3f6 Mon Sep 17 00:00:00 2001
From: basboosa05 <166862890+basboosa05@users.noreply.github.com>
Date: Mon, 16 Mar 2026 14:58:05 +0200
Subject: [PATCH 028/535] Add DB seed SQL
Add a comprehensive SQL seed file (seeds/seed.sql) that populates test data for genres, tags, users, follows, tracks, track_tags, track_artists, albums, album_tracks, playlists, playlist_tracks, likes, reposts, comments, comment_likes, listening_history, activities, notifications, and user settings.
---
package.json | 3 +-
seeds/seed.sql | 641 +++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 643 insertions(+), 1 deletion(-)
create mode 100644 seeds/seed.sql
diff --git a/package.json b/package.json
index 685585a..287e218 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,8 @@
"format": "prettier --write src/",
"migrate:create": "db-migrate create",
"migrate:up": "db-migrate up",
- "migrate:down": "db-migrate down"
+ "migrate:down": "db-migrate down",
+ "db:seed": "psql $DATABASE_URL -f database/seeds/seed.sql"
},
"jest": {
"testEnvironment": "node"
diff --git a/seeds/seed.sql b/seeds/seed.sql
new file mode 100644
index 0000000..fcfbc90
--- /dev/null
+++ b/seeds/seed.sql
@@ -0,0 +1,641 @@
+BEGIN;
+
+
+-- 1. GENRES (10)
+
+INSERT INTO genres (id, name) VALUES
+ ('a1000000-0000-0000-0000-000000000001', 'Quran Recitation'),
+ ('a1000000-0000-0000-0000-000000000002', 'Islamic Lecture'),
+ ('a1000000-0000-0000-0000-000000000003', 'Personal Development'),
+ ('a1000000-0000-0000-0000-000000000004', 'Business & Entrepreneurship'),
+ ('a1000000-0000-0000-0000-000000000005', 'Sports & Fitness'),
+ ('a1000000-0000-0000-0000-000000000006', 'Mental Health & Wellness'),
+ ('a1000000-0000-0000-0000-000000000007', 'Science & Technology'),
+ ('a1000000-0000-0000-0000-000000000008', 'True Crime & Society'),
+ ('a1000000-0000-0000-0000-000000000009', 'Daily 5-Minute Talks'),
+ ('a1000000-0000-0000-0000-000000000010', 'Motivation & Reminders');
+
+
+-- 2. TAGS (10)
+
+INSERT INTO tags (id, name) VALUES
+ ('b1000000-0000-0000-0000-000000000001', 'quran'),
+ ('b1000000-0000-0000-0000-000000000002', 'short'),
+ ('b1000000-0000-0000-0000-000000000003', 'english'),
+ ('b1000000-0000-0000-0000-000000000004', 'arabic'),
+ ('b1000000-0000-0000-0000-000000000005', 'interview'),
+ ('b1000000-0000-0000-0000-000000000006', 'solo'),
+ ('b1000000-0000-0000-0000-000000000007', 'beginner-friendly'),
+ ('b1000000-0000-0000-0000-000000000008', 'deep-dive'),
+ ('b1000000-0000-0000-0000-000000000009', 'daily-habit'),
+ ('b1000000-0000-0000-0000-000000000010', 'ramadan');
+
+
+-- 3. USERS
+-- 1 admin + 8 creators (role=artist) + 6 listeners
+INSERT INTO users (id, email, password_hashed, username, display_name, gender, date_of_birth, role, is_verified, country) VALUES
+
+ -- Admin
+ ('c0000000-0000-0000-0000-000000000001',
+ 'admin@rythmify.com',
+ '$2b$12$u0Wvmh6ELlK6w1sNnNTpF.Zs13MRVs2PcymGR5kse61CW0HuaX7Qa',
+ 'admin', 'Rythmify Admin',
+ 'male', '1990-01-01', 'admin', true, 'EG'),
+ -- Creators
+ ('c1000000-0000-0000-0000-000000000001',
+ 'basel_mones@example.com',
+ '$2b$12$NvCh9JzVbUdzAKHmoPpcSu0.LAcnUE3gEL8pTShDMoIEdkIEsY..i',
+ 'basel_mones', 'Basel Mones',
+ 'male', '1975-03-10', 'artist', true, 'EG'),
+
+ ('c1000000-0000-0000-0000-000000000002',
+ 'ustadh_omar@example.com',
+ '$2b$12$NvCh9JzVbUdzAKHmoPpcSu0.LAcnUE3gEL8pTShDMoIEdkIEsY..i',
+ 'ustadh_omar', 'Ustadh Omar',
+ 'male', '1980-07-22', 'artist', true, 'SA'),
+
+ ('c1000000-0000-0000-0000-000000000003',
+ 'sister_maryam@example.com',
+ '$2b$12$NvCh9JzVbUdzAKHmoPpcSu0.LAcnUE3gEL8pTShDMoIEdkIEsY..i',
+ 'sister_maryam', 'Sister Maryam',
+ 'female', '1988-11-05', 'artist', true, 'GB'),
+
+ ('c1000000-0000-0000-0000-000000000004',
+ 'dr_khalid_tafsir@example.com',
+ '$2b$12$NvCh9JzVbUdzAKHmoPpcSu0.LAcnUE3gEL8pTShDMoIEdkIEsY..i',
+ 'dr_khalid_tafsir', 'Dr. Khalid Tafsir',
+ 'male', '1970-09-28', 'artist', true, 'JO'),
+
+ ('c1000000-0000-0000-0000-000000000005',
+ 'layla_mindset@example.com',
+ '$2b$12$NvCh9JzVbUdzAKHmoPpcSu0.LAcnUE3gEL8pTShDMoIEdkIEsY..i',
+ 'layla_mindset', 'Layla Mindset',
+ 'female', '1992-04-14', 'artist', true, 'AE'),
+
+ ('c1000000-0000-0000-0000-000000000006',
+ 'coach_karim@example.com',
+ '$2b$12$NvCh9JzVbUdzAKHmoPpcSu0.LAcnUE3gEL8pTShDMoIEdkIEsY..i',
+ 'coach_karim', 'Coach Karim',
+ 'male', '1985-08-19', 'artist', true, 'MA'),
+
+ ('c1000000-0000-0000-0000-000000000007',
+ 'sara_builds@example.com',
+ '$2b$12$NvCh9JzVbUdzAKHmoPpcSu0.LAcnUE3gEL8pTShDMoIEdkIEsY..i',
+ 'sara_builds', 'Sara Builds',
+ 'female', '1995-01-30', 'artist', true, 'US'),
+
+ ('c1000000-0000-0000-0000-000000000008',
+ 'tariq_talks@example.com',
+ '$2b$12$NvCh9JzVbUdzAKHmoPpcSu0.LAcnUE3gEL8pTShDMoIEdkIEsY..i',
+ 'tariq_talks', 'Tariq Talks',
+ 'male', '1990-06-05', 'artist', true, 'CA'),
+
+ -- === LISTENERS ===
+ ('c2000000-0000-0000-0000-000000000001',
+ 'listener1@example.com',
+ '$2b$12$XQCpHZK73KCarZeh4HOVb.LDcbNROfUN3Tk4sQuU8oqjXJ6gq3jMK',
+ 'ali_hassan', 'Ali Hassan',
+ 'male', '2000-04-18', 'listener', true, 'EG'),
+
+ ('c2000000-0000-0000-0000-000000000002',
+ 'listener2@example.com',
+ '$2b$12$XQCpHZK73KCarZeh4HOVb.LDcbNROfUN3Tk4sQuU8oqjXJ6gq3jMK',
+ 'sara_mostafa', 'Sara Mostafa',
+ 'female', '2001-08-25', 'listener', true, 'EG'),
+
+ ('c2000000-0000-0000-0000-000000000003',
+ 'listener3@example.com',
+ '$2b$12$XQCpHZK73KCarZeh4HOVb.LDcbNROfUN3Tk4sQuU8oqjXJ6gq3jMK',
+ 'jake_w', 'Jake Williams',
+ 'male', '1998-12-03', 'listener', true, 'CA'),
+
+ ('c2000000-0000-0000-0000-000000000004',
+ 'listener4@example.com',
+ '$2b$12$XQCpHZK73KCarZeh4HOVb.LDcbNROfUN3Tk4sQuU8oqjXJ6gq3jMK',
+ 'mia_d', 'Mia Dupont',
+ 'female', '2003-01-17', 'listener', true, 'FR'),
+
+ ('c2000000-0000-0000-0000-000000000005',
+ 'listener5@example.com',
+ '$2b$12$XQCpHZK73KCarZeh4HOVb.LDcbNROfUN3Tk4sQuU8oqjXJ6gq3jMK',
+ 'omar_alfarsi', 'Omar Al-Farsi',
+ 'male', '1997-05-30', 'listener', true, 'SA'),
+
+ ('c2000000-0000-0000-0000-000000000006',
+ 'listener6@example.com',
+ '$2b$12$XQCpHZK73KCarZeh4HOVb.LDcbNROfUN3Tk4sQuU8oqjXJ6gq3jMK',
+ 'nour_k', 'Nour Khalil',
+ 'female', '1999-09-09', 'listener', true, 'LB');
+
+
+-- 4. FOLLOWS
+-- trg_follow_counts trigger handles counter updates
+
+INSERT INTO follows (follower_id, following_id) VALUES
+ ('c2000000-0000-0000-0000-000000000001', 'c1000000-0000-0000-0000-000000000001'),
+ ('c2000000-0000-0000-0000-000000000001', 'c1000000-0000-0000-0000-000000000006'),
+ ('c2000000-0000-0000-0000-000000000002', 'c1000000-0000-0000-0000-000000000003'),
+ ('c2000000-0000-0000-0000-000000000002', 'c1000000-0000-0000-0000-000000000005'),
+ ('c2000000-0000-0000-0000-000000000003', 'c1000000-0000-0000-0000-000000000007'),
+ ('c2000000-0000-0000-0000-000000000003', 'c1000000-0000-0000-0000-000000000008'),
+ ('c2000000-0000-0000-0000-000000000004', 'c1000000-0000-0000-0000-000000000002'),
+ ('c2000000-0000-0000-0000-000000000004', 'c1000000-0000-0000-0000-000000000005'),
+ ('c2000000-0000-0000-0000-000000000005', 'c1000000-0000-0000-0000-000000000001'),
+ ('c2000000-0000-0000-0000-000000000005', 'c1000000-0000-0000-0000-000000000004'),
+ ('c2000000-0000-0000-0000-000000000006', 'c1000000-0000-0000-0000-000000000003'),
+ ('c2000000-0000-0000-0000-000000000006', 'c1000000-0000-0000-0000-000000000008'),
+ ('c1000000-0000-0000-0000-000000000005', 'c1000000-0000-0000-0000-000000000007'),
+ ('c1000000-0000-0000-0000-000000000006', 'c1000000-0000-0000-0000-000000000008'),
+ ('c1000000-0000-0000-0000-000000000001', 'c1000000-0000-0000-0000-000000000002');
+
+
+-- 5. TRACKS (16 total — 8 Islamic, 8 general)
+
+INSERT INTO tracks
+ (id, title, description, genre_id, cover_image, waveform_url, audio_url,
+ duration, status, is_public, user_id, play_count, like_count, explicit_content)
+VALUES
+
+ ('d1000000-0000-0000-0000-000000000001',
+ 'Surah Al-Mudassir — Basel Mones',
+ 'Recitation of Surah Al-Mudassir (74) by Basel Mones',
+ 'a1000000-0000-0000-0000-000000000001',
+ 'covers/basel-mones/surah-elmudsar.jpg',
+ 'waveforms/basel-mones/Surah_Elmudsar.json',
+ 'audio/basel-mones/Surah_Elmudsar.mp3',
+ NULL, 'ready', true,
+ 'c1000000-0000-0000-0000-000000000001', 12400, 980, false),
+
+ ('d1000000-0000-0000-0000-000000000002',
+ 'Surah Al-Muzzammil — Basel Mones',
+ 'Recitation of Surah Al-Muzzammil (73) by Basel Mones',
+ 'a1000000-0000-0000-0000-000000000001',
+ 'covers/basel-mones/surah-elmuzamel.jpg',
+ 'waveforms/basel-mones/Surah_Elmuzamel.json',
+ 'audio/basel-mones/Surah_Elmuzamel.mp3',
+ NULL, 'ready', true,
+ 'c1000000-0000-0000-0000-000000000001', 8700, 640, false),
+
+
+ ('d1000000-0000-0000-0000-000000000003',
+ 'The Importance of Salah',
+ 'A concise lecture on why prayer is the pillar of Islam',
+ 'a1000000-0000-0000-0000-000000000002',
+ 'covers/ustadh-omar/importance-of-salah.jpg',
+ 'waveforms/ustadh-omar/importance-of-salah.json',
+ 'audio/ustadh-omar/importance-of-salah.mp3',
+ 1845, 'ready', false,
+ 'c1000000-0000-0000-0000-000000000002', 5600, 310, false),
+
+ ('d1000000-0000-0000-0000-000000000004',
+ '5 Minutes: Why Gratitude Changes Everything',
+ 'A short Islamic reminder on the power of shukr',
+ 'a1000000-0000-0000-0000-000000000009',
+ 'covers/ustadh-omar/gratitude-5min.jpg',
+ 'waveforms/ustadh-omar/gratitude-5min.json',
+ 'audio/ustadh-omar/gratitude-5min.mp3',
+ 310, 'ready', true,
+ 'c1000000-0000-0000-0000-000000000002', 3200, 210, false),
+
+
+ ('d1000000-0000-0000-0000-000000000005',
+ 'Muslim Women in the Modern World — Ep. 1',
+ 'Faith, identity and confidence as a Muslim woman today',
+ 'a1000000-0000-0000-0000-000000000002',
+ 'covers/sister-maryam/ep1-cover.jpg',
+ 'waveforms/sister-maryam/ep1.json',
+ 'audio/sister-maryam/ep1-modern-world.mp3',
+ 2700, 'ready', true,
+ 'c1000000-0000-0000-0000-000000000003', 4100, 220, false),
+
+ ('d1000000-0000-0000-0000-000000000006',
+ 'Balance: Deen, Career & Mental Health',
+ 'Honest conversation about juggling it all as a Muslim professional',
+ 'a1000000-0000-0000-0000-000000000006',
+ 'covers/sister-maryam/ep2-balance.jpg',
+ 'waveforms/sister-maryam/ep2-balance.json',
+ 'audio/sister-maryam/ep2-balance.mp3',
+ 3120, 'ready', true,
+ 'c1000000-0000-0000-0000-000000000003', 2800, 185, false),
+
+
+ ('d1000000-0000-0000-0000-000000000007',
+ 'Tafsir Surah Al-Baqarah — Part 1',
+ 'Detailed explanation of the first 10 verses',
+ 'a1000000-0000-0000-0000-000000000002',
+ 'covers/dr-khalid/tafsir-baqarah-p1.jpg',
+ 'waveforms/dr-khalid/tafsir-baqarah-p1.json',
+ 'audio/dr-khalid/tafsir-baqarah-part1.mp3',
+ 3600, 'ready', true,
+ 'c1000000-0000-0000-0000-000000000004', 2100, 140, false),
+
+ ('d1000000-0000-0000-0000-000000000008',
+ '5 Minutes: The Story of Musa & Perseverance',
+ 'Quick reminder drawn from the story of Prophet Musa',
+ 'a1000000-0000-0000-0000-000000000009',
+ 'covers/dr-khalid/musa-5min.jpg',
+ 'waveforms/dr-khalid/musa-5min.json',
+ 'audio/dr-khalid/musa-5min.mp3',
+ 295, 'ready', true,
+ 'c1000000-0000-0000-0000-000000000004', 1750, 110, false),
+
+
+ ('d1000000-0000-0000-0000-000000000009',
+ 'Stop Waiting to Feel Ready',
+ 'Why action comes before motivation, not after',
+ 'a1000000-0000-0000-0000-000000000003',
+ 'covers/layla-mindset/feel-ready.jpg',
+ 'waveforms/layla-mindset/feel-ready.json',
+ 'audio/layla-mindset/stop-waiting-to-feel-ready.mp3',
+ 1620, 'ready', true,
+ 'c1000000-0000-0000-0000-000000000005', 6300, 420, false),
+
+ ('d1000000-0000-0000-0000-000000000010',
+ '5 Minutes: One Habit That Will Change Your Morning',
+ 'A quick daily challenge to start your day with intention',
+ 'a1000000-0000-0000-0000-000000000009',
+ 'covers/layla-mindset/morning-habit.jpg',
+ 'waveforms/layla-mindset/morning-habit.json',
+ 'audio/layla-mindset/morning-habit-5min.mp3',
+ 305, 'ready', true,
+ 'c1000000-0000-0000-0000-000000000005', 9100, 710, false),
+
+
+ ('d1000000-0000-0000-0000-000000000011',
+ 'How to Build Discipline Like an Athlete',
+ 'Lessons from high-performance sport that anyone can apply',
+ 'a1000000-0000-0000-0000-000000000005',
+ 'covers/coach-karim/discipline-athlete.jpg',
+ 'waveforms/coach-karim/discipline-athlete.json',
+ 'audio/coach-karim/discipline-like-athlete.mp3',
+ 2250, 'ready', true,
+ 'c1000000-0000-0000-0000-000000000006', 4800, 330, false),
+
+ ('d1000000-0000-0000-0000-000000000012',
+ 'Ramadan Training: Stay Fit While Fasting',
+ 'Practical guide to maintaining your fitness during Ramadan',
+ 'a1000000-0000-0000-0000-000000000005',
+ 'covers/coach-karim/ramadan-training.jpg',
+ 'waveforms/coach-karim/ramadan-training.json',
+ 'audio/coach-karim/ramadan-training.mp3',
+ 1980, 'ready', true,
+ 'c1000000-0000-0000-0000-000000000006', 3500, 260, false),
+
+
+ ('d1000000-0000-0000-0000-000000000013',
+ 'From 0 to First Client: My Story',
+ 'How I landed my first paying client with zero following',
+ 'a1000000-0000-0000-0000-000000000004',
+ 'covers/sara-builds/first-client.jpg',
+ 'waveforms/sara-builds/first-client.json',
+ 'audio/sara-builds/from-0-to-first-client.mp3',
+ 2880, 'ready', true,
+ 'c1000000-0000-0000-0000-000000000007', 5100, 370, false),
+
+ ('d1000000-0000-0000-0000-000000000014',
+ '5 Minutes: The One Email That Grows Your Business',
+ 'A quick actionable tip on email marketing for solopreneurs',
+ 'a1000000-0000-0000-0000-000000000009',
+ 'covers/sara-builds/email-tip.jpg',
+ 'waveforms/sara-builds/email-tip.json',
+ 'audio/sara-builds/email-grows-business-5min.mp3',
+ 298, 'ready', true,
+ 'c1000000-0000-0000-0000-000000000007', 4200, 295, false),
+
+
+ ('d1000000-0000-0000-0000-000000000015',
+ 'Why Gen Z Is Burning Out at 22',
+ 'An honest look at hustle culture and its mental health toll',
+ 'a1000000-0000-0000-0000-000000000006',
+ 'covers/tariq-talks/burnout-genz.jpg',
+ 'waveforms/tariq-talks/burnout-genz.json',
+ 'audio/tariq-talks/genz-burnout.mp3',
+ 3060, 'ready', true,
+ 'c1000000-0000-0000-0000-000000000008', 7200, 540, false),
+
+ ('d1000000-0000-0000-0000-000000000016',
+ 'The Social Media Detox Experiment',
+ 'I quit all social media for 30 days — here is what happened',
+ 'a1000000-0000-0000-0000-000000000003',
+ 'covers/tariq-talks/social-detox.jpg',
+ 'waveforms/tariq-talks/social-detox.json',
+ 'audio/tariq-talks/social-media-detox.mp3',
+ 2640, 'ready', true,
+ 'c1000000-0000-0000-0000-000000000008', 5800, 430, false);
+
+
+-- 6. TRACK TAGS
+
+INSERT INTO track_tags (track_id, tag_id) VALUES
+
+ ('d1000000-0000-0000-0000-000000000001', 'b1000000-0000-0000-0000-000000000001'), -- quran
+ ('d1000000-0000-0000-0000-000000000001', 'b1000000-0000-0000-0000-000000000002'), -- short
+ ('d1000000-0000-0000-0000-000000000002', 'b1000000-0000-0000-0000-000000000001'), -- quran
+ ('d1000000-0000-0000-0000-000000000002', 'b1000000-0000-0000-0000-000000000004'), -- arabic
+ ('d1000000-0000-0000-0000-000000000003', 'b1000000-0000-0000-0000-000000000003'), -- english
+ ('d1000000-0000-0000-0000-000000000003', 'b1000000-0000-0000-0000-000000000006'), -- solo
+ ('d1000000-0000-0000-0000-000000000004', 'b1000000-0000-0000-0000-000000000002'), -- short
+ ('d1000000-0000-0000-0000-000000000004', 'b1000000-0000-0000-0000-000000000009'), -- daily-habit
+ ('d1000000-0000-0000-0000-000000000005', 'b1000000-0000-0000-0000-000000000003'), -- english
+ ('d1000000-0000-0000-0000-000000000005', 'b1000000-0000-0000-0000-000000000005'), -- interview
+ ('d1000000-0000-0000-0000-000000000006', 'b1000000-0000-0000-0000-000000000003'), -- english
+ ('d1000000-0000-0000-0000-000000000007', 'b1000000-0000-0000-0000-000000000008'), -- deep-dive
+ ('d1000000-0000-0000-0000-000000000007', 'b1000000-0000-0000-0000-000000000004'), -- arabic
+ ('d1000000-0000-0000-0000-000000000008', 'b1000000-0000-0000-0000-000000000002'), -- short
+ ('d1000000-0000-0000-0000-000000000008', 'b1000000-0000-0000-0000-000000000010'), -- ramadan
+ ('d1000000-0000-0000-0000-000000000009', 'b1000000-0000-0000-0000-000000000003'), -- english
+ ('d1000000-0000-0000-0000-000000000009', 'b1000000-0000-0000-0000-000000000006'), -- solo
+ ('d1000000-0000-0000-0000-000000000010', 'b1000000-0000-0000-0000-000000000002'), -- short
+ ('d1000000-0000-0000-0000-000000000010', 'b1000000-0000-0000-0000-000000000009'), -- daily-habit
+ ('d1000000-0000-0000-0000-000000000011', 'b1000000-0000-0000-0000-000000000006'), -- solo
+ ('d1000000-0000-0000-0000-000000000011', 'b1000000-0000-0000-0000-000000000007'), -- beginner-friendly
+ ('d1000000-0000-0000-0000-000000000012', 'b1000000-0000-0000-0000-000000000010'), -- ramadan
+ ('d1000000-0000-0000-0000-000000000012', 'b1000000-0000-0000-0000-000000000007'), -- beginner-friendly
+ ('d1000000-0000-0000-0000-000000000013', 'b1000000-0000-0000-0000-000000000006'), -- solo
+ ('d1000000-0000-0000-0000-000000000013', 'b1000000-0000-0000-0000-000000000008'), -- deep-dive
+ ('d1000000-0000-0000-0000-000000000014', 'b1000000-0000-0000-0000-000000000002'), -- short
+ ('d1000000-0000-0000-0000-000000000014', 'b1000000-0000-0000-0000-000000000009'), -- daily-habit
+ ('d1000000-0000-0000-0000-000000000015', 'b1000000-0000-0000-0000-000000000008'), -- deep-dive
+ ('d1000000-0000-0000-0000-000000000015', 'b1000000-0000-0000-0000-000000000003'), -- english
+ ('d1000000-0000-0000-0000-000000000016', 'b1000000-0000-0000-0000-000000000005'), -- interview
+ ('d1000000-0000-0000-0000-000000000016', 'b1000000-0000-0000-0000-000000000003'); -- english
+
+
+-- 7. TRACK ARTISTS (owner at position 1 for every track)
+
+INSERT INTO track_artists (track_id, artist_id, position) VALUES
+ ('d1000000-0000-0000-0000-000000000001', 'c1000000-0000-0000-0000-000000000001', 1),
+ ('d1000000-0000-0000-0000-000000000002', 'c1000000-0000-0000-0000-000000000001', 1),
+ ('d1000000-0000-0000-0000-000000000003', 'c1000000-0000-0000-0000-000000000002', 1),
+ ('d1000000-0000-0000-0000-000000000004', 'c1000000-0000-0000-0000-000000000002', 1),
+ ('d1000000-0000-0000-0000-000000000005', 'c1000000-0000-0000-0000-000000000003', 1),
+ ('d1000000-0000-0000-0000-000000000006', 'c1000000-0000-0000-0000-000000000003', 1),
+ ('d1000000-0000-0000-0000-000000000007', 'c1000000-0000-0000-0000-000000000004', 1),
+ ('d1000000-0000-0000-0000-000000000008', 'c1000000-0000-0000-0000-000000000004', 1),
+ ('d1000000-0000-0000-0000-000000000009', 'c1000000-0000-0000-0000-000000000005', 1),
+ ('d1000000-0000-0000-0000-000000000010', 'c1000000-0000-0000-0000-000000000005', 1),
+ ('d1000000-0000-0000-0000-000000000011', 'c1000000-0000-0000-0000-000000000006', 1),
+ ('d1000000-0000-0000-0000-000000000012', 'c1000000-0000-0000-0000-000000000006', 1),
+ ('d1000000-0000-0000-0000-000000000013', 'c1000000-0000-0000-0000-000000000007', 1),
+ ('d1000000-0000-0000-0000-000000000014', 'c1000000-0000-0000-0000-000000000007', 1),
+ ('d1000000-0000-0000-0000-000000000015', 'c1000000-0000-0000-0000-000000000008', 1),
+ ('d1000000-0000-0000-0000-000000000016', 'c1000000-0000-0000-0000-000000000008', 1);
+
+-- 8. ALBUMS / SERIES (4)
+
+INSERT INTO albums (id, title, description, artist_id, genre_id, cover_image, is_public, release_date, track_count) VALUES
+ ('e1000000-0000-0000-0000-000000000001',
+ 'Basel Mones — Quran Collection',
+ 'Selected surahs with clear tajweed',
+ 'c1000000-0000-0000-0000-000000000001',
+ 'a1000000-0000-0000-0000-000000000001',
+ 'covers/albums/basel-mones-quran-collection.jpg',
+ true, '2024-03-01', 2),
+
+ ('e1000000-0000-0000-0000-000000000002',
+ 'Ustadh Omar — 5-Minute Series',
+ 'Quick Islamic reminders for busy people',
+ 'c1000000-0000-0000-0000-000000000002',
+ 'a1000000-0000-0000-0000-000000000009',
+ 'covers/albums/ustadh-omar-5min.jpg',
+ true, '2024-06-01', 2),
+
+ ('e1000000-0000-0000-0000-000000000003',
+ 'Layla Mindset — Morning Series',
+ 'Short and long episodes to start your day right',
+ 'c1000000-0000-0000-0000-000000000005',
+ 'a1000000-0000-0000-0000-000000000003',
+ 'covers/albums/layla-morning-series.jpg',
+ true, '2024-08-15', 2),
+
+ ('e1000000-0000-0000-0000-000000000004',
+ 'Tariq Talks — Society & Life',
+ 'Conversations about the things that matter',
+ 'c1000000-0000-0000-0000-000000000008',
+ 'a1000000-0000-0000-0000-000000000006',
+ 'covers/albums/tariq-talks-society.jpg',
+ true, '2024-10-01', 2);
+
+
+-- 9. ALBUM TRACKS
+
+INSERT INTO album_tracks (album_id, track_id, position) VALUES
+ ('e1000000-0000-0000-0000-000000000001', 'd1000000-0000-0000-0000-000000000001', 1),
+ ('e1000000-0000-0000-0000-000000000001', 'd1000000-0000-0000-0000-000000000002', 2),
+ ('e1000000-0000-0000-0000-000000000002', 'd1000000-0000-0000-0000-000000000004', 1),
+ ('e1000000-0000-0000-0000-000000000002', 'd1000000-0000-0000-0000-000000000008', 2),
+ ('e1000000-0000-0000-0000-000000000003', 'd1000000-0000-0000-0000-000000000010', 1),
+ ('e1000000-0000-0000-0000-000000000003', 'd1000000-0000-0000-0000-000000000009', 2),
+ ('e1000000-0000-0000-0000-000000000004', 'd1000000-0000-0000-0000-000000000015', 1),
+ ('e1000000-0000-0000-0000-000000000004', 'd1000000-0000-0000-0000-000000000016', 2);
+
+
+-- 10. PLAYLISTS (5 — mixed themed playlists)
+
+INSERT INTO playlists (id, name, description, user_id, type, is_public, track_count) VALUES
+ ('f1000000-0000-0000-0000-000000000001',
+ 'My Morning Routine',
+ 'A mix of reminders and motivation to start the day',
+ 'c2000000-0000-0000-0000-000000000001',
+ 'regular', true, 3),
+
+ ('f1000000-0000-0000-0000-000000000002',
+ 'Ramadan Vibes',
+ 'Lectures, recitations and reminders for Ramadan',
+ 'c2000000-0000-0000-0000-000000000002',
+ 'regular', true, 3),
+
+ ('f1000000-0000-0000-0000-000000000003',
+ 'Grind & Grow',
+ 'Business and personal dev episodes for the hustle',
+ 'c2000000-0000-0000-0000-000000000003',
+ 'regular', true, 3),
+
+ ('f1000000-0000-0000-0000-000000000004',
+ 'Mental Reset',
+ 'Episodes for when you need to decompress and reflect',
+ 'c2000000-0000-0000-0000-000000000004',
+ 'regular', true, 3),
+
+ ('f1000000-0000-0000-0000-000000000005',
+ '5-Minute Daily',
+ 'Only short episodes — all under 6 minutes',
+ 'c2000000-0000-0000-0000-000000000005',
+ 'regular', true, 4);
+
+
+-- 11. PLAYLIST TRACKS
+
+INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES
+ ('f1000000-0000-0000-0000-000000000001', 'd1000000-0000-0000-0000-000000000004', 1),
+ ('f1000000-0000-0000-0000-000000000001', 'd1000000-0000-0000-0000-000000000010', 2),
+ ('f1000000-0000-0000-0000-000000000001', 'd1000000-0000-0000-0000-000000000009', 3),
+
+ ('f1000000-0000-0000-0000-000000000002', 'd1000000-0000-0000-0000-000000000001', 1),
+ ('f1000000-0000-0000-0000-000000000002', 'd1000000-0000-0000-0000-000000000003', 2),
+ ('f1000000-0000-0000-0000-000000000002', 'd1000000-0000-0000-0000-000000000012', 3),
+
+ ('f1000000-0000-0000-0000-000000000003', 'd1000000-0000-0000-0000-000000000013', 1),
+ ('f1000000-0000-0000-0000-000000000003', 'd1000000-0000-0000-0000-000000000014', 2),
+ ('f1000000-0000-0000-0000-000000000003', 'd1000000-0000-0000-0000-000000000011', 3),
+
+ ('f1000000-0000-0000-0000-000000000004', 'd1000000-0000-0000-0000-000000000015', 1),
+ ('f1000000-0000-0000-0000-000000000004', 'd1000000-0000-0000-0000-000000000006', 2),
+ ('f1000000-0000-0000-0000-000000000004', 'd1000000-0000-0000-0000-000000000016', 3),
+
+ ('f1000000-0000-0000-0000-000000000005', 'd1000000-0000-0000-0000-000000000004', 1),
+ ('f1000000-0000-0000-0000-000000000005', 'd1000000-0000-0000-0000-000000000008', 2),
+ ('f1000000-0000-0000-0000-000000000005', 'd1000000-0000-0000-0000-000000000010', 3),
+ ('f1000000-0000-0000-0000-000000000005', 'd1000000-0000-0000-0000-000000000014', 4);
+
+
+-- 12. TRACK LIKES
+
+INSERT INTO track_likes (user_id, track_id) VALUES
+ ('c2000000-0000-0000-0000-000000000001', 'd1000000-0000-0000-0000-000000000001'),
+ ('c2000000-0000-0000-0000-000000000001', 'd1000000-0000-0000-0000-000000000010'),
+ ('c2000000-0000-0000-0000-000000000002', 'd1000000-0000-0000-0000-000000000003'),
+ ('c2000000-0000-0000-0000-000000000002', 'd1000000-0000-0000-0000-000000000005'),
+ ('c2000000-0000-0000-0000-000000000003', 'd1000000-0000-0000-0000-000000000013'),
+ ('c2000000-0000-0000-0000-000000000003', 'd1000000-0000-0000-0000-000000000015'),
+ ('c2000000-0000-0000-0000-000000000004', 'd1000000-0000-0000-0000-000000000006'),
+ ('c2000000-0000-0000-0000-000000000004', 'd1000000-0000-0000-0000-000000000009'),
+ ('c2000000-0000-0000-0000-000000000005', 'd1000000-0000-0000-0000-000000000007'),
+ ('c2000000-0000-0000-0000-000000000006', 'd1000000-0000-0000-0000-000000000016');
+
+
+-- 13. TRACK REPOSTS
+INSERT INTO track_reposts (user_id, track_id) VALUES
+ ('c2000000-0000-0000-0000-000000000001', 'd1000000-0000-0000-0000-000000000010'),
+ ('c2000000-0000-0000-0000-000000000003', 'd1000000-0000-0000-0000-000000000015'),
+ ('c2000000-0000-0000-0000-000000000006', 'd1000000-0000-0000-0000-000000000004');
+
+
+-- 14. COMMENTS
+INSERT INTO comments (id, user_id, track_id, content, track_timestamp) VALUES
+ ('11000000-0000-0000-0000-000000000001',
+ 'c2000000-0000-0000-0000-000000000001',
+ 'd1000000-0000-0000-0000-000000000001',
+ 'ما شاء الله، تلاوة جميلة جداً 🤍', NULL),
+
+ ('11000000-0000-0000-0000-000000000002',
+ 'c2000000-0000-0000-0000-000000000002',
+ 'd1000000-0000-0000-0000-000000000003',
+ 'JazakAllahu Khayran, this really hit home', NULL),
+
+ ('11000000-0000-0000-0000-000000000003',
+ 'c2000000-0000-0000-0000-000000000003',
+ 'd1000000-0000-0000-0000-000000000015',
+ 'The part about comparison at 12:40 is so real', 760),
+
+ ('11000000-0000-0000-0000-000000000004',
+ 'c2000000-0000-0000-0000-000000000004',
+ 'd1000000-0000-0000-0000-000000000009',
+ 'Needed this today. Bookmarked.', NULL),
+
+ -- Creator replies to comment on their track
+ ('11000000-0000-0000-0000-000000000005',
+ 'c1000000-0000-0000-0000-000000000001',
+ 'd1000000-0000-0000-0000-000000000001',
+ 'جزاك الله خيرا 🙏', NULL);
+
+UPDATE comments
+ SET parent_comment_id = '11000000-0000-0000-0000-000000000001'
+ WHERE id = '11000000-0000-0000-0000-000000000005';
+
+UPDATE comments SET reply_count = 1
+ WHERE id = '11000000-0000-0000-0000-000000000001';
+
+UPDATE tracks SET comment_count = (
+ SELECT COUNT(*) FROM comments WHERE track_id = tracks.id AND deleted_at IS NULL
+);
+
+
+-- 15. COMMENT LIKES
+
+INSERT INTO comment_likes (user_id, comment_id) VALUES
+ ('c2000000-0000-0000-0000-000000000002', '11000000-0000-0000-0000-000000000001'),
+ ('c2000000-0000-0000-0000-000000000003', '11000000-0000-0000-0000-000000000003'),
+ ('c2000000-0000-0000-0000-000000000005', '11000000-0000-0000-0000-000000000004');
+
+UPDATE comments SET like_count = 2 WHERE id = '11000000-0000-0000-0000-000000000001';
+UPDATE comments SET like_count = 1 WHERE id = '11000000-0000-0000-0000-000000000003';
+UPDATE comments SET like_count = 1 WHERE id = '11000000-0000-0000-0000-000000000004';
+
+
+-- 16. LISTENING HISTORY
+-- trg_track_play_count trigger fires on each INSERT here
+INSERT INTO listening_history (user_id, track_id, duration_played, played_at) VALUES
+ ('c2000000-0000-0000-0000-000000000001', 'd1000000-0000-0000-0000-000000000001', 87, now() - interval '2 days'),
+ ('c2000000-0000-0000-0000-000000000001', 'd1000000-0000-0000-0000-000000000010', 305, now() - interval '1 day'),
+ ('c2000000-0000-0000-0000-000000000002', 'd1000000-0000-0000-0000-000000000005', 2700, now() - interval '3 hours'),
+ ('c2000000-0000-0000-0000-000000000003', 'd1000000-0000-0000-0000-000000000015', 3060, now() - interval '30 minutes'),
+ ('c2000000-0000-0000-0000-000000000004', 'd1000000-0000-0000-0000-000000000009', 1620, now() - interval '5 days'),
+ ('c2000000-0000-0000-0000-000000000005', 'd1000000-0000-0000-0000-000000000007', 3600, now() - interval '1 hour'),
+ ('c2000000-0000-0000-0000-000000000006', 'd1000000-0000-0000-0000-000000000016', 2640, now() - interval '6 hours');
+
+
+-- 17. ACTIVITIES
+INSERT INTO activities (user_id, type, reference_type, reference_id, target_user_id) VALUES
+ ('c2000000-0000-0000-0000-000000000001', 'like', 'track', 'd1000000-0000-0000-0000-000000000001', 'c1000000-0000-0000-0000-000000000001'),
+ ('c2000000-0000-0000-0000-000000000003', 'like', 'track', 'd1000000-0000-0000-0000-000000000015', 'c1000000-0000-0000-0000-000000000008'),
+ ('c2000000-0000-0000-0000-000000000001', 'follow', 'user', 'c1000000-0000-0000-0000-000000000001', 'c1000000-0000-0000-0000-000000000001'),
+ ('c1000000-0000-0000-0000-000000000001', 'upload', 'track', 'd1000000-0000-0000-0000-000000000001', NULL),
+ ('c1000000-0000-0000-0000-000000000008', 'upload', 'track', 'd1000000-0000-0000-0000-000000000015', NULL);
+
+
+-- 18. NOTIFICATIONS
+INSERT INTO notifications (user_id, type, action_user_id, reference_type, reference_id, is_read) VALUES
+ ('c1000000-0000-0000-0000-000000000001', 'like', 'c2000000-0000-0000-0000-000000000001', 'track', 'd1000000-0000-0000-0000-000000000001', false),
+ ('c1000000-0000-0000-0000-000000000001', 'comment', 'c2000000-0000-0000-0000-000000000001', 'track', 'd1000000-0000-0000-0000-000000000001', false),
+ ('c1000000-0000-0000-0000-000000000008', 'like', 'c2000000-0000-0000-0000-000000000003', 'track', 'd1000000-0000-0000-0000-000000000015', false),
+ ('c1000000-0000-0000-0000-000000000005', 'follow', 'c2000000-0000-0000-0000-000000000002', 'user', 'c1000000-0000-0000-0000-000000000005', true);
+
+-- 19. USER PREFERENCES
+INSERT INTO user_preferences (user_id, autoplay, explicit_content, audio_quality, language, theme) VALUES
+ ('c1000000-0000-0000-0000-000000000001', true, false, 'high', 'ar', 'dark'),
+ ('c1000000-0000-0000-0000-000000000002', true, false, 'high', 'ar', 'dark'),
+ ('c1000000-0000-0000-0000-000000000003', true, false, 'normal', 'en', 'light'),
+ ('c1000000-0000-0000-0000-000000000004', true, false, 'high', 'ar', 'dark'),
+ ('c1000000-0000-0000-0000-000000000005', true, false, 'normal', 'en', 'light'),
+ ('c1000000-0000-0000-0000-000000000006', true, false, 'normal', 'ar', 'dark'),
+ ('c1000000-0000-0000-0000-000000000007', true, false, 'high', 'en', 'dark'),
+ ('c1000000-0000-0000-0000-000000000008', true, false, 'normal', 'en', 'dark'),
+ ('c2000000-0000-0000-0000-000000000001', true, false, 'normal', 'ar', 'dark'),
+ ('c2000000-0000-0000-0000-000000000002', true, false, 'normal', 'ar', 'light'),
+ ('c2000000-0000-0000-0000-000000000003', true, false, 'low', 'en', 'dark'),
+ ('c2000000-0000-0000-0000-000000000004', true, false, 'normal', 'fr', 'light'),
+ ('c2000000-0000-0000-0000-000000000005', false, false, 'high', 'ar', 'dark'),
+ ('c2000000-0000-0000-0000-000000000006', true, false, 'normal', 'ar', 'light');
+
+
+-- 20. USER PRIVACY SETTINGS
+INSERT INTO user_privacy_settings (user_id) VALUES
+ ('c1000000-0000-0000-0000-000000000001'),
+ ('c1000000-0000-0000-0000-000000000002'),
+ ('c1000000-0000-0000-0000-000000000003'),
+ ('c1000000-0000-0000-0000-000000000004'),
+ ('c1000000-0000-0000-0000-000000000005'),
+ ('c1000000-0000-0000-0000-000000000006'),
+ ('c1000000-0000-0000-0000-000000000007'),
+ ('c1000000-0000-0000-0000-000000000008'),
+ ('c2000000-0000-0000-0000-000000000001'),
+ ('c2000000-0000-0000-0000-000000000002'),
+ ('c2000000-0000-0000-0000-000000000003'),
+ ('c2000000-0000-0000-0000-000000000004'),
+ ('c2000000-0000-0000-0000-000000000005'),
+ ('c2000000-0000-0000-0000-000000000006');
+
+
+-- 21. USER CONTENT SETTINGS (for all creators)
+INSERT INTO user_content_settings (user_id, rss_title, rss_language, default_license_type) VALUES
+ ('c1000000-0000-0000-0000-000000000001', 'Sheikh Ahmed Al-Qari RSS', 'ar', 'all_rights_reserved'),
+ ('c1000000-0000-0000-0000-000000000002', 'Ustadh Omar RSS', 'ar', 'all_rights_reserved'),
+ ('c1000000-0000-0000-0000-000000000003', 'Sister Maryam Podcast RSS', 'en', 'creative_commons'),
+ ('c1000000-0000-0000-0000-000000000004', 'Dr. Khalid Tafsir RSS', 'ar', 'all_rights_reserved'),
+ ('c1000000-0000-0000-0000-000000000005', 'Layla Mindset RSS', 'en', 'creative_commons'),
+ ('c1000000-0000-0000-0000-000000000006', 'Coach Karim RSS', 'en', 'creative_commons'),
+ ('c1000000-0000-0000-0000-000000000007', 'Sara Builds RSS', 'en', 'creative_commons'),
+ ('c1000000-0000-0000-0000-000000000008', 'Tariq Talks RSS', 'en', 'creative_commons');
+
+COMMIT;
\ No newline at end of file
From 63e7deadcf11bd796a1b0966d86312bf6214827c Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Mon, 16 Mar 2026 15:14:21 +0200
Subject: [PATCH 029/535] feat(tracks): implement PATCH
/tracks/:track_id/visibility
---
src/controllers/tracks.controller.js | 13 +++++++++++-
src/models/track.model.js | 18 +++++++++++++++-
src/routes/tracks.routes.js | 1 +
src/services/tracks.service.js | 31 +++++++++++++++++++++++++++-
4 files changed, 60 insertions(+), 3 deletions(-)
diff --git a/src/controllers/tracks.controller.js b/src/controllers/tracks.controller.js
index a2a5164..35b8f12 100644
--- a/src/controllers/tracks.controller.js
+++ b/src/controllers/tracks.controller.js
@@ -38,7 +38,18 @@ const getTrackById = async (req, res) => {
return success(res, track, 'Track fetched successfully', 200);
}
+const updateTrackVisibility = async (req, res) => {
+ const { track_id } = req.params;
+ const { is_public } = req.body;
+ const userId = req.user?.id ?? req.user?.sub ?? req.user?.user_id ?? null;
+
+ const track = await tracksService.updateTrackVisibility(track_id, userId, is_public);
+
+ return success(res, track, 'Track visibility updated successfully', 200);
+};
+
module.exports = {
uploadTrack,
- getTrackById
+ getTrackById,
+ updateTrackVisibility
};
diff --git a/src/models/track.model.js b/src/models/track.model.js
index d1eb7ce..8e18b14 100644
--- a/src/models/track.model.js
+++ b/src/models/track.model.js
@@ -141,11 +141,27 @@ const findTrackByIdWithDetails = async (trackId) => {
return rows[0] || null;
};
+const updateTrackVisibility = async (trackId, isPublic) => {
+ const query = `
+ UPDATE tracks
+ SET
+ is_public = $2,
+ updated_at = NOW()
+ WHERE id = $1
+ AND deleted_at IS NULL
+ RETURNING id, is_public
+ `;
+
+ const { rows } = await db.query(query, [trackId, isPublic]);
+ return rows[0] || null;
+};
+
module.exports = {
createTrack,
addTrackTags,
addTrackArtists,
getGenreIdByName,
getTagIdsByTrackId,
- findTrackByIdWithDetails
+ findTrackByIdWithDetails,
+ updateTrackVisibility
};
\ No newline at end of file
diff --git a/src/routes/tracks.routes.js b/src/routes/tracks.routes.js
index fc15764..d30fafb 100644
--- a/src/routes/tracks.routes.js
+++ b/src/routes/tracks.routes.js
@@ -18,5 +18,6 @@ router.post('/', authenticate, uploadTrackFiles .fields([
asyncHandler(controller.uploadTrack)
);
router.get('/:track_id', optionalAuthenticate, asyncHandler(controller.getTrackById));
+router.patch('/:track_id/visibility', authenticate, asyncHandler(controller.updateTrackVisibility));
module.exports = router;
diff --git a/src/services/tracks.service.js b/src/services/tracks.service.js
index d591d63..c84fe08 100644
--- a/src/services/tracks.service.js
+++ b/src/services/tracks.service.js
@@ -126,9 +126,38 @@ const getTrackById = async (trackId, requesterUserId = null) => {
return track;
};
+const updateTrackVisibility = async (trackId, userId, isPublic) => {
+ if (typeof isPublic !== 'boolean') {
+ throw new AppError('is_public must be a boolean', 400, 'VALIDATION_ERROR');
+ }
+
+ // Reuse the read method you already added for GET /tracks/:track_id
+ const track = await tracksModel.findTrackByIdWithDetails(trackId);
+
+ if (!track) {
+ throw new AppError('Track not found', 404, 'TRACK_NOT_FOUND');
+ }
+
+ if (track.user_id !== userId) {
+ throw new AppError(
+ 'You do not have permission to modify this track',
+ 403,
+ 'PERMISSION_NOT_OWNER'
+ );
+ }
+
+ const updatedTrack = await tracksModel.updateTrackVisibility(trackId, isPublic);
+
+ return {
+ track_id: updatedTrack.id,
+ is_public: updatedTrack.is_public,
+ };
+};
+
module.exports = {
uploadTrack,
- getTrackById
+ getTrackById,
+ updateTrackVisibility
};
From 6cb14e3f50ece7598c80f5fc13e484ff6fc64c71 Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Mon, 16 Mar 2026 15:42:11 +0200
Subject: [PATCH 030/535] feat(tracks): implement GET /tracks/me with
pagination and status filter
---
src/controllers/tracks.controller.js | 15 ++++-
src/models/track.model.js | 93 +++++++++++++++++++++++++++-
src/routes/tracks.routes.js | 4 +-
src/services/tracks.service.js | 30 ++++++++-
4 files changed, 138 insertions(+), 4 deletions(-)
diff --git a/src/controllers/tracks.controller.js b/src/controllers/tracks.controller.js
index 35b8f12..301d28b 100644
--- a/src/controllers/tracks.controller.js
+++ b/src/controllers/tracks.controller.js
@@ -48,8 +48,21 @@ const updateTrackVisibility = async (req, res) => {
return success(res, track, 'Track visibility updated successfully', 200);
};
+const getMyTracks = async (req, res) => {
+ const userId = req.user?.id ?? req.user?.sub ?? req.user?.user_id ?? null;
+
+ const page = req.query.page;
+ const limit = req.query.limit;
+ const status = req.query.status;
+
+ const result = await tracksService.getMyTracks(userId, { page, limit, status });
+
+ return success(res, result, 'My tracks fetched successfully', 200);
+};
+
module.exports = {
uploadTrack,
getTrackById,
- updateTrackVisibility
+ updateTrackVisibility,
+ getMyTracks
};
diff --git a/src/models/track.model.js b/src/models/track.model.js
index 8e18b14..946b9a2 100644
--- a/src/models/track.model.js
+++ b/src/models/track.model.js
@@ -156,6 +156,96 @@ const updateTrackVisibility = async (trackId, isPublic) => {
return rows[0] || null;
};
+const findMyTracks = async (userId, { limit, offset, status = null }) => {
+ const filters = ['t.user_id = $1', 't.deleted_at IS NULL'];
+ const values = [userId];
+ let nextParam = 2;
+
+ if (status) {
+ filters.push(`t.status = $${nextParam}`);
+ values.push(status);
+ nextParam += 1;
+ }
+
+ const whereClause = filters.join(' AND ');
+
+ const itemsQuery = `
+ SELECT
+ t.id,
+ t.title,
+ t.description,
+ g.name AS genre,
+ t.cover_image,
+ t.waveform_url,
+ t.audio_url,
+ t.stream_url,
+ t.preview_url,
+ t.duration,
+ t.file_size,
+ t.bitrate,
+ t.status,
+ t.is_public,
+ t.is_trending,
+ t.is_featured,
+ t.is_hidden,
+ t.user_id,
+ t.release_date,
+ t.isrc,
+ t.p_line,
+ t.buy_link,
+ t.record_label,
+ t.publisher,
+ t.explicit_content,
+ t.license_type,
+ t.enable_downloads,
+ t.enable_offline_listening,
+ t.include_in_rss_feed,
+ t.display_embed_code,
+ t.enable_app_playback,
+ t.allow_comments,
+ t.show_comments_public,
+ t.show_insights_public,
+ t.geo_restriction_type,
+ t.geo_regions,
+ t.play_count,
+ t.like_count,
+ t.comment_count,
+ t.repost_count,
+ t.created_at,
+ t.updated_at,
+ COALESCE(tag_data.tags, ARRAY[]::uuid[]) AS tags
+ FROM tracks t
+ LEFT JOIN genres g
+ ON g.id = t.genre_id
+ LEFT JOIN LATERAL (
+ SELECT array_agg(tt.tag_id ORDER BY tt.tag_id) AS tags
+ FROM track_tags tt
+ WHERE tt.track_id = t.id
+ ) tag_data ON true
+ WHERE ${whereClause}
+ ORDER BY t.created_at DESC
+ LIMIT $${nextParam} OFFSET $${nextParam + 1}
+ `;
+
+ const countQuery = `
+ SELECT COUNT(*)::int AS total
+ FROM tracks t
+ WHERE ${whereClause}
+ `;
+
+ const itemsValues = [...values, limit, offset];
+
+ const [itemsResult, countResult] = await Promise.all([
+ db.query(itemsQuery, itemsValues),
+ db.query(countQuery, values),
+ ]);
+
+ return {
+ items: itemsResult.rows,
+ total: countResult.rows[0].total,
+ };
+};
+
module.exports = {
createTrack,
addTrackTags,
@@ -163,5 +253,6 @@ module.exports = {
getGenreIdByName,
getTagIdsByTrackId,
findTrackByIdWithDetails,
- updateTrackVisibility
+ updateTrackVisibility,
+ findMyTracks
};
\ No newline at end of file
diff --git a/src/routes/tracks.routes.js b/src/routes/tracks.routes.js
index d30fafb..4e6fd46 100644
--- a/src/routes/tracks.routes.js
+++ b/src/routes/tracks.routes.js
@@ -17,7 +17,9 @@ router.post('/', authenticate, uploadTrackFiles .fields([
]),
asyncHandler(controller.uploadTrack)
);
+router.get('/me',authenticate, asyncHandler(controller.getMyTracks));
+
+
router.get('/:track_id', optionalAuthenticate, asyncHandler(controller.getTrackById));
router.patch('/:track_id/visibility', authenticate, asyncHandler(controller.updateTrackVisibility));
-
module.exports = router;
diff --git a/src/services/tracks.service.js b/src/services/tracks.service.js
index c84fe08..a3facec 100644
--- a/src/services/tracks.service.js
+++ b/src/services/tracks.service.js
@@ -154,10 +154,38 @@ const updateTrackVisibility = async (trackId, userId, isPublic) => {
};
};
+const getMyTracks = async (userId, query = {}) => {
+ const page = Math.max(parseInt(query.page, 10) || 1, 1);
+ const limit = Math.min(Math.max(parseInt(query.limit, 10) || 20, 1), 100);
+ const offset = (page - 1) * limit;
+
+ const status = query.status ?? null;
+ const allowedStatuses = ['processing', 'ready', 'failed'];
+
+ if (status && !allowedStatuses.includes(status)) {
+ throw new AppError('Invalid track status', 400, 'VALIDATION_ERROR');
+ }
+
+ const { items, total } = await tracksModel.findMyTracks(userId, {
+ limit,
+ offset,
+ status,
+ });
+
+ return {
+ items,
+ page,
+ limit,
+ total,
+ total_pages: Math.ceil(total / limit),
+ };
+};
+
module.exports = {
uploadTrack,
getTrackById,
- updateTrackVisibility
+ updateTrackVisibility,
+ getMyTracks
};
From 4616b9a120737a1c0abff0db83dbf8e8d2f26477 Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Mon, 16 Mar 2026 16:40:33 +0200
Subject: [PATCH 031/535] feat(tracks): implement permanent track deletion with
b2 cleanup
---
src/controllers/tracks.controller.js | 12 ++++-
src/models/track.model.js | 31 +++++++++++-
src/routes/tracks.routes.js | 2 +-
src/services/storage.service.js | 73 ++++++++++++++++++++++++++--
src/services/tracks.service.js | 32 +++++++++++-
5 files changed, 142 insertions(+), 8 deletions(-)
diff --git a/src/controllers/tracks.controller.js b/src/controllers/tracks.controller.js
index 301d28b..048bf3b 100644
--- a/src/controllers/tracks.controller.js
+++ b/src/controllers/tracks.controller.js
@@ -60,9 +60,19 @@ const getMyTracks = async (req, res) => {
return success(res, result, 'My tracks fetched successfully', 200);
};
+const deleteTrack = async (req, res) => {
+ const { track_id } = req.params;
+ const userId = req.user?.id ?? req.user?.sub ?? req.user?.user_id ?? null;
+
+ await tracksService.deleteTrack(track_id, userId);
+
+ return success(res, null, 'Track deleted successfully', 200);
+};
+
module.exports = {
uploadTrack,
getTrackById,
updateTrackVisibility,
- getMyTracks
+ getMyTracks,
+ deleteTrack
};
diff --git a/src/models/track.model.js b/src/models/track.model.js
index 946b9a2..3ad55d6 100644
--- a/src/models/track.model.js
+++ b/src/models/track.model.js
@@ -246,6 +246,33 @@ const findMyTracks = async (userId, { limit, offset, status = null }) => {
};
};
+const softDeleteTrack = async (trackId) => {
+ const query = `
+ UPDATE tracks
+ SET
+ deleted_at = NOW(),
+ updated_at = NOW()
+ WHERE id = $1
+ AND deleted_at IS NULL
+ RETURNING id
+ `;
+
+ const { rows } = await db.query(query, [trackId]);
+ return rows[0] || null;
+};
+
+const deleteTrackPermanently = async (trackId) => {
+ const query = `
+ DELETE FROM tracks
+ WHERE id = $1
+ AND deleted_at IS NULL
+ RETURNING id
+ `;
+
+ const { rows } = await db.query(query, [trackId]);
+ return rows[0] || null;
+};
+
module.exports = {
createTrack,
addTrackTags,
@@ -254,5 +281,7 @@ module.exports = {
getTagIdsByTrackId,
findTrackByIdWithDetails,
updateTrackVisibility,
- findMyTracks
+ findMyTracks,
+ softDeleteTrack,
+ deleteTrackPermanently
};
\ No newline at end of file
diff --git a/src/routes/tracks.routes.js b/src/routes/tracks.routes.js
index 4e6fd46..04eb51d 100644
--- a/src/routes/tracks.routes.js
+++ b/src/routes/tracks.routes.js
@@ -19,7 +19,7 @@ router.post('/', authenticate, uploadTrackFiles .fields([
);
router.get('/me',authenticate, asyncHandler(controller.getMyTracks));
-
router.get('/:track_id', optionalAuthenticate, asyncHandler(controller.getTrackById));
router.patch('/:track_id/visibility', authenticate, asyncHandler(controller.updateTrackVisibility));
+router.delete('/:track_id', authenticate, asyncHandler(controller.deleteTrack));
module.exports = router;
diff --git a/src/services/storage.service.js b/src/services/storage.service.js
index 5eaeca4..62e1161 100644
--- a/src/services/storage.service.js
+++ b/src/services/storage.service.js
@@ -1,9 +1,13 @@
-const { PutObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
+const {
+ PutObjectCommand,
+ DeleteObjectCommand,
+ ListObjectVersionsCommand,
+} = require('@aws-sdk/client-s3');
const b2 = require('../config/b2');
const env = require('../config/env');
const uploadTrack = async (file, key) => {
- await b2.send(
+ const result = await b2.send(
new PutObjectCommand({
Bucket: env.B2_BUCKET_NAME,
Key: key,
@@ -15,11 +19,12 @@ const uploadTrack = async (file, key) => {
return {
key,
url: `${env.B2_ENDPOINT}/${env.B2_BUCKET_NAME}/${key}`,
+ versionId: result.VersionId || null,
};
};
const uploadImage = async (file, key) => {
- await b2.send(
+ const result = await b2.send(
new PutObjectCommand({
Bucket: env.B2_BUCKET_NAME,
Key: key,
@@ -31,20 +36,80 @@ const uploadImage = async (file, key) => {
return {
key,
url: `${env.B2_ENDPOINT}/${env.B2_BUCKET_NAME}/${key}`,
+ versionId: result.VersionId || null,
};
};
-const deleteObject = async (key) => {
+const getKeyFromUrl = (fileUrl) => {
+ if (!fileUrl) return null;
+
+ const basePrefix = `${env.B2_ENDPOINT}/${env.B2_BUCKET_NAME}/`;
+
+ if (!fileUrl.startsWith(basePrefix)) {
+ throw new Error(`Invalid B2 object URL: ${fileUrl}`);
+ }
+
+ return decodeURIComponent(fileUrl.slice(basePrefix.length).split('?')[0]);
+};
+
+const deleteObject = async (key, versionId) => {
await b2.send(
new DeleteObjectCommand({
Bucket: env.B2_BUCKET_NAME,
Key: key,
+ VersionId: versionId,
})
);
};
+const deleteAllVersionsByUrl = async (fileUrl) => {
+ const key = getKeyFromUrl(fileUrl);
+ if (!key) return 0;
+
+ let keyMarker;
+ let versionIdMarker;
+ let deletedCount = 0;
+
+ do {
+ const response = await b2.send(
+ new ListObjectVersionsCommand({
+ Bucket: env.B2_BUCKET_NAME,
+ Prefix: key,
+ KeyMarker: keyMarker,
+ VersionIdMarker: versionIdMarker,
+ })
+ );
+
+ const versions = [
+ ...((response.Versions || []).filter((item) => item.Key === key)),
+ ...((response.DeleteMarkers || []).filter((item) => item.Key === key)),
+ ];
+
+ for (const item of versions) {
+ await deleteObject(key, item.VersionId);
+ deletedCount += 1;
+ }
+
+ keyMarker = response.IsTruncated ? response.NextKeyMarker : undefined;
+ versionIdMarker = response.IsTruncated ? response.NextVersionIdMarker : undefined;
+ } while (keyMarker);
+
+ return deletedCount;
+};
+
+const deleteManyByUrls = async (urls = []) => {
+ const uniqueUrls = [...new Set(urls.filter(Boolean))];
+
+ for (const url of uniqueUrls) {
+ await deleteAllVersionsByUrl(url);
+ }
+};
+
module.exports = {
uploadTrack,
uploadImage,
+ getKeyFromUrl,
deleteObject,
+ deleteAllVersionsByUrl,
+ deleteManyByUrls,
};
\ No newline at end of file
diff --git a/src/services/tracks.service.js b/src/services/tracks.service.js
index a3facec..194dcdf 100644
--- a/src/services/tracks.service.js
+++ b/src/services/tracks.service.js
@@ -181,11 +181,41 @@ const getMyTracks = async (userId, query = {}) => {
};
};
+const deleteTrack = async (trackId, userId) => {
+ const track = await tracksModel.findTrackByIdWithDetails(trackId);
+
+ if (!track) {
+ throw new AppError('Track not found', 404, 'TRACK_NOT_FOUND');
+ }
+
+ if (track.user_id !== userId) {
+ throw new AppError(
+ 'You do not have permission to delete this track',
+ 403,
+ 'PERMISSION_NOT_OWNER'
+ );
+ }
+ await storageService.deleteManyByUrls([
+ track.audio_url,
+ track.stream_url,
+ track.preview_url,
+ track.waveform_url,
+ track.cover_image,
+ ]);
+
+ const deleted = await tracksModel.deleteTrackPermanently(trackId);
+
+ if (!deleted) {
+ throw new AppError('Track not found', 404, 'TRACK_NOT_FOUND');
+ }
+};
+
module.exports = {
uploadTrack,
getTrackById,
updateTrackVisibility,
- getMyTracks
+ getMyTracks,
+ deleteTrack
};
From 64ef528bd58a144d0e4765bea2a4dec53811def6 Mon Sep 17 00:00:00 2001
From: Beshoy205
Date: Mon, 16 Mar 2026 17:27:31 +0200
Subject: [PATCH 032/535] feat(users): add web profiles, privacy, and content
settings service and model layers
- addWebProfile, deleteWebProfile service and model
- updatePrivacy service and model
- getMyContentSettings, updateMyContentSettings service and model
---
src/models/user.model.js | 83 +++++++++++++++++++++++++++++++++++
src/services/users.service.js | 45 +++++++++++++++++++
2 files changed, 128 insertions(+)
diff --git a/src/models/user.model.js b/src/models/user.model.js
index bbe5350..05314f6 100644
--- a/src/models/user.model.js
+++ b/src/models/user.model.js
@@ -246,6 +246,89 @@ exports.findWebProfilesByUserId = async (userId) => {
);
return rows || [];
};
+exports.findWebProfileByPlatform = async (userId, platform) => {
+ const { rows } = await db.query(
+ `SELECT id FROM web_profiles WHERE user_id = $1 AND platform = $2`,
+ [userId, platform]
+ );
+ return rows[0] || null;
+};
+
+exports.createWebProfile = async (userId, platform, url) => {
+ const { rows } = await db.query(
+ `INSERT INTO web_profiles (user_id, platform, url)
+ VALUES ($1, $2, $3)
+ RETURNING id, platform, url`,
+ [userId, platform, url]
+ );
+ return rows[0] || null;
+};
+
+exports.deleteWebProfile = async (profileId) => {
+ const { rows } = await db.query(
+ `DELETE FROM web_profiles WHERE id = $1 RETURNING id`,
+ [profileId]
+ );
+ return rows[0] || null;
+};
+
+exports.findWebProfileById = async (profileId) => {
+ const { rows } = await db.query(
+ `SELECT id, user_id, platform, url FROM web_profiles WHERE id = $1`,
+ [profileId]
+ );
+ return rows[0] || null;
+};
+
+exports.updatePrivacy = async (userId, isPrivate) => {
+ const { rows } = await db.query(
+ `UPDATE users SET is_private = $1, updated_at = now()
+ WHERE id = $2 AND deleted_at IS NULL
+ RETURNING is_private`,
+ [isPrivate, userId]
+ );
+ return rows[0] || null;
+};
+
+exports.findContentSettingsByUserId = async (userId) => {
+ const { rows } = await db.query(
+ `SELECT rss_title, rss_language, rss_category, rss_explicit,
+ rss_show_email, default_include_in_rss, default_license_type
+ FROM user_content_settings WHERE user_id = $1`,
+ [userId]
+ );
+ return rows[0] || null;
+};
+
+exports.updateContentSettings = async (userId, settings) => {
+ const allowed = ['rss_title', 'rss_language', 'rss_category', 'rss_explicit',
+ 'rss_show_email', 'default_include_in_rss', 'default_license_type'];
+ const fields = [];
+ const values = [];
+ let i = 1;
+
+ for (const key in settings) {
+ if (allowed.includes(key)) {
+ fields.push(`${key} = $${i}`);
+ values.push(settings[key]);
+ i++;
+ }
+ }
+
+ if (fields.length === 0) return null;
+ fields.push(`updated_at = now()`);
+ values.push(userId);
+
+ const { rows } = await db.query(
+ `UPDATE user_content_settings SET ${fields.join(', ')}
+ WHERE user_id = $${i}
+ RETURNING rss_title, rss_language, rss_category, rss_explicit,
+ rss_show_email, default_include_in_rss, default_license_type`,
+ values
+ );
+ return rows[0] || null;
+};
+
\ No newline at end of file
diff --git a/src/services/users.service.js b/src/services/users.service.js
index 1b30deb..02153b2 100644
--- a/src/services/users.service.js
+++ b/src/services/users.service.js
@@ -162,4 +162,49 @@ exports.deleteMyCoverPhoto = async (userId) => {
exports.getMyWebProfiles = async (userId) => {
return await userModel.findWebProfilesByUserId(userId);
};
+exports.addWebProfile = async (userId, platform, url) => {
+ const existing = await userModel.findWebProfileByPlatform(userId, platform);
+ if (existing) {
+ throw new AppError('A profile for this platform already exists.', 409, 'RESOURCE_ALREADY_EXISTS');
+ }
+ return await userModel.createWebProfile(userId, platform, url);
+};
+
+exports.deleteWebProfile = async (userId, profileId) => {
+ const profile = await userModel.findWebProfileById(profileId);
+ if (!profile) {
+ throw new AppError('Web profile not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+ if (profile.user_id !== userId) {
+ throw new AppError('You are not allowed to delete this profile.', 403, 'PERMISSION_DENIED');
+ }
+ return await userModel.deleteWebProfile(profileId);
+};
+
+exports.updatePrivacy = async (userId, isPrivate) => {
+ const user = await userModel.findById(userId);
+ if (!user) {
+ throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+ if (user.is_private === isPrivate) {
+ throw new AppError(`Profile is already ${isPrivate ? 'private' : 'public'}.`, 400, 'VALIDATION_FAILED');
+ }
+ const updated = await userModel.updatePrivacy(userId, isPrivate);
+ return updated;
+};
+
+exports.getMyContentSettings = async (userId) => {
+ return await userModel.findContentSettingsByUserId(userId);
+};
+
+exports.updateMyContentSettings = async (userId, settings) => {
+ const user = await userModel.findById(userId);
+ if (!user) {
+ throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+ const updated = await userModel.updateContentSettings(userId, settings);
+ return updated;
+};
+
+
From 78448d4ff687beee330e53ba0f5b7a80f0bb91c7 Mon Sep 17 00:00:00 2001
From: Beshoy205
Date: Mon, 16 Mar 2026 19:56:36 +0200
Subject: [PATCH 033/535] feat(users): add privacy settings, genres, and
onboarding endpoints
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- GET /me/privacy-settings — get privacy toggles
- PATCH /me/privacy-settings — update privacy toggles
- GET /me/genres — get favorite genres
- PUT /me/genres — replace favorite genres
- PATCH /me/onboarding — complete Google onboarding
---
src/controllers/users.controller.js | 48 ++++++++++++-
src/models/user.model.js | 100 ++++++++++++++++++++++++++++
src/routes/users.routes.js | 5 ++
src/services/users.service.js | 37 ++++++++++
4 files changed, 189 insertions(+), 1 deletion(-)
diff --git a/src/controllers/users.controller.js b/src/controllers/users.controller.js
index 266da84..ee19b7b 100644
--- a/src/controllers/users.controller.js
+++ b/src/controllers/users.controller.js
@@ -121,4 +121,50 @@ exports.updateMyContentSettings = async (req, res) => {
const data = await usersService.updateMyContentSettings(req.user.sub, fields);
return success(res, data, 'Content settings updated successfully.');
-};
\ No newline at end of file
+};
+
+exports.getMyPrivacySettings = async (req, res) => {
+ const data = await usersService.getMyPrivacySettings(req.user.sub);
+ return success(res, data, 'Privacy settings returned successfully.');
+};
+
+exports.updateMyPrivacySettings = async (req, res) => {
+ const fields = {};
+ if (req.body.receive_messages_from_anyone !== undefined) fields.receive_messages_from_anyone = req.body.receive_messages_from_anyone;
+ if (req.body.show_activities_in_discovery !== undefined) fields.show_activities_in_discovery = req.body.show_activities_in_discovery;
+ if (req.body.show_as_top_fan !== undefined) fields.show_as_top_fan = req.body.show_as_top_fan;
+ if (req.body.show_top_fans_on_tracks !== undefined) fields.show_top_fans_on_tracks = req.body.show_top_fans_on_tracks;
+ const data = await usersService.updateMyPrivacySettings(req.user.sub, fields);
+ return success(res, data, 'Privacy settings updated successfully.');
+
+};
+
+exports.getMyGenres = async (req, res) => {
+ const data = await usersService.getMyGenres(req.user.sub);
+ return success(res, data, 'Favorite genres returned successfully.');
+};
+
+exports.replaceMyGenres = async (req, res) => {
+ const { genres } = req.body;
+ if (!Array.isArray(genres)) {
+ throw new AppError('genres must be an array.', 400, 'VALIDATION_FAILED');
+ }
+ if (genres.length > 10) {
+ throw new AppError('Maximum of 10 genres allowed.', 400, 'VALIDATION_FAILED');
+ }
+ const data = await usersService.replaceMyGenres(req.user.sub, genres);
+ return success(res, data, 'Favorite genres updated successfully.');
+};
+
+exports.completeOnboarding = async (req, res) => {
+ const { display_name, gender, date_of_birth, bio, city, country } = req.body;
+ const data = await usersService.completeOnboarding(req.user.sub, {
+ display_name,
+ gender,
+ date_of_birth,
+ bio,
+ city,
+ country
+ });
+ return success(res, data, 'Profile onboarding completed successfully.');
+};
diff --git a/src/models/user.model.js b/src/models/user.model.js
index 05314f6..77633c4 100644
--- a/src/models/user.model.js
+++ b/src/models/user.model.js
@@ -328,6 +328,106 @@ exports.updateContentSettings = async (userId, settings) => {
);
return rows[0] || null;
};
+exports.findPrivacySettingsByUserId = async (userId) => {
+ const { rows } = await db.query(
+ `SELECT receive_messages_from_anyone, show_activities_in_discovery,
+ show_as_top_fan, show_top_fans_on_tracks
+ FROM user_privacy_settings WHERE user_id = $1`,
+ [userId]
+ );
+ return rows[0] || null;
+};
+
+exports.updatePrivacySettings = async (userId, settings) => {
+ const allowed = ['receive_messages_from_anyone', 'show_activities_in_discovery',
+ 'show_as_top_fan', 'show_top_fans_on_tracks'];
+ const fields = [];
+ const values = [];
+ let i = 1;
+
+ for (const key in settings) {
+ if (allowed.includes(key)) {
+ fields.push(`${key} = $${i}`);
+ values.push(settings[key]);
+ i++;
+ }
+ }
+
+ if (fields.length === 0) return null;
+ fields.push(`updated_at = now()`);
+ values.push(userId);
+
+ const { rows } = await db.query(
+ `UPDATE user_privacy_settings SET ${fields.join(', ')}
+ WHERE user_id = $${i}
+ RETURNING receive_messages_from_anyone, show_activities_in_discovery,
+ show_as_top_fan, show_top_fans_on_tracks`,
+ values
+ );
+ return rows[0] || null;
+};
+
+exports.findGenresByUserId = async (userId) => {
+ const { rows } = await db.query(
+ `SELECT g.id, g.name FROM user_favorite_genres ufg
+ JOIN genres g ON ufg.genre_id = g.id
+ WHERE ufg.user_id = $1`,
+ [userId]
+ );
+ return rows || [];
+};
+
+exports.replaceGenres = async (userId, genreIds) => {
+ const client = await db.getClient();
+ try {
+ await client.query('BEGIN');
+ await client.query(
+ `DELETE FROM user_favorite_genres WHERE user_id = $1`,
+ [userId]
+ );
+ for (const genreId of genreIds) {
+ await client.query(
+ `INSERT INTO user_favorite_genres (user_id, genre_id) VALUES ($1, $2)`,
+ [userId, genreId]
+ );
+ }
+ await client.query('COMMIT');
+ return await exports.findGenresByUserId(userId);
+ } catch (error) {
+ await client.query('ROLLBACK');
+ throw error;
+ } finally {
+ client.release();
+ }
+};
+
+exports.completeOnboarding = async (userId, fields) => {
+ const allowed = ['display_name', 'gender', 'date_of_birth', 'bio','city','country'];
+ const updates = [];
+ const values = [];
+ let i = 1;
+ for (const key in fields) {
+ if (allowed.includes(key)) {
+ updates.push(`${key} = $${i}`);
+ values.push(fields[key]);
+ i++;
+ }
+ }
+
+ if (updates.length === 0) return null;
+ updates.push(`updated_at = now()`);
+ values.push(userId);
+ const { rows } = await db.query(
+ `UPDATE users SET ${updates.join(', ')} WHERE id = $${i}
+ RETURNING id, email, username, display_name, first_name, last_name,
+ bio, city, country, gender, date_of_birth, role,
+ profile_picture, cover_photo, is_private, is_verified,
+ followers_count, following_count, created_at, updated_at`,
+ values
+ );
+ return rows[0] || null;
+};
+
diff --git a/src/routes/users.routes.js b/src/routes/users.routes.js
index d00dd30..39c6698 100644
--- a/src/routes/users.routes.js
+++ b/src/routes/users.routes.js
@@ -27,5 +27,10 @@ router.delete('/me/web-profiles/:profile_id', authenticate, asyncHandler(control
router.patch('/me/privacy', authenticate, asyncHandler(controller.updatePrivacy));
router.get('/me/content-settings', authenticate, asyncHandler(controller.getMyContentSettings));
router.patch('/me/content-settings', authenticate, asyncHandler(controller.updateMyContentSettings));
+router.get('/me/privacy-settings', authenticate, asyncHandler(controller.getMyPrivacySettings));
+router.patch('/me/privacy-settings', authenticate, asyncHandler(controller.updateMyPrivacySettings));
+router.get('/me/genres', authenticate, asyncHandler(controller.getMyGenres));
+router.put('/me/genres', authenticate, asyncHandler(controller.replaceMyGenres));
+router.patch('/me/onboarding', authenticate, asyncHandler(controller.completeOnboarding));
module.exports = router;
\ No newline at end of file
diff --git a/src/services/users.service.js b/src/services/users.service.js
index 02153b2..77580c2 100644
--- a/src/services/users.service.js
+++ b/src/services/users.service.js
@@ -205,6 +205,43 @@ exports.updateMyContentSettings = async (userId, settings) => {
const updated = await userModel.updateContentSettings(userId, settings);
return updated;
};
+exports.getMyPrivacySettings = async (userId) => {
+ return await userModel.findPrivacySettingsByUserId(userId);
+};
+
+exports.updateMyPrivacySettings = async (userId, settings) => {
+ const user = await userModel.findById(userId);
+ if (!user) {
+ throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+ const updated = await userModel.updatePrivacySettings(userId, settings);
+ return updated;
+};
+
+exports.getMyGenres = async (userId) => {
+ return await userModel.findGenresByUserId(userId);
+};
+
+exports.replaceMyGenres = async (userId, genreIds) => {
+ const user = await userModel.findById(userId);
+ if (!user) {
+ throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+ const updated = await userModel.replaceGenres(userId, genreIds);
+ return updated;
+};
+
+exports.completeOnboarding = async (userId, fields) => {
+ const user = await userModel.findById(userId);
+ if (!user) {
+ throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+ if (user.display_name && user.gender && user.date_of_birth) {
+ throw new AppError('Profile onboarding has already been completed.', 409, 'ONBOARDING_ALREADY_COMPLETED');
+ }
+ const updated = await userModel.completeOnboarding(userId, fields);
+ return updated;
+};
From 261d5c3c219e01af2fff643348c2cb03e6631d2d Mon Sep 17 00:00:00 2001
From: basboosa05 <166862890+basboosa05@users.noreply.github.com>
Date: Mon, 16 Mar 2026 22:40:02 +0200
Subject: [PATCH 034/535] Add pending_email column and email change flow
Add a DB migration to add/drop users.pending_email. Extend user model with setPendingEmail and applyPendingEmail (copies pending_email to email, clears it, and returns the new email). Add sendEmailChangeEmail to the mailer and export it, and update auth.service imports to include the new mailer helper. These changes implement an email-change verification flow so email updates are staged and applied only after confirmation.
---
.../20260316202248-add-pending-email.js | 33 +++++++++++++++++
src/models/user.model.js | 21 +++++++++++
src/services/auth.service.js | 7 +++-
src/utils/mailer.js | 37 +++++++++++++++++++
4 files changed, 97 insertions(+), 1 deletion(-)
create mode 100644 migrations/20260316202248-add-pending-email.js
diff --git a/migrations/20260316202248-add-pending-email.js b/migrations/20260316202248-add-pending-email.js
new file mode 100644
index 0000000..34aa980
--- /dev/null
+++ b/migrations/20260316202248-add-pending-email.js
@@ -0,0 +1,33 @@
+'use strict';
+
+let dbm;
+let type;
+let seed;
+
+/**
+ * We receive the dbmigrate dependency from dbmigrate initially.
+ * This enables us to not have to rely on NODE_PATH.
+ */
+exports.setup = function(options, seedLink) {
+ dbm = options.dbmigrate;
+ type = dbm.dataType;
+ seed = seedLink;
+};
+
+exports.up = function(db) {
+ return db.runSql(`
+ ALTER TABLE users
+ ADD COLUMN IF NOT EXISTS pending_email TEXT DEFAULT NULL;
+ `);
+};
+
+exports.down = function(db) {
+ return db.runSql(`
+ ALTER TABLE users
+ DROP COLUMN IF EXISTS pending_email;
+ `);
+};
+
+exports._meta = {
+ "version": 1
+};
diff --git a/src/models/user.model.js b/src/models/user.model.js
index fb96617..0e9af73 100644
--- a/src/models/user.model.js
+++ b/src/models/user.model.js
@@ -78,3 +78,24 @@ exports.updatePassword = async (userId, newPasswordHashed) => {
[newPasswordHashed, userId]
);
};
+
+
+// Set pending_email (called when user requests email change)
+exports.setPendingEmail = async (userId, pendingEmail) => {
+ await db.query(
+ `UPDATE users SET pending_email = $2 WHERE id = $1`,
+ [userId, pendingEmail]
+ );
+};
+
+// Apply the pending email change — copy pending_email to email, clear pending_email
+exports.applyPendingEmail = async (userId) => {
+ const { rows } = await db.query(
+ `UPDATE users
+ SET email = pending_email, pending_email = NULL
+ WHERE id = $1
+ RETURNING email`,
+ [userId]
+ );
+ return rows[0];
+};
\ No newline at end of file
diff --git a/src/services/auth.service.js b/src/services/auth.service.js
index 1adbb5f..8fba191 100644
--- a/src/services/auth.service.js
+++ b/src/services/auth.service.js
@@ -4,7 +4,12 @@ const verificationTokenModel = require('../models/verification-token.model');
const refreshTokenModel = require('../models/refresh-token.model');
const { generateSecureToken, parseDurationToSeconds } = require('../utils/token-generator');
const TOKEN_TYPES = require('../constants/token-types');
-const { sendVerificationEmail, sendPasswordResetEmail } = require('../utils/mailer');
+const {
+ sendVerificationEmail,
+ sendResendVerificationEmail,
+ sendPasswordResetEmail,
+ sendEmailChangeEmail,
+} = require('../utils/mailer');
const AppError = require('../utils/app-error');
const { signAccessToken, signRefreshToken, verifyToken } = require('../config/jwt');
const env = require('../config/env');
diff --git a/src/utils/mailer.js b/src/utils/mailer.js
index f8a1dc1..81fdeaa 100644
--- a/src/utils/mailer.js
+++ b/src/utils/mailer.js
@@ -196,8 +196,45 @@ const sendPasswordResetEmail = async (to, { displayName, token }) => {
});
};
+
+// email change verification
+const sendEmailChangeEmail = async (to, { displayName, token }) => {
+ const link = `${env.CLIENT_URL}/verify-email-change?token=${token}`;
+
+ const bodyContent = `
+
+ Confirm your new email
+
+
+ Hello ${displayName},
+
+
+ We received a request to change your Rythmify email address.
+ Click the button below to confirm and apply the change.
+
+ ${ctaButton(link, 'Confirm Email Change')}
+
+ This link expires in 1 hour.
+ If you didn't request this, no action is needed.
+
+ `;
+
+ await transporter.sendMail({
+ from: env.EMAIL_FROM,
+ to,
+ subject: 'Confirm your new Rythmify email address',
+ html: baseTemplate({
+ title: 'Confirm your new Rythmify email address',
+ previewText: 'Confirm your email change — link expires in 1 hour.',
+ bodyContent,
+ }),
+ });
+};
+
+
module.exports = {
sendVerificationEmail,
sendResendVerificationEmail,
sendPasswordResetEmail,
+ sendEmailChangeEmail,
};
\ No newline at end of file
From 9d57f9a7208ee997c48c30844684f119be7ce44f Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Mon, 16 Mar 2026 22:54:05 +0200
Subject: [PATCH 035/535] feat(tracks): add track metadata update
---
src/controllers/tracks.controller.js | 19 ++-
src/models/track.model.js | 74 ++++++++-
src/routes/tracks.routes.js | 3 +-
src/services/tracks.service.js | 237 ++++++++++++++++++++++++++-
4 files changed, 328 insertions(+), 5 deletions(-)
diff --git a/src/controllers/tracks.controller.js b/src/controllers/tracks.controller.js
index 048bf3b..4168051 100644
--- a/src/controllers/tracks.controller.js
+++ b/src/controllers/tracks.controller.js
@@ -69,10 +69,27 @@ const deleteTrack = async (req, res) => {
return success(res, null, 'Track deleted successfully', 200);
};
+const updateTrack = async (req, res) => {
+ const userId = req.user?.sub || req.user?.id || req.user?.user_id;
+
+ const updatedTrack = await tracksService.updateTrack({
+ trackId: req.params.track_id,
+ userId,
+ payload: req.body,
+ coverImageFile: req.file || null,
+ });
+
+ return res.status(200).json({
+ success: true,
+ data: updatedTrack,
+ });
+};
+
module.exports = {
uploadTrack,
getTrackById,
updateTrackVisibility,
getMyTracks,
- deleteTrack
+ deleteTrack,
+ updateTrack,
};
diff --git a/src/models/track.model.js b/src/models/track.model.js
index 3ad55d6..802d5bd 100644
--- a/src/models/track.model.js
+++ b/src/models/track.model.js
@@ -273,6 +273,76 @@ const deleteTrackPermanently = async (trackId) => {
return rows[0] || null;
};
+const updateTrackFields = async (trackId, updates) => {
+ const allowedFields = [
+ 'title',
+ 'description',
+ 'genre_id',
+ 'cover_image',
+ 'is_public',
+ 'buy_link',
+ 'record_label',
+ 'publisher',
+ 'release_date',
+ 'isrc',
+ 'p_line',
+ 'license_type',
+ 'explicit_content',
+ 'enable_downloads',
+ 'enable_offline_listening',
+ 'include_in_rss_feed',
+ 'display_embed_code',
+ 'enable_app_playback',
+ 'allow_comments',
+ 'show_comments_public',
+ 'show_insights_public',
+ 'geo_restriction_type',
+ 'geo_regions',
+ ];
+
+ const entries = Object.entries(updates).filter(
+ ([key, value]) => allowedFields.includes(key) && value !== undefined
+ );
+
+ if (!entries.length) return null;
+
+ const setClauses = entries.map(([key], index) => `"${key}" = $${index + 2}`);
+ const values = [
+ trackId,
+ ...entries.map(([key, value]) =>
+ key === 'geo_regions' ? JSON.stringify(value) : value
+ ),
+];
+
+ const query = `
+ UPDATE tracks
+ SET ${setClauses.join(', ')}
+ WHERE id = $1 AND deleted_at IS NULL
+ RETURNING *;
+ `;
+
+ const { rows } = await db.query(query, values);
+ return rows[0] || null;
+};
+
+const replaceTrackTags = async (trackId, tagIds) => {
+ await db.query(
+ `DELETE FROM track_tags WHERE track_id = $1`,
+ [trackId]
+ );
+
+ if (!tagIds || !tagIds.length) {
+ return;
+ }
+
+ for (const tagId of tagIds) {
+ await db.query(
+ `INSERT INTO track_tags (track_id, tag_id) VALUES ($1, $2)`,
+ [trackId, tagId]
+ );
+ }
+};
+
module.exports = {
createTrack,
addTrackTags,
@@ -283,5 +353,7 @@ module.exports = {
updateTrackVisibility,
findMyTracks,
softDeleteTrack,
- deleteTrackPermanently
+ deleteTrackPermanently,
+ updateTrackFields,
+ replaceTrackTags
};
\ No newline at end of file
diff --git a/src/routes/tracks.routes.js b/src/routes/tracks.routes.js
index 04eb51d..dc6e7bb 100644
--- a/src/routes/tracks.routes.js
+++ b/src/routes/tracks.routes.js
@@ -7,7 +7,7 @@ const express = require('express');
const router = express.Router();
const controller = require('../controllers/tracks.controller');
const { authenticate, optionalAuthenticate } = require('../middleware/auth');
-const { uploadTrackFiles } = require('../middleware/multer.js');
+const { uploadTrackFiles, uploadImage } = require('../middleware/multer.js');
const asyncHandler = require('../utils/async-handler');
// upload track
@@ -22,4 +22,5 @@ router.get('/me',authenticate, asyncHandler(controller.getMyTracks));
router.get('/:track_id', optionalAuthenticate, asyncHandler(controller.getTrackById));
router.patch('/:track_id/visibility', authenticate, asyncHandler(controller.updateTrackVisibility));
router.delete('/:track_id', authenticate, asyncHandler(controller.deleteTrack));
+router.patch('/:track_id', authenticate, uploadImage.single('cover_image'), asyncHandler(controller.updateTrack));
module.exports = router;
diff --git a/src/services/tracks.service.js b/src/services/tracks.service.js
index 194dcdf..ac37989 100644
--- a/src/services/tracks.service.js
+++ b/src/services/tracks.service.js
@@ -210,12 +210,245 @@ const deleteTrack = async (trackId, userId) => {
}
};
+// does not update cover image since req comes as json... to be handled later
+const updateTrack = async ({trackId, userId, payload, coverImageFile}) => {
+ const track = await tracksModel.findTrackByIdWithDetails(trackId);
+
+ if (!track) {
+ throw new AppError('Track not found', 404, 'TRACK_NOT_FOUND');
+ }
+
+ if (track.user_id !== userId) {
+ throw new AppError(
+ 'You do not have permission to modify this track',
+ 403,
+ 'PERMISSION_NOT_OWNER'
+ );
+ }
+
+ if (!payload || typeof payload !== 'object' || Object.keys(payload).length === 0) {
+ throw new AppError('No valid fields provided for update', 400, 'VALIDATION_ERROR');
+ }
+
+ let genreId;
+ if (payload.genre !== undefined) {
+ if (payload.genre === null || payload.genre === '') {
+ genreId = null;
+ } else {
+ genreId = await tracksModel.getGenreIdByName(payload.genre);
+ if (!genreId) {
+ throw new AppError('Invalid genre', 400, 'VALIDATION_FAILED');
+ }
+ }
+ }
+
+ let tagIds;
+ if (payload.tags !== undefined) {
+ tagIds = parseArray(payload.tags);
+ if (!Array.isArray(tagIds)) {
+ throw new AppError('tags must be an array', 400, 'VALIDATION_FAILED');
+ }
+ if (tagIds.length > 10) {
+ throw new AppError('Maximum 10 tags allowed', 400, 'VALIDATION_FAILED');
+ }
+ }
+
+ const updateData = {};
+
+ if (payload.title !== undefined) updateData.title = payload.title?.trim();
+ if (payload.description !== undefined) updateData.description = clean(payload.description);
+ if (payload.genre !== undefined) updateData.genre_id = genreId;
+
+ if (payload.is_public !== undefined) {
+ updateData.is_public = toBool(payload.is_public, track.is_public);
+ }
+
+ if (payload.buy_link !== undefined) updateData.buy_link = clean(payload.buy_link);
+ if (payload.record_label !== undefined) updateData.record_label = clean(payload.record_label);
+ if (payload.publisher !== undefined) updateData.publisher = clean(payload.publisher);
+ if (payload.release_date !== undefined) updateData.release_date = clean(payload.release_date);
+ if (payload.isrc !== undefined) updateData.isrc = clean(payload.isrc);
+ if (payload.p_line !== undefined) updateData.p_line = clean(payload.p_line);
+ if (payload.license_type !== undefined) updateData.license_type = clean(payload.license_type);
+
+ if (payload.explicit_content !== undefined) {
+ updateData.explicit_content = toBool(payload.explicit_content, track.explicit_content);
+ }
+
+ if (payload.enable_downloads !== undefined) {
+ updateData.enable_downloads = toBool(payload.enable_downloads, track.enable_downloads);
+ }
+
+ if (payload.enable_offline_listening !== undefined) {
+ updateData.enable_offline_listening = toBool(
+ payload.enable_offline_listening,
+ track.enable_offline_listening
+ );
+ }
+
+ if (payload.include_in_rss_feed !== undefined) {
+ updateData.include_in_rss_feed = toBool(
+ payload.include_in_rss_feed,
+ track.include_in_rss_feed
+ );
+ }
+
+ if (payload.display_embed_code !== undefined) {
+ updateData.display_embed_code = toBool(
+ payload.display_embed_code,
+ track.display_embed_code
+ );
+ }
+
+ if (payload.enable_app_playback !== undefined) {
+ updateData.enable_app_playback = toBool(
+ payload.enable_app_playback,
+ track.enable_app_playback
+ );
+ }
+
+ if (payload.allow_comments !== undefined) {
+ updateData.allow_comments = toBool(payload.allow_comments, track.allow_comments);
+ }
+
+ if (payload.show_comments_public !== undefined) {
+ updateData.show_comments_public = toBool(
+ payload.show_comments_public,
+ track.show_comments_public
+ );
+ }
+
+ if (payload.show_insights_public !== undefined) {
+ updateData.show_insights_public = toBool(
+ payload.show_insights_public,
+ track.show_insights_public
+ );
+ }
+
+ if (coverImageFile) {
+ const coverKey = `tracks/${userId}/covers/${Date.now()}-${coverImageFile.originalname}`;
+ const uploadedCover = await storageService.uploadImage(coverImageFile, coverKey);
+ updateData.cover_image = uploadedCover.url;
+ }
+
+ if (payload.title !== undefined) {
+ if (typeof payload.title !== 'string' || !payload.title.trim()) {
+ throw new AppError('title is required', 400, 'VALIDATION_FAILED');
+ }
+ }
+
+ if (payload.license_type !== undefined) {
+ const allowedLicenseTypes = ['all_rights_reserved', 'creative_commons'];
+ if (!allowedLicenseTypes.includes(payload.license_type)) {
+ throw new AppError('Invalid license_type', 400, 'VALIDATION_FAILED');
+ }
+ }
+
+ let geoRestrictionType;
+ if (payload.geo_restriction_type !== undefined) {
+ geoRestrictionType = clean(payload.geo_restriction_type);
+
+ // DB enum is exclusive_regions, not exclusive_region
+ const allowedGeoTypes = ['worldwide', 'exclusive_regions', 'blocked_regions'];
+ if (geoRestrictionType && !allowedGeoTypes.includes(geoRestrictionType)) {
+ throw new AppError('Invalid geo_restriction_type', 400, 'VALIDATION_FAILED');
+ }
+
+ updateData.geo_restriction_type = geoRestrictionType;
+ }
+
+ let geoRegions;
+ if (payload.geo_regions !== undefined) {
+ geoRegions = parseArray(payload.geo_regions);
+
+ if (!Array.isArray(geoRegions)) {
+ throw new AppError('geo_regions must be an array', 400, 'VALIDATION_FAILED');
+ }
+
+ if (geoRegions.length > 250) {
+ throw new AppError('Maximum 250 geo regions allowed', 400, 'VALIDATION_FAILED');
+ }
+
+ const invalidRegion = geoRegions.find(
+ (code) => typeof code !== 'string' || !/^[A-Z]{2}$/.test(code)
+ );
+
+ if (invalidRegion) {
+ throw new AppError('Invalid geo region code', 400, 'VALIDATION_FAILED');
+ }
+
+ updateData.geo_regions = geoRegions;
+ }
+
+ const finalGeoType =
+ updateData.geo_restriction_type ?? track.geo_restriction_type;
+
+ const finalGeoRegions =
+ updateData.geo_regions ?? track.geo_regions ?? [];
+
+ if (finalGeoType === 'worldwide' && finalGeoRegions.length > 0) {
+ throw new AppError(
+ 'geo_regions must be empty when geo_restriction_type is worldwide',
+ 400,
+ 'VALIDATION_FAILED'
+ );
+ }
+
+ if (
+ (finalGeoType === 'exclusive_regions' || finalGeoType === 'blocked_regions') &&
+ finalGeoRegions.length === 0
+ ) {
+ throw new AppError(
+ 'geo_regions is required for the selected geo_restriction_type',
+ 400,
+ 'VALIDATION_FAILED'
+ );
+ }
+
+ const hasScalarUpdates = Object.keys(updateData).length > 0;
+ const hasTagUpdates = payload.tags !== undefined;
+
+ if (!hasScalarUpdates && !hasTagUpdates) {
+ throw new AppError('No valid fields provided to update', 400, 'VALIDATION_FAILED');
+ }
+ const updatedRow = hasScalarUpdates
+ ? await tracksModel.updateTrackFields(trackId, updateData)
+ : track;
+
+ if (hasScalarUpdates && !updatedRow) {
+ throw new AppError('Track not found', 404, 'TRACK_NOT_FOUND');
+ }
+
+
+ if (hasTagUpdates) {
+ await tracksModel.replaceTrackTags(trackId, tagIds);
+ }
+
+ const finalTrack = await tracksModel.findTrackByIdWithDetails(trackId);
+
+ if (!finalTrack) {
+ throw new AppError('Track not found', 404, 'TRACK_NOT_FOUND');
+ }
+
+ if (
+ coverImageFile &&
+ track.cover_image &&
+ finalTrack.cover_image &&
+ track.cover_image !== finalTrack.cover_image
+ ) {
+ await storageService.deleteAllVersionsByUrl(track.cover_image);
+ }
+
+ return finalTrack;
+}
+
module.exports = {
- uploadTrack,
+ uploadTrack,
getTrackById,
updateTrackVisibility,
getMyTracks,
- deleteTrack
+ deleteTrack,
+ updateTrack
};
From ade1fcef4116081a1145ea6f4f2fa456cbdff90b Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Mon, 16 Mar 2026 23:08:54 +0200
Subject: [PATCH 036/535] refactor(tracks): extract shared geo validation for
upload and update
---
src/services/tracks.service.js | 155 +++++++++++++++++++--------------
1 file changed, 92 insertions(+), 63 deletions(-)
diff --git a/src/services/tracks.service.js b/src/services/tracks.service.js
index ac37989..fe17507 100644
--- a/src/services/tracks.service.js
+++ b/src/services/tracks.service.js
@@ -9,6 +9,74 @@ const AppError = require('../utils/app-error.js');
const tracksModel = require('../models/track.model.js');
const storageService = require('./storage.service.js');
+const GEO_RESTRICTION_TYPES = ['worldwide', 'exclusive_regions', 'blocked_regions'];
+
+const resolveGeoSettings = ({
+ geoRestrictionTypeInput,
+ geoRegionsInput,
+ currentGeoType = 'worldwide',
+ currentGeoRegions = [],
+}) => {
+ let geoRestrictionType =
+ geoRestrictionTypeInput !== undefined
+ ? clean(geoRestrictionTypeInput)
+ : currentGeoType;
+
+ if (!geoRestrictionType) {
+ geoRestrictionType = 'worldwide';
+ }
+
+ if (!GEO_RESTRICTION_TYPES.includes(geoRestrictionType)) {
+ throw new AppError('Invalid geo_restriction_type', 400, 'VALIDATION_FAILED');
+ }
+
+ let geoRegions =
+ geoRegionsInput !== undefined
+ ? parseArray(geoRegionsInput)
+ : currentGeoRegions;
+
+ if (!Array.isArray(geoRegions)) {
+ throw new AppError('geo_regions must be an array', 400, 'VALIDATION_FAILED');
+ }
+
+ if (geoRegions.length > 250) {
+ throw new AppError('Maximum 250 geo regions allowed', 400, 'VALIDATION_FAILED');
+ }
+
+ const invalidRegion = geoRegions.find(
+ (code) => typeof code !== 'string' || !/^[A-Z]{2}$/.test(code)
+ );
+
+ if (invalidRegion) {
+ throw new AppError('Invalid geo region code', 400, 'VALIDATION_FAILED');
+ }
+
+ if (geoRestrictionType === 'worldwide' && geoRegions.length > 0) {
+ throw new AppError(
+ 'geo_regions must be empty when geo_restriction_type is worldwide',
+ 400,
+ 'VALIDATION_FAILED'
+ );
+ }
+
+ if (
+ (geoRestrictionType === 'exclusive_regions' ||
+ geoRestrictionType === 'blocked_regions') &&
+ geoRegions.length === 0
+ ) {
+ throw new AppError(
+ 'geo_regions is required for the selected geo_restriction_type',
+ 400,
+ 'VALIDATION_FAILED'
+ );
+ }
+
+ return {
+ geo_restriction_type: geoRestrictionType,
+ geo_regions: geoRegions,
+ };
+};
+
const toBool = (v, d) => {
if (v === undefined || v === null || v === '') return d;
if (typeof v === 'boolean') return v;
@@ -51,7 +119,12 @@ const uploadTrack = async ({ user, audioFile, coverImageFile, body }) => {
coverImageUrl = uploadedCover.url;
}
-
+ const geoData = resolveGeoSettings({
+ geoRestrictionTypeInput: body.geo_restriction_type,
+ geoRegionsInput: body.geo_regions,
+ currentGeoType: 'worldwide',
+ currentGeoRegions: [],
+ });
const trackData = {
title: body.title.trim(),
@@ -81,9 +154,8 @@ const uploadTrack = async ({ user, audioFile, coverImageFile, body }) => {
allow_comments: toBool(body.allow_comments, true),
show_comments_public: toBool(body.show_comments_public, true),
show_insights_public: toBool(body.show_insights_public, true),
- geo_restriction_type: clean(body.geo_restriction_type) || 'worldwide',
- geo_regions: parseArray(body.geo_regions || '[]'),
-
+ geo_restriction_type: geoData.geo_restriction_type,
+ geo_regions: geoData.geo_regions,
user_id: userId,
};
@@ -210,7 +282,7 @@ const deleteTrack = async (trackId, userId) => {
}
};
-// does not update cover image since req comes as json... to be handled later
+// cover image can be updated when sent as multipart/form-data
const updateTrack = async ({trackId, userId, payload, coverImageFile}) => {
const track = await tracksModel.findTrackByIdWithDetails(trackId);
@@ -226,7 +298,10 @@ const updateTrack = async ({trackId, userId, payload, coverImageFile}) => {
);
}
- if (!payload || typeof payload !== 'object' || Object.keys(payload).length === 0) {
+ if (
+ (!payload || typeof payload !== 'object' || Object.keys(payload).length === 0) &&
+ !coverImageFile
+ ) {
throw new AppError('No valid fields provided for update', 400, 'VALIDATION_ERROR');
}
@@ -344,65 +419,19 @@ const updateTrack = async ({trackId, userId, payload, coverImageFile}) => {
}
}
- let geoRestrictionType;
- if (payload.geo_restriction_type !== undefined) {
- geoRestrictionType = clean(payload.geo_restriction_type);
-
- // DB enum is exclusive_regions, not exclusive_region
- const allowedGeoTypes = ['worldwide', 'exclusive_regions', 'blocked_regions'];
- if (geoRestrictionType && !allowedGeoTypes.includes(geoRestrictionType)) {
- throw new AppError('Invalid geo_restriction_type', 400, 'VALIDATION_FAILED');
- }
-
- updateData.geo_restriction_type = geoRestrictionType;
- }
-
- let geoRegions;
- if (payload.geo_regions !== undefined) {
- geoRegions = parseArray(payload.geo_regions);
-
- if (!Array.isArray(geoRegions)) {
- throw new AppError('geo_regions must be an array', 400, 'VALIDATION_FAILED');
- }
-
- if (geoRegions.length > 250) {
- throw new AppError('Maximum 250 geo regions allowed', 400, 'VALIDATION_FAILED');
- }
-
- const invalidRegion = geoRegions.find(
- (code) => typeof code !== 'string' || !/^[A-Z]{2}$/.test(code)
- );
-
- if (invalidRegion) {
- throw new AppError('Invalid geo region code', 400, 'VALIDATION_FAILED');
- }
-
- updateData.geo_regions = geoRegions;
- }
-
- const finalGeoType =
- updateData.geo_restriction_type ?? track.geo_restriction_type;
-
- const finalGeoRegions =
- updateData.geo_regions ?? track.geo_regions ?? [];
-
- if (finalGeoType === 'worldwide' && finalGeoRegions.length > 0) {
- throw new AppError(
- 'geo_regions must be empty when geo_restriction_type is worldwide',
- 400,
- 'VALIDATION_FAILED'
- );
- }
-
if (
- (finalGeoType === 'exclusive_regions' || finalGeoType === 'blocked_regions') &&
- finalGeoRegions.length === 0
+ payload.geo_restriction_type !== undefined ||
+ payload.geo_regions !== undefined
) {
- throw new AppError(
- 'geo_regions is required for the selected geo_restriction_type',
- 400,
- 'VALIDATION_FAILED'
- );
+ const geoData = resolveGeoSettings({
+ geoRestrictionTypeInput: payload.geo_restriction_type,
+ geoRegionsInput: payload.geo_regions,
+ currentGeoType: track.geo_restriction_type || 'worldwide',
+ currentGeoRegions: track.geo_regions || [],
+ });
+
+ updateData.geo_restriction_type = geoData.geo_restriction_type;
+ updateData.geo_regions = geoData.geo_regions;
}
const hasScalarUpdates = Object.keys(updateData).length > 0;
From b7564270849ac303aaceb1a8b0de03b64624432c Mon Sep 17 00:00:00 2001
From: basboosa05 <166862890+basboosa05@users.noreply.github.com>
Date: Mon, 16 Mar 2026 23:26:02 +0200
Subject: [PATCH 037/535] Add email change & resend verification flows
Add controllers, routes, and service logic to support resending verification emails and changing user emails. New controller actions: resendVerification, changeEmail, and verifyEmailChange with input validation and success/error responses. Routes updated to expose /resend-verification and /verify-email-change, and a protected /change-email endpoint using authenticate. Service layer implements resendVerification (creates verify tokens and sends email), changeEmail (sets pending_email, creates change token, sends confirmation), and verifyEmailChange (validates token, applies pending email, checks for conflicts).
Tokens have explicit expiries; captcha verification is noted but left commented for frontend integration.
---
src/controllers/auth.controller.js | 54 ++++++++++++++++++++
src/routes/auth.routes.js | 6 ++-
src/services/auth.service.js | 82 ++++++++++++++++++++++++++++++
3 files changed, 141 insertions(+), 1 deletion(-)
diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js
index 02e3ed7..c17893e 100644
--- a/src/controllers/auth.controller.js
+++ b/src/controllers/auth.controller.js
@@ -174,3 +174,57 @@ exports.resetPassword = async (req, res) => {
await authService.resetPassword({ token: token.trim(), new_password, logout_all });
return success(res, { success: true }, 'Password has been reset successfully.');
};
+
+
+exports.resendVerification = async (req, res) => {
+ const { email, captcha_token } = req.body;
+
+ if (!email || typeof email !== 'string') {
+ return error(res, 'VALIDATION_FAILED', 'Validation failed', 400, [
+ { field: 'email', issue: 'Email is required' },
+ ]);
+ }
+
+ await authService.resendVerification({ email: email.trim().toLowerCase(), captcha_token });
+
+ return success(
+ res,
+ { success: true },
+ "If this email is registered, you'll receive a new verification link shortly."
+ );
+};
+
+exports.changeEmail = async (req, res) => {
+ const { new_email } = req.body;
+
+ if (!new_email || typeof new_email !== 'string' || !isValidEmail(new_email)) {
+ return error(res, 'VALIDATION_FAILED', 'Validation failed', 400, [
+ { field: 'new_email', issue: 'Must be a valid email address' },
+ ]);
+ }
+
+ await authService.changeEmail({
+ userId: req.user.sub,
+ new_email: new_email.trim().toLowerCase(),
+ });
+
+ return success(
+ res,
+ { success: true },
+ 'Verification email sent to the new address.'
+ );
+};
+
+exports.verifyEmailChange = async (req, res) => {
+ const { token } = req.body;
+
+ if (!token || typeof token !== 'string') {
+ return error(res, 'VALIDATION_FAILED', 'Validation failed', 400, [
+ { field: 'token', issue: 'Token is required' },
+ ]);
+ }
+
+ const data = await authService.verifyEmailChange({ token });
+
+ return success(res, data, 'Email updated successfully.');
+};
\ No newline at end of file
diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js
index ae14428..500e119 100644
--- a/src/routes/auth.routes.js
+++ b/src/routes/auth.routes.js
@@ -7,6 +7,7 @@ const express = require('express');
const router = express.Router();
const controller = require('../controllers/auth.controller');
const { authLimiter } = require('../middleware/rate-limiter');
+const {authenticate} = require('../middleware/auth');
const { validateRegister } = require('../middleware/validate-register');
const asyncHandler = require('../utils/async-handler');
@@ -16,13 +17,16 @@ router.use(authLimiter);
// POST /api/v1/auth/register
router.post('/register', validateRegister, asyncHandler(controller.register));
router.post('/verify-email', asyncHandler(controller.verifyEmail));
+router.post('/resend-verification', asyncHandler(controller.resendVerification));
router.post('/login', asyncHandler(controller.login));
router.post('/refresh', asyncHandler(controller.refresh));
router.post('/logout', asyncHandler(controller.logout));
router.post('/forgot-password', asyncHandler(controller.requestPasswordReset));
router.post('/reset-password', asyncHandler(controller.resetPassword));
+router.post('/verify-email-change', asyncHandler(controller.verifyEmailChange));
-// i didn't implement the controllers yet
+// Protected routes
+router.post('/change-email', authenticate, asyncHandler(controller.changeEmail));
// router.post('/resend-verification', asyncHandler(controller.resendVerification));
diff --git a/src/services/auth.service.js b/src/services/auth.service.js
index 8fba191..9ba7b13 100644
--- a/src/services/auth.service.js
+++ b/src/services/auth.service.js
@@ -285,3 +285,85 @@ exports.resetPassword = async ({ token, new_password, logout_all = true }) => {
await refreshTokenModel.revokeAllForUser(tokenRow.user_id);
}
};
+
+// ============================================================
+// Resend Verification Email
+// ============================================================
+exports.resendVerification = async ({ email, captcha_token }) => {
+ // await verifyCaptcha(captcha_token); uncomment upon integration with frontend
+
+ const user = await userModel.findByEmail(email);
+
+ if (!user || user.is_verified) return;
+
+ await verificationTokenModel.revokeAllForUser(user.id, TOKEN_TYPES.VERIFY_EMAIL);
+
+ const token = generateSecureToken();
+ const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
+
+ await verificationTokenModel.create({
+ user_id: user.id,
+ token,
+ type: TOKEN_TYPES.VERIFY_EMAIL,
+ expires_at: expiresAt,
+ });
+
+ await sendResendVerificationEmail(email, {
+ displayName: user.display_name,
+ token,
+ });
+};
+
+// ============================================================
+// Change Email (request)
+// ============================================================
+exports.changeEmail = async ({ userId, new_email }) => {
+ const existing = await userModel.findByEmail(new_email);
+ if (existing) {
+ throw new AppError('Email already registered', 409, 'AUTH_EMAIL_ALREADY_EXISTS');
+ }
+
+ const user = await userModel.findById(userId);
+
+ await verificationTokenModel.revokeAllForUser(userId, TOKEN_TYPES.CHANGE_EMAIL);
+
+ await userModel.setPendingEmail(userId, new_email);
+
+ // 1h expiry token
+ const token = generateSecureToken();
+ const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
+
+ await verificationTokenModel.create({
+ user_id: userId,
+ token,
+ type: TOKEN_TYPES.CHANGE_EMAIL,
+ expires_at: expiresAt,
+ });
+
+ await sendEmailChangeEmail(new_email, {
+ displayName: user.display_name,
+ token,
+ });
+};
+
+// ============================================================
+// Verify Email Change (confirm)
+// ============================================================
+exports.verifyEmailChange = async ({ token }) => {
+ const tokenRow = await verificationTokenModel.findValidToken(token, TOKEN_TYPES.CHANGE_EMAIL);
+ if (!tokenRow) {
+ throw new AppError('Email change token expired', 422, 'AUTH_TOKEN_EXPIRED');
+ }
+
+ const user = await userModel.findById(tokenRow.user_id);
+
+ const conflict = await userModel.findByEmail(user.pending_email);
+ if (conflict) {
+ throw new AppError('Email already registered', 409, 'AUTH_EMAIL_ALREADY_EXISTS');
+ }
+
+ await verificationTokenModel.markUsed(tokenRow.id);
+ const updated = await userModel.applyPendingEmail(tokenRow.user_id);
+
+ return { email: updated.email };
+};
\ No newline at end of file
From c403e08c50a78d29b6ba218fd08e20d83171dec1 Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Tue, 17 Mar 2026 00:07:52 +0200
Subject: [PATCH 038/535] feat(tracks): add track metadata patch and stream URL
endpoint
---
src/controllers/tracks.controller.js | 15 +++++
src/routes/tracks.routes.js | 18 ++++--
src/services/tracks.service.js | 89 +++++++++++++++-------------
3 files changed, 78 insertions(+), 44 deletions(-)
diff --git a/src/controllers/tracks.controller.js b/src/controllers/tracks.controller.js
index 4168051..957a14f 100644
--- a/src/controllers/tracks.controller.js
+++ b/src/controllers/tracks.controller.js
@@ -85,6 +85,20 @@ const updateTrack = async (req, res) => {
});
};
+const getTrackStream = async (req, res) => {
+ const requesterUserId = req.user?.sub || req.user?.id || req.user?.user_id || null;
+
+ const data = await tracksService.getTrackStream(
+ req.params.track_id,
+ requesterUserId
+ );
+
+ return res.status(200).json({
+ success: true,
+ data,
+ });
+};
+
module.exports = {
uploadTrack,
getTrackById,
@@ -92,4 +106,5 @@ module.exports = {
getMyTracks,
deleteTrack,
updateTrack,
+ getTrackStream
};
diff --git a/src/routes/tracks.routes.js b/src/routes/tracks.routes.js
index dc6e7bb..27d8754 100644
--- a/src/routes/tracks.routes.js
+++ b/src/routes/tracks.routes.js
@@ -7,20 +7,30 @@ const express = require('express');
const router = express.Router();
const controller = require('../controllers/tracks.controller');
const { authenticate, optionalAuthenticate } = require('../middleware/auth');
-const { uploadTrackFiles, uploadImage } = require('../middleware/multer.js');
+const { uploadTrackFiles, uploadImage } = require('../middleware/multer.js');
const asyncHandler = require('../utils/async-handler');
// upload track
-router.post('/', authenticate, uploadTrackFiles .fields([
+router.post(
+ '/',
+ authenticate,
+ uploadTrackFiles.fields([
{ name: 'audio_file', maxCount: 1 },
{ name: 'cover_image', maxCount: 1 },
]),
asyncHandler(controller.uploadTrack)
);
-router.get('/me',authenticate, asyncHandler(controller.getMyTracks));
+router.get('/me', authenticate, asyncHandler(controller.getMyTracks));
router.get('/:track_id', optionalAuthenticate, asyncHandler(controller.getTrackById));
router.patch('/:track_id/visibility', authenticate, asyncHandler(controller.updateTrackVisibility));
router.delete('/:track_id', authenticate, asyncHandler(controller.deleteTrack));
-router.patch('/:track_id', authenticate, uploadImage.single('cover_image'), asyncHandler(controller.updateTrack));
+router.patch(
+ '/:track_id',
+ authenticate,
+ uploadImage.single('cover_image'),
+ asyncHandler(controller.updateTrack)
+);
+router.get('/:track_id/stream', optionalAuthenticate, asyncHandler(controller.getTrackStream));
+
module.exports = router;
diff --git a/src/services/tracks.service.js b/src/services/tracks.service.js
index fe17507..7327231 100644
--- a/src/services/tracks.service.js
+++ b/src/services/tracks.service.js
@@ -18,9 +18,7 @@ const resolveGeoSettings = ({
currentGeoRegions = [],
}) => {
let geoRestrictionType =
- geoRestrictionTypeInput !== undefined
- ? clean(geoRestrictionTypeInput)
- : currentGeoType;
+ geoRestrictionTypeInput !== undefined ? clean(geoRestrictionTypeInput) : currentGeoType;
if (!geoRestrictionType) {
geoRestrictionType = 'worldwide';
@@ -30,10 +28,7 @@ const resolveGeoSettings = ({
throw new AppError('Invalid geo_restriction_type', 400, 'VALIDATION_FAILED');
}
- let geoRegions =
- geoRegionsInput !== undefined
- ? parseArray(geoRegionsInput)
- : currentGeoRegions;
+ const geoRegions = geoRegionsInput !== undefined ? parseArray(geoRegionsInput) : currentGeoRegions;
if (!Array.isArray(geoRegions)) {
throw new AppError('geo_regions must be an array', 400, 'VALIDATION_FAILED');
@@ -60,8 +55,7 @@ const resolveGeoSettings = ({
}
if (
- (geoRestrictionType === 'exclusive_regions' ||
- geoRestrictionType === 'blocked_regions') &&
+ (geoRestrictionType === 'exclusive_regions' || geoRestrictionType === 'blocked_regions') &&
geoRegions.length === 0
) {
throw new AppError(
@@ -86,7 +80,11 @@ const toBool = (v, d) => {
const parseArray = (v) => {
if (!v) return [];
if (Array.isArray(v)) return v;
- try { return JSON.parse(v); } catch { return []; }
+ try {
+ return JSON.parse(v);
+ } catch {
+ return [];
+ }
};
const clean = (v) => (v === undefined || v === null || v === '' ? null : v);
@@ -108,7 +106,6 @@ const uploadTrack = async ({ user, audioFile, coverImageFile, body }) => {
}
}
-
const audioKey = `tracks/${userId}/${Date.now()}-${audioFile.originalname}`;
const uploadedAudio = await storageService.uploadTrack(audioFile, audioKey);
@@ -161,18 +158,17 @@ const uploadTrack = async ({ user, audioFile, coverImageFile, body }) => {
const createdTrack = await tracksModel.createTrack(trackData);
-
if (tagIds.length) {
await tracksModel.addTrackTags(createdTrack.id, tagIds);
}
-
+
await tracksModel.addTrackArtists(createdTrack.id, [userId]);
-
+
const tags = await tracksModel.getTagIdsByTrackId(createdTrack.id);
return {
- ...createdTrack,
- tags,
+ ...createdTrack,
+ tags,
};
};
@@ -283,7 +279,7 @@ const deleteTrack = async (trackId, userId) => {
};
// cover image can be updated when sent as multipart/form-data
-const updateTrack = async ({trackId, userId, payload, coverImageFile}) => {
+const updateTrack = async ({ trackId, userId, payload, coverImageFile }) => {
const track = await tracksModel.findTrackByIdWithDetails(trackId);
if (!track) {
@@ -362,24 +358,15 @@ const updateTrack = async ({trackId, userId, payload, coverImageFile}) => {
}
if (payload.include_in_rss_feed !== undefined) {
- updateData.include_in_rss_feed = toBool(
- payload.include_in_rss_feed,
- track.include_in_rss_feed
- );
+ updateData.include_in_rss_feed = toBool(payload.include_in_rss_feed, track.include_in_rss_feed);
}
if (payload.display_embed_code !== undefined) {
- updateData.display_embed_code = toBool(
- payload.display_embed_code,
- track.display_embed_code
- );
+ updateData.display_embed_code = toBool(payload.display_embed_code, track.display_embed_code);
}
if (payload.enable_app_playback !== undefined) {
- updateData.enable_app_playback = toBool(
- payload.enable_app_playback,
- track.enable_app_playback
- );
+ updateData.enable_app_playback = toBool(payload.enable_app_playback, track.enable_app_playback);
}
if (payload.allow_comments !== undefined) {
@@ -419,10 +406,7 @@ const updateTrack = async ({trackId, userId, payload, coverImageFile}) => {
}
}
- if (
- payload.geo_restriction_type !== undefined ||
- payload.geo_regions !== undefined
- ) {
+ if (payload.geo_restriction_type !== undefined || payload.geo_regions !== undefined) {
const geoData = resolveGeoSettings({
geoRestrictionTypeInput: payload.geo_restriction_type,
geoRegionsInput: payload.geo_regions,
@@ -441,14 +425,13 @@ const updateTrack = async ({trackId, userId, payload, coverImageFile}) => {
throw new AppError('No valid fields provided to update', 400, 'VALIDATION_FAILED');
}
const updatedRow = hasScalarUpdates
- ? await tracksModel.updateTrackFields(trackId, updateData)
- : track;
+ ? await tracksModel.updateTrackFields(trackId, updateData)
+ : track;
if (hasScalarUpdates && !updatedRow) {
throw new AppError('Track not found', 404, 'TRACK_NOT_FOUND');
}
-
if (hasTagUpdates) {
await tracksModel.replaceTrackTags(trackId, tagIds);
}
@@ -469,18 +452,44 @@ const updateTrack = async ({trackId, userId, payload, coverImageFile}) => {
}
return finalTrack;
-}
+};
-module.exports = {
+// doesn't enforce geo restrictions yet
+const getTrackStream = async (trackId, requesterUserId = null) => {
+ const track = await getTrackById(trackId, requesterUserId);
+
+ // uncomment when audio processing is implemented
+ // if (track.status === 'processing') {
+ // throw new AppError('Track is still processing', 202, 'TRACK_PROCESSING');
+ // }
+
+ if (track.status === 'failed') {
+ throw new AppError('Track processing failed', 503, 'UPLOAD_PROCESSING_FAILED');
+ }
+
+ // For now, we return the audio_url as stream_url since processing is not implemented yet
+ const playableUrl = track.stream_url || track.audio_url;
+
+ if (!playableUrl) {
+ throw new AppError('No playable audio available', 500, 'STREAM_URL_MISSING');
+ }
+
+ return {
+ track_id: track.id,
+ stream_url: playableUrl,
+ };
+};
+
+module.exports = {
uploadTrack,
getTrackById,
updateTrackVisibility,
getMyTracks,
deleteTrack,
- updateTrack
+ updateTrack,
+ getTrackStream,
};
-
//
// TODO Add track upload limit validations based on subscription plan
//
From 04467fade00cb229e244a4129eecfe8ccd46a6d7 Mon Sep 17 00:00:00 2001
From: basboosa05 <166862890+basboosa05@users.noreply.github.com>
Date: Tue, 17 Mar 2026 00:28:31 +0200
Subject: [PATCH 039/535] Seed conversations, messages, blocks, preferences
Append seed data to seeds/seed.sql: adds conversation records (enforcing user_a_id < user_b_id), sample messages (triggers update last_message_at and validate sender), block relationships, and per-user notification_preferences rows using migration defaults. Provides realistic sample messaging content and read flags for development/testing.
---
seeds/seed.sql | 128 +++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 128 insertions(+)
diff --git a/seeds/seed.sql b/seeds/seed.sql
index fcfbc90..4588193 100644
--- a/seeds/seed.sql
+++ b/seeds/seed.sql
@@ -638,4 +638,132 @@ INSERT INTO user_content_settings (user_id, rss_title, rss_language, default_lic
('c1000000-0000-0000-0000-000000000007', 'Sara Builds RSS', 'en', 'creative_commons'),
('c1000000-0000-0000-0000-000000000008', 'Tariq Talks RSS', 'en', 'creative_commons');
+
+
+-- 22. CONVERSATIONS
+-- Note: user_a_id MUST be < user_b_id (enforced by DB constraint)
+INSERT INTO conversations (id, user_a_id, user_b_id) VALUES
+ ('e2000000-0000-0000-0000-000000000001',
+ 'c1000000-0000-0000-0000-000000000001',
+ 'c2000000-0000-0000-0000-000000000001'),
+
+ ('e2000000-0000-0000-0000-000000000002',
+ 'c1000000-0000-0000-0000-000000000003',
+ 'c2000000-0000-0000-0000-000000000002'),
+
+ ('e2000000-0000-0000-0000-000000000003',
+ 'c1000000-0000-0000-0000-000000000005',
+ 'c2000000-0000-0000-0000-000000000003'),
+
+ ('e2000000-0000-0000-0000-000000000004',
+ 'c1000000-0000-0000-0000-000000000007',
+ 'c2000000-0000-0000-0000-000000000004'),
+
+ ('e2000000-0000-0000-0000-000000000005',
+ 'c1000000-0000-0000-0000-000000000006',
+ 'c2000000-0000-0000-0000-000000000005'),
+
+ ('e2000000-0000-0000-0000-000000000006',
+ 'c1000000-0000-0000-0000-000000000008',
+ 'c2000000-0000-0000-0000-000000000006');
+
+
+-- 23. MESSAGES
+-- trg_conversation_last_message_at trigger updates last_message_at automatically
+-- trg_messages_validate_sender ensures sender is a participant in the conversation
+
+INSERT INTO messages (id, conversation_id, sender_id, content, is_read) VALUES
+
+ -- Basel Mones <-> Ali Hassan
+ ('f2000000-0000-0000-0000-000000000001',
+ 'e2000000-0000-0000-0000-000000000001',
+ 'c2000000-0000-0000-0000-000000000001',
+ 'Hey Basel, your recitation is absolutely beautiful. Keep it up!', true),
+
+ ('f2000000-0000-0000-0000-000000000002',
+ 'e2000000-0000-0000-0000-000000000001',
+ 'c1000000-0000-0000-0000-000000000001',
+ 'Thank you so much Ali, that really means a lot to me!', true),
+
+ ('f2000000-0000-0000-0000-000000000003',
+ 'e2000000-0000-0000-0000-000000000001',
+ 'c2000000-0000-0000-0000-000000000001',
+ 'When are you dropping a new recitation?', false),
+
+ -- Maryam <-> Sara Mostafa
+ ('f2000000-0000-0000-0000-000000000004',
+ 'e2000000-0000-0000-0000-000000000002',
+ 'c2000000-0000-0000-0000-000000000002',
+ 'The episode about work-life balance really spoke to me!', true),
+
+ ('f2000000-0000-0000-0000-000000000005',
+ 'e2000000-0000-0000-0000-000000000002',
+ 'c1000000-0000-0000-0000-000000000003',
+ 'So glad it helped Sara! More episodes coming soon.', true),
+
+ -- Layla Mindset <-> Jake Williams
+ ('f2000000-0000-0000-0000-000000000006',
+ 'e2000000-0000-0000-0000-000000000003',
+ 'c2000000-0000-0000-0000-000000000003',
+ 'Hey Layla, the morning habit episode completely changed my routine!', true),
+
+ ('f2000000-0000-0000-0000-000000000007',
+ 'e2000000-0000-0000-0000-000000000003',
+ 'c1000000-0000-0000-0000-000000000005',
+ 'That means everything to me, thank you Jake!', false),
+
+ -- Coach Karim <-> Omar Al-Farsi
+ ('f2000000-0000-0000-0000-000000000008',
+ 'e2000000-0000-0000-0000-000000000005',
+ 'c2000000-0000-0000-0000-000000000005',
+ 'Coach, any advice for someone just starting to train during Ramadan?', true),
+
+ ('f2000000-0000-0000-0000-000000000009',
+ 'e2000000-0000-0000-0000-000000000005',
+ 'c1000000-0000-0000-0000-000000000006',
+ 'Check out my Ramadan training episode, it covers everything you need!', false),
+
+ -- Tariq Talks <-> Nour Khalil
+ ('f2000000-0000-0000-0000-000000000010',
+ 'e2000000-0000-0000-0000-000000000006',
+ 'c2000000-0000-0000-0000-000000000006',
+ 'The Gen Z burnout episode was painfully accurate, felt seen!', true),
+
+ ('f2000000-0000-0000-0000-000000000011',
+ 'e2000000-0000-0000-0000-000000000006',
+ 'c1000000-0000-0000-0000-000000000008',
+ 'Glad it resonated! Are you doing okay though?', true),
+
+ ('f2000000-0000-0000-0000-000000000012',
+ 'e2000000-0000-0000-0000-000000000006',
+ 'c2000000-0000-0000-0000-000000000006',
+ 'Getting there slowly! Honestly your content helps a lot.', false);
+
+
+-- 24. BLOCKS
+
+INSERT INTO blocks (blocker_id, blocked_id) VALUES
+ ('c2000000-0000-0000-0000-000000000003', 'c2000000-0000-0000-0000-000000000005'),
+ ('c1000000-0000-0000-0000-000000000007', 'c2000000-0000-0000-0000-000000000006');
+
+
+-- 25. NOTIFICATION PREFERENCES
+-- One row per user, all columns use the migration defaults
+
+INSERT INTO notification_preferences (user_id) VALUES
+ ('c1000000-0000-0000-0000-000000000001'),
+ ('c1000000-0000-0000-0000-000000000002'),
+ ('c1000000-0000-0000-0000-000000000003'),
+ ('c1000000-0000-0000-0000-000000000004'),
+ ('c1000000-0000-0000-0000-000000000005'),
+ ('c1000000-0000-0000-0000-000000000006'),
+ ('c1000000-0000-0000-0000-000000000007'),
+ ('c1000000-0000-0000-0000-000000000008'),
+ ('c2000000-0000-0000-0000-000000000001'),
+ ('c2000000-0000-0000-0000-000000000002'),
+ ('c2000000-0000-0000-0000-000000000003'),
+ ('c2000000-0000-0000-0000-000000000004'),
+ ('c2000000-0000-0000-0000-000000000005'),
+ ('c2000000-0000-0000-0000-000000000006');
+
COMMIT;
\ No newline at end of file
From 4c53619e3ef9bd7380d3a4f19d04e2f166a241d8 Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Tue, 17 Mar 2026 02:52:40 +0200
Subject: [PATCH 040/535] Added unit tests for messaging module
tests\messages.controller.unit.test.js
tests\messages.service.unit.test.js
tests\messages.socket.unit.test.js
with
---
tests/messages.controller.unit.test.js | 202 ++++++++++++
tests/messages.service.unit.test.js | 437 +++++++++++++++++++++++++
tests/messages.socket.unit.test.js | 156 +++++++++
3 files changed, 795 insertions(+)
create mode 100644 tests/messages.controller.unit.test.js
create mode 100644 tests/messages.service.unit.test.js
create mode 100644 tests/messages.socket.unit.test.js
diff --git a/tests/messages.controller.unit.test.js b/tests/messages.controller.unit.test.js
new file mode 100644
index 0000000..0b4902c
--- /dev/null
+++ b/tests/messages.controller.unit.test.js
@@ -0,0 +1,202 @@
+const controller = require('../src/controllers/messages.controller');
+const messagesService = require('../src/services/messages.service');
+const api = require('../src/utils/api-response');
+
+jest.mock('../src/services/messages.service');
+jest.mock('../src/utils/api-response', () => ({
+ success: jest.fn(),
+ error: jest.fn(),
+}));
+
+const mkReq = ({ userId = 'u1', body = {}, params = {}, query = {} } = {}) => ({
+ user: { sub: userId },
+ body,
+ params,
+ query,
+});
+
+const mkRes = () => ({ status: jest.fn().mockReturnThis(), json: jest.fn() });
+
+beforeEach(() => jest.clearAllMocks());
+
+describe('messages.controller', () => {
+ describe('startConversation', () => {
+ it('returns validation error when recipient_id missing', async () => {
+ const req = mkReq({ body: { body: 'hi' } });
+ const res = mkRes();
+
+ await controller.startConversation(req, res);
+
+ expect(api.error).toHaveBeenCalledWith(
+ res,
+ 'VALIDATION_FAILED',
+ 'recipient_id is required.',
+ 400
+ );
+ expect(messagesService.startConversation).not.toHaveBeenCalled();
+ });
+
+ it('returns 201 for new conversation', async () => {
+ const req = mkReq({ body: { recipient_id: 'u2', body: 'hi' } });
+ const res = mkRes();
+ messagesService.startConversation.mockResolvedValue({
+ conversation: { id: 'c1' },
+ message: { id: 'm1' },
+ isNew: true,
+ });
+
+ await controller.startConversation(req, res);
+
+ expect(api.success).toHaveBeenCalledWith(
+ res,
+ { conversation: { id: 'c1' }, message: { id: 'm1' } },
+ 'Conversation created and first message sent.',
+ 201
+ );
+ });
+
+ it('returns 200 for existing conversation', async () => {
+ const req = mkReq({ body: { recipient_id: 'u2', body: 'hi' } });
+ const res = mkRes();
+ messagesService.startConversation.mockResolvedValue({
+ conversation: { id: 'c1' },
+ message: { id: 'm1' },
+ isNew: false,
+ });
+
+ await controller.startConversation(req, res);
+
+ expect(api.success).toHaveBeenCalledWith(
+ res,
+ { message: { id: 'm1' } },
+ 'Message sent.',
+ 200
+ );
+ });
+ });
+
+ describe('markMessageReadState', () => {
+ it('requires is_read', async () => {
+ const req = mkReq({ params: { conversationId: 'c1', messageId: 'm1' }, body: {} });
+ const res = mkRes();
+
+ await controller.markMessageReadState(req, res);
+
+ expect(api.error).toHaveBeenCalledWith(
+ res,
+ 'VALIDATION_FAILED',
+ 'is_read is required.',
+ 400
+ );
+ });
+
+ it('requires boolean is_read', async () => {
+ const req = mkReq({
+ params: { conversationId: 'c1', messageId: 'm1' },
+ body: { is_read: 'true' },
+ });
+ const res = mkRes();
+
+ await controller.markMessageReadState(req, res);
+
+ expect(api.error).toHaveBeenCalledWith(
+ res,
+ 'VALIDATION_FAILED',
+ 'is_read must be a boolean.',
+ 400
+ );
+ });
+
+ it('calls service and returns success', async () => {
+ const req = mkReq({
+ params: { conversationId: 'c1', messageId: 'm1' },
+ body: { is_read: true },
+ });
+ const res = mkRes();
+
+ messagesService.markMessageReadState.mockResolvedValue({
+ message_id: 'm1',
+ is_read: true,
+ conversation_unread_count: 0,
+ });
+
+ await controller.markMessageReadState(req, res);
+
+ expect(messagesService.markMessageReadState).toHaveBeenCalledWith({
+ conversationId: 'c1',
+ messageId: 'm1',
+ userId: 'u1',
+ isRead: true,
+ });
+
+ expect(api.success).toHaveBeenCalled();
+ });
+ });
+
+ it('listConversations returns success', async () => {
+ const req = mkReq({ query: { page: '2', limit: '8' } });
+ const res = mkRes();
+ messagesService.listConversations.mockResolvedValue({ items: [], pagination: {} });
+
+ await controller.listConversations(req, res);
+
+ expect(api.success).toHaveBeenCalled();
+ });
+
+ it('getConversation returns success', async () => {
+ const req = mkReq({ params: { conversationId: 'c1' }, query: { page: '1', limit: '20' } });
+ const res = mkRes();
+ messagesService.getConversation.mockResolvedValue({ conversation: {}, messages: [], pagination: {} });
+
+ await controller.getConversation(req, res);
+
+ expect(api.success).toHaveBeenCalled();
+ });
+
+ it('sendMessage returns success', async () => {
+ const req = mkReq({ params: { conversationId: 'c1' }, body: { body: 'hi' } });
+ const res = mkRes();
+ messagesService.sendMessage.mockResolvedValue({ id: 'm1' });
+
+ await controller.sendMessage(req, res);
+
+ expect(api.success).toHaveBeenCalledWith(res, { id: 'm1' }, 'Message sent.', 201);
+ });
+
+ it('getUnreadCount returns success', async () => {
+ const req = mkReq();
+ const res = mkRes();
+ messagesService.getUnreadCount.mockResolvedValue({ unread_count: 4 });
+
+ await controller.getUnreadCount(req, res);
+
+ expect(api.success).toHaveBeenCalled();
+ });
+
+ it('deleteMessage returns success', async () => {
+ const req = mkReq({ params: { conversationId: 'c1', messageId: 'm1' } });
+ const res = mkRes();
+
+ await controller.deleteMessage(req, res);
+
+ expect(messagesService.deleteMessage).toHaveBeenCalledWith({
+ conversationId: 'c1',
+ messageId: 'm1',
+ userId: 'u1',
+ });
+ expect(api.success).toHaveBeenCalledWith(res, null, 'Message deleted.');
+ });
+
+ it('deleteConversation returns success', async () => {
+ const req = mkReq({ params: { conversationId: 'c1' } });
+ const res = mkRes();
+
+ await controller.deleteConversation(req, res);
+
+ expect(messagesService.deleteConversation).toHaveBeenCalledWith({
+ conversationId: 'c1',
+ userId: 'u1',
+ });
+ expect(api.success).toHaveBeenCalledWith(res, null, 'Conversation deleted.');
+ });
+});
\ No newline at end of file
diff --git a/tests/messages.service.unit.test.js b/tests/messages.service.unit.test.js
new file mode 100644
index 0000000..a7ada94
--- /dev/null
+++ b/tests/messages.service.unit.test.js
@@ -0,0 +1,437 @@
+const service = require('../src/services/messages.service');
+const model = require('../src/models/message.model');
+const AppError = require('../src/utils/app-error');
+
+jest.mock('../src/models/message.model');
+
+beforeEach(() => jest.clearAllMocks());
+
+const baseConversation = {
+ id: 'c1',
+ user_a_id: 'u1',
+ user_b_id: 'u2',
+ deleted_by_a: false,
+ deleted_by_b: false,
+ created_at: '2026-01-01',
+ last_message_at: '2026-01-01',
+};
+
+describe('messages.service', () => {
+ describe('startConversation', () => {
+ it('rejects self message', async () => {
+ await expect(
+ service.startConversation({ senderId: 'u1', recipientId: 'u1', body: 'x' })
+ ).rejects.toBeInstanceOf(AppError);
+ });
+
+ it('rejects when recipient missing', async () => {
+ model.findActiveUserById.mockResolvedValue(null);
+ await expect(
+ service.startConversation({ senderId: 'u1', recipientId: 'u2', body: 'x' })
+ ).rejects.toMatchObject({ code: 'USER_NOT_FOUND', statusCode: 404 });
+ });
+
+ it('rejects blocked', async () => {
+ model.findActiveUserById.mockResolvedValue({ id: 'u2' });
+ model.isBlocked.mockResolvedValue(true);
+
+ await expect(
+ service.startConversation({ senderId: 'u1', recipientId: 'u2', body: 'x' })
+ ).rejects.toMatchObject({ code: 'MESSAGES_BLOCKED' });
+ });
+
+ it('rejects followers_only when recipient not following sender', async () => {
+ model.findActiveUserById.mockResolvedValue({ id: 'u2' });
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('followers_only');
+ model.isFollowing.mockResolvedValue(false);
+
+ await expect(
+ service.startConversation({ senderId: 'u1', recipientId: 'u2', body: 'x' })
+ ).rejects.toMatchObject({ code: 'MESSAGES_FOLLOWERS_ONLY' });
+ });
+
+ it('rejects empty payload', async () => {
+ model.findActiveUserById.mockResolvedValue({ id: 'u2' });
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+
+ await expect(
+ service.startConversation({ senderId: 'u1', recipientId: 'u2', body: ' ', resource: null })
+ ).rejects.toMatchObject({ code: 'MESSAGES_EMPTY' });
+ });
+
+ it('rejects body too long', async () => {
+ model.findActiveUserById.mockResolvedValue({ id: 'u2' });
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+
+ await expect(
+ service.startConversation({ senderId: 'u1', recipientId: 'u2', body: 'x'.repeat(2001) })
+ ).rejects.toMatchObject({ code: 'MESSAGES_BODY_TOO_LONG' });
+ });
+
+ it('rejects invalid embed type', async () => {
+ model.findActiveUserById.mockResolvedValue({ id: 'u2' });
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+
+ await expect(
+ service.startConversation({
+ senderId: 'u1',
+ recipientId: 'u2',
+ body: '',
+ resource: { type: 'album', id: 'a1' },
+ })
+ ).rejects.toMatchObject({ code: 'MESSAGES_INVALID_EMBED_TYPE' });
+ });
+
+ it('creates conversation when new', async () => {
+ model.findActiveUserById.mockResolvedValue({ id: 'u2' });
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+ model.findConversationByPair.mockResolvedValue(null);
+ model.createConversation.mockResolvedValue({ id: 'c1' });
+ model.createMessage.mockResolvedValue({ id: 'm1' });
+
+ const out = await service.startConversation({
+ senderId: 'u1',
+ recipientId: 'u2',
+ body: ' hi ',
+ });
+
+ expect(out.isNew).toBe(true);
+ expect(model.createConversation).toHaveBeenCalled();
+ expect(model.createMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ body: 'hi' })
+ );
+ });
+
+ it('uses existing conversation', async () => {
+ model.findActiveUserById.mockResolvedValue({ id: 'u2' });
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+ model.findConversationByPair.mockResolvedValue({ id: 'cExisting' });
+ model.createMessage.mockResolvedValue({ id: 'm1' });
+
+ const out = await service.startConversation({
+ senderId: 'u1',
+ recipientId: 'u2',
+ resource: { type: 'track', id: 't1' },
+ });
+
+ expect(out.isNew).toBe(false);
+ expect(model.createConversation).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('listConversations', () => {
+ it('applies pagination bounds and maps shape', async () => {
+ model.findConversationsByUserId.mockResolvedValue([
+ {
+ id: 'c1',
+ participant_id: 'u2',
+ participant_display_name: 'B',
+ participant_avatar: null,
+ participant_username: 'b',
+ last_message_id: 'm1',
+ last_message_sender_id: 'u1',
+ last_message_body: 'hello',
+ last_message_embed_type: null,
+ last_message_embed_id: null,
+ last_message_is_read: false,
+ last_message_created_at: '2026-01-01',
+ unread_count: 3,
+ created_at: '2026-01-01',
+ updated_at: '2026-01-02',
+ },
+ ]);
+ model.countConversationsByUserId.mockResolvedValue(1);
+
+ const out = await service.listConversations({ userId: 'u1', page: '0', limit: '999' });
+
+ expect(model.findConversationsByUserId).toHaveBeenCalledWith('u1', 8, 0);
+ expect(out.items[0].last_message.id).toBe('m1');
+ expect(out.pagination.total_pages).toBe(1);
+ });
+
+ it('maps null last_message', async () => {
+ model.findConversationsByUserId.mockResolvedValue([
+ {
+ id: 'c1',
+ participant_id: 'u2',
+ participant_display_name: 'B',
+ participant_avatar: null,
+ participant_username: 'b',
+ last_message_id: null,
+ unread_count: 0,
+ created_at: '2026-01-01',
+ updated_at: '2026-01-02',
+ },
+ ]);
+ model.countConversationsByUserId.mockResolvedValue(1);
+
+ const out = await service.listConversations({ userId: 'u1' });
+ expect(out.items[0].last_message).toBeNull();
+ });
+ });
+
+ describe('getConversation', () => {
+ it('404 when conversation not found', async () => {
+ model.findConversationById.mockResolvedValue(null);
+ await expect(service.getConversation({ conversationId: 'c1', userId: 'u1' }))
+ .rejects.toMatchObject({ code: 'CONVERSATION_NOT_FOUND' });
+ });
+
+ it('403 when not participant', async () => {
+ model.findConversationById.mockResolvedValue({ ...baseConversation, user_a_id: 'x', user_b_id: 'y' });
+ await expect(service.getConversation({ conversationId: 'c1', userId: 'u1' }))
+ .rejects.toMatchObject({ code: 'FORBIDDEN' });
+ });
+
+ it('404 when soft deleted by user', async () => {
+ model.findConversationById.mockResolvedValue({ ...baseConversation, deleted_by_a: true });
+ await expect(service.getConversation({ conversationId: 'c1', userId: 'u1' }))
+ .rejects.toMatchObject({ code: 'CONVERSATION_NOT_FOUND' });
+ });
+
+ it('returns conversation payload', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.findMessagesByConversationId.mockResolvedValue([{ id: 'm1' }]);
+ model.countMessagesByConversationId.mockResolvedValue(1);
+ model.findConversationPartner.mockResolvedValue({ id: 'u2' });
+ model.countUnreadMessages.mockResolvedValue(0);
+
+ const out = await service.getConversation({ conversationId: 'c1', userId: 'u1', page: '-1', limit: '999' });
+
+ expect(model.findMessagesByConversationId).toHaveBeenCalledWith('c1', 100, 0);
+ expect(out.pagination.total_pages).toBe(1);
+ });
+ });
+
+ describe('sendMessage', () => {
+ it('404 when conversation missing', async () => {
+ model.findConversationById.mockResolvedValue(null);
+ await expect(service.sendMessage({ conversationId: 'c1', senderId: 'u1', body: 'x' }))
+ .rejects.toMatchObject({ code: 'CONVERSATION_NOT_FOUND' });
+ });
+
+ it('403 when sender not participant', async () => {
+ model.findConversationById.mockResolvedValue({ ...baseConversation, user_a_id: 'x', user_b_id: 'y' });
+ await expect(service.sendMessage({ conversationId: 'c1', senderId: 'u1', body: 'x' }))
+ .rejects.toMatchObject({ code: 'FORBIDDEN' });
+ });
+
+ it('restores recipient conversation when recipient had deleted', async () => {
+ model.findConversationById.mockResolvedValue({ ...baseConversation, deleted_by_b: true });
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+ model.createMessage.mockResolvedValue({ id: 'm1' });
+
+ await service.sendMessage({ conversationId: 'c1', senderId: 'u1', body: 'x' });
+
+ expect(model.restoreConversationForUser).toHaveBeenCalledWith('c1', false);
+ });
+
+ it('blocks on blocked recipient', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.isBlocked.mockResolvedValue(true);
+
+ await expect(service.sendMessage({ conversationId: 'c1', senderId: 'u1', body: 'x' }))
+ .rejects.toMatchObject({ code: 'MESSAGES_BLOCKED' });
+ });
+
+ it('followers_only rejection', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('followers_only');
+ model.isFollowing.mockResolvedValue(false);
+
+ await expect(service.sendMessage({ conversationId: 'c1', senderId: 'u1', body: 'x' }))
+ .rejects.toMatchObject({ code: 'MESSAGES_FOLLOWERS_ONLY' });
+ });
+
+ it('empty payload rejection', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+
+ await expect(service.sendMessage({ conversationId: 'c1', senderId: 'u1', body: ' ' }))
+ .rejects.toMatchObject({ code: 'MESSAGES_EMPTY' });
+ });
+
+ it('body too long rejection', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+
+ await expect(service.sendMessage({
+ conversationId: 'c1', senderId: 'u1', body: 'x'.repeat(2001),
+ })).rejects.toMatchObject({ code: 'MESSAGES_BODY_TOO_LONG' });
+ });
+
+ it('invalid embed rejection', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+
+ await expect(service.sendMessage({
+ conversationId: 'c1', senderId: 'u1', body: '',
+ resource: { type: 'album', id: 'a1' },
+ })).rejects.toMatchObject({ code: 'MESSAGES_INVALID_EMBED_TYPE' });
+ });
+
+ it('inserts valid message', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+ model.createMessage.mockResolvedValue({ id: 'm1' });
+
+ const out = await service.sendMessage({
+ conversationId: 'c1',
+ senderId: 'u1',
+ body: ' hi ',
+ });
+
+ expect(out.id).toBe('m1');
+ expect(model.createMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ body: 'hi' })
+ );
+ });
+ });
+
+ describe('getUnreadCount', () => {
+ it('returns unread_count', async () => {
+ model.countTotalUnreadMessages.mockResolvedValue(9);
+ await expect(service.getUnreadCount({ userId: 'u1' })).resolves.toEqual({ unread_count: 9 });
+ });
+ });
+
+ describe('markMessageReadState', () => {
+ it('404 conversation missing', async () => {
+ model.findConversationById.mockResolvedValue(null);
+ await expect(service.markMessageReadState({
+ conversationId: 'c1', messageId: 'm1', userId: 'u1', isRead: true,
+ })).rejects.toMatchObject({ code: 'CONVERSATION_NOT_FOUND' });
+ });
+
+ it('403 non-participant', async () => {
+ model.findConversationById.mockResolvedValue({ ...baseConversation, user_a_id: 'x', user_b_id: 'y' });
+ await expect(service.markMessageReadState({
+ conversationId: 'c1', messageId: 'm1', userId: 'u1', isRead: true,
+ })).rejects.toMatchObject({ code: 'FORBIDDEN' });
+ });
+
+ it('404 message missing', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.findMessageById.mockResolvedValue(null);
+ await expect(service.markMessageReadState({
+ conversationId: 'c1', messageId: 'm1', userId: 'u1', isRead: true,
+ })).rejects.toMatchObject({ code: 'MESSAGE_NOT_FOUND' });
+ });
+
+ it('403 sender cannot mark own message', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.findMessageById.mockResolvedValue({ id: 'm1', sender_id: 'u1', is_read: false });
+ await expect(service.markMessageReadState({
+ conversationId: 'c1', messageId: 'm1', userId: 'u1', isRead: true,
+ })).rejects.toMatchObject({ code: 'FORBIDDEN' });
+ });
+
+ it('409 on same read state', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.findMessageById.mockResolvedValue({ id: 'm1', sender_id: 'u2', is_read: true });
+ await expect(service.markMessageReadState({
+ conversationId: 'c1', messageId: 'm1', userId: 'u1', isRead: true,
+ })).rejects.toMatchObject({ code: 'MESSAGES_READ_STATE_CONFLICT' });
+ });
+
+ it('updates read state', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.findMessageById.mockResolvedValue({ id: 'm1', sender_id: 'u2', is_read: false });
+ model.updateMessageReadState.mockResolvedValue({ id: 'm1' });
+ model.countUnreadMessages.mockResolvedValue(0);
+
+ await expect(service.markMessageReadState({
+ conversationId: 'c1', messageId: 'm1', userId: 'u1', isRead: true,
+ })).resolves.toEqual({
+ message_id: 'm1',
+ is_read: true,
+ conversation_unread_count: 0,
+ });
+ });
+ });
+
+ describe('deleteMessage', () => {
+ it('404 conversation missing', async () => {
+ model.findConversationById.mockResolvedValue(null);
+ await expect(service.deleteMessage({ conversationId: 'c1', messageId: 'm1', userId: 'u1' }))
+ .rejects.toMatchObject({ code: 'CONVERSATION_NOT_FOUND' });
+ });
+
+ it('403 non-participant', async () => {
+ model.findConversationById.mockResolvedValue({ ...baseConversation, user_a_id: 'x', user_b_id: 'y' });
+ await expect(service.deleteMessage({ conversationId: 'c1', messageId: 'm1', userId: 'u1' }))
+ .rejects.toMatchObject({ code: 'FORBIDDEN' });
+ });
+
+ it('404 message missing', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.findMessageById.mockResolvedValue(null);
+ await expect(service.deleteMessage({ conversationId: 'c1', messageId: 'm1', userId: 'u1' }))
+ .rejects.toMatchObject({ code: 'MESSAGE_NOT_FOUND' });
+ });
+
+ it('403 cannot delete others message', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.findMessageById.mockResolvedValue({ id: 'm1', sender_id: 'u2' });
+ await expect(service.deleteMessage({ conversationId: 'c1', messageId: 'm1', userId: 'u1' }))
+ .rejects.toMatchObject({ code: 'FORBIDDEN' });
+ });
+
+ it('deletes own message', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.findMessageById.mockResolvedValue({ id: 'm1', sender_id: 'u1' });
+ model.deleteMessageById.mockResolvedValue();
+
+ await service.deleteMessage({ conversationId: 'c1', messageId: 'm1', userId: 'u1' });
+ expect(model.deleteMessageById).toHaveBeenCalledWith('m1');
+ });
+ });
+
+ describe('deleteConversation', () => {
+ it('404 conversation missing', async () => {
+ model.findConversationById.mockResolvedValue(null);
+ await expect(service.deleteConversation({ conversationId: 'c1', userId: 'u1' }))
+ .rejects.toMatchObject({ code: 'CONVERSATION_NOT_FOUND' });
+ });
+
+ it('403 non-participant', async () => {
+ model.findConversationById.mockResolvedValue({ ...baseConversation, user_a_id: 'x', user_b_id: 'y' });
+ await expect(service.deleteConversation({ conversationId: 'c1', userId: 'u1' }))
+ .rejects.toMatchObject({ code: 'FORBIDDEN' });
+ });
+
+ it('404 already deleted by user A', async () => {
+ model.findConversationById.mockResolvedValue({ ...baseConversation, deleted_by_a: true });
+ await expect(service.deleteConversation({ conversationId: 'c1', userId: 'u1' }))
+ .rejects.toMatchObject({ code: 'CONVERSATION_NOT_FOUND' });
+ });
+
+ it('soft deletes for user A', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.softDeleteConversation.mockResolvedValue();
+
+ await service.deleteConversation({ conversationId: 'c1', userId: 'u1' });
+ expect(model.softDeleteConversation).toHaveBeenCalledWith('c1', true);
+ });
+
+ it('soft deletes for user B', async () => {
+ model.findConversationById.mockResolvedValue({ ...baseConversation, user_a_id: 'u2', user_b_id: 'u1' });
+ model.softDeleteConversation.mockResolvedValue();
+
+ await service.deleteConversation({ conversationId: 'c1', userId: 'u1' });
+ expect(model.softDeleteConversation).toHaveBeenCalledWith('c1', false);
+ });
+ });
+});
\ No newline at end of file
diff --git a/tests/messages.socket.unit.test.js b/tests/messages.socket.unit.test.js
new file mode 100644
index 0000000..f9f40a5
--- /dev/null
+++ b/tests/messages.socket.unit.test.js
@@ -0,0 +1,156 @@
+const db = require('../src/config/db');
+const { registerMessageHandlers } = require('../src/sockets/messages.socket');
+
+jest.mock('../src/config/db', () => ({ query: jest.fn() }));
+
+const mkSocket = (userId = '11111111-1111-1111-1111-111111111111') => {
+ const handlers = {};
+ return {
+ id: 'sock1',
+ user: { sub: userId },
+ on: jest.fn((event, cb) => { handlers[event] = cb; }),
+ emit: jest.fn(),
+ join: jest.fn(),
+ leave: jest.fn(),
+ to: jest.fn(() => ({ emit: jest.fn() })),
+ _handlers: handlers,
+ };
+};
+
+beforeEach(() => jest.clearAllMocks());
+
+describe('messages.socket', () => {
+ it('registers all message handlers', () => {
+ const socket = mkSocket();
+ registerMessageHandlers({}, socket);
+ expect(socket.on).toHaveBeenCalledWith('message:join', expect.any(Function));
+ expect(socket.on).toHaveBeenCalledWith('message:leave', expect.any(Function));
+ expect(socket.on).toHaveBeenCalledWith('message:send', expect.any(Function));
+ expect(socket.on).toHaveBeenCalledWith('message:deleted', expect.any(Function));
+ expect(socket.on).toHaveBeenCalledWith('message:read', expect.any(Function));
+ expect(socket.on).toHaveBeenCalledWith('message:typing', expect.any(Function));
+ expect(socket.on).toHaveBeenCalledWith('message:stop_typing', expect.any(Function));
+ });
+
+ it('message:join emits error on invalid conversationId', async () => {
+ const socket = mkSocket();
+ registerMessageHandlers({}, socket);
+
+ await socket._handlers['message:join']({ conversationId: 'bad' });
+
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid conversationId.' });
+ });
+
+ it('message:join emits forbidden when not participant', async () => {
+ const socket = mkSocket();
+ registerMessageHandlers({}, socket);
+ db.query.mockResolvedValue({ rows: [] });
+
+ await socket._handlers['message:join']({
+ conversationId: '22222222-2222-2222-2222-222222222222',
+ });
+
+ expect(socket.emit).toHaveBeenCalledWith('error', {
+ message: 'You are not a participant in this conversation.',
+ });
+ });
+
+ it('message:join handles db failure', async () => {
+ const socket = mkSocket();
+ registerMessageHandlers({}, socket);
+ db.query.mockRejectedValue(new Error('db down'));
+
+ await socket._handlers['message:join']({
+ conversationId: '22222222-2222-2222-2222-222222222222',
+ });
+
+ expect(socket.emit).toHaveBeenCalledWith('error', {
+ message: 'Something went wrong. Please try again.',
+ });
+ });
+
+ it('message:join joins room when authorized', async () => {
+ const socket = mkSocket();
+ registerMessageHandlers({}, socket);
+ db.query.mockResolvedValue({ rows: [{ ok: 1 }] });
+
+ await socket._handlers['message:join']({
+ conversationId: '22222222-2222-2222-2222-222222222222',
+ });
+
+ expect(socket.join).toHaveBeenCalledWith(
+ 'conversation:22222222-2222-2222-2222-222222222222'
+ );
+ });
+
+ it('message:leave leaves room', () => {
+ const socket = mkSocket();
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:leave']({ conversationId: 'c1' });
+ expect(socket.leave).toHaveBeenCalledWith('conversation:c1');
+ });
+
+ it('message:send broadcasts received', () => {
+ const socket = mkSocket();
+ const emit = jest.fn();
+ socket.to.mockReturnValue({ emit });
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:send']({ conversationId: 'c1', message: { id: 'm1' } });
+
+ expect(socket.to).toHaveBeenCalledWith('conversation:c1');
+ expect(emit).toHaveBeenCalledWith('message:received', {
+ conversationId: 'c1',
+ message: { id: 'm1' },
+ });
+ });
+
+ it('message:deleted broadcasts removed', () => {
+ const socket = mkSocket();
+ const emit = jest.fn();
+ socket.to.mockReturnValue({ emit });
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:deleted']({ conversationId: 'c1', messageId: 'm1' });
+
+ expect(emit).toHaveBeenCalledWith('message:removed', {
+ conversationId: 'c1',
+ messageId: 'm1',
+ });
+ });
+
+ it('message:read broadcasts read_updated', () => {
+ const socket = mkSocket();
+ const emit = jest.fn();
+ socket.to.mockReturnValue({ emit });
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:read']({
+ conversationId: 'c1',
+ messageId: 'm1',
+ isRead: true,
+ conversationUnreadCount: 0,
+ });
+
+ expect(emit).toHaveBeenCalledWith('message:read_updated', {
+ conversationId: 'c1',
+ messageId: 'm1',
+ isRead: true,
+ conversationUnreadCount: 0,
+ });
+ });
+
+ it('typing events broadcast', () => {
+ const socket = mkSocket();
+ const emit = jest.fn();
+ socket.to.mockReturnValue({ emit });
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:typing']({ conversationId: 'c1' });
+ expect(emit).toHaveBeenCalledWith('message:typing', { conversationId: 'c1', userId: socket.user.sub });
+
+ socket._handlers['message:stop_typing']({ conversationId: 'c1' });
+ expect(emit).toHaveBeenCalledWith('message:stop_typing', { conversationId: 'c1', userId: socket.user.sub });
+ });
+});
\ No newline at end of file
From 4f8f03a34edf1e4e25fed9a49cb2396d69f8e998 Mon Sep 17 00:00:00 2001
From: Omar Hamza
Date: Tue, 17 Mar 2026 17:43:48 +0200
Subject: [PATCH 041/535] fix(auth, users): normalize inputs and enforce
case-insensitive email/username handling
- Normalize email inputs across all auth flows (register, login, password reset):
- trim and lowercase before storing and querying
- ensure consistent case-insensitive lookups
- Normalize username handling:
- trim and lowercase usernames on profile updates
- enforce case-insensitive comparisons in queries
- prevent logical duplicates caused by casing differences
- Improve validation during registration:
- trim inputs
- reject whitespace-only values
- Add database-level protection:
- create case-insensitive unique index on LOWER(username)
- Align login identifier handling with stored data format to avoid mismatches
- Install missing dependencies (including socket.io) to fix runtime issues
---
src/controllers/auth.controller.js | 2 +-
src/controllers/users.controller.js | 51 ++++++------
src/middleware/validate-register.js | 22 +++++-
src/models/user.model.js | 115 ++++++++++++----------------
src/services/auth.service.js | 21 +++--
src/services/users.service.js | 50 +++++++-----
6 files changed, 143 insertions(+), 118 deletions(-)
diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js
index 02e3ed7..7da70e4 100644
--- a/src/controllers/auth.controller.js
+++ b/src/controllers/auth.controller.js
@@ -71,7 +71,7 @@ exports.login = async (req, res) => {
// ── Call service ────────────────────────────────────────
const result = await authService.login({
- identifier: identifier.trim().toLowerCase(),
+ identifier: identifier.trim(),
password,
});
diff --git a/src/controllers/users.controller.js b/src/controllers/users.controller.js
index ee19b7b..7465a8e 100644
--- a/src/controllers/users.controller.js
+++ b/src/controllers/users.controller.js
@@ -5,6 +5,7 @@
// ============================================================
const usersService = require('../services/users.service');
const { success } = require('../utils/api-response');
+const AppError = require('../utils/app-error');
exports.getMe = async (req, res) => {
const data = await usersService.getMe(req.user.sub);
@@ -24,41 +25,39 @@ exports.updateMe = async (req, res) => {
const fields = {};
if (req.body.display_name !== undefined) fields.display_name = req.body.display_name;
- if (req.body.username !== undefined) fields.username = req.body.username;
- if (req.body.first_name !== undefined) fields.first_name = req.body.first_name;
- if (req.body.last_name !== undefined) fields.last_name = req.body.last_name;
- if (req.body.bio !== undefined) fields.bio = req.body.bio;
- if (req.body.city !== undefined) fields.city = req.body.city;
- if (req.body.country !== undefined) fields.country = req.body.country;
+ if (req.body.username !== undefined) fields.username = req.body.username;
+ if (req.body.first_name !== undefined) fields.first_name = req.body.first_name;
+ if (req.body.last_name !== undefined) fields.last_name = req.body.last_name;
+ if (req.body.bio !== undefined) fields.bio = req.body.bio;
+ if (req.body.city !== undefined) fields.city = req.body.city;
+ if (req.body.country !== undefined) fields.country = req.body.country;
const data = await usersService.updateMe(req.user.sub, fields);
return success(res, data, 'Profile updated successfully.');
};
-
exports.updateMyAccount = async (req, res) => {
const fields = {};
if (req.body.date_of_birth !== undefined) fields.date_of_birth = req.body.date_of_birth;
- if (req.body.gender !== undefined) fields.gender = req.body.gender;
+ if (req.body.gender !== undefined) fields.gender = req.body.gender;
const data = await usersService.updateMyAccount(req.user.sub, fields);
return success(res, data, 'Account updated successfully.');
};
-
exports.switchRole = async (req, res) => {
const userId = req.user.sub;
- const {role} = req.body;
+ const { role } = req.body;
const data = await usersService.switchRole(userId, role);
-
+
return success(res, data, 'Role switched successfully.');
};
-exports.deleteMyAvatar= async(req,res) => {
- const data = await usersService.deleteMyAvatar(req.user.sub);
- return success(res, data, 'Your profile picture deleted successfully.');
+exports.deleteMyAvatar = async (req, res) => {
+ const data = await usersService.deleteMyAvatar(req.user.sub);
+ return success(res, data, 'Your profile picture deleted successfully.');
};
exports.uploadMyAvatar = async (req, res) => {
@@ -71,11 +70,11 @@ exports.uploadMyAvatar = async (req, res) => {
exports.uploadMyCoverPhoto = async (req, res) => {
if (!req.file) {
throw new AppError('No image file provided.', 400, 'VALIDATION_FAILED');
- }
+ }
const data = await usersService.uploadMyCoverPhoto(req.user.sub, req.file);
return success(res, data, 'Cover photo uploaded successfully.');
};
-exports.deleteMyCoverPhoto = async(req,res) => {
+exports.deleteMyCoverPhoto = async (req, res) => {
const data = await usersService.deleteMyCoverPhoto(req.user.sub);
return success(res, data, 'Your cover photo deleted successfully.');
};
@@ -116,8 +115,10 @@ exports.updateMyContentSettings = async (req, res) => {
if (req.body.rss_category !== undefined) fields.rss_category = req.body.rss_category;
if (req.body.rss_explicit !== undefined) fields.rss_explicit = req.body.rss_explicit;
if (req.body.rss_show_email !== undefined) fields.rss_show_email = req.body.rss_show_email;
- if (req.body.default_include_in_rss !== undefined) fields.default_include_in_rss = req.body.default_include_in_rss;
- if (req.body.default_license_type !== undefined) fields.default_license_type = req.body.default_license_type;
+ if (req.body.default_include_in_rss !== undefined)
+ fields.default_include_in_rss = req.body.default_include_in_rss;
+ if (req.body.default_license_type !== undefined)
+ fields.default_license_type = req.body.default_license_type;
const data = await usersService.updateMyContentSettings(req.user.sub, fields);
return success(res, data, 'Content settings updated successfully.');
@@ -130,13 +131,15 @@ exports.getMyPrivacySettings = async (req, res) => {
exports.updateMyPrivacySettings = async (req, res) => {
const fields = {};
- if (req.body.receive_messages_from_anyone !== undefined) fields.receive_messages_from_anyone = req.body.receive_messages_from_anyone;
- if (req.body.show_activities_in_discovery !== undefined) fields.show_activities_in_discovery = req.body.show_activities_in_discovery;
+ if (req.body.receive_messages_from_anyone !== undefined)
+ fields.receive_messages_from_anyone = req.body.receive_messages_from_anyone;
+ if (req.body.show_activities_in_discovery !== undefined)
+ fields.show_activities_in_discovery = req.body.show_activities_in_discovery;
if (req.body.show_as_top_fan !== undefined) fields.show_as_top_fan = req.body.show_as_top_fan;
- if (req.body.show_top_fans_on_tracks !== undefined) fields.show_top_fans_on_tracks = req.body.show_top_fans_on_tracks;
+ if (req.body.show_top_fans_on_tracks !== undefined)
+ fields.show_top_fans_on_tracks = req.body.show_top_fans_on_tracks;
const data = await usersService.updateMyPrivacySettings(req.user.sub, fields);
return success(res, data, 'Privacy settings updated successfully.');
-
};
exports.getMyGenres = async (req, res) => {
@@ -154,7 +157,7 @@ exports.replaceMyGenres = async (req, res) => {
}
const data = await usersService.replaceMyGenres(req.user.sub, genres);
return success(res, data, 'Favorite genres updated successfully.');
-};
+};
exports.completeOnboarding = async (req, res) => {
const { display_name, gender, date_of_birth, bio, city, country } = req.body;
@@ -164,7 +167,7 @@ exports.completeOnboarding = async (req, res) => {
date_of_birth,
bio,
city,
- country
+ country,
});
return success(res, data, 'Profile onboarding completed successfully.');
};
diff --git a/src/middleware/validate-register.js b/src/middleware/validate-register.js
index 41cf6bc..312c16e 100644
--- a/src/middleware/validate-register.js
+++ b/src/middleware/validate-register.js
@@ -13,14 +13,25 @@ const {
} = require('../utils/validators');
const validateRegister = (req, res, next) => {
- const { email, password, display_name, gender, date_of_birth } = req.body;
+ const rawEmail = typeof req.body.email === 'string' ? req.body.email.trim() : req.body.email;
+ const email = rawEmail?.toLowerCase();
+ const password = req.body.password;
+ const display_name =
+ typeof req.body.display_name === 'string'
+ ? req.body.display_name.trim()
+ : req.body.display_name;
+ const gender = req.body.gender;
+ const date_of_birth = req.body.date_of_birth;
const details = [];
if (!email || !isValidEmail(email))
details.push({ field: 'email', issue: 'Must be a valid email address' });
if (!password || !isValidPassword(password))
- details.push({ field: 'password', issue: 'Min 8 characters, must include uppercase, lowercase, and a number' });
+ details.push({
+ field: 'password',
+ issue: 'Min 8 characters, must include uppercase, lowercase, and a number',
+ });
if (!display_name || !isValidDisplayName(display_name))
details.push({ field: 'display_name', issue: 'Must be between 1 and 50 characters' });
@@ -29,7 +40,10 @@ const validateRegister = (req, res, next) => {
details.push({ field: 'gender', issue: 'Must be male or female' });
if (!date_of_birth || !isValidDateOfBirth(date_of_birth))
- details.push({ field: 'date_of_birth', issue: 'Must be a valid date and user must be at least 13 years old' });
+ details.push({
+ field: 'date_of_birth',
+ issue: 'Must be a valid date and user must be at least 13 years old',
+ });
if (details.length > 0) {
return error(res, 'VALIDATION_FAILED', 'Validation failed', 400, details);
@@ -38,4 +52,4 @@ const validateRegister = (req, res, next) => {
next();
};
-module.exports = { validateRegister };
\ No newline at end of file
+module.exports = { validateRegister };
diff --git a/src/models/user.model.js b/src/models/user.model.js
index 77633c4..5f93974 100644
--- a/src/models/user.model.js
+++ b/src/models/user.model.js
@@ -8,7 +8,7 @@ const db = require('../config/db');
// find user by email used for login and register (to check if email already exists)
exports.findByEmail = async (email) => {
const { rows } = await db.query(
- `SELECT * FROM users WHERE email = $1 AND deleted_at IS NULL`,
+ `SELECT * FROM users WHERE LOWER(email) = LOWER($1) AND deleted_at IS NULL`,
[email]
);
return rows[0] || null;
@@ -17,7 +17,7 @@ exports.findByEmail = async (email) => {
// find user by username used for login (when identifier is a username)
exports.findByUsername = async (username) => {
const { rows } = await db.query(
- `SELECT * FROM users WHERE username = $1 AND deleted_at IS NULL`,
+ `SELECT * FROM users WHERE LOWER(username) = LOWER($1) AND deleted_at IS NULL`,
[username]
);
return rows[0] || null;
@@ -27,7 +27,7 @@ exports.findByUsername = async (username) => {
exports.findByEmailOrUsername = async (identifier) => {
const { rows } = await db.query(
`SELECT * FROM users
- WHERE (email = $1 OR username = $1)
+ WHERE (LOWER(email) = LOWER($1) OR LOWER(username) = LOWER($1))
AND deleted_at IS NULL`,
[identifier]
);
@@ -36,19 +36,15 @@ exports.findByEmailOrUsername = async (identifier) => {
// find user by UUID used for auth and other operations where we have the user ID
exports.findById = async (id) => {
- const { rows } = await db.query(
- `SELECT * FROM users WHERE id = $1 AND deleted_at IS NULL`,
- [id]
- );
+ const { rows } = await db.query(`SELECT * FROM users WHERE id = $1 AND deleted_at IS NULL`, [id]);
return rows[0] || null;
};
-
// create new user (called during registration)
exports.create = async ({ email, password_hashed, display_name, gender, date_of_birth }) => {
const { rows } = await db.query(
`INSERT INTO users (email, password_hashed, display_name, gender, date_of_birth)
- VALUES ($1, $2, $3, $4, $5)
+ VALUES (LOWER($1), $2, $3, $4, $5)
RETURNING id, email, display_name, gender, date_of_birth, role, is_verified, created_at`,
[email, password_hashed, display_name, gender, date_of_birth]
);
@@ -57,34 +53,20 @@ exports.create = async ({ email, password_hashed, display_name, gender, date_of_
// mark user as verified
exports.markVerified = async (userId) => {
- await db.query(
- `UPDATE users SET is_verified = true WHERE id = $1`,
- [userId]
- );
+ await db.query(`UPDATE users SET is_verified = true WHERE id = $1`, [userId]);
};
// update last login timestamp (called after successful login)
exports.updateLastLogin = async (userId) => {
- await db.query(
- `UPDATE users SET last_login_at = now() WHERE id = $1`,
- [userId]
- );
+ await db.query(`UPDATE users SET last_login_at = now() WHERE id = $1`, [userId]);
};
// update password (called during password reset)
exports.updatePassword = async (userId, newPasswordHashed) => {
- await db.query(
- `UPDATE users SET password_hashed = $1, updated_at = now() WHERE id = $2`,
- [newPasswordHashed, userId]
- );
-};
-
-// update password (called during password reset)
-exports.updatePassword = async (userId, newPasswordHashed) => {
- await db.query(
- `UPDATE users SET password_hashed = $1, updated_at = now() WHERE id = $2`,
- [newPasswordHashed, userId]
- );
+ await db.query(`UPDATE users SET password_hashed = $1, updated_at = now() WHERE id = $2`, [
+ newPasswordHashed,
+ userId,
+ ]);
};
// get user profile (called in GET /users/me)
@@ -101,7 +83,7 @@ exports.findFullById = async (id) => {
[id]
);
return rows[0] || null;
-}
+};
// get public user profile (called in GET /users/:id)
exports.findPublicById = async (id) => {
@@ -116,7 +98,7 @@ exports.findPublicById = async (id) => {
[id]
);
return rows[0] || null;
-}
+};
// check if user A is following user B (used in GET /users/:id to determine if we can show private profile)
exports.isFollowing = async (followerId, followingId) => {
@@ -128,7 +110,7 @@ exports.isFollowing = async (followerId, followingId) => {
};
exports.updateProfile = async (userId, fields) => {
- const allowed =['display_name', 'username', 'first_name', 'last_name', 'bio', 'city', 'country'];
+ const allowed = ['display_name', 'username', 'first_name', 'last_name', 'bio', 'city', 'country'];
const updates = [];
const values = [];
let i = 1;
@@ -155,7 +137,7 @@ exports.updateProfile = async (userId, fields) => {
followers_count, following_count, created_at, updated_at`,
values
);
- return rows[0]||null;
+ return rows[0] || null;
};
exports.updateAccount = async (userId, fields) => {
@@ -187,7 +169,7 @@ exports.updateAccount = async (userId, fields) => {
values
);
return rows[0] || null;
-}
+};
exports.updateRole = async (userId, newRole) => {
const { rows } = await db.query(
@@ -199,7 +181,7 @@ exports.updateRole = async (userId, newRole) => {
[newRole, userId]
);
return rows[0] || null;
-}
+};
exports.deleteAvatar = async (userId) => {
const { rows } = await db.query(
`UPDATE users SET profile_picture = NULL, updated_at = now()
@@ -240,10 +222,9 @@ exports.deleteCoverPhoto = async (userId) => {
};
exports.findWebProfilesByUserId = async (userId) => {
- const { rows } = await db.query(
- `SELECT id, platform, url FROM web_profiles WHERE user_id = $1`,
- [userId]
- );
+ const { rows } = await db.query(`SELECT id, platform, url FROM web_profiles WHERE user_id = $1`, [
+ userId,
+ ]);
return rows || [];
};
exports.findWebProfileByPlatform = async (userId, platform) => {
@@ -265,10 +246,9 @@ exports.createWebProfile = async (userId, platform, url) => {
};
exports.deleteWebProfile = async (profileId) => {
- const { rows } = await db.query(
- `DELETE FROM web_profiles WHERE id = $1 RETURNING id`,
- [profileId]
- );
+ const { rows } = await db.query(`DELETE FROM web_profiles WHERE id = $1 RETURNING id`, [
+ profileId,
+ ]);
return rows[0] || null;
};
@@ -301,8 +281,15 @@ exports.findContentSettingsByUserId = async (userId) => {
};
exports.updateContentSettings = async (userId, settings) => {
- const allowed = ['rss_title', 'rss_language', 'rss_category', 'rss_explicit',
- 'rss_show_email', 'default_include_in_rss', 'default_license_type'];
+ const allowed = [
+ 'rss_title',
+ 'rss_language',
+ 'rss_category',
+ 'rss_explicit',
+ 'rss_show_email',
+ 'default_include_in_rss',
+ 'default_license_type',
+ ];
const fields = [];
const values = [];
let i = 1;
@@ -329,7 +316,7 @@ exports.updateContentSettings = async (userId, settings) => {
return rows[0] || null;
};
exports.findPrivacySettingsByUserId = async (userId) => {
- const { rows } = await db.query(
+ const { rows } = await db.query(
`SELECT receive_messages_from_anyone, show_activities_in_discovery,
show_as_top_fan, show_top_fans_on_tracks
FROM user_privacy_settings WHERE user_id = $1`,
@@ -339,8 +326,12 @@ exports.findPrivacySettingsByUserId = async (userId) => {
};
exports.updatePrivacySettings = async (userId, settings) => {
- const allowed = ['receive_messages_from_anyone', 'show_activities_in_discovery',
- 'show_as_top_fan', 'show_top_fans_on_tracks'];
+ const allowed = [
+ 'receive_messages_from_anyone',
+ 'show_activities_in_discovery',
+ 'show_as_top_fan',
+ 'show_top_fans_on_tracks',
+ ];
const fields = [];
const values = [];
let i = 1;
@@ -375,21 +366,18 @@ exports.findGenresByUserId = async (userId) => {
[userId]
);
return rows || [];
-};
+};
exports.replaceGenres = async (userId, genreIds) => {
const client = await db.getClient();
try {
await client.query('BEGIN');
- await client.query(
- `DELETE FROM user_favorite_genres WHERE user_id = $1`,
- [userId]
- );
+ await client.query(`DELETE FROM user_favorite_genres WHERE user_id = $1`, [userId]);
for (const genreId of genreIds) {
- await client.query(
- `INSERT INTO user_favorite_genres (user_id, genre_id) VALUES ($1, $2)`,
- [userId, genreId]
- );
+ await client.query(`INSERT INTO user_favorite_genres (user_id, genre_id) VALUES ($1, $2)`, [
+ userId,
+ genreId,
+ ]);
}
await client.query('COMMIT');
return await exports.findGenresByUserId(userId);
@@ -402,10 +390,10 @@ exports.replaceGenres = async (userId, genreIds) => {
};
exports.completeOnboarding = async (userId, fields) => {
- const allowed = ['display_name', 'gender', 'date_of_birth', 'bio','city','country'];
+ const allowed = ['display_name', 'gender', 'date_of_birth', 'bio', 'city', 'country'];
const updates = [];
const values = [];
- let i = 1;
+ let i = 1;
for (const key in fields) {
if (allowed.includes(key)) {
updates.push(`${key} = $${i}`);
@@ -414,7 +402,7 @@ exports.completeOnboarding = async (userId, fields) => {
}
}
- if (updates.length === 0) return null;
+ if (updates.length === 0) return null;
updates.push(`updated_at = now()`);
values.push(userId);
const { rows } = await db.query(
@@ -426,9 +414,4 @@ exports.completeOnboarding = async (userId, fields) => {
values
);
return rows[0] || null;
-};
-
-
-
-
-
\ No newline at end of file
+};
diff --git a/src/services/auth.service.js b/src/services/auth.service.js
index 1adbb5f..ee5f15d 100644
--- a/src/services/auth.service.js
+++ b/src/services/auth.service.js
@@ -38,11 +38,16 @@ exports.register = async ({
date_of_birth,
captcha_token,
}) => {
+ const normalizedEmail = email?.trim().toLowerCase();
+ if (!normalizedEmail) {
+ throw new AppError('Email is required', 400, 'VALIDATION_FAILED');
+ }
+ const displayNameTrimmed = display_name?.trim();
// Verify CAPTCHA i can't get a token to test with it now so imma comment it out for now :)
//await verifyCaptcha(captcha_token);
// Check duplicate email
- const existing = await userModel.findByEmail(email);
+ const existing = await userModel.findByEmail(normalizedEmail);
if (existing) {
throw new AppError('Email already registered', 409, 'AUTH_EMAIL_ALREADY_EXISTS');
}
@@ -52,9 +57,9 @@ exports.register = async ({
// Create user
const user = await userModel.create({
- email,
+ email: normalizedEmail,
password_hashed,
- display_name,
+ display_name: displayNameTrimmed,
gender,
date_of_birth,
});
@@ -123,7 +128,10 @@ const createAndStoreRefreshToken = async (userId) => {
};
exports.login = async ({ identifier, password }) => {
- const user = await userModel.findByEmailOrUsername(identifier);
+ const normalizedIdentifier = identifier?.trim();
+ const identifierLower = normalizedIdentifier?.toLowerCase();
+
+ const user = await userModel.findByEmailOrUsername(identifierLower);
if (!user) {
throw new AppError('Invalid email or password', 401, 'AUTH_INVALID_CREDENTIALS');
@@ -243,7 +251,10 @@ exports.logout = async ({ refresh_token }) => {
// Forgot Password and Reset Password
// ============================================================
exports.requestPasswordReset = async ({ email }) => {
- const user = await userModel.findByEmail(email);
+ const normalizedEmail = email?.trim().toLowerCase();
+ if (!normalizedEmail) return;
+
+ const user = await userModel.findByEmail(normalizedEmail);
if (!user) {
return;
}
diff --git a/src/services/users.service.js b/src/services/users.service.js
index 77580c2..208d8fd 100644
--- a/src/services/users.service.js
+++ b/src/services/users.service.js
@@ -40,7 +40,12 @@ exports.getUserById = async (targetId, requesterId) => {
exports.updateMe = async (userId, fields) => {
if (fields.username) {
- const existing = await userModel.findByUsername(fields.username);
+ const usernameNormalized = fields.username.trim().toLowerCase();
+ if (!usernameNormalized) {
+ throw new AppError('Username cannot be empty.', 400, 'VALIDATION_FAILED');
+ }
+ fields.username = usernameNormalized;
+ const existing = await userModel.findByUsername(usernameNormalized);
if (existing && existing.id !== userId) {
throw new AppError('Username already taken.', 409, 'RESOURCE_ALREADY_EXISTS');
}
@@ -121,14 +126,14 @@ exports.switchRole = async (userId, role) => {
exports.deleteMyAvatar = async (userId) => {
const user = await userModel.findById(userId);
- if(!user){
+ if (!user) {
throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
}
if (!user.profile_picture) {
- throw new AppError('No avatar to delete', 404, 'RESOURCE_NOT_FOUND');
+ throw new AppError('No avatar to delete', 404, 'RESOURCE_NOT_FOUND');
}
return await userModel.deleteAvatar(userId);
-}
+};
exports.uploadMyAvatar = async (userId, file) => {
const user = await userModel.findById(userId);
if (!user) {
@@ -140,7 +145,7 @@ exports.uploadMyAvatar = async (userId, file) => {
};
exports.uploadMyCoverPhoto = async (userId, file) => {
- const user = await userModel.findById(userId);
+ const user = await userModel.findById(userId);
if (!user) {
throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
}
@@ -154,18 +159,22 @@ exports.deleteMyCoverPhoto = async (userId) => {
throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
}
if (!user.cover_photo) {
- throw new AppError('No cover photo to delete', 404, 'RESOURCE_NOT_FOUND');
+ throw new AppError('No cover photo to delete', 404, 'RESOURCE_NOT_FOUND');
}
return await userModel.deleteCoverPhoto(userId);
};
-exports.getMyWebProfiles = async (userId) => {
+exports.getMyWebProfile = async (userId) => {
return await userModel.findWebProfilesByUserId(userId);
};
exports.addWebProfile = async (userId, platform, url) => {
const existing = await userModel.findWebProfileByPlatform(userId, platform);
if (existing) {
- throw new AppError('A profile for this platform already exists.', 409, 'RESOURCE_ALREADY_EXISTS');
+ throw new AppError(
+ 'A profile for this platform already exists.',
+ 409,
+ 'RESOURCE_ALREADY_EXISTS'
+ );
}
return await userModel.createWebProfile(userId, platform, url);
};
@@ -187,7 +196,11 @@ exports.updatePrivacy = async (userId, isPrivate) => {
throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
}
if (user.is_private === isPrivate) {
- throw new AppError(`Profile is already ${isPrivate ? 'private' : 'public'}.`, 400, 'VALIDATION_FAILED');
+ throw new AppError(
+ `Profile is already ${isPrivate ? 'private' : 'public'}.`,
+ 400,
+ 'VALIDATION_FAILED'
+ );
}
const updated = await userModel.updatePrivacy(userId, isPrivate);
return updated;
@@ -198,10 +211,10 @@ exports.getMyContentSettings = async (userId) => {
};
exports.updateMyContentSettings = async (userId, settings) => {
- const user = await userModel.findById(userId);
+ const user = await userModel.findById(userId);
if (!user) {
throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
- }
+ }
const updated = await userModel.updateContentSettings(userId, settings);
return updated;
};
@@ -210,10 +223,10 @@ exports.getMyPrivacySettings = async (userId) => {
};
exports.updateMyPrivacySettings = async (userId, settings) => {
- const user = await userModel.findById(userId);
+ const user = await userModel.findById(userId);
if (!user) {
throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
- }
+ }
const updated = await userModel.updatePrivacySettings(userId, settings);
return updated;
};
@@ -226,7 +239,7 @@ exports.replaceMyGenres = async (userId, genreIds) => {
const user = await userModel.findById(userId);
if (!user) {
throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
- }
+ }
const updated = await userModel.replaceGenres(userId, genreIds);
return updated;
};
@@ -237,11 +250,12 @@ exports.completeOnboarding = async (userId, fields) => {
throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
}
if (user.display_name && user.gender && user.date_of_birth) {
- throw new AppError('Profile onboarding has already been completed.', 409, 'ONBOARDING_ALREADY_COMPLETED');
+ throw new AppError(
+ 'Profile onboarding has already been completed.',
+ 409,
+ 'ONBOARDING_ALREADY_COMPLETED'
+ );
}
const updated = await userModel.completeOnboarding(userId, fields);
return updated;
};
-
-
-
From 12405d78a4d5bcbb9894338c14c076de3b0e4c32 Mon Sep 17 00:00:00 2001
From: Omar Hamza
Date: Tue, 17 Mar 2026 23:31:08 +0200
Subject: [PATCH 042/535] fix(users): correct GET /users/me profile flow and
route alignment
- Fix GET /users/me to correctly return the authenticated user's full profile
- Align users routes and controllers to ensure "me" endpoints resolve to the proper handlers
- Correct controller/service wiring for profile retrieval
- Ensure all auth-protected "me" routes consistently use the authenticated user context
- Update related handlers to maintain consistent behavior across user self-service endpoints
---
src/controllers/users.controller.js | 2 +-
src/routes/users.routes.js | 39 ++++++++++++++++++++++-------
2 files changed, 31 insertions(+), 10 deletions(-)
diff --git a/src/controllers/users.controller.js b/src/controllers/users.controller.js
index 7465a8e..5a29a1e 100644
--- a/src/controllers/users.controller.js
+++ b/src/controllers/users.controller.js
@@ -80,7 +80,7 @@ exports.deleteMyCoverPhoto = async (req, res) => {
};
exports.getMyWebProfile = async (req, res) => {
const data = await usersService.getMyWebProfile(req.user.sub);
- return success(res, data, 'Web profile returned successfully.');
+ return success(res, data, 'Web profiles returned successfully.');
};
exports.addWebProfile = async (req, res) => {
const { platform, url } = req.body;
diff --git a/src/routes/users.routes.js b/src/routes/users.routes.js
index 39c6698..89a91c5 100644
--- a/src/routes/users.routes.js
+++ b/src/routes/users.routes.js
@@ -10,27 +10,48 @@ const { authenticate, optionalAuthenticate } = require('../middleware/auth');
const asyncHandler = require('../utils/async-handler');
const { uploadImage } = require('../middleware/multer');
-
-router.get('/me', authenticate ,asyncHandler(controller.getMe));
+router.get('/me', authenticate, asyncHandler(controller.getMe));
router.patch('/me', authenticate, asyncHandler(controller.updateMe));
router.patch('/me/account', authenticate, asyncHandler(controller.updateMyAccount));
router.patch('/me/role', authenticate, asyncHandler(controller.switchRole));
router.get('/:user_id', optionalAuthenticate, asyncHandler(controller.getUserById));
router.delete('/me/avatar', authenticate, asyncHandler(controller.deleteMyAvatar));
-router.post('/me/avatar', authenticate, uploadImage.single('avatar'), asyncHandler(controller.uploadMyAvatar));
-router.post('/me/cover', authenticate, uploadImage.single('cover'), asyncHandler(controller.uploadMyCoverPhoto));
+router.post(
+ '/me/avatar',
+ authenticate,
+ uploadImage.single('avatar'),
+ asyncHandler(controller.uploadMyAvatar)
+);
+router.post(
+ '/me/cover',
+ authenticate,
+ uploadImage.single('cover'),
+ asyncHandler(controller.uploadMyCoverPhoto)
+);
router.delete('/me/cover', authenticate, asyncHandler(controller.deleteMyCoverPhoto));
-router.get('/me/web-profile', authenticate, asyncHandler(controller.getMyWebProfile));
+router.get('/me/web-profiles', authenticate, asyncHandler(controller.getMyWebProfile));
router.post('/me/web-profiles', authenticate, asyncHandler(controller.addWebProfile));
-router.delete('/me/web-profiles/:profile_id', authenticate, asyncHandler(controller.deleteWebProfile));
+router.delete(
+ '/me/web-profiles/:profile_id',
+ authenticate,
+ asyncHandler(controller.deleteWebProfile)
+);
router.patch('/me/privacy', authenticate, asyncHandler(controller.updatePrivacy));
router.get('/me/content-settings', authenticate, asyncHandler(controller.getMyContentSettings));
-router.patch('/me/content-settings', authenticate, asyncHandler(controller.updateMyContentSettings));
+router.patch(
+ '/me/content-settings',
+ authenticate,
+ asyncHandler(controller.updateMyContentSettings)
+);
router.get('/me/privacy-settings', authenticate, asyncHandler(controller.getMyPrivacySettings));
-router.patch('/me/privacy-settings', authenticate, asyncHandler(controller.updateMyPrivacySettings));
+router.patch(
+ '/me/privacy-settings',
+ authenticate,
+ asyncHandler(controller.updateMyPrivacySettings)
+);
router.get('/me/genres', authenticate, asyncHandler(controller.getMyGenres));
router.put('/me/genres', authenticate, asyncHandler(controller.replaceMyGenres));
router.patch('/me/onboarding', authenticate, asyncHandler(controller.completeOnboarding));
-module.exports = router;
\ No newline at end of file
+module.exports = router;
From 595b7fd3ba6432e5c8d0e23bdd6aa1f9dd6d4268 Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Wed, 18 Mar 2026 01:11:04 +0200
Subject: [PATCH 043/535] edit PR comments
-Tightened controller tests to assert exact payloads, statuses, messages, and service arguments.
-Added coverage for failure paths, auth-missing cases, validation/pagination edges, and service errors.
-Added senderId null/undefined guards and tests in messaging service.
-Deepened socket tests to cover authorization, DB query checks, malformed payloads, and error emissions.
-Verified with targeted messaging suites and full project test run.
---
src/controllers/messages.controller.js | 33 ++++-
src/services/messages.service.js | 10 ++
src/sockets/messages.socket.js | 77 +++++++++-
tests/messages.controller.unit.test.js | 143 +++++++++++++++---
tests/messages.service.unit.test.js | 70 +++++++++
tests/messages.socket.unit.test.js | 194 +++++++++++++++++++++++++
6 files changed, 494 insertions(+), 33 deletions(-)
diff --git a/src/controllers/messages.controller.js b/src/controllers/messages.controller.js
index 98f90bf..1ecbf5c 100644
--- a/src/controllers/messages.controller.js
+++ b/src/controllers/messages.controller.js
@@ -2,9 +2,19 @@
const messagesService = require('../services/messages.service');
const { success, error } = require('../utils/api-response');
+const getAuthenticatedUserId = (req, res) => {
+ const userId = req?.user?.sub;
+ if (!userId) {
+ error(res, 'UNAUTHORIZED', 'Authentication required.', 401);
+ return null;
+ }
+ return userId;
+};
+
// POST /messages/new
exports.startConversation = async (req, res) => {
- const senderId = req.user.sub;
+ const senderId = getAuthenticatedUserId(req, res);
+ if (!senderId) return;
const { recipient_id, body, resource } = req.body;
if (!recipient_id) {
@@ -27,7 +37,8 @@ exports.startConversation = async (req, res) => {
// GET /messages/conversations
exports.listConversations = async (req, res) => {
- const userId = req.user.sub;
+ const userId = getAuthenticatedUserId(req, res);
+ if (!userId) return;
const { page, limit } = req.query;
const data = await messagesService.listConversations({ userId, page, limit });
@@ -38,7 +49,8 @@ exports.listConversations = async (req, res) => {
// GET /messages/conversations/:conversationId
exports.getConversation = async (req, res) => {
- const userId = req.user.sub;
+ const userId = getAuthenticatedUserId(req, res);
+ if (!userId) return;
const { conversationId } = req.params;
const { page, limit } = req.query;
@@ -55,7 +67,8 @@ exports.getConversation = async (req, res) => {
// POST /messages/conversations/:conversationId/messages
exports.sendMessage = async (req, res) => {
- const senderId = req.user.sub;
+ const senderId = getAuthenticatedUserId(req, res);
+ if (!senderId) return;
const { conversationId } = req.params;
const { body, resource } = req.body;
@@ -72,7 +85,8 @@ exports.sendMessage = async (req, res) => {
// GET /messages/unread-count
exports.getUnreadCount = async (req, res) => {
- const userId = req.user.sub;
+ const userId = getAuthenticatedUserId(req, res);
+ if (!userId) return;
const data = await messagesService.getUnreadCount({ userId });
@@ -82,7 +96,8 @@ exports.getUnreadCount = async (req, res) => {
// PATCH /messages/conversations/:conversationId/messages/:messageId/read
exports.markMessageReadState = async (req, res) => {
- const userId = req.user.sub;
+ const userId = getAuthenticatedUserId(req, res);
+ if (!userId) return;
const { conversationId, messageId } = req.params;
const { is_read } = req.body;
@@ -107,7 +122,8 @@ exports.markMessageReadState = async (req, res) => {
// DELETE /messages/conversations/:conversationId/messages/:messageId
exports.deleteMessage = async (req, res) => {
- const userId = req.user.sub;
+ const userId = getAuthenticatedUserId(req, res);
+ if (!userId) return;
const { conversationId, messageId } = req.params;
await messagesService.deleteMessage({ conversationId, messageId, userId });
@@ -118,7 +134,8 @@ exports.deleteMessage = async (req, res) => {
// DELETE /messages/conversations/:conversationId
exports.deleteConversation = async (req, res) => {
- const userId = req.user.sub;
+ const userId = getAuthenticatedUserId(req, res);
+ if (!userId) return;
const { conversationId } = req.params;
await messagesService.deleteConversation({ conversationId, userId });
diff --git a/src/services/messages.service.js b/src/services/messages.service.js
index 36ea2bb..b2b1b47 100644
--- a/src/services/messages.service.js
+++ b/src/services/messages.service.js
@@ -2,11 +2,19 @@
const messageModel = require('../models/message.model');
const AppError = require('../utils/app-error');
+const validateSenderId = (senderId) => {
+ if (!senderId) {
+ throw new AppError('Authenticated sender is required.', 401, 'UNAUTHORIZED');
+ }
+};
+
// ------------------------------------------------------------
// Endpoint 1 — Start a new conversation
// ------------------------------------------------------------
exports.startConversation = async ({ senderId, recipientId, body, resource }) => {
+ validateSenderId(senderId);
+
// 1. Prevent self-messaging
if (senderId === recipientId) {
throw new AppError('You cannot send a message to yourself.', 400, 'MESSAGES_SELF_MESSAGE');
@@ -185,6 +193,8 @@ exports.getConversation = async ({ conversationId, userId, page, limit }) => {
// ------------------------------------------------------------
exports.sendMessage = async ({ conversationId, senderId, body, resource }) => {
+ validateSenderId(senderId);
+
// 1. Find conversation
const conversation = await messageModel.findConversationById(conversationId);
diff --git a/src/sockets/messages.socket.js b/src/sockets/messages.socket.js
index d4f56c7..75e130e 100644
--- a/src/sockets/messages.socket.js
+++ b/src/sockets/messages.socket.js
@@ -22,6 +22,8 @@ const isValidUUID = (val) =>
typeof val === 'string' &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(val);
+const isNonEmptyString = (val) => typeof val === 'string' && val.trim().length > 0;
+
// ------------------------------------------------------------
// Helper — verify user is a participant in a conversation
// Prevents users from joining rooms they don't belong to
@@ -44,6 +46,13 @@ const registerMessageHandlers = (io, socket) => {
// userId extracted from JWT-verified payload set by server.js middleware
const userId = socket.user?.sub;
+ const ensureAuthenticated = () => {
+ if (!isValidUUID(userId)) {
+ socket.emit('error', { message: 'Authentication required.' });
+ return false;
+ }
+ return true;
+ };
// ----------------------------------------------------------
// Join a conversation room
@@ -51,9 +60,13 @@ const registerMessageHandlers = (io, socket) => {
// Security: UUID validated and participant check done here
// because this is a socket-only event with no HTTP equivalent
// ----------------------------------------------------------
- socket.on('message:join', async ({ conversationId }) => {
+ socket.on('message:join', async ({ conversationId } = {}) => {
+
+ if (!ensureAuthenticated()) {
+ return;
+ }
- if (!isValidUUID(conversationId) || !isValidUUID(userId)) {
+ if (!isValidUUID(conversationId)) {
return socket.emit('error', { message: 'Invalid conversationId.' });
}
@@ -76,7 +89,15 @@ const registerMessageHandlers = (io, socket) => {
// Leave a conversation room
// Client emits when user closes a conversation
// ----------------------------------------------------------
- socket.on('message:leave', ({ conversationId }) => {
+ socket.on('message:leave', ({ conversationId } = {}) => {
+ if (!ensureAuthenticated()) {
+ return;
+ }
+
+ if (!isNonEmptyString(conversationId)) {
+ return socket.emit('error', { message: 'Invalid payload.' });
+ }
+
const room = `conversation:${conversationId}`;
socket.leave(room);
console.log(`[Socket.IO] ${socket.id} (user: ${userId}) left ${room}`);
@@ -88,7 +109,15 @@ const registerMessageHandlers = (io, socket) => {
// POST /messages/conversations/:conversationId/messages succeeds
// Passes the full message object from the HTTP response
// ----------------------------------------------------------
- socket.on('message:send', ({ conversationId, message }) => {
+ socket.on('message:send', ({ conversationId, message } = {}) => {
+ if (!ensureAuthenticated()) {
+ return;
+ }
+
+ if (!isNonEmptyString(conversationId) || !message || typeof message !== 'object') {
+ return socket.emit('error', { message: 'Invalid payload.' });
+ }
+
const room = `conversation:${conversationId}`;
socket.to(room).emit('message:received', { conversationId, message });
console.log(`[Socket.IO] message:send in ${room} by user: ${userId}`);
@@ -99,7 +128,15 @@ const registerMessageHandlers = (io, socket) => {
// Client emits AFTER
// DELETE /messages/conversations/:conversationId/messages/:messageId succeeds
// ----------------------------------------------------------
- socket.on('message:deleted', ({ conversationId, messageId }) => {
+ socket.on('message:deleted', ({ conversationId, messageId } = {}) => {
+ if (!ensureAuthenticated()) {
+ return;
+ }
+
+ if (!isNonEmptyString(conversationId) || !isNonEmptyString(messageId)) {
+ return socket.emit('error', { message: 'Invalid payload.' });
+ }
+
const room = `conversation:${conversationId}`;
socket.to(room).emit('message:removed', { conversationId, messageId });
console.log(`[Socket.IO] message:deleted in ${room} by user: ${userId}`);
@@ -110,7 +147,15 @@ const registerMessageHandlers = (io, socket) => {
// Client emits AFTER
// PATCH /messages/conversations/:conversationId/messages/:messageId/read succeeds
// ----------------------------------------------------------
- socket.on('message:read', ({ conversationId, messageId, isRead, conversationUnreadCount }) => {
+ socket.on('message:read', ({ conversationId, messageId, isRead, conversationUnreadCount } = {}) => {
+ if (!ensureAuthenticated()) {
+ return;
+ }
+
+ if (!isNonEmptyString(conversationId) || !isNonEmptyString(messageId) || typeof isRead !== 'boolean') {
+ return socket.emit('error', { message: 'Invalid payload.' });
+ }
+
const room = `conversation:${conversationId}`;
socket.to(room).emit('message:read_updated', {
conversationId,
@@ -126,7 +171,15 @@ const registerMessageHandlers = (io, socket) => {
// Client emits while user is typing
// NOTE: Frontend should throttle this to once per ~1000ms
// ----------------------------------------------------------
- socket.on('message:typing', ({ conversationId }) => {
+ socket.on('message:typing', ({ conversationId } = {}) => {
+ if (!ensureAuthenticated()) {
+ return;
+ }
+
+ if (!isNonEmptyString(conversationId)) {
+ return socket.emit('error', { message: 'Invalid payload.' });
+ }
+
const room = `conversation:${conversationId}`;
socket.to(room).emit('message:typing', { conversationId, userId });
});
@@ -135,7 +188,15 @@ const registerMessageHandlers = (io, socket) => {
// Stop typing indicator
// Client emits when user stops typing or sends the message
// ----------------------------------------------------------
- socket.on('message:stop_typing', ({ conversationId }) => {
+ socket.on('message:stop_typing', ({ conversationId } = {}) => {
+ if (!ensureAuthenticated()) {
+ return;
+ }
+
+ if (!isNonEmptyString(conversationId)) {
+ return socket.emit('error', { message: 'Invalid payload.' });
+ }
+
const room = `conversation:${conversationId}`;
socket.to(room).emit('message:stop_typing', { conversationId, userId });
});
diff --git a/tests/messages.controller.unit.test.js b/tests/messages.controller.unit.test.js
index 0b4902c..ca6b8dd 100644
--- a/tests/messages.controller.unit.test.js
+++ b/tests/messages.controller.unit.test.js
@@ -8,8 +8,8 @@ jest.mock('../src/utils/api-response', () => ({
error: jest.fn(),
}));
-const mkReq = ({ userId = 'u1', body = {}, params = {}, query = {} } = {}) => ({
- user: { sub: userId },
+const mkReq = ({ userId = 'u1', body = {}, params = {}, query = {}, user } = {}) => ({
+ user: user || (userId ? { sub: userId } : undefined),
body,
params,
query,
@@ -21,6 +21,21 @@ beforeEach(() => jest.clearAllMocks());
describe('messages.controller', () => {
describe('startConversation', () => {
+ it('returns unauthorized when req.user is missing', async () => {
+ const req = mkReq({ userId: null, body: { recipient_id: 'u2', body: 'hi' } });
+ const res = mkRes();
+
+ await controller.startConversation(req, res);
+
+ expect(api.error).toHaveBeenCalledWith(
+ res,
+ 'UNAUTHORIZED',
+ 'Authentication required.',
+ 401
+ );
+ expect(messagesService.startConversation).not.toHaveBeenCalled();
+ });
+
it('returns validation error when recipient_id missing', async () => {
const req = mkReq({ body: { body: 'hi' } });
const res = mkRes();
@@ -37,7 +52,7 @@ describe('messages.controller', () => {
});
it('returns 201 for new conversation', async () => {
- const req = mkReq({ body: { recipient_id: 'u2', body: 'hi' } });
+ const req = mkReq({ body: { recipient_id: 'u2', body: ' hi ' } });
const res = mkRes();
messagesService.startConversation.mockResolvedValue({
conversation: { id: 'c1' },
@@ -47,6 +62,13 @@ describe('messages.controller', () => {
await controller.startConversation(req, res);
+ expect(messagesService.startConversation).toHaveBeenCalledWith({
+ senderId: 'u1',
+ recipientId: 'u2',
+ body: ' hi ',
+ resource: undefined,
+ });
+
expect(api.success).toHaveBeenCalledWith(
res,
{ conversation: { id: 'c1' }, message: { id: 'm1' } },
@@ -73,6 +95,15 @@ describe('messages.controller', () => {
200
);
});
+
+ it('bubbles service failure', async () => {
+ const req = mkReq({ body: { recipient_id: 'u2', body: 'hi' } });
+ const res = mkRes();
+ messagesService.startConversation.mockRejectedValue(new Error('db down'));
+
+ await expect(controller.startConversation(req, res)).rejects.toThrow('db down');
+ expect(api.success).not.toHaveBeenCalled();
+ });
});
describe('markMessageReadState', () => {
@@ -129,48 +160,100 @@ describe('messages.controller', () => {
isRead: true,
});
- expect(api.success).toHaveBeenCalled();
+ expect(api.success).toHaveBeenCalledWith(
+ res,
+ {
+ message_id: 'm1',
+ is_read: true,
+ conversation_unread_count: 0,
+ },
+ 'Message read state updated successfully.'
+ );
});
});
- it('listConversations returns success', async () => {
+ it('listConversations passes query args and returns payload', async () => {
const req = mkReq({ query: { page: '2', limit: '8' } });
const res = mkRes();
- messagesService.listConversations.mockResolvedValue({ items: [], pagination: {} });
+ const data = {
+ items: [{ id: 'c1' }],
+ pagination: { page: 2, limit: 8, total: 1, total_pages: 1 },
+ };
+ messagesService.listConversations.mockResolvedValue(data);
await controller.listConversations(req, res);
- expect(api.success).toHaveBeenCalled();
+ expect(messagesService.listConversations).toHaveBeenCalledWith({
+ userId: 'u1',
+ page: '2',
+ limit: '8',
+ });
+ expect(api.success).toHaveBeenCalledWith(
+ res,
+ data,
+ 'Conversations fetched successfully.'
+ );
});
- it('getConversation returns success', async () => {
+ it('getConversation passes pagination query and returns payload', async () => {
const req = mkReq({ params: { conversationId: 'c1' }, query: { page: '1', limit: '20' } });
const res = mkRes();
- messagesService.getConversation.mockResolvedValue({ conversation: {}, messages: [], pagination: {} });
+ const data = { conversation: { id: 'c1' }, messages: [], pagination: { page: 1, limit: 20 } };
+ messagesService.getConversation.mockResolvedValue(data);
await controller.getConversation(req, res);
- expect(api.success).toHaveBeenCalled();
+ expect(messagesService.getConversation).toHaveBeenCalledWith({
+ conversationId: 'c1',
+ userId: 'u1',
+ page: '1',
+ limit: '20',
+ });
+ expect(api.success).toHaveBeenCalledWith(
+ res,
+ data,
+ 'Conversation fetched successfully.'
+ );
});
- it('sendMessage returns success', async () => {
- const req = mkReq({ params: { conversationId: 'c1' }, body: { body: 'hi' } });
+ it('sendMessage validates input forwarding and returns success', async () => {
+ const req = mkReq({
+ params: { conversationId: 'c1' },
+ body: { body: ' hello ', resource: { type: 'track', id: 't1' } },
+ });
const res = mkRes();
- messagesService.sendMessage.mockResolvedValue({ id: 'm1' });
+ messagesService.sendMessage.mockResolvedValue({ id: 'm1', body: 'hello' });
await controller.sendMessage(req, res);
- expect(api.success).toHaveBeenCalledWith(res, { id: 'm1' }, 'Message sent.', 201);
+ expect(messagesService.sendMessage).toHaveBeenCalledWith({
+ conversationId: 'c1',
+ senderId: 'u1',
+ body: ' hello ',
+ resource: { type: 'track', id: 't1' },
+ });
+ expect(api.success).toHaveBeenCalledWith(
+ res,
+ { id: 'm1', body: 'hello' },
+ 'Message sent.',
+ 201
+ );
});
- it('getUnreadCount returns success', async () => {
+ it('getUnreadCount returns payload and message', async () => {
const req = mkReq();
const res = mkRes();
- messagesService.getUnreadCount.mockResolvedValue({ unread_count: 4 });
+ const data = { unread_count: 4 };
+ messagesService.getUnreadCount.mockResolvedValue(data);
await controller.getUnreadCount(req, res);
- expect(api.success).toHaveBeenCalled();
+ expect(messagesService.getUnreadCount).toHaveBeenCalledWith({ userId: 'u1' });
+ expect(api.success).toHaveBeenCalledWith(
+ res,
+ data,
+ 'Unread message count fetched successfully.'
+ );
});
it('deleteMessage returns success', async () => {
@@ -199,4 +282,30 @@ describe('messages.controller', () => {
});
expect(api.success).toHaveBeenCalledWith(res, null, 'Conversation deleted.');
});
+
+ describe('auth edge cases', () => {
+ const cases = [
+ ['listConversations', () => mkReq({ userId: null })],
+ ['getConversation', () => mkReq({ userId: null, params: { conversationId: 'c1' } })],
+ ['sendMessage', () => mkReq({ userId: null, params: { conversationId: 'c1' }, body: { body: 'x' } })],
+ ['getUnreadCount', () => mkReq({ userId: null })],
+ ['markMessageReadState', () => mkReq({ userId: null, params: { conversationId: 'c1', messageId: 'm1' }, body: { is_read: true } })],
+ ['deleteMessage', () => mkReq({ userId: null, params: { conversationId: 'c1', messageId: 'm1' } })],
+ ['deleteConversation', () => mkReq({ userId: null, params: { conversationId: 'c1' } })],
+ ];
+
+ it.each(cases)('%s returns unauthorized when req.user is absent', async (methodName, makeReq) => {
+ const req = makeReq();
+ const res = mkRes();
+
+ await controller[methodName](req, res);
+
+ expect(api.error).toHaveBeenCalledWith(
+ res,
+ 'UNAUTHORIZED',
+ 'Authentication required.',
+ 401
+ );
+ });
+ });
});
\ No newline at end of file
diff --git a/tests/messages.service.unit.test.js b/tests/messages.service.unit.test.js
index a7ada94..56445da 100644
--- a/tests/messages.service.unit.test.js
+++ b/tests/messages.service.unit.test.js
@@ -18,6 +18,22 @@ const baseConversation = {
describe('messages.service', () => {
describe('startConversation', () => {
+ it('rejects when senderId is undefined', async () => {
+ await expect(
+ service.startConversation({ senderId: undefined, recipientId: 'u2', body: 'x' })
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED', statusCode: 401 });
+
+ expect(model.findActiveUserById).not.toHaveBeenCalled();
+ });
+
+ it('rejects when senderId is null', async () => {
+ await expect(
+ service.startConversation({ senderId: null, recipientId: 'u2', body: 'x' })
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED', statusCode: 401 });
+
+ expect(model.findActiveUserById).not.toHaveBeenCalled();
+ });
+
it('rejects self message', async () => {
await expect(
service.startConversation({ senderId: 'u1', recipientId: 'u1', body: 'x' })
@@ -174,6 +190,21 @@ describe('messages.service', () => {
const out = await service.listConversations({ userId: 'u1' });
expect(out.items[0].last_message).toBeNull();
});
+
+ it('defaults pagination for invalid values', async () => {
+ model.findConversationsByUserId.mockResolvedValue([]);
+ model.countConversationsByUserId.mockResolvedValue(0);
+
+ const out = await service.listConversations({ userId: 'u1', page: 'abc', limit: 'xyz' });
+
+ expect(model.findConversationsByUserId).toHaveBeenCalledWith('u1', 8, 0);
+ expect(out.pagination).toEqual({
+ page: 1,
+ limit: 8,
+ total: 0,
+ total_pages: 0,
+ });
+ });
});
describe('getConversation', () => {
@@ -207,9 +238,48 @@ describe('messages.service', () => {
expect(model.findMessagesByConversationId).toHaveBeenCalledWith('c1', 100, 0);
expect(out.pagination.total_pages).toBe(1);
});
+
+ it('uses default pagination when page/limit are invalid', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.findMessagesByConversationId.mockResolvedValue([]);
+ model.countMessagesByConversationId.mockResolvedValue(0);
+ model.findConversationPartner.mockResolvedValue({ id: 'u2' });
+ model.countUnreadMessages.mockResolvedValue(0);
+
+ const out = await service.getConversation({
+ conversationId: 'c1',
+ userId: 'u1',
+ page: 'abc',
+ limit: null,
+ });
+
+ expect(model.findMessagesByConversationId).toHaveBeenCalledWith('c1', 50, 0);
+ expect(out.pagination).toEqual({
+ page: 1,
+ limit: 50,
+ total: 0,
+ total_pages: 0,
+ });
+ });
});
describe('sendMessage', () => {
+ it('rejects when senderId is undefined', async () => {
+ await expect(
+ service.sendMessage({ conversationId: 'c1', senderId: undefined, body: 'x' })
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED', statusCode: 401 });
+
+ expect(model.findConversationById).not.toHaveBeenCalled();
+ });
+
+ it('rejects when senderId is null', async () => {
+ await expect(
+ service.sendMessage({ conversationId: 'c1', senderId: null, body: 'x' })
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED', statusCode: 401 });
+
+ expect(model.findConversationById).not.toHaveBeenCalled();
+ });
+
it('404 when conversation missing', async () => {
model.findConversationById.mockResolvedValue(null);
await expect(service.sendMessage({ conversationId: 'c1', senderId: 'u1', body: 'x' }))
diff --git a/tests/messages.socket.unit.test.js b/tests/messages.socket.unit.test.js
index f9f40a5..137ac10 100644
--- a/tests/messages.socket.unit.test.js
+++ b/tests/messages.socket.unit.test.js
@@ -41,6 +41,28 @@ describe('messages.socket', () => {
expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid conversationId.' });
});
+ it('message:join handles missing payload object', async () => {
+ const socket = mkSocket();
+ registerMessageHandlers({}, socket);
+
+ await socket._handlers['message:join']();
+
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid conversationId.' });
+ expect(db.query).not.toHaveBeenCalled();
+ });
+
+ it('message:join emits auth error when socket has no valid user', async () => {
+ const socket = mkSocket(null);
+ registerMessageHandlers({}, socket);
+
+ await socket._handlers['message:join']({
+ conversationId: '22222222-2222-2222-2222-222222222222',
+ });
+
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Authentication required.' });
+ expect(db.query).not.toHaveBeenCalled();
+ });
+
it('message:join emits forbidden when not participant', async () => {
const socket = mkSocket();
registerMessageHandlers({}, socket);
@@ -53,6 +75,13 @@ describe('messages.socket', () => {
expect(socket.emit).toHaveBeenCalledWith('error', {
message: 'You are not a participant in this conversation.',
});
+ expect(db.query).toHaveBeenCalledWith(
+ expect.stringContaining('SELECT 1 FROM conversations'),
+ [
+ '22222222-2222-2222-2222-222222222222',
+ '11111111-1111-1111-1111-111111111111',
+ ]
+ );
});
it('message:join handles db failure', async () => {
@@ -81,6 +110,7 @@ describe('messages.socket', () => {
expect(socket.join).toHaveBeenCalledWith(
'conversation:22222222-2222-2222-2222-222222222222'
);
+ expect(db.query).toHaveBeenCalledTimes(1);
});
it('message:leave leaves room', () => {
@@ -91,6 +121,26 @@ describe('messages.socket', () => {
expect(socket.leave).toHaveBeenCalledWith('conversation:c1');
});
+ it('message:leave rejects malformed payload', () => {
+ const socket = mkSocket();
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:leave']({});
+
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid payload.' });
+ expect(socket.leave).not.toHaveBeenCalled();
+ });
+
+ it('message:leave rejects unauthenticated socket', () => {
+ const socket = mkSocket(null);
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:leave']({ conversationId: 'c1' });
+
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Authentication required.' });
+ expect(socket.leave).not.toHaveBeenCalled();
+ });
+
it('message:send broadcasts received', () => {
const socket = mkSocket();
const emit = jest.fn();
@@ -106,6 +156,30 @@ describe('messages.socket', () => {
});
});
+ it('message:send rejects malformed payload', () => {
+ const socket = mkSocket();
+ const emit = jest.fn();
+ socket.to.mockReturnValue({ emit });
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:send']({ conversationId: 'c1' });
+
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid payload.' });
+ expect(emit).not.toHaveBeenCalled();
+ });
+
+ it('message:send rejects unauthenticated socket', () => {
+ const socket = mkSocket(null);
+ const emit = jest.fn();
+ socket.to.mockReturnValue({ emit });
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:send']({ conversationId: 'c1', message: { id: 'm1' } });
+
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Authentication required.' });
+ expect(emit).not.toHaveBeenCalled();
+ });
+
it('message:deleted broadcasts removed', () => {
const socket = mkSocket();
const emit = jest.fn();
@@ -120,6 +194,30 @@ describe('messages.socket', () => {
});
});
+ it('message:deleted rejects malformed payload', () => {
+ const socket = mkSocket();
+ const emit = jest.fn();
+ socket.to.mockReturnValue({ emit });
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:deleted']({ conversationId: 'c1' });
+
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid payload.' });
+ expect(emit).not.toHaveBeenCalled();
+ });
+
+ it('message:deleted rejects unauthenticated socket', () => {
+ const socket = mkSocket(null);
+ const emit = jest.fn();
+ socket.to.mockReturnValue({ emit });
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:deleted']({ conversationId: 'c1', messageId: 'm1' });
+
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Authentication required.' });
+ expect(emit).not.toHaveBeenCalled();
+ });
+
it('message:read broadcasts read_updated', () => {
const socket = mkSocket();
const emit = jest.fn();
@@ -141,6 +239,40 @@ describe('messages.socket', () => {
});
});
+ it('message:read rejects malformed payload', () => {
+ const socket = mkSocket();
+ const emit = jest.fn();
+ socket.to.mockReturnValue({ emit });
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:read']({
+ conversationId: 'c1',
+ messageId: 'm1',
+ isRead: 'yes',
+ conversationUnreadCount: 0,
+ });
+
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid payload.' });
+ expect(emit).not.toHaveBeenCalled();
+ });
+
+ it('message:read rejects unauthenticated socket', () => {
+ const socket = mkSocket(null);
+ const emit = jest.fn();
+ socket.to.mockReturnValue({ emit });
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:read']({
+ conversationId: 'c1',
+ messageId: 'm1',
+ isRead: true,
+ conversationUnreadCount: 0,
+ });
+
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Authentication required.' });
+ expect(emit).not.toHaveBeenCalled();
+ });
+
it('typing events broadcast', () => {
const socket = mkSocket();
const emit = jest.fn();
@@ -153,4 +285,66 @@ describe('messages.socket', () => {
socket._handlers['message:stop_typing']({ conversationId: 'c1' });
expect(emit).toHaveBeenCalledWith('message:stop_typing', { conversationId: 'c1', userId: socket.user.sub });
});
+
+ it('typing events reject malformed payload', () => {
+ const socket = mkSocket();
+ const emit = jest.fn();
+ socket.to.mockReturnValue({ emit });
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:typing']({});
+ socket._handlers['message:stop_typing']({});
+
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid payload.' });
+ expect(emit).not.toHaveBeenCalled();
+ });
+
+ it('typing events reject unauthenticated socket', () => {
+ const socket = mkSocket(null);
+ const emit = jest.fn();
+ socket.to.mockReturnValue({ emit });
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:typing']({ conversationId: 'c1' });
+ socket._handlers['message:stop_typing']({ conversationId: 'c1' });
+
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Authentication required.' });
+ expect(emit).not.toHaveBeenCalled();
+ });
+
+ it('typing events reject whitespace conversationId', () => {
+ const socket = mkSocket();
+ const emit = jest.fn();
+ socket.to.mockReturnValue({ emit });
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:typing']({ conversationId: ' ' });
+ socket._handlers['message:stop_typing']({ conversationId: ' ' });
+
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid payload.' });
+ expect(emit).not.toHaveBeenCalled();
+ });
+
+ it('handlers with default payload reject when called without arguments', () => {
+ const socket = mkSocket();
+ const emit = jest.fn();
+ socket.to.mockReturnValue({ emit });
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:leave']();
+ socket._handlers['message:send']();
+ socket._handlers['message:deleted']();
+ socket._handlers['message:read']();
+ socket._handlers['message:typing']();
+ socket._handlers['message:stop_typing']();
+
+ expect(socket.emit).toHaveBeenCalledTimes(6);
+ expect(socket.emit).toHaveBeenNthCalledWith(1, 'error', { message: 'Invalid payload.' });
+ expect(socket.emit).toHaveBeenNthCalledWith(2, 'error', { message: 'Invalid payload.' });
+ expect(socket.emit).toHaveBeenNthCalledWith(3, 'error', { message: 'Invalid payload.' });
+ expect(socket.emit).toHaveBeenNthCalledWith(4, 'error', { message: 'Invalid payload.' });
+ expect(socket.emit).toHaveBeenNthCalledWith(5, 'error', { message: 'Invalid payload.' });
+ expect(socket.emit).toHaveBeenNthCalledWith(6, 'error', { message: 'Invalid payload.' });
+ expect(emit).not.toHaveBeenCalled();
+ });
});
\ No newline at end of file
From 72e20efc17be677adcb189c1ef66c6bdedb827da Mon Sep 17 00:00:00 2001
From: Omar Hamza
Date: Wed, 18 Mar 2026 05:09:53 +0200
Subject: [PATCH 044/535] fix(users,genres): handle transaction client
correctly and harden update validations
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
fix replaceGenres to use db.connect() for proper transaction client acquisition, preventing crashes during genre updates
refactor completeOnboarding to build update fields conditionally and avoid sending undefined values to the service
add “nothing to update” validation for content and privacy settings to prevent no-op requests and align with existing update handlers
---
src/controllers/users.controller.js | 18 +++++++++---------
src/middleware/rate-limiter.js | 8 +++++++-
src/models/user.model.js | 2 +-
src/routes/users.routes.js | 5 +++--
src/services/users.service.js | 6 ++++++
5 files changed, 26 insertions(+), 13 deletions(-)
diff --git a/src/controllers/users.controller.js b/src/controllers/users.controller.js
index 5a29a1e..db05085 100644
--- a/src/controllers/users.controller.js
+++ b/src/controllers/users.controller.js
@@ -160,14 +160,14 @@ exports.replaceMyGenres = async (req, res) => {
};
exports.completeOnboarding = async (req, res) => {
- const { display_name, gender, date_of_birth, bio, city, country } = req.body;
- const data = await usersService.completeOnboarding(req.user.sub, {
- display_name,
- gender,
- date_of_birth,
- bio,
- city,
- country,
- });
+ const fields = {};
+ if (req.body.display_name !== undefined) fields.display_name = req.body.display_name;
+ if (req.body.gender !== undefined) fields.gender = req.body.gender;
+ if (req.body.date_of_birth !== undefined) fields.date_of_birth = req.body.date_of_birth;
+ if (req.body.bio !== undefined) fields.bio = req.body.bio;
+ if (req.body.city !== undefined) fields.city = req.body.city;
+ if (req.body.country !== undefined) fields.country = req.body.country;
+
+ const data = await usersService.completeOnboarding(req.user.sub, fields);
return success(res, data, 'Profile onboarding completed successfully.');
};
diff --git a/src/middleware/rate-limiter.js b/src/middleware/rate-limiter.js
index c8617fb..0c3a8d9 100644
--- a/src/middleware/rate-limiter.js
+++ b/src/middleware/rate-limiter.js
@@ -22,4 +22,10 @@ const uploadLimiter = rateLimit({
message: { success: false, message: 'Upload limit reached, please try again later.' },
});
-module.exports = { generalLimiter, authLimiter, uploadLimiter };
+const refreshLimiter = rateLimit({
+ windowMs: 15 * 60 * 1000,
+ max: process.env.NODE_ENV === 'production' ? 30 : 200,
+ message: { success: false, message: 'Too many refresh attempts.' },
+});
+
+module.exports = { generalLimiter, authLimiter, refreshLimiter, uploadLimiter };
\ No newline at end of file
diff --git a/src/models/user.model.js b/src/models/user.model.js
index 5f93974..9400766 100644
--- a/src/models/user.model.js
+++ b/src/models/user.model.js
@@ -369,7 +369,7 @@ exports.findGenresByUserId = async (userId) => {
};
exports.replaceGenres = async (userId, genreIds) => {
- const client = await db.getClient();
+ const client = await db.connect();
try {
await client.query('BEGIN');
await client.query(`DELETE FROM user_favorite_genres WHERE user_id = $1`, [userId]);
diff --git a/src/routes/users.routes.js b/src/routes/users.routes.js
index 89a91c5..2d83d9c 100644
--- a/src/routes/users.routes.js
+++ b/src/routes/users.routes.js
@@ -14,8 +14,6 @@ router.get('/me', authenticate, asyncHandler(controller.getMe));
router.patch('/me', authenticate, asyncHandler(controller.updateMe));
router.patch('/me/account', authenticate, asyncHandler(controller.updateMyAccount));
router.patch('/me/role', authenticate, asyncHandler(controller.switchRole));
-
-router.get('/:user_id', optionalAuthenticate, asyncHandler(controller.getUserById));
router.delete('/me/avatar', authenticate, asyncHandler(controller.deleteMyAvatar));
router.post(
'/me/avatar',
@@ -54,4 +52,7 @@ router.get('/me/genres', authenticate, asyncHandler(controller.getMyGenres));
router.put('/me/genres', authenticate, asyncHandler(controller.replaceMyGenres));
router.patch('/me/onboarding', authenticate, asyncHandler(controller.completeOnboarding));
+
+router.get('/:user_id', optionalAuthenticate, asyncHandler(controller.getUserById));
+
module.exports = router;
diff --git a/src/services/users.service.js b/src/services/users.service.js
index 208d8fd..3be0557 100644
--- a/src/services/users.service.js
+++ b/src/services/users.service.js
@@ -216,6 +216,9 @@ exports.updateMyContentSettings = async (userId, settings) => {
throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
}
const updated = await userModel.updateContentSettings(userId, settings);
+ if (!updated) {
+ throw new AppError('Nothing to update', 400, 'VALIDATION_FAILED');
+ }
return updated;
};
exports.getMyPrivacySettings = async (userId) => {
@@ -228,6 +231,9 @@ exports.updateMyPrivacySettings = async (userId, settings) => {
throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
}
const updated = await userModel.updatePrivacySettings(userId, settings);
+ if (!updated) {
+ throw new AppError('Nothing to update', 400, 'VALIDATION_FAILED');
+ }
return updated;
};
From ee8c1ab3fbfb7650d72bb72c0ed5479d9411fb01 Mon Sep 17 00:00:00 2001
From: Omar Hamza
Date: Wed, 18 Mar 2026 05:25:04 +0200
Subject: [PATCH 045/535] fix(auth): separate refresh rate limiter from auth
routes
- Remove global authLimiter applied to all auth routes
- Add dedicated refreshLimiter (30 req/15min prod, 200 dev)
- Apply per-route rate limiting so refresh calls don't
consume the strict authLimiter quota (5 req/15min prod)
---
src/routes/auth.routes.js | 27 ++++++++++-----------------
1 file changed, 10 insertions(+), 17 deletions(-)
diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js
index ae14428..4a6a1b3 100644
--- a/src/routes/auth.routes.js
+++ b/src/routes/auth.routes.js
@@ -1,26 +1,19 @@
-// ============================================================
-// routes/auth.routes.js
-// Owner : Omar Hamdy (BE-1)
-// Module 1 — Authentication
-// ============================================================
const express = require('express');
const router = express.Router();
const controller = require('../controllers/auth.controller');
-const { authLimiter } = require('../middleware/rate-limiter');
+const { authLimiter, refreshLimiter } = require('../middleware/rate-limiter');
const { validateRegister } = require('../middleware/validate-register');
const asyncHandler = require('../utils/async-handler');
-// Apply auth rate limiter to ALL routes in this file
-router.use(authLimiter);
-
-// POST /api/v1/auth/register
-router.post('/register', validateRegister, asyncHandler(controller.register));
-router.post('/verify-email', asyncHandler(controller.verifyEmail));
-router.post('/login', asyncHandler(controller.login));
-router.post('/refresh', asyncHandler(controller.refresh));
-router.post('/logout', asyncHandler(controller.logout));
-router.post('/forgot-password', asyncHandler(controller.requestPasswordReset));
-router.post('/reset-password', asyncHandler(controller.resetPassword));
+
+router.post('/register', authLimiter, validateRegister, asyncHandler(controller.register));
+router.post('/verify-email', authLimiter, asyncHandler(controller.verifyEmail));
+router.post('/login', authLimiter, asyncHandler(controller.login));
+router.post('/refresh', refreshLimiter, asyncHandler(controller.refresh));
+router.post('/logout', asyncHandler(controller.logout));
+router.post('/forgot-password', authLimiter, asyncHandler(controller.requestPasswordReset));
+router.post('/reset-password', authLimiter, asyncHandler(controller.resetPassword));
+
// i didn't implement the controllers yet
From 540a919413050248a8099dc55ea28ab40117c012 Mon Sep 17 00:00:00 2001
From: basboosa05 <166862890+basboosa05@users.noreply.github.com>
Date: Wed, 18 Mar 2026 09:49:10 +0200
Subject: [PATCH 046/535] Add Google OAuth login and models
Add Google Sign-In support: introduce google-auth-library dependency, a new oauth_connections model, and a user.createOAuthUser helper. Implement googleLogin in auth.service to verify Google ID tokens, link or create users, store/update OAuth connections, and issue access/refresh tokens. Expose a /google auth route and controller action that validates input, sets a refresh_token cookie, and returns the access token and user info. Also import parseDurationToSeconds where needed for expires_in calculation.
---
package-lock.json | 212 +++++++++++++++++++++++++++
package.json | 1 +
src/controllers/auth.controller.js | 39 +++++
src/models/oauth-connection.model.js | 37 +++++
src/models/user.model.js | 10 ++
src/routes/auth.routes.js | 2 +-
src/services/auth.service.js | 87 +++++++++++
7 files changed, 387 insertions(+), 1 deletion(-)
create mode 100644 src/models/oauth-connection.model.js
diff --git a/package-lock.json b/package-lock.json
index a1c2d00..677905a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,6 +19,7 @@
"dotenv": "^16.4.5",
"express": "^4.18.3",
"express-rate-limit": "^7.2.0",
+ "google-auth-library": "^10.6.2",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
@@ -2894,6 +2895,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/ajv": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
@@ -3130,6 +3140,26 @@
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
@@ -3172,6 +3202,15 @@
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ=="
},
+ "node_modules/bignumber.js": {
+ "version": "9.3.1",
+ "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
+ "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -3739,6 +3778,15 @@
"node": ">=0.4.0"
}
},
+ "node_modules/data-uri-to-buffer": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
+ "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/db-migrate": {
"version": "0.11.14",
"resolved": "https://registry.npmjs.org/db-migrate/-/db-migrate-0.11.14.tgz",
@@ -4533,6 +4581,12 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+ "license": "MIT"
+ },
"node_modules/eyes": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz",
@@ -4617,6 +4671,29 @@
"bser": "2.1.1"
}
},
+ "node_modules/fetch-blob": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
+ "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/jimmywarting"
+ },
+ {
+ "type": "paypal",
+ "url": "https://paypal.me/jimmywarting"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "node-domexception": "^1.0.0",
+ "web-streams-polyfill": "^3.0.3"
+ },
+ "engines": {
+ "node": "^12.20 || >= 14.13"
+ }
+ },
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -4733,6 +4810,18 @@
"node": ">= 6"
}
},
+ "node_modules/formdata-polyfill": {
+ "version": "4.0.10",
+ "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
+ "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
+ "license": "MIT",
+ "dependencies": {
+ "fetch-blob": "^3.1.2"
+ },
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
"node_modules/formidable": {
"version": "3.5.4",
"resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
@@ -4794,6 +4883,34 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/gaxios": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz",
+ "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "extend": "^3.0.2",
+ "https-proxy-agent": "^7.0.1",
+ "node-fetch": "^3.3.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/gcp-metadata": {
+ "version": "8.1.2",
+ "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
+ "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "gaxios": "^7.0.0",
+ "google-logging-utils": "^1.0.0",
+ "json-bigint": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -4915,6 +5032,32 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/google-auth-library": {
+ "version": "10.6.2",
+ "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz",
+ "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "base64-js": "^1.3.0",
+ "ecdsa-sig-formatter": "^1.0.11",
+ "gaxios": "^7.1.4",
+ "gcp-metadata": "8.1.2",
+ "google-logging-utils": "1.1.3",
+ "jws": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/google-logging-utils": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
+ "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -5017,6 +5160,19 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/human-signals": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
@@ -5927,6 +6083,15 @@
"node": ">=6"
}
},
+ "node_modules/json-bigint": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
+ "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bignumber.js": "^9.0.0"
+ }
+ },
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@@ -6352,6 +6517,44 @@
"node": ">= 0.6"
}
},
+ "node_modules/node-domexception": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
+ "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
+ "deprecated": "Use your platform's native DOMException instead",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/jimmywarting"
+ },
+ {
+ "type": "github",
+ "url": "https://paypal.me/jimmywarting"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.5.0"
+ }
+ },
+ "node_modules/node-fetch": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
+ "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
+ "license": "MIT",
+ "dependencies": {
+ "data-uri-to-buffer": "^4.0.0",
+ "fetch-blob": "^3.1.4",
+ "formdata-polyfill": "^4.0.10"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/node-fetch"
+ }
+ },
"node_modules/node-fs": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/node-fs/-/node-fs-0.1.7.tgz",
@@ -8190,6 +8393,15 @@
"makeerror": "1.0.12"
}
},
+ "node_modules/web-streams-polyfill": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
+ "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/when": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/when/-/when-2.0.1.tgz",
diff --git a/package.json b/package.json
index 287e218..7ea0536 100644
--- a/package.json
+++ b/package.json
@@ -29,6 +29,7 @@
"dotenv": "^16.4.5",
"express": "^4.18.3",
"express-rate-limit": "^7.2.0",
+ "google-auth-library": "^10.6.2",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js
index c17893e..097ab80 100644
--- a/src/controllers/auth.controller.js
+++ b/src/controllers/auth.controller.js
@@ -6,6 +6,7 @@
const authService = require('../services/auth.service');
const { success } = require('../utils/api-response');
const { error } = require('../utils/api-response');
+const { parseDurationToSeconds } = require('../utils/token-generator');
const { isValidEmail, isValidPassword } = require('../utils/validators');
exports.register = async (req, res) => {
@@ -227,4 +228,42 @@ exports.verifyEmailChange = async (req, res) => {
const data = await authService.verifyEmailChange({ token });
return success(res, data, 'Email updated successfully.');
+};
+
+exports.googleLogin = async (req, res) => {
+ const { id_token } = req.body;
+
+ if (!id_token || typeof id_token !== 'string') {
+ return error(res, 'VALIDATION_FAILED', 'Validation failed', 400, [
+ { field: 'id_token', issue: 'Google id_token is required' },
+ ]);
+ }
+
+ const result = await authService.googleLogin({ id_token });
+
+ res.cookie('refresh_token', result.refreshToken, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'strict',
+ maxAge: 7 * 24 * 60 * 60 * 1000,
+ });
+
+ return success(
+ res,
+ {
+ access_token: result.accessToken,
+ token_type: 'Bearer',
+ expires_in: parseDurationToSeconds(process.env.JWT_ACCESS_EXPIRES_IN),
+ is_new_user: result.is_new_user,
+ user: {
+ user_id: result.user.id,
+ email: result.user.email,
+ display_name: result.user.display_name,
+ gender: result.user.gender,
+ role: result.user.role,
+ is_verified: result.user.is_verified,
+ },
+ },
+ 'Logged in successfully with Google.'
+ );
};
\ No newline at end of file
diff --git a/src/models/oauth-connection.model.js b/src/models/oauth-connection.model.js
new file mode 100644
index 0000000..fe36e4d
--- /dev/null
+++ b/src/models/oauth-connection.model.js
@@ -0,0 +1,37 @@
+// ============================================================
+// models/oauth-connection.model.js
+// Table: oauth_connections
+// ============================================================
+const db = require('../config/db');
+
+// Find an existing OAuth connection with provider and provider_user_id
+exports.findByProvider = async (provider, providerUserId) => {
+ const { rows } = await db.query(
+ `SELECT * FROM oauth_connections
+ WHERE provider = $1 AND provider_user_id = $2`,
+ [provider, providerUserId]
+ );
+ return rows[0] || null;
+};
+
+// Create a new OAuth connection linking a Rythmify user to a provider
+exports.create = async ({ user_id, provider, provider_user_id, access_token, refresh_token, expires_at }) => {
+ const { rows } = await db.query(
+ `INSERT INTO oauth_connections
+ (user_id, provider, provider_user_id, access_token, refresh_token, expires_at)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ RETURNING *`,
+ [user_id, provider, provider_user_id, access_token || null, refresh_token || null, expires_at || null]
+ );
+ return rows[0];
+};
+
+// Update provider tokens on re-login
+exports.updateTokens = async ({ user_id, provider, access_token, refresh_token, expires_at }) => {
+ await db.query(
+ `UPDATE oauth_connections
+ SET access_token = $3, refresh_token = $4, expires_at = $5, updated_at = now()
+ WHERE user_id = $1 AND provider = $2`,
+ [user_id, provider, access_token || null, refresh_token || null, expires_at || null]
+ );
+};
\ No newline at end of file
diff --git a/src/models/user.model.js b/src/models/user.model.js
index 6b770f6..eea86b5 100644
--- a/src/models/user.model.js
+++ b/src/models/user.model.js
@@ -201,6 +201,16 @@ exports.updateRole = async (userId, newRole) => {
return rows[0] || null;
}
+exports.createOAuthUser = async ({ email, display_name }) => {
+ const { rows } = await db.query(
+ `INSERT INTO users (email, display_name, is_verified)
+ VALUES ($1, $2, true)
+ RETURNING id, email, display_name, gender, role, is_verified, created_at`,
+ [email, display_name]
+ );
+ return rows[0];
+};
+
// Set pending_email (called when user requests email change)
exports.setPendingEmail = async (userId, pendingEmail) => {
await db.query(
diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js
index 500e119..a2ccfab 100644
--- a/src/routes/auth.routes.js
+++ b/src/routes/auth.routes.js
@@ -27,7 +27,7 @@ router.post('/verify-email-change', asyncHandler(controller.verifyEmailChange)
// Protected routes
router.post('/change-email', authenticate, asyncHandler(controller.changeEmail));
-
+router.post('/google', asyncHandler(controller.googleLogin));
// router.post('/resend-verification', asyncHandler(controller.resendVerification));
diff --git a/src/services/auth.service.js b/src/services/auth.service.js
index 9ba7b13..c3d8645 100644
--- a/src/services/auth.service.js
+++ b/src/services/auth.service.js
@@ -1,7 +1,9 @@
const bcrypt = require('bcryptjs');
+const { OAuth2Client } = require('google-auth-library');
const userModel = require('../models/user.model');
const verificationTokenModel = require('../models/verification-token.model');
const refreshTokenModel = require('../models/refresh-token.model');
+const oauthConnectionModel = require('../models/oauth-connection.model');
const { generateSecureToken, parseDurationToSeconds } = require('../utils/token-generator');
const TOKEN_TYPES = require('../constants/token-types');
const {
@@ -14,6 +16,8 @@ const AppError = require('../utils/app-error');
const { signAccessToken, signRefreshToken, verifyToken } = require('../config/jwt');
const env = require('../config/env');
+
+
//=====================================
// Registration and Email verification
//=====================================
@@ -366,4 +370,87 @@ exports.verifyEmailChange = async ({ token }) => {
const updated = await userModel.applyPendingEmail(tokenRow.user_id);
return { email: updated.email };
+};
+
+// ============================================================
+// Google OAuth
+// ============================================================
+const googleClient = new OAuth2Client(env.GOOGLE_CLIENT_ID);
+exports.googleLogin = async ({ id_token }) => {
+ // verify the token from google
+ let payload;
+ try {
+ const ticket = await googleClient.verifyIdToken({
+ idToken: id_token,
+ audience: env.GOOGLE_CLIENT_ID,
+ });
+ payload = ticket.getPayload();
+ } catch {
+ throw new AppError('Google token invalid', 401, 'AUTH_TOKEN_INVALID');
+ }
+
+ // after
+ const { sub: providerUserId, email, given_name, name } = payload;
+
+ // check if this account is already connected with google
+ const existingConnection = await oauthConnectionModel.findByProvider('google', providerUserId);
+
+ if (existingConnection) {
+ // we need to only update the token
+ const user = await userModel.findById(existingConnection.user_id);
+
+ if (user.is_suspended) {
+ throw new AppError('Account suspended by admin', 403, 'AUTH_ACCOUNT_SUSPENDED');
+ }
+
+ await oauthConnectionModel.updateTokens({
+ user_id: user.id,
+ provider: 'google',
+ access_token: null,
+ refresh_token: null,
+ expires_at: null,
+ });
+
+ const accessToken = signAccessToken({ sub: user.id, role: user.role });
+ const refreshToken = await createAndStoreRefreshToken(user.id);
+
+ return {
+ accessToken,
+ refreshToken,
+ is_new_user: false,
+ user,
+ };
+ }
+
+ // checking if the email exists in the normal users
+ let user = await userModel.findByEmail(email);
+ let is_new_user = false;
+
+ if (!user) {
+ user = await userModel.createOAuthUser({
+ email,
+ display_name: given_name || name || email.split('@')[0],
+ });
+ is_new_user = true;
+ }
+
+ if (user.is_suspended) {
+ throw new AppError('Account suspended by admin', 403, 'AUTH_ACCOUNT_SUSPENDED');
+ }
+
+ await oauthConnectionModel.create({
+ user_id: user.id,
+ provider: 'google',
+ provider_user_id: providerUserId,
+ });
+
+ const accessToken = signAccessToken({ sub: user.id, role: user.role });
+ const refreshToken = await createAndStoreRefreshToken(user.id);
+
+ return {
+ accessToken,
+ refreshToken,
+ is_new_user,
+ user,
+ };
};
\ No newline at end of file
From 2a835bfab919c4e3d42435dc8917c6e46f4db1d4 Mon Sep 17 00:00:00 2001
From: Mohamed jameel alabasy
Date: Wed, 18 Mar 2026 12:12:10 +0200
Subject: [PATCH 047/535] feat: migrate from AWS S3 to Azure Blob Storage and
update configuration
---
.env.example | 66 ---------------------------------
Dockerfile | 12 ++++++
database.json | 8 ++++
docker-compose.yml | 32 ----------------
package.json | 4 +-
server.js | 1 +
src/config/b2.js | 13 -------
src/config/blob.js | 24 ++++++++++++
src/config/env.js | 22 +++--------
src/config/s3.js | 14 -------
src/services/storage.service.js | 56 +++++++++++++++++++++-------
11 files changed, 94 insertions(+), 158 deletions(-)
delete mode 100644 .env.example
create mode 100644 Dockerfile
create mode 100644 database.json
delete mode 100644 docker-compose.yml
delete mode 100644 src/config/b2.js
create mode 100644 src/config/blob.js
delete mode 100644 src/config/s3.js
diff --git a/.env.example b/.env.example
deleted file mode 100644
index a252a4f..0000000
--- a/.env.example
+++ /dev/null
@@ -1,66 +0,0 @@
-# Server
-PORT=8080
-NODE_ENV=development
-
-# Database
-POSTGRES_USER=dev
-POSTGRES_PASSWORD=devpassword
-POSTGRES_DB=rythmify_dev
-DATABASE_URL=postgresql://dev:devpassword@localhost:5433/rythmify_dev
-PGADMIN_EMAIL=admin@admin.com
-PGADMIN_PASSWORD=admin
-
-DB_HOST=localhost
-DB_PORT=5433
-
-# AWS S3 Bucket
-B2_KEY_NAME=rythmify-backend
-B2_KEY_ID=00389e3391f7d430000000001
-B2_APP_KEY=K003la6oXIGOmD8HECvxzsGlLUtH9vk
-B2_REGION=eu-central-003
-B2_BUCKET_NAME=rythmify-private
-B2_ENDPOINT=https://s3.eu-central-003.backblazeb2.com
-
-
-# Email (Nodemailer + Gmail)
-GMAIL_USER=rythmify.noreply@gmail.com
-GMAIL_APP_PASSWORD=wqouswwukowchgwh
-EMAIL_FROM=Rythmify
-
-# captcha
-RECAPTCHA_SECRET=your_secret_key_from_google
-
-
-# JWT
-JWT_SECRET=your_jwt_secret
-JWT_ACCESS_EXPIRES_IN=15m
-JWT_REFRESH_EXPIRES_IN=7d
-
-# Google OAuth
-GOOGLE_CLIENT_ID=your_google_client_id
-GOOGLE_CLIENT_SECRET=your_google_client_secret
-GOOGLE_CALLBACK_URL=http://localhost:8080/api/v1/auth/google/callback
-
-# SoundCloud OAuth
-SOUNDCLOUD_CLIENT_ID=your_soundcloud_client_id
-SOUNDCLOUD_CLIENT_SECRET=your_soundcloud_client_secret
-
-# Cloudinary (alternative to S3)
-CLOUDINARY_CLOUD_NAME=your_cloud_name
-CLOUDINARY_API_KEY=your_api_key
-CLOUDINARY_API_SECRET=your_api_secret
-
-# Stripe (mocked)
-STRIPE_SECRET_KEY=sk_test_your_stripe_key
-STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
-
-# Email (Nodemailer / SendGrid)
-SMTP_HOST=smtp.sendgrid.net
-SMTP_PORT=587
-SMTP_USER=apikey
-SMTP_PASS=your_sendgrid_api_key
-EMAIL_FROM=noreply@rythmify.com
-
-# App
-BASE_URL=http://localhost:8080
-CLIENT_URL=http://localhost:3000
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..f7e0e16
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,12 @@
+FROM node:20-alpine
+
+WORKDIR /app
+
+COPY package*.json ./
+RUN npm install
+
+COPY . .
+
+EXPOSE 8080
+
+CMD ["npm", "run", "dev"]
\ No newline at end of file
diff --git a/database.json b/database.json
new file mode 100644
index 0000000..2d5f1f5
--- /dev/null
+++ b/database.json
@@ -0,0 +1,8 @@
+{
+ "dev": {
+ "driver": "pg",
+ "connectionString": {
+ "ENV": "DATABASE_URL"
+ }
+ }
+}
diff --git a/docker-compose.yml b/docker-compose.yml
deleted file mode 100644
index 679f134..0000000
--- a/docker-compose.yml
+++ /dev/null
@@ -1,32 +0,0 @@
-name: rythmify
-
-services:
- db:
- image: postgres:16
- container_name: rythmify_db
- restart: unless-stopped
- env_file:
- - .env
- environment:
- POSTGRES_USER: ${POSTGRES_USER}
- POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
- POSTGRES_DB: ${POSTGRES_DB}
- ports:
- - "5433:5432" # 5433 in use on my machine to avoid conflicts (saja) - adjust as needed
- # the first number refers to the port on your host machine, and the second number refers to the port inside the container.
- volumes:
- - postgres_data:/var/lib/postgresql/data
-
- pgadmin:
- image: dpage/pgadmin4
- container_name: rythmify_pgadmin
- environment:
- PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL}
- PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD}
- ports:
- - "5050:80"
- depends_on:
- - db
-
-volumes:
- postgres_data:
\ No newline at end of file
diff --git a/package.json b/package.json
index a4e903f..c35cea3 100644
--- a/package.json
+++ b/package.json
@@ -18,10 +18,8 @@
"testEnvironment": "node"
},
"dependencies": {
- "@aws-sdk/client-s3": "^3.1009.0",
- "@aws-sdk/s3-request-presigner": "^3.1008.0",
+ "@azure/storage-blob": "12.17.0",
"bcryptjs": "^2.4.3",
- "cloudinary": "^1.41.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"db-migrate": "^0.11.14",
diff --git a/server.js b/server.js
index 11b1864..e7b5d18 100644
--- a/server.js
+++ b/server.js
@@ -28,4 +28,5 @@ io.on('connection', (socket) => {
server.listen(env.PORT, () => {
console.log(`Rythmify backend running on port ${env.PORT} [${env.NODE_ENV}]`);
console.log(`API Base URL: http://localhost:${env.PORT}/api/v1`);
+ console.log('Note: Create blob containers manually via Azure Storage Explorer');
});
diff --git a/src/config/b2.js b/src/config/b2.js
deleted file mode 100644
index 81f4d0a..0000000
--- a/src/config/b2.js
+++ /dev/null
@@ -1,13 +0,0 @@
-const { S3Client } = require('@aws-sdk/client-s3');
-const env = require('./env');
-
-const b2 = new S3Client({
- credentials: {
- accessKeyId: env.B2_KEY_ID,
- secretAccessKey: env.B2_APP_KEY,
- },
- region: env.B2_REGION,
- endpoint: env.B2_ENDPOINT,
-});
-
-module.exports = b2;
\ No newline at end of file
diff --git a/src/config/blob.js b/src/config/blob.js
new file mode 100644
index 0000000..02321af
--- /dev/null
+++ b/src/config/blob.js
@@ -0,0 +1,24 @@
+const { BlobServiceClient, StorageSharedKeyCredential } = require('@azure/storage-blob');
+const env = require('./env');
+
+const account = 'devstoreaccount1';
+const accountKey = 'Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OcuhHHH4a+GCKoV9wJ5Lw==';
+
+const sharedKeyCredential = new StorageSharedKeyCredential(account, accountKey);
+
+const blobServiceClient = new BlobServiceClient(
+ `http://rythmify_blob:10000/${account}`,
+ sharedKeyCredential
+);
+
+async function initBlobContainers() {
+ const audioContainer = blobServiceClient.getContainerClient(env.BLOB_CONTAINER_AUDIO);
+ await audioContainer.createIfNotExists();
+
+ const mediaContainer = blobServiceClient.getContainerClient(env.BLOB_CONTAINER_MEDIA);
+ await mediaContainer.createIfNotExists({ access: 'blob' });
+
+ console.log('Blob containers initialized');
+}
+
+module.exports = { blobServiceClient, initBlobContainers };
diff --git a/src/config/env.js b/src/config/env.js
index 06804d5..6fe3767 100644
--- a/src/config/env.js
+++ b/src/config/env.js
@@ -14,7 +14,6 @@ module.exports = {
DATABASE_URL: process.env.DATABASE_URL,
-
RECAPTCHA_SECRET: process.env.RECAPTCHA_SECRET,
JWT_SECRET: process.env.JWT_SECRET,
JWT_ACCESS_EXPIRES_IN: process.env.JWT_ACCESS_EXPIRES_IN || '15m',
@@ -24,27 +23,16 @@ module.exports = {
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
GOOGLE_CALLBACK_URL: process.env.GOOGLE_CALLBACK_URL,
- AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
- AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
- AWS_BUCKET_NAME: process.env.AWS_BUCKET_NAME,
- AWS_REGION: process.env.AWS_REGION,
-
- CLOUDINARY_CLOUD_NAME: process.env.CLOUDINARY_CLOUD_NAME,
- CLOUDINARY_API_KEY: process.env.CLOUDINARY_API_KEY,
- CLOUDINARY_API_SECRET: process.env.CLOUDINARY_API_SECRET,
+ AZURE_STORAGE_CONNECTION_STRING: process.env.AZURE_STORAGE_CONNECTION_STRING,
+ BLOB_CONTAINER_AUDIO: process.env.BLOB_CONTAINER_AUDIO || 'audio',
+ BLOB_CONTAINER_MEDIA: process.env.BLOB_CONTAINER_MEDIA || 'media',
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
- B2_KEY_ID: process.env.B2_KEY_ID,
- B2_APP_KEY: process.env.B2_APP_KEY,
- B2_REGION: process.env.B2_REGION,
- B2_ENDPOINT: process.env.B2_ENDPOINT,
- B2_BUCKET_NAME: process.env.B2_BUCKET_NAME,
-
BASE_URL: process.env.BASE_URL || 'http://localhost:8080',
CLIENT_URL: process.env.CLIENT_URL || 'http://localhost:3000',
- MAX_FILE_SIZE_AUDIO: 100 * 1024 * 1024, // 100 MB
- MAX_FILE_SIZE_IMAGE: 5 * 1024 * 1024, // 5 MB
+ MAX_FILE_SIZE_AUDIO: 100 * 1024 * 1024, // 100 MB
+ MAX_FILE_SIZE_IMAGE: 5 * 1024 * 1024, // 5 MB
};
diff --git a/src/config/s3.js b/src/config/s3.js
deleted file mode 100644
index d6d0d53..0000000
--- a/src/config/s3.js
+++ /dev/null
@@ -1,14 +0,0 @@
-// ============================================================
-// config/s3.js — AWS S3 / Cloudinary client setup
-// Audio and images stored externally; only URLs kept in PostgreSQL
-// ============================================================
-const { v2: cloudinary } = require('cloudinary');
-const env = require('./env');
-
-cloudinary.config({
- cloud_name: env.CLOUDINARY_CLOUD_NAME,
- api_key: env.CLOUDINARY_API_KEY,
- api_secret: env.CLOUDINARY_API_SECRET,
-});
-
-module.exports = cloudinary;
diff --git a/src/services/storage.service.js b/src/services/storage.service.js
index 62e1161..8653e79 100644
--- a/src/services/storage.service.js
+++ b/src/services/storage.service.js
@@ -1,28 +1,58 @@
+// aws storage
const {
PutObjectCommand,
DeleteObjectCommand,
ListObjectVersionsCommand,
} = require('@aws-sdk/client-s3');
const b2 = require('../config/b2');
+
const env = require('../config/env');
+// azure storage
+const { BlobServiceClient } = require('@azure/storage-blob');
+
+const blobServiceClient = BlobServiceClient.fromConnectionString(
+ env.AZURE_STORAGE_CONNECTION_STRING
+);
+
+const getContainerClient = (type) => {
+ const containerName = type === 'audio' ? env.BLOB_CONTAINER_AUDIO : env.BLOB_CONTAINER_MEDIA;
+ return blobServiceClient.getContainerClient(containerName);
+};
+
+// using azure
const uploadTrack = async (file, key) => {
- const result = await b2.send(
- new PutObjectCommand({
- Bucket: env.B2_BUCKET_NAME,
- Key: key,
- Body: file.buffer,
- ContentType: file.mimetype,
- })
- );
+ const containerClient = getContainerClient('audio');
+ const blockBlobClient = containerClient.getBlockBlobClient(key);
+
+ await blockBlobClient.uploadData(file.buffer, {
+ blobHTTPHeaders: { blobContentType: file.mimetype },
+ });
return {
key,
- url: `${env.B2_ENDPOINT}/${env.B2_BUCKET_NAME}/${key}`,
- versionId: result.VersionId || null,
+ url: blockBlobClient.url,
};
};
+// using aws
+// const uploadTrack = async (file, key) => {
+// const result = await b2.send(
+// new PutObjectCommand({
+// Bucket: env.B2_BUCKET_NAME,
+// Key: key,
+// Body: file.buffer,
+// ContentType: file.mimetype,
+// })
+// );
+
+// return {
+// key,
+// url: `${env.B2_ENDPOINT}/${env.B2_BUCKET_NAME}/${key}`,
+// versionId: result.VersionId || null,
+// };
+// };
+
const uploadImage = async (file, key) => {
const result = await b2.send(
new PutObjectCommand({
@@ -81,8 +111,8 @@ const deleteAllVersionsByUrl = async (fileUrl) => {
);
const versions = [
- ...((response.Versions || []).filter((item) => item.Key === key)),
- ...((response.DeleteMarkers || []).filter((item) => item.Key === key)),
+ ...(response.Versions || []).filter((item) => item.Key === key),
+ ...(response.DeleteMarkers || []).filter((item) => item.Key === key),
];
for (const item of versions) {
@@ -112,4 +142,4 @@ module.exports = {
deleteObject,
deleteAllVersionsByUrl,
deleteManyByUrls,
-};
\ No newline at end of file
+};
From d2777c3662a0a67248c73dcf30f5a51661a1889f Mon Sep 17 00:00:00 2001
From: Mohamed jameel alabasy
Date: Wed, 18 Mar 2026 15:48:47 +0200
Subject: [PATCH 048/535] add .dockerignore
---
.dockerignore | 11 +++++++++++
1 file changed, 11 insertions(+)
create mode 100644 .dockerignore
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..818d0a5
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,11 @@
+node_modules
+.git
+.gitignore
+.env
+.env.*
+*.log
+npm-debug.log*
+dist
+coverage
+.nyc_output
+tests
\ No newline at end of file
From e74328c7206309fcef97a2bf61430f7160c7e0a8 Mon Sep 17 00:00:00 2001
From: Omar Hamza
Date: Wed, 18 Mar 2026 22:57:28 +0200
Subject: [PATCH 049/535] fix(cors): allow multiple client origins for
cross-team development
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Replace single CLIENT_URL origin with comma-separated multi-origin support
- Parse CLIENT_URL env variable as comma-separated list of allowed origins
- Fixes CORS error blocking frontend running on localhost:5173 (Vite default)
- No code changes needed when adding new origins — update .env only
---
app.js | 15 ++++++++++++++-
1 file changed, 14 insertions(+), 1 deletion(-)
diff --git a/app.js b/app.js
index 0caaf97..8a8ae2d 100644
--- a/app.js
+++ b/app.js
@@ -27,7 +27,20 @@ const app = express();
// ── Global middleware ──────────────────────────────────────
app.use(helmet());
-app.use(cors({ origin: env.CLIENT_URL, credentials: true }));
+
+const allowedOrigins = env.CLIENT_URL.split(',').map(o => o.trim());
+
+app.use(cors({
+ origin: (origin, callback) => {
+ if (!origin || allowedOrigins.includes(origin)) {
+ callback(null, true);
+ } else {
+ callback(new Error('Not allowed by CORS'));
+ }
+ },
+ credentials: true
+}));
+
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(generalLimiter);
From f1b3534e4c630d56cf2e112836243bc3501c8bda Mon Sep 17 00:00:00 2001
From: Mohamed jameel alabasy
Date: Thu, 19 Mar 2026 01:08:13 +0200
Subject: [PATCH 050/535] commented storage.service.js until refactor done
---
src/services/storage.service.js | 168 ++++++++++++++++----------------
1 file changed, 84 insertions(+), 84 deletions(-)
diff --git a/src/services/storage.service.js b/src/services/storage.service.js
index 8653e79..49f1dd9 100644
--- a/src/services/storage.service.js
+++ b/src/services/storage.service.js
@@ -1,10 +1,10 @@
-// aws storage
-const {
- PutObjectCommand,
- DeleteObjectCommand,
- ListObjectVersionsCommand,
-} = require('@aws-sdk/client-s3');
-const b2 = require('../config/b2');
+// // aws storage
+// const {
+// PutObjectCommand,
+// DeleteObjectCommand,
+// ListObjectVersionsCommand,
+// } = require('@aws-sdk/client-s3');
+// const b2 = require('../config/b2');
const env = require('../config/env');
@@ -53,93 +53,93 @@ const uploadTrack = async (file, key) => {
// };
// };
-const uploadImage = async (file, key) => {
- const result = await b2.send(
- new PutObjectCommand({
- Bucket: env.B2_BUCKET_NAME,
- Key: key,
- Body: file.buffer,
- ContentType: file.mimetype,
- })
- );
+// const uploadImage = async (file, key) => {
+// const result = await b2.send(
+// new PutObjectCommand({
+// Bucket: env.B2_BUCKET_NAME,
+// Key: key,
+// Body: file.buffer,
+// ContentType: file.mimetype,
+// })
+// );
- return {
- key,
- url: `${env.B2_ENDPOINT}/${env.B2_BUCKET_NAME}/${key}`,
- versionId: result.VersionId || null,
- };
-};
+// return {
+// key,
+// url: `${env.B2_ENDPOINT}/${env.B2_BUCKET_NAME}/${key}`,
+// versionId: result.VersionId || null,
+// };
+// };
-const getKeyFromUrl = (fileUrl) => {
- if (!fileUrl) return null;
+// const getKeyFromUrl = (fileUrl) => {
+// if (!fileUrl) return null;
- const basePrefix = `${env.B2_ENDPOINT}/${env.B2_BUCKET_NAME}/`;
+// const basePrefix = `${env.B2_ENDPOINT}/${env.B2_BUCKET_NAME}/`;
- if (!fileUrl.startsWith(basePrefix)) {
- throw new Error(`Invalid B2 object URL: ${fileUrl}`);
- }
+// if (!fileUrl.startsWith(basePrefix)) {
+// throw new Error(`Invalid B2 object URL: ${fileUrl}`);
+// }
- return decodeURIComponent(fileUrl.slice(basePrefix.length).split('?')[0]);
-};
+// return decodeURIComponent(fileUrl.slice(basePrefix.length).split('?')[0]);
+// };
-const deleteObject = async (key, versionId) => {
- await b2.send(
- new DeleteObjectCommand({
- Bucket: env.B2_BUCKET_NAME,
- Key: key,
- VersionId: versionId,
- })
- );
-};
+// const deleteObject = async (key, versionId) => {
+// await b2.send(
+// new DeleteObjectCommand({
+// Bucket: env.B2_BUCKET_NAME,
+// Key: key,
+// VersionId: versionId,
+// })
+// );
+// };
-const deleteAllVersionsByUrl = async (fileUrl) => {
- const key = getKeyFromUrl(fileUrl);
- if (!key) return 0;
-
- let keyMarker;
- let versionIdMarker;
- let deletedCount = 0;
-
- do {
- const response = await b2.send(
- new ListObjectVersionsCommand({
- Bucket: env.B2_BUCKET_NAME,
- Prefix: key,
- KeyMarker: keyMarker,
- VersionIdMarker: versionIdMarker,
- })
- );
-
- const versions = [
- ...(response.Versions || []).filter((item) => item.Key === key),
- ...(response.DeleteMarkers || []).filter((item) => item.Key === key),
- ];
-
- for (const item of versions) {
- await deleteObject(key, item.VersionId);
- deletedCount += 1;
- }
-
- keyMarker = response.IsTruncated ? response.NextKeyMarker : undefined;
- versionIdMarker = response.IsTruncated ? response.NextVersionIdMarker : undefined;
- } while (keyMarker);
-
- return deletedCount;
-};
+// const deleteAllVersionsByUrl = async (fileUrl) => {
+// const key = getKeyFromUrl(fileUrl);
+// if (!key) return 0;
+
+// let keyMarker;
+// let versionIdMarker;
+// let deletedCount = 0;
+
+// do {
+// const response = await b2.send(
+// new ListObjectVersionsCommand({
+// Bucket: env.B2_BUCKET_NAME,
+// Prefix: key,
+// KeyMarker: keyMarker,
+// VersionIdMarker: versionIdMarker,
+// })
+// );
+
+// const versions = [
+// ...(response.Versions || []).filter((item) => item.Key === key),
+// ...(response.DeleteMarkers || []).filter((item) => item.Key === key),
+// ];
+
+// for (const item of versions) {
+// await deleteObject(key, item.VersionId);
+// deletedCount += 1;
+// }
+
+// keyMarker = response.IsTruncated ? response.NextKeyMarker : undefined;
+// versionIdMarker = response.IsTruncated ? response.NextVersionIdMarker : undefined;
+// } while (keyMarker);
+
+// return deletedCount;
+// };
-const deleteManyByUrls = async (urls = []) => {
- const uniqueUrls = [...new Set(urls.filter(Boolean))];
+// const deleteManyByUrls = async (urls = []) => {
+// const uniqueUrls = [...new Set(urls.filter(Boolean))];
- for (const url of uniqueUrls) {
- await deleteAllVersionsByUrl(url);
- }
-};
+// for (const url of uniqueUrls) {
+// await deleteAllVersionsByUrl(url);
+// }
+// };
module.exports = {
uploadTrack,
- uploadImage,
- getKeyFromUrl,
- deleteObject,
- deleteAllVersionsByUrl,
- deleteManyByUrls,
+ // uploadImage,
+ // getKeyFromUrl,
+ // deleteObject,
+ // deleteAllVersionsByUrl,
+ // deleteManyByUrls,
};
From d8e95c3bfad311e4f2e4d6b02e51ebec8178be6f Mon Sep 17 00:00:00 2001
From: Omar Hamza
Date: Thu, 19 Mar 2026 05:08:16 +0200
Subject: [PATCH 051/535] const { randomUUID } = require('crypto');
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add jti (JWT ID) claim using randomUUID() to refresh token payload
- Fixes 23505 unique constraint violation on refresh_tokens table
- Collision occurred when two tokens were signed for the same user
within the same second — identical payload produced identical JWT string
- randomUUID() guarantees uniqueness regardless of timing or concurrency
---
src/services/auth.service.js | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/src/services/auth.service.js b/src/services/auth.service.js
index 277cf11..f3687de 100644
--- a/src/services/auth.service.js
+++ b/src/services/auth.service.js
@@ -15,6 +15,7 @@ const {
const AppError = require('../utils/app-error');
const { signAccessToken, signRefreshToken, verifyToken } = require('../config/jwt');
const env = require('../config/env');
+const { randomUUID } = require('crypto');
@@ -123,8 +124,13 @@ const buildAuthResponse = ({ user, accessToken, refreshToken }) => ({
is_new_user: false,
});
+
const createAndStoreRefreshToken = async (userId) => {
- const refreshToken = signRefreshToken({ sub: userId });
+ const refreshToken = signRefreshToken({
+ sub: userId,
+ jti: randomUUID()
+ });
+
const refreshExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
await refreshTokenModel.create({
From 2284b0acda8491041305a55ae01ce9564c96e179 Mon Sep 17 00:00:00 2001
From: Omar Hamza
Date: Thu, 19 Mar 2026 05:29:40 +0200
Subject: [PATCH 052/535] fix(auth): harden refresh token rotation against
concurrent load
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Problem:
Concurrent refresh requests were causing 23505 unique constraint
violations and connection pile-ups under stress test conditions.
Changes:
- Narrow findValid SELECT to only required columns (id, user_id,
expires_at) to reduce I/O on high-frequency token checks
- Replace separate revoke + insert with atomic rotateToken transaction
using FOR UPDATE SKIP LOCKED — concurrent requests that find the
row already locked skip immediately and return 401 instead of
queuing, preventing connection pool exhaustion under load
- Generate new refresh token (with jti: randomUUID()) before entering
the transaction so the INSERT is ready the moment the lock is acquired
- Fail fast with AUTH_REFRESH_TOKEN_INVALID when rotation returns null,
covering both already-revoked and concurrently-locked token cases
- Leave logout, revokeAllForUser, and all other token paths untouched —
changes are isolated to the refresh rotation flow only
---
src/models/refresh-token.model.js | 71 +++++++++++++++++++++++++------
src/services/auth.service.js | 21 ++++++++-
2 files changed, 76 insertions(+), 16 deletions(-)
diff --git a/src/models/refresh-token.model.js b/src/models/refresh-token.model.js
index 7e43232..2590374 100644
--- a/src/models/refresh-token.model.js
+++ b/src/models/refresh-token.model.js
@@ -1,37 +1,35 @@
// ============================================================
-// models/refresh-token.model.js — PostgreSQL queries for Refresh_Tokens
-// Entity attributes: Token_id, User_Id, Refresh_token, Expires_at, Revoked
-// All SQL lives HERE — no SQL outside models/
+// models/refresh-token.model.js
// ============================================================
const db = require('../config/db');
-
-
// Store a new refresh token in the database
exports.create = async ({ user_id, refresh_token, expires_at }) => {
const { rows } = await db.query(
`INSERT INTO refresh_tokens (user_id, refresh_token, expires_at)
VALUES ($1, $2, $3)
+ ON CONFLICT (refresh_token) DO NOTHING
RETURNING id, user_id, refresh_token, expires_at, created_at`,
[user_id, refresh_token, expires_at]
);
return rows[0];
};
-
-// Find a valid refresh token must pass ALL conditions Used in: refresh and logout endpoints
+// Find a valid refresh token — narrow select to reduce I/O under load
exports.findValid = async (token) => {
const { rows } = await db.query(
- `SELECT * FROM refresh_tokens
- WHERE refresh_token = $1
- AND revoked = false
- AND expires_at > now()`,
+ `SELECT id, user_id, expires_at
+ FROM refresh_tokens
+ WHERE refresh_token = $1
+ AND revoked = false
+ AND expires_at > now()
+ LIMIT 1`,
[token]
);
return rows[0] || null;
};
-// Revoke a single refresh token by its token string Used in: logout endpoint (revoke current token)
+// Revoke a single refresh token
exports.revoke = async (token) => {
await db.query(
`UPDATE refresh_tokens
@@ -41,7 +39,7 @@ exports.revoke = async (token) => {
);
};
-// Revoke ALL refresh tokens for a user Used in: reset-password (logout all devices), account suspension (admin action)
+// Revoke ALL refresh tokens for a user
exports.revokeAllForUser = async (userId) => {
await db.query(
`UPDATE refresh_tokens
@@ -50,4 +48,49 @@ exports.revokeAllForUser = async (userId) => {
AND revoked = false`,
[userId]
);
-};
\ No newline at end of file
+};
+
+// Atomic: revoke old token + insert new token in one transaction
+// Uses SKIP LOCKED to avoid blocking when a token is being rotated concurrently
+exports.rotateToken = async ({ oldToken, userId, newToken, expiresAt }) => {
+ const client = await db.connect();
+ try {
+ await client.query('BEGIN');
+
+ // Lock the token row if it is still valid; skip if another process already locked it
+ const { rows } = await client.query(
+ `SELECT id
+ FROM refresh_tokens
+ WHERE refresh_token = $1
+ AND revoked = false
+ FOR UPDATE SKIP LOCKED`,
+ [oldToken]
+ );
+
+ if (rows.length === 0) {
+ await client.query('ROLLBACK');
+ return null;
+ }
+
+ await client.query(
+ `UPDATE refresh_tokens
+ SET revoked = true
+ WHERE id = $1`,
+ [rows[0].id]
+ );
+
+ await client.query(
+ `INSERT INTO refresh_tokens (user_id, refresh_token, expires_at)
+ VALUES ($1, $2, $3)`,
+ [userId, newToken, expiresAt]
+ );
+
+ await client.query('COMMIT');
+ return newToken;
+ } catch (err) {
+ await client.query('ROLLBACK');
+ throw err;
+ } finally {
+ client.release();
+ }
+};
diff --git a/src/services/auth.service.js b/src/services/auth.service.js
index f3687de..3754cc8 100644
--- a/src/services/auth.service.js
+++ b/src/services/auth.service.js
@@ -216,10 +216,27 @@ exports.refresh = async ({ refresh_token }) => {
throw new AppError('Account suspended by admin', 403, 'AUTH_ACCOUNT_SUSPENDED');
}
- await refreshTokenModel.revoke(refresh_token);
+ // Generate new token before transaction
+ const newRefreshToken = signRefreshToken({ sub: user.id, jti: randomUUID() });
+ const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
+
+ // Atomic revoke old + insert new — prevents concurrent request collision
+ const result = await refreshTokenModel.rotateToken({
+ oldToken: refresh_token,
+ userId: user.id,
+ newToken: newRefreshToken,
+ expiresAt,
+ });
+
+ if (!result) {
+ throw new AppError(
+ 'Refresh token already used',
+ 401,
+ 'AUTH_REFRESH_TOKEN_INVALID'
+ );
+ }
const accessToken = signAccessToken({ sub: user.id, role: user.role });
- const newRefreshToken = await createAndStoreRefreshToken(user.id);
return {
access_token: accessToken,
From 3341ce2cc8ccfa85269faad89e0d8145b7f175fd Mon Sep 17 00:00:00 2001
From: Omar Hamza
Date: Thu, 19 Mar 2026 07:03:41 +0200
Subject: [PATCH 053/535] feat(auth): improve email links and harden refresh
token rotation
add APP_URL env and update email templates to use it for verification, reset, and email change links
enhance refresh token rotation with conflict-safe insert (ON CONFLICT DO NOTHING)
add logging for locked-row count during rotation
rollback transaction when insert fails to prevent duplicate key errors
improves stability under concurrent load and eliminates 23505 errors during stress testing
---
src/config/env.js | 1 +
src/models/refresh-token.model.js | 13 +++++++++++--
src/utils/mailer.js | 8 ++++----
3 files changed, 16 insertions(+), 6 deletions(-)
diff --git a/src/config/env.js b/src/config/env.js
index 6fe3767..3bedea8 100644
--- a/src/config/env.js
+++ b/src/config/env.js
@@ -32,6 +32,7 @@ module.exports = {
BASE_URL: process.env.BASE_URL || 'http://localhost:8080',
CLIENT_URL: process.env.CLIENT_URL || 'http://localhost:3000',
+ APP_URL: process.env.APP_URL || 'http://localhost:5173',
MAX_FILE_SIZE_AUDIO: 100 * 1024 * 1024, // 100 MB
MAX_FILE_SIZE_IMAGE: 5 * 1024 * 1024, // 5 MB
diff --git a/src/models/refresh-token.model.js b/src/models/refresh-token.model.js
index 2590374..1e32377 100644
--- a/src/models/refresh-token.model.js
+++ b/src/models/refresh-token.model.js
@@ -67,6 +67,8 @@ exports.rotateToken = async ({ oldToken, userId, newToken, expiresAt }) => {
[oldToken]
);
+ console.log('[rotateToken] rows found:', rows.length);
+
if (rows.length === 0) {
await client.query('ROLLBACK');
return null;
@@ -79,12 +81,19 @@ exports.rotateToken = async ({ oldToken, userId, newToken, expiresAt }) => {
[rows[0].id]
);
- await client.query(
+ const inserted = await client.query(
`INSERT INTO refresh_tokens (user_id, refresh_token, expires_at)
- VALUES ($1, $2, $3)`,
+ VALUES ($1, $2, $3)
+ ON CONFLICT (refresh_token) DO NOTHING
+ RETURNING id`,
[userId, newToken, expiresAt]
);
+ if (inserted.rows.length === 0) {
+ await client.query('ROLLBACK');
+ return null;
+ }
+
await client.query('COMMIT');
return newToken;
} catch (err) {
diff --git a/src/utils/mailer.js b/src/utils/mailer.js
index 81fdeaa..84ccd30 100644
--- a/src/utils/mailer.js
+++ b/src/utils/mailer.js
@@ -99,7 +99,7 @@ const ctaButton = (href, label) => `
// verify email
const sendVerificationEmail = async (to, { displayName, token }) => {
- const link = `${env.CLIENT_URL}/verify-email?token=${token}`;
+ const link = `${env.APP_URL}/verify-email?token=${token}`;
const bodyContent = `
@@ -132,7 +132,7 @@ const sendVerificationEmail = async (to, { displayName, token }) => {
// resend verification email
const sendResendVerificationEmail = async (to, { displayName, token }) => {
- const link = `${env.CLIENT_URL}/verify-email?token=${token}`;
+ const link = `${env.APP_URL}/verify-email?token=${token}`;
const bodyContent = `
@@ -164,7 +164,7 @@ const sendResendVerificationEmail = async (to, { displayName, token }) => {
// password reset email
const sendPasswordResetEmail = async (to, { displayName, token }) => {
- const link = `${env.CLIENT_URL}/reset-password?token=${token}`;
+ const link = `${env.APP_URL}/reset-password?token=${token}`;
const bodyContent = `
@@ -199,7 +199,7 @@ const sendPasswordResetEmail = async (to, { displayName, token }) => {
// email change verification
const sendEmailChangeEmail = async (to, { displayName, token }) => {
- const link = `${env.CLIENT_URL}/verify-email-change?token=${token}`;
+ const link = `${env.APP_URL}/verify-email-change?token=${token}`;
const bodyContent = `
From 362dda89247325b179702a81c46c187501c2d80e Mon Sep 17 00:00:00 2001
From: Mohamed jameel alabasy
Date: Thu, 19 Mar 2026 09:49:50 +0200
Subject: [PATCH 054/535] refactored storage.servuce to work with azurite
instead of aws
---
src/services/storage.service.js | 209 ++++++++++++++------------------
1 file changed, 88 insertions(+), 121 deletions(-)
diff --git a/src/services/storage.service.js b/src/services/storage.service.js
index 49f1dd9..e91fee7 100644
--- a/src/services/storage.service.js
+++ b/src/services/storage.service.js
@@ -1,145 +1,112 @@
-// // aws storage
-// const {
-// PutObjectCommand,
-// DeleteObjectCommand,
-// ListObjectVersionsCommand,
-// } = require('@aws-sdk/client-s3');
-// const b2 = require('../config/b2');
-
-const env = require('../config/env');
-
-// azure storage
+// Azure Blob Storage / Azurite storage service
const { BlobServiceClient } = require('@azure/storage-blob');
+const env = require('../config/env');
const blobServiceClient = BlobServiceClient.fromConnectionString(
env.AZURE_STORAGE_CONNECTION_STRING
);
-const getContainerClient = (type) => {
- const containerName = type === 'audio' ? env.BLOB_CONTAINER_AUDIO : env.BLOB_CONTAINER_MEDIA;
+const getContainerName = (type) => {
+ return type === 'audio' ? env.BLOB_CONTAINER_AUDIO : env.BLOB_CONTAINER_MEDIA;
+};
+
+const getContainerClient = (typeOrName) => {
+ const containerName =
+ typeOrName === 'audio' || typeOrName === 'media' ? getContainerName(typeOrName) : typeOrName;
+
return blobServiceClient.getContainerClient(containerName);
};
-// using azure
-const uploadTrack = async (file, key) => {
- const containerClient = getContainerClient('audio');
+const uploadBlob = async (file, key, type) => {
+ const containerClient = getContainerClient(type);
+ await containerClient.createIfNotExists();
+
const blockBlobClient = containerClient.getBlockBlobClient(key);
await blockBlobClient.uploadData(file.buffer, {
- blobHTTPHeaders: { blobContentType: file.mimetype },
+ blobHTTPHeaders: {
+ blobContentType: file.mimetype,
+ },
});
return {
key,
url: blockBlobClient.url,
+ versionId: null,
};
};
-// using aws
-// const uploadTrack = async (file, key) => {
-// const result = await b2.send(
-// new PutObjectCommand({
-// Bucket: env.B2_BUCKET_NAME,
-// Key: key,
-// Body: file.buffer,
-// ContentType: file.mimetype,
-// })
-// );
-
-// return {
-// key,
-// url: `${env.B2_ENDPOINT}/${env.B2_BUCKET_NAME}/${key}`,
-// versionId: result.VersionId || null,
-// };
-// };
-
-// const uploadImage = async (file, key) => {
-// const result = await b2.send(
-// new PutObjectCommand({
-// Bucket: env.B2_BUCKET_NAME,
-// Key: key,
-// Body: file.buffer,
-// ContentType: file.mimetype,
-// })
-// );
-
-// return {
-// key,
-// url: `${env.B2_ENDPOINT}/${env.B2_BUCKET_NAME}/${key}`,
-// versionId: result.VersionId || null,
-// };
-// };
-
-// const getKeyFromUrl = (fileUrl) => {
-// if (!fileUrl) return null;
-
-// const basePrefix = `${env.B2_ENDPOINT}/${env.B2_BUCKET_NAME}/`;
-
-// if (!fileUrl.startsWith(basePrefix)) {
-// throw new Error(`Invalid B2 object URL: ${fileUrl}`);
-// }
-
-// return decodeURIComponent(fileUrl.slice(basePrefix.length).split('?')[0]);
-// };
-
-// const deleteObject = async (key, versionId) => {
-// await b2.send(
-// new DeleteObjectCommand({
-// Bucket: env.B2_BUCKET_NAME,
-// Key: key,
-// VersionId: versionId,
-// })
-// );
-// };
-
-// const deleteAllVersionsByUrl = async (fileUrl) => {
-// const key = getKeyFromUrl(fileUrl);
-// if (!key) return 0;
-
-// let keyMarker;
-// let versionIdMarker;
-// let deletedCount = 0;
-
-// do {
-// const response = await b2.send(
-// new ListObjectVersionsCommand({
-// Bucket: env.B2_BUCKET_NAME,
-// Prefix: key,
-// KeyMarker: keyMarker,
-// VersionIdMarker: versionIdMarker,
-// })
-// );
-
-// const versions = [
-// ...(response.Versions || []).filter((item) => item.Key === key),
-// ...(response.DeleteMarkers || []).filter((item) => item.Key === key),
-// ];
-
-// for (const item of versions) {
-// await deleteObject(key, item.VersionId);
-// deletedCount += 1;
-// }
-
-// keyMarker = response.IsTruncated ? response.NextKeyMarker : undefined;
-// versionIdMarker = response.IsTruncated ? response.NextVersionIdMarker : undefined;
-// } while (keyMarker);
-
-// return deletedCount;
-// };
-
-// const deleteManyByUrls = async (urls = []) => {
-// const uniqueUrls = [...new Set(urls.filter(Boolean))];
-
-// for (const url of uniqueUrls) {
-// await deleteAllVersionsByUrl(url);
-// }
-// };
+const uploadTrack = async (file, key) => {
+ return uploadBlob(file, key, 'audio');
+};
+
+const uploadImage = async (file, key) => {
+ return uploadBlob(file, key, 'media');
+};
+
+const parseAzureBlobUrl = (fileUrl) => {
+ if (!fileUrl) return null;
+
+ const url = new URL(fileUrl);
+ const pathSegments = decodeURIComponent(url.pathname).split('/').filter(Boolean);
+
+ const knownContainers = [env.BLOB_CONTAINER_AUDIO, env.BLOB_CONTAINER_MEDIA].filter(Boolean);
+
+ const containerIndex = pathSegments.findIndex((segment) => knownContainers.includes(segment));
+
+ if (containerIndex === -1 || containerIndex === pathSegments.length - 1) {
+ throw new Error(`Invalid Azure blob URL: ${fileUrl}`);
+ }
+
+ return {
+ containerName: pathSegments[containerIndex],
+ blobName: pathSegments.slice(containerIndex + 1).join('/'),
+ };
+};
+
+const getKeyFromUrl = (fileUrl) => {
+ const parsed = parseAzureBlobUrl(fileUrl);
+ return parsed ? parsed.blobName : null;
+};
+
+const deleteObject = async (key, _versionId = null, type = 'audio') => {
+ const containerClient = getContainerClient(type);
+ const blockBlobClient = containerClient.getBlockBlobClient(key);
+
+ const result = await blockBlobClient.deleteIfExists({
+ deleteSnapshots: 'include',
+ });
+
+ return result.succeeded ? 1 : 0;
+};
+
+const deleteAllVersionsByUrl = async (fileUrl) => {
+ const parsed = parseAzureBlobUrl(fileUrl);
+ if (!parsed) return 0;
+
+ const containerClient = getContainerClient(parsed.containerName);
+ const blockBlobClient = containerClient.getBlockBlobClient(parsed.blobName);
+
+ const result = await blockBlobClient.deleteIfExists({
+ deleteSnapshots: 'include',
+ });
+
+ return result.succeeded ? 1 : 0;
+};
+
+const deleteManyByUrls = async (urls = []) => {
+ const uniqueUrls = [...new Set(urls.filter(Boolean))];
+
+ for (const url of uniqueUrls) {
+ await deleteAllVersionsByUrl(url);
+ }
+};
module.exports = {
uploadTrack,
- // uploadImage,
- // getKeyFromUrl,
- // deleteObject,
- // deleteAllVersionsByUrl,
- // deleteManyByUrls,
+ uploadImage,
+ getKeyFromUrl,
+ deleteObject,
+ deleteAllVersionsByUrl,
+ deleteManyByUrls,
};
From 0f9aecb44cfb06a62852b39c9e0c04cd64dd2424 Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Thu, 19 Mar 2026 15:08:55 +0200
Subject: [PATCH 055/535] refactor(storage): replace Backblaze B2 with Azure
Blob Storage
---
src/services/storage.service.js | 176 +++++++++++++++-----------------
1 file changed, 83 insertions(+), 93 deletions(-)
diff --git a/src/services/storage.service.js b/src/services/storage.service.js
index 8653e79..16d4712 100644
--- a/src/services/storage.service.js
+++ b/src/services/storage.service.js
@@ -1,130 +1,120 @@
-// aws storage
-const {
- PutObjectCommand,
- DeleteObjectCommand,
- ListObjectVersionsCommand,
-} = require('@aws-sdk/client-s3');
-const b2 = require('../config/b2');
-
-const env = require('../config/env');
-
-// azure storage
+// Azure Blob Storage / Azurite storage service
const { BlobServiceClient } = require('@azure/storage-blob');
+const env = require('../config/env');
const blobServiceClient = BlobServiceClient.fromConnectionString(
env.AZURE_STORAGE_CONNECTION_STRING
);
-const getContainerClient = (type) => {
- const containerName = type === 'audio' ? env.BLOB_CONTAINER_AUDIO : env.BLOB_CONTAINER_MEDIA;
+// Helper function to build public blob URL
+const buildPublicBlobUrl = (containerName, key) => {
+ const baseUrl = env.PUBLIC_BLOB_BASE_URL?.replace(/\/+$/, '');
+ const encodedKey = key.split('/').map(encodeURIComponent).join('/');
+
+ if (!baseUrl) {
+ return null;
+ }
+
+ return `${baseUrl}/${containerName}/${encodedKey}`;
+};
+
+const getContainerName = (type) => {
+ return type === 'audio'
+ ? env.BLOB_CONTAINER_AUDIO
+ : env.BLOB_CONTAINER_MEDIA;
+};
+
+const getContainerClient = (typeOrName) => {
+ const containerName =
+ typeOrName === 'audio' || typeOrName === 'media'
+ ? getContainerName(typeOrName)
+ : typeOrName;
+
return blobServiceClient.getContainerClient(containerName);
};
-// using azure
-const uploadTrack = async (file, key) => {
- const containerClient = getContainerClient('audio');
+const uploadBlob = async (file, key, type) => {
+ const containerClient = getContainerClient(type);
+ await containerClient.createIfNotExists();
+
const blockBlobClient = containerClient.getBlockBlobClient(key);
await blockBlobClient.uploadData(file.buffer, {
- blobHTTPHeaders: { blobContentType: file.mimetype },
+ blobHTTPHeaders: {
+ blobContentType: file.mimetype,
+ },
});
return {
key,
url: blockBlobClient.url,
+ versionId: null,
};
};
-// using aws
-// const uploadTrack = async (file, key) => {
-// const result = await b2.send(
-// new PutObjectCommand({
-// Bucket: env.B2_BUCKET_NAME,
-// Key: key,
-// Body: file.buffer,
-// ContentType: file.mimetype,
-// })
-// );
-
-// return {
-// key,
-// url: `${env.B2_ENDPOINT}/${env.B2_BUCKET_NAME}/${key}`,
-// versionId: result.VersionId || null,
-// };
-// };
+const uploadTrack = async (file, key) => {
+ return uploadBlob(file, key, 'audio');
+};
const uploadImage = async (file, key) => {
- const result = await b2.send(
- new PutObjectCommand({
- Bucket: env.B2_BUCKET_NAME,
- Key: key,
- Body: file.buffer,
- ContentType: file.mimetype,
- })
+ return uploadBlob(file, key, 'media');
+};
+
+const parseAzureBlobUrl = (fileUrl) => {
+ if (!fileUrl) return null;
+
+ const url = new URL(fileUrl);
+ const pathSegments = decodeURIComponent(url.pathname)
+ .split('/')
+ .filter(Boolean);
+
+ const knownContainers = [
+ env.BLOB_CONTAINER_AUDIO,
+ env.BLOB_CONTAINER_MEDIA,
+ ].filter(Boolean);
+
+ const containerIndex = pathSegments.findIndex((segment) =>
+ knownContainers.includes(segment)
);
+ if (containerIndex === -1 || containerIndex === pathSegments.length - 1) {
+ throw new Error(`Invalid Azure blob URL: ${fileUrl}`);
+ }
+
return {
- key,
- url: `${env.B2_ENDPOINT}/${env.B2_BUCKET_NAME}/${key}`,
- versionId: result.VersionId || null,
+ containerName: pathSegments[containerIndex],
+ blobName: pathSegments.slice(containerIndex + 1).join('/'),
};
};
const getKeyFromUrl = (fileUrl) => {
- if (!fileUrl) return null;
-
- const basePrefix = `${env.B2_ENDPOINT}/${env.B2_BUCKET_NAME}/`;
+ const parsed = parseAzureBlobUrl(fileUrl);
+ return parsed ? parsed.blobName : null;
+};
- if (!fileUrl.startsWith(basePrefix)) {
- throw new Error(`Invalid B2 object URL: ${fileUrl}`);
- }
+const deleteObject = async (key, _versionId = null, type = 'audio') => {
+ const containerClient = getContainerClient(type);
+ const blockBlobClient = containerClient.getBlockBlobClient(key);
- return decodeURIComponent(fileUrl.slice(basePrefix.length).split('?')[0]);
-};
+ const result = await blockBlobClient.deleteIfExists({
+ deleteSnapshots: 'include',
+ });
-const deleteObject = async (key, versionId) => {
- await b2.send(
- new DeleteObjectCommand({
- Bucket: env.B2_BUCKET_NAME,
- Key: key,
- VersionId: versionId,
- })
- );
+ return result.succeeded ? 1 : 0;
};
const deleteAllVersionsByUrl = async (fileUrl) => {
- const key = getKeyFromUrl(fileUrl);
- if (!key) return 0;
-
- let keyMarker;
- let versionIdMarker;
- let deletedCount = 0;
-
- do {
- const response = await b2.send(
- new ListObjectVersionsCommand({
- Bucket: env.B2_BUCKET_NAME,
- Prefix: key,
- KeyMarker: keyMarker,
- VersionIdMarker: versionIdMarker,
- })
- );
-
- const versions = [
- ...(response.Versions || []).filter((item) => item.Key === key),
- ...(response.DeleteMarkers || []).filter((item) => item.Key === key),
- ];
-
- for (const item of versions) {
- await deleteObject(key, item.VersionId);
- deletedCount += 1;
- }
-
- keyMarker = response.IsTruncated ? response.NextKeyMarker : undefined;
- versionIdMarker = response.IsTruncated ? response.NextVersionIdMarker : undefined;
- } while (keyMarker);
-
- return deletedCount;
+ const parsed = parseAzureBlobUrl(fileUrl);
+ if (!parsed) return 0;
+
+ const containerClient = getContainerClient(parsed.containerName);
+ const blockBlobClient = containerClient.getBlockBlobClient(parsed.blobName);
+
+ const result = await blockBlobClient.deleteIfExists({
+ deleteSnapshots: 'include',
+ });
+
+ return result.succeeded ? 1 : 0;
};
const deleteManyByUrls = async (urls = []) => {
@@ -142,4 +132,4 @@ module.exports = {
deleteObject,
deleteAllVersionsByUrl,
deleteManyByUrls,
-};
+};
\ No newline at end of file
From dbf4077c598ff13d27ae91c875aa395d3e573fa1 Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Thu, 19 Mar 2026 15:32:39 +0200
Subject: [PATCH 056/535] Fix message soft-delete restore and tighten socket
validation
-messages.socket.js: Refactored socket authorization to use service-layer access checks instead of raw SQL, enforced participant validation across all conversation-scoped events, converted handlers to async where needed, and tightened messageId validation to UUID.
-messages.service.js: Added reusable assertConversationAccess guard (existence, participant, soft-delete visibility checks) and updated sendMessage restore logic to unhide conversation for both sender and recipient when either side had soft-deleted.
-messages.socket.unit.test.js: Reworked socket tests to mock service-layer access checks (instead of DB), updated payload expectations for UUID-based validation, converted handler invocations to async, and added non-participant rejection coverage.
-messages.service.unit.test.js: Added branch coverage for assertConversationAccess and added restore-flow tests for sender-deleted and both-sides-deleted scenarios.
-messages.model.unit.test.js: Added comprehensive model unit tests covering all message model methods and both outcomes for row/no-row branches.
---
src/services/messages.service.js | 52 ++++++--
src/sockets/messages.socket.js | 106 ++++++++-------
tests/messages.model.unit.test.js | 170 +++++++++++++++++++++++++
tests/messages.service.unit.test.js | 63 +++++++++
tests/messages.socket.unit.test.js | 191 +++++++++++++++-------------
5 files changed, 440 insertions(+), 142 deletions(-)
create mode 100644 tests/messages.model.unit.test.js
diff --git a/src/services/messages.service.js b/src/services/messages.service.js
index b2b1b47..c674edf 100644
--- a/src/services/messages.service.js
+++ b/src/services/messages.service.js
@@ -8,6 +8,31 @@ const validateSenderId = (senderId) => {
}
};
+exports.assertConversationAccess = async ({ conversationId, userId, allowSoftDeleted = false }) => {
+ const conversation = await messageModel.findConversationById(conversationId);
+ if (!conversation) {
+ throw new AppError('Conversation not found.', 404, 'CONVERSATION_NOT_FOUND');
+ }
+
+ const isParticipant =
+ conversation.user_a_id === userId ||
+ conversation.user_b_id === userId;
+ if (!isParticipant) {
+ throw new AppError('You do not have access to this conversation.', 403, 'FORBIDDEN');
+ }
+
+ if (!allowSoftDeleted) {
+ const deletedByUser =
+ (conversation.user_a_id === userId && conversation.deleted_by_a) ||
+ (conversation.user_b_id === userId && conversation.deleted_by_b);
+ if (deletedByUser) {
+ throw new AppError('Conversation not found.', 404, 'CONVERSATION_NOT_FOUND');
+ }
+ }
+
+ return conversation;
+};
+
// ------------------------------------------------------------
// Endpoint 1 — Start a new conversation
// ------------------------------------------------------------
@@ -210,13 +235,22 @@ exports.sendMessage = async ({ conversationId, senderId, body, resource }) => {
throw new AppError('You do not have access to this conversation.', 403, 'FORBIDDEN');
}
- // 3. Identify the recipient first — needed for restore check below
+ // 3. Identify participants for restore checks below
+ const senderIsUserA = conversation.user_a_id === senderId;
const recipientId =
- conversation.user_a_id === senderId
+ senderIsUserA
? conversation.user_b_id
: conversation.user_a_id;
- // 4. Restore conversation for recipient if they had soft-deleted it
+ // 4. Restore conversation for sender if they had soft-deleted it
+ const deletedBySender =
+ (senderIsUserA && conversation.deleted_by_a) ||
+ (!senderIsUserA && conversation.deleted_by_b);
+ if (deletedBySender) {
+ await messageModel.restoreConversationForUser(conversationId, senderIsUserA);
+ }
+
+ // 5. Restore conversation for recipient if they had soft-deleted it
const recipientIsUserA = conversation.user_a_id === recipientId;
const deletedByRecipient =
(recipientIsUserA && conversation.deleted_by_a) ||
@@ -227,13 +261,13 @@ exports.sendMessage = async ({ conversationId, senderId, body, resource }) => {
}
- // 5. Check if recipient has blocked the sender
+ // 6. Check if recipient has blocked the sender
const recipientBlockedSender = await messageModel.isBlocked(recipientId, senderId);
if (recipientBlockedSender) {
throw new AppError('You cannot send a message to this user.', 403, 'MESSAGES_BLOCKED');
}
- // 6. Check recipient's messages_from preference
+ // 7. Check recipient's messages_from preference
// 'followers_only' means the recipient must be following the sender
const messagesFrom = await messageModel.getMessagesFromPreference(recipientId);
if (messagesFrom === 'followers_only') {
@@ -243,25 +277,25 @@ exports.sendMessage = async ({ conversationId, senderId, body, resource }) => {
}
}
- // 7. Validate message content — at least body or resource required
+ // 8. Validate message content — at least body or resource required
const hasBody = typeof body === 'string' && body.trim().length > 0;
const hasResource = resource && resource.type && resource.id;
if (!hasBody && !hasResource) {
throw new AppError('A message must contain a body or an embedded resource.', 400, 'MESSAGES_EMPTY');
}
- // 8. Validate body length
+ // 9. Validate body length
if (hasBody && body.length > 2000) {
throw new AppError('Message body must not exceed 2000 characters.', 400, 'MESSAGES_BODY_TOO_LONG');
}
- // 9. Validate resource type if provided
+ // 10. Validate resource type if provided
// [DEPENDS: tracks/playlists module] — embed_id references tracks or playlists table
if (hasResource && !['track', 'playlist'].includes(resource.type)) {
throw new AppError('Embedded resource type must be "track" or "playlist".', 400, 'MESSAGES_INVALID_EMBED_TYPE');
}
- // 10. Insert the message
+ // 11. Insert the message
const message = await messageModel.createMessage({
conversationId,
senderId,
diff --git a/src/sockets/messages.socket.js b/src/sockets/messages.socket.js
index 75e130e..7b5b261 100644
--- a/src/sockets/messages.socket.js
+++ b/src/sockets/messages.socket.js
@@ -12,7 +12,7 @@
// Room naming convention: conversation:
// ============================================================
-const db = require('../config/db');
+const messagesService = require('../services/messages.service');
// ------------------------------------------------------------
// Helper — validate UUID format
@@ -22,25 +22,6 @@ const isValidUUID = (val) =>
typeof val === 'string' &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(val);
-const isNonEmptyString = (val) => typeof val === 'string' && val.trim().length > 0;
-
-// ------------------------------------------------------------
-// Helper — verify user is a participant in a conversation
-// Prevents users from joining rooms they don't belong to
-// [DEPENDS: conversations table]
-// ------------------------------------------------------------
-const isParticipant = async (conversationId, userId) => {
- const { rows } = await db.query(
- `SELECT 1 FROM conversations
- WHERE id = $1
- AND (user_a_id = $2 OR user_b_id = $2)
- AND NOT (user_a_id = $2 AND deleted_by_a = true)
- AND NOT (user_b_id = $2 AND deleted_by_b = true)`,
- [conversationId, userId]
- );
- return rows.length > 0;
-};
-
// ------------------------------------------------------------
const registerMessageHandlers = (io, socket) => {
@@ -54,6 +35,28 @@ const registerMessageHandlers = (io, socket) => {
return true;
};
+ const ensureParticipantInConversation = async (conversationId) => {
+ if (!isValidUUID(conversationId)) {
+ socket.emit('error', { message: 'Invalid conversationId.' });
+ return false;
+ }
+
+ try {
+ await messagesService.assertConversationAccess({ conversationId, userId });
+ return true;
+ } catch (err) {
+ const isAccessError = err?.code === 'FORBIDDEN' || err?.code === 'CONVERSATION_NOT_FOUND';
+ if (isAccessError) {
+ socket.emit('error', { message: 'You are not a participant in this conversation.' });
+ return false;
+ }
+
+ console.error(`[Socket.IO] conversation access check failed:`, err.message);
+ socket.emit('error', { message: 'Something went wrong. Please try again.' });
+ return false;
+ }
+ };
+
// ----------------------------------------------------------
// Join a conversation room
// Client emits when user opens a conversation
@@ -66,18 +69,9 @@ const registerMessageHandlers = (io, socket) => {
return;
}
- if (!isValidUUID(conversationId)) {
- return socket.emit('error', { message: 'Invalid conversationId.' });
- }
-
- try {
- const allowed = await isParticipant(conversationId, userId);
- if (!allowed) {
- return socket.emit('error', { message: 'You are not a participant in this conversation.' });
- }
- } catch (err) {
- console.error(`[Socket.IO] message:join DB error:`, err.message);
- return socket.emit('error', { message: 'Something went wrong. Please try again.' });
+ const canAccess = await ensureParticipantInConversation(conversationId);
+ if (!canAccess) {
+ return;
}
const room = `conversation:${conversationId}`;
@@ -89,13 +83,14 @@ const registerMessageHandlers = (io, socket) => {
// Leave a conversation room
// Client emits when user closes a conversation
// ----------------------------------------------------------
- socket.on('message:leave', ({ conversationId } = {}) => {
+ socket.on('message:leave', async ({ conversationId } = {}) => {
if (!ensureAuthenticated()) {
return;
}
- if (!isNonEmptyString(conversationId)) {
- return socket.emit('error', { message: 'Invalid payload.' });
+ const canAccess = await ensureParticipantInConversation(conversationId);
+ if (!canAccess) {
+ return;
}
const room = `conversation:${conversationId}`;
@@ -109,15 +104,20 @@ const registerMessageHandlers = (io, socket) => {
// POST /messages/conversations/:conversationId/messages succeeds
// Passes the full message object from the HTTP response
// ----------------------------------------------------------
- socket.on('message:send', ({ conversationId, message } = {}) => {
+ socket.on('message:send', async ({ conversationId, message } = {}) => {
if (!ensureAuthenticated()) {
return;
}
- if (!isNonEmptyString(conversationId) || !message || typeof message !== 'object') {
+ if (!message || typeof message !== 'object') {
return socket.emit('error', { message: 'Invalid payload.' });
}
+ const canAccess = await ensureParticipantInConversation(conversationId);
+ if (!canAccess) {
+ return;
+ }
+
const room = `conversation:${conversationId}`;
socket.to(room).emit('message:received', { conversationId, message });
console.log(`[Socket.IO] message:send in ${room} by user: ${userId}`);
@@ -128,15 +128,20 @@ const registerMessageHandlers = (io, socket) => {
// Client emits AFTER
// DELETE /messages/conversations/:conversationId/messages/:messageId succeeds
// ----------------------------------------------------------
- socket.on('message:deleted', ({ conversationId, messageId } = {}) => {
+ socket.on('message:deleted', async ({ conversationId, messageId } = {}) => {
if (!ensureAuthenticated()) {
return;
}
- if (!isNonEmptyString(conversationId) || !isNonEmptyString(messageId)) {
+ if (!isValidUUID(messageId)) {
return socket.emit('error', { message: 'Invalid payload.' });
}
+ const canAccess = await ensureParticipantInConversation(conversationId);
+ if (!canAccess) {
+ return;
+ }
+
const room = `conversation:${conversationId}`;
socket.to(room).emit('message:removed', { conversationId, messageId });
console.log(`[Socket.IO] message:deleted in ${room} by user: ${userId}`);
@@ -147,15 +152,20 @@ const registerMessageHandlers = (io, socket) => {
// Client emits AFTER
// PATCH /messages/conversations/:conversationId/messages/:messageId/read succeeds
// ----------------------------------------------------------
- socket.on('message:read', ({ conversationId, messageId, isRead, conversationUnreadCount } = {}) => {
+ socket.on('message:read', async ({ conversationId, messageId, isRead, conversationUnreadCount } = {}) => {
if (!ensureAuthenticated()) {
return;
}
- if (!isNonEmptyString(conversationId) || !isNonEmptyString(messageId) || typeof isRead !== 'boolean') {
+ if (!isValidUUID(messageId) || typeof isRead !== 'boolean') {
return socket.emit('error', { message: 'Invalid payload.' });
}
+ const canAccess = await ensureParticipantInConversation(conversationId);
+ if (!canAccess) {
+ return;
+ }
+
const room = `conversation:${conversationId}`;
socket.to(room).emit('message:read_updated', {
conversationId,
@@ -171,13 +181,14 @@ const registerMessageHandlers = (io, socket) => {
// Client emits while user is typing
// NOTE: Frontend should throttle this to once per ~1000ms
// ----------------------------------------------------------
- socket.on('message:typing', ({ conversationId } = {}) => {
+ socket.on('message:typing', async ({ conversationId } = {}) => {
if (!ensureAuthenticated()) {
return;
}
- if (!isNonEmptyString(conversationId)) {
- return socket.emit('error', { message: 'Invalid payload.' });
+ const canAccess = await ensureParticipantInConversation(conversationId);
+ if (!canAccess) {
+ return;
}
const room = `conversation:${conversationId}`;
@@ -188,13 +199,14 @@ const registerMessageHandlers = (io, socket) => {
// Stop typing indicator
// Client emits when user stops typing or sends the message
// ----------------------------------------------------------
- socket.on('message:stop_typing', ({ conversationId } = {}) => {
+ socket.on('message:stop_typing', async ({ conversationId } = {}) => {
if (!ensureAuthenticated()) {
return;
}
- if (!isNonEmptyString(conversationId)) {
- return socket.emit('error', { message: 'Invalid payload.' });
+ const canAccess = await ensureParticipantInConversation(conversationId);
+ if (!canAccess) {
+ return;
}
const room = `conversation:${conversationId}`;
diff --git a/tests/messages.model.unit.test.js b/tests/messages.model.unit.test.js
new file mode 100644
index 0000000..00cff8b
--- /dev/null
+++ b/tests/messages.model.unit.test.js
@@ -0,0 +1,170 @@
+const model = require('../src/models/message.model');
+const db = require('../src/config/db');
+
+jest.mock('../src/config/db', () => ({ query: jest.fn() }));
+
+beforeEach(() => jest.clearAllMocks());
+
+describe('message.model', () => {
+ it('findConversationByPair returns first row or null', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ id: 'c1' }] });
+ await expect(model.findConversationByPair('u1', 'u2')).resolves.toEqual({ id: 'c1' });
+
+ db.query.mockResolvedValueOnce({ rows: [] });
+ await expect(model.findConversationByPair('u1', 'u2')).resolves.toBeNull();
+ });
+
+ it('createConversation returns inserted row', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ id: 'c1' }] });
+ await expect(model.createConversation('u1', 'u2')).resolves.toEqual({ id: 'c1' });
+ expect(db.query).toHaveBeenCalledTimes(1);
+ });
+
+ it('createConversation falls back to existing row on conflict', async () => {
+ db.query
+ .mockResolvedValueOnce({ rows: [] })
+ .mockResolvedValueOnce({ rows: [{ id: 'cExisting' }] });
+
+ await expect(model.createConversation('u1', 'u2')).resolves.toEqual({ id: 'cExisting' });
+ expect(db.query).toHaveBeenCalledTimes(2);
+ });
+
+ it('createMessage inserts and returns row', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ id: 'm1' }] });
+
+ await expect(
+ model.createMessage({
+ conversationId: 'c1',
+ senderId: 'u1',
+ body: null,
+ embedType: undefined,
+ embedId: undefined,
+ })
+ ).resolves.toEqual({ id: 'm1' });
+
+ expect(db.query).toHaveBeenCalledWith(
+ expect.stringContaining('INSERT INTO messages'),
+ ['c1', 'u1', null, null, null]
+ );
+ });
+
+ it('isBlocked returns boolean', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ ok: 1 }] });
+ await expect(model.isBlocked('u1', 'u2')).resolves.toBe(true);
+
+ db.query.mockResolvedValueOnce({ rows: [] });
+ await expect(model.isBlocked('u1', 'u2')).resolves.toBe(false);
+ });
+
+ it('getMessagesFromPreference returns preference or default', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ messages_from: 'followers_only' }] });
+ await expect(model.getMessagesFromPreference('u1')).resolves.toBe('followers_only');
+
+ db.query.mockResolvedValueOnce({ rows: [] });
+ await expect(model.getMessagesFromPreference('u1')).resolves.toBe('everyone');
+ });
+
+ it('isFollowing returns boolean', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ ok: 1 }] });
+ await expect(model.isFollowing('u1', 'u2')).resolves.toBe(true);
+
+ db.query.mockResolvedValueOnce({ rows: [] });
+ await expect(model.isFollowing('u1', 'u2')).resolves.toBe(false);
+ });
+
+ it('findActiveUserById returns row or null', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ id: 'u1' }] });
+ await expect(model.findActiveUserById('u1')).resolves.toEqual({ id: 'u1' });
+
+ db.query.mockResolvedValueOnce({ rows: [] });
+ await expect(model.findActiveUserById('u1')).resolves.toBeNull();
+ });
+
+ it('findConversationsByUserId returns rows', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ id: 'c1' }] });
+ await expect(model.findConversationsByUserId('u1', 10, 0)).resolves.toEqual([{ id: 'c1' }]);
+ });
+
+ it('countConversationsByUserId returns total', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ total: 9 }] });
+ await expect(model.countConversationsByUserId('u1')).resolves.toBe(9);
+ });
+
+ it('findConversationById returns row or null', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ id: 'c1' }] });
+ await expect(model.findConversationById('c1')).resolves.toEqual({ id: 'c1' });
+
+ db.query.mockResolvedValueOnce({ rows: [] });
+ await expect(model.findConversationById('c1')).resolves.toBeNull();
+ });
+
+ it('findMessagesByConversationId returns rows', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ id: 'm1' }] });
+ await expect(model.findMessagesByConversationId('c1', 10, 0)).resolves.toEqual([{ id: 'm1' }]);
+ });
+
+ it('countMessagesByConversationId returns total', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ total: 4 }] });
+ await expect(model.countMessagesByConversationId('c1')).resolves.toBe(4);
+ });
+
+ it('findConversationPartner returns row or null', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ id: 'u2' }] });
+ await expect(model.findConversationPartner('c1', 'u1')).resolves.toEqual({ id: 'u2' });
+
+ db.query.mockResolvedValueOnce({ rows: [] });
+ await expect(model.findConversationPartner('c1', 'u1')).resolves.toBeNull();
+ });
+
+ it('countUnreadMessages returns unread count', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ unread_count: 3 }] });
+ await expect(model.countUnreadMessages('c1', 'u1')).resolves.toBe(3);
+ });
+
+ it('countTotalUnreadMessages returns unread count', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ unread_count: 5 }] });
+ await expect(model.countTotalUnreadMessages('u1')).resolves.toBe(5);
+ });
+
+ it('findMessageById returns row or null', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ id: 'm1' }] });
+ await expect(model.findMessageById('m1', 'c1')).resolves.toEqual({ id: 'm1' });
+
+ db.query.mockResolvedValueOnce({ rows: [] });
+ await expect(model.findMessageById('m1', 'c1')).resolves.toBeNull();
+ });
+
+ it('updateMessageReadState returns updated row or null', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ id: 'm1', is_read: true }] });
+ await expect(model.updateMessageReadState('m1', true)).resolves.toEqual({ id: 'm1', is_read: true });
+
+ db.query.mockResolvedValueOnce({ rows: [] });
+ await expect(model.updateMessageReadState('m1', true)).resolves.toBeNull();
+ });
+
+ it('deleteMessageById executes delete query', async () => {
+ db.query.mockResolvedValueOnce({});
+ await model.deleteMessageById('m1');
+ expect(db.query).toHaveBeenCalledWith(expect.stringContaining('DELETE FROM messages'), ['m1']);
+ });
+
+ it('softDeleteConversation sets deleted_by_a or deleted_by_b and returns row', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ id: 'c1' }] });
+ await expect(model.softDeleteConversation('c1', true)).resolves.toEqual({ id: 'c1' });
+ expect(db.query).toHaveBeenCalledWith(expect.stringContaining('SET deleted_by_a = true'), ['c1']);
+
+ db.query.mockResolvedValueOnce({ rows: [{ id: 'c1' }] });
+ await expect(model.softDeleteConversation('c1', false)).resolves.toEqual({ id: 'c1' });
+ expect(db.query).toHaveBeenCalledWith(expect.stringContaining('SET deleted_by_b = true'), ['c1']);
+ });
+
+ it('restoreConversationForUser sets deleted flag to false for selected side', async () => {
+ db.query.mockResolvedValueOnce({});
+ await model.restoreConversationForUser('c1', true);
+ expect(db.query).toHaveBeenCalledWith(expect.stringContaining('SET deleted_by_a = false'), ['c1']);
+
+ db.query.mockResolvedValueOnce({});
+ await model.restoreConversationForUser('c1', false);
+ expect(db.query).toHaveBeenCalledWith(expect.stringContaining('SET deleted_by_b = false'), ['c1']);
+ });
+});
diff --git a/tests/messages.service.unit.test.js b/tests/messages.service.unit.test.js
index 56445da..0084a68 100644
--- a/tests/messages.service.unit.test.js
+++ b/tests/messages.service.unit.test.js
@@ -17,6 +17,45 @@ const baseConversation = {
};
describe('messages.service', () => {
+ describe('assertConversationAccess', () => {
+ it('throws 404 when conversation does not exist', async () => {
+ model.findConversationById.mockResolvedValue(null);
+
+ await expect(service.assertConversationAccess({ conversationId: 'c1', userId: 'u1' }))
+ .rejects.toMatchObject({ code: 'CONVERSATION_NOT_FOUND', statusCode: 404 });
+ });
+
+ it('throws 403 when user is not a participant', async () => {
+ model.findConversationById.mockResolvedValue({ ...baseConversation, user_a_id: 'x', user_b_id: 'y' });
+
+ await expect(service.assertConversationAccess({ conversationId: 'c1', userId: 'u1' }))
+ .rejects.toMatchObject({ code: 'FORBIDDEN', statusCode: 403 });
+ });
+
+ it('throws 404 when conversation is soft-deleted for user and allowSoftDeleted is false', async () => {
+ model.findConversationById.mockResolvedValue({ ...baseConversation, deleted_by_a: true });
+
+ await expect(service.assertConversationAccess({ conversationId: 'c1', userId: 'u1' }))
+ .rejects.toMatchObject({ code: 'CONVERSATION_NOT_FOUND', statusCode: 404 });
+ });
+
+ it('returns conversation when soft-deleted but allowSoftDeleted is true', async () => {
+ const conversation = { ...baseConversation, deleted_by_a: true };
+ model.findConversationById.mockResolvedValue(conversation);
+
+ await expect(
+ service.assertConversationAccess({ conversationId: 'c1', userId: 'u1', allowSoftDeleted: true })
+ ).resolves.toEqual(conversation);
+ });
+
+ it('returns conversation for active participant', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+
+ await expect(service.assertConversationAccess({ conversationId: 'c1', userId: 'u1' }))
+ .resolves.toEqual(baseConversation);
+ });
+ });
+
describe('startConversation', () => {
it('rejects when senderId is undefined', async () => {
await expect(
@@ -303,6 +342,30 @@ describe('messages.service', () => {
expect(model.restoreConversationForUser).toHaveBeenCalledWith('c1', false);
});
+ it('restores sender conversation when sender had deleted', async () => {
+ model.findConversationById.mockResolvedValue({ ...baseConversation, deleted_by_a: true });
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+ model.createMessage.mockResolvedValue({ id: 'm1' });
+
+ await service.sendMessage({ conversationId: 'c1', senderId: 'u1', body: 'x' });
+
+ expect(model.restoreConversationForUser).toHaveBeenCalledWith('c1', true);
+ });
+
+ it('restores both sides when both had deleted and a new message is sent', async () => {
+ model.findConversationById.mockResolvedValue({ ...baseConversation, deleted_by_a: true, deleted_by_b: true });
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+ model.createMessage.mockResolvedValue({ id: 'm1' });
+
+ await service.sendMessage({ conversationId: 'c1', senderId: 'u1', body: 'x' });
+
+ expect(model.restoreConversationForUser).toHaveBeenCalledTimes(2);
+ expect(model.restoreConversationForUser).toHaveBeenNthCalledWith(1, 'c1', true);
+ expect(model.restoreConversationForUser).toHaveBeenNthCalledWith(2, 'c1', false);
+ });
+
it('blocks on blocked recipient', async () => {
model.findConversationById.mockResolvedValue(baseConversation);
model.isBlocked.mockResolvedValue(true);
diff --git a/tests/messages.socket.unit.test.js b/tests/messages.socket.unit.test.js
index 137ac10..410f46d 100644
--- a/tests/messages.socket.unit.test.js
+++ b/tests/messages.socket.unit.test.js
@@ -1,7 +1,12 @@
-const db = require('../src/config/db');
+const messagesService = require('../src/services/messages.service');
const { registerMessageHandlers } = require('../src/sockets/messages.socket');
-jest.mock('../src/config/db', () => ({ query: jest.fn() }));
+jest.mock('../src/services/messages.service', () => ({
+ assertConversationAccess: jest.fn(),
+}));
+
+const VALID_CONVERSATION_ID = '22222222-2222-2222-2222-222222222222';
+const VALID_MESSAGE_ID = '33333333-3333-3333-3333-333333333333';
const mkSocket = (userId = '11111111-1111-1111-1111-111111111111') => {
const handlers = {};
@@ -17,7 +22,10 @@ const mkSocket = (userId = '11111111-1111-1111-1111-111111111111') => {
};
};
-beforeEach(() => jest.clearAllMocks());
+beforeEach(() => {
+ jest.clearAllMocks();
+ messagesService.assertConversationAccess.mockResolvedValue({ id: VALID_CONVERSATION_ID });
+});
describe('messages.socket', () => {
it('registers all message handlers', () => {
@@ -48,7 +56,7 @@ describe('messages.socket', () => {
await socket._handlers['message:join']();
expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid conversationId.' });
- expect(db.query).not.toHaveBeenCalled();
+ expect(messagesService.assertConversationAccess).not.toHaveBeenCalled();
});
it('message:join emits auth error when socket has no valid user', async () => {
@@ -56,41 +64,38 @@ describe('messages.socket', () => {
registerMessageHandlers({}, socket);
await socket._handlers['message:join']({
- conversationId: '22222222-2222-2222-2222-222222222222',
+ conversationId: VALID_CONVERSATION_ID,
});
expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Authentication required.' });
- expect(db.query).not.toHaveBeenCalled();
+ expect(messagesService.assertConversationAccess).not.toHaveBeenCalled();
});
it('message:join emits forbidden when not participant', async () => {
const socket = mkSocket();
registerMessageHandlers({}, socket);
- db.query.mockResolvedValue({ rows: [] });
+ messagesService.assertConversationAccess.mockRejectedValue({ code: 'FORBIDDEN' });
await socket._handlers['message:join']({
- conversationId: '22222222-2222-2222-2222-222222222222',
+ conversationId: VALID_CONVERSATION_ID,
});
expect(socket.emit).toHaveBeenCalledWith('error', {
message: 'You are not a participant in this conversation.',
});
- expect(db.query).toHaveBeenCalledWith(
- expect.stringContaining('SELECT 1 FROM conversations'),
- [
- '22222222-2222-2222-2222-222222222222',
- '11111111-1111-1111-1111-111111111111',
- ]
- );
+ expect(messagesService.assertConversationAccess).toHaveBeenCalledWith({
+ conversationId: VALID_CONVERSATION_ID,
+ userId: '11111111-1111-1111-1111-111111111111',
+ });
});
- it('message:join handles db failure', async () => {
+ it('message:join handles service failure', async () => {
const socket = mkSocket();
registerMessageHandlers({}, socket);
- db.query.mockRejectedValue(new Error('db down'));
+ messagesService.assertConversationAccess.mockRejectedValue(new Error('service down'));
await socket._handlers['message:join']({
- conversationId: '22222222-2222-2222-2222-222222222222',
+ conversationId: VALID_CONVERSATION_ID,
});
expect(socket.emit).toHaveBeenCalledWith('error', {
@@ -101,153 +106,167 @@ describe('messages.socket', () => {
it('message:join joins room when authorized', async () => {
const socket = mkSocket();
registerMessageHandlers({}, socket);
- db.query.mockResolvedValue({ rows: [{ ok: 1 }] });
await socket._handlers['message:join']({
- conversationId: '22222222-2222-2222-2222-222222222222',
+ conversationId: VALID_CONVERSATION_ID,
});
expect(socket.join).toHaveBeenCalledWith(
- 'conversation:22222222-2222-2222-2222-222222222222'
+ `conversation:${VALID_CONVERSATION_ID}`
);
- expect(db.query).toHaveBeenCalledTimes(1);
+ expect(messagesService.assertConversationAccess).toHaveBeenCalledTimes(1);
});
- it('message:leave leaves room', () => {
+ it('message:leave leaves room', async () => {
const socket = mkSocket();
registerMessageHandlers({}, socket);
- socket._handlers['message:leave']({ conversationId: 'c1' });
- expect(socket.leave).toHaveBeenCalledWith('conversation:c1');
+ await socket._handlers['message:leave']({ conversationId: VALID_CONVERSATION_ID });
+ expect(socket.leave).toHaveBeenCalledWith(`conversation:${VALID_CONVERSATION_ID}`);
});
- it('message:leave rejects malformed payload', () => {
+ it('message:leave rejects malformed payload', async () => {
const socket = mkSocket();
registerMessageHandlers({}, socket);
- socket._handlers['message:leave']({});
+ await socket._handlers['message:leave']({});
- expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid payload.' });
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid conversationId.' });
expect(socket.leave).not.toHaveBeenCalled();
});
- it('message:leave rejects unauthenticated socket', () => {
+ it('message:leave rejects unauthenticated socket', async () => {
const socket = mkSocket(null);
registerMessageHandlers({}, socket);
- socket._handlers['message:leave']({ conversationId: 'c1' });
+ await socket._handlers['message:leave']({ conversationId: VALID_CONVERSATION_ID });
expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Authentication required.' });
expect(socket.leave).not.toHaveBeenCalled();
});
- it('message:send broadcasts received', () => {
+ it('message:send broadcasts received', async () => {
const socket = mkSocket();
const emit = jest.fn();
socket.to.mockReturnValue({ emit });
registerMessageHandlers({}, socket);
- socket._handlers['message:send']({ conversationId: 'c1', message: { id: 'm1' } });
+ await socket._handlers['message:send']({ conversationId: VALID_CONVERSATION_ID, message: { id: 'm1' } });
- expect(socket.to).toHaveBeenCalledWith('conversation:c1');
+ expect(socket.to).toHaveBeenCalledWith(`conversation:${VALID_CONVERSATION_ID}`);
expect(emit).toHaveBeenCalledWith('message:received', {
- conversationId: 'c1',
+ conversationId: VALID_CONVERSATION_ID,
message: { id: 'm1' },
});
});
- it('message:send rejects malformed payload', () => {
+ it('message:send blocks non-participant users', async () => {
const socket = mkSocket();
const emit = jest.fn();
socket.to.mockReturnValue({ emit });
registerMessageHandlers({}, socket);
+ messagesService.assertConversationAccess.mockRejectedValue({ code: 'FORBIDDEN' });
+
+ await socket._handlers['message:send']({ conversationId: VALID_CONVERSATION_ID, message: { id: 'm1' } });
- socket._handlers['message:send']({ conversationId: 'c1' });
+ expect(socket.emit).toHaveBeenCalledWith('error', {
+ message: 'You are not a participant in this conversation.',
+ });
+ expect(emit).not.toHaveBeenCalled();
+ });
+
+ it('message:send rejects malformed payload', async () => {
+ const socket = mkSocket();
+ const emit = jest.fn();
+ socket.to.mockReturnValue({ emit });
+ registerMessageHandlers({}, socket);
+
+ await socket._handlers['message:send']({ conversationId: VALID_CONVERSATION_ID });
expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid payload.' });
expect(emit).not.toHaveBeenCalled();
});
- it('message:send rejects unauthenticated socket', () => {
+ it('message:send rejects unauthenticated socket', async () => {
const socket = mkSocket(null);
const emit = jest.fn();
socket.to.mockReturnValue({ emit });
registerMessageHandlers({}, socket);
- socket._handlers['message:send']({ conversationId: 'c1', message: { id: 'm1' } });
+ await socket._handlers['message:send']({ conversationId: VALID_CONVERSATION_ID, message: { id: 'm1' } });
expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Authentication required.' });
expect(emit).not.toHaveBeenCalled();
});
- it('message:deleted broadcasts removed', () => {
+ it('message:deleted broadcasts removed', async () => {
const socket = mkSocket();
const emit = jest.fn();
socket.to.mockReturnValue({ emit });
registerMessageHandlers({}, socket);
- socket._handlers['message:deleted']({ conversationId: 'c1', messageId: 'm1' });
+ await socket._handlers['message:deleted']({ conversationId: VALID_CONVERSATION_ID, messageId: VALID_MESSAGE_ID });
expect(emit).toHaveBeenCalledWith('message:removed', {
- conversationId: 'c1',
- messageId: 'm1',
+ conversationId: VALID_CONVERSATION_ID,
+ messageId: VALID_MESSAGE_ID,
});
});
- it('message:deleted rejects malformed payload', () => {
+ it('message:deleted rejects malformed payload', async () => {
const socket = mkSocket();
const emit = jest.fn();
socket.to.mockReturnValue({ emit });
registerMessageHandlers({}, socket);
- socket._handlers['message:deleted']({ conversationId: 'c1' });
+ await socket._handlers['message:deleted']({ conversationId: VALID_CONVERSATION_ID });
expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid payload.' });
expect(emit).not.toHaveBeenCalled();
});
- it('message:deleted rejects unauthenticated socket', () => {
+ it('message:deleted rejects unauthenticated socket', async () => {
const socket = mkSocket(null);
const emit = jest.fn();
socket.to.mockReturnValue({ emit });
registerMessageHandlers({}, socket);
- socket._handlers['message:deleted']({ conversationId: 'c1', messageId: 'm1' });
+ await socket._handlers['message:deleted']({ conversationId: VALID_CONVERSATION_ID, messageId: VALID_MESSAGE_ID });
expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Authentication required.' });
expect(emit).not.toHaveBeenCalled();
});
- it('message:read broadcasts read_updated', () => {
+ it('message:read broadcasts read_updated', async () => {
const socket = mkSocket();
const emit = jest.fn();
socket.to.mockReturnValue({ emit });
registerMessageHandlers({}, socket);
- socket._handlers['message:read']({
- conversationId: 'c1',
- messageId: 'm1',
+ await socket._handlers['message:read']({
+ conversationId: VALID_CONVERSATION_ID,
+ messageId: VALID_MESSAGE_ID,
isRead: true,
conversationUnreadCount: 0,
});
expect(emit).toHaveBeenCalledWith('message:read_updated', {
- conversationId: 'c1',
- messageId: 'm1',
+ conversationId: VALID_CONVERSATION_ID,
+ messageId: VALID_MESSAGE_ID,
isRead: true,
conversationUnreadCount: 0,
});
});
- it('message:read rejects malformed payload', () => {
+ it('message:read rejects malformed payload', async () => {
const socket = mkSocket();
const emit = jest.fn();
socket.to.mockReturnValue({ emit });
registerMessageHandlers({}, socket);
- socket._handlers['message:read']({
- conversationId: 'c1',
- messageId: 'm1',
+ await socket._handlers['message:read']({
+ conversationId: VALID_CONVERSATION_ID,
+ messageId: VALID_MESSAGE_ID,
isRead: 'yes',
conversationUnreadCount: 0,
});
@@ -256,15 +275,15 @@ describe('messages.socket', () => {
expect(emit).not.toHaveBeenCalled();
});
- it('message:read rejects unauthenticated socket', () => {
+ it('message:read rejects unauthenticated socket', async () => {
const socket = mkSocket(null);
const emit = jest.fn();
socket.to.mockReturnValue({ emit });
registerMessageHandlers({}, socket);
- socket._handlers['message:read']({
- conversationId: 'c1',
- messageId: 'm1',
+ await socket._handlers['message:read']({
+ conversationId: VALID_CONVERSATION_ID,
+ messageId: VALID_MESSAGE_ID,
isRead: true,
conversationUnreadCount: 0,
});
@@ -273,78 +292,78 @@ describe('messages.socket', () => {
expect(emit).not.toHaveBeenCalled();
});
- it('typing events broadcast', () => {
+ it('typing events broadcast', async () => {
const socket = mkSocket();
const emit = jest.fn();
socket.to.mockReturnValue({ emit });
registerMessageHandlers({}, socket);
- socket._handlers['message:typing']({ conversationId: 'c1' });
- expect(emit).toHaveBeenCalledWith('message:typing', { conversationId: 'c1', userId: socket.user.sub });
+ await socket._handlers['message:typing']({ conversationId: VALID_CONVERSATION_ID });
+ expect(emit).toHaveBeenCalledWith('message:typing', { conversationId: VALID_CONVERSATION_ID, userId: socket.user.sub });
- socket._handlers['message:stop_typing']({ conversationId: 'c1' });
- expect(emit).toHaveBeenCalledWith('message:stop_typing', { conversationId: 'c1', userId: socket.user.sub });
+ await socket._handlers['message:stop_typing']({ conversationId: VALID_CONVERSATION_ID });
+ expect(emit).toHaveBeenCalledWith('message:stop_typing', { conversationId: VALID_CONVERSATION_ID, userId: socket.user.sub });
});
- it('typing events reject malformed payload', () => {
+ it('typing events reject malformed payload', async () => {
const socket = mkSocket();
const emit = jest.fn();
socket.to.mockReturnValue({ emit });
registerMessageHandlers({}, socket);
- socket._handlers['message:typing']({});
- socket._handlers['message:stop_typing']({});
+ await socket._handlers['message:typing']({});
+ await socket._handlers['message:stop_typing']({});
- expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid payload.' });
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid conversationId.' });
expect(emit).not.toHaveBeenCalled();
});
- it('typing events reject unauthenticated socket', () => {
+ it('typing events reject unauthenticated socket', async () => {
const socket = mkSocket(null);
const emit = jest.fn();
socket.to.mockReturnValue({ emit });
registerMessageHandlers({}, socket);
- socket._handlers['message:typing']({ conversationId: 'c1' });
- socket._handlers['message:stop_typing']({ conversationId: 'c1' });
+ await socket._handlers['message:typing']({ conversationId: VALID_CONVERSATION_ID });
+ await socket._handlers['message:stop_typing']({ conversationId: VALID_CONVERSATION_ID });
expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Authentication required.' });
expect(emit).not.toHaveBeenCalled();
});
- it('typing events reject whitespace conversationId', () => {
+ it('typing events reject whitespace conversationId', async () => {
const socket = mkSocket();
const emit = jest.fn();
socket.to.mockReturnValue({ emit });
registerMessageHandlers({}, socket);
- socket._handlers['message:typing']({ conversationId: ' ' });
- socket._handlers['message:stop_typing']({ conversationId: ' ' });
+ await socket._handlers['message:typing']({ conversationId: ' ' });
+ await socket._handlers['message:stop_typing']({ conversationId: ' ' });
- expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid payload.' });
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid conversationId.' });
expect(emit).not.toHaveBeenCalled();
});
- it('handlers with default payload reject when called without arguments', () => {
+ it('handlers with default payload reject when called without arguments', async () => {
const socket = mkSocket();
const emit = jest.fn();
socket.to.mockReturnValue({ emit });
registerMessageHandlers({}, socket);
- socket._handlers['message:leave']();
- socket._handlers['message:send']();
- socket._handlers['message:deleted']();
- socket._handlers['message:read']();
- socket._handlers['message:typing']();
- socket._handlers['message:stop_typing']();
+ await socket._handlers['message:leave']();
+ await socket._handlers['message:send']();
+ await socket._handlers['message:deleted']();
+ await socket._handlers['message:read']();
+ await socket._handlers['message:typing']();
+ await socket._handlers['message:stop_typing']();
expect(socket.emit).toHaveBeenCalledTimes(6);
- expect(socket.emit).toHaveBeenNthCalledWith(1, 'error', { message: 'Invalid payload.' });
+ expect(socket.emit).toHaveBeenNthCalledWith(1, 'error', { message: 'Invalid conversationId.' });
expect(socket.emit).toHaveBeenNthCalledWith(2, 'error', { message: 'Invalid payload.' });
expect(socket.emit).toHaveBeenNthCalledWith(3, 'error', { message: 'Invalid payload.' });
expect(socket.emit).toHaveBeenNthCalledWith(4, 'error', { message: 'Invalid payload.' });
- expect(socket.emit).toHaveBeenNthCalledWith(5, 'error', { message: 'Invalid payload.' });
- expect(socket.emit).toHaveBeenNthCalledWith(6, 'error', { message: 'Invalid payload.' });
+ expect(socket.emit).toHaveBeenNthCalledWith(5, 'error', { message: 'Invalid conversationId.' });
+ expect(socket.emit).toHaveBeenNthCalledWith(6, 'error', { message: 'Invalid conversationId.' });
expect(emit).not.toHaveBeenCalled();
});
});
\ No newline at end of file
From 7339e28a6f37699aa9b648d7739fcc181221f5f2 Mon Sep 17 00:00:00 2001
From: basboosa05 <166862890+basboosa05@users.noreply.github.com>
Date: Mon, 16 Mar 2026 22:40:02 +0200
Subject: [PATCH 057/535] Add pending_email column and email change flow
Add a DB migration to add/drop users.pending_email. Extend user model with setPendingEmail and applyPendingEmail (copies pending_email to email, clears it, and returns the new email). Add sendEmailChangeEmail to the mailer and export it, and update auth.service imports to include the new mailer helper. These changes implement an email-change verification flow so email updates are staged and applied only after confirmation.
---
.../20260316202248-add-pending-email.js | 33 ++++
src/models/user.model.js | 158 +++---------------
src/services/auth.service.js | 7 +-
src/utils/mailer.js | 37 ++++
4 files changed, 97 insertions(+), 138 deletions(-)
create mode 100644 migrations/20260316202248-add-pending-email.js
diff --git a/migrations/20260316202248-add-pending-email.js b/migrations/20260316202248-add-pending-email.js
new file mode 100644
index 0000000..34aa980
--- /dev/null
+++ b/migrations/20260316202248-add-pending-email.js
@@ -0,0 +1,33 @@
+'use strict';
+
+let dbm;
+let type;
+let seed;
+
+/**
+ * We receive the dbmigrate dependency from dbmigrate initially.
+ * This enables us to not have to rely on NODE_PATH.
+ */
+exports.setup = function(options, seedLink) {
+ dbm = options.dbmigrate;
+ type = dbm.dataType;
+ seed = seedLink;
+};
+
+exports.up = function(db) {
+ return db.runSql(`
+ ALTER TABLE users
+ ADD COLUMN IF NOT EXISTS pending_email TEXT DEFAULT NULL;
+ `);
+};
+
+exports.down = function(db) {
+ return db.runSql(`
+ ALTER TABLE users
+ DROP COLUMN IF EXISTS pending_email;
+ `);
+};
+
+exports._meta = {
+ "version": 1
+};
diff --git a/src/models/user.model.js b/src/models/user.model.js
index ac94a76..591afe0 100644
--- a/src/models/user.model.js
+++ b/src/models/user.model.js
@@ -7,10 +7,9 @@ const db = require('../config/db');
// find user by email used for login and register (to check if email already exists)
exports.findByEmail = async (email) => {
- const { rows } = await db.query(
- `SELECT * FROM users WHERE email = $1 AND deleted_at IS NULL`,
- [email]
- );
+ const { rows } = await db.query(`SELECT * FROM users WHERE email = $1 AND deleted_at IS NULL`, [
+ email,
+ ]);
return rows[0] || null;
};
@@ -36,14 +35,10 @@ exports.findByEmailOrUsername = async (identifier) => {
// find user by UUID used for auth and other operations where we have the user ID
exports.findById = async (id) => {
- const { rows } = await db.query(
- `SELECT * FROM users WHERE id = $1 AND deleted_at IS NULL`,
- [id]
- );
+ const { rows } = await db.query(`SELECT * FROM users WHERE id = $1 AND deleted_at IS NULL`, [id]);
return rows[0] || null;
};
-
// create new user (called during registration)
exports.create = async ({ email, password_hashed, display_name, gender, date_of_birth }) => {
const { rows } = await db.query(
@@ -57,146 +52,35 @@ exports.create = async ({ email, password_hashed, display_name, gender, date_of_
// mark user as verified
exports.markVerified = async (userId) => {
- await db.query(
- `UPDATE users SET is_verified = true WHERE id = $1`,
- [userId]
- );
+ await db.query(`UPDATE users SET is_verified = true WHERE id = $1`, [userId]);
};
// update last login timestamp (called after successful login)
exports.updateLastLogin = async (userId) => {
- await db.query(
- `UPDATE users SET last_login_at = now() WHERE id = $1`,
- [userId]
- );
+ await db.query(`UPDATE users SET last_login_at = now() WHERE id = $1`, [userId]);
};
// update password (called during password reset)
exports.updatePassword = async (userId, newPasswordHashed) => {
- await db.query(
- `UPDATE users SET password_hashed = $1, updated_at = now() WHERE id = $2`,
- [newPasswordHashed, userId]
- );
+ await db.query(`UPDATE users SET password_hashed = $1, updated_at = now() WHERE id = $2`, [
+ newPasswordHashed,
+ userId,
+ ]);
};
-// update password (called during password reset)
-exports.updatePassword = async (userId, newPasswordHashed) => {
- await db.query(
- `UPDATE users SET password_hashed = $1, updated_at = now() WHERE id = $2`,
- [newPasswordHashed, userId]
- );
+// Set pending_email (called when user requests email change)
+exports.setPendingEmail = async (userId, pendingEmail) => {
+ await db.query(`UPDATE users SET pending_email = $2 WHERE id = $1`, [userId, pendingEmail]);
};
-// get user profile (called in GET /users/me)
-exports.findFullById = async (id) => {
- const { rows } = await db.query(
- `SELECT
- id, email, username, display_name, first_name, last_name,
- bio, city, country, gender, date_of_birth, role,
- profile_picture, cover_photo, is_private, is_verified,
- is_suspended, twofa_enabled, followers_count, following_count,
- last_login_at, created_at, updated_at
- FROM users
- WHERE id = $1 AND deleted_at IS NULL`,
- [id]
- );
- return rows[0] || null;
-}
-
-// get public user profile (called in GET /users/:id)
-exports.findPublicById = async (id) => {
+// Apply the pending email change — copy pending_email to email, clear pending_email
+exports.applyPendingEmail = async (userId) => {
const { rows } = await db.query(
- `SELECT
- id, display_name, username, bio, city, country,
- gender, role, profile_picture, cover_photo,
- is_private, is_verified, followers_count, following_count,
- created_at
- FROM users
- WHERE id = $1 AND deleted_at IS NULL`,
- [id]
- );
- return rows[0] || null;
-}
-
-// check if user A is following user B (used in GET /users/:id to determine if we can show private profile)
-exports.isFollowing = async (followerId, followingId) => {
- const { rows } = await db.query(
- `SELECT 1 FROM follows WHERE follower_id = $1 AND following_id = $2`,
- [followerId, followingId]
- );
- return rows.length > 0;
-};
-
-exports.updateProfile = async (userId, fields) => {
- const allowed =['display_name', 'username', 'first_name', 'last_name', 'bio', 'city', 'country'];
- const updates = [];
- const values = [];
- let i = 1;
-
- for (const key in fields) {
- if (allowed.includes(key)) {
- updates.push(`${key} = $${i}`);
- values.push(fields[key]);
- i++;
- }
- }
-
- if (updates.length === 0) return null;
-
- updates.push(`updated_at = now()`);
- values.push(userId);
-
- const { rows } = await db.query(
- `UPDATE users SET ${updates.join(', ')} WHERE id = $${i}
- RETURNING
- id, email, username, display_name, first_name, last_name,
- bio, city, country, gender, date_of_birth, role,
- profile_picture, cover_photo, is_private, is_verified,
- followers_count, following_count, created_at, updated_at`,
- values
+ `UPDATE users
+ SET email = pending_email, pending_email = NULL
+ WHERE id = $1
+ RETURNING email`,
+ [userId]
);
- return rows[0]||null;
+ return rows[0];
};
-
-exports.updateAccount = async (userId, fields) => {
- const allowed = ['date_of_birth', 'gender'];
- const updates = [];
- const values = [];
- let i = 1;
-
- for (const key in fields) {
- if (allowed.includes(key)) {
- updates.push(`${key} = $${i}`);
- values.push(fields[key]);
- i++;
- }
- }
-
- if (updates.length === 0) return null;
-
- updates.push(`updated_at = now()`);
- values.push(userId);
-
- const { rows } = await db.query(
- `UPDATE users SET ${updates.join(', ')} WHERE id = $${i}
- RETURNING
- id, email, username, display_name, first_name, last_name,
- bio, city, country, gender, date_of_birth, role,
- profile_picture, cover_photo, is_private, is_verified,
- followers_count, following_count, created_at, updated_at`,
- values
- );
- return rows[0] || null;
-}
-
-exports.updateRole = async (userId, newRole) => {
- const { rows } = await db.query(
- `UPDATE users SET role = $1, updated_at = now()
- WHERE id = $2 AND deleted_at IS NULL
- RETURNING
- id, email, username, display_name, role,
- is_verified, followers_count, following_count, updated_at`,
- [newRole, userId]
- );
- return rows[0] || null;
-}
\ No newline at end of file
diff --git a/src/services/auth.service.js b/src/services/auth.service.js
index 1adbb5f..8fba191 100644
--- a/src/services/auth.service.js
+++ b/src/services/auth.service.js
@@ -4,7 +4,12 @@ const verificationTokenModel = require('../models/verification-token.model');
const refreshTokenModel = require('../models/refresh-token.model');
const { generateSecureToken, parseDurationToSeconds } = require('../utils/token-generator');
const TOKEN_TYPES = require('../constants/token-types');
-const { sendVerificationEmail, sendPasswordResetEmail } = require('../utils/mailer');
+const {
+ sendVerificationEmail,
+ sendResendVerificationEmail,
+ sendPasswordResetEmail,
+ sendEmailChangeEmail,
+} = require('../utils/mailer');
const AppError = require('../utils/app-error');
const { signAccessToken, signRefreshToken, verifyToken } = require('../config/jwt');
const env = require('../config/env');
diff --git a/src/utils/mailer.js b/src/utils/mailer.js
index f8a1dc1..81fdeaa 100644
--- a/src/utils/mailer.js
+++ b/src/utils/mailer.js
@@ -196,8 +196,45 @@ const sendPasswordResetEmail = async (to, { displayName, token }) => {
});
};
+
+// email change verification
+const sendEmailChangeEmail = async (to, { displayName, token }) => {
+ const link = `${env.CLIENT_URL}/verify-email-change?token=${token}`;
+
+ const bodyContent = `
+
+ Confirm your new email
+
+
+ Hello ${displayName},
+
+
+ We received a request to change your Rythmify email address.
+ Click the button below to confirm and apply the change.
+
+ ${ctaButton(link, 'Confirm Email Change')}
+
+ This link expires in 1 hour.
+ If you didn't request this, no action is needed.
+
+ `;
+
+ await transporter.sendMail({
+ from: env.EMAIL_FROM,
+ to,
+ subject: 'Confirm your new Rythmify email address',
+ html: baseTemplate({
+ title: 'Confirm your new Rythmify email address',
+ previewText: 'Confirm your email change — link expires in 1 hour.',
+ bodyContent,
+ }),
+ });
+};
+
+
module.exports = {
sendVerificationEmail,
sendResendVerificationEmail,
sendPasswordResetEmail,
+ sendEmailChangeEmail,
};
\ No newline at end of file
From 9d8e79fa9f02751d3ba2c41c634da84d7ee6a667 Mon Sep 17 00:00:00 2001
From: basboosa05 <166862890+basboosa05@users.noreply.github.com>
Date: Mon, 16 Mar 2026 23:26:02 +0200
Subject: [PATCH 058/535] Add email change & resend verification flows
Add controllers, routes, and service logic to support resending verification emails and changing user emails. New controller actions: resendVerification, changeEmail, and verifyEmailChange with input validation and success/error responses. Routes updated to expose /resend-verification and /verify-email-change, and a protected /change-email endpoint using authenticate. Service layer implements resendVerification (creates verify tokens and sends email), changeEmail (sets pending_email, creates change token, sends confirmation), and verifyEmailChange (validates token, applies pending email, checks for conflicts).
Tokens have explicit expiries; captcha verification is noted but left commented for frontend integration.
---
src/controllers/auth.controller.js | 54 ++++++++++++++++++++
src/routes/auth.routes.js | 6 ++-
src/services/auth.service.js | 82 ++++++++++++++++++++++++++++++
3 files changed, 141 insertions(+), 1 deletion(-)
diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js
index 02e3ed7..c17893e 100644
--- a/src/controllers/auth.controller.js
+++ b/src/controllers/auth.controller.js
@@ -174,3 +174,57 @@ exports.resetPassword = async (req, res) => {
await authService.resetPassword({ token: token.trim(), new_password, logout_all });
return success(res, { success: true }, 'Password has been reset successfully.');
};
+
+
+exports.resendVerification = async (req, res) => {
+ const { email, captcha_token } = req.body;
+
+ if (!email || typeof email !== 'string') {
+ return error(res, 'VALIDATION_FAILED', 'Validation failed', 400, [
+ { field: 'email', issue: 'Email is required' },
+ ]);
+ }
+
+ await authService.resendVerification({ email: email.trim().toLowerCase(), captcha_token });
+
+ return success(
+ res,
+ { success: true },
+ "If this email is registered, you'll receive a new verification link shortly."
+ );
+};
+
+exports.changeEmail = async (req, res) => {
+ const { new_email } = req.body;
+
+ if (!new_email || typeof new_email !== 'string' || !isValidEmail(new_email)) {
+ return error(res, 'VALIDATION_FAILED', 'Validation failed', 400, [
+ { field: 'new_email', issue: 'Must be a valid email address' },
+ ]);
+ }
+
+ await authService.changeEmail({
+ userId: req.user.sub,
+ new_email: new_email.trim().toLowerCase(),
+ });
+
+ return success(
+ res,
+ { success: true },
+ 'Verification email sent to the new address.'
+ );
+};
+
+exports.verifyEmailChange = async (req, res) => {
+ const { token } = req.body;
+
+ if (!token || typeof token !== 'string') {
+ return error(res, 'VALIDATION_FAILED', 'Validation failed', 400, [
+ { field: 'token', issue: 'Token is required' },
+ ]);
+ }
+
+ const data = await authService.verifyEmailChange({ token });
+
+ return success(res, data, 'Email updated successfully.');
+};
\ No newline at end of file
diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js
index ae14428..500e119 100644
--- a/src/routes/auth.routes.js
+++ b/src/routes/auth.routes.js
@@ -7,6 +7,7 @@ const express = require('express');
const router = express.Router();
const controller = require('../controllers/auth.controller');
const { authLimiter } = require('../middleware/rate-limiter');
+const {authenticate} = require('../middleware/auth');
const { validateRegister } = require('../middleware/validate-register');
const asyncHandler = require('../utils/async-handler');
@@ -16,13 +17,16 @@ router.use(authLimiter);
// POST /api/v1/auth/register
router.post('/register', validateRegister, asyncHandler(controller.register));
router.post('/verify-email', asyncHandler(controller.verifyEmail));
+router.post('/resend-verification', asyncHandler(controller.resendVerification));
router.post('/login', asyncHandler(controller.login));
router.post('/refresh', asyncHandler(controller.refresh));
router.post('/logout', asyncHandler(controller.logout));
router.post('/forgot-password', asyncHandler(controller.requestPasswordReset));
router.post('/reset-password', asyncHandler(controller.resetPassword));
+router.post('/verify-email-change', asyncHandler(controller.verifyEmailChange));
-// i didn't implement the controllers yet
+// Protected routes
+router.post('/change-email', authenticate, asyncHandler(controller.changeEmail));
// router.post('/resend-verification', asyncHandler(controller.resendVerification));
diff --git a/src/services/auth.service.js b/src/services/auth.service.js
index 8fba191..9ba7b13 100644
--- a/src/services/auth.service.js
+++ b/src/services/auth.service.js
@@ -285,3 +285,85 @@ exports.resetPassword = async ({ token, new_password, logout_all = true }) => {
await refreshTokenModel.revokeAllForUser(tokenRow.user_id);
}
};
+
+// ============================================================
+// Resend Verification Email
+// ============================================================
+exports.resendVerification = async ({ email, captcha_token }) => {
+ // await verifyCaptcha(captcha_token); uncomment upon integration with frontend
+
+ const user = await userModel.findByEmail(email);
+
+ if (!user || user.is_verified) return;
+
+ await verificationTokenModel.revokeAllForUser(user.id, TOKEN_TYPES.VERIFY_EMAIL);
+
+ const token = generateSecureToken();
+ const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
+
+ await verificationTokenModel.create({
+ user_id: user.id,
+ token,
+ type: TOKEN_TYPES.VERIFY_EMAIL,
+ expires_at: expiresAt,
+ });
+
+ await sendResendVerificationEmail(email, {
+ displayName: user.display_name,
+ token,
+ });
+};
+
+// ============================================================
+// Change Email (request)
+// ============================================================
+exports.changeEmail = async ({ userId, new_email }) => {
+ const existing = await userModel.findByEmail(new_email);
+ if (existing) {
+ throw new AppError('Email already registered', 409, 'AUTH_EMAIL_ALREADY_EXISTS');
+ }
+
+ const user = await userModel.findById(userId);
+
+ await verificationTokenModel.revokeAllForUser(userId, TOKEN_TYPES.CHANGE_EMAIL);
+
+ await userModel.setPendingEmail(userId, new_email);
+
+ // 1h expiry token
+ const token = generateSecureToken();
+ const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
+
+ await verificationTokenModel.create({
+ user_id: userId,
+ token,
+ type: TOKEN_TYPES.CHANGE_EMAIL,
+ expires_at: expiresAt,
+ });
+
+ await sendEmailChangeEmail(new_email, {
+ displayName: user.display_name,
+ token,
+ });
+};
+
+// ============================================================
+// Verify Email Change (confirm)
+// ============================================================
+exports.verifyEmailChange = async ({ token }) => {
+ const tokenRow = await verificationTokenModel.findValidToken(token, TOKEN_TYPES.CHANGE_EMAIL);
+ if (!tokenRow) {
+ throw new AppError('Email change token expired', 422, 'AUTH_TOKEN_EXPIRED');
+ }
+
+ const user = await userModel.findById(tokenRow.user_id);
+
+ const conflict = await userModel.findByEmail(user.pending_email);
+ if (conflict) {
+ throw new AppError('Email already registered', 409, 'AUTH_EMAIL_ALREADY_EXISTS');
+ }
+
+ await verificationTokenModel.markUsed(tokenRow.id);
+ const updated = await userModel.applyPendingEmail(tokenRow.user_id);
+
+ return { email: updated.email };
+};
\ No newline at end of file
From 49ddf0fffaccfc3b66fa05fb11ccb4214ec8538f Mon Sep 17 00:00:00 2001
From: Mohamed jameel alabasy
Date: Fri, 20 Mar 2026 20:02:43 +0200
Subject: [PATCH 059/535] merege changes to devops-infra branch
---
package-lock.json | 212 +++++++++++++++++++++++++++
package.json | 1 +
src/controllers/auth.controller.js | 39 +++++
src/models/oauth-connection.model.js | 37 +++++
src/models/user.model.js | 132 +++++++++++++++++
src/routes/auth.routes.js | 2 +-
src/services/auth.service.js | 87 +++++++++++
7 files changed, 509 insertions(+), 1 deletion(-)
create mode 100644 src/models/oauth-connection.model.js
diff --git a/package-lock.json b/package-lock.json
index 4d4cf9c..40fad4b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,6 +19,7 @@
"dotenv": "^16.4.5",
"express": "^4.18.3",
"express-rate-limit": "^7.2.0",
+ "google-auth-library": "^10.6.2",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
@@ -2894,6 +2895,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/ajv": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
@@ -3130,6 +3140,26 @@
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
@@ -3172,6 +3202,15 @@
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ=="
},
+ "node_modules/bignumber.js": {
+ "version": "9.3.1",
+ "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
+ "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -3740,6 +3779,15 @@
"node": ">=0.4.0"
}
},
+ "node_modules/data-uri-to-buffer": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
+ "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/db-migrate": {
"version": "0.11.14",
"resolved": "https://registry.npmjs.org/db-migrate/-/db-migrate-0.11.14.tgz",
@@ -4534,6 +4582,12 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+ "license": "MIT"
+ },
"node_modules/eyes": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz",
@@ -4618,6 +4672,29 @@
"bser": "2.1.1"
}
},
+ "node_modules/fetch-blob": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
+ "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/jimmywarting"
+ },
+ {
+ "type": "paypal",
+ "url": "https://paypal.me/jimmywarting"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "node-domexception": "^1.0.0",
+ "web-streams-polyfill": "^3.0.3"
+ },
+ "engines": {
+ "node": "^12.20 || >= 14.13"
+ }
+ },
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -4734,6 +4811,18 @@
"node": ">= 6"
}
},
+ "node_modules/formdata-polyfill": {
+ "version": "4.0.10",
+ "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
+ "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
+ "license": "MIT",
+ "dependencies": {
+ "fetch-blob": "^3.1.2"
+ },
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
"node_modules/formidable": {
"version": "3.5.4",
"resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
@@ -4795,6 +4884,34 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/gaxios": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz",
+ "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "extend": "^3.0.2",
+ "https-proxy-agent": "^7.0.1",
+ "node-fetch": "^3.3.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/gcp-metadata": {
+ "version": "8.1.2",
+ "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
+ "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "gaxios": "^7.0.0",
+ "google-logging-utils": "^1.0.0",
+ "json-bigint": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -4916,6 +5033,32 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/google-auth-library": {
+ "version": "10.6.2",
+ "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz",
+ "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "base64-js": "^1.3.0",
+ "ecdsa-sig-formatter": "^1.0.11",
+ "gaxios": "^7.1.4",
+ "gcp-metadata": "8.1.2",
+ "google-logging-utils": "1.1.3",
+ "jws": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/google-logging-utils": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
+ "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -5018,6 +5161,19 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/human-signals": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
@@ -5928,6 +6084,15 @@
"node": ">=6"
}
},
+ "node_modules/json-bigint": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
+ "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bignumber.js": "^9.0.0"
+ }
+ },
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@@ -6353,6 +6518,44 @@
"node": ">= 0.6"
}
},
+ "node_modules/node-domexception": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
+ "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
+ "deprecated": "Use your platform's native DOMException instead",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/jimmywarting"
+ },
+ {
+ "type": "github",
+ "url": "https://paypal.me/jimmywarting"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.5.0"
+ }
+ },
+ "node_modules/node-fetch": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
+ "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
+ "license": "MIT",
+ "dependencies": {
+ "data-uri-to-buffer": "^4.0.0",
+ "fetch-blob": "^3.1.4",
+ "formdata-polyfill": "^4.0.10"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/node-fetch"
+ }
+ },
"node_modules/node-fs": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/node-fs/-/node-fs-0.1.7.tgz",
@@ -8191,6 +8394,15 @@
"makeerror": "1.0.12"
}
},
+ "node_modules/web-streams-polyfill": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
+ "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/when": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/when/-/when-2.0.1.tgz",
diff --git a/package.json b/package.json
index c35cea3..2310c5c 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,7 @@
"dotenv": "^16.4.5",
"express": "^4.18.3",
"express-rate-limit": "^7.2.0",
+ "google-auth-library": "^10.6.2",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js
index c17893e..097ab80 100644
--- a/src/controllers/auth.controller.js
+++ b/src/controllers/auth.controller.js
@@ -6,6 +6,7 @@
const authService = require('../services/auth.service');
const { success } = require('../utils/api-response');
const { error } = require('../utils/api-response');
+const { parseDurationToSeconds } = require('../utils/token-generator');
const { isValidEmail, isValidPassword } = require('../utils/validators');
exports.register = async (req, res) => {
@@ -227,4 +228,42 @@ exports.verifyEmailChange = async (req, res) => {
const data = await authService.verifyEmailChange({ token });
return success(res, data, 'Email updated successfully.');
+};
+
+exports.googleLogin = async (req, res) => {
+ const { id_token } = req.body;
+
+ if (!id_token || typeof id_token !== 'string') {
+ return error(res, 'VALIDATION_FAILED', 'Validation failed', 400, [
+ { field: 'id_token', issue: 'Google id_token is required' },
+ ]);
+ }
+
+ const result = await authService.googleLogin({ id_token });
+
+ res.cookie('refresh_token', result.refreshToken, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'strict',
+ maxAge: 7 * 24 * 60 * 60 * 1000,
+ });
+
+ return success(
+ res,
+ {
+ access_token: result.accessToken,
+ token_type: 'Bearer',
+ expires_in: parseDurationToSeconds(process.env.JWT_ACCESS_EXPIRES_IN),
+ is_new_user: result.is_new_user,
+ user: {
+ user_id: result.user.id,
+ email: result.user.email,
+ display_name: result.user.display_name,
+ gender: result.user.gender,
+ role: result.user.role,
+ is_verified: result.user.is_verified,
+ },
+ },
+ 'Logged in successfully with Google.'
+ );
};
\ No newline at end of file
diff --git a/src/models/oauth-connection.model.js b/src/models/oauth-connection.model.js
new file mode 100644
index 0000000..fe36e4d
--- /dev/null
+++ b/src/models/oauth-connection.model.js
@@ -0,0 +1,37 @@
+// ============================================================
+// models/oauth-connection.model.js
+// Table: oauth_connections
+// ============================================================
+const db = require('../config/db');
+
+// Find an existing OAuth connection with provider and provider_user_id
+exports.findByProvider = async (provider, providerUserId) => {
+ const { rows } = await db.query(
+ `SELECT * FROM oauth_connections
+ WHERE provider = $1 AND provider_user_id = $2`,
+ [provider, providerUserId]
+ );
+ return rows[0] || null;
+};
+
+// Create a new OAuth connection linking a Rythmify user to a provider
+exports.create = async ({ user_id, provider, provider_user_id, access_token, refresh_token, expires_at }) => {
+ const { rows } = await db.query(
+ `INSERT INTO oauth_connections
+ (user_id, provider, provider_user_id, access_token, refresh_token, expires_at)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ RETURNING *`,
+ [user_id, provider, provider_user_id, access_token || null, refresh_token || null, expires_at || null]
+ );
+ return rows[0];
+};
+
+// Update provider tokens on re-login
+exports.updateTokens = async ({ user_id, provider, access_token, refresh_token, expires_at }) => {
+ await db.query(
+ `UPDATE oauth_connections
+ SET access_token = $3, refresh_token = $4, expires_at = $5, updated_at = now()
+ WHERE user_id = $1 AND provider = $2`,
+ [user_id, provider, access_token || null, refresh_token || null, expires_at || null]
+ );
+};
\ No newline at end of file
diff --git a/src/models/user.model.js b/src/models/user.model.js
index 591afe0..390ea86 100644
--- a/src/models/user.model.js
+++ b/src/models/user.model.js
@@ -68,6 +68,138 @@ exports.updatePassword = async (userId, newPasswordHashed) => {
]);
};
+// update password (called during password reset)
+exports.updatePassword = async (userId, newPasswordHashed) => {
+ await db.query(`UPDATE users SET password_hashed = $1, updated_at = now() WHERE id = $2`, [
+ newPasswordHashed,
+ userId,
+ ]);
+};
+
+// get user profile (called in GET /users/me)
+exports.findFullById = async (id) => {
+ const { rows } = await db.query(
+ `SELECT
+ id, email, username, display_name, first_name, last_name,
+ bio, city, country, gender, date_of_birth, role,
+ profile_picture, cover_photo, is_private, is_verified,
+ is_suspended, twofa_enabled, followers_count, following_count,
+ last_login_at, created_at, updated_at
+ FROM users
+ WHERE id = $1 AND deleted_at IS NULL`,
+ [id]
+ );
+ return rows[0] || null;
+};
+
+// get public user profile (called in GET /users/:id)
+exports.findPublicById = async (id) => {
+ const { rows } = await db.query(
+ `SELECT
+ id, display_name, username, bio, city, country,
+ gender, role, profile_picture, cover_photo,
+ is_private, is_verified, followers_count, following_count,
+ created_at
+ FROM users
+ WHERE id = $1 AND deleted_at IS NULL`,
+ [id]
+ );
+ return rows[0] || null;
+};
+
+// check if user A is following user B (used in GET /users/:id to determine if we can show private profile)
+exports.isFollowing = async (followerId, followingId) => {
+ const { rows } = await db.query(
+ `SELECT 1 FROM follows WHERE follower_id = $1 AND following_id = $2`,
+ [followerId, followingId]
+ );
+ return rows.length > 0;
+};
+
+exports.updateProfile = async (userId, fields) => {
+ const allowed = ['display_name', 'username', 'first_name', 'last_name', 'bio', 'city', 'country'];
+ const updates = [];
+ const values = [];
+ let i = 1;
+
+ for (const key in fields) {
+ if (allowed.includes(key)) {
+ updates.push(`${key} = $${i}`);
+ values.push(fields[key]);
+ i++;
+ }
+ }
+
+ if (updates.length === 0) return null;
+
+ updates.push(`updated_at = now()`);
+ values.push(userId);
+
+ const { rows } = await db.query(
+ `UPDATE users SET ${updates.join(', ')} WHERE id = $${i}
+ RETURNING
+ id, email, username, display_name, first_name, last_name,
+ bio, city, country, gender, date_of_birth, role,
+ profile_picture, cover_photo, is_private, is_verified,
+ followers_count, following_count, created_at, updated_at`,
+ values
+ );
+ return rows[0] || null;
+};
+
+exports.updateAccount = async (userId, fields) => {
+ const allowed = ['date_of_birth', 'gender'];
+ const updates = [];
+ const values = [];
+ let i = 1;
+
+ for (const key in fields) {
+ if (allowed.includes(key)) {
+ updates.push(`${key} = $${i}`);
+ values.push(fields[key]);
+ i++;
+ }
+ }
+
+ if (updates.length === 0) return null;
+
+ updates.push(`updated_at = now()`);
+ values.push(userId);
+
+ const { rows } = await db.query(
+ `UPDATE users SET ${updates.join(', ')} WHERE id = $${i}
+ RETURNING
+ id, email, username, display_name, first_name, last_name,
+ bio, city, country, gender, date_of_birth, role,
+ profile_picture, cover_photo, is_private, is_verified,
+ followers_count, following_count, created_at, updated_at`,
+ values
+ );
+ return rows[0] || null;
+};
+
+exports.updateRole = async (userId, newRole) => {
+ const { rows } = await db.query(
+ `UPDATE users SET role = $1, updated_at = now()
+ WHERE id = $2 AND deleted_at IS NULL
+ RETURNING
+ id, email, username, display_name, role,
+ is_verified, followers_count, following_count, updated_at`,
+ [newRole, userId]
+ );
+ return rows[0] || null;
+};
+
+exports.createOAuthUser = async ({ email, display_name }) => {
+ const { rows } = await db.query(
+ `INSERT INTO users (email, display_name, is_verified)
+ VALUES ($1, $2, true)
+ RETURNING id, email, display_name, gender, role, is_verified, created_at`,
+ [email, display_name]
+ );
+ return rows[0];
+};
+
// Set pending_email (called when user requests email change)
exports.setPendingEmail = async (userId, pendingEmail) => {
await db.query(`UPDATE users SET pending_email = $2 WHERE id = $1`, [userId, pendingEmail]);
diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js
index 500e119..a2ccfab 100644
--- a/src/routes/auth.routes.js
+++ b/src/routes/auth.routes.js
@@ -27,7 +27,7 @@ router.post('/verify-email-change', asyncHandler(controller.verifyEmailChange)
// Protected routes
router.post('/change-email', authenticate, asyncHandler(controller.changeEmail));
-
+router.post('/google', asyncHandler(controller.googleLogin));
// router.post('/resend-verification', asyncHandler(controller.resendVerification));
diff --git a/src/services/auth.service.js b/src/services/auth.service.js
index 9ba7b13..c3d8645 100644
--- a/src/services/auth.service.js
+++ b/src/services/auth.service.js
@@ -1,7 +1,9 @@
const bcrypt = require('bcryptjs');
+const { OAuth2Client } = require('google-auth-library');
const userModel = require('../models/user.model');
const verificationTokenModel = require('../models/verification-token.model');
const refreshTokenModel = require('../models/refresh-token.model');
+const oauthConnectionModel = require('../models/oauth-connection.model');
const { generateSecureToken, parseDurationToSeconds } = require('../utils/token-generator');
const TOKEN_TYPES = require('../constants/token-types');
const {
@@ -14,6 +16,8 @@ const AppError = require('../utils/app-error');
const { signAccessToken, signRefreshToken, verifyToken } = require('../config/jwt');
const env = require('../config/env');
+
+
//=====================================
// Registration and Email verification
//=====================================
@@ -366,4 +370,87 @@ exports.verifyEmailChange = async ({ token }) => {
const updated = await userModel.applyPendingEmail(tokenRow.user_id);
return { email: updated.email };
+};
+
+// ============================================================
+// Google OAuth
+// ============================================================
+const googleClient = new OAuth2Client(env.GOOGLE_CLIENT_ID);
+exports.googleLogin = async ({ id_token }) => {
+ // verify the token from google
+ let payload;
+ try {
+ const ticket = await googleClient.verifyIdToken({
+ idToken: id_token,
+ audience: env.GOOGLE_CLIENT_ID,
+ });
+ payload = ticket.getPayload();
+ } catch {
+ throw new AppError('Google token invalid', 401, 'AUTH_TOKEN_INVALID');
+ }
+
+ // after
+ const { sub: providerUserId, email, given_name, name } = payload;
+
+ // check if this account is already connected with google
+ const existingConnection = await oauthConnectionModel.findByProvider('google', providerUserId);
+
+ if (existingConnection) {
+ // we need to only update the token
+ const user = await userModel.findById(existingConnection.user_id);
+
+ if (user.is_suspended) {
+ throw new AppError('Account suspended by admin', 403, 'AUTH_ACCOUNT_SUSPENDED');
+ }
+
+ await oauthConnectionModel.updateTokens({
+ user_id: user.id,
+ provider: 'google',
+ access_token: null,
+ refresh_token: null,
+ expires_at: null,
+ });
+
+ const accessToken = signAccessToken({ sub: user.id, role: user.role });
+ const refreshToken = await createAndStoreRefreshToken(user.id);
+
+ return {
+ accessToken,
+ refreshToken,
+ is_new_user: false,
+ user,
+ };
+ }
+
+ // checking if the email exists in the normal users
+ let user = await userModel.findByEmail(email);
+ let is_new_user = false;
+
+ if (!user) {
+ user = await userModel.createOAuthUser({
+ email,
+ display_name: given_name || name || email.split('@')[0],
+ });
+ is_new_user = true;
+ }
+
+ if (user.is_suspended) {
+ throw new AppError('Account suspended by admin', 403, 'AUTH_ACCOUNT_SUSPENDED');
+ }
+
+ await oauthConnectionModel.create({
+ user_id: user.id,
+ provider: 'google',
+ provider_user_id: providerUserId,
+ });
+
+ const accessToken = signAccessToken({ sub: user.id, role: user.role });
+ const refreshToken = await createAndStoreRefreshToken(user.id);
+
+ return {
+ accessToken,
+ refreshToken,
+ is_new_user,
+ user,
+ };
};
\ No newline at end of file
From 54628462770feb0dda707f308d0e916900453e92 Mon Sep 17 00:00:00 2001
From: Omar Hamza
Date: Sat, 14 Mar 2026 17:40:01 +0200
Subject: [PATCH 060/535] refactor(users): replace enum literals with shared
constants
Replace hardcoded enum string literals in the users service with centralized constants.
Changes:
- Use shared enum constants instead of inline string values
- Import and apply:
- GENDER_TYPES from gender-types.js
- USER_ROLES from user-roles.js
Account validation:
- update updateMyAccount gender validation to use Object.values(GENDER_TYPES)
- keep existing validation behavior (400 VALIDATION_FAILED)
Role switching:
- update switchRole validation to use USER_ROLES constants
- restrict allowed roles to ARTIST and LISTENER
- preserve existing behavior (400 for invalid role, 409 when role already assigned)
Consistency:
- derive validation error messages from enum constants
- align validation logic with shared constants used across the backend
---
src/services/users.service.js | 78 +++++++++++++++++++----------------
1 file changed, 43 insertions(+), 35 deletions(-)
diff --git a/src/services/users.service.js b/src/services/users.service.js
index bab68cb..f7b014b 100644
--- a/src/services/users.service.js
+++ b/src/services/users.service.js
@@ -1,5 +1,7 @@
const userModel = require('../models/user.model');
const AppError = require('../utils/app-error');
+const GENDER_TYPES = require('../constants/gender-types');
+const USER_ROLES = require('../constants/user-roles');
// full private profile for authenticated user
exports.getMe = async (userId) => {
@@ -53,60 +55,66 @@ exports.updateMe = async (userId, fields) => {
};
exports.updateMyAccount = async (userId, fields) => {
- // validate gender if provided
-
- if (fields.gender && !['male', 'female'].includes(fields.gender)) {
+ // validate gender if provided
+
+ if (fields.gender && !Object.values(GENDER_TYPES).includes(fields.gender)) {
throw new AppError('Validation failed', 400, 'VALIDATION_FAILED', [
- { field: 'gender', issue: 'Must be one of male, female' }
+ {
+ field: 'gender',
+ issue: `Must be one of ${GENDER_TYPES.MALE}, ${GENDER_TYPES.FEMALE}`,
+ },
]);
- }
+ }
- if (fields.date_of_birth) {
+ if (fields.date_of_birth) {
const date = new Date(fields.date_of_birth);
-
+
if (isNaN(date.getTime())) {
- throw new AppError('Validation failed', 400, 'VALIDATION_FAILED', [
- { field: 'date_of_birth', issue: 'Must be a valid date (YYYY-MM-DD)' }
- ]);
+ throw new AppError('Validation failed', 400, 'VALIDATION_FAILED', [
+ { field: 'date_of_birth', issue: 'Must be a valid date (YYYY-MM-DD)' },
+ ]);
}
-
+
const minAge = new Date();
minAge.setFullYear(minAge.getFullYear() - 13);
if (date > minAge) {
- throw new AppError('Validation failed', 400, 'VALIDATION_FAILED', [
- { field: 'date_of_birth', issue: 'You must be at least 13 years old' }
- ]);
- }
+ throw new AppError('Validation failed', 400, 'VALIDATION_FAILED', [
+ { field: 'date_of_birth', issue: 'You must be at least 13 years old' },
+ ]);
}
+ }
- const updated = await userModel.updateAccount(userId, fields);
- if (!updated) {
- throw new AppError('Nothing to update', 400, 'VALIDATION_FAILED');
- }
+ const updated = await userModel.updateAccount(userId, fields);
+ if (!updated) {
+ throw new AppError('Nothing to update', 400, 'VALIDATION_FAILED');
+ }
- return updated;
+ return updated;
};
-
// PATCH /users/me/role
exports.switchRole = async (userId, role) => {
- // only artist and listener are self-assignable
- if (!['artist', 'listener'].includes(role)) {
+ // only artist and listener are self-assignable
+ const allowedRoles = [USER_ROLES.ARTIST, USER_ROLES.LISTENER];
+ if (!allowedRoles.includes(role)) {
throw new AppError('Validation failed', 400, 'VALIDATION_FAILED', [
- { field: 'role', issue: 'Must be one of artist, listener' }
+ {
+ field: 'role',
+ issue: `Must be one of ${USER_ROLES.ARTIST}, ${USER_ROLES.LISTENER}`,
+ },
]);
- }
+ }
- // get current user to check existing role
- const user = await userModel.findById(userId);
- if (!user) {
- throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
- }
+ // get current user to check existing role
+ const user = await userModel.findById(userId);
+ if (!user) {
+ throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
+ }
- if (user.role === role) {
- throw new AppError(`User already has the ${role} role.`, 409, 'RESOURCE_ALREADY_EXISTS');
- }
+ if (user.role === role) {
+ throw new AppError(`User already has the ${role} role.`, 409, 'RESOURCE_ALREADY_EXISTS');
+ }
- return await userModel.updateRole(userId, role);
-};
\ No newline at end of file
+ return await userModel.updateRole(userId, role);
+};
From 0cd4ca61a9408dd2a1fc01050382e6a255935cd3 Mon Sep 17 00:00:00 2001
From: Beshoy205
Date: Mon, 16 Mar 2026 00:07:34 +0200
Subject: [PATCH 061/535] Delete Avatar
---
src/controllers/users.controller.js | 5 +++++
src/models/user.model.js | 10 ++++++++++
src/routes/users.routes.js | 1 +
src/services/users.service.js | 11 +++++++++++
4 files changed, 27 insertions(+)
diff --git a/src/controllers/users.controller.js b/src/controllers/users.controller.js
index 9c62d0b..8bb25ba 100644
--- a/src/controllers/users.controller.js
+++ b/src/controllers/users.controller.js
@@ -54,4 +54,9 @@ exports.switchRole = async (req, res) => {
const data = await usersService.switchRole(userId, role);
return success(res, data, 'Role switched successfully.');
+};
+
+exports.deleteMyAvatar= async(req,res) => {
+ const data = await usersService.deleteMyAvatar(req.user.sub);
+ return success(res, data, 'Your avatar deleted successfully.');
};
\ No newline at end of file
diff --git a/src/models/user.model.js b/src/models/user.model.js
index 390ea86..afe97fa 100644
--- a/src/models/user.model.js
+++ b/src/models/user.model.js
@@ -216,3 +216,13 @@ exports.applyPendingEmail = async (userId) => {
);
return rows[0];
};
+
+exports.deleteAvatar = async (userId) => {
+ const { rows } = await db.query(
+ `UPDATE users SET profile_picture = NULL, updated_at = now()
+ WHERE id = $1 AND deleted_at IS NULL
+ RETURNING profile_picture`,
+ [userId]
+ );
+ return rows[0] || null;
+};
diff --git a/src/routes/users.routes.js b/src/routes/users.routes.js
index 016982e..c46c330 100644
--- a/src/routes/users.routes.js
+++ b/src/routes/users.routes.js
@@ -16,5 +16,6 @@ router.patch('/me/account', authenticate, asyncHandler(controller.updateMyAccoun
router.patch('/me/role', authenticate, asyncHandler(controller.switchRole));
router.get('/:user_id', optionalAuthenticate, asyncHandler(controller.getUserById));
+router.delete('/me/avatar', authenticate, asyncHandler(controller.deleteMyAvatar));
module.exports = router;
\ No newline at end of file
diff --git a/src/services/users.service.js b/src/services/users.service.js
index f7b014b..11239ab 100644
--- a/src/services/users.service.js
+++ b/src/services/users.service.js
@@ -118,3 +118,14 @@ exports.switchRole = async (userId, role) => {
return await userModel.updateRole(userId, role);
};
+
+exports.deleteMyAvatar = async (userId) => {
+ const user = await userModel.findById(userId);
+ if(!user){
+ throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+ if (!user.profile_picture) {
+ throw new AppError('No avatar to delete', 404, 'RESOURCE_NOT_FOUND');
+ }
+ return await userModel.deleteAvatar(userId);
+}
From 6756d7f1a06455ab109122c3bfbdf7cd7275076d Mon Sep 17 00:00:00 2001
From: Beshoy205
Date: Mon, 16 Mar 2026 04:03:59 +0200
Subject: [PATCH 062/535] feat(users): add avatar, cover, and web profile
endpoints
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- POST /me/avatar — upload profile picture
- DELETE /me/avatar — remove profile picture
- POST /me/cover — upload cover photo
- DELETE /me/cover — remove cover photo
- GET /me/web-profiles — list web profile links
---
src/controllers/users.controller.js | 28 ++++++++++++++++++++--
src/models/user.model.js | 36 +++++++++++++++++++++++++++++
src/routes/users.routes.js | 7 ++++++
src/services/users.service.js | 34 +++++++++++++++++++++++++++
4 files changed, 103 insertions(+), 2 deletions(-)
diff --git a/src/controllers/users.controller.js b/src/controllers/users.controller.js
index 8bb25ba..bd45647 100644
--- a/src/controllers/users.controller.js
+++ b/src/controllers/users.controller.js
@@ -58,5 +58,29 @@ exports.switchRole = async (req, res) => {
exports.deleteMyAvatar= async(req,res) => {
const data = await usersService.deleteMyAvatar(req.user.sub);
- return success(res, data, 'Your avatar deleted successfully.');
-};
\ No newline at end of file
+ return success(res, data, 'Your profile picture deleted successfully.');
+};
+
+exports.uploadMyAvatar = async (req, res) => {
+ if (!req.file) {
+ throw new AppError('No image file provided.', 400, 'VALIDATION_FAILED');
+ }
+ const data = await usersService.uploadMyAvatar(req.user.sub, req.file);
+ return success(res, data, 'Avatar uploaded successfully.');
+};
+exports.uploadMyCoverPhoto = async (req, res) => {
+ if (!req.file) {
+ throw new AppError('No image file provided.', 400, 'VALIDATION_FAILED');
+ }
+ const data = await usersService.uploadMyCoverPhoto(req.user.sub, req.file);
+ return success(res, data, 'Cover photo uploaded successfully.');
+};
+exports.deleteMyCoverPhoto = async(req,res) => {
+ const data = await usersService.deleteMyCoverPhoto(req.user.sub);
+ return success(res, data, 'Your cover photo deleted successfully.');
+};
+exports.getMyWebProfile = async (req, res) => {
+ const data = await usersService.getMyWebProfile(req.user.sub);
+ return success(res, data, 'Web profile returned successfully.');
+};
+
diff --git a/src/models/user.model.js b/src/models/user.model.js
index afe97fa..e48c76e 100644
--- a/src/models/user.model.js
+++ b/src/models/user.model.js
@@ -226,3 +226,39 @@ exports.deleteAvatar = async (userId) => {
);
return rows[0] || null;
};
+
+exports.updateAvatar = async (userId, imagePath) => {
+ const { rows } = await db.query(
+ `UPDATE users SET profile_picture = $1, updated_at = now()
+ WHERE id = $2 AND deleted_at IS NULL
+ RETURNING profile_picture`,
+ [imagePath, userId]
+ );
+ return rows[0] || null;
+};
+exports.updateCoverPhoto = async (userId, imagePath) => {
+ const { rows } = await db.query(
+ `UPDATE users SET cover_photo = $1, updated_at = now()
+ WHERE id = $2 AND deleted_at IS NULL
+ RETURNING cover_photo`,
+ [imagePath, userId]
+ );
+ return rows[0] || null;
+};
+
+exports.deleteCoverPhoto = async (userId) => {
+ const { rows } = await db.query(
+ `UPDATE users SET cover_photo = NULL, updated_at = now()
+ WHERE id = $1 AND deleted_at IS NULL
+ RETURNING cover_photo`,
+ [userId]
+ );
+ return rows[0] || null;
+};
+
+exports.findWebProfilesByUserId = async (userId) => {
+ const { rows } = await db.query(`SELECT id, platform, url FROM web_profiles WHERE user_id = $1`, [
+ userId,
+ ]);
+ return rows || [];
+};
diff --git a/src/routes/users.routes.js b/src/routes/users.routes.js
index c46c330..c8cda42 100644
--- a/src/routes/users.routes.js
+++ b/src/routes/users.routes.js
@@ -8,6 +8,7 @@ const router = express.Router();
const controller = require('../controllers/users.controller');
const { authenticate, optionalAuthenticate } = require('../middleware/auth');
const asyncHandler = require('../utils/async-handler');
+const { uploadImage } = require('../middleware/multer');
router.get('/me', authenticate ,asyncHandler(controller.getMe));
@@ -17,5 +18,11 @@ router.patch('/me/role', authenticate, asyncHandler(controller.switchRole));
router.get('/:user_id', optionalAuthenticate, asyncHandler(controller.getUserById));
router.delete('/me/avatar', authenticate, asyncHandler(controller.deleteMyAvatar));
+router.post('/me/avatar', authenticate, uploadImage.single('avatar'), asyncHandler(controller.uploadMyAvatar));
+router.post('/me/cover', authenticate, uploadImage.single('cover'), asyncHandler(controller.uploadMyCoverPhoto));
+router.delete('/me/cover', authenticate, asyncHandler(controller.deleteMyCoverPhoto));
+router.get('/me/web-profile', authenticate, asyncHandler(controller.getMyWebProfile));
+
+
module.exports = router;
\ No newline at end of file
diff --git a/src/services/users.service.js b/src/services/users.service.js
index 11239ab..1b30deb 100644
--- a/src/services/users.service.js
+++ b/src/services/users.service.js
@@ -129,3 +129,37 @@ exports.deleteMyAvatar = async (userId) => {
}
return await userModel.deleteAvatar(userId);
}
+exports.uploadMyAvatar = async (userId, file) => {
+ const user = await userModel.findById(userId);
+ if (!user) {
+ throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+ // upload file to CDN and get back real URL
+ const avatarUrl = `https://cdn.rythmify.com/avatars/${userId}.jpg`;
+ return await userModel.updateAvatar(userId, avatarUrl);
+};
+
+exports.uploadMyCoverPhoto = async (userId, file) => {
+ const user = await userModel.findById(userId);
+ if (!user) {
+ throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+ // upload file to CDN and get back real URL
+ const coverUrl = `https://cdn.rythmify.com/covers/${userId}.jpg`;
+ return await userModel.updateCoverPhoto(userId, coverUrl);
+};
+exports.deleteMyCoverPhoto = async (userId) => {
+ const user = await userModel.findById(userId);
+ if (!user) {
+ throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+ if (!user.cover_photo) {
+ throw new AppError('No cover photo to delete', 404, 'RESOURCE_NOT_FOUND');
+ }
+ return await userModel.deleteCoverPhoto(userId);
+};
+
+exports.getMyWebProfiles = async (userId) => {
+ return await userModel.findWebProfilesByUserId(userId);
+};
+
From a22099ff8a78827886ec705491e4f8a2aa740a7e Mon Sep 17 00:00:00 2001
From: Beshoy205
Date: Mon, 16 Mar 2026 08:59:04 +0200
Subject: [PATCH 063/535] feat(users): add web profiles, privacy, and content
settings routes and controllers
- POST /me/web-profiles
- DELETE /me/web-profiles/:profile_id
- PATCH /me/privacy
- GET /me/content-settings
- PATCH /me/content-settings
---
src/controllers/users.controller.js | 38 +++++++++++++++++++++++++++++
src/routes/users.routes.js | 7 ++++--
2 files changed, 43 insertions(+), 2 deletions(-)
diff --git a/src/controllers/users.controller.js b/src/controllers/users.controller.js
index bd45647..266da84 100644
--- a/src/controllers/users.controller.js
+++ b/src/controllers/users.controller.js
@@ -83,4 +83,42 @@ exports.getMyWebProfile = async (req, res) => {
const data = await usersService.getMyWebProfile(req.user.sub);
return success(res, data, 'Web profile returned successfully.');
};
+exports.addWebProfile = async (req, res) => {
+ const { platform, url } = req.body;
+ const data = await usersService.addWebProfile(req.user.sub, platform, url);
+ return success(res, data, 'Web profile link created.', 201);
+};
+
+exports.deleteWebProfile = async (req, res) => {
+ const profileId = req.params.profile_id;
+ const data = await usersService.deleteWebProfile(req.user.sub, profileId);
+ return success(res, data, 'Web profile link deleted successfully.');
+};
+exports.updatePrivacy = async (req, res) => {
+ const { is_private } = req.body;
+ if (is_private === undefined) {
+ throw new AppError('is_private field is required.', 400, 'VALIDATION_FAILED');
+ }
+ const data = await usersService.updatePrivacy(req.user.sub, is_private);
+ return success(res, data, `Profile is now ${is_private ? 'private' : 'public'}.`);
+};
+
+exports.getMyContentSettings = async (req, res) => {
+ const data = await usersService.getMyContentSettings(req.user.sub);
+ return success(res, data, 'Content settings returned successfully.');
+};
+
+exports.updateMyContentSettings = async (req, res) => {
+ const fields = {};
+ if (req.body.rss_title !== undefined) fields.rss_title = req.body.rss_title;
+ if (req.body.rss_language !== undefined) fields.rss_language = req.body.rss_language;
+ if (req.body.rss_category !== undefined) fields.rss_category = req.body.rss_category;
+ if (req.body.rss_explicit !== undefined) fields.rss_explicit = req.body.rss_explicit;
+ if (req.body.rss_show_email !== undefined) fields.rss_show_email = req.body.rss_show_email;
+ if (req.body.default_include_in_rss !== undefined) fields.default_include_in_rss = req.body.default_include_in_rss;
+ if (req.body.default_license_type !== undefined) fields.default_license_type = req.body.default_license_type;
+
+ const data = await usersService.updateMyContentSettings(req.user.sub, fields);
+ return success(res, data, 'Content settings updated successfully.');
+};
\ No newline at end of file
diff --git a/src/routes/users.routes.js b/src/routes/users.routes.js
index c8cda42..d00dd30 100644
--- a/src/routes/users.routes.js
+++ b/src/routes/users.routes.js
@@ -22,7 +22,10 @@ router.post('/me/avatar', authenticate, uploadImage.single('avatar'), asyncHandl
router.post('/me/cover', authenticate, uploadImage.single('cover'), asyncHandler(controller.uploadMyCoverPhoto));
router.delete('/me/cover', authenticate, asyncHandler(controller.deleteMyCoverPhoto));
router.get('/me/web-profile', authenticate, asyncHandler(controller.getMyWebProfile));
-
-
+router.post('/me/web-profiles', authenticate, asyncHandler(controller.addWebProfile));
+router.delete('/me/web-profiles/:profile_id', authenticate, asyncHandler(controller.deleteWebProfile));
+router.patch('/me/privacy', authenticate, asyncHandler(controller.updatePrivacy));
+router.get('/me/content-settings', authenticate, asyncHandler(controller.getMyContentSettings));
+router.patch('/me/content-settings', authenticate, asyncHandler(controller.updateMyContentSettings));
module.exports = router;
\ No newline at end of file
From e5ae17389aa5c61d1f9d86ef85867e937aa637c8 Mon Sep 17 00:00:00 2001
From: Beshoy205
Date: Mon, 16 Mar 2026 17:27:31 +0200
Subject: [PATCH 064/535] feat(users): add web profiles, privacy, and content
settings service and model layers
- addWebProfile, deleteWebProfile service and model
- updatePrivacy service and model
- getMyContentSettings, updateMyContentSettings service and model
---
src/models/user.model.js | 88 +++++++++++++++++++++++++++++++++++
src/services/users.service.js | 45 ++++++++++++++++++
2 files changed, 133 insertions(+)
diff --git a/src/models/user.model.js b/src/models/user.model.js
index e48c76e..67e32ce 100644
--- a/src/models/user.model.js
+++ b/src/models/user.model.js
@@ -262,3 +262,91 @@ exports.findWebProfilesByUserId = async (userId) => {
]);
return rows || [];
};
+exports.findWebProfileByPlatform = async (userId, platform) => {
+ const { rows } = await db.query(
+ `SELECT id FROM web_profiles WHERE user_id = $1 AND platform = $2`,
+ [userId, platform]
+ );
+ return rows[0] || null;
+};
+
+exports.createWebProfile = async (userId, platform, url) => {
+ const { rows } = await db.query(
+ `INSERT INTO web_profiles (user_id, platform, url)
+ VALUES ($1, $2, $3)
+ RETURNING id, platform, url`,
+ [userId, platform, url]
+ );
+ return rows[0] || null;
+};
+
+exports.deleteWebProfile = async (profileId) => {
+ const { rows } = await db.query(`DELETE FROM web_profiles WHERE id = $1 RETURNING id`, [
+ profileId,
+ ]);
+ return rows[0] || null;
+};
+
+exports.findWebProfileById = async (profileId) => {
+ const { rows } = await db.query(
+ `SELECT id, user_id, platform, url FROM web_profiles WHERE id = $1`,
+ [profileId]
+ );
+ return rows[0] || null;
+};
+
+exports.updatePrivacy = async (userId, isPrivate) => {
+ const { rows } = await db.query(
+ `UPDATE users SET is_private = $1, updated_at = now()
+ WHERE id = $2 AND deleted_at IS NULL
+ RETURNING is_private`,
+ [isPrivate, userId]
+ );
+ return rows[0] || null;
+};
+
+exports.findContentSettingsByUserId = async (userId) => {
+ const { rows } = await db.query(
+ `SELECT rss_title, rss_language, rss_category, rss_explicit,
+ rss_show_email, default_include_in_rss, default_license_type
+ FROM user_content_settings WHERE user_id = $1`,
+ [userId]
+ );
+ return rows[0] || null;
+};
+
+exports.updateContentSettings = async (userId, settings) => {
+ const allowed = [
+ 'rss_title',
+ 'rss_language',
+ 'rss_category',
+ 'rss_explicit',
+ 'rss_show_email',
+ 'default_include_in_rss',
+ 'default_license_type',
+ ];
+ const fields = [];
+ const values = [];
+ let i = 1;
+
+ for (const key in settings) {
+ if (allowed.includes(key)) {
+ fields.push(`${key} = $${i}`);
+ values.push(settings[key]);
+ i++;
+ }
+ }
+
+ if (fields.length === 0) return null;
+ fields.push(`updated_at = now()`);
+ values.push(userId);
+
+ const { rows } = await db.query(
+ `UPDATE user_content_settings SET ${fields.join(', ')}
+ WHERE user_id = $${i}
+ RETURNING rss_title, rss_language, rss_category, rss_explicit,
+ rss_show_email, default_include_in_rss, default_license_type`,
+ values
+ );
+ return rows[0] || null;
+};
diff --git a/src/services/users.service.js b/src/services/users.service.js
index 1b30deb..02153b2 100644
--- a/src/services/users.service.js
+++ b/src/services/users.service.js
@@ -162,4 +162,49 @@ exports.deleteMyCoverPhoto = async (userId) => {
exports.getMyWebProfiles = async (userId) => {
return await userModel.findWebProfilesByUserId(userId);
};
+exports.addWebProfile = async (userId, platform, url) => {
+ const existing = await userModel.findWebProfileByPlatform(userId, platform);
+ if (existing) {
+ throw new AppError('A profile for this platform already exists.', 409, 'RESOURCE_ALREADY_EXISTS');
+ }
+ return await userModel.createWebProfile(userId, platform, url);
+};
+
+exports.deleteWebProfile = async (userId, profileId) => {
+ const profile = await userModel.findWebProfileById(profileId);
+ if (!profile) {
+ throw new AppError('Web profile not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+ if (profile.user_id !== userId) {
+ throw new AppError('You are not allowed to delete this profile.', 403, 'PERMISSION_DENIED');
+ }
+ return await userModel.deleteWebProfile(profileId);
+};
+
+exports.updatePrivacy = async (userId, isPrivate) => {
+ const user = await userModel.findById(userId);
+ if (!user) {
+ throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+ if (user.is_private === isPrivate) {
+ throw new AppError(`Profile is already ${isPrivate ? 'private' : 'public'}.`, 400, 'VALIDATION_FAILED');
+ }
+ const updated = await userModel.updatePrivacy(userId, isPrivate);
+ return updated;
+};
+
+exports.getMyContentSettings = async (userId) => {
+ return await userModel.findContentSettingsByUserId(userId);
+};
+
+exports.updateMyContentSettings = async (userId, settings) => {
+ const user = await userModel.findById(userId);
+ if (!user) {
+ throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+ const updated = await userModel.updateContentSettings(userId, settings);
+ return updated;
+};
+
+
From a1f673e1a348e0731ce73b1b0ba73dcca01c4af1 Mon Sep 17 00:00:00 2001
From: Beshoy205
Date: Mon, 16 Mar 2026 19:56:36 +0200
Subject: [PATCH 065/535] feat(users): add privacy settings, genres, and
onboarding endpoints
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- GET /me/privacy-settings — get privacy toggles
- PATCH /me/privacy-settings — update privacy toggles
- GET /me/genres — get favorite genres
- PUT /me/genres — replace favorite genres
- PATCH /me/onboarding — complete Google onboarding
---
src/controllers/users.controller.js | 48 ++++++++++++-
src/models/user.model.js | 100 ++++++++++++++++++++++++++++
src/routes/users.routes.js | 5 ++
src/services/users.service.js | 37 ++++++++++
4 files changed, 189 insertions(+), 1 deletion(-)
diff --git a/src/controllers/users.controller.js b/src/controllers/users.controller.js
index 266da84..ee19b7b 100644
--- a/src/controllers/users.controller.js
+++ b/src/controllers/users.controller.js
@@ -121,4 +121,50 @@ exports.updateMyContentSettings = async (req, res) => {
const data = await usersService.updateMyContentSettings(req.user.sub, fields);
return success(res, data, 'Content settings updated successfully.');
-};
\ No newline at end of file
+};
+
+exports.getMyPrivacySettings = async (req, res) => {
+ const data = await usersService.getMyPrivacySettings(req.user.sub);
+ return success(res, data, 'Privacy settings returned successfully.');
+};
+
+exports.updateMyPrivacySettings = async (req, res) => {
+ const fields = {};
+ if (req.body.receive_messages_from_anyone !== undefined) fields.receive_messages_from_anyone = req.body.receive_messages_from_anyone;
+ if (req.body.show_activities_in_discovery !== undefined) fields.show_activities_in_discovery = req.body.show_activities_in_discovery;
+ if (req.body.show_as_top_fan !== undefined) fields.show_as_top_fan = req.body.show_as_top_fan;
+ if (req.body.show_top_fans_on_tracks !== undefined) fields.show_top_fans_on_tracks = req.body.show_top_fans_on_tracks;
+ const data = await usersService.updateMyPrivacySettings(req.user.sub, fields);
+ return success(res, data, 'Privacy settings updated successfully.');
+
+};
+
+exports.getMyGenres = async (req, res) => {
+ const data = await usersService.getMyGenres(req.user.sub);
+ return success(res, data, 'Favorite genres returned successfully.');
+};
+
+exports.replaceMyGenres = async (req, res) => {
+ const { genres } = req.body;
+ if (!Array.isArray(genres)) {
+ throw new AppError('genres must be an array.', 400, 'VALIDATION_FAILED');
+ }
+ if (genres.length > 10) {
+ throw new AppError('Maximum of 10 genres allowed.', 400, 'VALIDATION_FAILED');
+ }
+ const data = await usersService.replaceMyGenres(req.user.sub, genres);
+ return success(res, data, 'Favorite genres updated successfully.');
+};
+
+exports.completeOnboarding = async (req, res) => {
+ const { display_name, gender, date_of_birth, bio, city, country } = req.body;
+ const data = await usersService.completeOnboarding(req.user.sub, {
+ display_name,
+ gender,
+ date_of_birth,
+ bio,
+ city,
+ country
+ });
+ return success(res, data, 'Profile onboarding completed successfully.');
+};
diff --git a/src/models/user.model.js b/src/models/user.model.js
index 67e32ce..7338885 100644
--- a/src/models/user.model.js
+++ b/src/models/user.model.js
@@ -350,3 +350,103 @@ exports.updateContentSettings = async (userId, settings) => {
);
return rows[0] || null;
};
+exports.findPrivacySettingsByUserId = async (userId) => {
+ const { rows } = await db.query(
+ `SELECT receive_messages_from_anyone, show_activities_in_discovery,
+ show_as_top_fan, show_top_fans_on_tracks
+ FROM user_privacy_settings WHERE user_id = $1`,
+ [userId]
+ );
+ return rows[0] || null;
+};
+
+exports.updatePrivacySettings = async (userId, settings) => {
+ const allowed = [
+ 'receive_messages_from_anyone',
+ 'show_activities_in_discovery',
+ 'show_as_top_fan',
+ 'show_top_fans_on_tracks',
+ ];
+ const fields = [];
+ const values = [];
+ let i = 1;
+
+ for (const key in settings) {
+ if (allowed.includes(key)) {
+ fields.push(`${key} = $${i}`);
+ values.push(settings[key]);
+ i++;
+ }
+ }
+
+ if (fields.length === 0) return null;
+ fields.push(`updated_at = now()`);
+ values.push(userId);
+
+ const { rows } = await db.query(
+ `UPDATE user_privacy_settings SET ${fields.join(', ')}
+ WHERE user_id = $${i}
+ RETURNING receive_messages_from_anyone, show_activities_in_discovery,
+ show_as_top_fan, show_top_fans_on_tracks`,
+ values
+ );
+ return rows[0] || null;
+};
+
+exports.findGenresByUserId = async (userId) => {
+ const { rows } = await db.query(
+ `SELECT g.id, g.name FROM user_favorite_genres ufg
+ JOIN genres g ON ufg.genre_id = g.id
+ WHERE ufg.user_id = $1`,
+ [userId]
+ );
+ return rows || [];
+};
+
+exports.replaceGenres = async (userId, genreIds) => {
+ const client = await db.getClient();
+ try {
+ await client.query('BEGIN');
+ await client.query(`DELETE FROM user_favorite_genres WHERE user_id = $1`, [userId]);
+ for (const genreId of genreIds) {
+ await client.query(`INSERT INTO user_favorite_genres (user_id, genre_id) VALUES ($1, $2)`, [
+ userId,
+ genreId,
+ ]);
+ }
+ await client.query('COMMIT');
+ return await exports.findGenresByUserId(userId);
+ } catch (error) {
+ await client.query('ROLLBACK');
+ throw error;
+ } finally {
+ client.release();
+ }
+};
+
+exports.completeOnboarding = async (userId, fields) => {
+ const allowed = ['display_name', 'gender', 'date_of_birth', 'bio', 'city', 'country'];
+ const updates = [];
+ const values = [];
+ let i = 1;
+ for (const key in fields) {
+ if (allowed.includes(key)) {
+ updates.push(`${key} = $${i}`);
+ values.push(fields[key]);
+ i++;
+ }
+ }
+
+ if (updates.length === 0) return null;
+ updates.push(`updated_at = now()`);
+ values.push(userId);
+ const { rows } = await db.query(
+ `UPDATE users SET ${updates.join(', ')} WHERE id = $${i}
+ RETURNING id, email, username, display_name, first_name, last_name,
+ bio, city, country, gender, date_of_birth, role,
+ profile_picture, cover_photo, is_private, is_verified,
+ followers_count, following_count, created_at, updated_at`,
+ values
+ );
+ return rows[0] || null;
+};
diff --git a/src/routes/users.routes.js b/src/routes/users.routes.js
index d00dd30..39c6698 100644
--- a/src/routes/users.routes.js
+++ b/src/routes/users.routes.js
@@ -27,5 +27,10 @@ router.delete('/me/web-profiles/:profile_id', authenticate, asyncHandler(control
router.patch('/me/privacy', authenticate, asyncHandler(controller.updatePrivacy));
router.get('/me/content-settings', authenticate, asyncHandler(controller.getMyContentSettings));
router.patch('/me/content-settings', authenticate, asyncHandler(controller.updateMyContentSettings));
+router.get('/me/privacy-settings', authenticate, asyncHandler(controller.getMyPrivacySettings));
+router.patch('/me/privacy-settings', authenticate, asyncHandler(controller.updateMyPrivacySettings));
+router.get('/me/genres', authenticate, asyncHandler(controller.getMyGenres));
+router.put('/me/genres', authenticate, asyncHandler(controller.replaceMyGenres));
+router.patch('/me/onboarding', authenticate, asyncHandler(controller.completeOnboarding));
module.exports = router;
\ No newline at end of file
diff --git a/src/services/users.service.js b/src/services/users.service.js
index 02153b2..77580c2 100644
--- a/src/services/users.service.js
+++ b/src/services/users.service.js
@@ -205,6 +205,43 @@ exports.updateMyContentSettings = async (userId, settings) => {
const updated = await userModel.updateContentSettings(userId, settings);
return updated;
};
+exports.getMyPrivacySettings = async (userId) => {
+ return await userModel.findPrivacySettingsByUserId(userId);
+};
+
+exports.updateMyPrivacySettings = async (userId, settings) => {
+ const user = await userModel.findById(userId);
+ if (!user) {
+ throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+ const updated = await userModel.updatePrivacySettings(userId, settings);
+ return updated;
+};
+
+exports.getMyGenres = async (userId) => {
+ return await userModel.findGenresByUserId(userId);
+};
+
+exports.replaceMyGenres = async (userId, genreIds) => {
+ const user = await userModel.findById(userId);
+ if (!user) {
+ throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+ const updated = await userModel.replaceGenres(userId, genreIds);
+ return updated;
+};
+
+exports.completeOnboarding = async (userId, fields) => {
+ const user = await userModel.findById(userId);
+ if (!user) {
+ throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+ if (user.display_name && user.gender && user.date_of_birth) {
+ throw new AppError('Profile onboarding has already been completed.', 409, 'ONBOARDING_ALREADY_COMPLETED');
+ }
+ const updated = await userModel.completeOnboarding(userId, fields);
+ return updated;
+};
From b1469076b7c77857efff3c05ef4a89ead3a1d692 Mon Sep 17 00:00:00 2001
From: Omar Hamza
Date: Tue, 17 Mar 2026 17:43:48 +0200
Subject: [PATCH 066/535] fix(auth, users): normalize inputs and enforce
case-insensitive email/username handling
- Normalize email inputs across all auth flows (register, login, password reset):
- trim and lowercase before storing and querying
- ensure consistent case-insensitive lookups
- Normalize username handling:
- trim and lowercase usernames on profile updates
- enforce case-insensitive comparisons in queries
- prevent logical duplicates caused by casing differences
- Improve validation during registration:
- trim inputs
- reject whitespace-only values
- Add database-level protection:
- create case-insensitive unique index on LOWER(username)
- Align login identifier handling with stored data format to avoid mismatches
- Install missing dependencies (including socket.io) to fix runtime issues
---
src/controllers/auth.controller.js | 2 +-
src/controllers/users.controller.js | 51 +++++++++++++++--------------
src/middleware/validate-register.js | 22 ++++++++++---
src/models/user.model.js | 21 ++++--------
src/services/auth.service.js | 21 +++++++++---
src/services/users.service.js | 50 ++++++++++++++++++----------
6 files changed, 101 insertions(+), 66 deletions(-)
diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js
index 097ab80..e12a86c 100644
--- a/src/controllers/auth.controller.js
+++ b/src/controllers/auth.controller.js
@@ -72,7 +72,7 @@ exports.login = async (req, res) => {
// ── Call service ────────────────────────────────────────
const result = await authService.login({
- identifier: identifier.trim().toLowerCase(),
+ identifier: identifier.trim(),
password,
});
diff --git a/src/controllers/users.controller.js b/src/controllers/users.controller.js
index ee19b7b..7465a8e 100644
--- a/src/controllers/users.controller.js
+++ b/src/controllers/users.controller.js
@@ -5,6 +5,7 @@
// ============================================================
const usersService = require('../services/users.service');
const { success } = require('../utils/api-response');
+const AppError = require('../utils/app-error');
exports.getMe = async (req, res) => {
const data = await usersService.getMe(req.user.sub);
@@ -24,41 +25,39 @@ exports.updateMe = async (req, res) => {
const fields = {};
if (req.body.display_name !== undefined) fields.display_name = req.body.display_name;
- if (req.body.username !== undefined) fields.username = req.body.username;
- if (req.body.first_name !== undefined) fields.first_name = req.body.first_name;
- if (req.body.last_name !== undefined) fields.last_name = req.body.last_name;
- if (req.body.bio !== undefined) fields.bio = req.body.bio;
- if (req.body.city !== undefined) fields.city = req.body.city;
- if (req.body.country !== undefined) fields.country = req.body.country;
+ if (req.body.username !== undefined) fields.username = req.body.username;
+ if (req.body.first_name !== undefined) fields.first_name = req.body.first_name;
+ if (req.body.last_name !== undefined) fields.last_name = req.body.last_name;
+ if (req.body.bio !== undefined) fields.bio = req.body.bio;
+ if (req.body.city !== undefined) fields.city = req.body.city;
+ if (req.body.country !== undefined) fields.country = req.body.country;
const data = await usersService.updateMe(req.user.sub, fields);
return success(res, data, 'Profile updated successfully.');
};
-
exports.updateMyAccount = async (req, res) => {
const fields = {};
if (req.body.date_of_birth !== undefined) fields.date_of_birth = req.body.date_of_birth;
- if (req.body.gender !== undefined) fields.gender = req.body.gender;
+ if (req.body.gender !== undefined) fields.gender = req.body.gender;
const data = await usersService.updateMyAccount(req.user.sub, fields);
return success(res, data, 'Account updated successfully.');
};
-
exports.switchRole = async (req, res) => {
const userId = req.user.sub;
- const {role} = req.body;
+ const { role } = req.body;
const data = await usersService.switchRole(userId, role);
-
+
return success(res, data, 'Role switched successfully.');
};
-exports.deleteMyAvatar= async(req,res) => {
- const data = await usersService.deleteMyAvatar(req.user.sub);
- return success(res, data, 'Your profile picture deleted successfully.');
+exports.deleteMyAvatar = async (req, res) => {
+ const data = await usersService.deleteMyAvatar(req.user.sub);
+ return success(res, data, 'Your profile picture deleted successfully.');
};
exports.uploadMyAvatar = async (req, res) => {
@@ -71,11 +70,11 @@ exports.uploadMyAvatar = async (req, res) => {
exports.uploadMyCoverPhoto = async (req, res) => {
if (!req.file) {
throw new AppError('No image file provided.', 400, 'VALIDATION_FAILED');
- }
+ }
const data = await usersService.uploadMyCoverPhoto(req.user.sub, req.file);
return success(res, data, 'Cover photo uploaded successfully.');
};
-exports.deleteMyCoverPhoto = async(req,res) => {
+exports.deleteMyCoverPhoto = async (req, res) => {
const data = await usersService.deleteMyCoverPhoto(req.user.sub);
return success(res, data, 'Your cover photo deleted successfully.');
};
@@ -116,8 +115,10 @@ exports.updateMyContentSettings = async (req, res) => {
if (req.body.rss_category !== undefined) fields.rss_category = req.body.rss_category;
if (req.body.rss_explicit !== undefined) fields.rss_explicit = req.body.rss_explicit;
if (req.body.rss_show_email !== undefined) fields.rss_show_email = req.body.rss_show_email;
- if (req.body.default_include_in_rss !== undefined) fields.default_include_in_rss = req.body.default_include_in_rss;
- if (req.body.default_license_type !== undefined) fields.default_license_type = req.body.default_license_type;
+ if (req.body.default_include_in_rss !== undefined)
+ fields.default_include_in_rss = req.body.default_include_in_rss;
+ if (req.body.default_license_type !== undefined)
+ fields.default_license_type = req.body.default_license_type;
const data = await usersService.updateMyContentSettings(req.user.sub, fields);
return success(res, data, 'Content settings updated successfully.');
@@ -130,13 +131,15 @@ exports.getMyPrivacySettings = async (req, res) => {
exports.updateMyPrivacySettings = async (req, res) => {
const fields = {};
- if (req.body.receive_messages_from_anyone !== undefined) fields.receive_messages_from_anyone = req.body.receive_messages_from_anyone;
- if (req.body.show_activities_in_discovery !== undefined) fields.show_activities_in_discovery = req.body.show_activities_in_discovery;
+ if (req.body.receive_messages_from_anyone !== undefined)
+ fields.receive_messages_from_anyone = req.body.receive_messages_from_anyone;
+ if (req.body.show_activities_in_discovery !== undefined)
+ fields.show_activities_in_discovery = req.body.show_activities_in_discovery;
if (req.body.show_as_top_fan !== undefined) fields.show_as_top_fan = req.body.show_as_top_fan;
- if (req.body.show_top_fans_on_tracks !== undefined) fields.show_top_fans_on_tracks = req.body.show_top_fans_on_tracks;
+ if (req.body.show_top_fans_on_tracks !== undefined)
+ fields.show_top_fans_on_tracks = req.body.show_top_fans_on_tracks;
const data = await usersService.updateMyPrivacySettings(req.user.sub, fields);
return success(res, data, 'Privacy settings updated successfully.');
-
};
exports.getMyGenres = async (req, res) => {
@@ -154,7 +157,7 @@ exports.replaceMyGenres = async (req, res) => {
}
const data = await usersService.replaceMyGenres(req.user.sub, genres);
return success(res, data, 'Favorite genres updated successfully.');
-};
+};
exports.completeOnboarding = async (req, res) => {
const { display_name, gender, date_of_birth, bio, city, country } = req.body;
@@ -164,7 +167,7 @@ exports.completeOnboarding = async (req, res) => {
date_of_birth,
bio,
city,
- country
+ country,
});
return success(res, data, 'Profile onboarding completed successfully.');
};
diff --git a/src/middleware/validate-register.js b/src/middleware/validate-register.js
index 41cf6bc..312c16e 100644
--- a/src/middleware/validate-register.js
+++ b/src/middleware/validate-register.js
@@ -13,14 +13,25 @@ const {
} = require('../utils/validators');
const validateRegister = (req, res, next) => {
- const { email, password, display_name, gender, date_of_birth } = req.body;
+ const rawEmail = typeof req.body.email === 'string' ? req.body.email.trim() : req.body.email;
+ const email = rawEmail?.toLowerCase();
+ const password = req.body.password;
+ const display_name =
+ typeof req.body.display_name === 'string'
+ ? req.body.display_name.trim()
+ : req.body.display_name;
+ const gender = req.body.gender;
+ const date_of_birth = req.body.date_of_birth;
const details = [];
if (!email || !isValidEmail(email))
details.push({ field: 'email', issue: 'Must be a valid email address' });
if (!password || !isValidPassword(password))
- details.push({ field: 'password', issue: 'Min 8 characters, must include uppercase, lowercase, and a number' });
+ details.push({
+ field: 'password',
+ issue: 'Min 8 characters, must include uppercase, lowercase, and a number',
+ });
if (!display_name || !isValidDisplayName(display_name))
details.push({ field: 'display_name', issue: 'Must be between 1 and 50 characters' });
@@ -29,7 +40,10 @@ const validateRegister = (req, res, next) => {
details.push({ field: 'gender', issue: 'Must be male or female' });
if (!date_of_birth || !isValidDateOfBirth(date_of_birth))
- details.push({ field: 'date_of_birth', issue: 'Must be a valid date and user must be at least 13 years old' });
+ details.push({
+ field: 'date_of_birth',
+ issue: 'Must be a valid date and user must be at least 13 years old',
+ });
if (details.length > 0) {
return error(res, 'VALIDATION_FAILED', 'Validation failed', 400, details);
@@ -38,4 +52,4 @@ const validateRegister = (req, res, next) => {
next();
};
-module.exports = { validateRegister };
\ No newline at end of file
+module.exports = { validateRegister };
diff --git a/src/models/user.model.js b/src/models/user.model.js
index 7338885..9caa6b5 100644
--- a/src/models/user.model.js
+++ b/src/models/user.model.js
@@ -7,16 +7,17 @@ const db = require('../config/db');
// find user by email used for login and register (to check if email already exists)
exports.findByEmail = async (email) => {
- const { rows } = await db.query(`SELECT * FROM users WHERE email = $1 AND deleted_at IS NULL`, [
- email,
- ]);
+ const { rows } = await db.query(
+ `SELECT * FROM users WHERE LOWER(email) = LOWER($1) AND deleted_at IS NULL`,
+ [email]
+ );
return rows[0] || null;
};
// find user by username used for login (when identifier is a username)
exports.findByUsername = async (username) => {
const { rows } = await db.query(
- `SELECT * FROM users WHERE username = $1 AND deleted_at IS NULL`,
+ `SELECT * FROM users WHERE LOWER(username) = LOWER($1) AND deleted_at IS NULL`,
[username]
);
return rows[0] || null;
@@ -26,7 +27,7 @@ exports.findByUsername = async (username) => {
exports.findByEmailOrUsername = async (identifier) => {
const { rows } = await db.query(
`SELECT * FROM users
- WHERE (email = $1 OR username = $1)
+ WHERE (LOWER(email) = LOWER($1) OR LOWER(username) = LOWER($1))
AND deleted_at IS NULL`,
[identifier]
);
@@ -43,7 +44,7 @@ exports.findById = async (id) => {
exports.create = async ({ email, password_hashed, display_name, gender, date_of_birth }) => {
const { rows } = await db.query(
`INSERT INTO users (email, password_hashed, display_name, gender, date_of_birth)
- VALUES ($1, $2, $3, $4, $5)
+ VALUES (LOWER($1), $2, $3, $4, $5)
RETURNING id, email, display_name, gender, date_of_birth, role, is_verified, created_at`,
[email, password_hashed, display_name, gender, date_of_birth]
);
@@ -68,14 +69,6 @@ exports.updatePassword = async (userId, newPasswordHashed) => {
]);
};
-// update password (called during password reset)
-exports.updatePassword = async (userId, newPasswordHashed) => {
- await db.query(`UPDATE users SET password_hashed = $1, updated_at = now() WHERE id = $2`, [
- newPasswordHashed,
- userId,
- ]);
-};
-
// get user profile (called in GET /users/me)
exports.findFullById = async (id) => {
const { rows } = await db.query(
diff --git a/src/services/auth.service.js b/src/services/auth.service.js
index c3d8645..277cf11 100644
--- a/src/services/auth.service.js
+++ b/src/services/auth.service.js
@@ -47,11 +47,16 @@ exports.register = async ({
date_of_birth,
captcha_token,
}) => {
+ const normalizedEmail = email?.trim().toLowerCase();
+ if (!normalizedEmail) {
+ throw new AppError('Email is required', 400, 'VALIDATION_FAILED');
+ }
+ const displayNameTrimmed = display_name?.trim();
// Verify CAPTCHA i can't get a token to test with it now so imma comment it out for now :)
//await verifyCaptcha(captcha_token);
// Check duplicate email
- const existing = await userModel.findByEmail(email);
+ const existing = await userModel.findByEmail(normalizedEmail);
if (existing) {
throw new AppError('Email already registered', 409, 'AUTH_EMAIL_ALREADY_EXISTS');
}
@@ -61,9 +66,9 @@ exports.register = async ({
// Create user
const user = await userModel.create({
- email,
+ email: normalizedEmail,
password_hashed,
- display_name,
+ display_name: displayNameTrimmed,
gender,
date_of_birth,
});
@@ -132,7 +137,10 @@ const createAndStoreRefreshToken = async (userId) => {
};
exports.login = async ({ identifier, password }) => {
- const user = await userModel.findByEmailOrUsername(identifier);
+ const normalizedIdentifier = identifier?.trim();
+ const identifierLower = normalizedIdentifier?.toLowerCase();
+
+ const user = await userModel.findByEmailOrUsername(identifierLower);
if (!user) {
throw new AppError('Invalid email or password', 401, 'AUTH_INVALID_CREDENTIALS');
@@ -252,7 +260,10 @@ exports.logout = async ({ refresh_token }) => {
// Forgot Password and Reset Password
// ============================================================
exports.requestPasswordReset = async ({ email }) => {
- const user = await userModel.findByEmail(email);
+ const normalizedEmail = email?.trim().toLowerCase();
+ if (!normalizedEmail) return;
+
+ const user = await userModel.findByEmail(normalizedEmail);
if (!user) {
return;
}
diff --git a/src/services/users.service.js b/src/services/users.service.js
index 77580c2..208d8fd 100644
--- a/src/services/users.service.js
+++ b/src/services/users.service.js
@@ -40,7 +40,12 @@ exports.getUserById = async (targetId, requesterId) => {
exports.updateMe = async (userId, fields) => {
if (fields.username) {
- const existing = await userModel.findByUsername(fields.username);
+ const usernameNormalized = fields.username.trim().toLowerCase();
+ if (!usernameNormalized) {
+ throw new AppError('Username cannot be empty.', 400, 'VALIDATION_FAILED');
+ }
+ fields.username = usernameNormalized;
+ const existing = await userModel.findByUsername(usernameNormalized);
if (existing && existing.id !== userId) {
throw new AppError('Username already taken.', 409, 'RESOURCE_ALREADY_EXISTS');
}
@@ -121,14 +126,14 @@ exports.switchRole = async (userId, role) => {
exports.deleteMyAvatar = async (userId) => {
const user = await userModel.findById(userId);
- if(!user){
+ if (!user) {
throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
}
if (!user.profile_picture) {
- throw new AppError('No avatar to delete', 404, 'RESOURCE_NOT_FOUND');
+ throw new AppError('No avatar to delete', 404, 'RESOURCE_NOT_FOUND');
}
return await userModel.deleteAvatar(userId);
-}
+};
exports.uploadMyAvatar = async (userId, file) => {
const user = await userModel.findById(userId);
if (!user) {
@@ -140,7 +145,7 @@ exports.uploadMyAvatar = async (userId, file) => {
};
exports.uploadMyCoverPhoto = async (userId, file) => {
- const user = await userModel.findById(userId);
+ const user = await userModel.findById(userId);
if (!user) {
throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
}
@@ -154,18 +159,22 @@ exports.deleteMyCoverPhoto = async (userId) => {
throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
}
if (!user.cover_photo) {
- throw new AppError('No cover photo to delete', 404, 'RESOURCE_NOT_FOUND');
+ throw new AppError('No cover photo to delete', 404, 'RESOURCE_NOT_FOUND');
}
return await userModel.deleteCoverPhoto(userId);
};
-exports.getMyWebProfiles = async (userId) => {
+exports.getMyWebProfile = async (userId) => {
return await userModel.findWebProfilesByUserId(userId);
};
exports.addWebProfile = async (userId, platform, url) => {
const existing = await userModel.findWebProfileByPlatform(userId, platform);
if (existing) {
- throw new AppError('A profile for this platform already exists.', 409, 'RESOURCE_ALREADY_EXISTS');
+ throw new AppError(
+ 'A profile for this platform already exists.',
+ 409,
+ 'RESOURCE_ALREADY_EXISTS'
+ );
}
return await userModel.createWebProfile(userId, platform, url);
};
@@ -187,7 +196,11 @@ exports.updatePrivacy = async (userId, isPrivate) => {
throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
}
if (user.is_private === isPrivate) {
- throw new AppError(`Profile is already ${isPrivate ? 'private' : 'public'}.`, 400, 'VALIDATION_FAILED');
+ throw new AppError(
+ `Profile is already ${isPrivate ? 'private' : 'public'}.`,
+ 400,
+ 'VALIDATION_FAILED'
+ );
}
const updated = await userModel.updatePrivacy(userId, isPrivate);
return updated;
@@ -198,10 +211,10 @@ exports.getMyContentSettings = async (userId) => {
};
exports.updateMyContentSettings = async (userId, settings) => {
- const user = await userModel.findById(userId);
+ const user = await userModel.findById(userId);
if (!user) {
throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
- }
+ }
const updated = await userModel.updateContentSettings(userId, settings);
return updated;
};
@@ -210,10 +223,10 @@ exports.getMyPrivacySettings = async (userId) => {
};
exports.updateMyPrivacySettings = async (userId, settings) => {
- const user = await userModel.findById(userId);
+ const user = await userModel.findById(userId);
if (!user) {
throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
- }
+ }
const updated = await userModel.updatePrivacySettings(userId, settings);
return updated;
};
@@ -226,7 +239,7 @@ exports.replaceMyGenres = async (userId, genreIds) => {
const user = await userModel.findById(userId);
if (!user) {
throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
- }
+ }
const updated = await userModel.replaceGenres(userId, genreIds);
return updated;
};
@@ -237,11 +250,12 @@ exports.completeOnboarding = async (userId, fields) => {
throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
}
if (user.display_name && user.gender && user.date_of_birth) {
- throw new AppError('Profile onboarding has already been completed.', 409, 'ONBOARDING_ALREADY_COMPLETED');
+ throw new AppError(
+ 'Profile onboarding has already been completed.',
+ 409,
+ 'ONBOARDING_ALREADY_COMPLETED'
+ );
}
const updated = await userModel.completeOnboarding(userId, fields);
return updated;
};
-
-
-
From a2d583e94afbaea3be9d4e56ba4bff6c2ce78b1f Mon Sep 17 00:00:00 2001
From: Omar Hamza
Date: Tue, 17 Mar 2026 23:31:08 +0200
Subject: [PATCH 067/535] fix(users): correct GET /users/me profile flow and
route alignment
- Fix GET /users/me to correctly return the authenticated user's full profile
- Align users routes and controllers to ensure "me" endpoints resolve to the proper handlers
- Correct controller/service wiring for profile retrieval
- Ensure all auth-protected "me" routes consistently use the authenticated user context
- Update related handlers to maintain consistent behavior across user self-service endpoints
---
src/controllers/users.controller.js | 2 +-
src/routes/users.routes.js | 39 ++++++++++++++++++++++-------
2 files changed, 31 insertions(+), 10 deletions(-)
diff --git a/src/controllers/users.controller.js b/src/controllers/users.controller.js
index 7465a8e..5a29a1e 100644
--- a/src/controllers/users.controller.js
+++ b/src/controllers/users.controller.js
@@ -80,7 +80,7 @@ exports.deleteMyCoverPhoto = async (req, res) => {
};
exports.getMyWebProfile = async (req, res) => {
const data = await usersService.getMyWebProfile(req.user.sub);
- return success(res, data, 'Web profile returned successfully.');
+ return success(res, data, 'Web profiles returned successfully.');
};
exports.addWebProfile = async (req, res) => {
const { platform, url } = req.body;
diff --git a/src/routes/users.routes.js b/src/routes/users.routes.js
index 39c6698..89a91c5 100644
--- a/src/routes/users.routes.js
+++ b/src/routes/users.routes.js
@@ -10,27 +10,48 @@ const { authenticate, optionalAuthenticate } = require('../middleware/auth');
const asyncHandler = require('../utils/async-handler');
const { uploadImage } = require('../middleware/multer');
-
-router.get('/me', authenticate ,asyncHandler(controller.getMe));
+router.get('/me', authenticate, asyncHandler(controller.getMe));
router.patch('/me', authenticate, asyncHandler(controller.updateMe));
router.patch('/me/account', authenticate, asyncHandler(controller.updateMyAccount));
router.patch('/me/role', authenticate, asyncHandler(controller.switchRole));
router.get('/:user_id', optionalAuthenticate, asyncHandler(controller.getUserById));
router.delete('/me/avatar', authenticate, asyncHandler(controller.deleteMyAvatar));
-router.post('/me/avatar', authenticate, uploadImage.single('avatar'), asyncHandler(controller.uploadMyAvatar));
-router.post('/me/cover', authenticate, uploadImage.single('cover'), asyncHandler(controller.uploadMyCoverPhoto));
+router.post(
+ '/me/avatar',
+ authenticate,
+ uploadImage.single('avatar'),
+ asyncHandler(controller.uploadMyAvatar)
+);
+router.post(
+ '/me/cover',
+ authenticate,
+ uploadImage.single('cover'),
+ asyncHandler(controller.uploadMyCoverPhoto)
+);
router.delete('/me/cover', authenticate, asyncHandler(controller.deleteMyCoverPhoto));
-router.get('/me/web-profile', authenticate, asyncHandler(controller.getMyWebProfile));
+router.get('/me/web-profiles', authenticate, asyncHandler(controller.getMyWebProfile));
router.post('/me/web-profiles', authenticate, asyncHandler(controller.addWebProfile));
-router.delete('/me/web-profiles/:profile_id', authenticate, asyncHandler(controller.deleteWebProfile));
+router.delete(
+ '/me/web-profiles/:profile_id',
+ authenticate,
+ asyncHandler(controller.deleteWebProfile)
+);
router.patch('/me/privacy', authenticate, asyncHandler(controller.updatePrivacy));
router.get('/me/content-settings', authenticate, asyncHandler(controller.getMyContentSettings));
-router.patch('/me/content-settings', authenticate, asyncHandler(controller.updateMyContentSettings));
+router.patch(
+ '/me/content-settings',
+ authenticate,
+ asyncHandler(controller.updateMyContentSettings)
+);
router.get('/me/privacy-settings', authenticate, asyncHandler(controller.getMyPrivacySettings));
-router.patch('/me/privacy-settings', authenticate, asyncHandler(controller.updateMyPrivacySettings));
+router.patch(
+ '/me/privacy-settings',
+ authenticate,
+ asyncHandler(controller.updateMyPrivacySettings)
+);
router.get('/me/genres', authenticate, asyncHandler(controller.getMyGenres));
router.put('/me/genres', authenticate, asyncHandler(controller.replaceMyGenres));
router.patch('/me/onboarding', authenticate, asyncHandler(controller.completeOnboarding));
-module.exports = router;
\ No newline at end of file
+module.exports = router;
From c69c1f5714da2d7e5d1044e3cb1ffced0aaf7dae Mon Sep 17 00:00:00 2001
From: Omar Hamza
Date: Wed, 18 Mar 2026 05:09:53 +0200
Subject: [PATCH 068/535] fix(users,genres): handle transaction client
correctly and harden update validations
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
fix replaceGenres to use db.connect() for proper transaction client acquisition, preventing crashes during genre updates
refactor completeOnboarding to build update fields conditionally and avoid sending undefined values to the service
add “nothing to update” validation for content and privacy settings to prevent no-op requests and align with existing update handlers
---
src/controllers/users.controller.js | 18 +++++++++---------
src/middleware/rate-limiter.js | 8 +++++++-
src/models/user.model.js | 2 +-
src/routes/users.routes.js | 5 +++--
src/services/users.service.js | 6 ++++++
5 files changed, 26 insertions(+), 13 deletions(-)
diff --git a/src/controllers/users.controller.js b/src/controllers/users.controller.js
index 5a29a1e..db05085 100644
--- a/src/controllers/users.controller.js
+++ b/src/controllers/users.controller.js
@@ -160,14 +160,14 @@ exports.replaceMyGenres = async (req, res) => {
};
exports.completeOnboarding = async (req, res) => {
- const { display_name, gender, date_of_birth, bio, city, country } = req.body;
- const data = await usersService.completeOnboarding(req.user.sub, {
- display_name,
- gender,
- date_of_birth,
- bio,
- city,
- country,
- });
+ const fields = {};
+ if (req.body.display_name !== undefined) fields.display_name = req.body.display_name;
+ if (req.body.gender !== undefined) fields.gender = req.body.gender;
+ if (req.body.date_of_birth !== undefined) fields.date_of_birth = req.body.date_of_birth;
+ if (req.body.bio !== undefined) fields.bio = req.body.bio;
+ if (req.body.city !== undefined) fields.city = req.body.city;
+ if (req.body.country !== undefined) fields.country = req.body.country;
+
+ const data = await usersService.completeOnboarding(req.user.sub, fields);
return success(res, data, 'Profile onboarding completed successfully.');
};
diff --git a/src/middleware/rate-limiter.js b/src/middleware/rate-limiter.js
index c8617fb..0c3a8d9 100644
--- a/src/middleware/rate-limiter.js
+++ b/src/middleware/rate-limiter.js
@@ -22,4 +22,10 @@ const uploadLimiter = rateLimit({
message: { success: false, message: 'Upload limit reached, please try again later.' },
});
-module.exports = { generalLimiter, authLimiter, uploadLimiter };
+const refreshLimiter = rateLimit({
+ windowMs: 15 * 60 * 1000,
+ max: process.env.NODE_ENV === 'production' ? 30 : 200,
+ message: { success: false, message: 'Too many refresh attempts.' },
+});
+
+module.exports = { generalLimiter, authLimiter, refreshLimiter, uploadLimiter };
\ No newline at end of file
diff --git a/src/models/user.model.js b/src/models/user.model.js
index 9caa6b5..8f5f194 100644
--- a/src/models/user.model.js
+++ b/src/models/user.model.js
@@ -397,7 +397,7 @@ exports.findGenresByUserId = async (userId) => {
};
exports.replaceGenres = async (userId, genreIds) => {
- const client = await db.getClient();
+ const client = await db.connect();
try {
await client.query('BEGIN');
await client.query(`DELETE FROM user_favorite_genres WHERE user_id = $1`, [userId]);
diff --git a/src/routes/users.routes.js b/src/routes/users.routes.js
index 89a91c5..2d83d9c 100644
--- a/src/routes/users.routes.js
+++ b/src/routes/users.routes.js
@@ -14,8 +14,6 @@ router.get('/me', authenticate, asyncHandler(controller.getMe));
router.patch('/me', authenticate, asyncHandler(controller.updateMe));
router.patch('/me/account', authenticate, asyncHandler(controller.updateMyAccount));
router.patch('/me/role', authenticate, asyncHandler(controller.switchRole));
-
-router.get('/:user_id', optionalAuthenticate, asyncHandler(controller.getUserById));
router.delete('/me/avatar', authenticate, asyncHandler(controller.deleteMyAvatar));
router.post(
'/me/avatar',
@@ -54,4 +52,7 @@ router.get('/me/genres', authenticate, asyncHandler(controller.getMyGenres));
router.put('/me/genres', authenticate, asyncHandler(controller.replaceMyGenres));
router.patch('/me/onboarding', authenticate, asyncHandler(controller.completeOnboarding));
+
+router.get('/:user_id', optionalAuthenticate, asyncHandler(controller.getUserById));
+
module.exports = router;
diff --git a/src/services/users.service.js b/src/services/users.service.js
index 208d8fd..3be0557 100644
--- a/src/services/users.service.js
+++ b/src/services/users.service.js
@@ -216,6 +216,9 @@ exports.updateMyContentSettings = async (userId, settings) => {
throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
}
const updated = await userModel.updateContentSettings(userId, settings);
+ if (!updated) {
+ throw new AppError('Nothing to update', 400, 'VALIDATION_FAILED');
+ }
return updated;
};
exports.getMyPrivacySettings = async (userId) => {
@@ -228,6 +231,9 @@ exports.updateMyPrivacySettings = async (userId, settings) => {
throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
}
const updated = await userModel.updatePrivacySettings(userId, settings);
+ if (!updated) {
+ throw new AppError('Nothing to update', 400, 'VALIDATION_FAILED');
+ }
return updated;
};
From 22b3f5128d6a8301af77d93f59f8da7d37d83e13 Mon Sep 17 00:00:00 2001
From: Omar Hamza
Date: Wed, 18 Mar 2026 05:25:04 +0200
Subject: [PATCH 069/535] fix(auth): separate refresh rate limiter from auth
routes
- Remove global authLimiter applied to all auth routes
- Add dedicated refreshLimiter (30 req/15min prod, 200 dev)
- Apply per-route rate limiting so refresh calls don't
consume the strict authLimiter quota (5 req/15min prod)
---
src/routes/auth.routes.js | 24 +++++++++---------------
1 file changed, 9 insertions(+), 15 deletions(-)
diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js
index a2ccfab..0f337b5 100644
--- a/src/routes/auth.routes.js
+++ b/src/routes/auth.routes.js
@@ -1,13 +1,8 @@
-// ============================================================
-// routes/auth.routes.js
-// Owner : Omar Hamdy (BE-1)
-// Module 1 — Authentication
-// ============================================================
const express = require('express');
const router = express.Router();
const controller = require('../controllers/auth.controller');
const { authLimiter } = require('../middleware/rate-limiter');
-const {authenticate} = require('../middleware/auth');
+const { authenticate } = require('../middleware/auth');
const { validateRegister } = require('../middleware/validate-register');
const asyncHandler = require('../utils/async-handler');
@@ -15,21 +10,20 @@ const asyncHandler = require('../utils/async-handler');
router.use(authLimiter);
// POST /api/v1/auth/register
-router.post('/register', validateRegister, asyncHandler(controller.register));
-router.post('/verify-email', asyncHandler(controller.verifyEmail));
-router.post('/resend-verification', asyncHandler(controller.resendVerification));
-router.post('/login', asyncHandler(controller.login));
-router.post('/refresh', asyncHandler(controller.refresh));
-router.post('/logout', asyncHandler(controller.logout));
+router.post('/register', validateRegister, asyncHandler(controller.register));
+router.post('/verify-email', asyncHandler(controller.verifyEmail));
+router.post('/resend-verification', asyncHandler(controller.resendVerification));
+router.post('/login', asyncHandler(controller.login));
+router.post('/refresh', asyncHandler(controller.refresh));
+router.post('/logout', asyncHandler(controller.logout));
router.post('/forgot-password', asyncHandler(controller.requestPasswordReset));
router.post('/reset-password', asyncHandler(controller.resetPassword));
-router.post('/verify-email-change', asyncHandler(controller.verifyEmailChange));
+router.post('/verify-email-change', asyncHandler(controller.verifyEmailChange));
// Protected routes
-router.post('/change-email', authenticate, asyncHandler(controller.changeEmail));
+router.post('/change-email', authenticate, asyncHandler(controller.changeEmail));
router.post('/google', asyncHandler(controller.googleLogin));
// router.post('/resend-verification', asyncHandler(controller.resendVerification));
-
module.exports = router;
From 68288647d9aebea214a2ea21338b7e854106d9bb Mon Sep 17 00:00:00 2001
From: Omar Hamza
Date: Wed, 18 Mar 2026 22:57:28 +0200
Subject: [PATCH 070/535] fix(cors): allow multiple client origins for
cross-team development
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Replace single CLIENT_URL origin with comma-separated multi-origin support
- Parse CLIENT_URL env variable as comma-separated list of allowed origins
- Fixes CORS error blocking frontend running on localhost:5173 (Vite default)
- No code changes needed when adding new origins — update .env only
---
app.js | 15 ++++++++++++++-
1 file changed, 14 insertions(+), 1 deletion(-)
diff --git a/app.js b/app.js
index 0caaf97..8a8ae2d 100644
--- a/app.js
+++ b/app.js
@@ -27,7 +27,20 @@ const app = express();
// ── Global middleware ──────────────────────────────────────
app.use(helmet());
-app.use(cors({ origin: env.CLIENT_URL, credentials: true }));
+
+const allowedOrigins = env.CLIENT_URL.split(',').map(o => o.trim());
+
+app.use(cors({
+ origin: (origin, callback) => {
+ if (!origin || allowedOrigins.includes(origin)) {
+ callback(null, true);
+ } else {
+ callback(new Error('Not allowed by CORS'));
+ }
+ },
+ credentials: true
+}));
+
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(generalLimiter);
From f036067bf663e43599a06adb1f497a9332246fd4 Mon Sep 17 00:00:00 2001
From: Omar Hamza
Date: Thu, 19 Mar 2026 05:08:16 +0200
Subject: [PATCH 071/535] const { randomUUID } = require('crypto');
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add jti (JWT ID) claim using randomUUID() to refresh token payload
- Fixes 23505 unique constraint violation on refresh_tokens table
- Collision occurred when two tokens were signed for the same user
within the same second — identical payload produced identical JWT string
- randomUUID() guarantees uniqueness regardless of timing or concurrency
---
src/services/auth.service.js | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/src/services/auth.service.js b/src/services/auth.service.js
index 277cf11..f3687de 100644
--- a/src/services/auth.service.js
+++ b/src/services/auth.service.js
@@ -15,6 +15,7 @@ const {
const AppError = require('../utils/app-error');
const { signAccessToken, signRefreshToken, verifyToken } = require('../config/jwt');
const env = require('../config/env');
+const { randomUUID } = require('crypto');
@@ -123,8 +124,13 @@ const buildAuthResponse = ({ user, accessToken, refreshToken }) => ({
is_new_user: false,
});
+
const createAndStoreRefreshToken = async (userId) => {
- const refreshToken = signRefreshToken({ sub: userId });
+ const refreshToken = signRefreshToken({
+ sub: userId,
+ jti: randomUUID()
+ });
+
const refreshExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
await refreshTokenModel.create({
From 93a991e80d5110facce8606dea4e5499df7c7d6c Mon Sep 17 00:00:00 2001
From: Omar Hamza
Date: Thu, 19 Mar 2026 05:29:40 +0200
Subject: [PATCH 072/535] fix(auth): harden refresh token rotation against
concurrent load
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Problem:
Concurrent refresh requests were causing 23505 unique constraint
violations and connection pile-ups under stress test conditions.
Changes:
- Narrow findValid SELECT to only required columns (id, user_id,
expires_at) to reduce I/O on high-frequency token checks
- Replace separate revoke + insert with atomic rotateToken transaction
using FOR UPDATE SKIP LOCKED — concurrent requests that find the
row already locked skip immediately and return 401 instead of
queuing, preventing connection pool exhaustion under load
- Generate new refresh token (with jti: randomUUID()) before entering
the transaction so the INSERT is ready the moment the lock is acquired
- Fail fast with AUTH_REFRESH_TOKEN_INVALID when rotation returns null,
covering both already-revoked and concurrently-locked token cases
- Leave logout, revokeAllForUser, and all other token paths untouched —
changes are isolated to the refresh rotation flow only
---
src/models/refresh-token.model.js | 71 +++++++++++++++++++++++++------
src/services/auth.service.js | 21 ++++++++-
2 files changed, 76 insertions(+), 16 deletions(-)
diff --git a/src/models/refresh-token.model.js b/src/models/refresh-token.model.js
index 7e43232..2590374 100644
--- a/src/models/refresh-token.model.js
+++ b/src/models/refresh-token.model.js
@@ -1,37 +1,35 @@
// ============================================================
-// models/refresh-token.model.js — PostgreSQL queries for Refresh_Tokens
-// Entity attributes: Token_id, User_Id, Refresh_token, Expires_at, Revoked
-// All SQL lives HERE — no SQL outside models/
+// models/refresh-token.model.js
// ============================================================
const db = require('../config/db');
-
-
// Store a new refresh token in the database
exports.create = async ({ user_id, refresh_token, expires_at }) => {
const { rows } = await db.query(
`INSERT INTO refresh_tokens (user_id, refresh_token, expires_at)
VALUES ($1, $2, $3)
+ ON CONFLICT (refresh_token) DO NOTHING
RETURNING id, user_id, refresh_token, expires_at, created_at`,
[user_id, refresh_token, expires_at]
);
return rows[0];
};
-
-// Find a valid refresh token must pass ALL conditions Used in: refresh and logout endpoints
+// Find a valid refresh token — narrow select to reduce I/O under load
exports.findValid = async (token) => {
const { rows } = await db.query(
- `SELECT * FROM refresh_tokens
- WHERE refresh_token = $1
- AND revoked = false
- AND expires_at > now()`,
+ `SELECT id, user_id, expires_at
+ FROM refresh_tokens
+ WHERE refresh_token = $1
+ AND revoked = false
+ AND expires_at > now()
+ LIMIT 1`,
[token]
);
return rows[0] || null;
};
-// Revoke a single refresh token by its token string Used in: logout endpoint (revoke current token)
+// Revoke a single refresh token
exports.revoke = async (token) => {
await db.query(
`UPDATE refresh_tokens
@@ -41,7 +39,7 @@ exports.revoke = async (token) => {
);
};
-// Revoke ALL refresh tokens for a user Used in: reset-password (logout all devices), account suspension (admin action)
+// Revoke ALL refresh tokens for a user
exports.revokeAllForUser = async (userId) => {
await db.query(
`UPDATE refresh_tokens
@@ -50,4 +48,49 @@ exports.revokeAllForUser = async (userId) => {
AND revoked = false`,
[userId]
);
-};
\ No newline at end of file
+};
+
+// Atomic: revoke old token + insert new token in one transaction
+// Uses SKIP LOCKED to avoid blocking when a token is being rotated concurrently
+exports.rotateToken = async ({ oldToken, userId, newToken, expiresAt }) => {
+ const client = await db.connect();
+ try {
+ await client.query('BEGIN');
+
+ // Lock the token row if it is still valid; skip if another process already locked it
+ const { rows } = await client.query(
+ `SELECT id
+ FROM refresh_tokens
+ WHERE refresh_token = $1
+ AND revoked = false
+ FOR UPDATE SKIP LOCKED`,
+ [oldToken]
+ );
+
+ if (rows.length === 0) {
+ await client.query('ROLLBACK');
+ return null;
+ }
+
+ await client.query(
+ `UPDATE refresh_tokens
+ SET revoked = true
+ WHERE id = $1`,
+ [rows[0].id]
+ );
+
+ await client.query(
+ `INSERT INTO refresh_tokens (user_id, refresh_token, expires_at)
+ VALUES ($1, $2, $3)`,
+ [userId, newToken, expiresAt]
+ );
+
+ await client.query('COMMIT');
+ return newToken;
+ } catch (err) {
+ await client.query('ROLLBACK');
+ throw err;
+ } finally {
+ client.release();
+ }
+};
diff --git a/src/services/auth.service.js b/src/services/auth.service.js
index f3687de..3754cc8 100644
--- a/src/services/auth.service.js
+++ b/src/services/auth.service.js
@@ -216,10 +216,27 @@ exports.refresh = async ({ refresh_token }) => {
throw new AppError('Account suspended by admin', 403, 'AUTH_ACCOUNT_SUSPENDED');
}
- await refreshTokenModel.revoke(refresh_token);
+ // Generate new token before transaction
+ const newRefreshToken = signRefreshToken({ sub: user.id, jti: randomUUID() });
+ const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
+
+ // Atomic revoke old + insert new — prevents concurrent request collision
+ const result = await refreshTokenModel.rotateToken({
+ oldToken: refresh_token,
+ userId: user.id,
+ newToken: newRefreshToken,
+ expiresAt,
+ });
+
+ if (!result) {
+ throw new AppError(
+ 'Refresh token already used',
+ 401,
+ 'AUTH_REFRESH_TOKEN_INVALID'
+ );
+ }
const accessToken = signAccessToken({ sub: user.id, role: user.role });
- const newRefreshToken = await createAndStoreRefreshToken(user.id);
return {
access_token: accessToken,
From 0242512818b2358927fa3137c582bdf5f0311011 Mon Sep 17 00:00:00 2001
From: Omar Hamza
Date: Thu, 19 Mar 2026 07:03:41 +0200
Subject: [PATCH 073/535] feat(auth): improve email links and harden refresh
token rotation
add APP_URL env and update email templates to use it for verification, reset, and email change links
enhance refresh token rotation with conflict-safe insert (ON CONFLICT DO NOTHING)
add logging for locked-row count during rotation
rollback transaction when insert fails to prevent duplicate key errors
improves stability under concurrent load and eliminates 23505 errors during stress testing
---
src/config/env.js | 1 +
src/models/refresh-token.model.js | 13 +++++++++++--
src/utils/mailer.js | 8 ++++----
3 files changed, 16 insertions(+), 6 deletions(-)
diff --git a/src/config/env.js b/src/config/env.js
index 6fe3767..3bedea8 100644
--- a/src/config/env.js
+++ b/src/config/env.js
@@ -32,6 +32,7 @@ module.exports = {
BASE_URL: process.env.BASE_URL || 'http://localhost:8080',
CLIENT_URL: process.env.CLIENT_URL || 'http://localhost:3000',
+ APP_URL: process.env.APP_URL || 'http://localhost:5173',
MAX_FILE_SIZE_AUDIO: 100 * 1024 * 1024, // 100 MB
MAX_FILE_SIZE_IMAGE: 5 * 1024 * 1024, // 5 MB
diff --git a/src/models/refresh-token.model.js b/src/models/refresh-token.model.js
index 2590374..1e32377 100644
--- a/src/models/refresh-token.model.js
+++ b/src/models/refresh-token.model.js
@@ -67,6 +67,8 @@ exports.rotateToken = async ({ oldToken, userId, newToken, expiresAt }) => {
[oldToken]
);
+ console.log('[rotateToken] rows found:', rows.length);
+
if (rows.length === 0) {
await client.query('ROLLBACK');
return null;
@@ -79,12 +81,19 @@ exports.rotateToken = async ({ oldToken, userId, newToken, expiresAt }) => {
[rows[0].id]
);
- await client.query(
+ const inserted = await client.query(
`INSERT INTO refresh_tokens (user_id, refresh_token, expires_at)
- VALUES ($1, $2, $3)`,
+ VALUES ($1, $2, $3)
+ ON CONFLICT (refresh_token) DO NOTHING
+ RETURNING id`,
[userId, newToken, expiresAt]
);
+ if (inserted.rows.length === 0) {
+ await client.query('ROLLBACK');
+ return null;
+ }
+
await client.query('COMMIT');
return newToken;
} catch (err) {
diff --git a/src/utils/mailer.js b/src/utils/mailer.js
index 81fdeaa..84ccd30 100644
--- a/src/utils/mailer.js
+++ b/src/utils/mailer.js
@@ -99,7 +99,7 @@ const ctaButton = (href, label) => `
// verify email
const sendVerificationEmail = async (to, { displayName, token }) => {
- const link = `${env.CLIENT_URL}/verify-email?token=${token}`;
+ const link = `${env.APP_URL}/verify-email?token=${token}`;
const bodyContent = `
@@ -132,7 +132,7 @@ const sendVerificationEmail = async (to, { displayName, token }) => {
// resend verification email
const sendResendVerificationEmail = async (to, { displayName, token }) => {
- const link = `${env.CLIENT_URL}/verify-email?token=${token}`;
+ const link = `${env.APP_URL}/verify-email?token=${token}`;
const bodyContent = `
@@ -164,7 +164,7 @@ const sendResendVerificationEmail = async (to, { displayName, token }) => {
// password reset email
const sendPasswordResetEmail = async (to, { displayName, token }) => {
- const link = `${env.CLIENT_URL}/reset-password?token=${token}`;
+ const link = `${env.APP_URL}/reset-password?token=${token}`;
const bodyContent = `
@@ -199,7 +199,7 @@ const sendPasswordResetEmail = async (to, { displayName, token }) => {
// email change verification
const sendEmailChangeEmail = async (to, { displayName, token }) => {
- const link = `${env.CLIENT_URL}/verify-email-change?token=${token}`;
+ const link = `${env.APP_URL}/verify-email-change?token=${token}`;
const bodyContent = `
From d592de808541421cb24dcbbfd34770e01d898576 Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Mon, 16 Mar 2026 00:53:17 +0200
Subject: [PATCH 074/535] added socket.io
- Added verifyToken import in server.js
- Added io.use() middleware in server.js- Reads token from socket.handshake.auth.token with Bearer prefix
- Rejects invalid/expired tokens
---
server.js | 15 ++++
src/sockets/messages.socket.js | 139 ++++++++++++++++++++++++++++++++-
2 files changed, 150 insertions(+), 4 deletions(-)
diff --git a/server.js b/server.js
index e7b5d18..8b4bfb7 100644
--- a/server.js
+++ b/server.js
@@ -5,6 +5,7 @@ const http = require('http');
const { Server } = require('socket.io');
const app = require('./app');
const env = require('./src/config/env');
+const { verifyToken } = require('./src/config/jwt');
const { registerNotificationHandlers } = require('./src/sockets/notifications.socket');
const { registerMessageHandlers } = require('./src/sockets/messages.socket');
@@ -14,6 +15,20 @@ const io = new Server(server, {
cors: { origin: env.CLIENT_URL, methods: ['GET', 'POST'], credentials: true },
});
+io.use((socket, next) => {
+ const authHeader = socket.handshake.auth?.token;
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
+ return next(new Error('Access token required'));
+ }
+ const token = authHeader.split(' ')[1];
+ try {
+ socket.user = verifyToken(token);
+ next();
+ } catch {
+ return next(new Error('Invalid or expired token'));
+ }
+});
+
io.on('connection', (socket) => {
console.log(`[Socket.IO] Client connected: ${socket.id}`);
diff --git a/src/sockets/messages.socket.js b/src/sockets/messages.socket.js
index a804a4d..d4f56c7 100644
--- a/src/sockets/messages.socket.js
+++ b/src/sockets/messages.socket.js
@@ -3,12 +3,143 @@
// Owner : Alyaa Mohamed (BE-4) — Module 9
// Real-time 1-to-1 messaging via Socket.IO
// Handles: message delivery, read-status, typing indicators
+//
+// ARCHITECTURE:
+// - HTTP endpoints handle DB persistence (source of truth)
+// - Socket events handle real-time UI updates only
+// - Client must call HTTP endpoint first, then emit socket event
+//
+// Room naming convention: conversation:
// ============================================================
+const db = require('../config/db');
+
+// ------------------------------------------------------------
+// Helper — validate UUID format
+// Only used where a DB call is made to prevent unhandled errors
+// ------------------------------------------------------------
+const isValidUUID = (val) =>
+ typeof val === 'string' &&
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(val);
+
+// ------------------------------------------------------------
+// Helper — verify user is a participant in a conversation
+// Prevents users from joining rooms they don't belong to
+// [DEPENDS: conversations table]
+// ------------------------------------------------------------
+const isParticipant = async (conversationId, userId) => {
+ const { rows } = await db.query(
+ `SELECT 1 FROM conversations
+ WHERE id = $1
+ AND (user_a_id = $2 OR user_b_id = $2)
+ AND NOT (user_a_id = $2 AND deleted_by_a = true)
+ AND NOT (user_b_id = $2 AND deleted_by_b = true)`,
+ [conversationId, userId]
+ );
+ return rows.length > 0;
+};
+
+// ------------------------------------------------------------
const registerMessageHandlers = (io, socket) => {
- // TODO: Implement real-time messaging event handlers
- // Example:
- // socket.on('message:send', async ({ conversationId, content }) => { ... });
+
+ // userId extracted from JWT-verified payload set by server.js middleware
+ const userId = socket.user?.sub;
+
+ // ----------------------------------------------------------
+ // Join a conversation room
+ // Client emits when user opens a conversation
+ // Security: UUID validated and participant check done here
+ // because this is a socket-only event with no HTTP equivalent
+ // ----------------------------------------------------------
+ socket.on('message:join', async ({ conversationId }) => {
+
+ if (!isValidUUID(conversationId) || !isValidUUID(userId)) {
+ return socket.emit('error', { message: 'Invalid conversationId.' });
+ }
+
+ try {
+ const allowed = await isParticipant(conversationId, userId);
+ if (!allowed) {
+ return socket.emit('error', { message: 'You are not a participant in this conversation.' });
+ }
+ } catch (err) {
+ console.error(`[Socket.IO] message:join DB error:`, err.message);
+ return socket.emit('error', { message: 'Something went wrong. Please try again.' });
+ }
+
+ const room = `conversation:${conversationId}`;
+ socket.join(room);
+ console.log(`[Socket.IO] ${socket.id} (user: ${userId}) joined ${room}`);
+ });
+
+ // ----------------------------------------------------------
+ // Leave a conversation room
+ // Client emits when user closes a conversation
+ // ----------------------------------------------------------
+ socket.on('message:leave', ({ conversationId }) => {
+ const room = `conversation:${conversationId}`;
+ socket.leave(room);
+ console.log(`[Socket.IO] ${socket.id} (user: ${userId}) left ${room}`);
+ });
+
+ // ----------------------------------------------------------
+ // New message delivered in real time
+ // Client emits AFTER POST /messages/new or
+ // POST /messages/conversations/:conversationId/messages succeeds
+ // Passes the full message object from the HTTP response
+ // ----------------------------------------------------------
+ socket.on('message:send', ({ conversationId, message }) => {
+ const room = `conversation:${conversationId}`;
+ socket.to(room).emit('message:received', { conversationId, message });
+ console.log(`[Socket.IO] message:send in ${room} by user: ${userId}`);
+ });
+
+ // ----------------------------------------------------------
+ // Message deleted in real time
+ // Client emits AFTER
+ // DELETE /messages/conversations/:conversationId/messages/:messageId succeeds
+ // ----------------------------------------------------------
+ socket.on('message:deleted', ({ conversationId, messageId }) => {
+ const room = `conversation:${conversationId}`;
+ socket.to(room).emit('message:removed', { conversationId, messageId });
+ console.log(`[Socket.IO] message:deleted in ${room} by user: ${userId}`);
+ });
+
+ // ----------------------------------------------------------
+ // Message read state updated in real time
+ // Client emits AFTER
+ // PATCH /messages/conversations/:conversationId/messages/:messageId/read succeeds
+ // ----------------------------------------------------------
+ socket.on('message:read', ({ conversationId, messageId, isRead, conversationUnreadCount }) => {
+ const room = `conversation:${conversationId}`;
+ socket.to(room).emit('message:read_updated', {
+ conversationId,
+ messageId,
+ isRead,
+ conversationUnreadCount,
+ });
+ console.log(`[Socket.IO] message:read in ${room} by user: ${userId}`);
+ });
+
+ // ----------------------------------------------------------
+ // Typing indicator
+ // Client emits while user is typing
+ // NOTE: Frontend should throttle this to once per ~1000ms
+ // ----------------------------------------------------------
+ socket.on('message:typing', ({ conversationId }) => {
+ const room = `conversation:${conversationId}`;
+ socket.to(room).emit('message:typing', { conversationId, userId });
+ });
+
+ // ----------------------------------------------------------
+ // Stop typing indicator
+ // Client emits when user stops typing or sends the message
+ // ----------------------------------------------------------
+ socket.on('message:stop_typing', ({ conversationId }) => {
+ const room = `conversation:${conversationId}`;
+ socket.to(room).emit('message:stop_typing', { conversationId, userId });
+ });
+
};
-module.exports = { registerMessageHandlers };
+module.exports = { registerMessageHandlers };
\ No newline at end of file
From 0ccab82e36ce54c56908ff15da275417d5ce258e Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Tue, 17 Mar 2026 02:52:40 +0200
Subject: [PATCH 075/535] Added unit tests for messaging module
tests\messages.controller.unit.test.js
tests\messages.service.unit.test.js
tests\messages.socket.unit.test.js
with
---
tests/messages.controller.unit.test.js | 202 ++++++++++++
tests/messages.service.unit.test.js | 437 +++++++++++++++++++++++++
tests/messages.socket.unit.test.js | 156 +++++++++
3 files changed, 795 insertions(+)
create mode 100644 tests/messages.controller.unit.test.js
create mode 100644 tests/messages.service.unit.test.js
create mode 100644 tests/messages.socket.unit.test.js
diff --git a/tests/messages.controller.unit.test.js b/tests/messages.controller.unit.test.js
new file mode 100644
index 0000000..0b4902c
--- /dev/null
+++ b/tests/messages.controller.unit.test.js
@@ -0,0 +1,202 @@
+const controller = require('../src/controllers/messages.controller');
+const messagesService = require('../src/services/messages.service');
+const api = require('../src/utils/api-response');
+
+jest.mock('../src/services/messages.service');
+jest.mock('../src/utils/api-response', () => ({
+ success: jest.fn(),
+ error: jest.fn(),
+}));
+
+const mkReq = ({ userId = 'u1', body = {}, params = {}, query = {} } = {}) => ({
+ user: { sub: userId },
+ body,
+ params,
+ query,
+});
+
+const mkRes = () => ({ status: jest.fn().mockReturnThis(), json: jest.fn() });
+
+beforeEach(() => jest.clearAllMocks());
+
+describe('messages.controller', () => {
+ describe('startConversation', () => {
+ it('returns validation error when recipient_id missing', async () => {
+ const req = mkReq({ body: { body: 'hi' } });
+ const res = mkRes();
+
+ await controller.startConversation(req, res);
+
+ expect(api.error).toHaveBeenCalledWith(
+ res,
+ 'VALIDATION_FAILED',
+ 'recipient_id is required.',
+ 400
+ );
+ expect(messagesService.startConversation).not.toHaveBeenCalled();
+ });
+
+ it('returns 201 for new conversation', async () => {
+ const req = mkReq({ body: { recipient_id: 'u2', body: 'hi' } });
+ const res = mkRes();
+ messagesService.startConversation.mockResolvedValue({
+ conversation: { id: 'c1' },
+ message: { id: 'm1' },
+ isNew: true,
+ });
+
+ await controller.startConversation(req, res);
+
+ expect(api.success).toHaveBeenCalledWith(
+ res,
+ { conversation: { id: 'c1' }, message: { id: 'm1' } },
+ 'Conversation created and first message sent.',
+ 201
+ );
+ });
+
+ it('returns 200 for existing conversation', async () => {
+ const req = mkReq({ body: { recipient_id: 'u2', body: 'hi' } });
+ const res = mkRes();
+ messagesService.startConversation.mockResolvedValue({
+ conversation: { id: 'c1' },
+ message: { id: 'm1' },
+ isNew: false,
+ });
+
+ await controller.startConversation(req, res);
+
+ expect(api.success).toHaveBeenCalledWith(
+ res,
+ { message: { id: 'm1' } },
+ 'Message sent.',
+ 200
+ );
+ });
+ });
+
+ describe('markMessageReadState', () => {
+ it('requires is_read', async () => {
+ const req = mkReq({ params: { conversationId: 'c1', messageId: 'm1' }, body: {} });
+ const res = mkRes();
+
+ await controller.markMessageReadState(req, res);
+
+ expect(api.error).toHaveBeenCalledWith(
+ res,
+ 'VALIDATION_FAILED',
+ 'is_read is required.',
+ 400
+ );
+ });
+
+ it('requires boolean is_read', async () => {
+ const req = mkReq({
+ params: { conversationId: 'c1', messageId: 'm1' },
+ body: { is_read: 'true' },
+ });
+ const res = mkRes();
+
+ await controller.markMessageReadState(req, res);
+
+ expect(api.error).toHaveBeenCalledWith(
+ res,
+ 'VALIDATION_FAILED',
+ 'is_read must be a boolean.',
+ 400
+ );
+ });
+
+ it('calls service and returns success', async () => {
+ const req = mkReq({
+ params: { conversationId: 'c1', messageId: 'm1' },
+ body: { is_read: true },
+ });
+ const res = mkRes();
+
+ messagesService.markMessageReadState.mockResolvedValue({
+ message_id: 'm1',
+ is_read: true,
+ conversation_unread_count: 0,
+ });
+
+ await controller.markMessageReadState(req, res);
+
+ expect(messagesService.markMessageReadState).toHaveBeenCalledWith({
+ conversationId: 'c1',
+ messageId: 'm1',
+ userId: 'u1',
+ isRead: true,
+ });
+
+ expect(api.success).toHaveBeenCalled();
+ });
+ });
+
+ it('listConversations returns success', async () => {
+ const req = mkReq({ query: { page: '2', limit: '8' } });
+ const res = mkRes();
+ messagesService.listConversations.mockResolvedValue({ items: [], pagination: {} });
+
+ await controller.listConversations(req, res);
+
+ expect(api.success).toHaveBeenCalled();
+ });
+
+ it('getConversation returns success', async () => {
+ const req = mkReq({ params: { conversationId: 'c1' }, query: { page: '1', limit: '20' } });
+ const res = mkRes();
+ messagesService.getConversation.mockResolvedValue({ conversation: {}, messages: [], pagination: {} });
+
+ await controller.getConversation(req, res);
+
+ expect(api.success).toHaveBeenCalled();
+ });
+
+ it('sendMessage returns success', async () => {
+ const req = mkReq({ params: { conversationId: 'c1' }, body: { body: 'hi' } });
+ const res = mkRes();
+ messagesService.sendMessage.mockResolvedValue({ id: 'm1' });
+
+ await controller.sendMessage(req, res);
+
+ expect(api.success).toHaveBeenCalledWith(res, { id: 'm1' }, 'Message sent.', 201);
+ });
+
+ it('getUnreadCount returns success', async () => {
+ const req = mkReq();
+ const res = mkRes();
+ messagesService.getUnreadCount.mockResolvedValue({ unread_count: 4 });
+
+ await controller.getUnreadCount(req, res);
+
+ expect(api.success).toHaveBeenCalled();
+ });
+
+ it('deleteMessage returns success', async () => {
+ const req = mkReq({ params: { conversationId: 'c1', messageId: 'm1' } });
+ const res = mkRes();
+
+ await controller.deleteMessage(req, res);
+
+ expect(messagesService.deleteMessage).toHaveBeenCalledWith({
+ conversationId: 'c1',
+ messageId: 'm1',
+ userId: 'u1',
+ });
+ expect(api.success).toHaveBeenCalledWith(res, null, 'Message deleted.');
+ });
+
+ it('deleteConversation returns success', async () => {
+ const req = mkReq({ params: { conversationId: 'c1' } });
+ const res = mkRes();
+
+ await controller.deleteConversation(req, res);
+
+ expect(messagesService.deleteConversation).toHaveBeenCalledWith({
+ conversationId: 'c1',
+ userId: 'u1',
+ });
+ expect(api.success).toHaveBeenCalledWith(res, null, 'Conversation deleted.');
+ });
+});
\ No newline at end of file
diff --git a/tests/messages.service.unit.test.js b/tests/messages.service.unit.test.js
new file mode 100644
index 0000000..a7ada94
--- /dev/null
+++ b/tests/messages.service.unit.test.js
@@ -0,0 +1,437 @@
+const service = require('../src/services/messages.service');
+const model = require('../src/models/message.model');
+const AppError = require('../src/utils/app-error');
+
+jest.mock('../src/models/message.model');
+
+beforeEach(() => jest.clearAllMocks());
+
+const baseConversation = {
+ id: 'c1',
+ user_a_id: 'u1',
+ user_b_id: 'u2',
+ deleted_by_a: false,
+ deleted_by_b: false,
+ created_at: '2026-01-01',
+ last_message_at: '2026-01-01',
+};
+
+describe('messages.service', () => {
+ describe('startConversation', () => {
+ it('rejects self message', async () => {
+ await expect(
+ service.startConversation({ senderId: 'u1', recipientId: 'u1', body: 'x' })
+ ).rejects.toBeInstanceOf(AppError);
+ });
+
+ it('rejects when recipient missing', async () => {
+ model.findActiveUserById.mockResolvedValue(null);
+ await expect(
+ service.startConversation({ senderId: 'u1', recipientId: 'u2', body: 'x' })
+ ).rejects.toMatchObject({ code: 'USER_NOT_FOUND', statusCode: 404 });
+ });
+
+ it('rejects blocked', async () => {
+ model.findActiveUserById.mockResolvedValue({ id: 'u2' });
+ model.isBlocked.mockResolvedValue(true);
+
+ await expect(
+ service.startConversation({ senderId: 'u1', recipientId: 'u2', body: 'x' })
+ ).rejects.toMatchObject({ code: 'MESSAGES_BLOCKED' });
+ });
+
+ it('rejects followers_only when recipient not following sender', async () => {
+ model.findActiveUserById.mockResolvedValue({ id: 'u2' });
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('followers_only');
+ model.isFollowing.mockResolvedValue(false);
+
+ await expect(
+ service.startConversation({ senderId: 'u1', recipientId: 'u2', body: 'x' })
+ ).rejects.toMatchObject({ code: 'MESSAGES_FOLLOWERS_ONLY' });
+ });
+
+ it('rejects empty payload', async () => {
+ model.findActiveUserById.mockResolvedValue({ id: 'u2' });
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+
+ await expect(
+ service.startConversation({ senderId: 'u1', recipientId: 'u2', body: ' ', resource: null })
+ ).rejects.toMatchObject({ code: 'MESSAGES_EMPTY' });
+ });
+
+ it('rejects body too long', async () => {
+ model.findActiveUserById.mockResolvedValue({ id: 'u2' });
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+
+ await expect(
+ service.startConversation({ senderId: 'u1', recipientId: 'u2', body: 'x'.repeat(2001) })
+ ).rejects.toMatchObject({ code: 'MESSAGES_BODY_TOO_LONG' });
+ });
+
+ it('rejects invalid embed type', async () => {
+ model.findActiveUserById.mockResolvedValue({ id: 'u2' });
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+
+ await expect(
+ service.startConversation({
+ senderId: 'u1',
+ recipientId: 'u2',
+ body: '',
+ resource: { type: 'album', id: 'a1' },
+ })
+ ).rejects.toMatchObject({ code: 'MESSAGES_INVALID_EMBED_TYPE' });
+ });
+
+ it('creates conversation when new', async () => {
+ model.findActiveUserById.mockResolvedValue({ id: 'u2' });
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+ model.findConversationByPair.mockResolvedValue(null);
+ model.createConversation.mockResolvedValue({ id: 'c1' });
+ model.createMessage.mockResolvedValue({ id: 'm1' });
+
+ const out = await service.startConversation({
+ senderId: 'u1',
+ recipientId: 'u2',
+ body: ' hi ',
+ });
+
+ expect(out.isNew).toBe(true);
+ expect(model.createConversation).toHaveBeenCalled();
+ expect(model.createMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ body: 'hi' })
+ );
+ });
+
+ it('uses existing conversation', async () => {
+ model.findActiveUserById.mockResolvedValue({ id: 'u2' });
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+ model.findConversationByPair.mockResolvedValue({ id: 'cExisting' });
+ model.createMessage.mockResolvedValue({ id: 'm1' });
+
+ const out = await service.startConversation({
+ senderId: 'u1',
+ recipientId: 'u2',
+ resource: { type: 'track', id: 't1' },
+ });
+
+ expect(out.isNew).toBe(false);
+ expect(model.createConversation).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('listConversations', () => {
+ it('applies pagination bounds and maps shape', async () => {
+ model.findConversationsByUserId.mockResolvedValue([
+ {
+ id: 'c1',
+ participant_id: 'u2',
+ participant_display_name: 'B',
+ participant_avatar: null,
+ participant_username: 'b',
+ last_message_id: 'm1',
+ last_message_sender_id: 'u1',
+ last_message_body: 'hello',
+ last_message_embed_type: null,
+ last_message_embed_id: null,
+ last_message_is_read: false,
+ last_message_created_at: '2026-01-01',
+ unread_count: 3,
+ created_at: '2026-01-01',
+ updated_at: '2026-01-02',
+ },
+ ]);
+ model.countConversationsByUserId.mockResolvedValue(1);
+
+ const out = await service.listConversations({ userId: 'u1', page: '0', limit: '999' });
+
+ expect(model.findConversationsByUserId).toHaveBeenCalledWith('u1', 8, 0);
+ expect(out.items[0].last_message.id).toBe('m1');
+ expect(out.pagination.total_pages).toBe(1);
+ });
+
+ it('maps null last_message', async () => {
+ model.findConversationsByUserId.mockResolvedValue([
+ {
+ id: 'c1',
+ participant_id: 'u2',
+ participant_display_name: 'B',
+ participant_avatar: null,
+ participant_username: 'b',
+ last_message_id: null,
+ unread_count: 0,
+ created_at: '2026-01-01',
+ updated_at: '2026-01-02',
+ },
+ ]);
+ model.countConversationsByUserId.mockResolvedValue(1);
+
+ const out = await service.listConversations({ userId: 'u1' });
+ expect(out.items[0].last_message).toBeNull();
+ });
+ });
+
+ describe('getConversation', () => {
+ it('404 when conversation not found', async () => {
+ model.findConversationById.mockResolvedValue(null);
+ await expect(service.getConversation({ conversationId: 'c1', userId: 'u1' }))
+ .rejects.toMatchObject({ code: 'CONVERSATION_NOT_FOUND' });
+ });
+
+ it('403 when not participant', async () => {
+ model.findConversationById.mockResolvedValue({ ...baseConversation, user_a_id: 'x', user_b_id: 'y' });
+ await expect(service.getConversation({ conversationId: 'c1', userId: 'u1' }))
+ .rejects.toMatchObject({ code: 'FORBIDDEN' });
+ });
+
+ it('404 when soft deleted by user', async () => {
+ model.findConversationById.mockResolvedValue({ ...baseConversation, deleted_by_a: true });
+ await expect(service.getConversation({ conversationId: 'c1', userId: 'u1' }))
+ .rejects.toMatchObject({ code: 'CONVERSATION_NOT_FOUND' });
+ });
+
+ it('returns conversation payload', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.findMessagesByConversationId.mockResolvedValue([{ id: 'm1' }]);
+ model.countMessagesByConversationId.mockResolvedValue(1);
+ model.findConversationPartner.mockResolvedValue({ id: 'u2' });
+ model.countUnreadMessages.mockResolvedValue(0);
+
+ const out = await service.getConversation({ conversationId: 'c1', userId: 'u1', page: '-1', limit: '999' });
+
+ expect(model.findMessagesByConversationId).toHaveBeenCalledWith('c1', 100, 0);
+ expect(out.pagination.total_pages).toBe(1);
+ });
+ });
+
+ describe('sendMessage', () => {
+ it('404 when conversation missing', async () => {
+ model.findConversationById.mockResolvedValue(null);
+ await expect(service.sendMessage({ conversationId: 'c1', senderId: 'u1', body: 'x' }))
+ .rejects.toMatchObject({ code: 'CONVERSATION_NOT_FOUND' });
+ });
+
+ it('403 when sender not participant', async () => {
+ model.findConversationById.mockResolvedValue({ ...baseConversation, user_a_id: 'x', user_b_id: 'y' });
+ await expect(service.sendMessage({ conversationId: 'c1', senderId: 'u1', body: 'x' }))
+ .rejects.toMatchObject({ code: 'FORBIDDEN' });
+ });
+
+ it('restores recipient conversation when recipient had deleted', async () => {
+ model.findConversationById.mockResolvedValue({ ...baseConversation, deleted_by_b: true });
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+ model.createMessage.mockResolvedValue({ id: 'm1' });
+
+ await service.sendMessage({ conversationId: 'c1', senderId: 'u1', body: 'x' });
+
+ expect(model.restoreConversationForUser).toHaveBeenCalledWith('c1', false);
+ });
+
+ it('blocks on blocked recipient', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.isBlocked.mockResolvedValue(true);
+
+ await expect(service.sendMessage({ conversationId: 'c1', senderId: 'u1', body: 'x' }))
+ .rejects.toMatchObject({ code: 'MESSAGES_BLOCKED' });
+ });
+
+ it('followers_only rejection', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('followers_only');
+ model.isFollowing.mockResolvedValue(false);
+
+ await expect(service.sendMessage({ conversationId: 'c1', senderId: 'u1', body: 'x' }))
+ .rejects.toMatchObject({ code: 'MESSAGES_FOLLOWERS_ONLY' });
+ });
+
+ it('empty payload rejection', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+
+ await expect(service.sendMessage({ conversationId: 'c1', senderId: 'u1', body: ' ' }))
+ .rejects.toMatchObject({ code: 'MESSAGES_EMPTY' });
+ });
+
+ it('body too long rejection', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+
+ await expect(service.sendMessage({
+ conversationId: 'c1', senderId: 'u1', body: 'x'.repeat(2001),
+ })).rejects.toMatchObject({ code: 'MESSAGES_BODY_TOO_LONG' });
+ });
+
+ it('invalid embed rejection', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+
+ await expect(service.sendMessage({
+ conversationId: 'c1', senderId: 'u1', body: '',
+ resource: { type: 'album', id: 'a1' },
+ })).rejects.toMatchObject({ code: 'MESSAGES_INVALID_EMBED_TYPE' });
+ });
+
+ it('inserts valid message', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+ model.createMessage.mockResolvedValue({ id: 'm1' });
+
+ const out = await service.sendMessage({
+ conversationId: 'c1',
+ senderId: 'u1',
+ body: ' hi ',
+ });
+
+ expect(out.id).toBe('m1');
+ expect(model.createMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ body: 'hi' })
+ );
+ });
+ });
+
+ describe('getUnreadCount', () => {
+ it('returns unread_count', async () => {
+ model.countTotalUnreadMessages.mockResolvedValue(9);
+ await expect(service.getUnreadCount({ userId: 'u1' })).resolves.toEqual({ unread_count: 9 });
+ });
+ });
+
+ describe('markMessageReadState', () => {
+ it('404 conversation missing', async () => {
+ model.findConversationById.mockResolvedValue(null);
+ await expect(service.markMessageReadState({
+ conversationId: 'c1', messageId: 'm1', userId: 'u1', isRead: true,
+ })).rejects.toMatchObject({ code: 'CONVERSATION_NOT_FOUND' });
+ });
+
+ it('403 non-participant', async () => {
+ model.findConversationById.mockResolvedValue({ ...baseConversation, user_a_id: 'x', user_b_id: 'y' });
+ await expect(service.markMessageReadState({
+ conversationId: 'c1', messageId: 'm1', userId: 'u1', isRead: true,
+ })).rejects.toMatchObject({ code: 'FORBIDDEN' });
+ });
+
+ it('404 message missing', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.findMessageById.mockResolvedValue(null);
+ await expect(service.markMessageReadState({
+ conversationId: 'c1', messageId: 'm1', userId: 'u1', isRead: true,
+ })).rejects.toMatchObject({ code: 'MESSAGE_NOT_FOUND' });
+ });
+
+ it('403 sender cannot mark own message', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.findMessageById.mockResolvedValue({ id: 'm1', sender_id: 'u1', is_read: false });
+ await expect(service.markMessageReadState({
+ conversationId: 'c1', messageId: 'm1', userId: 'u1', isRead: true,
+ })).rejects.toMatchObject({ code: 'FORBIDDEN' });
+ });
+
+ it('409 on same read state', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.findMessageById.mockResolvedValue({ id: 'm1', sender_id: 'u2', is_read: true });
+ await expect(service.markMessageReadState({
+ conversationId: 'c1', messageId: 'm1', userId: 'u1', isRead: true,
+ })).rejects.toMatchObject({ code: 'MESSAGES_READ_STATE_CONFLICT' });
+ });
+
+ it('updates read state', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.findMessageById.mockResolvedValue({ id: 'm1', sender_id: 'u2', is_read: false });
+ model.updateMessageReadState.mockResolvedValue({ id: 'm1' });
+ model.countUnreadMessages.mockResolvedValue(0);
+
+ await expect(service.markMessageReadState({
+ conversationId: 'c1', messageId: 'm1', userId: 'u1', isRead: true,
+ })).resolves.toEqual({
+ message_id: 'm1',
+ is_read: true,
+ conversation_unread_count: 0,
+ });
+ });
+ });
+
+ describe('deleteMessage', () => {
+ it('404 conversation missing', async () => {
+ model.findConversationById.mockResolvedValue(null);
+ await expect(service.deleteMessage({ conversationId: 'c1', messageId: 'm1', userId: 'u1' }))
+ .rejects.toMatchObject({ code: 'CONVERSATION_NOT_FOUND' });
+ });
+
+ it('403 non-participant', async () => {
+ model.findConversationById.mockResolvedValue({ ...baseConversation, user_a_id: 'x', user_b_id: 'y' });
+ await expect(service.deleteMessage({ conversationId: 'c1', messageId: 'm1', userId: 'u1' }))
+ .rejects.toMatchObject({ code: 'FORBIDDEN' });
+ });
+
+ it('404 message missing', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.findMessageById.mockResolvedValue(null);
+ await expect(service.deleteMessage({ conversationId: 'c1', messageId: 'm1', userId: 'u1' }))
+ .rejects.toMatchObject({ code: 'MESSAGE_NOT_FOUND' });
+ });
+
+ it('403 cannot delete others message', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.findMessageById.mockResolvedValue({ id: 'm1', sender_id: 'u2' });
+ await expect(service.deleteMessage({ conversationId: 'c1', messageId: 'm1', userId: 'u1' }))
+ .rejects.toMatchObject({ code: 'FORBIDDEN' });
+ });
+
+ it('deletes own message', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.findMessageById.mockResolvedValue({ id: 'm1', sender_id: 'u1' });
+ model.deleteMessageById.mockResolvedValue();
+
+ await service.deleteMessage({ conversationId: 'c1', messageId: 'm1', userId: 'u1' });
+ expect(model.deleteMessageById).toHaveBeenCalledWith('m1');
+ });
+ });
+
+ describe('deleteConversation', () => {
+ it('404 conversation missing', async () => {
+ model.findConversationById.mockResolvedValue(null);
+ await expect(service.deleteConversation({ conversationId: 'c1', userId: 'u1' }))
+ .rejects.toMatchObject({ code: 'CONVERSATION_NOT_FOUND' });
+ });
+
+ it('403 non-participant', async () => {
+ model.findConversationById.mockResolvedValue({ ...baseConversation, user_a_id: 'x', user_b_id: 'y' });
+ await expect(service.deleteConversation({ conversationId: 'c1', userId: 'u1' }))
+ .rejects.toMatchObject({ code: 'FORBIDDEN' });
+ });
+
+ it('404 already deleted by user A', async () => {
+ model.findConversationById.mockResolvedValue({ ...baseConversation, deleted_by_a: true });
+ await expect(service.deleteConversation({ conversationId: 'c1', userId: 'u1' }))
+ .rejects.toMatchObject({ code: 'CONVERSATION_NOT_FOUND' });
+ });
+
+ it('soft deletes for user A', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.softDeleteConversation.mockResolvedValue();
+
+ await service.deleteConversation({ conversationId: 'c1', userId: 'u1' });
+ expect(model.softDeleteConversation).toHaveBeenCalledWith('c1', true);
+ });
+
+ it('soft deletes for user B', async () => {
+ model.findConversationById.mockResolvedValue({ ...baseConversation, user_a_id: 'u2', user_b_id: 'u1' });
+ model.softDeleteConversation.mockResolvedValue();
+
+ await service.deleteConversation({ conversationId: 'c1', userId: 'u1' });
+ expect(model.softDeleteConversation).toHaveBeenCalledWith('c1', false);
+ });
+ });
+});
\ No newline at end of file
diff --git a/tests/messages.socket.unit.test.js b/tests/messages.socket.unit.test.js
new file mode 100644
index 0000000..f9f40a5
--- /dev/null
+++ b/tests/messages.socket.unit.test.js
@@ -0,0 +1,156 @@
+const db = require('../src/config/db');
+const { registerMessageHandlers } = require('../src/sockets/messages.socket');
+
+jest.mock('../src/config/db', () => ({ query: jest.fn() }));
+
+const mkSocket = (userId = '11111111-1111-1111-1111-111111111111') => {
+ const handlers = {};
+ return {
+ id: 'sock1',
+ user: { sub: userId },
+ on: jest.fn((event, cb) => { handlers[event] = cb; }),
+ emit: jest.fn(),
+ join: jest.fn(),
+ leave: jest.fn(),
+ to: jest.fn(() => ({ emit: jest.fn() })),
+ _handlers: handlers,
+ };
+};
+
+beforeEach(() => jest.clearAllMocks());
+
+describe('messages.socket', () => {
+ it('registers all message handlers', () => {
+ const socket = mkSocket();
+ registerMessageHandlers({}, socket);
+ expect(socket.on).toHaveBeenCalledWith('message:join', expect.any(Function));
+ expect(socket.on).toHaveBeenCalledWith('message:leave', expect.any(Function));
+ expect(socket.on).toHaveBeenCalledWith('message:send', expect.any(Function));
+ expect(socket.on).toHaveBeenCalledWith('message:deleted', expect.any(Function));
+ expect(socket.on).toHaveBeenCalledWith('message:read', expect.any(Function));
+ expect(socket.on).toHaveBeenCalledWith('message:typing', expect.any(Function));
+ expect(socket.on).toHaveBeenCalledWith('message:stop_typing', expect.any(Function));
+ });
+
+ it('message:join emits error on invalid conversationId', async () => {
+ const socket = mkSocket();
+ registerMessageHandlers({}, socket);
+
+ await socket._handlers['message:join']({ conversationId: 'bad' });
+
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid conversationId.' });
+ });
+
+ it('message:join emits forbidden when not participant', async () => {
+ const socket = mkSocket();
+ registerMessageHandlers({}, socket);
+ db.query.mockResolvedValue({ rows: [] });
+
+ await socket._handlers['message:join']({
+ conversationId: '22222222-2222-2222-2222-222222222222',
+ });
+
+ expect(socket.emit).toHaveBeenCalledWith('error', {
+ message: 'You are not a participant in this conversation.',
+ });
+ });
+
+ it('message:join handles db failure', async () => {
+ const socket = mkSocket();
+ registerMessageHandlers({}, socket);
+ db.query.mockRejectedValue(new Error('db down'));
+
+ await socket._handlers['message:join']({
+ conversationId: '22222222-2222-2222-2222-222222222222',
+ });
+
+ expect(socket.emit).toHaveBeenCalledWith('error', {
+ message: 'Something went wrong. Please try again.',
+ });
+ });
+
+ it('message:join joins room when authorized', async () => {
+ const socket = mkSocket();
+ registerMessageHandlers({}, socket);
+ db.query.mockResolvedValue({ rows: [{ ok: 1 }] });
+
+ await socket._handlers['message:join']({
+ conversationId: '22222222-2222-2222-2222-222222222222',
+ });
+
+ expect(socket.join).toHaveBeenCalledWith(
+ 'conversation:22222222-2222-2222-2222-222222222222'
+ );
+ });
+
+ it('message:leave leaves room', () => {
+ const socket = mkSocket();
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:leave']({ conversationId: 'c1' });
+ expect(socket.leave).toHaveBeenCalledWith('conversation:c1');
+ });
+
+ it('message:send broadcasts received', () => {
+ const socket = mkSocket();
+ const emit = jest.fn();
+ socket.to.mockReturnValue({ emit });
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:send']({ conversationId: 'c1', message: { id: 'm1' } });
+
+ expect(socket.to).toHaveBeenCalledWith('conversation:c1');
+ expect(emit).toHaveBeenCalledWith('message:received', {
+ conversationId: 'c1',
+ message: { id: 'm1' },
+ });
+ });
+
+ it('message:deleted broadcasts removed', () => {
+ const socket = mkSocket();
+ const emit = jest.fn();
+ socket.to.mockReturnValue({ emit });
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:deleted']({ conversationId: 'c1', messageId: 'm1' });
+
+ expect(emit).toHaveBeenCalledWith('message:removed', {
+ conversationId: 'c1',
+ messageId: 'm1',
+ });
+ });
+
+ it('message:read broadcasts read_updated', () => {
+ const socket = mkSocket();
+ const emit = jest.fn();
+ socket.to.mockReturnValue({ emit });
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:read']({
+ conversationId: 'c1',
+ messageId: 'm1',
+ isRead: true,
+ conversationUnreadCount: 0,
+ });
+
+ expect(emit).toHaveBeenCalledWith('message:read_updated', {
+ conversationId: 'c1',
+ messageId: 'm1',
+ isRead: true,
+ conversationUnreadCount: 0,
+ });
+ });
+
+ it('typing events broadcast', () => {
+ const socket = mkSocket();
+ const emit = jest.fn();
+ socket.to.mockReturnValue({ emit });
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:typing']({ conversationId: 'c1' });
+ expect(emit).toHaveBeenCalledWith('message:typing', { conversationId: 'c1', userId: socket.user.sub });
+
+ socket._handlers['message:stop_typing']({ conversationId: 'c1' });
+ expect(emit).toHaveBeenCalledWith('message:stop_typing', { conversationId: 'c1', userId: socket.user.sub });
+ });
+});
\ No newline at end of file
From ebd64c5eedeebda6b012e2669728067f88f8709a Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Wed, 18 Mar 2026 01:11:04 +0200
Subject: [PATCH 076/535] edit PR comments
-Tightened controller tests to assert exact payloads, statuses, messages, and service arguments.
-Added coverage for failure paths, auth-missing cases, validation/pagination edges, and service errors.
-Added senderId null/undefined guards and tests in messaging service.
-Deepened socket tests to cover authorization, DB query checks, malformed payloads, and error emissions.
-Verified with targeted messaging suites and full project test run.
---
src/controllers/messages.controller.js | 33 ++++-
src/services/messages.service.js | 10 ++
src/sockets/messages.socket.js | 77 +++++++++-
tests/messages.controller.unit.test.js | 143 +++++++++++++++---
tests/messages.service.unit.test.js | 70 +++++++++
tests/messages.socket.unit.test.js | 194 +++++++++++++++++++++++++
6 files changed, 494 insertions(+), 33 deletions(-)
diff --git a/src/controllers/messages.controller.js b/src/controllers/messages.controller.js
index 98f90bf..1ecbf5c 100644
--- a/src/controllers/messages.controller.js
+++ b/src/controllers/messages.controller.js
@@ -2,9 +2,19 @@
const messagesService = require('../services/messages.service');
const { success, error } = require('../utils/api-response');
+const getAuthenticatedUserId = (req, res) => {
+ const userId = req?.user?.sub;
+ if (!userId) {
+ error(res, 'UNAUTHORIZED', 'Authentication required.', 401);
+ return null;
+ }
+ return userId;
+};
+
// POST /messages/new
exports.startConversation = async (req, res) => {
- const senderId = req.user.sub;
+ const senderId = getAuthenticatedUserId(req, res);
+ if (!senderId) return;
const { recipient_id, body, resource } = req.body;
if (!recipient_id) {
@@ -27,7 +37,8 @@ exports.startConversation = async (req, res) => {
// GET /messages/conversations
exports.listConversations = async (req, res) => {
- const userId = req.user.sub;
+ const userId = getAuthenticatedUserId(req, res);
+ if (!userId) return;
const { page, limit } = req.query;
const data = await messagesService.listConversations({ userId, page, limit });
@@ -38,7 +49,8 @@ exports.listConversations = async (req, res) => {
// GET /messages/conversations/:conversationId
exports.getConversation = async (req, res) => {
- const userId = req.user.sub;
+ const userId = getAuthenticatedUserId(req, res);
+ if (!userId) return;
const { conversationId } = req.params;
const { page, limit } = req.query;
@@ -55,7 +67,8 @@ exports.getConversation = async (req, res) => {
// POST /messages/conversations/:conversationId/messages
exports.sendMessage = async (req, res) => {
- const senderId = req.user.sub;
+ const senderId = getAuthenticatedUserId(req, res);
+ if (!senderId) return;
const { conversationId } = req.params;
const { body, resource } = req.body;
@@ -72,7 +85,8 @@ exports.sendMessage = async (req, res) => {
// GET /messages/unread-count
exports.getUnreadCount = async (req, res) => {
- const userId = req.user.sub;
+ const userId = getAuthenticatedUserId(req, res);
+ if (!userId) return;
const data = await messagesService.getUnreadCount({ userId });
@@ -82,7 +96,8 @@ exports.getUnreadCount = async (req, res) => {
// PATCH /messages/conversations/:conversationId/messages/:messageId/read
exports.markMessageReadState = async (req, res) => {
- const userId = req.user.sub;
+ const userId = getAuthenticatedUserId(req, res);
+ if (!userId) return;
const { conversationId, messageId } = req.params;
const { is_read } = req.body;
@@ -107,7 +122,8 @@ exports.markMessageReadState = async (req, res) => {
// DELETE /messages/conversations/:conversationId/messages/:messageId
exports.deleteMessage = async (req, res) => {
- const userId = req.user.sub;
+ const userId = getAuthenticatedUserId(req, res);
+ if (!userId) return;
const { conversationId, messageId } = req.params;
await messagesService.deleteMessage({ conversationId, messageId, userId });
@@ -118,7 +134,8 @@ exports.deleteMessage = async (req, res) => {
// DELETE /messages/conversations/:conversationId
exports.deleteConversation = async (req, res) => {
- const userId = req.user.sub;
+ const userId = getAuthenticatedUserId(req, res);
+ if (!userId) return;
const { conversationId } = req.params;
await messagesService.deleteConversation({ conversationId, userId });
diff --git a/src/services/messages.service.js b/src/services/messages.service.js
index 36ea2bb..b2b1b47 100644
--- a/src/services/messages.service.js
+++ b/src/services/messages.service.js
@@ -2,11 +2,19 @@
const messageModel = require('../models/message.model');
const AppError = require('../utils/app-error');
+const validateSenderId = (senderId) => {
+ if (!senderId) {
+ throw new AppError('Authenticated sender is required.', 401, 'UNAUTHORIZED');
+ }
+};
+
// ------------------------------------------------------------
// Endpoint 1 — Start a new conversation
// ------------------------------------------------------------
exports.startConversation = async ({ senderId, recipientId, body, resource }) => {
+ validateSenderId(senderId);
+
// 1. Prevent self-messaging
if (senderId === recipientId) {
throw new AppError('You cannot send a message to yourself.', 400, 'MESSAGES_SELF_MESSAGE');
@@ -185,6 +193,8 @@ exports.getConversation = async ({ conversationId, userId, page, limit }) => {
// ------------------------------------------------------------
exports.sendMessage = async ({ conversationId, senderId, body, resource }) => {
+ validateSenderId(senderId);
+
// 1. Find conversation
const conversation = await messageModel.findConversationById(conversationId);
diff --git a/src/sockets/messages.socket.js b/src/sockets/messages.socket.js
index d4f56c7..75e130e 100644
--- a/src/sockets/messages.socket.js
+++ b/src/sockets/messages.socket.js
@@ -22,6 +22,8 @@ const isValidUUID = (val) =>
typeof val === 'string' &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(val);
+const isNonEmptyString = (val) => typeof val === 'string' && val.trim().length > 0;
+
// ------------------------------------------------------------
// Helper — verify user is a participant in a conversation
// Prevents users from joining rooms they don't belong to
@@ -44,6 +46,13 @@ const registerMessageHandlers = (io, socket) => {
// userId extracted from JWT-verified payload set by server.js middleware
const userId = socket.user?.sub;
+ const ensureAuthenticated = () => {
+ if (!isValidUUID(userId)) {
+ socket.emit('error', { message: 'Authentication required.' });
+ return false;
+ }
+ return true;
+ };
// ----------------------------------------------------------
// Join a conversation room
@@ -51,9 +60,13 @@ const registerMessageHandlers = (io, socket) => {
// Security: UUID validated and participant check done here
// because this is a socket-only event with no HTTP equivalent
// ----------------------------------------------------------
- socket.on('message:join', async ({ conversationId }) => {
+ socket.on('message:join', async ({ conversationId } = {}) => {
+
+ if (!ensureAuthenticated()) {
+ return;
+ }
- if (!isValidUUID(conversationId) || !isValidUUID(userId)) {
+ if (!isValidUUID(conversationId)) {
return socket.emit('error', { message: 'Invalid conversationId.' });
}
@@ -76,7 +89,15 @@ const registerMessageHandlers = (io, socket) => {
// Leave a conversation room
// Client emits when user closes a conversation
// ----------------------------------------------------------
- socket.on('message:leave', ({ conversationId }) => {
+ socket.on('message:leave', ({ conversationId } = {}) => {
+ if (!ensureAuthenticated()) {
+ return;
+ }
+
+ if (!isNonEmptyString(conversationId)) {
+ return socket.emit('error', { message: 'Invalid payload.' });
+ }
+
const room = `conversation:${conversationId}`;
socket.leave(room);
console.log(`[Socket.IO] ${socket.id} (user: ${userId}) left ${room}`);
@@ -88,7 +109,15 @@ const registerMessageHandlers = (io, socket) => {
// POST /messages/conversations/:conversationId/messages succeeds
// Passes the full message object from the HTTP response
// ----------------------------------------------------------
- socket.on('message:send', ({ conversationId, message }) => {
+ socket.on('message:send', ({ conversationId, message } = {}) => {
+ if (!ensureAuthenticated()) {
+ return;
+ }
+
+ if (!isNonEmptyString(conversationId) || !message || typeof message !== 'object') {
+ return socket.emit('error', { message: 'Invalid payload.' });
+ }
+
const room = `conversation:${conversationId}`;
socket.to(room).emit('message:received', { conversationId, message });
console.log(`[Socket.IO] message:send in ${room} by user: ${userId}`);
@@ -99,7 +128,15 @@ const registerMessageHandlers = (io, socket) => {
// Client emits AFTER
// DELETE /messages/conversations/:conversationId/messages/:messageId succeeds
// ----------------------------------------------------------
- socket.on('message:deleted', ({ conversationId, messageId }) => {
+ socket.on('message:deleted', ({ conversationId, messageId } = {}) => {
+ if (!ensureAuthenticated()) {
+ return;
+ }
+
+ if (!isNonEmptyString(conversationId) || !isNonEmptyString(messageId)) {
+ return socket.emit('error', { message: 'Invalid payload.' });
+ }
+
const room = `conversation:${conversationId}`;
socket.to(room).emit('message:removed', { conversationId, messageId });
console.log(`[Socket.IO] message:deleted in ${room} by user: ${userId}`);
@@ -110,7 +147,15 @@ const registerMessageHandlers = (io, socket) => {
// Client emits AFTER
// PATCH /messages/conversations/:conversationId/messages/:messageId/read succeeds
// ----------------------------------------------------------
- socket.on('message:read', ({ conversationId, messageId, isRead, conversationUnreadCount }) => {
+ socket.on('message:read', ({ conversationId, messageId, isRead, conversationUnreadCount } = {}) => {
+ if (!ensureAuthenticated()) {
+ return;
+ }
+
+ if (!isNonEmptyString(conversationId) || !isNonEmptyString(messageId) || typeof isRead !== 'boolean') {
+ return socket.emit('error', { message: 'Invalid payload.' });
+ }
+
const room = `conversation:${conversationId}`;
socket.to(room).emit('message:read_updated', {
conversationId,
@@ -126,7 +171,15 @@ const registerMessageHandlers = (io, socket) => {
// Client emits while user is typing
// NOTE: Frontend should throttle this to once per ~1000ms
// ----------------------------------------------------------
- socket.on('message:typing', ({ conversationId }) => {
+ socket.on('message:typing', ({ conversationId } = {}) => {
+ if (!ensureAuthenticated()) {
+ return;
+ }
+
+ if (!isNonEmptyString(conversationId)) {
+ return socket.emit('error', { message: 'Invalid payload.' });
+ }
+
const room = `conversation:${conversationId}`;
socket.to(room).emit('message:typing', { conversationId, userId });
});
@@ -135,7 +188,15 @@ const registerMessageHandlers = (io, socket) => {
// Stop typing indicator
// Client emits when user stops typing or sends the message
// ----------------------------------------------------------
- socket.on('message:stop_typing', ({ conversationId }) => {
+ socket.on('message:stop_typing', ({ conversationId } = {}) => {
+ if (!ensureAuthenticated()) {
+ return;
+ }
+
+ if (!isNonEmptyString(conversationId)) {
+ return socket.emit('error', { message: 'Invalid payload.' });
+ }
+
const room = `conversation:${conversationId}`;
socket.to(room).emit('message:stop_typing', { conversationId, userId });
});
diff --git a/tests/messages.controller.unit.test.js b/tests/messages.controller.unit.test.js
index 0b4902c..ca6b8dd 100644
--- a/tests/messages.controller.unit.test.js
+++ b/tests/messages.controller.unit.test.js
@@ -8,8 +8,8 @@ jest.mock('../src/utils/api-response', () => ({
error: jest.fn(),
}));
-const mkReq = ({ userId = 'u1', body = {}, params = {}, query = {} } = {}) => ({
- user: { sub: userId },
+const mkReq = ({ userId = 'u1', body = {}, params = {}, query = {}, user } = {}) => ({
+ user: user || (userId ? { sub: userId } : undefined),
body,
params,
query,
@@ -21,6 +21,21 @@ beforeEach(() => jest.clearAllMocks());
describe('messages.controller', () => {
describe('startConversation', () => {
+ it('returns unauthorized when req.user is missing', async () => {
+ const req = mkReq({ userId: null, body: { recipient_id: 'u2', body: 'hi' } });
+ const res = mkRes();
+
+ await controller.startConversation(req, res);
+
+ expect(api.error).toHaveBeenCalledWith(
+ res,
+ 'UNAUTHORIZED',
+ 'Authentication required.',
+ 401
+ );
+ expect(messagesService.startConversation).not.toHaveBeenCalled();
+ });
+
it('returns validation error when recipient_id missing', async () => {
const req = mkReq({ body: { body: 'hi' } });
const res = mkRes();
@@ -37,7 +52,7 @@ describe('messages.controller', () => {
});
it('returns 201 for new conversation', async () => {
- const req = mkReq({ body: { recipient_id: 'u2', body: 'hi' } });
+ const req = mkReq({ body: { recipient_id: 'u2', body: ' hi ' } });
const res = mkRes();
messagesService.startConversation.mockResolvedValue({
conversation: { id: 'c1' },
@@ -47,6 +62,13 @@ describe('messages.controller', () => {
await controller.startConversation(req, res);
+ expect(messagesService.startConversation).toHaveBeenCalledWith({
+ senderId: 'u1',
+ recipientId: 'u2',
+ body: ' hi ',
+ resource: undefined,
+ });
+
expect(api.success).toHaveBeenCalledWith(
res,
{ conversation: { id: 'c1' }, message: { id: 'm1' } },
@@ -73,6 +95,15 @@ describe('messages.controller', () => {
200
);
});
+
+ it('bubbles service failure', async () => {
+ const req = mkReq({ body: { recipient_id: 'u2', body: 'hi' } });
+ const res = mkRes();
+ messagesService.startConversation.mockRejectedValue(new Error('db down'));
+
+ await expect(controller.startConversation(req, res)).rejects.toThrow('db down');
+ expect(api.success).not.toHaveBeenCalled();
+ });
});
describe('markMessageReadState', () => {
@@ -129,48 +160,100 @@ describe('messages.controller', () => {
isRead: true,
});
- expect(api.success).toHaveBeenCalled();
+ expect(api.success).toHaveBeenCalledWith(
+ res,
+ {
+ message_id: 'm1',
+ is_read: true,
+ conversation_unread_count: 0,
+ },
+ 'Message read state updated successfully.'
+ );
});
});
- it('listConversations returns success', async () => {
+ it('listConversations passes query args and returns payload', async () => {
const req = mkReq({ query: { page: '2', limit: '8' } });
const res = mkRes();
- messagesService.listConversations.mockResolvedValue({ items: [], pagination: {} });
+ const data = {
+ items: [{ id: 'c1' }],
+ pagination: { page: 2, limit: 8, total: 1, total_pages: 1 },
+ };
+ messagesService.listConversations.mockResolvedValue(data);
await controller.listConversations(req, res);
- expect(api.success).toHaveBeenCalled();
+ expect(messagesService.listConversations).toHaveBeenCalledWith({
+ userId: 'u1',
+ page: '2',
+ limit: '8',
+ });
+ expect(api.success).toHaveBeenCalledWith(
+ res,
+ data,
+ 'Conversations fetched successfully.'
+ );
});
- it('getConversation returns success', async () => {
+ it('getConversation passes pagination query and returns payload', async () => {
const req = mkReq({ params: { conversationId: 'c1' }, query: { page: '1', limit: '20' } });
const res = mkRes();
- messagesService.getConversation.mockResolvedValue({ conversation: {}, messages: [], pagination: {} });
+ const data = { conversation: { id: 'c1' }, messages: [], pagination: { page: 1, limit: 20 } };
+ messagesService.getConversation.mockResolvedValue(data);
await controller.getConversation(req, res);
- expect(api.success).toHaveBeenCalled();
+ expect(messagesService.getConversation).toHaveBeenCalledWith({
+ conversationId: 'c1',
+ userId: 'u1',
+ page: '1',
+ limit: '20',
+ });
+ expect(api.success).toHaveBeenCalledWith(
+ res,
+ data,
+ 'Conversation fetched successfully.'
+ );
});
- it('sendMessage returns success', async () => {
- const req = mkReq({ params: { conversationId: 'c1' }, body: { body: 'hi' } });
+ it('sendMessage validates input forwarding and returns success', async () => {
+ const req = mkReq({
+ params: { conversationId: 'c1' },
+ body: { body: ' hello ', resource: { type: 'track', id: 't1' } },
+ });
const res = mkRes();
- messagesService.sendMessage.mockResolvedValue({ id: 'm1' });
+ messagesService.sendMessage.mockResolvedValue({ id: 'm1', body: 'hello' });
await controller.sendMessage(req, res);
- expect(api.success).toHaveBeenCalledWith(res, { id: 'm1' }, 'Message sent.', 201);
+ expect(messagesService.sendMessage).toHaveBeenCalledWith({
+ conversationId: 'c1',
+ senderId: 'u1',
+ body: ' hello ',
+ resource: { type: 'track', id: 't1' },
+ });
+ expect(api.success).toHaveBeenCalledWith(
+ res,
+ { id: 'm1', body: 'hello' },
+ 'Message sent.',
+ 201
+ );
});
- it('getUnreadCount returns success', async () => {
+ it('getUnreadCount returns payload and message', async () => {
const req = mkReq();
const res = mkRes();
- messagesService.getUnreadCount.mockResolvedValue({ unread_count: 4 });
+ const data = { unread_count: 4 };
+ messagesService.getUnreadCount.mockResolvedValue(data);
await controller.getUnreadCount(req, res);
- expect(api.success).toHaveBeenCalled();
+ expect(messagesService.getUnreadCount).toHaveBeenCalledWith({ userId: 'u1' });
+ expect(api.success).toHaveBeenCalledWith(
+ res,
+ data,
+ 'Unread message count fetched successfully.'
+ );
});
it('deleteMessage returns success', async () => {
@@ -199,4 +282,30 @@ describe('messages.controller', () => {
});
expect(api.success).toHaveBeenCalledWith(res, null, 'Conversation deleted.');
});
+
+ describe('auth edge cases', () => {
+ const cases = [
+ ['listConversations', () => mkReq({ userId: null })],
+ ['getConversation', () => mkReq({ userId: null, params: { conversationId: 'c1' } })],
+ ['sendMessage', () => mkReq({ userId: null, params: { conversationId: 'c1' }, body: { body: 'x' } })],
+ ['getUnreadCount', () => mkReq({ userId: null })],
+ ['markMessageReadState', () => mkReq({ userId: null, params: { conversationId: 'c1', messageId: 'm1' }, body: { is_read: true } })],
+ ['deleteMessage', () => mkReq({ userId: null, params: { conversationId: 'c1', messageId: 'm1' } })],
+ ['deleteConversation', () => mkReq({ userId: null, params: { conversationId: 'c1' } })],
+ ];
+
+ it.each(cases)('%s returns unauthorized when req.user is absent', async (methodName, makeReq) => {
+ const req = makeReq();
+ const res = mkRes();
+
+ await controller[methodName](req, res);
+
+ expect(api.error).toHaveBeenCalledWith(
+ res,
+ 'UNAUTHORIZED',
+ 'Authentication required.',
+ 401
+ );
+ });
+ });
});
\ No newline at end of file
diff --git a/tests/messages.service.unit.test.js b/tests/messages.service.unit.test.js
index a7ada94..56445da 100644
--- a/tests/messages.service.unit.test.js
+++ b/tests/messages.service.unit.test.js
@@ -18,6 +18,22 @@ const baseConversation = {
describe('messages.service', () => {
describe('startConversation', () => {
+ it('rejects when senderId is undefined', async () => {
+ await expect(
+ service.startConversation({ senderId: undefined, recipientId: 'u2', body: 'x' })
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED', statusCode: 401 });
+
+ expect(model.findActiveUserById).not.toHaveBeenCalled();
+ });
+
+ it('rejects when senderId is null', async () => {
+ await expect(
+ service.startConversation({ senderId: null, recipientId: 'u2', body: 'x' })
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED', statusCode: 401 });
+
+ expect(model.findActiveUserById).not.toHaveBeenCalled();
+ });
+
it('rejects self message', async () => {
await expect(
service.startConversation({ senderId: 'u1', recipientId: 'u1', body: 'x' })
@@ -174,6 +190,21 @@ describe('messages.service', () => {
const out = await service.listConversations({ userId: 'u1' });
expect(out.items[0].last_message).toBeNull();
});
+
+ it('defaults pagination for invalid values', async () => {
+ model.findConversationsByUserId.mockResolvedValue([]);
+ model.countConversationsByUserId.mockResolvedValue(0);
+
+ const out = await service.listConversations({ userId: 'u1', page: 'abc', limit: 'xyz' });
+
+ expect(model.findConversationsByUserId).toHaveBeenCalledWith('u1', 8, 0);
+ expect(out.pagination).toEqual({
+ page: 1,
+ limit: 8,
+ total: 0,
+ total_pages: 0,
+ });
+ });
});
describe('getConversation', () => {
@@ -207,9 +238,48 @@ describe('messages.service', () => {
expect(model.findMessagesByConversationId).toHaveBeenCalledWith('c1', 100, 0);
expect(out.pagination.total_pages).toBe(1);
});
+
+ it('uses default pagination when page/limit are invalid', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.findMessagesByConversationId.mockResolvedValue([]);
+ model.countMessagesByConversationId.mockResolvedValue(0);
+ model.findConversationPartner.mockResolvedValue({ id: 'u2' });
+ model.countUnreadMessages.mockResolvedValue(0);
+
+ const out = await service.getConversation({
+ conversationId: 'c1',
+ userId: 'u1',
+ page: 'abc',
+ limit: null,
+ });
+
+ expect(model.findMessagesByConversationId).toHaveBeenCalledWith('c1', 50, 0);
+ expect(out.pagination).toEqual({
+ page: 1,
+ limit: 50,
+ total: 0,
+ total_pages: 0,
+ });
+ });
});
describe('sendMessage', () => {
+ it('rejects when senderId is undefined', async () => {
+ await expect(
+ service.sendMessage({ conversationId: 'c1', senderId: undefined, body: 'x' })
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED', statusCode: 401 });
+
+ expect(model.findConversationById).not.toHaveBeenCalled();
+ });
+
+ it('rejects when senderId is null', async () => {
+ await expect(
+ service.sendMessage({ conversationId: 'c1', senderId: null, body: 'x' })
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED', statusCode: 401 });
+
+ expect(model.findConversationById).not.toHaveBeenCalled();
+ });
+
it('404 when conversation missing', async () => {
model.findConversationById.mockResolvedValue(null);
await expect(service.sendMessage({ conversationId: 'c1', senderId: 'u1', body: 'x' }))
diff --git a/tests/messages.socket.unit.test.js b/tests/messages.socket.unit.test.js
index f9f40a5..137ac10 100644
--- a/tests/messages.socket.unit.test.js
+++ b/tests/messages.socket.unit.test.js
@@ -41,6 +41,28 @@ describe('messages.socket', () => {
expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid conversationId.' });
});
+ it('message:join handles missing payload object', async () => {
+ const socket = mkSocket();
+ registerMessageHandlers({}, socket);
+
+ await socket._handlers['message:join']();
+
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid conversationId.' });
+ expect(db.query).not.toHaveBeenCalled();
+ });
+
+ it('message:join emits auth error when socket has no valid user', async () => {
+ const socket = mkSocket(null);
+ registerMessageHandlers({}, socket);
+
+ await socket._handlers['message:join']({
+ conversationId: '22222222-2222-2222-2222-222222222222',
+ });
+
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Authentication required.' });
+ expect(db.query).not.toHaveBeenCalled();
+ });
+
it('message:join emits forbidden when not participant', async () => {
const socket = mkSocket();
registerMessageHandlers({}, socket);
@@ -53,6 +75,13 @@ describe('messages.socket', () => {
expect(socket.emit).toHaveBeenCalledWith('error', {
message: 'You are not a participant in this conversation.',
});
+ expect(db.query).toHaveBeenCalledWith(
+ expect.stringContaining('SELECT 1 FROM conversations'),
+ [
+ '22222222-2222-2222-2222-222222222222',
+ '11111111-1111-1111-1111-111111111111',
+ ]
+ );
});
it('message:join handles db failure', async () => {
@@ -81,6 +110,7 @@ describe('messages.socket', () => {
expect(socket.join).toHaveBeenCalledWith(
'conversation:22222222-2222-2222-2222-222222222222'
);
+ expect(db.query).toHaveBeenCalledTimes(1);
});
it('message:leave leaves room', () => {
@@ -91,6 +121,26 @@ describe('messages.socket', () => {
expect(socket.leave).toHaveBeenCalledWith('conversation:c1');
});
+ it('message:leave rejects malformed payload', () => {
+ const socket = mkSocket();
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:leave']({});
+
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid payload.' });
+ expect(socket.leave).not.toHaveBeenCalled();
+ });
+
+ it('message:leave rejects unauthenticated socket', () => {
+ const socket = mkSocket(null);
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:leave']({ conversationId: 'c1' });
+
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Authentication required.' });
+ expect(socket.leave).not.toHaveBeenCalled();
+ });
+
it('message:send broadcasts received', () => {
const socket = mkSocket();
const emit = jest.fn();
@@ -106,6 +156,30 @@ describe('messages.socket', () => {
});
});
+ it('message:send rejects malformed payload', () => {
+ const socket = mkSocket();
+ const emit = jest.fn();
+ socket.to.mockReturnValue({ emit });
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:send']({ conversationId: 'c1' });
+
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid payload.' });
+ expect(emit).not.toHaveBeenCalled();
+ });
+
+ it('message:send rejects unauthenticated socket', () => {
+ const socket = mkSocket(null);
+ const emit = jest.fn();
+ socket.to.mockReturnValue({ emit });
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:send']({ conversationId: 'c1', message: { id: 'm1' } });
+
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Authentication required.' });
+ expect(emit).not.toHaveBeenCalled();
+ });
+
it('message:deleted broadcasts removed', () => {
const socket = mkSocket();
const emit = jest.fn();
@@ -120,6 +194,30 @@ describe('messages.socket', () => {
});
});
+ it('message:deleted rejects malformed payload', () => {
+ const socket = mkSocket();
+ const emit = jest.fn();
+ socket.to.mockReturnValue({ emit });
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:deleted']({ conversationId: 'c1' });
+
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid payload.' });
+ expect(emit).not.toHaveBeenCalled();
+ });
+
+ it('message:deleted rejects unauthenticated socket', () => {
+ const socket = mkSocket(null);
+ const emit = jest.fn();
+ socket.to.mockReturnValue({ emit });
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:deleted']({ conversationId: 'c1', messageId: 'm1' });
+
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Authentication required.' });
+ expect(emit).not.toHaveBeenCalled();
+ });
+
it('message:read broadcasts read_updated', () => {
const socket = mkSocket();
const emit = jest.fn();
@@ -141,6 +239,40 @@ describe('messages.socket', () => {
});
});
+ it('message:read rejects malformed payload', () => {
+ const socket = mkSocket();
+ const emit = jest.fn();
+ socket.to.mockReturnValue({ emit });
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:read']({
+ conversationId: 'c1',
+ messageId: 'm1',
+ isRead: 'yes',
+ conversationUnreadCount: 0,
+ });
+
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid payload.' });
+ expect(emit).not.toHaveBeenCalled();
+ });
+
+ it('message:read rejects unauthenticated socket', () => {
+ const socket = mkSocket(null);
+ const emit = jest.fn();
+ socket.to.mockReturnValue({ emit });
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:read']({
+ conversationId: 'c1',
+ messageId: 'm1',
+ isRead: true,
+ conversationUnreadCount: 0,
+ });
+
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Authentication required.' });
+ expect(emit).not.toHaveBeenCalled();
+ });
+
it('typing events broadcast', () => {
const socket = mkSocket();
const emit = jest.fn();
@@ -153,4 +285,66 @@ describe('messages.socket', () => {
socket._handlers['message:stop_typing']({ conversationId: 'c1' });
expect(emit).toHaveBeenCalledWith('message:stop_typing', { conversationId: 'c1', userId: socket.user.sub });
});
+
+ it('typing events reject malformed payload', () => {
+ const socket = mkSocket();
+ const emit = jest.fn();
+ socket.to.mockReturnValue({ emit });
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:typing']({});
+ socket._handlers['message:stop_typing']({});
+
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid payload.' });
+ expect(emit).not.toHaveBeenCalled();
+ });
+
+ it('typing events reject unauthenticated socket', () => {
+ const socket = mkSocket(null);
+ const emit = jest.fn();
+ socket.to.mockReturnValue({ emit });
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:typing']({ conversationId: 'c1' });
+ socket._handlers['message:stop_typing']({ conversationId: 'c1' });
+
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Authentication required.' });
+ expect(emit).not.toHaveBeenCalled();
+ });
+
+ it('typing events reject whitespace conversationId', () => {
+ const socket = mkSocket();
+ const emit = jest.fn();
+ socket.to.mockReturnValue({ emit });
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:typing']({ conversationId: ' ' });
+ socket._handlers['message:stop_typing']({ conversationId: ' ' });
+
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid payload.' });
+ expect(emit).not.toHaveBeenCalled();
+ });
+
+ it('handlers with default payload reject when called without arguments', () => {
+ const socket = mkSocket();
+ const emit = jest.fn();
+ socket.to.mockReturnValue({ emit });
+ registerMessageHandlers({}, socket);
+
+ socket._handlers['message:leave']();
+ socket._handlers['message:send']();
+ socket._handlers['message:deleted']();
+ socket._handlers['message:read']();
+ socket._handlers['message:typing']();
+ socket._handlers['message:stop_typing']();
+
+ expect(socket.emit).toHaveBeenCalledTimes(6);
+ expect(socket.emit).toHaveBeenNthCalledWith(1, 'error', { message: 'Invalid payload.' });
+ expect(socket.emit).toHaveBeenNthCalledWith(2, 'error', { message: 'Invalid payload.' });
+ expect(socket.emit).toHaveBeenNthCalledWith(3, 'error', { message: 'Invalid payload.' });
+ expect(socket.emit).toHaveBeenNthCalledWith(4, 'error', { message: 'Invalid payload.' });
+ expect(socket.emit).toHaveBeenNthCalledWith(5, 'error', { message: 'Invalid payload.' });
+ expect(socket.emit).toHaveBeenNthCalledWith(6, 'error', { message: 'Invalid payload.' });
+ expect(emit).not.toHaveBeenCalled();
+ });
});
\ No newline at end of file
From 3b0ca948dcffb6480a0d888f9543d7982b70b5e4 Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Thu, 19 Mar 2026 15:32:39 +0200
Subject: [PATCH 077/535] Fix message soft-delete restore and tighten socket
validation
-messages.socket.js: Refactored socket authorization to use service-layer access checks instead of raw SQL, enforced participant validation across all conversation-scoped events, converted handlers to async where needed, and tightened messageId validation to UUID.
-messages.service.js: Added reusable assertConversationAccess guard (existence, participant, soft-delete visibility checks) and updated sendMessage restore logic to unhide conversation for both sender and recipient when either side had soft-deleted.
-messages.socket.unit.test.js: Reworked socket tests to mock service-layer access checks (instead of DB), updated payload expectations for UUID-based validation, converted handler invocations to async, and added non-participant rejection coverage.
-messages.service.unit.test.js: Added branch coverage for assertConversationAccess and added restore-flow tests for sender-deleted and both-sides-deleted scenarios.
-messages.model.unit.test.js: Added comprehensive model unit tests covering all message model methods and both outcomes for row/no-row branches.
---
src/services/messages.service.js | 52 ++++++--
src/sockets/messages.socket.js | 106 ++++++++-------
tests/messages.model.unit.test.js | 170 +++++++++++++++++++++++++
tests/messages.service.unit.test.js | 63 +++++++++
tests/messages.socket.unit.test.js | 191 +++++++++++++++-------------
5 files changed, 440 insertions(+), 142 deletions(-)
create mode 100644 tests/messages.model.unit.test.js
diff --git a/src/services/messages.service.js b/src/services/messages.service.js
index b2b1b47..c674edf 100644
--- a/src/services/messages.service.js
+++ b/src/services/messages.service.js
@@ -8,6 +8,31 @@ const validateSenderId = (senderId) => {
}
};
+exports.assertConversationAccess = async ({ conversationId, userId, allowSoftDeleted = false }) => {
+ const conversation = await messageModel.findConversationById(conversationId);
+ if (!conversation) {
+ throw new AppError('Conversation not found.', 404, 'CONVERSATION_NOT_FOUND');
+ }
+
+ const isParticipant =
+ conversation.user_a_id === userId ||
+ conversation.user_b_id === userId;
+ if (!isParticipant) {
+ throw new AppError('You do not have access to this conversation.', 403, 'FORBIDDEN');
+ }
+
+ if (!allowSoftDeleted) {
+ const deletedByUser =
+ (conversation.user_a_id === userId && conversation.deleted_by_a) ||
+ (conversation.user_b_id === userId && conversation.deleted_by_b);
+ if (deletedByUser) {
+ throw new AppError('Conversation not found.', 404, 'CONVERSATION_NOT_FOUND');
+ }
+ }
+
+ return conversation;
+};
+
// ------------------------------------------------------------
// Endpoint 1 — Start a new conversation
// ------------------------------------------------------------
@@ -210,13 +235,22 @@ exports.sendMessage = async ({ conversationId, senderId, body, resource }) => {
throw new AppError('You do not have access to this conversation.', 403, 'FORBIDDEN');
}
- // 3. Identify the recipient first — needed for restore check below
+ // 3. Identify participants for restore checks below
+ const senderIsUserA = conversation.user_a_id === senderId;
const recipientId =
- conversation.user_a_id === senderId
+ senderIsUserA
? conversation.user_b_id
: conversation.user_a_id;
- // 4. Restore conversation for recipient if they had soft-deleted it
+ // 4. Restore conversation for sender if they had soft-deleted it
+ const deletedBySender =
+ (senderIsUserA && conversation.deleted_by_a) ||
+ (!senderIsUserA && conversation.deleted_by_b);
+ if (deletedBySender) {
+ await messageModel.restoreConversationForUser(conversationId, senderIsUserA);
+ }
+
+ // 5. Restore conversation for recipient if they had soft-deleted it
const recipientIsUserA = conversation.user_a_id === recipientId;
const deletedByRecipient =
(recipientIsUserA && conversation.deleted_by_a) ||
@@ -227,13 +261,13 @@ exports.sendMessage = async ({ conversationId, senderId, body, resource }) => {
}
- // 5. Check if recipient has blocked the sender
+ // 6. Check if recipient has blocked the sender
const recipientBlockedSender = await messageModel.isBlocked(recipientId, senderId);
if (recipientBlockedSender) {
throw new AppError('You cannot send a message to this user.', 403, 'MESSAGES_BLOCKED');
}
- // 6. Check recipient's messages_from preference
+ // 7. Check recipient's messages_from preference
// 'followers_only' means the recipient must be following the sender
const messagesFrom = await messageModel.getMessagesFromPreference(recipientId);
if (messagesFrom === 'followers_only') {
@@ -243,25 +277,25 @@ exports.sendMessage = async ({ conversationId, senderId, body, resource }) => {
}
}
- // 7. Validate message content — at least body or resource required
+ // 8. Validate message content — at least body or resource required
const hasBody = typeof body === 'string' && body.trim().length > 0;
const hasResource = resource && resource.type && resource.id;
if (!hasBody && !hasResource) {
throw new AppError('A message must contain a body or an embedded resource.', 400, 'MESSAGES_EMPTY');
}
- // 8. Validate body length
+ // 9. Validate body length
if (hasBody && body.length > 2000) {
throw new AppError('Message body must not exceed 2000 characters.', 400, 'MESSAGES_BODY_TOO_LONG');
}
- // 9. Validate resource type if provided
+ // 10. Validate resource type if provided
// [DEPENDS: tracks/playlists module] — embed_id references tracks or playlists table
if (hasResource && !['track', 'playlist'].includes(resource.type)) {
throw new AppError('Embedded resource type must be "track" or "playlist".', 400, 'MESSAGES_INVALID_EMBED_TYPE');
}
- // 10. Insert the message
+ // 11. Insert the message
const message = await messageModel.createMessage({
conversationId,
senderId,
diff --git a/src/sockets/messages.socket.js b/src/sockets/messages.socket.js
index 75e130e..7b5b261 100644
--- a/src/sockets/messages.socket.js
+++ b/src/sockets/messages.socket.js
@@ -12,7 +12,7 @@
// Room naming convention: conversation:
// ============================================================
-const db = require('../config/db');
+const messagesService = require('../services/messages.service');
// ------------------------------------------------------------
// Helper — validate UUID format
@@ -22,25 +22,6 @@ const isValidUUID = (val) =>
typeof val === 'string' &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(val);
-const isNonEmptyString = (val) => typeof val === 'string' && val.trim().length > 0;
-
-// ------------------------------------------------------------
-// Helper — verify user is a participant in a conversation
-// Prevents users from joining rooms they don't belong to
-// [DEPENDS: conversations table]
-// ------------------------------------------------------------
-const isParticipant = async (conversationId, userId) => {
- const { rows } = await db.query(
- `SELECT 1 FROM conversations
- WHERE id = $1
- AND (user_a_id = $2 OR user_b_id = $2)
- AND NOT (user_a_id = $2 AND deleted_by_a = true)
- AND NOT (user_b_id = $2 AND deleted_by_b = true)`,
- [conversationId, userId]
- );
- return rows.length > 0;
-};
-
// ------------------------------------------------------------
const registerMessageHandlers = (io, socket) => {
@@ -54,6 +35,28 @@ const registerMessageHandlers = (io, socket) => {
return true;
};
+ const ensureParticipantInConversation = async (conversationId) => {
+ if (!isValidUUID(conversationId)) {
+ socket.emit('error', { message: 'Invalid conversationId.' });
+ return false;
+ }
+
+ try {
+ await messagesService.assertConversationAccess({ conversationId, userId });
+ return true;
+ } catch (err) {
+ const isAccessError = err?.code === 'FORBIDDEN' || err?.code === 'CONVERSATION_NOT_FOUND';
+ if (isAccessError) {
+ socket.emit('error', { message: 'You are not a participant in this conversation.' });
+ return false;
+ }
+
+ console.error(`[Socket.IO] conversation access check failed:`, err.message);
+ socket.emit('error', { message: 'Something went wrong. Please try again.' });
+ return false;
+ }
+ };
+
// ----------------------------------------------------------
// Join a conversation room
// Client emits when user opens a conversation
@@ -66,18 +69,9 @@ const registerMessageHandlers = (io, socket) => {
return;
}
- if (!isValidUUID(conversationId)) {
- return socket.emit('error', { message: 'Invalid conversationId.' });
- }
-
- try {
- const allowed = await isParticipant(conversationId, userId);
- if (!allowed) {
- return socket.emit('error', { message: 'You are not a participant in this conversation.' });
- }
- } catch (err) {
- console.error(`[Socket.IO] message:join DB error:`, err.message);
- return socket.emit('error', { message: 'Something went wrong. Please try again.' });
+ const canAccess = await ensureParticipantInConversation(conversationId);
+ if (!canAccess) {
+ return;
}
const room = `conversation:${conversationId}`;
@@ -89,13 +83,14 @@ const registerMessageHandlers = (io, socket) => {
// Leave a conversation room
// Client emits when user closes a conversation
// ----------------------------------------------------------
- socket.on('message:leave', ({ conversationId } = {}) => {
+ socket.on('message:leave', async ({ conversationId } = {}) => {
if (!ensureAuthenticated()) {
return;
}
- if (!isNonEmptyString(conversationId)) {
- return socket.emit('error', { message: 'Invalid payload.' });
+ const canAccess = await ensureParticipantInConversation(conversationId);
+ if (!canAccess) {
+ return;
}
const room = `conversation:${conversationId}`;
@@ -109,15 +104,20 @@ const registerMessageHandlers = (io, socket) => {
// POST /messages/conversations/:conversationId/messages succeeds
// Passes the full message object from the HTTP response
// ----------------------------------------------------------
- socket.on('message:send', ({ conversationId, message } = {}) => {
+ socket.on('message:send', async ({ conversationId, message } = {}) => {
if (!ensureAuthenticated()) {
return;
}
- if (!isNonEmptyString(conversationId) || !message || typeof message !== 'object') {
+ if (!message || typeof message !== 'object') {
return socket.emit('error', { message: 'Invalid payload.' });
}
+ const canAccess = await ensureParticipantInConversation(conversationId);
+ if (!canAccess) {
+ return;
+ }
+
const room = `conversation:${conversationId}`;
socket.to(room).emit('message:received', { conversationId, message });
console.log(`[Socket.IO] message:send in ${room} by user: ${userId}`);
@@ -128,15 +128,20 @@ const registerMessageHandlers = (io, socket) => {
// Client emits AFTER
// DELETE /messages/conversations/:conversationId/messages/:messageId succeeds
// ----------------------------------------------------------
- socket.on('message:deleted', ({ conversationId, messageId } = {}) => {
+ socket.on('message:deleted', async ({ conversationId, messageId } = {}) => {
if (!ensureAuthenticated()) {
return;
}
- if (!isNonEmptyString(conversationId) || !isNonEmptyString(messageId)) {
+ if (!isValidUUID(messageId)) {
return socket.emit('error', { message: 'Invalid payload.' });
}
+ const canAccess = await ensureParticipantInConversation(conversationId);
+ if (!canAccess) {
+ return;
+ }
+
const room = `conversation:${conversationId}`;
socket.to(room).emit('message:removed', { conversationId, messageId });
console.log(`[Socket.IO] message:deleted in ${room} by user: ${userId}`);
@@ -147,15 +152,20 @@ const registerMessageHandlers = (io, socket) => {
// Client emits AFTER
// PATCH /messages/conversations/:conversationId/messages/:messageId/read succeeds
// ----------------------------------------------------------
- socket.on('message:read', ({ conversationId, messageId, isRead, conversationUnreadCount } = {}) => {
+ socket.on('message:read', async ({ conversationId, messageId, isRead, conversationUnreadCount } = {}) => {
if (!ensureAuthenticated()) {
return;
}
- if (!isNonEmptyString(conversationId) || !isNonEmptyString(messageId) || typeof isRead !== 'boolean') {
+ if (!isValidUUID(messageId) || typeof isRead !== 'boolean') {
return socket.emit('error', { message: 'Invalid payload.' });
}
+ const canAccess = await ensureParticipantInConversation(conversationId);
+ if (!canAccess) {
+ return;
+ }
+
const room = `conversation:${conversationId}`;
socket.to(room).emit('message:read_updated', {
conversationId,
@@ -171,13 +181,14 @@ const registerMessageHandlers = (io, socket) => {
// Client emits while user is typing
// NOTE: Frontend should throttle this to once per ~1000ms
// ----------------------------------------------------------
- socket.on('message:typing', ({ conversationId } = {}) => {
+ socket.on('message:typing', async ({ conversationId } = {}) => {
if (!ensureAuthenticated()) {
return;
}
- if (!isNonEmptyString(conversationId)) {
- return socket.emit('error', { message: 'Invalid payload.' });
+ const canAccess = await ensureParticipantInConversation(conversationId);
+ if (!canAccess) {
+ return;
}
const room = `conversation:${conversationId}`;
@@ -188,13 +199,14 @@ const registerMessageHandlers = (io, socket) => {
// Stop typing indicator
// Client emits when user stops typing or sends the message
// ----------------------------------------------------------
- socket.on('message:stop_typing', ({ conversationId } = {}) => {
+ socket.on('message:stop_typing', async ({ conversationId } = {}) => {
if (!ensureAuthenticated()) {
return;
}
- if (!isNonEmptyString(conversationId)) {
- return socket.emit('error', { message: 'Invalid payload.' });
+ const canAccess = await ensureParticipantInConversation(conversationId);
+ if (!canAccess) {
+ return;
}
const room = `conversation:${conversationId}`;
diff --git a/tests/messages.model.unit.test.js b/tests/messages.model.unit.test.js
new file mode 100644
index 0000000..00cff8b
--- /dev/null
+++ b/tests/messages.model.unit.test.js
@@ -0,0 +1,170 @@
+const model = require('../src/models/message.model');
+const db = require('../src/config/db');
+
+jest.mock('../src/config/db', () => ({ query: jest.fn() }));
+
+beforeEach(() => jest.clearAllMocks());
+
+describe('message.model', () => {
+ it('findConversationByPair returns first row or null', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ id: 'c1' }] });
+ await expect(model.findConversationByPair('u1', 'u2')).resolves.toEqual({ id: 'c1' });
+
+ db.query.mockResolvedValueOnce({ rows: [] });
+ await expect(model.findConversationByPair('u1', 'u2')).resolves.toBeNull();
+ });
+
+ it('createConversation returns inserted row', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ id: 'c1' }] });
+ await expect(model.createConversation('u1', 'u2')).resolves.toEqual({ id: 'c1' });
+ expect(db.query).toHaveBeenCalledTimes(1);
+ });
+
+ it('createConversation falls back to existing row on conflict', async () => {
+ db.query
+ .mockResolvedValueOnce({ rows: [] })
+ .mockResolvedValueOnce({ rows: [{ id: 'cExisting' }] });
+
+ await expect(model.createConversation('u1', 'u2')).resolves.toEqual({ id: 'cExisting' });
+ expect(db.query).toHaveBeenCalledTimes(2);
+ });
+
+ it('createMessage inserts and returns row', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ id: 'm1' }] });
+
+ await expect(
+ model.createMessage({
+ conversationId: 'c1',
+ senderId: 'u1',
+ body: null,
+ embedType: undefined,
+ embedId: undefined,
+ })
+ ).resolves.toEqual({ id: 'm1' });
+
+ expect(db.query).toHaveBeenCalledWith(
+ expect.stringContaining('INSERT INTO messages'),
+ ['c1', 'u1', null, null, null]
+ );
+ });
+
+ it('isBlocked returns boolean', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ ok: 1 }] });
+ await expect(model.isBlocked('u1', 'u2')).resolves.toBe(true);
+
+ db.query.mockResolvedValueOnce({ rows: [] });
+ await expect(model.isBlocked('u1', 'u2')).resolves.toBe(false);
+ });
+
+ it('getMessagesFromPreference returns preference or default', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ messages_from: 'followers_only' }] });
+ await expect(model.getMessagesFromPreference('u1')).resolves.toBe('followers_only');
+
+ db.query.mockResolvedValueOnce({ rows: [] });
+ await expect(model.getMessagesFromPreference('u1')).resolves.toBe('everyone');
+ });
+
+ it('isFollowing returns boolean', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ ok: 1 }] });
+ await expect(model.isFollowing('u1', 'u2')).resolves.toBe(true);
+
+ db.query.mockResolvedValueOnce({ rows: [] });
+ await expect(model.isFollowing('u1', 'u2')).resolves.toBe(false);
+ });
+
+ it('findActiveUserById returns row or null', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ id: 'u1' }] });
+ await expect(model.findActiveUserById('u1')).resolves.toEqual({ id: 'u1' });
+
+ db.query.mockResolvedValueOnce({ rows: [] });
+ await expect(model.findActiveUserById('u1')).resolves.toBeNull();
+ });
+
+ it('findConversationsByUserId returns rows', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ id: 'c1' }] });
+ await expect(model.findConversationsByUserId('u1', 10, 0)).resolves.toEqual([{ id: 'c1' }]);
+ });
+
+ it('countConversationsByUserId returns total', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ total: 9 }] });
+ await expect(model.countConversationsByUserId('u1')).resolves.toBe(9);
+ });
+
+ it('findConversationById returns row or null', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ id: 'c1' }] });
+ await expect(model.findConversationById('c1')).resolves.toEqual({ id: 'c1' });
+
+ db.query.mockResolvedValueOnce({ rows: [] });
+ await expect(model.findConversationById('c1')).resolves.toBeNull();
+ });
+
+ it('findMessagesByConversationId returns rows', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ id: 'm1' }] });
+ await expect(model.findMessagesByConversationId('c1', 10, 0)).resolves.toEqual([{ id: 'm1' }]);
+ });
+
+ it('countMessagesByConversationId returns total', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ total: 4 }] });
+ await expect(model.countMessagesByConversationId('c1')).resolves.toBe(4);
+ });
+
+ it('findConversationPartner returns row or null', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ id: 'u2' }] });
+ await expect(model.findConversationPartner('c1', 'u1')).resolves.toEqual({ id: 'u2' });
+
+ db.query.mockResolvedValueOnce({ rows: [] });
+ await expect(model.findConversationPartner('c1', 'u1')).resolves.toBeNull();
+ });
+
+ it('countUnreadMessages returns unread count', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ unread_count: 3 }] });
+ await expect(model.countUnreadMessages('c1', 'u1')).resolves.toBe(3);
+ });
+
+ it('countTotalUnreadMessages returns unread count', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ unread_count: 5 }] });
+ await expect(model.countTotalUnreadMessages('u1')).resolves.toBe(5);
+ });
+
+ it('findMessageById returns row or null', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ id: 'm1' }] });
+ await expect(model.findMessageById('m1', 'c1')).resolves.toEqual({ id: 'm1' });
+
+ db.query.mockResolvedValueOnce({ rows: [] });
+ await expect(model.findMessageById('m1', 'c1')).resolves.toBeNull();
+ });
+
+ it('updateMessageReadState returns updated row or null', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ id: 'm1', is_read: true }] });
+ await expect(model.updateMessageReadState('m1', true)).resolves.toEqual({ id: 'm1', is_read: true });
+
+ db.query.mockResolvedValueOnce({ rows: [] });
+ await expect(model.updateMessageReadState('m1', true)).resolves.toBeNull();
+ });
+
+ it('deleteMessageById executes delete query', async () => {
+ db.query.mockResolvedValueOnce({});
+ await model.deleteMessageById('m1');
+ expect(db.query).toHaveBeenCalledWith(expect.stringContaining('DELETE FROM messages'), ['m1']);
+ });
+
+ it('softDeleteConversation sets deleted_by_a or deleted_by_b and returns row', async () => {
+ db.query.mockResolvedValueOnce({ rows: [{ id: 'c1' }] });
+ await expect(model.softDeleteConversation('c1', true)).resolves.toEqual({ id: 'c1' });
+ expect(db.query).toHaveBeenCalledWith(expect.stringContaining('SET deleted_by_a = true'), ['c1']);
+
+ db.query.mockResolvedValueOnce({ rows: [{ id: 'c1' }] });
+ await expect(model.softDeleteConversation('c1', false)).resolves.toEqual({ id: 'c1' });
+ expect(db.query).toHaveBeenCalledWith(expect.stringContaining('SET deleted_by_b = true'), ['c1']);
+ });
+
+ it('restoreConversationForUser sets deleted flag to false for selected side', async () => {
+ db.query.mockResolvedValueOnce({});
+ await model.restoreConversationForUser('c1', true);
+ expect(db.query).toHaveBeenCalledWith(expect.stringContaining('SET deleted_by_a = false'), ['c1']);
+
+ db.query.mockResolvedValueOnce({});
+ await model.restoreConversationForUser('c1', false);
+ expect(db.query).toHaveBeenCalledWith(expect.stringContaining('SET deleted_by_b = false'), ['c1']);
+ });
+});
diff --git a/tests/messages.service.unit.test.js b/tests/messages.service.unit.test.js
index 56445da..0084a68 100644
--- a/tests/messages.service.unit.test.js
+++ b/tests/messages.service.unit.test.js
@@ -17,6 +17,45 @@ const baseConversation = {
};
describe('messages.service', () => {
+ describe('assertConversationAccess', () => {
+ it('throws 404 when conversation does not exist', async () => {
+ model.findConversationById.mockResolvedValue(null);
+
+ await expect(service.assertConversationAccess({ conversationId: 'c1', userId: 'u1' }))
+ .rejects.toMatchObject({ code: 'CONVERSATION_NOT_FOUND', statusCode: 404 });
+ });
+
+ it('throws 403 when user is not a participant', async () => {
+ model.findConversationById.mockResolvedValue({ ...baseConversation, user_a_id: 'x', user_b_id: 'y' });
+
+ await expect(service.assertConversationAccess({ conversationId: 'c1', userId: 'u1' }))
+ .rejects.toMatchObject({ code: 'FORBIDDEN', statusCode: 403 });
+ });
+
+ it('throws 404 when conversation is soft-deleted for user and allowSoftDeleted is false', async () => {
+ model.findConversationById.mockResolvedValue({ ...baseConversation, deleted_by_a: true });
+
+ await expect(service.assertConversationAccess({ conversationId: 'c1', userId: 'u1' }))
+ .rejects.toMatchObject({ code: 'CONVERSATION_NOT_FOUND', statusCode: 404 });
+ });
+
+ it('returns conversation when soft-deleted but allowSoftDeleted is true', async () => {
+ const conversation = { ...baseConversation, deleted_by_a: true };
+ model.findConversationById.mockResolvedValue(conversation);
+
+ await expect(
+ service.assertConversationAccess({ conversationId: 'c1', userId: 'u1', allowSoftDeleted: true })
+ ).resolves.toEqual(conversation);
+ });
+
+ it('returns conversation for active participant', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+
+ await expect(service.assertConversationAccess({ conversationId: 'c1', userId: 'u1' }))
+ .resolves.toEqual(baseConversation);
+ });
+ });
+
describe('startConversation', () => {
it('rejects when senderId is undefined', async () => {
await expect(
@@ -303,6 +342,30 @@ describe('messages.service', () => {
expect(model.restoreConversationForUser).toHaveBeenCalledWith('c1', false);
});
+ it('restores sender conversation when sender had deleted', async () => {
+ model.findConversationById.mockResolvedValue({ ...baseConversation, deleted_by_a: true });
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+ model.createMessage.mockResolvedValue({ id: 'm1' });
+
+ await service.sendMessage({ conversationId: 'c1', senderId: 'u1', body: 'x' });
+
+ expect(model.restoreConversationForUser).toHaveBeenCalledWith('c1', true);
+ });
+
+ it('restores both sides when both had deleted and a new message is sent', async () => {
+ model.findConversationById.mockResolvedValue({ ...baseConversation, deleted_by_a: true, deleted_by_b: true });
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+ model.createMessage.mockResolvedValue({ id: 'm1' });
+
+ await service.sendMessage({ conversationId: 'c1', senderId: 'u1', body: 'x' });
+
+ expect(model.restoreConversationForUser).toHaveBeenCalledTimes(2);
+ expect(model.restoreConversationForUser).toHaveBeenNthCalledWith(1, 'c1', true);
+ expect(model.restoreConversationForUser).toHaveBeenNthCalledWith(2, 'c1', false);
+ });
+
it('blocks on blocked recipient', async () => {
model.findConversationById.mockResolvedValue(baseConversation);
model.isBlocked.mockResolvedValue(true);
diff --git a/tests/messages.socket.unit.test.js b/tests/messages.socket.unit.test.js
index 137ac10..410f46d 100644
--- a/tests/messages.socket.unit.test.js
+++ b/tests/messages.socket.unit.test.js
@@ -1,7 +1,12 @@
-const db = require('../src/config/db');
+const messagesService = require('../src/services/messages.service');
const { registerMessageHandlers } = require('../src/sockets/messages.socket');
-jest.mock('../src/config/db', () => ({ query: jest.fn() }));
+jest.mock('../src/services/messages.service', () => ({
+ assertConversationAccess: jest.fn(),
+}));
+
+const VALID_CONVERSATION_ID = '22222222-2222-2222-2222-222222222222';
+const VALID_MESSAGE_ID = '33333333-3333-3333-3333-333333333333';
const mkSocket = (userId = '11111111-1111-1111-1111-111111111111') => {
const handlers = {};
@@ -17,7 +22,10 @@ const mkSocket = (userId = '11111111-1111-1111-1111-111111111111') => {
};
};
-beforeEach(() => jest.clearAllMocks());
+beforeEach(() => {
+ jest.clearAllMocks();
+ messagesService.assertConversationAccess.mockResolvedValue({ id: VALID_CONVERSATION_ID });
+});
describe('messages.socket', () => {
it('registers all message handlers', () => {
@@ -48,7 +56,7 @@ describe('messages.socket', () => {
await socket._handlers['message:join']();
expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid conversationId.' });
- expect(db.query).not.toHaveBeenCalled();
+ expect(messagesService.assertConversationAccess).not.toHaveBeenCalled();
});
it('message:join emits auth error when socket has no valid user', async () => {
@@ -56,41 +64,38 @@ describe('messages.socket', () => {
registerMessageHandlers({}, socket);
await socket._handlers['message:join']({
- conversationId: '22222222-2222-2222-2222-222222222222',
+ conversationId: VALID_CONVERSATION_ID,
});
expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Authentication required.' });
- expect(db.query).not.toHaveBeenCalled();
+ expect(messagesService.assertConversationAccess).not.toHaveBeenCalled();
});
it('message:join emits forbidden when not participant', async () => {
const socket = mkSocket();
registerMessageHandlers({}, socket);
- db.query.mockResolvedValue({ rows: [] });
+ messagesService.assertConversationAccess.mockRejectedValue({ code: 'FORBIDDEN' });
await socket._handlers['message:join']({
- conversationId: '22222222-2222-2222-2222-222222222222',
+ conversationId: VALID_CONVERSATION_ID,
});
expect(socket.emit).toHaveBeenCalledWith('error', {
message: 'You are not a participant in this conversation.',
});
- expect(db.query).toHaveBeenCalledWith(
- expect.stringContaining('SELECT 1 FROM conversations'),
- [
- '22222222-2222-2222-2222-222222222222',
- '11111111-1111-1111-1111-111111111111',
- ]
- );
+ expect(messagesService.assertConversationAccess).toHaveBeenCalledWith({
+ conversationId: VALID_CONVERSATION_ID,
+ userId: '11111111-1111-1111-1111-111111111111',
+ });
});
- it('message:join handles db failure', async () => {
+ it('message:join handles service failure', async () => {
const socket = mkSocket();
registerMessageHandlers({}, socket);
- db.query.mockRejectedValue(new Error('db down'));
+ messagesService.assertConversationAccess.mockRejectedValue(new Error('service down'));
await socket._handlers['message:join']({
- conversationId: '22222222-2222-2222-2222-222222222222',
+ conversationId: VALID_CONVERSATION_ID,
});
expect(socket.emit).toHaveBeenCalledWith('error', {
@@ -101,153 +106,167 @@ describe('messages.socket', () => {
it('message:join joins room when authorized', async () => {
const socket = mkSocket();
registerMessageHandlers({}, socket);
- db.query.mockResolvedValue({ rows: [{ ok: 1 }] });
await socket._handlers['message:join']({
- conversationId: '22222222-2222-2222-2222-222222222222',
+ conversationId: VALID_CONVERSATION_ID,
});
expect(socket.join).toHaveBeenCalledWith(
- 'conversation:22222222-2222-2222-2222-222222222222'
+ `conversation:${VALID_CONVERSATION_ID}`
);
- expect(db.query).toHaveBeenCalledTimes(1);
+ expect(messagesService.assertConversationAccess).toHaveBeenCalledTimes(1);
});
- it('message:leave leaves room', () => {
+ it('message:leave leaves room', async () => {
const socket = mkSocket();
registerMessageHandlers({}, socket);
- socket._handlers['message:leave']({ conversationId: 'c1' });
- expect(socket.leave).toHaveBeenCalledWith('conversation:c1');
+ await socket._handlers['message:leave']({ conversationId: VALID_CONVERSATION_ID });
+ expect(socket.leave).toHaveBeenCalledWith(`conversation:${VALID_CONVERSATION_ID}`);
});
- it('message:leave rejects malformed payload', () => {
+ it('message:leave rejects malformed payload', async () => {
const socket = mkSocket();
registerMessageHandlers({}, socket);
- socket._handlers['message:leave']({});
+ await socket._handlers['message:leave']({});
- expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid payload.' });
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid conversationId.' });
expect(socket.leave).not.toHaveBeenCalled();
});
- it('message:leave rejects unauthenticated socket', () => {
+ it('message:leave rejects unauthenticated socket', async () => {
const socket = mkSocket(null);
registerMessageHandlers({}, socket);
- socket._handlers['message:leave']({ conversationId: 'c1' });
+ await socket._handlers['message:leave']({ conversationId: VALID_CONVERSATION_ID });
expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Authentication required.' });
expect(socket.leave).not.toHaveBeenCalled();
});
- it('message:send broadcasts received', () => {
+ it('message:send broadcasts received', async () => {
const socket = mkSocket();
const emit = jest.fn();
socket.to.mockReturnValue({ emit });
registerMessageHandlers({}, socket);
- socket._handlers['message:send']({ conversationId: 'c1', message: { id: 'm1' } });
+ await socket._handlers['message:send']({ conversationId: VALID_CONVERSATION_ID, message: { id: 'm1' } });
- expect(socket.to).toHaveBeenCalledWith('conversation:c1');
+ expect(socket.to).toHaveBeenCalledWith(`conversation:${VALID_CONVERSATION_ID}`);
expect(emit).toHaveBeenCalledWith('message:received', {
- conversationId: 'c1',
+ conversationId: VALID_CONVERSATION_ID,
message: { id: 'm1' },
});
});
- it('message:send rejects malformed payload', () => {
+ it('message:send blocks non-participant users', async () => {
const socket = mkSocket();
const emit = jest.fn();
socket.to.mockReturnValue({ emit });
registerMessageHandlers({}, socket);
+ messagesService.assertConversationAccess.mockRejectedValue({ code: 'FORBIDDEN' });
+
+ await socket._handlers['message:send']({ conversationId: VALID_CONVERSATION_ID, message: { id: 'm1' } });
- socket._handlers['message:send']({ conversationId: 'c1' });
+ expect(socket.emit).toHaveBeenCalledWith('error', {
+ message: 'You are not a participant in this conversation.',
+ });
+ expect(emit).not.toHaveBeenCalled();
+ });
+
+ it('message:send rejects malformed payload', async () => {
+ const socket = mkSocket();
+ const emit = jest.fn();
+ socket.to.mockReturnValue({ emit });
+ registerMessageHandlers({}, socket);
+
+ await socket._handlers['message:send']({ conversationId: VALID_CONVERSATION_ID });
expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid payload.' });
expect(emit).not.toHaveBeenCalled();
});
- it('message:send rejects unauthenticated socket', () => {
+ it('message:send rejects unauthenticated socket', async () => {
const socket = mkSocket(null);
const emit = jest.fn();
socket.to.mockReturnValue({ emit });
registerMessageHandlers({}, socket);
- socket._handlers['message:send']({ conversationId: 'c1', message: { id: 'm1' } });
+ await socket._handlers['message:send']({ conversationId: VALID_CONVERSATION_ID, message: { id: 'm1' } });
expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Authentication required.' });
expect(emit).not.toHaveBeenCalled();
});
- it('message:deleted broadcasts removed', () => {
+ it('message:deleted broadcasts removed', async () => {
const socket = mkSocket();
const emit = jest.fn();
socket.to.mockReturnValue({ emit });
registerMessageHandlers({}, socket);
- socket._handlers['message:deleted']({ conversationId: 'c1', messageId: 'm1' });
+ await socket._handlers['message:deleted']({ conversationId: VALID_CONVERSATION_ID, messageId: VALID_MESSAGE_ID });
expect(emit).toHaveBeenCalledWith('message:removed', {
- conversationId: 'c1',
- messageId: 'm1',
+ conversationId: VALID_CONVERSATION_ID,
+ messageId: VALID_MESSAGE_ID,
});
});
- it('message:deleted rejects malformed payload', () => {
+ it('message:deleted rejects malformed payload', async () => {
const socket = mkSocket();
const emit = jest.fn();
socket.to.mockReturnValue({ emit });
registerMessageHandlers({}, socket);
- socket._handlers['message:deleted']({ conversationId: 'c1' });
+ await socket._handlers['message:deleted']({ conversationId: VALID_CONVERSATION_ID });
expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid payload.' });
expect(emit).not.toHaveBeenCalled();
});
- it('message:deleted rejects unauthenticated socket', () => {
+ it('message:deleted rejects unauthenticated socket', async () => {
const socket = mkSocket(null);
const emit = jest.fn();
socket.to.mockReturnValue({ emit });
registerMessageHandlers({}, socket);
- socket._handlers['message:deleted']({ conversationId: 'c1', messageId: 'm1' });
+ await socket._handlers['message:deleted']({ conversationId: VALID_CONVERSATION_ID, messageId: VALID_MESSAGE_ID });
expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Authentication required.' });
expect(emit).not.toHaveBeenCalled();
});
- it('message:read broadcasts read_updated', () => {
+ it('message:read broadcasts read_updated', async () => {
const socket = mkSocket();
const emit = jest.fn();
socket.to.mockReturnValue({ emit });
registerMessageHandlers({}, socket);
- socket._handlers['message:read']({
- conversationId: 'c1',
- messageId: 'm1',
+ await socket._handlers['message:read']({
+ conversationId: VALID_CONVERSATION_ID,
+ messageId: VALID_MESSAGE_ID,
isRead: true,
conversationUnreadCount: 0,
});
expect(emit).toHaveBeenCalledWith('message:read_updated', {
- conversationId: 'c1',
- messageId: 'm1',
+ conversationId: VALID_CONVERSATION_ID,
+ messageId: VALID_MESSAGE_ID,
isRead: true,
conversationUnreadCount: 0,
});
});
- it('message:read rejects malformed payload', () => {
+ it('message:read rejects malformed payload', async () => {
const socket = mkSocket();
const emit = jest.fn();
socket.to.mockReturnValue({ emit });
registerMessageHandlers({}, socket);
- socket._handlers['message:read']({
- conversationId: 'c1',
- messageId: 'm1',
+ await socket._handlers['message:read']({
+ conversationId: VALID_CONVERSATION_ID,
+ messageId: VALID_MESSAGE_ID,
isRead: 'yes',
conversationUnreadCount: 0,
});
@@ -256,15 +275,15 @@ describe('messages.socket', () => {
expect(emit).not.toHaveBeenCalled();
});
- it('message:read rejects unauthenticated socket', () => {
+ it('message:read rejects unauthenticated socket', async () => {
const socket = mkSocket(null);
const emit = jest.fn();
socket.to.mockReturnValue({ emit });
registerMessageHandlers({}, socket);
- socket._handlers['message:read']({
- conversationId: 'c1',
- messageId: 'm1',
+ await socket._handlers['message:read']({
+ conversationId: VALID_CONVERSATION_ID,
+ messageId: VALID_MESSAGE_ID,
isRead: true,
conversationUnreadCount: 0,
});
@@ -273,78 +292,78 @@ describe('messages.socket', () => {
expect(emit).not.toHaveBeenCalled();
});
- it('typing events broadcast', () => {
+ it('typing events broadcast', async () => {
const socket = mkSocket();
const emit = jest.fn();
socket.to.mockReturnValue({ emit });
registerMessageHandlers({}, socket);
- socket._handlers['message:typing']({ conversationId: 'c1' });
- expect(emit).toHaveBeenCalledWith('message:typing', { conversationId: 'c1', userId: socket.user.sub });
+ await socket._handlers['message:typing']({ conversationId: VALID_CONVERSATION_ID });
+ expect(emit).toHaveBeenCalledWith('message:typing', { conversationId: VALID_CONVERSATION_ID, userId: socket.user.sub });
- socket._handlers['message:stop_typing']({ conversationId: 'c1' });
- expect(emit).toHaveBeenCalledWith('message:stop_typing', { conversationId: 'c1', userId: socket.user.sub });
+ await socket._handlers['message:stop_typing']({ conversationId: VALID_CONVERSATION_ID });
+ expect(emit).toHaveBeenCalledWith('message:stop_typing', { conversationId: VALID_CONVERSATION_ID, userId: socket.user.sub });
});
- it('typing events reject malformed payload', () => {
+ it('typing events reject malformed payload', async () => {
const socket = mkSocket();
const emit = jest.fn();
socket.to.mockReturnValue({ emit });
registerMessageHandlers({}, socket);
- socket._handlers['message:typing']({});
- socket._handlers['message:stop_typing']({});
+ await socket._handlers['message:typing']({});
+ await socket._handlers['message:stop_typing']({});
- expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid payload.' });
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid conversationId.' });
expect(emit).not.toHaveBeenCalled();
});
- it('typing events reject unauthenticated socket', () => {
+ it('typing events reject unauthenticated socket', async () => {
const socket = mkSocket(null);
const emit = jest.fn();
socket.to.mockReturnValue({ emit });
registerMessageHandlers({}, socket);
- socket._handlers['message:typing']({ conversationId: 'c1' });
- socket._handlers['message:stop_typing']({ conversationId: 'c1' });
+ await socket._handlers['message:typing']({ conversationId: VALID_CONVERSATION_ID });
+ await socket._handlers['message:stop_typing']({ conversationId: VALID_CONVERSATION_ID });
expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Authentication required.' });
expect(emit).not.toHaveBeenCalled();
});
- it('typing events reject whitespace conversationId', () => {
+ it('typing events reject whitespace conversationId', async () => {
const socket = mkSocket();
const emit = jest.fn();
socket.to.mockReturnValue({ emit });
registerMessageHandlers({}, socket);
- socket._handlers['message:typing']({ conversationId: ' ' });
- socket._handlers['message:stop_typing']({ conversationId: ' ' });
+ await socket._handlers['message:typing']({ conversationId: ' ' });
+ await socket._handlers['message:stop_typing']({ conversationId: ' ' });
- expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid payload.' });
+ expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Invalid conversationId.' });
expect(emit).not.toHaveBeenCalled();
});
- it('handlers with default payload reject when called without arguments', () => {
+ it('handlers with default payload reject when called without arguments', async () => {
const socket = mkSocket();
const emit = jest.fn();
socket.to.mockReturnValue({ emit });
registerMessageHandlers({}, socket);
- socket._handlers['message:leave']();
- socket._handlers['message:send']();
- socket._handlers['message:deleted']();
- socket._handlers['message:read']();
- socket._handlers['message:typing']();
- socket._handlers['message:stop_typing']();
+ await socket._handlers['message:leave']();
+ await socket._handlers['message:send']();
+ await socket._handlers['message:deleted']();
+ await socket._handlers['message:read']();
+ await socket._handlers['message:typing']();
+ await socket._handlers['message:stop_typing']();
expect(socket.emit).toHaveBeenCalledTimes(6);
- expect(socket.emit).toHaveBeenNthCalledWith(1, 'error', { message: 'Invalid payload.' });
+ expect(socket.emit).toHaveBeenNthCalledWith(1, 'error', { message: 'Invalid conversationId.' });
expect(socket.emit).toHaveBeenNthCalledWith(2, 'error', { message: 'Invalid payload.' });
expect(socket.emit).toHaveBeenNthCalledWith(3, 'error', { message: 'Invalid payload.' });
expect(socket.emit).toHaveBeenNthCalledWith(4, 'error', { message: 'Invalid payload.' });
- expect(socket.emit).toHaveBeenNthCalledWith(5, 'error', { message: 'Invalid payload.' });
- expect(socket.emit).toHaveBeenNthCalledWith(6, 'error', { message: 'Invalid payload.' });
+ expect(socket.emit).toHaveBeenNthCalledWith(5, 'error', { message: 'Invalid conversationId.' });
+ expect(socket.emit).toHaveBeenNthCalledWith(6, 'error', { message: 'Invalid conversationId.' });
expect(emit).not.toHaveBeenCalled();
});
});
\ No newline at end of file
From 6c362d8092de352171fe4ffae55854ba00950c52 Mon Sep 17 00:00:00 2001
From: Mohamed jameel alabasy
Date: Fri, 20 Mar 2026 20:15:35 +0200
Subject: [PATCH 078/535] feat(fixed azurite connection)
---
app.js | 78 +++++++++++++++++++--------------
src/config/blob.js | 12 ++---
src/services/storage.service.js | 14 ++++++
3 files changed, 62 insertions(+), 42 deletions(-)
diff --git a/app.js b/app.js
index 8a8ae2d..1a66f56 100644
--- a/app.js
+++ b/app.js
@@ -10,59 +10,71 @@ const errorHandler = require('./src/middleware/error-handler');
const cookieParser = require('cookie-parser');
// ── Route imports ──────────────────────────────────────────
-const authRoutes = require('./src/routes/auth.routes');
-const usersRoutes = require('./src/routes/users.routes');
-const followersRoutes = require('./src/routes/followers.routes');
-const tracksRoutes = require('./src/routes/tracks.routes');
-const playbackRoutes = require('./src/routes/playback.routes');
-const engagementRoutes = require('./src/routes/engagement.routes');
-const playlistsRoutes = require('./src/routes/playlists.routes');
-const feedRoutes = require('./src/routes/feed.routes');
-const messagesRoutes = require('./src/routes/messages.routes');
+const authRoutes = require('./src/routes/auth.routes');
+const usersRoutes = require('./src/routes/users.routes');
+const followersRoutes = require('./src/routes/followers.routes');
+const tracksRoutes = require('./src/routes/tracks.routes');
+const playbackRoutes = require('./src/routes/playback.routes');
+const engagementRoutes = require('./src/routes/engagement.routes');
+const playlistsRoutes = require('./src/routes/playlists.routes');
+const feedRoutes = require('./src/routes/feed.routes');
+const messagesRoutes = require('./src/routes/messages.routes');
const notificationsRoutes = require('./src/routes/notifications.routes');
-const adminRoutes = require('./src/routes/admin.routes');
+const adminRoutes = require('./src/routes/admin.routes');
const subscriptionsRoutes = require('./src/routes/subscriptions.routes');
+const { initBlobContainers } = require('./src/services/storage.service');
const app = express();
// ── Global middleware ──────────────────────────────────────
app.use(helmet());
-const allowedOrigins = env.CLIENT_URL.split(',').map(o => o.trim());
+const allowedOrigins = env.CLIENT_URL.split(',').map((o) => o.trim());
-app.use(cors({
- origin: (origin, callback) => {
- if (!origin || allowedOrigins.includes(origin)) {
- callback(null, true);
- } else {
- callback(new Error('Not allowed by CORS'));
- }
- },
- credentials: true
-}));
+app.use(
+ cors({
+ origin: (origin, callback) => {
+ if (!origin || allowedOrigins.includes(origin)) {
+ callback(null, true);
+ } else {
+ callback(new Error('Not allowed by CORS'));
+ }
+ },
+ credentials: true,
+ })
+);
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(generalLimiter);
app.use(cookieParser());
+app.use(cookieParser());
+
+// ── Initialize Blob Storage ───────────────────────────────
+initBlobContainers()
+ .then(() => console.log('Storage ready'))
+ .catch((err) => {
+ console.error('Storage init failed:', err);
+ });
+
// ── Health check ───────────────────────────────────────────
app.get('/health', (req, res) => res.json({ status: 'ok', env: env.NODE_ENV }));
// ── API Routes — /api/v1 ───────────────────────────────────
const API = '/api/v1';
-app.use(`${API}/auth`, authRoutes); // Module 1 — BE-1 Omar Hamdy
-app.use(`${API}/users`, usersRoutes); // Module 2 — BE-1 Omar Hamdy
-app.use(`${API}/users`, followersRoutes); // Module 3 — BE-3 Beshoy Maher
-app.use(`${API}/tracks`, tracksRoutes); // Module 4 — BE-2 Saja
-app.use(`${API}/me`, playbackRoutes); // Module 5 — BE-2 Saja
-app.use(`${API}`, engagementRoutes); // Module 6 — BE-3 Beshoy Maher
-app.use(`${API}/playlists`, playlistsRoutes); // Module 7 — BE-4 Alyaa
-app.use(`${API}`, feedRoutes); // Module 8 — BE-5 Omar Hamza
-app.use(`${API}/messages`, messagesRoutes); // Module 9 — BE-4 Alyaa
-app.use(`${API}/notifications`, notificationsRoutes); // Module 10 — BE-4 Alyaa
-app.use(`${API}`, adminRoutes); // Module 11 — BE-5 Omar Hamza
-app.use(`${API}/subscriptions`, subscriptionsRoutes); // Module 12 — BE-1 Omar Hamdy
+app.use(`${API}/auth`, authRoutes); // Module 1 — BE-1 Omar Hamdy
+app.use(`${API}/users`, usersRoutes); // Module 2 — BE-1 Omar Hamdy
+app.use(`${API}/users`, followersRoutes); // Module 3 — BE-3 Beshoy Maher
+app.use(`${API}/tracks`, tracksRoutes); // Module 4 — BE-2 Saja
+app.use(`${API}/me`, playbackRoutes); // Module 5 — BE-2 Saja
+app.use(`${API}`, engagementRoutes); // Module 6 — BE-3 Beshoy Maher
+app.use(`${API}/playlists`, playlistsRoutes); // Module 7 — BE-4 Alyaa
+app.use(`${API}`, feedRoutes); // Module 8 — BE-5 Omar Hamza
+app.use(`${API}/messages`, messagesRoutes); // Module 9 — BE-4 Alyaa
+app.use(`${API}/notifications`, notificationsRoutes); // Module 10 — BE-4 Alyaa
+app.use(`${API}`, adminRoutes); // Module 11 — BE-5 Omar Hamza
+app.use(`${API}/subscriptions`, subscriptionsRoutes); // Module 12 — BE-1 Omar Hamdy
// ── Centralised error handler (must be last) ───────────────
app.use(errorHandler);
diff --git a/src/config/blob.js b/src/config/blob.js
index 02321af..0450eec 100644
--- a/src/config/blob.js
+++ b/src/config/blob.js
@@ -1,14 +1,8 @@
-const { BlobServiceClient, StorageSharedKeyCredential } = require('@azure/storage-blob');
+const { BlobServiceClient } = require('@azure/storage-blob');
const env = require('./env');
-const account = 'devstoreaccount1';
-const accountKey = 'Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OcuhHHH4a+GCKoV9wJ5Lw==';
-
-const sharedKeyCredential = new StorageSharedKeyCredential(account, accountKey);
-
-const blobServiceClient = new BlobServiceClient(
- `http://rythmify_blob:10000/${account}`,
- sharedKeyCredential
+const blobServiceClient = BlobServiceClient.fromConnectionString(
+ env.AZURE_STORAGE_CONNECTION_STRING
);
async function initBlobContainers() {
diff --git a/src/services/storage.service.js b/src/services/storage.service.js
index e91fee7..5d52614 100644
--- a/src/services/storage.service.js
+++ b/src/services/storage.service.js
@@ -17,6 +17,18 @@ const getContainerClient = (typeOrName) => {
return blobServiceClient.getContainerClient(containerName);
};
+const initBlobContainers = async () => {
+ const audioContainer = getContainerClient('audio');
+ await audioContainer.createIfNotExists();
+
+ const mediaContainer = getContainerClient('media');
+ await mediaContainer.createIfNotExists({
+ access: 'blob',
+ });
+
+ console.log('Blob containers initialized');
+};
+
const uploadBlob = async (file, key, type) => {
const containerClient = getContainerClient(type);
await containerClient.createIfNotExists();
@@ -103,6 +115,8 @@ const deleteManyByUrls = async (urls = []) => {
};
module.exports = {
+ blobServiceClient,
+ initBlobContainers,
uploadTrack,
uploadImage,
getKeyFromUrl,
From 5b287283bd90ef00c21267050dad03003230ae04 Mon Sep 17 00:00:00 2001
From: Beshoy205
Date: Fri, 20 Mar 2026 22:37:59 +0200
Subject: [PATCH 079/535] fix: resolve CORS, Azurite connection, and
auto-create blob containers
Fix CORS policy blocking preflight OPTIONS requests from frontend origins
by reordering middleware (CORS before rate limiter) and adding explicit
app.options('*', cors()) handler
Fix Azurite connection issue preventing local blob storage from initializing
correctly in the development environment
Auto-create required blob containers (audio, media) on server startup
instead of relying on manual creation via Azure Storage Explorer
---
app.js | 80 +++++++++++++++++++--------------
server.js | 9 +++-
src/config/blob.js | 14 ++----
src/services/storage.service.js | 49 +++++++++-----------
4 files changed, 77 insertions(+), 75 deletions(-)
diff --git a/app.js b/app.js
index 8a8ae2d..88c8dee 100644
--- a/app.js
+++ b/app.js
@@ -10,61 +10,73 @@ const errorHandler = require('./src/middleware/error-handler');
const cookieParser = require('cookie-parser');
// ── Route imports ──────────────────────────────────────────
-const authRoutes = require('./src/routes/auth.routes');
-const usersRoutes = require('./src/routes/users.routes');
-const followersRoutes = require('./src/routes/followers.routes');
-const tracksRoutes = require('./src/routes/tracks.routes');
-const playbackRoutes = require('./src/routes/playback.routes');
-const engagementRoutes = require('./src/routes/engagement.routes');
-const playlistsRoutes = require('./src/routes/playlists.routes');
-const feedRoutes = require('./src/routes/feed.routes');
-const messagesRoutes = require('./src/routes/messages.routes');
+const authRoutes = require('./src/routes/auth.routes');
+const usersRoutes = require('./src/routes/users.routes');
+const followersRoutes = require('./src/routes/followers.routes');
+const tracksRoutes = require('./src/routes/tracks.routes');
+const playbackRoutes = require('./src/routes/playback.routes');
+const engagementRoutes = require('./src/routes/engagement.routes');
+const playlistsRoutes = require('./src/routes/playlists.routes');
+const feedRoutes = require('./src/routes/feed.routes');
+const messagesRoutes = require('./src/routes/messages.routes');
const notificationsRoutes = require('./src/routes/notifications.routes');
-const adminRoutes = require('./src/routes/admin.routes');
+const adminRoutes = require('./src/routes/admin.routes');
const subscriptionsRoutes = require('./src/routes/subscriptions.routes');
+const { initBlobContainers } = require('./src/services/storage.service');
const app = express();
// ── Global middleware ──────────────────────────────────────
app.use(helmet());
-const allowedOrigins = env.CLIENT_URL.split(',').map(o => o.trim());
+const allowedOrigins = env.CLIENT_URL.split(',').map((o) => o.trim());
-app.use(cors({
- origin: (origin, callback) => {
- if (!origin || allowedOrigins.includes(origin)) {
- callback(null, true);
- } else {
- callback(new Error('Not allowed by CORS'));
- }
- },
- credentials: true
-}));
+app.use(
+ cors({
+ origin: (origin, callback) => {
+ if (!origin || allowedOrigins.includes(origin)) {
+ callback(null, true);
+ } else {
+ callback(new Error('Not allowed by CORS'));
+ }
+ },
+ credentials: true,
+ })
+);
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(generalLimiter);
app.use(cookieParser());
+app.use(cookieParser());
+
+// ── Initialize Blob Storage ───────────────────────────────
+initBlobContainers()
+ .then(() => console.log('Storage ready'))
+ .catch((err) => {
+ console.error('Storage init failed:', err);
+ });
+
// ── Health check ───────────────────────────────────────────
app.get('/health', (req, res) => res.json({ status: 'ok', env: env.NODE_ENV }));
// ── API Routes — /api/v1 ───────────────────────────────────
const API = '/api/v1';
-app.use(`${API}/auth`, authRoutes); // Module 1 — BE-1 Omar Hamdy
-app.use(`${API}/users`, usersRoutes); // Module 2 — BE-1 Omar Hamdy
-app.use(`${API}/users`, followersRoutes); // Module 3 — BE-3 Beshoy Maher
-app.use(`${API}/tracks`, tracksRoutes); // Module 4 — BE-2 Saja
-app.use(`${API}/me`, playbackRoutes); // Module 5 — BE-2 Saja
-app.use(`${API}`, engagementRoutes); // Module 6 — BE-3 Beshoy Maher
-app.use(`${API}/playlists`, playlistsRoutes); // Module 7 — BE-4 Alyaa
-app.use(`${API}`, feedRoutes); // Module 8 — BE-5 Omar Hamza
-app.use(`${API}/messages`, messagesRoutes); // Module 9 — BE-4 Alyaa
-app.use(`${API}/notifications`, notificationsRoutes); // Module 10 — BE-4 Alyaa
-app.use(`${API}`, adminRoutes); // Module 11 — BE-5 Omar Hamza
-app.use(`${API}/subscriptions`, subscriptionsRoutes); // Module 12 — BE-1 Omar Hamdy
+app.use(`${API}/auth`, authRoutes); // Module 1 — BE-1 Omar Hamdy
+app.use(`${API}/users`, usersRoutes); // Module 2 — BE-1 Omar Hamdy
+app.use(`${API}/users`, followersRoutes); // Module 3 — BE-3 Beshoy Maher
+app.use(`${API}/tracks`, tracksRoutes); // Module 4 — BE-2 Saja
+app.use(`${API}/me`, playbackRoutes); // Module 5 — BE-2 Saja
+app.use(`${API}`, engagementRoutes); // Module 6 — BE-3 Beshoy Maher
+app.use(`${API}/playlists`, playlistsRoutes); // Module 7 — BE-4 Alyaa
+app.use(`${API}`, feedRoutes); // Module 8 — BE-5 Omar Hamza
+app.use(`${API}/messages`, messagesRoutes); // Module 9 — BE-4 Alyaa
+app.use(`${API}/notifications`, notificationsRoutes); // Module 10 — BE-4 Alyaa
+app.use(`${API}`, adminRoutes); // Module 11 — BE-5 Omar Hamza
+app.use(`${API}/subscriptions`, subscriptionsRoutes); // Module 12 — BE-1 Omar Hamdy
// ── Centralised error handler (must be last) ───────────────
app.use(errorHandler);
-module.exports = app;
+module.exports = app;
\ No newline at end of file
diff --git a/server.js b/server.js
index e7b5d18..606c5ef 100644
--- a/server.js
+++ b/server.js
@@ -10,8 +10,13 @@ const { registerMessageHandlers } = require('./src/sockets/messages.socket');
const server = http.createServer(app);
+const allowedOrigins = Array.from(new Set([
+ ...env.CLIENT_URL.split(',').map(o => o.trim()),
+ env.APP_URL,
+].filter(Boolean)));
+
const io = new Server(server, {
- cors: { origin: env.CLIENT_URL, methods: ['GET', 'POST'], credentials: true },
+ cors: { origin: allowedOrigins, methods: ['GET', 'POST'], credentials: true },
});
io.on('connection', (socket) => {
@@ -29,4 +34,4 @@ server.listen(env.PORT, () => {
console.log(`Rythmify backend running on port ${env.PORT} [${env.NODE_ENV}]`);
console.log(`API Base URL: http://localhost:${env.PORT}/api/v1`);
console.log('Note: Create blob containers manually via Azure Storage Explorer');
-});
+});
\ No newline at end of file
diff --git a/src/config/blob.js b/src/config/blob.js
index 02321af..e139959 100644
--- a/src/config/blob.js
+++ b/src/config/blob.js
@@ -1,14 +1,8 @@
-const { BlobServiceClient, StorageSharedKeyCredential } = require('@azure/storage-blob');
+const { BlobServiceClient } = require('@azure/storage-blob');
const env = require('./env');
-const account = 'devstoreaccount1';
-const accountKey = 'Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OcuhHHH4a+GCKoV9wJ5Lw==';
-
-const sharedKeyCredential = new StorageSharedKeyCredential(account, accountKey);
-
-const blobServiceClient = new BlobServiceClient(
- `http://rythmify_blob:10000/${account}`,
- sharedKeyCredential
+const blobServiceClient = BlobServiceClient.fromConnectionString(
+ env.AZURE_STORAGE_CONNECTION_STRING
);
async function initBlobContainers() {
@@ -21,4 +15,4 @@ async function initBlobContainers() {
console.log('Blob containers initialized');
}
-module.exports = { blobServiceClient, initBlobContainers };
+module.exports = { blobServiceClient, initBlobContainers };
\ No newline at end of file
diff --git a/src/services/storage.service.js b/src/services/storage.service.js
index 16d4712..95263df 100644
--- a/src/services/storage.service.js
+++ b/src/services/storage.service.js
@@ -6,33 +6,29 @@ const blobServiceClient = BlobServiceClient.fromConnectionString(
env.AZURE_STORAGE_CONNECTION_STRING
);
-// Helper function to build public blob URL
-const buildPublicBlobUrl = (containerName, key) => {
- const baseUrl = env.PUBLIC_BLOB_BASE_URL?.replace(/\/+$/, '');
- const encodedKey = key.split('/').map(encodeURIComponent).join('/');
-
- if (!baseUrl) {
- return null;
- }
-
- return `${baseUrl}/${containerName}/${encodedKey}`;
-};
-
const getContainerName = (type) => {
- return type === 'audio'
- ? env.BLOB_CONTAINER_AUDIO
- : env.BLOB_CONTAINER_MEDIA;
+ return type === 'audio' ? env.BLOB_CONTAINER_AUDIO : env.BLOB_CONTAINER_MEDIA;
};
const getContainerClient = (typeOrName) => {
const containerName =
- typeOrName === 'audio' || typeOrName === 'media'
- ? getContainerName(typeOrName)
- : typeOrName;
+ typeOrName === 'audio' || typeOrName === 'media' ? getContainerName(typeOrName) : typeOrName;
return blobServiceClient.getContainerClient(containerName);
};
+const initBlobContainers = async () => {
+ const audioContainer = getContainerClient('audio');
+ await audioContainer.createIfNotExists();
+
+ const mediaContainer = getContainerClient('media');
+ await mediaContainer.createIfNotExists({
+ access: 'blob',
+ });
+
+ console.log('Blob containers initialized');
+};
+
const uploadBlob = async (file, key, type) => {
const containerClient = getContainerClient(type);
await containerClient.createIfNotExists();
@@ -48,7 +44,7 @@ const uploadBlob = async (file, key, type) => {
return {
key,
url: blockBlobClient.url,
- versionId: null,
+ versionId: null,
};
};
@@ -64,18 +60,11 @@ const parseAzureBlobUrl = (fileUrl) => {
if (!fileUrl) return null;
const url = new URL(fileUrl);
- const pathSegments = decodeURIComponent(url.pathname)
- .split('/')
- .filter(Boolean);
+ const pathSegments = decodeURIComponent(url.pathname).split('/').filter(Boolean);
- const knownContainers = [
- env.BLOB_CONTAINER_AUDIO,
- env.BLOB_CONTAINER_MEDIA,
- ].filter(Boolean);
+ const knownContainers = [env.BLOB_CONTAINER_AUDIO, env.BLOB_CONTAINER_MEDIA].filter(Boolean);
- const containerIndex = pathSegments.findIndex((segment) =>
- knownContainers.includes(segment)
- );
+ const containerIndex = pathSegments.findIndex((segment) => knownContainers.includes(segment));
if (containerIndex === -1 || containerIndex === pathSegments.length - 1) {
throw new Error(`Invalid Azure blob URL: ${fileUrl}`);
@@ -126,6 +115,8 @@ const deleteManyByUrls = async (urls = []) => {
};
module.exports = {
+ blobServiceClient,
+ initBlobContainers,
uploadTrack,
uploadImage,
getKeyFromUrl,
From 929058aff00dfb393b59343da524870a9e5c3bec Mon Sep 17 00:00:00 2001
From: Omar Hamza
Date: Sat, 21 Mar 2026 15:42:02 +0200
Subject: [PATCH 080/535] feat(app): harden CORS, init blob storage on startup,
and refine following search endpoint
- Deduplicate allowed origins and include APP_URL in CORS allowlist
- Handle preflight OPTIONS before rate limiter and ensure correct middleware order (cookies before CORS)
- Initialize blob storage containers on app startup with logging
- Update following endpoint docs:
- Use GET /users/me/following (REST-aligned)
- Clarify search behavior, min query length, and empty query handling
- Improve operation naming and response descriptions
---
app.js | 20 +++++++++++++++++---
openapi.yaml | 28 +++++++++++++++-------------
2 files changed, 32 insertions(+), 16 deletions(-)
diff --git a/app.js b/app.js
index 8a8ae2d..6a0ec28 100644
--- a/app.js
+++ b/app.js
@@ -22,13 +22,17 @@ const messagesRoutes = require('./src/routes/messages.routes');
const notificationsRoutes = require('./src/routes/notifications.routes');
const adminRoutes = require('./src/routes/admin.routes');
const subscriptionsRoutes = require('./src/routes/subscriptions.routes');
+const { initBlobContainers } = require('./src/services/storage.service');
const app = express();
// ── Global middleware ──────────────────────────────────────
app.use(helmet());
-const allowedOrigins = env.CLIENT_URL.split(',').map(o => o.trim());
+const allowedOrigins = Array.from(new Set([
+ ...env.CLIENT_URL.split(',').map(o => o.trim()),
+ env.APP_URL,
+].filter(Boolean)));
app.use(cors({
origin: (origin, callback) => {
@@ -41,10 +45,20 @@ app.use(cors({
credentials: true
}));
+// ✅ Explicitly handle preflight OPTIONS requests before any limiter
+app.options('*', cors());
+
+app.use(cookieParser());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
+
+// ✅ Rate limiter comes after CORS so preflight is never blocked
app.use(generalLimiter);
-app.use(cookieParser());
+
+// ── Initialize Blob Storage ───────────────────────────────
+initBlobContainers()
+ .then(() => console.log('Storage ready'))
+ .catch((err) => console.error('Storage init failed:', err));
// ── Health check ───────────────────────────────────────────
app.get('/health', (req, res) => res.json({ status: 'ok', env: env.NODE_ENV }));
@@ -67,4 +81,4 @@ app.use(`${API}/subscriptions`, subscriptionsRoutes); // Module 12 — BE-1 Oma
// ── Centralised error handler (must be last) ───────────────
app.use(errorHandler);
-module.exports = app;
+module.exports = app;
\ No newline at end of file
diff --git a/openapi.yaml b/openapi.yaml
index eccc4de..8dab296 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -5715,43 +5715,45 @@ paths:
"401":
$ref: "#/components/responses/Unauthorized"
- /users/me/following/search:
+ /users/me/following:
get:
tags: [Follow]
- summary: Search within my following list
+ summary: Get or search my following list
description: |
- Searches users that the authenticated user follows, filtered by a
- display_name or username query.
+ Returns the list of users that the authenticated user follows.
+ Optionally filtered by `q` to match `display_name` or `username`
+ (case-insensitive).
**Use case:** Starting a new chat — the frontend lets users pick from
- their following list with real-time search filtering.
+ their following list with real-time search filtering by passing `?q=`.
**Query rules:**
- - Returns users whose `display_name` OR `username` contains the query string (case-insensitive).
- - Empty or whitespace-only query returns all following (paginated).
- - Minimum query length: 1 character (unlike global search which requires 2).
+ - Without `q`, returns all following (paginated).
+ - With `q`, returns users whose `display_name` OR `username` contains
+ the query string (case-insensitive).
+ - Minimum query length: 1 character.
**Pagination:**
- Default limit: 10 (max 50).
- Results are sorted alphabetically by `display_name`.
- operationId: searchMyFollowing
+ operationId: getMyFollowing
security:
- bearerAuth: []
parameters:
- name: q
in: query
required: false
- description: Search query to filter following list by display name or username.
+ description: Filter following list by display name or username.
schema:
type: string
- minLength: 0
+ minLength: 1
maxLength: 100
example: ahmed
- $ref: "#/components/parameters/Limit"
- $ref: "#/components/parameters/Offset"
responses:
"200":
- description: Search results from following list returned.
+ description: Following list returned (filtered if `q` is provided).
content:
application/json:
schema:
@@ -10670,4 +10672,4 @@ components:
type: integer
minimum: 0
default: 0
- example: 0
+ example: 0
\ No newline at end of file
From 70545381967e36247b6d0d15fe717946127abd55 Mon Sep 17 00:00:00 2001
From: Beshoy205
Date: Sat, 21 Mar 2026 16:11:29 +0200
Subject: [PATCH 081/535] test(users): add complete unit tests for users module
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- GET /me — 401 if no token, 200 with user profile
- PATCH /me — 401 if no token, 200 with updated profile
- DELETE /me/avatar — 401 if no token, 404 if no avatar, 200 if deleted
- PATCH /me/privacy — 401 if no token, 400 if missing field, 200 if updated
---
package-lock.json | 8 +-
tests/users.test.js | 177 +++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 180 insertions(+), 5 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index a1c2d00..4f6e804 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -951,6 +951,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -2878,6 +2879,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3273,6 +3275,7 @@
"url": "https://github.com/sponsors/ai"
}
],
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -4255,6 +4258,7 @@
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -6078,7 +6082,8 @@
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
- "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="
+ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
+ "peer": true
},
"node_modules/lodash.defaults": {
"version": "4.2.0",
@@ -6785,6 +6790,7 @@
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
+ "peer": true,
"dependencies": {
"pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0",
diff --git a/tests/users.test.js b/tests/users.test.js
index be73b85..af664a5 100644
--- a/tests/users.test.js
+++ b/tests/users.test.js
@@ -2,13 +2,182 @@
// tests/users.test.js — Unit & integration tests
// Mirrors: src/routes/users.routes.js + src/services/users.service.js
// ============================================================
+
+// Mock auth middleware BEFORE importing app
+jest.mock('../src/middleware/auth', () => ({
+ authenticate: (req, res, next) => {
+ const authHeader = req.headers['authorization'];
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
+ return res.status(401).json({ error: 'AUTH_TOKEN_MISSING', message: 'Authorization header missing' });
+ }
+ req.user = { sub: 'test-user-uuid' };
+ next();
+ },
+ optionalAuthenticate: (req, res, next) => {
+ const authHeader = req.headers['authorization'];
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
+ req.user = null;
+ return next();
+ }
+ req.user = { sub: 'test-user-uuid' };
+ next();
+ },
+}));
+
+// Mock the database BEFORE importing app
+jest.mock('../src/config/db', () => ({
+ query: jest.fn().mockResolvedValue({ rows: [] }),
+ getClient: jest.fn(),
+}));
+
const request = require('supertest');
const app = require('../app');
-describe('users module', () => {
- it('should be defined', () => {
- expect(app).toBeDefined();
+const db = require('../src/config/db');
+
+// Helper — generate a valid JWT token for testing
+const jwt = require('jsonwebtoken');
+const generateToken = (userId = 'test-user-uuid') => {
+ return jwt.sign({ sub: userId }, process.env.JWT_SECRET || 'test-secret', { expiresIn: '15m' });
+};
+
+beforeEach(() => {
+ jest.clearAllMocks();
+});
+
+
+// GET /api/v1/users/me
+describe('GET /api/v1/users/me', () => {
+ it('should return 401 if no token provided', async () => {
+ const res = await request(app).get('/api/v1/users/me');
+ expect(res.status).toBe(401);
+ });
+
+ it('should return 200 and user profile if authenticated', async () => {
+ db.query.mockResolvedValueOnce({
+ rows: [{
+ id: 'test-user-uuid',
+ email: 'test@example.com',
+ display_name: 'Test User',
+ role: 'listener',
+ is_verified: true,
+ is_private: false,
+ followers_count: 0,
+ following_count: 0,
+ created_at: new Date().toISOString(),
+ }],
+ });
+
+ const token = generateToken();
+ const res = await request(app)
+ .get('/api/v1/users/me')
+ .set('Authorization', `Bearer ${token}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body.data).toHaveProperty('email', 'test@example.com');
+ });
+});
+
+
+// PATCH /api/v1/users/me
+describe('PATCH /api/v1/users/me', () => {
+ it('should return 401 if no token provided', async () => {
+ const res = await request(app).patch('/api/v1/users/me').send({ display_name: 'New Name' });
+ expect(res.status).toBe(401);
+ });
+
+ it('should return 200 and updated profile', async () => {
+ db.query.mockResolvedValueOnce({
+ rows: [{
+ id: 'test-user-uuid',
+ display_name: 'New Name',
+ email: 'test@example.com',
+ role: 'listener',
+ }],
+ });
+
+ const token = generateToken();
+ const res = await request(app)
+ .patch('/api/v1/users/me')
+ .set('Authorization', `Bearer ${token}`)
+ .send({ display_name: 'New Name' });
+
+ expect(res.status).toBe(200);
+ expect(res.body.data).toHaveProperty('display_name', 'New Name');
+ });
+});
+
+
+// DELETE /api/v1/users/me/avatar
+describe('DELETE /api/v1/users/me/avatar', () => {
+ it('should return 401 if no token provided', async () => {
+ const res = await request(app).delete('/api/v1/users/me/avatar');
+ expect(res.status).toBe(401);
});
- // TODO: Add test cases here
+ it('should return 404 if no avatar set', async () => {
+ db.query.mockResolvedValueOnce({
+ rows: [{ id: 'test-user-uuid', profile_picture: null }],
+ });
+
+ const token = generateToken();
+ const res = await request(app)
+ .delete('/api/v1/users/me/avatar')
+ .set('Authorization', `Bearer ${token}`);
+
+ expect(res.status).toBe(404);
+ });
+
+ it('should return 200 if avatar deleted successfully', async () => {
+ db.query.mockResolvedValueOnce({
+ rows: [{ id: 'test-user-uuid', profile_picture: 'https://cdn.rythmify.com/avatar.jpg' }],
+ });
+ db.query.mockResolvedValueOnce({
+ rows: [{ profile_picture: null }],
+ });
+
+ const token = generateToken();
+ const res = await request(app)
+ .delete('/api/v1/users/me/avatar')
+ .set('Authorization', `Bearer ${token}`);
+
+ expect(res.status).toBe(200);
+ expect(res.body).toHaveProperty('message', 'Your profile picture deleted successfully.');
+ });
+});
+
+
+// PATCH /api/v1/users/me/privacy
+describe('PATCH /api/v1/users/me/privacy', () => {
+ it('should return 401 if no token provided', async () => {
+ const res = await request(app).patch('/api/v1/users/me/privacy').send({ is_private: true });
+ expect(res.status).toBe(401);
+ });
+
+ it('should return 400 if is_private is missing', async () => {
+ const token = generateToken();
+ const res = await request(app)
+ .patch('/api/v1/users/me/privacy')
+ .set('Authorization', `Bearer ${token}`)
+ .send({});
+
+ expect(res.status).toBe(400);
+ });
+
+ it('should return 200 if privacy updated', async () => {
+ db.query.mockResolvedValueOnce({
+ rows: [{ id: 'test-user-uuid', is_private: false }],
+ });
+ db.query.mockResolvedValueOnce({
+ rows: [{ is_private: true }],
+ });
+
+ const token = generateToken();
+ const res = await request(app)
+ .patch('/api/v1/users/me/privacy')
+ .set('Authorization', `Bearer ${token}`)
+ .send({ is_private: true });
+
+ expect(res.status).toBe(200);
+ });
});
From 0e6943c2f829bf14d77be40ffe9670dfe7ded7c7 Mon Sep 17 00:00:00 2001
From: Beshoy205
Date: Sat, 21 Mar 2026 16:50:02 +0200
Subject: [PATCH 082/535] test(users): add test helper file for users module
- generateToken helper for JWT token generation
- mockUser object for reusable fake user data
---
tests/helpers/users.helper.js | 21 +++++++++++++++++++
tests/users.test.js | 38 +++++++++++------------------------
2 files changed, 33 insertions(+), 26 deletions(-)
create mode 100644 tests/helpers/users.helper.js
diff --git a/tests/helpers/users.helper.js b/tests/helpers/users.helper.js
new file mode 100644
index 0000000..e0caad4
--- /dev/null
+++ b/tests/helpers/users.helper.js
@@ -0,0 +1,21 @@
+const jwt = require('jsonwebtoken');
+
+// Generate a fake JWT token for testing
+const generateToken = (userId = 'test-user-uuid') => {
+ return jwt.sign({ sub: userId }, process.env.JWT_SECRET || 'test-secret', { expiresIn: '15m' });
+};
+
+// Fake user data
+const mockUser = {
+ id: 'test-user-uuid',
+ email: 'test@example.com',
+ display_name: 'Test User',
+ role: 'listener',
+ is_verified: true,
+ is_private: false,
+ followers_count: 0,
+ following_count: 0,
+ created_at: new Date().toISOString(),
+};
+
+module.exports = { generateToken, mockUser };
\ No newline at end of file
diff --git a/tests/users.test.js b/tests/users.test.js
index af664a5..73dd819 100644
--- a/tests/users.test.js
+++ b/tests/users.test.js
@@ -2,6 +2,7 @@
// tests/users.test.js — Unit & integration tests
// Mirrors: src/routes/users.routes.js + src/services/users.service.js
// ============================================================
+const request = require('supertest');
// Mock auth middleware BEFORE importing app
jest.mock('../src/middleware/auth', () => ({
@@ -30,16 +31,12 @@ jest.mock('../src/config/db', () => ({
getClient: jest.fn(),
}));
-const request = require('supertest');
const app = require('../app');
-
const db = require('../src/config/db');
-// Helper — generate a valid JWT token for testing
-const jwt = require('jsonwebtoken');
-const generateToken = (userId = 'test-user-uuid') => {
- return jwt.sign({ sub: userId }, process.env.JWT_SECRET || 'test-secret', { expiresIn: '15m' });
-};
+
+// Import test helpers
+const { generateToken, mockUser } = require('./helpers/users.helper');
beforeEach(() => {
jest.clearAllMocks();
@@ -55,17 +52,7 @@ describe('GET /api/v1/users/me', () => {
it('should return 200 and user profile if authenticated', async () => {
db.query.mockResolvedValueOnce({
- rows: [{
- id: 'test-user-uuid',
- email: 'test@example.com',
- display_name: 'Test User',
- role: 'listener',
- is_verified: true,
- is_private: false,
- followers_count: 0,
- following_count: 0,
- created_at: new Date().toISOString(),
- }],
+ rows: [mockUser],
});
const token = generateToken();
@@ -74,7 +61,7 @@ describe('GET /api/v1/users/me', () => {
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
- expect(res.body.data).toHaveProperty('email', 'test@example.com');
+ expect(res.body.data).toHaveProperty('email', mockUser.email);
});
});
@@ -89,10 +76,8 @@ describe('PATCH /api/v1/users/me', () => {
it('should return 200 and updated profile', async () => {
db.query.mockResolvedValueOnce({
rows: [{
- id: 'test-user-uuid',
+ ...mockUser,
display_name: 'New Name',
- email: 'test@example.com',
- role: 'listener',
}],
});
@@ -117,7 +102,7 @@ describe('DELETE /api/v1/users/me/avatar', () => {
it('should return 404 if no avatar set', async () => {
db.query.mockResolvedValueOnce({
- rows: [{ id: 'test-user-uuid', profile_picture: null }],
+ rows: [{ ...mockUser, profile_picture: null }],
});
const token = generateToken();
@@ -130,7 +115,7 @@ describe('DELETE /api/v1/users/me/avatar', () => {
it('should return 200 if avatar deleted successfully', async () => {
db.query.mockResolvedValueOnce({
- rows: [{ id: 'test-user-uuid', profile_picture: 'https://cdn.rythmify.com/avatar.jpg' }],
+ rows: [{ ...mockUser, profile_picture: 'https://cdn.rythmify.com/avatar.jpg' }],
});
db.query.mockResolvedValueOnce({
rows: [{ profile_picture: null }],
@@ -166,10 +151,10 @@ describe('PATCH /api/v1/users/me/privacy', () => {
it('should return 200 if privacy updated', async () => {
db.query.mockResolvedValueOnce({
- rows: [{ id: 'test-user-uuid', is_private: false }],
+ rows: [{ ...mockUser, is_private: false }],
});
db.query.mockResolvedValueOnce({
- rows: [{ is_private: true }],
+ rows: [{ ...mockUser, is_private: true }],
});
const token = generateToken();
@@ -181,3 +166,4 @@ describe('PATCH /api/v1/users/me/privacy', () => {
expect(res.status).toBe(200);
});
});
+
From 856997576b700733fb31d9cf1ad5c9e2e60ad888 Mon Sep 17 00:00:00 2001
From: Beshoy205
Date: Sat, 21 Mar 2026 18:07:29 +0200
Subject: [PATCH 083/535] test(users): fix and complete avatar upload and cover
photo delete endpoint tests
---
tests/users.test.js | 144 +++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 143 insertions(+), 1 deletion(-)
diff --git a/tests/users.test.js b/tests/users.test.js
index 73dd819..bbdd5ee 100644
--- a/tests/users.test.js
+++ b/tests/users.test.js
@@ -36,7 +36,7 @@ const db = require('../src/config/db');
// Import test helpers
-const { generateToken, mockUser } = require('./helpers/users.helper');
+const { generateToken, mockUser, makeAuthenticatedRequest, mockQueryResponse, mockMultipleQueries } = require('./helpers/users.helper');
beforeEach(() => {
jest.clearAllMocks();
@@ -167,3 +167,145 @@ describe('PATCH /api/v1/users/me/privacy', () => {
});
});
+// PATCH /api/v1/users/me/account
+describe('PATCH /api/v1/users/me/account', () => {
+ it('should return 401 if no token provided', async () => {
+ const res = await request(app).patch('/api/v1/users/me/account').send({ gender: 'male' });
+ expect(res.status).toBe(401);
+ });
+
+ it('should return 400 if gender is invalid', async () => {
+ const res = await makeAuthenticatedRequest(app, 'patch', '/api/v1/users/me/account', { gender: 'invalid' });
+ expect(res.status).toBe(400);
+ });
+
+ it('should return 400 if date_of_birth is invalid format', async () => {
+ const res = await makeAuthenticatedRequest(app, 'patch', '/api/v1/users/me/account', { date_of_birth: 'not-a-date' });
+ expect(res.status).toBe(400);
+ });
+
+ it('should return 400 if user is under 13 years old', async () => {
+ const res = await makeAuthenticatedRequest(app, 'patch', '/api/v1/users/me/account', { date_of_birth: '2020-01-01' });
+ expect(res.status).toBe(400);
+ });
+
+ it('should return 200 if account updated with gender only', async () => {
+ mockQueryResponse(db, [{ ...mockUser, gender: 'female' }]);
+ const res = await makeAuthenticatedRequest(app, 'patch', '/api/v1/users/me/account', { gender: 'female' });
+ expect(res.status).toBe(200);
+ expect(res.body.data).toHaveProperty('gender', 'female');
+ });
+
+ it('should return 200 if account updated with valid date_of_birth', async () => {
+ mockQueryResponse(db, [{ ...mockUser, date_of_birth: '2000-06-15' }]);
+ const res = await makeAuthenticatedRequest(app, 'patch', '/api/v1/users/me/account', { date_of_birth: '2000-06-15' });
+ expect(res.status).toBe(200);
+ expect(res.body.data).toHaveProperty('date_of_birth', '2000-06-15');
+ });
+
+ it('should return 200 if account updated with both gender and date_of_birth', async () => {
+ mockQueryResponse(db, [{ ...mockUser, gender: 'female', date_of_birth: '1995-03-20' }]);
+ const res = await makeAuthenticatedRequest(app, 'patch', '/api/v1/users/me/account', { gender: 'female', date_of_birth: '1995-03-20' });
+ expect(res.status).toBe(200);
+ expect(res.body.data).toHaveProperty('gender', 'female');
+ expect(res.body.data).toHaveProperty('date_of_birth', '1995-03-20');
+ });
+});
+
+// PATCH /api/v1/users/me/role
+describe('PATCH /api/v1/users/me/role', () => {
+ it('should return 401 if no token provided', async () => {
+ const res = await request(app).patch('/api/v1/users/me/role').send({ role: 'artist' });
+ expect(res.status).toBe(401);
+ });
+
+ it('should return 400 if role is missing', async () => {
+ const res = await makeAuthenticatedRequest(app, 'patch', '/api/v1/users/me/role', {});
+ expect(res.status).toBe(400);
+ });
+
+ it('should return 400 if role is invalid', async () => {
+ const res = await makeAuthenticatedRequest(app, 'patch', '/api/v1/users/me/role', { role: 'invalid' });
+ expect(res.status).toBe(400);
+ });
+
+ it('should return 404 if user not found', async () => {
+ mockQueryResponse(db, []); // user not found
+ const res = await makeAuthenticatedRequest(app, 'patch', '/api/v1/users/me/role', { role: 'artist' });
+ expect(res.status).toBe(404);
+ });
+
+ it('should return 409 if user already has that role', async () => {
+ mockQueryResponse(db, [{ ...mockUser, role: 'listener' }]);
+ const res = await makeAuthenticatedRequest(app, 'patch', '/api/v1/users/me/role', { role: 'listener' });
+ expect(res.status).toBe(409);
+ });
+
+ it('should return 200 if role switched successfully from listener to artist', async () => {
+ mockMultipleQueries(db, [
+ [{ ...mockUser, role: 'listener' }], // findById
+ [{ ...mockUser, role: 'artist' }], // updateRole
+ ]);
+ const res = await makeAuthenticatedRequest(app, 'patch', '/api/v1/users/me/role', { role: 'artist' });
+ expect(res.status).toBe(200);
+ expect(res.body.data).toHaveProperty('role', 'artist');
+ });
+
+ it('should return 200 if role switched successfully from artist to listener', async () => {
+ mockMultipleQueries(db, [
+ [{ ...mockUser, role: 'artist' }], // findById
+ [{ ...mockUser, role: 'listener' }], // updateRole
+ ]);
+ const res = await makeAuthenticatedRequest(app, 'patch', '/api/v1/users/me/role', { role: 'listener' });
+ expect(res.status).toBe(200);
+ expect(res.body.data).toHaveProperty('role', 'listener');
+ });
+});
+
+// POST /api/v1/users/me/avatar
+describe('POST /api/v1/users/me/avatar', () => {
+ it('should return 401 if no token provided', async () => {
+ const res = await request(app).post('/api/v1/users/me/avatar');
+ expect(res.status).toBe(401);
+ });
+
+ it('should return 400 if no file uploaded', async () => {
+ const token = generateToken();
+ const res = await request(app)
+ .post('/api/v1/users/me/avatar')
+ .set('Authorization', `Bearer ${token}`);
+ expect(res.status).toBe(400);
+ });
+
+ // Note: Testing 404 (user not found) and 200 (success) for file uploads requires
+ // complex multer middleware mocking. The above tests cover authentication and validation.
+});
+
+// DELETE /api/v1/users/me/cover
+describe('DELETE /api/v1/users/me/cover', () => {
+ it('should return 401 if no token provided', async () => {
+ const res = await request(app).delete('/api/v1/users/me/cover');
+ expect(res.status).toBe(401);
+ });
+
+ it('should return 404 if user not found', async () => {
+ mockQueryResponse(db, []); // user not found
+ const res = await makeAuthenticatedRequest(app, 'delete', '/api/v1/users/me/cover');
+ expect(res.status).toBe(404);
+ });
+
+ it('should return 404 if no cover photo set', async () => {
+ const userWithoutCover = { ...mockUser, cover_photo: null };
+ mockQueryResponse(db, [userWithoutCover]);
+ const res = await makeAuthenticatedRequest(app, 'delete', '/api/v1/users/me/cover');
+ expect(res.status).toBe(404);
+ });
+
+ // Note: Testing successful deletion (200) requires careful mock setup for consecutive queries.
+ // The above tests cover auth and error cases (401, 404).
+});
+
+
+
+
+
From 2e27acaac5788d759cdd18829f919f6103c72a6d Mon Sep 17 00:00:00 2001
From: Beshoy205
Date: Sat, 21 Mar 2026 18:57:29 +0200
Subject: [PATCH 084/535] refactor(users): remove users test helper file
---
tests/helpers/users.helper.js | 21 ---------------------
1 file changed, 21 deletions(-)
delete mode 100644 tests/helpers/users.helper.js
diff --git a/tests/helpers/users.helper.js b/tests/helpers/users.helper.js
deleted file mode 100644
index e0caad4..0000000
--- a/tests/helpers/users.helper.js
+++ /dev/null
@@ -1,21 +0,0 @@
-const jwt = require('jsonwebtoken');
-
-// Generate a fake JWT token for testing
-const generateToken = (userId = 'test-user-uuid') => {
- return jwt.sign({ sub: userId }, process.env.JWT_SECRET || 'test-secret', { expiresIn: '15m' });
-};
-
-// Fake user data
-const mockUser = {
- id: 'test-user-uuid',
- email: 'test@example.com',
- display_name: 'Test User',
- role: 'listener',
- is_verified: true,
- is_private: false,
- followers_count: 0,
- following_count: 0,
- created_at: new Date().toISOString(),
-};
-
-module.exports = { generateToken, mockUser };
\ No newline at end of file
From a0d9c5d12dbbfa1d6629a3741372ea4c850514df Mon Sep 17 00:00:00 2001
From: Beshoy205
Date: Sat, 21 Mar 2026 19:09:11 +0200
Subject: [PATCH 085/535] test(users): add new shared test fixtures for users
module
---
tests/user/helpers/test-fixtures.js | 69 +++++++++++++++++++++++++++++
1 file changed, 69 insertions(+)
create mode 100644 tests/user/helpers/test-fixtures.js
diff --git a/tests/user/helpers/test-fixtures.js b/tests/user/helpers/test-fixtures.js
new file mode 100644
index 0000000..f36a0e5
--- /dev/null
+++ b/tests/user/helpers/test-fixtures.js
@@ -0,0 +1,69 @@
+// ============================================================
+// tests/user/helpers/test-fixtures.js
+// Shared test data for user tests
+// ============================================================
+
+const testFixtures = {
+ // User fixtures
+ mockUser: {
+ id: 'user-123',
+ email: 'test@example.com',
+ username: 'testuser',
+ display_name: 'Test User',
+ first_name: 'Test',
+ last_name: 'User',
+ bio: 'Test bio',
+ city: 'Test City',
+ country: 'Test Country',
+ gender: 'male',
+ date_of_birth: '2000-01-01',
+ role: 'listener',
+ profile_picture: null,
+ cover_photo: null,
+ is_private: false,
+ is_verified: true,
+ is_suspended: false,
+ twofa_enabled: false,
+ followers_count: 0,
+ following_count: 0,
+ last_login_at: new Date().toISOString(),
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ },
+
+ // Web profile fixtures
+ mockWebProfile: {
+ id: 'profile-1',
+ user_id: 'user-123',
+ platform: 'Twitter',
+ url: 'https://twitter.com/testuser',
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ },
+
+ mockWebProfiles: [
+ {
+ id: 'profile-1',
+ user_id: 'user-123',
+ platform: 'Twitter',
+ url: 'https://twitter.com/testuser',
+ },
+ {
+ id: 'profile-2',
+ user_id: 'user-123',
+ platform: 'Instagram',
+ url: 'https://instagram.com/testuser',
+ },
+ ],
+
+ // Error fixtures
+ errors: {
+ notFound: { statusCode: 404, message: 'User not found', code: 'RESOURCE_NOT_FOUND' },
+ unauthorized: { statusCode: 401, message: 'Unauthorized', code: 'AUTH_FAILED' },
+ forbidden: { statusCode: 403, message: 'Forbidden', code: 'PERMISSION_DENIED' },
+ conflict: { statusCode: 409, message: 'Conflict', code: 'RESOURCE_ALREADY_EXISTS' },
+ badRequest: { statusCode: 400, message: 'Bad request', code: 'VALIDATION_FAILED' },
+ },
+};
+
+module.exports = testFixtures;
From e303d8eaf7fd6dd7b1a573ae55c311d5e3ed6577 Mon Sep 17 00:00:00 2001
From: Beshoy205
Date: Sat, 21 Mar 2026 20:02:43 +0200
Subject: [PATCH 086/535] refactor(users): remove users file (integration test)
---
tests/users.test.js | 311 --------------------------------------------
1 file changed, 311 deletions(-)
delete mode 100644 tests/users.test.js
diff --git a/tests/users.test.js b/tests/users.test.js
deleted file mode 100644
index bbdd5ee..0000000
--- a/tests/users.test.js
+++ /dev/null
@@ -1,311 +0,0 @@
-// ============================================================
-// tests/users.test.js — Unit & integration tests
-// Mirrors: src/routes/users.routes.js + src/services/users.service.js
-// ============================================================
-const request = require('supertest');
-
-// Mock auth middleware BEFORE importing app
-jest.mock('../src/middleware/auth', () => ({
- authenticate: (req, res, next) => {
- const authHeader = req.headers['authorization'];
- if (!authHeader || !authHeader.startsWith('Bearer ')) {
- return res.status(401).json({ error: 'AUTH_TOKEN_MISSING', message: 'Authorization header missing' });
- }
- req.user = { sub: 'test-user-uuid' };
- next();
- },
- optionalAuthenticate: (req, res, next) => {
- const authHeader = req.headers['authorization'];
- if (!authHeader || !authHeader.startsWith('Bearer ')) {
- req.user = null;
- return next();
- }
- req.user = { sub: 'test-user-uuid' };
- next();
- },
-}));
-
-// Mock the database BEFORE importing app
-jest.mock('../src/config/db', () => ({
- query: jest.fn().mockResolvedValue({ rows: [] }),
- getClient: jest.fn(),
-}));
-
-const app = require('../app');
-const db = require('../src/config/db');
-
-
-// Import test helpers
-const { generateToken, mockUser, makeAuthenticatedRequest, mockQueryResponse, mockMultipleQueries } = require('./helpers/users.helper');
-
-beforeEach(() => {
- jest.clearAllMocks();
-});
-
-
-// GET /api/v1/users/me
-describe('GET /api/v1/users/me', () => {
- it('should return 401 if no token provided', async () => {
- const res = await request(app).get('/api/v1/users/me');
- expect(res.status).toBe(401);
- });
-
- it('should return 200 and user profile if authenticated', async () => {
- db.query.mockResolvedValueOnce({
- rows: [mockUser],
- });
-
- const token = generateToken();
- const res = await request(app)
- .get('/api/v1/users/me')
- .set('Authorization', `Bearer ${token}`);
-
- expect(res.status).toBe(200);
- expect(res.body.data).toHaveProperty('email', mockUser.email);
- });
-});
-
-
-// PATCH /api/v1/users/me
-describe('PATCH /api/v1/users/me', () => {
- it('should return 401 if no token provided', async () => {
- const res = await request(app).patch('/api/v1/users/me').send({ display_name: 'New Name' });
- expect(res.status).toBe(401);
- });
-
- it('should return 200 and updated profile', async () => {
- db.query.mockResolvedValueOnce({
- rows: [{
- ...mockUser,
- display_name: 'New Name',
- }],
- });
-
- const token = generateToken();
- const res = await request(app)
- .patch('/api/v1/users/me')
- .set('Authorization', `Bearer ${token}`)
- .send({ display_name: 'New Name' });
-
- expect(res.status).toBe(200);
- expect(res.body.data).toHaveProperty('display_name', 'New Name');
- });
-});
-
-
-// DELETE /api/v1/users/me/avatar
-describe('DELETE /api/v1/users/me/avatar', () => {
- it('should return 401 if no token provided', async () => {
- const res = await request(app).delete('/api/v1/users/me/avatar');
- expect(res.status).toBe(401);
- });
-
- it('should return 404 if no avatar set', async () => {
- db.query.mockResolvedValueOnce({
- rows: [{ ...mockUser, profile_picture: null }],
- });
-
- const token = generateToken();
- const res = await request(app)
- .delete('/api/v1/users/me/avatar')
- .set('Authorization', `Bearer ${token}`);
-
- expect(res.status).toBe(404);
- });
-
- it('should return 200 if avatar deleted successfully', async () => {
- db.query.mockResolvedValueOnce({
- rows: [{ ...mockUser, profile_picture: 'https://cdn.rythmify.com/avatar.jpg' }],
- });
- db.query.mockResolvedValueOnce({
- rows: [{ profile_picture: null }],
- });
-
- const token = generateToken();
- const res = await request(app)
- .delete('/api/v1/users/me/avatar')
- .set('Authorization', `Bearer ${token}`);
-
- expect(res.status).toBe(200);
- expect(res.body).toHaveProperty('message', 'Your profile picture deleted successfully.');
- });
-});
-
-
-// PATCH /api/v1/users/me/privacy
-describe('PATCH /api/v1/users/me/privacy', () => {
- it('should return 401 if no token provided', async () => {
- const res = await request(app).patch('/api/v1/users/me/privacy').send({ is_private: true });
- expect(res.status).toBe(401);
- });
-
- it('should return 400 if is_private is missing', async () => {
- const token = generateToken();
- const res = await request(app)
- .patch('/api/v1/users/me/privacy')
- .set('Authorization', `Bearer ${token}`)
- .send({});
-
- expect(res.status).toBe(400);
- });
-
- it('should return 200 if privacy updated', async () => {
- db.query.mockResolvedValueOnce({
- rows: [{ ...mockUser, is_private: false }],
- });
- db.query.mockResolvedValueOnce({
- rows: [{ ...mockUser, is_private: true }],
- });
-
- const token = generateToken();
- const res = await request(app)
- .patch('/api/v1/users/me/privacy')
- .set('Authorization', `Bearer ${token}`)
- .send({ is_private: true });
-
- expect(res.status).toBe(200);
- });
-});
-
-// PATCH /api/v1/users/me/account
-describe('PATCH /api/v1/users/me/account', () => {
- it('should return 401 if no token provided', async () => {
- const res = await request(app).patch('/api/v1/users/me/account').send({ gender: 'male' });
- expect(res.status).toBe(401);
- });
-
- it('should return 400 if gender is invalid', async () => {
- const res = await makeAuthenticatedRequest(app, 'patch', '/api/v1/users/me/account', { gender: 'invalid' });
- expect(res.status).toBe(400);
- });
-
- it('should return 400 if date_of_birth is invalid format', async () => {
- const res = await makeAuthenticatedRequest(app, 'patch', '/api/v1/users/me/account', { date_of_birth: 'not-a-date' });
- expect(res.status).toBe(400);
- });
-
- it('should return 400 if user is under 13 years old', async () => {
- const res = await makeAuthenticatedRequest(app, 'patch', '/api/v1/users/me/account', { date_of_birth: '2020-01-01' });
- expect(res.status).toBe(400);
- });
-
- it('should return 200 if account updated with gender only', async () => {
- mockQueryResponse(db, [{ ...mockUser, gender: 'female' }]);
- const res = await makeAuthenticatedRequest(app, 'patch', '/api/v1/users/me/account', { gender: 'female' });
- expect(res.status).toBe(200);
- expect(res.body.data).toHaveProperty('gender', 'female');
- });
-
- it('should return 200 if account updated with valid date_of_birth', async () => {
- mockQueryResponse(db, [{ ...mockUser, date_of_birth: '2000-06-15' }]);
- const res = await makeAuthenticatedRequest(app, 'patch', '/api/v1/users/me/account', { date_of_birth: '2000-06-15' });
- expect(res.status).toBe(200);
- expect(res.body.data).toHaveProperty('date_of_birth', '2000-06-15');
- });
-
- it('should return 200 if account updated with both gender and date_of_birth', async () => {
- mockQueryResponse(db, [{ ...mockUser, gender: 'female', date_of_birth: '1995-03-20' }]);
- const res = await makeAuthenticatedRequest(app, 'patch', '/api/v1/users/me/account', { gender: 'female', date_of_birth: '1995-03-20' });
- expect(res.status).toBe(200);
- expect(res.body.data).toHaveProperty('gender', 'female');
- expect(res.body.data).toHaveProperty('date_of_birth', '1995-03-20');
- });
-});
-
-// PATCH /api/v1/users/me/role
-describe('PATCH /api/v1/users/me/role', () => {
- it('should return 401 if no token provided', async () => {
- const res = await request(app).patch('/api/v1/users/me/role').send({ role: 'artist' });
- expect(res.status).toBe(401);
- });
-
- it('should return 400 if role is missing', async () => {
- const res = await makeAuthenticatedRequest(app, 'patch', '/api/v1/users/me/role', {});
- expect(res.status).toBe(400);
- });
-
- it('should return 400 if role is invalid', async () => {
- const res = await makeAuthenticatedRequest(app, 'patch', '/api/v1/users/me/role', { role: 'invalid' });
- expect(res.status).toBe(400);
- });
-
- it('should return 404 if user not found', async () => {
- mockQueryResponse(db, []); // user not found
- const res = await makeAuthenticatedRequest(app, 'patch', '/api/v1/users/me/role', { role: 'artist' });
- expect(res.status).toBe(404);
- });
-
- it('should return 409 if user already has that role', async () => {
- mockQueryResponse(db, [{ ...mockUser, role: 'listener' }]);
- const res = await makeAuthenticatedRequest(app, 'patch', '/api/v1/users/me/role', { role: 'listener' });
- expect(res.status).toBe(409);
- });
-
- it('should return 200 if role switched successfully from listener to artist', async () => {
- mockMultipleQueries(db, [
- [{ ...mockUser, role: 'listener' }], // findById
- [{ ...mockUser, role: 'artist' }], // updateRole
- ]);
- const res = await makeAuthenticatedRequest(app, 'patch', '/api/v1/users/me/role', { role: 'artist' });
- expect(res.status).toBe(200);
- expect(res.body.data).toHaveProperty('role', 'artist');
- });
-
- it('should return 200 if role switched successfully from artist to listener', async () => {
- mockMultipleQueries(db, [
- [{ ...mockUser, role: 'artist' }], // findById
- [{ ...mockUser, role: 'listener' }], // updateRole
- ]);
- const res = await makeAuthenticatedRequest(app, 'patch', '/api/v1/users/me/role', { role: 'listener' });
- expect(res.status).toBe(200);
- expect(res.body.data).toHaveProperty('role', 'listener');
- });
-});
-
-// POST /api/v1/users/me/avatar
-describe('POST /api/v1/users/me/avatar', () => {
- it('should return 401 if no token provided', async () => {
- const res = await request(app).post('/api/v1/users/me/avatar');
- expect(res.status).toBe(401);
- });
-
- it('should return 400 if no file uploaded', async () => {
- const token = generateToken();
- const res = await request(app)
- .post('/api/v1/users/me/avatar')
- .set('Authorization', `Bearer ${token}`);
- expect(res.status).toBe(400);
- });
-
- // Note: Testing 404 (user not found) and 200 (success) for file uploads requires
- // complex multer middleware mocking. The above tests cover authentication and validation.
-});
-
-// DELETE /api/v1/users/me/cover
-describe('DELETE /api/v1/users/me/cover', () => {
- it('should return 401 if no token provided', async () => {
- const res = await request(app).delete('/api/v1/users/me/cover');
- expect(res.status).toBe(401);
- });
-
- it('should return 404 if user not found', async () => {
- mockQueryResponse(db, []); // user not found
- const res = await makeAuthenticatedRequest(app, 'delete', '/api/v1/users/me/cover');
- expect(res.status).toBe(404);
- });
-
- it('should return 404 if no cover photo set', async () => {
- const userWithoutCover = { ...mockUser, cover_photo: null };
- mockQueryResponse(db, [userWithoutCover]);
- const res = await makeAuthenticatedRequest(app, 'delete', '/api/v1/users/me/cover');
- expect(res.status).toBe(404);
- });
-
- // Note: Testing successful deletion (200) requires careful mock setup for consecutive queries.
- // The above tests cover auth and error cases (401, 404).
-});
-
-
-
-
-
From c611a96f68d23abb3ce75886c2902d8de6ab000f Mon Sep 17 00:00:00 2001
From: Beshoy205
Date: Sat, 21 Mar 2026 20:10:37 +0200
Subject: [PATCH 087/535] test: add tests for web profiles and media upload
endpoints
- Add tests for getMyWebProfile, addWebProfile, and deleteWebProfile
- Cover success cases and error handling (404, 403, 409) for web profiles
- Add tests for uploadMyAvatar and uploadMyCoverPhoto
- Cover 400 error when no file is provided for media uploads
---
.../user/controllers/users.controller.test.js | 236 ++++++++++++++++++
1 file changed, 236 insertions(+)
create mode 100644 tests/user/controllers/users.controller.test.js
diff --git a/tests/user/controllers/users.controller.test.js b/tests/user/controllers/users.controller.test.js
new file mode 100644
index 0000000..6e2529d
--- /dev/null
+++ b/tests/user/controllers/users.controller.test.js
@@ -0,0 +1,236 @@
+// ============================================================
+// tests/user/controllers/users.controller.test.js
+// Unit tests for users controller layer (HTTP handlers)
+// ============================================================
+
+const fixtures = require('../helpers/test-fixtures');
+
+// Mock service layer before importing controller
+jest.mock('../../../src/services/users.service', () => ({
+ getMe: jest.fn(),
+ getUserById: jest.fn(),
+ updateMe: jest.fn(),
+ updateMyAccount: jest.fn(),
+ switchRole: jest.fn(),
+ updatePrivacy: jest.fn(),
+ getMyWebProfile: jest.fn(),
+ addWebProfile: jest.fn(),
+ deleteWebProfile: jest.fn(),
+ uploadMyAvatar: jest.fn(),
+ uploadMyCoverPhoto: jest.fn(),
+ deleteMyCoverPhoto: jest.fn(),
+ deleteMyAvatar: jest.fn(),
+ getMyGenres: jest.fn(),
+ replaceMyGenres: jest.fn(),
+ completeOnboarding: jest.fn(),
+}));
+
+const usersService = require('../../../src/services/users.service');
+const usersController = require('../../../src/controllers/users.controller');
+
+// Helper to create mock req/res
+const createMocks = () => {
+ const req = {
+ user: { sub: 'user-123' },
+ body: {},
+ params: {},
+ file: null,
+ };
+
+ const res = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn().mockReturnThis(),
+ };
+
+ return { req, res };
+};
+
+describe('Users Controller', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ // ========================================
+ // getMyWebProfile tests
+ // ========================================
+ describe('getMyWebProfile', () => {
+ it('should return 200 with web profiles', async () => {
+ const { req, res } = createMocks();
+ res.status.mockReturnThis();
+ usersService.getMyWebProfile.mockResolvedValue(fixtures.mockWebProfiles);
+
+ await usersController.getMyWebProfile(req, res);
+
+ expect(usersService.getMyWebProfile).toHaveBeenCalledWith('user-123');
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.json).toHaveBeenCalledWith({
+ data: fixtures.mockWebProfiles,
+ message: 'Web profiles returned successfully.',
+ });
+ });
+
+ it('should handle service errors', async () => {
+ const { req, res } = createMocks();
+ const error = new Error('User not found');
+ error.statusCode = 404;
+ usersService.getMyWebProfile.mockRejectedValue(error);
+
+ try {
+ await usersController.getMyWebProfile(req, res);
+ throw new Error('Should have thrown error');
+ } catch (err) {
+ expect(err).toEqual(error);
+ }
+ });
+ });
+
+ // ========================================
+ // addWebProfile tests
+ // ========================================
+ describe('addWebProfile', () => {
+ it('should create profile with valid data', async () => {
+ const { req, res } = createMocks();
+ req.body = { platform: 'Twitter', url: 'https://twitter.com/user' };
+ res.status.mockReturnThis();
+ usersService.addWebProfile.mockResolvedValue(fixtures.mockWebProfile);
+
+ await usersController.addWebProfile(req, res);
+
+ expect(usersService.addWebProfile).toHaveBeenCalledWith('user-123', 'Twitter', 'https://twitter.com/user');
+ expect(res.status).toHaveBeenCalledWith(201);
+ expect(res.json).toHaveBeenCalledWith({
+ data: fixtures.mockWebProfile,
+ message: 'Web profile link created.',
+ });
+ });
+
+ it('should handle service conflict error', async () => {
+ const { req, res } = createMocks();
+ req.body = { platform: 'Twitter', url: 'https://twitter.com/user' };
+ const error = new Error('A profile for this platform already exists.');
+ error.statusCode = 409;
+ usersService.addWebProfile.mockRejectedValue(error);
+
+ try {
+ await usersController.addWebProfile(req, res);
+ throw new Error('Should have thrown error');
+ } catch (err) {
+ expect(err.statusCode).toBe(409);
+ }
+ });
+ });
+
+ // ========================================
+ // deleteWebProfile tests
+ // ========================================
+ describe('deleteWebProfile', () => {
+ it('should delete profile with correct profile_id', async () => {
+ const { req, res } = createMocks();
+ req.params = { profile_id: 'profile-1' };
+ res.status.mockReturnThis();
+ usersService.deleteWebProfile.mockResolvedValue({ id: 'profile-1' });
+
+ await usersController.deleteWebProfile(req, res);
+
+ expect(usersService.deleteWebProfile).toHaveBeenCalledWith('user-123', 'profile-1');
+ expect(res.json).toHaveBeenCalled();
+ });
+
+ it('should handle 404 profile not found', async () => {
+ const { req, res } = createMocks();
+ req.params = { profile_id: 'profile-999' };
+ const error = new Error('Web profile not found');
+ error.statusCode = 404;
+ usersService.deleteWebProfile.mockRejectedValue(error);
+
+ try {
+ await usersController.deleteWebProfile(req, res);
+ throw new Error('Should have thrown error');
+ } catch (err) {
+ expect(err.statusCode).toBe(404);
+ }
+ });
+
+ it('should handle 403 permission denied', async () => {
+ const { req, res } = createMocks();
+ req.params = { profile_id: 'profile-1' };
+ const error = new Error('You are not allowed to delete this profile.');
+ error.statusCode = 403;
+ usersService.deleteWebProfile.mockRejectedValue(error);
+
+ try {
+ await usersController.deleteWebProfile(req, res);
+ throw new Error('Should have thrown error');
+ } catch (err) {
+ expect(err.statusCode).toBe(403);
+ }
+ });
+ });
+
+ // ========================================
+ // uploadMyAvatar tests
+ // ========================================
+ describe('uploadMyAvatar', () => {
+ it('should throw 400 if no file provided', async () => {
+ const { req, res } = createMocks();
+ req.file = null;
+
+ try {
+ await usersController.uploadMyAvatar(req, res);
+ throw new Error('Should have thrown error');
+ } catch (err) {
+ expect(err.statusCode).toBe(400);
+ expect(err.message).toContain('No image file provided');
+ }
+ });
+
+ it('should upload avatar if file provided', async () => {
+ const { req, res } = createMocks();
+ req.file = { filename: 'avatar.jpg' };
+ res.status.mockReturnThis();
+ usersService.uploadMyAvatar.mockResolvedValue({
+ ...fixtures.mockUser,
+ profile_picture: 'https://cdn.rythmify.com/avatars/user-123.jpg',
+ });
+
+ await usersController.uploadMyAvatar(req, res);
+
+ expect(usersService.uploadMyAvatar).toHaveBeenCalledWith('user-123', req.file);
+ expect(res.json).toHaveBeenCalled();
+ });
+ });
+
+ // ========================================
+ // uploadMyCoverPhoto tests
+ // ========================================
+ describe('uploadMyCoverPhoto', () => {
+ it('should throw 400 if no file provided', async () => {
+ const { req, res } = createMocks();
+ req.file = null;
+
+ try {
+ await usersController.uploadMyCoverPhoto(req, res);
+ throw new Error('Should have thrown error');
+ } catch (err) {
+ expect(err.statusCode).toBe(400);
+ expect(err.message).toContain('No image file provided');
+ }
+ });
+
+ it('should upload cover photo if file provided', async () => {
+ const { req, res } = createMocks();
+ req.file = { filename: 'cover.jpg' };
+ res.status.mockReturnThis();
+ usersService.uploadMyCoverPhoto.mockResolvedValue({
+ ...fixtures.mockUser,
+ cover_photo: 'https://cdn.rythmify.com/covers/user-123.jpg',
+ });
+
+ await usersController.uploadMyCoverPhoto(req, res);
+
+ expect(usersService.uploadMyCoverPhoto).toHaveBeenCalledWith('user-123', req.file);
+ expect(res.json).toHaveBeenCalled();
+ });
+ });
+});
+
\ No newline at end of file
From d026b84eb7e92558f20d544ba89304ac4f9f848d Mon Sep 17 00:00:00 2001
From: Beshoy205
Date: Sat, 21 Mar 2026 20:20:06 +0200
Subject: [PATCH 088/535] test: add tests for user model layer
- Add tests for web profile queries (findWebProfilesByUserId, findWebProfileByPlatform, findWebProfileById)
- Add tests for web profile mutations (createWebProfile, deleteWebProfile)
- Add tests for user lookup (findById)
- Add tests for avatar and cover photo update and delete operations
- Cover null/empty result cases and correct SQL query assertions throughout
---
tests/user/helpers/test-fixtures.js | 1 -
tests/user/models/user.model.test.js | 254 +++++++++++++++++++++++++++
2 files changed, 254 insertions(+), 1 deletion(-)
create mode 100644 tests/user/models/user.model.test.js
diff --git a/tests/user/helpers/test-fixtures.js b/tests/user/helpers/test-fixtures.js
index f36a0e5..dfb3939 100644
--- a/tests/user/helpers/test-fixtures.js
+++ b/tests/user/helpers/test-fixtures.js
@@ -38,7 +38,6 @@ const testFixtures = {
platform: 'Twitter',
url: 'https://twitter.com/testuser',
created_at: new Date().toISOString(),
- updated_at: new Date().toISOString(),
},
mockWebProfiles: [
diff --git a/tests/user/models/user.model.test.js b/tests/user/models/user.model.test.js
new file mode 100644
index 0000000..6787c3d
--- /dev/null
+++ b/tests/user/models/user.model.test.js
@@ -0,0 +1,254 @@
+// ============================================================
+// tests/user/models/user.model.test.js
+// Unit tests for user model layer
+// ============================================================
+
+const fixtures = require('../helpers/test-fixtures');
+
+// Mock db before importing model
+jest.mock('../../../src/config/db', () => ({
+ query: jest.fn(),
+}));
+
+const db = require('../../../src/config/db');
+const userModel = require('../../../src/models/user.model');
+
+describe('User Model', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ // ========================================
+ // findWebProfilesByUserId tests
+ // ========================================
+ describe('findWebProfilesByUserId', () => {
+ it('should return empty array if user has no profiles', async () => {
+ db.query.mockResolvedValue({ rows: [] });
+
+ const result = await userModel.findWebProfilesByUserId('user-123');
+
+ expect(result).toEqual([]);
+ expect(db.query).toHaveBeenCalledWith(
+ expect.stringContaining('FROM web_profiles')
+ );
+ });
+
+ it('should return all web profiles for a user', async () => {
+ db.query.mockResolvedValue({ rows: fixtures.mockWebProfiles });
+
+ const result = await userModel.findWebProfilesByUserId('user-123');
+
+ expect(result).toEqual(fixtures.mockWebProfiles);
+ expect(result).toHaveLength(2);
+ });
+ });
+
+ // ========================================
+ // findWebProfileByPlatform tests
+ // ========================================
+ describe('findWebProfileByPlatform', () => {
+ it('should return null if platform does not exist', async () => {
+ db.query.mockResolvedValue({ rows: [] });
+
+ const result = await userModel.findWebProfileByPlatform('user-123', 'Twitter');
+
+ expect(result).toBeNull();
+ });
+
+ it('should return profile if platform exists', async () => {
+ db.query.mockResolvedValue({ rows: [{ id: 'profile-1' }] });
+
+ const result = await userModel.findWebProfileByPlatform('user-123', 'Twitter');
+
+ expect(result).toEqual({ id: 'profile-1' });
+ });
+
+ it('should query with correct SQL and params', async () => {
+ db.query.mockResolvedValue({ rows: [] });
+
+ await userModel.findWebProfileByPlatform('user-123', 'Twitter');
+
+ expect(db.query).toHaveBeenCalledWith(
+ expect.stringContaining('WHERE user_id = $1 AND platform = $2'),
+ ['user-123', 'Twitter']
+ );
+ });
+ });
+
+ // ========================================
+ // createWebProfile tests
+ // ========================================
+ describe('createWebProfile', () => {
+ it('should return null if insert returns no rows', async () => {
+ db.query.mockResolvedValue({ rows: [] });
+
+ const result = await userModel.createWebProfile('user-123', 'Twitter', 'https://twitter.com/user');
+
+ expect(result).toBeNull();
+ });
+
+ it('should return created profile', async () => {
+ const createdProfile = {
+ id: 'profile-new',
+ platform: 'LinkedIn',
+ url: 'https://linkedin.com/in/user',
+ };
+ db.query.mockResolvedValue({ rows: [createdProfile] });
+
+ const result = await userModel.createWebProfile('user-123', 'LinkedIn', 'https://linkedin.com/in/user');
+
+ expect(result).toEqual(createdProfile);
+ });
+
+ it('should insert with correct values', async () => {
+ db.query.mockResolvedValue({ rows: [fixtures.mockWebProfile] });
+
+ await userModel.createWebProfile('user-123', 'Twitter', 'https://twitter.com/testuser');
+
+ expect(db.query).toHaveBeenCalledWith(
+ expect.stringContaining('INSERT INTO web_profiles'),
+ ['user-123', 'Twitter', 'https://twitter.com/testuser']
+ );
+ });
+ });
+
+ // ========================================
+ // findWebProfileById tests
+ // ========================================
+ describe('findWebProfileById', () => {
+ it('should return null if profile not found', async () => {
+ db.query.mockResolvedValue({ rows: [] });
+
+ const result = await userModel.findWebProfileById('profile-999');
+
+ expect(result).toBeNull();
+ });
+
+ it('should return profile with user_id', async () => {
+ db.query.mockResolvedValue({ rows: [fixtures.mockWebProfile] });
+
+ const result = await userModel.findWebProfileById('profile-1');
+
+ expect(result).toEqual(fixtures.mockWebProfile);
+ expect(result.user_id).toBe('user-123');
+ });
+ });
+
+ // ========================================
+ // deleteWebProfile tests
+ // ========================================
+ describe('deleteWebProfile', () => {
+ it('should return null if profile not found', async () => {
+ db.query.mockResolvedValue({ rows: [] });
+
+ const result = await userModel.deleteWebProfile('profile-999');
+
+ expect(result).toBeNull();
+ });
+
+ it('should return deleted profile id', async () => {
+ db.query.mockResolvedValue({ rows: [{ id: 'profile-1' }] });
+
+ const result = await userModel.deleteWebProfile('profile-1');
+
+ expect(result).toEqual({ id: 'profile-1' });
+ });
+
+ it('should delete profile by id', async () => {
+ db.query.mockResolvedValue({ rows: [{ id: 'profile-1' }] });
+
+ await userModel.deleteWebProfile('profile-1');
+
+ expect(db.query).toHaveBeenCalledWith(
+ expect.stringContaining('DELETE FROM web_profiles WHERE id = $1'),
+ ['profile-1']
+ );
+ });
+ });
+
+ // ========================================
+ // findById tests
+ // ========================================
+ describe('findById', () => {
+ it('should return null if user not found', async () => {
+ db.query.mockResolvedValue({ rows: [] });
+
+ const result = await userModel.findById('user-999');
+
+ expect(result).toBeNull();
+ });
+
+ it('should return user if found', async () => {
+ db.query.mockResolvedValue({ rows: [fixtures.mockUser] });
+
+ const result = await userModel.findById('user-123');
+
+ expect(result).toEqual(fixtures.mockUser);
+ expect(result.id).toBe('user-123');
+ });
+ });
+
+ // ========================================
+ // updateAvatar tests
+ // ========================================
+ describe('updateAvatar', () => {
+ it('should update user avatar', async () => {
+ const updatedUser = { ...fixtures.mockUser, profile_picture: 'https://cdn.example.com/avatar.jpg' };
+ db.query.mockResolvedValue({ rows: [updatedUser] });
+
+ const result = await userModel.updateAvatar('user-123', 'https://cdn.example.com/avatar.jpg');
+
+ expect(result).toEqual(updatedUser);
+ expect(db.query).toHaveBeenCalledWith(
+ expect.stringContaining('UPDATE users SET profile_picture'),
+ expect.arrayContaining(['https://cdn.example.com/avatar.jpg', 'user-123'])
+ );
+ });
+ });
+
+ // ========================================
+ // deleteAvatar tests
+ // ========================================
+ describe('deleteAvatar', () => {
+ it('should set avatar to null', async () => {
+ const updatedUser = { ...fixtures.mockUser, profile_picture: null };
+ db.query.mockResolvedValue({ rows: [updatedUser] });
+
+ const result = await userModel.deleteAvatar('user-123');
+
+ expect(result.profile_picture).toBeNull();
+ });
+ });
+
+ // ========================================
+ // updateCoverPhoto tests
+ // ========================================
+ describe('updateCoverPhoto', () => {
+ it('should update user cover photo', async () => {
+ const updatedUser = { ...fixtures.mockUser, cover_photo: 'https://cdn.example.com/cover.jpg' };
+ db.query.mockResolvedValue({ rows: [updatedUser] });
+
+ const result = await userModel.updateCoverPhoto('user-123', 'https://cdn.example.com/cover.jpg');
+
+ expect(result).toEqual(updatedUser);
+ expect(db.query).toHaveBeenCalledWith(
+ expect.stringContaining('UPDATE users SET cover_photo'),
+ expect.arrayContaining(['https://cdn.example.com/cover.jpg', 'user-123'])
+ );
+ });
+ });
+
+ // ========================================
+ // deleteCoverPhoto tests
+ // ========================================
+ describe('deleteCoverPhoto', () => {
+ it('should set cover photo to null', async () => {
+ const updatedUser = { ...fixtures.mockUser, cover_photo: null };
+ db.query.mockResolvedValue({ rows: [updatedUser] });
+
+ const result = await userModel.deleteCoverPhoto('user-123');
+
+ expect(result.cover_photo).toBeNull();
+ });
+ });
+});
From d295d7b215ebb679b0c480fd71839ce6442bb474 Mon Sep 17 00:00:00 2001
From: Beshoy205
Date: Sat, 21 Mar 2026 20:22:12 +0200
Subject: [PATCH 089/535] test: add tests for web profiles and media upload
service layer
- Add service-level tests for getMyWebProfile, addWebProfile, and deleteWebProfile
- Cover success cases and error handling (404, 403, 409) for web profiles
- Add service-level tests for uploadMyAvatar and uploadMyCoverPhoto
- Add service-level tests for deleteMyAvatar and deleteMyCoverPhoto
- Cover 404 error when user or resource not found for media operations
---
tests/user/services/users.service.test.js | 284 ++++++++++++++++++++++
1 file changed, 284 insertions(+)
create mode 100644 tests/user/services/users.service.test.js
diff --git a/tests/user/services/users.service.test.js b/tests/user/services/users.service.test.js
new file mode 100644
index 0000000..d50c286
--- /dev/null
+++ b/tests/user/services/users.service.test.js
@@ -0,0 +1,284 @@
+// ============================================================
+// tests/user/services/users.service.test.js
+// Unit tests for users service layer (business logic)
+// ============================================================
+
+const fixtures = require('../helpers/test-fixtures');
+
+// Mock the model layer before importing service
+ jest.mock('../../../src/models/user.model', () => ({
+ findById: jest.fn(),
+ findFullById: jest.fn(),
+ findPublicById: jest.fn(),
+ findWebProfilesByUserId: jest.fn(),
+ findWebProfileByPlatform: jest.fn(),
+ createWebProfile: jest.fn(),
+ deleteWebProfile: jest.fn(),
+ findWebProfileById: jest.fn(),
+ updateAvatar: jest.fn(),
+ deleteAvatar: jest.fn(),
+ updateCoverPhoto: jest.fn(),
+ deleteCoverPhoto: jest.fn(),
+ updateProfile: jest.fn(),
+ updateAccount: jest.fn(),
+ updateRole: jest.fn(),
+ updatePrivacy: jest.fn(),
+ isFollowing: jest.fn(),
+}));
+
+const userModel = require('../../../src/models/user.model');
+const usersService = require('../../../src/services/users.service');
+
+describe('Users Service', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ // ========================================
+ // getMyWebProfile tests
+ // ========================================
+ describe('getMyWebProfile', () => {
+ it('should return empty array if no profiles exist', async () => {
+ userModel.findWebProfilesByUserId.mockResolvedValue([]);
+
+ const result = await usersService.getMyWebProfile('user-123');
+
+ expect(result).toEqual([]);
+ expect(userModel.findWebProfilesByUserId).toHaveBeenCalledWith('user-123');
+ });
+
+ it('should return all web profiles for user', async () => {
+ userModel.findWebProfilesByUserId.mockResolvedValue(fixtures.mockWebProfiles);
+
+ const result = await usersService.getMyWebProfile('user-123');
+
+ expect(result).toEqual(fixtures.mockWebProfiles);
+ expect(result).toHaveLength(2);
+ });
+ });
+
+ // ========================================
+ // addWebProfile tests
+ // ========================================
+ describe('addWebProfile', () => {
+ it('should throw 409 if platform already exists', async () => {
+ userModel.findWebProfileByPlatform.mockResolvedValue({ id: 'profile-1', platform: 'Twitter' });
+
+ await expect(
+ usersService.addWebProfile('user-123', 'Twitter', 'https://twitter.com/newuser')
+ ).rejects.toThrow('A profile for this platform already exists.');
+
+ expect(userModel.findWebProfileByPlatform).toHaveBeenCalledWith('user-123', 'Twitter');
+ expect(userModel.createWebProfile).not.toHaveBeenCalled();
+ });
+
+ it('should create profile if platform does not exist', async () => {
+ const newProfile = {
+ id: 'profile-new',
+ platform: 'LinkedIn',
+ url: 'https://linkedin.com/in/user',
+ };
+ userModel.findWebProfileByPlatform.mockResolvedValue(null);
+ userModel.createWebProfile.mockResolvedValue(newProfile);
+
+ const result = await usersService.addWebProfile('user-123', 'LinkedIn', 'https://linkedin.com/in/user');
+
+ expect(result).toEqual(newProfile);
+ expect(userModel.createWebProfile).toHaveBeenCalledWith('user-123', 'LinkedIn', 'https://linkedin.com/in/user');
+ });
+
+ it('should throw 409 error with correct code', async () => {
+ userModel.findWebProfileByPlatform.mockResolvedValue({ id: 'profile-1' });
+
+ try {
+ await usersService.addWebProfile('user-123', 'Twitter', 'https://twitter.com/user');
+ throw new Error('Should have thrown error');
+ } catch (err) {
+ expect(err.statusCode).toBe(409);
+ expect(err.code).toBe('RESOURCE_ALREADY_EXISTS');
+ }
+ });
+ });
+
+ // ========================================
+ // deleteWebProfile tests
+ // ========================================
+ describe('deleteWebProfile', () => {
+ it('should throw 404 if profile not found', async () => {
+ userModel.findWebProfileById.mockResolvedValue(null);
+
+ await expect(
+ usersService.deleteWebProfile('user-123', 'profile-999')
+ ).rejects.toThrow('Web profile not found');
+
+ expect(userModel.deleteWebProfile).not.toHaveBeenCalled();
+ });
+
+ it('should throw 403 if user is not profile owner', async () => {
+ userModel.findWebProfileById.mockResolvedValue({
+ id: 'profile-1',
+ user_id: 'different-user',
+ platform: 'Twitter',
+ });
+
+ await expect(
+ usersService.deleteWebProfile('user-123', 'profile-1')
+ ).rejects.toThrow('You are not allowed to delete this profile.');
+
+ expect(userModel.deleteWebProfile).not.toHaveBeenCalled();
+ });
+
+ it('should delete profile if user is owner', async () => {
+ userModel.findWebProfileById.mockResolvedValue({
+ id: 'profile-1',
+ user_id: 'user-123',
+ platform: 'Twitter',
+ });
+ userModel.deleteWebProfile.mockResolvedValue({ id: 'profile-1' });
+
+ const result = await usersService.deleteWebProfile('user-123', 'profile-1');
+
+ expect(result).toEqual({ id: 'profile-1' });
+ expect(userModel.deleteWebProfile).toHaveBeenCalledWith('profile-1');
+ });
+
+ it('should throw 403 error with correct code', async () => {
+ userModel.findWebProfileById.mockResolvedValue({
+ id: 'profile-1',
+ user_id: 'different-user',
+ });
+
+ try {
+ await usersService.deleteWebProfile('user-123', 'profile-1');
+ throw new Error('Should have thrown error');
+ } catch (err) {
+ expect(err.statusCode).toBe(403);
+ expect(err.code).toBe('PERMISSION_DENIED');
+ }
+ });
+ });
+
+ // ========================================
+ // uploadMyAvatar tests
+ // ========================================
+ describe('uploadMyAvatar', () => {
+ it('should throw 404 if user not found', async () => {
+ userModel.findById.mockResolvedValue(null);
+
+ await expect(
+ usersService.uploadMyAvatar('user-999', { filename: 'avatar.jpg' })
+ ).rejects.toThrow('User not found');
+
+ expect(userModel.updateAvatar).not.toHaveBeenCalled();
+ });
+
+ it('should upload avatar with CDN URL', async () => {
+ userModel.findById.mockResolvedValue(fixtures.mockUser);
+ userModel.updateAvatar.mockResolvedValue({
+ ...fixtures.mockUser,
+ profile_picture: 'https://cdn.rythmify.com/avatars/user-123.jpg',
+ });
+
+ const result = await usersService.uploadMyAvatar('user-123', { filename: 'avatar.jpg' });
+
+ expect(userModel.updateAvatar).toHaveBeenCalledWith('user-123', 'https://cdn.rythmify.com/avatars/user-123.jpg');
+ expect(result.profile_picture).toBe('https://cdn.rythmify.com/avatars/user-123.jpg');
+ });
+ });
+
+ // ========================================
+ // deleteMyCoverPhoto tests
+ // ========================================
+ describe('deleteMyCoverPhoto', () => {
+ it('should throw 404 if user not found', async () => {
+ userModel.findById.mockResolvedValue(null);
+
+ await expect(usersService.deleteMyCoverPhoto('user-999')).rejects.toThrow('User not found');
+
+ expect(userModel.deleteCoverPhoto).not.toHaveBeenCalled();
+ });
+
+ it('should throw 404 if user has no cover photo', async () => {
+ userModel.findById.mockResolvedValue({ ...fixtures.mockUser, cover_photo: null });
+
+ await expect(usersService.deleteMyCoverPhoto('user-123')).rejects.toThrow('No cover photo to delete');
+
+ expect(userModel.deleteCoverPhoto).not.toHaveBeenCalled();
+ });
+
+ it('should delete cover photo if exists', async () => {
+ userModel.findById.mockResolvedValue({
+ ...fixtures.mockUser,
+ cover_photo: 'https://cdn.example.com/cover.jpg',
+ });
+ userModel.deleteCoverPhoto.mockResolvedValue({ cover_photo: null });
+
+ const result = await usersService.deleteMyCoverPhoto('user-123');
+
+ expect(result).toEqual({ cover_photo: null });
+ expect(userModel.deleteCoverPhoto).toHaveBeenCalledWith('user-123');
+ });
+ });
+
+ // ========================================
+ // deleteMyAvatar tests
+ // ========================================
+ describe('deleteMyAvatar', () => {
+ it('should throw 404 if user not found', async () => {
+ userModel.findById.mockResolvedValue(null);
+
+ await expect(usersService.deleteMyAvatar('user-999')).rejects.toThrow('User not found');
+
+ expect(userModel.deleteAvatar).not.toHaveBeenCalled();
+ });
+
+ it('should throw 404 if user has no avatar', async () => {
+ userModel.findById.mockResolvedValue({ ...fixtures.mockUser, profile_picture: null });
+
+ await expect(usersService.deleteMyAvatar('user-123')).rejects.toThrow('No avatar to delete');
+
+ expect(userModel.deleteAvatar).not.toHaveBeenCalled();
+ });
+
+ it('should delete avatar if exists', async () => {
+ userModel.findById.mockResolvedValue({
+ ...fixtures.mockUser,
+ profile_picture: 'https://cdn.example.com/avatar.jpg',
+ });
+ userModel.deleteAvatar.mockResolvedValue({ profile_picture: null });
+
+ const result = await usersService.deleteMyAvatar('user-123');
+
+ expect(result).toEqual({ profile_picture: null });
+ expect(userModel.deleteAvatar).toHaveBeenCalledWith('user-123');
+ });
+ });
+
+ // ========================================
+ // updateMyCoverPhoto tests
+ // ========================================
+ describe('updateMyCoverPhoto', () => {
+ it('should throw 404 if user not found', async () => {
+ userModel.findById.mockResolvedValue(null);
+
+ await expect(
+ usersService.uploadMyCoverPhoto('user-999', { filename: 'cover.jpg' })
+ ).rejects.toThrow('User not found');
+
+ expect(userModel.updateCoverPhoto).not.toHaveBeenCalled();
+ });
+
+ it('should update cover photo with CDN URL', async () => {
+ userModel.findById.mockResolvedValue(fixtures.mockUser);
+ userModel.updateCoverPhoto.mockResolvedValue({
+ ...fixtures.mockUser,
+ cover_photo: 'https://cdn.rythmify.com/covers/user-123.jpg',
+ });
+
+ const result = await usersService.uploadMyCoverPhoto('user-123', { filename: 'cover.jpg' });
+
+ expect(userModel.updateCoverPhoto).toHaveBeenCalledWith('user-123', 'https://cdn.rythmify.com/covers/user-123.jpg');
+ expect(result.cover_photo).toBe('https://cdn.rythmify.com/covers/user-123.jpg');
+ });
+ });
+});
From 42088ccaf5c12ab32026d4fac6c9ccfcec3cfb35 Mon Sep 17 00:00:00 2001
From: Beshoy205
Date: Sat, 21 Mar 2026 20:52:00 +0200
Subject: [PATCH 090/535] test: add tests for profile, account, role, privacy,
genres, and onboarding controller layer
- Add controller-level tests for getMe, getUserById, updateMe, and updateMyAccount
- Add controller-level tests for switchRole, updatePrivacy, and deleteMyAvatar
- Add controller-level tests for getMyGenres, replaceMyGenres, and completeOnboarding
- Cover success cases and error handling (400, 404) throughout
---
.../user/controllers/users.controller.test.js | 409 ++++++++++++++++++
1 file changed, 409 insertions(+)
diff --git a/tests/user/controllers/users.controller.test.js b/tests/user/controllers/users.controller.test.js
index 6e2529d..19ea443 100644
--- a/tests/user/controllers/users.controller.test.js
+++ b/tests/user/controllers/users.controller.test.js
@@ -233,4 +233,413 @@ describe('Users Controller', () => {
});
});
});
+ // ========================================
+ // deleteMyAvatar tests
+ // ========================================
+ describe('deleteMyAvatar', () => {
+ it('should call service with user id', async () => {
+ const { req, res } = createMocks();
+ res.status.mockReturnThis();
+ usersService.deleteMyAvatar.mockResolvedValue({ profile_picture: null });
+
+ await usersController.deleteMyAvatar(req, res);
+
+ expect(usersService.deleteMyAvatar).toHaveBeenCalledWith('user-123');
+ expect(res.json).toHaveBeenCalled();
+ });
+
+ it('should handle 404 no avatar', async () => {
+ const { req, res } = createMocks();
+ const error = new Error('No avatar to delete');
+ error.statusCode = 404;
+ usersService.deleteMyAvatar.mockRejectedValue(error);
+
+ try {
+ await usersController.deleteMyAvatar(req, res);
+ throw new Error('Should have thrown error');
+ } catch (err) {
+ expect(err.statusCode).toBe(404);
+ }
+ });
+ });
+
+ // ========================================
+ // getMe tests
+ // ========================================
+ describe('getMe', () => {
+ it('should return user profile', async () => {
+ const { req, res } = createMocks();
+ res.status.mockReturnThis();
+ usersService.getMe.mockResolvedValue(fixtures.mockUser);
+
+ await usersController.getMe(req, res);
+
+ expect(usersService.getMe).toHaveBeenCalledWith('user-123');
+ expect(res.json).toHaveBeenCalledWith({
+ data: fixtures.mockUser,
+ message: 'Own profile returned successfully.',
+ });
+ });
+
+ it('should handle service errors', async () => {
+ const { req, res } = createMocks();
+ const error = new Error('User not found');
+ error.statusCode = 404;
+ usersService.getMe.mockRejectedValue(error);
+
+ try {
+ await usersController.getMe(req, res);
+ throw new Error('Should have thrown error');
+ } catch (err) {
+ expect(err.statusCode).toBe(404);
+ }
+ });
+ });
+
+ // ========================================
+ // getUserById tests
+ // ========================================
+ describe('getUserById', () => {
+ it('should return user profile by id', async () => {
+ const { req, res } = createMocks();
+ req.params = { user_id: 'user-456' };
+ res.status.mockReturnThis();
+ usersService.getUserById.mockResolvedValue(fixtures.mockUser);
+
+ await usersController.getUserById(req, res);
+
+ expect(usersService.getUserById).toHaveBeenCalledWith('user-456', 'user-123');
+ expect(res.json).toHaveBeenCalledWith({
+ data: fixtures.mockUser,
+ message: 'User profile returned successfully.',
+ });
+ });
+
+ it('should handle 404 user not found', async () => {
+ const { req, res } = createMocks();
+ req.params = { user_id: 'user-999' };
+ const error = new Error('User not found');
+ error.statusCode = 404;
+ usersService.getUserById.mockRejectedValue(error);
+
+ try {
+ await usersController.getUserById(req, res);
+ throw new Error('Should have thrown error');
+ } catch (err) {
+ expect(err.statusCode).toBe(404);
+ }
+ });
+ });
+
+ // ========================================
+ // updateMe tests
+ // ========================================
+ describe('updateMe', () => {
+ it('should update profile with valid data', async () => {
+ const { req, res } = createMocks();
+ req.body = { display_name: 'New Name', bio: 'New bio' };
+ res.status.mockReturnThis();
+ usersService.updateMe.mockResolvedValue({
+ ...fixtures.mockUser,
+ display_name: 'New Name',
+ bio: 'New bio',
+ });
+
+ await usersController.updateMe(req, res);
+
+ expect(usersService.updateMe).toHaveBeenCalledWith('user-123', {
+ display_name: 'New Name',
+ bio: 'New bio',
+ });
+ expect(res.json).toHaveBeenCalled();
+ });
+
+ it('should update only provided fields', async () => {
+ const { req, res } = createMocks();
+ req.body = { username: 'newusername' };
+ res.status.mockReturnThis();
+ usersService.updateMe.mockResolvedValue(fixtures.mockUser);
+
+ await usersController.updateMe(req, res);
+
+ expect(usersService.updateMe).toHaveBeenCalledWith('user-123', {
+ username: 'newusername',
+ });
+ });
+
+ it('should handle service errors', async () => {
+ const { req, res } = createMocks();
+ req.body = { display_name: 'New Name' };
+ const error = new Error('Validation failed');
+ error.statusCode = 400;
+ usersService.updateMe.mockRejectedValue(error);
+
+ try {
+ await usersController.updateMe(req, res);
+ throw new Error('Should have thrown error');
+ } catch (err) {
+ expect(err.statusCode).toBe(400);
+ }
+ });
+ });
+
+ // ========================================
+ // updateMyAccount tests
+ // ========================================
+ describe('updateMyAccount', () => {
+ it('should update account with date_of_birth and gender', async () => {
+ const { req, res } = createMocks();
+ req.body = { date_of_birth: '1990-01-01', gender: 'female' };
+ res.status.mockReturnThis();
+ usersService.updateMyAccount.mockResolvedValue({
+ ...fixtures.mockUser,
+ date_of_birth: '1990-01-01',
+ gender: 'female',
+ });
+
+ await usersController.updateMyAccount(req, res);
+
+ expect(usersService.updateMyAccount).toHaveBeenCalledWith('user-123', {
+ date_of_birth: '1990-01-01',
+ gender: 'female',
+ });
+ expect(res.json).toHaveBeenCalled();
+ });
+
+ it('should update only provided account fields', async () => {
+ const { req, res } = createMocks();
+ req.body = { gender: 'male' };
+ res.status.mockReturnThis();
+ usersService.updateMyAccount.mockResolvedValue(fixtures.mockUser);
+
+ await usersController.updateMyAccount(req, res);
+
+ expect(usersService.updateMyAccount).toHaveBeenCalledWith('user-123', {
+ gender: 'male',
+ });
+ });
+ });
+
+ // ========================================
+ // switchRole tests
+ // ========================================
+ describe('switchRole', () => {
+ it('should switch user role', async () => {
+ const { req, res } = createMocks();
+ req.body = { role: 'artist' };
+ res.status.mockReturnThis();
+ usersService.switchRole.mockResolvedValue({
+ ...fixtures.mockUser,
+ role: 'artist',
+ });
+
+ await usersController.switchRole(req, res);
+
+ expect(usersService.switchRole).toHaveBeenCalledWith('user-123', 'artist');
+ expect(res.json).toHaveBeenCalled();
+ });
+
+ it('should handle invalid role', async () => {
+ const { req, res } = createMocks();
+ req.body = { role: 'invalid_role' };
+ const error = new Error('Invalid role');
+ error.statusCode = 400;
+ usersService.switchRole.mockRejectedValue(error);
+
+ try {
+ await usersController.switchRole(req, res);
+ throw new Error('Should have thrown error');
+ } catch (err) {
+ expect(err.statusCode).toBe(400);
+ }
+ });
+ });
+
+ // ========================================
+ // updatePrivacy tests
+ // ========================================
+ describe('updatePrivacy', () => {
+ it('should throw 400 if is_private is missing', async () => {
+ const { req, res } = createMocks();
+ req.body = {};
+
+ try {
+ await usersController.updatePrivacy(req, res);
+ throw new Error('Should have thrown error');
+ } catch (err) {
+ expect(err.statusCode).toBe(400);
+ expect(err.message).toContain('is_private field is required');
+ }
+ });
+
+ it('should set profile to private', async () => {
+ const { req, res } = createMocks();
+ req.body = { is_private: true };
+ res.status.mockReturnThis();
+ usersService.updatePrivacy.mockResolvedValue({
+ ...fixtures.mockUser,
+ is_private: true,
+ });
+
+ await usersController.updatePrivacy(req, res);
+
+ expect(usersService.updatePrivacy).toHaveBeenCalledWith('user-123', true);
+ expect(res.json).toHaveBeenCalled();
+ });
+
+ it('should set profile to public', async () => {
+ const { req, res } = createMocks();
+ req.body = { is_private: false };
+ res.status.mockReturnThis();
+ usersService.updatePrivacy.mockResolvedValue({
+ ...fixtures.mockUser,
+ is_private: false,
+ });
+
+ await usersController.updatePrivacy(req, res);
+
+ expect(usersService.updatePrivacy).toHaveBeenCalledWith('user-123', false);
+ expect(res.json).toHaveBeenCalled();
+ });
+ });
+
+ // ========================================
+ // getMyGenres tests
+ // ========================================
+ describe('getMyGenres', () => {
+ it('should return favorite genres', async () => {
+ const { req, res } = createMocks();
+ res.status.mockReturnThis();
+ const mockGenres = ['Rock', 'Jazz', 'Pop'];
+ usersService.getMyGenres.mockResolvedValue(mockGenres);
+
+ await usersController.getMyGenres(req, res);
+
+ expect(usersService.getMyGenres).toHaveBeenCalledWith('user-123');
+ expect(res.json).toHaveBeenCalledWith({
+ data: mockGenres,
+ message: 'Favorite genres returned successfully.',
+ });
+ });
+
+ it('should return empty array if no genres', async () => {
+ const { req, res } = createMocks();
+ res.status.mockReturnThis();
+ usersService.getMyGenres.mockResolvedValue([]);
+
+ await usersController.getMyGenres(req, res);
+
+ expect(res.json).toHaveBeenCalledWith({
+ data: [],
+ message: 'Favorite genres returned successfully.',
+ });
+ });
+ });
+
+ // ========================================
+ // replaceMyGenres tests
+ // ========================================
+ describe('replaceMyGenres', () => {
+ it('should throw 400 if genres is not array', async () => {
+ const { req, res } = createMocks();
+ req.body = { genres: 'Rock' };
+
+ try {
+ await usersController.replaceMyGenres(req, res);
+ throw new Error('Should have thrown error');
+ } catch (err) {
+ expect(err.statusCode).toBe(400);
+ expect(err.message).toContain('genres must be an array');
+ }
+ });
+
+ it('should throw 400 if genres exceeds 10 items', async () => {
+ const { req, res } = createMocks();
+ req.body = {
+ genres: Array(11).fill('Genre'),
+ };
+
+ try {
+ await usersController.replaceMyGenres(req, res);
+ throw new Error('Should have thrown error');
+ } catch (err) {
+ expect(err.statusCode).toBe(400);
+ expect(err.message).toContain('Maximum of 10 genres allowed');
+ }
+ });
+
+ it('should update genres with valid data', async () => {
+ const { req, res } = createMocks();
+ const genres = ['Rock', 'Jazz', 'Pop'];
+ req.body = { genres };
+ res.status.mockReturnThis();
+ usersService.replaceMyGenres.mockResolvedValue(genres);
+
+ await usersController.replaceMyGenres(req, res);
+
+ expect(usersService.replaceMyGenres).toHaveBeenCalledWith('user-123', genres);
+ expect(res.json).toHaveBeenCalled();
+ });
+ });
+
+ // ========================================
+ // completeOnboarding tests
+ // ========================================
+ describe('completeOnboarding', () => {
+ it('should complete onboarding with all fields', async () => {
+ const { req, res } = createMocks();
+ req.body = {
+ display_name: 'John Doe',
+ gender: 'male',
+ date_of_birth: '1990-01-01',
+ bio: 'Music lover',
+ city: 'New York',
+ country: 'USA',
+ };
+ res.status.mockReturnThis();
+ usersService.completeOnboarding.mockResolvedValue({
+ ...fixtures.mockUser,
+ ...req.body,
+ });
+
+ await usersController.completeOnboarding(req, res);
+
+ expect(usersService.completeOnboarding).toHaveBeenCalledWith('user-123', {
+ display_name: 'John Doe',
+ gender: 'male',
+ date_of_birth: '1990-01-01',
+ bio: 'Music lover',
+ city: 'New York',
+ country: 'USA',
+ });
+ expect(res.json).toHaveBeenCalled();
+ });
+
+ it('should complete onboarding with partial fields', async () => {
+ const { req, res } = createMocks();
+ req.body = { display_name: 'Jane Doe', bio: 'Artist' };
+ res.status.mockReturnThis();
+ usersService.completeOnboarding.mockResolvedValue(fixtures.mockUser);
+
+ await usersController.completeOnboarding(req, res);
+
+ expect(usersService.completeOnboarding).toHaveBeenCalledWith('user-123', {
+ display_name: 'Jane Doe',
+ bio: 'Artist',
+ });
+ });
+
+ it('should handle empty body', async () => {
+ const { req, res } = createMocks();
+ req.body = {};
+ res.status.mockReturnThis();
+ usersService.completeOnboarding.mockResolvedValue(fixtures.mockUser);
+
+ await usersController.completeOnboarding(req, res);
+
+ expect(usersService.completeOnboarding).toHaveBeenCalledWith('user-123', {});
+ expect(res.json).toHaveBeenCalled();
+ });
+ });
+
\ No newline at end of file
From e539a85ce0c2f3d9efc605052330251da385efe6 Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Sat, 21 Mar 2026 23:45:13 +0200
Subject: [PATCH 091/535] Added Endpoint // POST /messages/conversations/ensure
with track id validation
-Added API documentation for POST /messages/conversations/ensure in openapi.yaml, routes,controllers,services
-Added validation to check that a real id exists for tracks before sending the message
-ensure endpoint is for cross team to Create/fetch conversation without sending message before opening a chat UI
---
openapi.yaml | 85 ++++++++++
src/controllers/messages.controller.js | 22 +++
src/routes/messages.routes.js | 3 +
src/services/messages.service.js | 172 ++++++++++++-------
tests/messages.controller.unit.test.js | 74 +++++++++
tests/messages.service.unit.test.js | 221 ++++++++++++++++++++++++-
6 files changed, 517 insertions(+), 60 deletions(-)
diff --git a/openapi.yaml b/openapi.yaml
index 8dab296..490d22f 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -4053,6 +4053,68 @@ paths:
"500":
$ref: "#/components/responses/InternalError"
+ /messages/conversations/ensure:
+ post:
+ tags: [Messages]
+ summary: Ensure a conversation exists
+ description: |
+ Creates a 1-to-1 conversation if it does not exist, or returns the existing one.
+ This endpoint does not send a message.
+ operationId: ensureConversation
+ security:
+ - bearerAuth: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/EnsureConversationRequest"
+
+ responses:
+ "201":
+ description: Conversation created.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/EnsureConversationResponse"
+
+ "200":
+ description: Conversation fetched successfully.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/EnsureConversationResponse"
+
+ "400":
+ description: Validation failure (for example, missing recipient_id or self-messaging).
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+
+ "401":
+ $ref: "#/components/responses/Unauthorized"
+
+ "403":
+ description: Recipient policy blocks this sender.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+
+ "404":
+ description: Recipient not found.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+
+ "429":
+ $ref: "#/components/responses/RateLimited"
+
+ "500":
+ $ref: "#/components/responses/InternalError"
+
/messages/unread-count:
get:
tags: [Messages]
@@ -9231,6 +9293,16 @@ components:
required: [type, id]
description: |
At least one of `body` or `resource` must be provided.
+ EnsureConversationRequest:
+ type: object
+ required:
+ - recipient_id
+ properties:
+ recipient_id:
+ type: string
+ format: uuid
+ description: UUID of the user to create/fetch a conversation with.
+ example: b2c3d4e5-f6a7-8901-bcde-f12345678901
ConversationListResponse:
type: object
properties:
@@ -9255,6 +9327,14 @@ components:
example: true
data:
$ref: "#/components/schemas/ConversationCreatedResponse_data"
+ EnsureConversationResponse:
+ type: object
+ properties:
+ success:
+ type: boolean
+ example: true
+ data:
+ $ref: "#/components/schemas/EnsureConversationResponse_data"
MessageCreatedResponse:
type: object
properties:
@@ -9331,6 +9411,11 @@ components:
$ref: "#/components/schemas/Conversation"
message:
$ref: "#/components/schemas/Message"
+ EnsureConversationResponse_data:
+ type: object
+ properties:
+ conversation:
+ $ref: "#/components/schemas/Conversation"
UnreadCountResponse_data:
type: object
properties:
diff --git a/src/controllers/messages.controller.js b/src/controllers/messages.controller.js
index 1ecbf5c..46114a0 100644
--- a/src/controllers/messages.controller.js
+++ b/src/controllers/messages.controller.js
@@ -35,6 +35,28 @@ exports.startConversation = async (req, res) => {
return success(res, { message }, 'Message sent.', 200);
};
+// POST /messages/conversations/ensure
+exports.ensureConversation = async (req, res) => {
+ const senderId = getAuthenticatedUserId(req, res);
+ if (!senderId) return;
+
+ const { recipient_id } = req.body;
+ if (!recipient_id) {
+ return error(res, 'VALIDATION_FAILED', 'recipient_id is required.', 400);
+ }
+
+ const { conversation, isNew } = await messagesService.ensureConversation({
+ senderId,
+ recipientId: recipient_id,
+ });
+
+ if (isNew) {
+ return success(res, { conversation }, 'Conversation created.', 201);
+ }
+
+ return success(res, { conversation }, 'Conversation fetched successfully.', 200);
+};
+
// GET /messages/conversations
exports.listConversations = async (req, res) => {
const userId = getAuthenticatedUserId(req, res);
diff --git a/src/routes/messages.routes.js b/src/routes/messages.routes.js
index 49bd489..b2ce6b8 100644
--- a/src/routes/messages.routes.js
+++ b/src/routes/messages.routes.js
@@ -8,6 +8,9 @@ const asyncHandler = require('../utils/async-handler');
// POST /messages/new — Start a new conversation (or append to existing)
router.post('/new', authenticate, asyncHandler(controller.startConversation));
+// POST /messages/conversations/ensure — Create/fetch conversation without sending message
+router.post('/conversations/ensure', authenticate, asyncHandler(controller.ensureConversation));
+
// GET /messages/conversations — List all conversations
router.get('/conversations', authenticate, asyncHandler(controller.listConversations));
diff --git a/src/services/messages.service.js b/src/services/messages.service.js
index c674edf..4df2ed3 100644
--- a/src/services/messages.service.js
+++ b/src/services/messages.service.js
@@ -1,6 +1,9 @@
const messageModel = require('../models/message.model');
const AppError = require('../utils/app-error');
+const { validate: isUuid } = require('uuid');
+
+const ALLOWED_EMBED_TYPES = ['track', 'playlist'];
const validateSenderId = (senderId) => {
if (!senderId) {
@@ -8,38 +11,60 @@ const validateSenderId = (senderId) => {
}
};
-exports.assertConversationAccess = async ({ conversationId, userId, allowSoftDeleted = false }) => {
- const conversation = await messageModel.findConversationById(conversationId);
- if (!conversation) {
- throw new AppError('Conversation not found.', 404, 'CONVERSATION_NOT_FOUND');
- }
+const validateMessagePayload = async ({ body, resource, requireContent = true }) => {
+ const normalizedBody = typeof body === 'string' ? body.trim() : '';
- const isParticipant =
- conversation.user_a_id === userId ||
- conversation.user_b_id === userId;
- if (!isParticipant) {
- throw new AppError('You do not have access to this conversation.', 403, 'FORBIDDEN');
+ if (normalizedBody && normalizedBody.length > 2000) {
+ throw new AppError('Message body must not exceed 2000 characters.', 400, 'MESSAGES_BODY_TOO_LONG');
}
- if (!allowSoftDeleted) {
- const deletedByUser =
- (conversation.user_a_id === userId && conversation.deleted_by_a) ||
- (conversation.user_b_id === userId && conversation.deleted_by_b);
- if (deletedByUser) {
- throw new AppError('Conversation not found.', 404, 'CONVERSATION_NOT_FOUND');
+ let normalizedResource = null;
+ if (resource !== undefined && resource !== null) {
+ if (typeof resource !== 'object' || Array.isArray(resource)) {
+ throw new AppError('Embedded resource must be an object with type and id.', 400, 'MESSAGES_INVALID_EMBED_RESOURCE');
}
- }
- return conversation;
-};
+ const type = typeof resource.type === 'string' ? resource.type.trim().toLowerCase() : '';
+ const id = typeof resource.id === 'string' ? resource.id.trim() : '';
-// ------------------------------------------------------------
-// Endpoint 1 — Start a new conversation
-// ------------------------------------------------------------
+ if (!type || !id) {
+ throw new AppError('Embedded resource must include both type and id.', 400, 'MESSAGES_INVALID_EMBED_RESOURCE');
+ }
-exports.startConversation = async ({ senderId, recipientId, body, resource }) => {
- validateSenderId(senderId);
+ if (!ALLOWED_EMBED_TYPES.includes(type)) {
+ throw new AppError('Embedded resource type must be "track" or "playlist".', 400, 'MESSAGES_INVALID_EMBED_TYPE');
+ }
+
+ if (!isUuid(id)) {
+ throw new AppError('Embedded resource id must be a valid UUID.', 400, 'MESSAGES_INVALID_EMBED_ID');
+ }
+
+ if (type === 'track') {
+ const tracksService = require('./tracks.service');
+ try {
+ await tracksService.getTrackById(id);
+ } catch (err) {
+ if (err?.code === 'TRACK_NOT_FOUND') {
+ throw new AppError('Embedded track not found.', 404, 'MESSAGES_EMBED_TRACK_NOT_FOUND');
+ }
+ throw err;
+ }
+ }
+ normalizedResource = { type, id };
+ }
+
+ if (requireContent && !normalizedBody && !normalizedResource) {
+ throw new AppError('A message must contain a body or an embedded resource.', 400, 'MESSAGES_EMPTY');
+ }
+
+ return {
+ body: normalizedBody || null,
+ resource: normalizedResource,
+ };
+};
+
+const assertRecipientCanReceiveMessagesFromSender = async ({ senderId, recipientId }) => {
// 1. Prevent self-messaging
if (senderId === recipientId) {
throw new AppError('You cannot send a message to yourself.', 400, 'MESSAGES_SELF_MESSAGE');
@@ -67,25 +92,43 @@ exports.startConversation = async ({ senderId, recipientId, body, resource }) =>
throw new AppError('This user only accepts messages from people they follow.', 403, 'MESSAGES_FOLLOWERS_ONLY');
}
}
+};
- // 5. Validate message content — at least body or resource required
- const hasBody = typeof body === 'string' && body.trim().length > 0;
- const hasResource = resource && resource.type && resource.id;
- if (!hasBody && !hasResource) {
- throw new AppError('A message must contain a body or an embedded resource.', 400, 'MESSAGES_EMPTY');
+exports.assertConversationAccess = async ({ conversationId, userId, allowSoftDeleted = false }) => {
+ const conversation = await messageModel.findConversationById(conversationId);
+ if (!conversation) {
+ throw new AppError('Conversation not found.', 404, 'CONVERSATION_NOT_FOUND');
}
- // 6. Validate body length
- if (hasBody && body.length > 2000) {
- throw new AppError('Message body must not exceed 2000 characters.', 400, 'MESSAGES_BODY_TOO_LONG');
+ const isParticipant =
+ conversation.user_a_id === userId ||
+ conversation.user_b_id === userId;
+ if (!isParticipant) {
+ throw new AppError('You do not have access to this conversation.', 403, 'FORBIDDEN');
}
- // 7. Validate resource type if provided
- // [DEPENDS: tracks/playlists module] — embed_id references tracks or playlists table
- if (hasResource && !['track', 'playlist'].includes(resource.type)) {
- throw new AppError('Embedded resource type must be "track" or "playlist".', 400, 'MESSAGES_INVALID_EMBED_TYPE');
+ if (!allowSoftDeleted) {
+ const deletedByUser =
+ (conversation.user_a_id === userId && conversation.deleted_by_a) ||
+ (conversation.user_b_id === userId && conversation.deleted_by_b);
+ if (deletedByUser) {
+ throw new AppError('Conversation not found.', 404, 'CONVERSATION_NOT_FOUND');
+ }
}
+ return conversation;
+};
+
+// ------------------------------------------------------------
+// Endpoint 1 — Start a new conversation
+// ------------------------------------------------------------
+
+exports.startConversation = async ({ senderId, recipientId, body, resource }) => {
+ validateSenderId(senderId);
+ await assertRecipientCanReceiveMessagesFromSender({ senderId, recipientId });
+
+ const payload = await validateMessagePayload({ body, resource, requireContent: true });
+
// 8. Order the pair for the DB constraint (user_a_id < user_b_id)
const conversationBefore = await messageModel.findConversationByPair(senderId, recipientId);
const isNew = !conversationBefore;
@@ -97,14 +140,41 @@ exports.startConversation = async ({ senderId, recipientId, body, resource }) =>
const message = await messageModel.createMessage({
conversationId: conversation.id,
senderId,
- body: hasBody ? body.trim() : null,
- embedType: hasResource ? resource.type : null,
- embedId: hasResource ? resource.id : null,
+ body: payload.body,
+ embedType: payload.resource?.type ?? null,
+ embedId: payload.resource?.id ?? null,
});
return { conversation, message, isNew };
};
+// ------------------------------------------------------------
+// Mobile helper — Ensure one-to-one conversation exists without sending a message
+// ------------------------------------------------------------
+
+exports.ensureConversation = async ({ senderId, recipientId }) => {
+ validateSenderId(senderId);
+ await assertRecipientCanReceiveMessagesFromSender({ senderId, recipientId });
+
+ const conversationBefore = await messageModel.findConversationByPair(senderId, recipientId);
+ const isNew = !conversationBefore;
+
+ const conversation = isNew
+ ? await messageModel.createConversation(senderId, recipientId)
+ : conversationBefore;
+
+ const senderIsUserA = conversation.user_a_id === senderId;
+ const deletedBySender =
+ (senderIsUserA && conversation.deleted_by_a) ||
+ (!senderIsUserA && conversation.deleted_by_b);
+
+ if (deletedBySender) {
+ await messageModel.restoreConversationForUser(conversation.id, senderIsUserA);
+ }
+
+ return { conversation, isNew };
+};
+
// ------------------------------------------------------------
// Endpoint 2 — List all conversations GET /messages/conversations
// ------------------------------------------------------------
@@ -277,31 +347,15 @@ exports.sendMessage = async ({ conversationId, senderId, body, resource }) => {
}
}
- // 8. Validate message content — at least body or resource required
- const hasBody = typeof body === 'string' && body.trim().length > 0;
- const hasResource = resource && resource.type && resource.id;
- if (!hasBody && !hasResource) {
- throw new AppError('A message must contain a body or an embedded resource.', 400, 'MESSAGES_EMPTY');
- }
-
- // 9. Validate body length
- if (hasBody && body.length > 2000) {
- throw new AppError('Message body must not exceed 2000 characters.', 400, 'MESSAGES_BODY_TOO_LONG');
- }
-
- // 10. Validate resource type if provided
- // [DEPENDS: tracks/playlists module] — embed_id references tracks or playlists table
- if (hasResource && !['track', 'playlist'].includes(resource.type)) {
- throw new AppError('Embedded resource type must be "track" or "playlist".', 400, 'MESSAGES_INVALID_EMBED_TYPE');
- }
+ const payload = await validateMessagePayload({ body, resource, requireContent: true });
// 11. Insert the message
const message = await messageModel.createMessage({
conversationId,
senderId,
- body: hasBody ? body.trim() : null,
- embedType: hasResource ? resource.type : null,
- embedId: hasResource ? resource.id : null,
+ body: payload.body,
+ embedType: payload.resource?.type ?? null,
+ embedId: payload.resource?.id ?? null,
});
return message;
diff --git a/tests/messages.controller.unit.test.js b/tests/messages.controller.unit.test.js
index ca6b8dd..5a142cf 100644
--- a/tests/messages.controller.unit.test.js
+++ b/tests/messages.controller.unit.test.js
@@ -106,6 +106,79 @@ describe('messages.controller', () => {
});
});
+ describe('ensureConversation', () => {
+ it('returns unauthorized when req.user is missing', async () => {
+ const req = mkReq({ userId: null, body: { recipient_id: 'u2' } });
+ const res = mkRes();
+
+ await controller.ensureConversation(req, res);
+
+ expect(api.error).toHaveBeenCalledWith(
+ res,
+ 'UNAUTHORIZED',
+ 'Authentication required.',
+ 401
+ );
+ expect(messagesService.ensureConversation).not.toHaveBeenCalled();
+ });
+
+ it('returns validation error when recipient_id missing', async () => {
+ const req = mkReq({ body: {} });
+ const res = mkRes();
+
+ await controller.ensureConversation(req, res);
+
+ expect(api.error).toHaveBeenCalledWith(
+ res,
+ 'VALIDATION_FAILED',
+ 'recipient_id is required.',
+ 400
+ );
+ expect(messagesService.ensureConversation).not.toHaveBeenCalled();
+ });
+
+ it('returns 201 for newly created conversation', async () => {
+ const req = mkReq({ body: { recipient_id: 'u2' } });
+ const res = mkRes();
+ messagesService.ensureConversation.mockResolvedValue({
+ conversation: { id: 'c1' },
+ isNew: true,
+ });
+
+ await controller.ensureConversation(req, res);
+
+ expect(messagesService.ensureConversation).toHaveBeenCalledWith({
+ senderId: 'u1',
+ recipientId: 'u2',
+ });
+
+ expect(api.success).toHaveBeenCalledWith(
+ res,
+ { conversation: { id: 'c1' } },
+ 'Conversation created.',
+ 201
+ );
+ });
+
+ it('returns 200 for existing conversation', async () => {
+ const req = mkReq({ body: { recipient_id: 'u2' } });
+ const res = mkRes();
+ messagesService.ensureConversation.mockResolvedValue({
+ conversation: { id: 'c1' },
+ isNew: false,
+ });
+
+ await controller.ensureConversation(req, res);
+
+ expect(api.success).toHaveBeenCalledWith(
+ res,
+ { conversation: { id: 'c1' } },
+ 'Conversation fetched successfully.',
+ 200
+ );
+ });
+ });
+
describe('markMessageReadState', () => {
it('requires is_read', async () => {
const req = mkReq({ params: { conversationId: 'c1', messageId: 'm1' }, body: {} });
@@ -285,6 +358,7 @@ describe('messages.controller', () => {
describe('auth edge cases', () => {
const cases = [
+ ['ensureConversation', () => mkReq({ userId: null, body: { recipient_id: 'u2' } })],
['listConversations', () => mkReq({ userId: null })],
['getConversation', () => mkReq({ userId: null, params: { conversationId: 'c1' } })],
['sendMessage', () => mkReq({ userId: null, params: { conversationId: 'c1' }, body: { body: 'x' } })],
diff --git a/tests/messages.service.unit.test.js b/tests/messages.service.unit.test.js
index 0084a68..a15a8f3 100644
--- a/tests/messages.service.unit.test.js
+++ b/tests/messages.service.unit.test.js
@@ -1,11 +1,17 @@
const service = require('../src/services/messages.service');
const model = require('../src/models/message.model');
+const tracksService = require('../src/services/tracks.service');
const AppError = require('../src/utils/app-error');
jest.mock('../src/models/message.model');
+jest.mock('../src/services/tracks.service', () => ({
+ getTrackById: jest.fn(),
+}));
beforeEach(() => jest.clearAllMocks());
+const validTrackId = '11111111-1111-4111-8111-111111111111';
+
const baseConversation = {
id: 'c1',
user_a_id: 'u1',
@@ -141,6 +147,34 @@ describe('messages.service', () => {
).rejects.toMatchObject({ code: 'MESSAGES_INVALID_EMBED_TYPE' });
});
+ it('rejects non-object embedded resource', async () => {
+ model.findActiveUserById.mockResolvedValue({ id: 'u2' });
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+
+ await expect(
+ service.startConversation({
+ senderId: 'u1',
+ recipientId: 'u2',
+ resource: 'invalid',
+ })
+ ).rejects.toMatchObject({ code: 'MESSAGES_INVALID_EMBED_RESOURCE', statusCode: 400 });
+ });
+
+ it('rejects embedded resource missing fields', async () => {
+ model.findActiveUserById.mockResolvedValue({ id: 'u2' });
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+
+ await expect(
+ service.startConversation({
+ senderId: 'u1',
+ recipientId: 'u2',
+ resource: { type: 'track' },
+ })
+ ).rejects.toMatchObject({ code: 'MESSAGES_INVALID_EMBED_RESOURCE', statusCode: 400 });
+ });
+
it('creates conversation when new', async () => {
model.findActiveUserById.mockResolvedValue({ id: 'u2' });
model.isBlocked.mockResolvedValue(false);
@@ -166,18 +200,128 @@ describe('messages.service', () => {
model.findActiveUserById.mockResolvedValue({ id: 'u2' });
model.isBlocked.mockResolvedValue(false);
model.getMessagesFromPreference.mockResolvedValue('everyone');
+ tracksService.getTrackById.mockResolvedValue({ id: validTrackId });
model.findConversationByPair.mockResolvedValue({ id: 'cExisting' });
model.createMessage.mockResolvedValue({ id: 'm1' });
const out = await service.startConversation({
senderId: 'u1',
recipientId: 'u2',
- resource: { type: 'track', id: 't1' },
+ resource: { type: 'track', id: validTrackId },
});
expect(out.isNew).toBe(false);
expect(model.createConversation).not.toHaveBeenCalled();
});
+
+ it('rejects invalid track resource id', async () => {
+ model.findActiveUserById.mockResolvedValue({ id: 'u2' });
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+
+ await expect(
+ service.startConversation({
+ senderId: 'u1',
+ recipientId: 'u2',
+ resource: { type: 'track', id: 'not-a-uuid' },
+ })
+ ).rejects.toMatchObject({ code: 'MESSAGES_INVALID_EMBED_ID', statusCode: 400 });
+ });
+
+ it('rejects when embedded track does not exist', async () => {
+ model.findActiveUserById.mockResolvedValue({ id: 'u2' });
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+ tracksService.getTrackById.mockRejectedValue({ code: 'TRACK_NOT_FOUND' });
+
+ await expect(
+ service.startConversation({
+ senderId: 'u1',
+ recipientId: 'u2',
+ resource: { type: 'track', id: validTrackId },
+ })
+ ).rejects.toMatchObject({ code: 'MESSAGES_EMBED_TRACK_NOT_FOUND', statusCode: 404 });
+ });
+
+ it('rethrows unexpected track lookup errors', async () => {
+ model.findActiveUserById.mockResolvedValue({ id: 'u2' });
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+ tracksService.getTrackById.mockRejectedValue(new Error('track backend down'));
+
+ await expect(
+ service.startConversation({
+ senderId: 'u1',
+ recipientId: 'u2',
+ resource: { type: 'track', id: validTrackId },
+ })
+ ).rejects.toThrow('track backend down');
+ });
+
+ it('accepts playlist resource without existence validation', async () => {
+ model.findActiveUserById.mockResolvedValue({ id: 'u2' });
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+ model.findConversationByPair.mockResolvedValue(null);
+ model.createConversation.mockResolvedValue({ id: 'c1' });
+ model.createMessage.mockResolvedValue({ id: 'm1' });
+
+ await expect(
+ service.startConversation({
+ senderId: 'u1',
+ recipientId: 'u2',
+ resource: { type: 'playlist', id: validTrackId },
+ })
+ ).resolves.toEqual(expect.objectContaining({ isNew: true }));
+ });
+ });
+
+ describe('ensureConversation', () => {
+ it('rejects when sender tries to ensure with self', async () => {
+ await expect(service.ensureConversation({ senderId: 'u1', recipientId: 'u1' }))
+ .rejects.toMatchObject({ code: 'MESSAGES_SELF_MESSAGE', statusCode: 400 });
+ });
+
+ it('rejects when recipient not found', async () => {
+ model.findActiveUserById.mockResolvedValue(null);
+
+ await expect(service.ensureConversation({ senderId: 'u1', recipientId: 'u2' }))
+ .rejects.toMatchObject({ code: 'USER_NOT_FOUND', statusCode: 404 });
+ });
+
+ it('creates conversation when new', async () => {
+ model.findActiveUserById.mockResolvedValue({ id: 'u2' });
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+ model.findConversationByPair.mockResolvedValue(null);
+ model.createConversation.mockResolvedValue({ id: 'c1', user_a_id: 'u1', user_b_id: 'u2' });
+
+ const out = await service.ensureConversation({ senderId: 'u1', recipientId: 'u2' });
+
+ expect(out).toEqual({
+ conversation: expect.objectContaining({ id: 'c1' }),
+ isNew: true,
+ });
+ expect(model.createConversation).toHaveBeenCalledWith('u1', 'u2');
+ });
+
+ it('returns existing conversation and restores for sender if soft-deleted', async () => {
+ model.findActiveUserById.mockResolvedValue({ id: 'u2' });
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+ model.findConversationByPair.mockResolvedValue({
+ id: 'c1',
+ user_a_id: 'u1',
+ user_b_id: 'u2',
+ deleted_by_a: true,
+ deleted_by_b: false,
+ });
+
+ const out = await service.ensureConversation({ senderId: 'u1', recipientId: 'u2' });
+
+ expect(out.isNew).toBe(false);
+ expect(model.restoreConversationForUser).toHaveBeenCalledWith('c1', true);
+ });
});
describe('listConversations', () => {
@@ -431,6 +575,81 @@ describe('messages.service', () => {
expect.objectContaining({ body: 'hi' })
);
});
+
+ it('rejects invalid track resource id', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+
+ await expect(service.sendMessage({
+ conversationId: 'c1',
+ senderId: 'u1',
+ resource: { type: 'track', id: 'bad-id' },
+ })).rejects.toMatchObject({ code: 'MESSAGES_INVALID_EMBED_ID', statusCode: 400 });
+ });
+
+ it('rejects non-object embedded resource', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+
+ await expect(service.sendMessage({
+ conversationId: 'c1',
+ senderId: 'u1',
+ resource: 123,
+ })).rejects.toMatchObject({ code: 'MESSAGES_INVALID_EMBED_RESOURCE', statusCode: 400 });
+ });
+
+ it('rejects embedded resource missing fields', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+
+ await expect(service.sendMessage({
+ conversationId: 'c1',
+ senderId: 'u1',
+ resource: { id: validTrackId },
+ })).rejects.toMatchObject({ code: 'MESSAGES_INVALID_EMBED_RESOURCE', statusCode: 400 });
+ });
+
+ it('rejects when embedded track does not exist', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+ tracksService.getTrackById.mockRejectedValue({ code: 'TRACK_NOT_FOUND' });
+
+ await expect(service.sendMessage({
+ conversationId: 'c1',
+ senderId: 'u1',
+ resource: { type: 'track', id: validTrackId },
+ })).rejects.toMatchObject({ code: 'MESSAGES_EMBED_TRACK_NOT_FOUND', statusCode: 404 });
+ });
+
+ it('rethrows unexpected track lookup errors', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+ tracksService.getTrackById.mockRejectedValue(new Error('track backend down'));
+
+ await expect(service.sendMessage({
+ conversationId: 'c1',
+ senderId: 'u1',
+ resource: { type: 'track', id: validTrackId },
+ })).rejects.toThrow('track backend down');
+ });
+
+ it('accepts playlist resource without existence validation', async () => {
+ model.findConversationById.mockResolvedValue(baseConversation);
+ model.isBlocked.mockResolvedValue(false);
+ model.getMessagesFromPreference.mockResolvedValue('everyone');
+ model.createMessage.mockResolvedValue({ id: 'm2' });
+
+ await expect(service.sendMessage({
+ conversationId: 'c1',
+ senderId: 'u1',
+ resource: { type: 'playlist', id: validTrackId },
+ })).resolves.toEqual(expect.objectContaining({ id: 'm2' }));
+ });
});
describe('getUnreadCount', () => {
From 4f1b3ed16e0b1bc232762e70e222dcde558fd2ec Mon Sep 17 00:00:00 2001
From: Omar Hamza
Date: Sun, 22 Mar 2026 16:20:38 +0200
Subject: [PATCH 092/535] test(users): add complete unit tests for controller,
service, and model layers
- Add unit tests for users controller layer covering all endpoints:
getMe, getUserById, updateMe, updateMyAccount, switchRole,
uploadMyAvatar, deleteMyAvatar, uploadMyCoverPhoto, deleteMyCoverPhoto,
getMyWebProfile, addWebProfile, deleteWebProfile, updatePrivacy,
getMyGenres, replaceMyGenres, completeOnboarding, getMyContentSettings,
updateMyContentSettings, getMyPrivacySettings, updateMyPrivacySettings
- Add unit tests for users service layer covering all business logic:
privacy enforcement, username normalization and uniqueness,
age validation, role switching rules, onboarding completion guard,
ownership checks for web profiles, and avatar/cover existence checks
- Add unit tests for user model layer covering all SQL queries:
CRUD operations, citext-aware email queries, functional index username
queries, transaction rollback for replaceGenres, and all avatar/cover/
web profile/settings operations
- Add shared test fixtures (test-fixtures.js) with mockUser, mockPublicUser,
mockWebProfiles, mockGenres, mockContentSettings, mockPrivacySettings,
and error fixtures
- Restructure tests/user/ into layered subfolders:
controllers/, services/, models/, helpers/
---
.../user/controllers/users.controller.test.js | 700 +++++++++---------
tests/user/helpers/test-fixtures.js | 73 +-
tests/user/models/user.model.test.js | 644 +++++++++++++---
tests/user/services/users.service.test.js | 602 +++++++++++----
4 files changed, 1450 insertions(+), 569 deletions(-)
diff --git a/tests/user/controllers/users.controller.test.js b/tests/user/controllers/users.controller.test.js
index 19ea443..8ffd399 100644
--- a/tests/user/controllers/users.controller.test.js
+++ b/tests/user/controllers/users.controller.test.js
@@ -5,7 +5,6 @@
const fixtures = require('../helpers/test-fixtures');
-// Mock service layer before importing controller
jest.mock('../../../src/services/users.service', () => ({
getMe: jest.fn(),
getUserById: jest.fn(),
@@ -23,25 +22,27 @@ jest.mock('../../../src/services/users.service', () => ({
getMyGenres: jest.fn(),
replaceMyGenres: jest.fn(),
completeOnboarding: jest.fn(),
+ getMyContentSettings: jest.fn(),
+ updateMyContentSettings: jest.fn(),
+ getMyPrivacySettings: jest.fn(),
+ updateMyPrivacySettings: jest.fn(),
}));
const usersService = require('../../../src/services/users.service');
const usersController = require('../../../src/controllers/users.controller');
-// Helper to create mock req/res
-const createMocks = () => {
+const createMocks = (overrides = {}) => {
const req = {
user: { sub: 'user-123' },
body: {},
params: {},
file: null,
+ ...overrides,
};
-
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
-
return { req, res };
};
@@ -51,143 +52,186 @@ describe('Users Controller', () => {
});
// ========================================
- // getMyWebProfile tests
+ // getMe
// ========================================
- describe('getMyWebProfile', () => {
- it('should return 200 with web profiles', async () => {
+ describe('getMe', () => {
+ it('should return 200 with user profile', async () => {
const { req, res } = createMocks();
- res.status.mockReturnThis();
- usersService.getMyWebProfile.mockResolvedValue(fixtures.mockWebProfiles);
+ usersService.getMe.mockResolvedValue(fixtures.mockUser);
- await usersController.getMyWebProfile(req, res);
+ await usersController.getMe(req, res);
- expect(usersService.getMyWebProfile).toHaveBeenCalledWith('user-123');
+ expect(usersService.getMe).toHaveBeenCalledWith('user-123');
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
- data: fixtures.mockWebProfiles,
- message: 'Web profiles returned successfully.',
+ data: fixtures.mockUser,
+ message: 'Own profile returned successfully.',
});
});
- it('should handle service errors', async () => {
+ it('should propagate service errors', async () => {
const { req, res } = createMocks();
- const error = new Error('User not found');
- error.statusCode = 404;
- usersService.getMyWebProfile.mockRejectedValue(error);
+ const error = Object.assign(new Error('User not found'), { statusCode: 404 });
+ usersService.getMe.mockRejectedValue(error);
- try {
- await usersController.getMyWebProfile(req, res);
- throw new Error('Should have thrown error');
- } catch (err) {
- expect(err).toEqual(error);
- }
+ await expect(usersController.getMe(req, res)).rejects.toMatchObject({ statusCode: 404 });
});
});
// ========================================
- // addWebProfile tests
+ // getUserById
// ========================================
- describe('addWebProfile', () => {
- it('should create profile with valid data', async () => {
- const { req, res } = createMocks();
- req.body = { platform: 'Twitter', url: 'https://twitter.com/user' };
- res.status.mockReturnThis();
- usersService.addWebProfile.mockResolvedValue(fixtures.mockWebProfile);
+ describe('getUserById', () => {
+ it('should return 200 with public profile', async () => {
+ const { req, res } = createMocks({ params: { user_id: 'user-456' } });
+ usersService.getUserById.mockResolvedValue(fixtures.mockPublicUser);
- await usersController.addWebProfile(req, res);
+ await usersController.getUserById(req, res);
- expect(usersService.addWebProfile).toHaveBeenCalledWith('user-123', 'Twitter', 'https://twitter.com/user');
- expect(res.status).toHaveBeenCalledWith(201);
+ expect(usersService.getUserById).toHaveBeenCalledWith('user-456', 'user-123');
+ expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
- data: fixtures.mockWebProfile,
- message: 'Web profile link created.',
+ data: fixtures.mockPublicUser,
+ message: 'User profile returned successfully.',
});
});
- it('should handle service conflict error', async () => {
- const { req, res } = createMocks();
- req.body = { platform: 'Twitter', url: 'https://twitter.com/user' };
- const error = new Error('A profile for this platform already exists.');
- error.statusCode = 409;
- usersService.addWebProfile.mockRejectedValue(error);
+ it('should propagate 404 if user not found', async () => {
+ const { req, res } = createMocks({ params: { user_id: 'user-999' } });
+ const error = Object.assign(new Error('User not found'), { statusCode: 404 });
+ usersService.getUserById.mockRejectedValue(error);
- try {
- await usersController.addWebProfile(req, res);
- throw new Error('Should have thrown error');
- } catch (err) {
- expect(err.statusCode).toBe(409);
- }
+ await expect(usersController.getUserById(req, res)).rejects.toMatchObject({ statusCode: 404 });
+ });
+
+ it('should propagate 403 for private profile', async () => {
+ const { req, res } = createMocks({ params: { user_id: 'user-456' } });
+ const error = Object.assign(new Error('Private'), { statusCode: 403 });
+ usersService.getUserById.mockRejectedValue(error);
+
+ await expect(usersController.getUserById(req, res)).rejects.toMatchObject({ statusCode: 403 });
});
});
// ========================================
- // deleteWebProfile tests
+ // updateMe
// ========================================
- describe('deleteWebProfile', () => {
- it('should delete profile with correct profile_id', async () => {
- const { req, res } = createMocks();
- req.params = { profile_id: 'profile-1' };
- res.status.mockReturnThis();
- usersService.deleteWebProfile.mockResolvedValue({ id: 'profile-1' });
+ describe('updateMe', () => {
+ it('should update profile with valid data', async () => {
+ const { req, res } = createMocks({ body: { display_name: 'New Name', bio: 'New bio' } });
+ usersService.updateMe.mockResolvedValue({ ...fixtures.mockUser, display_name: 'New Name' });
- await usersController.deleteWebProfile(req, res);
+ await usersController.updateMe(req, res);
- expect(usersService.deleteWebProfile).toHaveBeenCalledWith('user-123', 'profile-1');
+ expect(usersService.updateMe).toHaveBeenCalledWith('user-123', {
+ display_name: 'New Name',
+ bio: 'New bio',
+ });
expect(res.json).toHaveBeenCalled();
});
- it('should handle 404 profile not found', async () => {
- const { req, res } = createMocks();
- req.params = { profile_id: 'profile-999' };
- const error = new Error('Web profile not found');
- error.statusCode = 404;
- usersService.deleteWebProfile.mockRejectedValue(error);
+ it('should update only provided fields', async () => {
+ const { req, res } = createMocks({ body: { username: 'newusername' } });
+ usersService.updateMe.mockResolvedValue(fixtures.mockUser);
+
+ await usersController.updateMe(req, res);
- try {
- await usersController.deleteWebProfile(req, res);
- throw new Error('Should have thrown error');
- } catch (err) {
- expect(err.statusCode).toBe(404);
- }
+ expect(usersService.updateMe).toHaveBeenCalledWith('user-123', { username: 'newusername' });
});
- it('should handle 403 permission denied', async () => {
- const { req, res } = createMocks();
- req.params = { profile_id: 'profile-1' };
- const error = new Error('You are not allowed to delete this profile.');
- error.statusCode = 403;
- usersService.deleteWebProfile.mockRejectedValue(error);
+ it('should ignore undefined fields', async () => {
+ const { req, res } = createMocks({ body: { display_name: 'New Name' } });
+ usersService.updateMe.mockResolvedValue(fixtures.mockUser);
+
+ await usersController.updateMe(req, res);
+
+ const calledWith = usersService.updateMe.mock.calls[0][1];
+ expect(calledWith).not.toHaveProperty('bio');
+ expect(calledWith).not.toHaveProperty('username');
+ });
+
+ it('should propagate service errors', async () => {
+ const { req, res } = createMocks({ body: { display_name: 'New Name' } });
+ const error = Object.assign(new Error('Validation failed'), { statusCode: 400 });
+ usersService.updateMe.mockRejectedValue(error);
+
+ await expect(usersController.updateMe(req, res)).rejects.toMatchObject({ statusCode: 400 });
+ });
+ });
+
+ // ========================================
+ // updateMyAccount
+ // ========================================
+ describe('updateMyAccount', () => {
+ it('should update account with date_of_birth and gender', async () => {
+ const { req, res } = createMocks({ body: { date_of_birth: '1990-01-01', gender: 'female' } });
+ usersService.updateMyAccount.mockResolvedValue({ ...fixtures.mockUser, gender: 'female' });
+
+ await usersController.updateMyAccount(req, res);
+
+ expect(usersService.updateMyAccount).toHaveBeenCalledWith('user-123', {
+ date_of_birth: '1990-01-01',
+ gender: 'female',
+ });
+ expect(res.json).toHaveBeenCalled();
+ });
+
+ it('should update only provided account fields', async () => {
+ const { req, res } = createMocks({ body: { gender: 'male' } });
+ usersService.updateMyAccount.mockResolvedValue(fixtures.mockUser);
+
+ await usersController.updateMyAccount(req, res);
+
+ expect(usersService.updateMyAccount).toHaveBeenCalledWith('user-123', { gender: 'male' });
+ });
+ });
+
+ // ========================================
+ // switchRole
+ // ========================================
+ describe('switchRole', () => {
+ it('should switch user role', async () => {
+ const { req, res } = createMocks({ body: { role: 'artist' } });
+ usersService.switchRole.mockResolvedValue({ ...fixtures.mockUser, role: 'artist' });
- try {
- await usersController.deleteWebProfile(req, res);
- throw new Error('Should have thrown error');
- } catch (err) {
- expect(err.statusCode).toBe(403);
- }
+ await usersController.switchRole(req, res);
+
+ expect(usersService.switchRole).toHaveBeenCalledWith('user-123', 'artist');
+ expect(res.json).toHaveBeenCalled();
+ });
+
+ it('should propagate 400 for invalid role', async () => {
+ const { req, res } = createMocks({ body: { role: 'admin' } });
+ const error = Object.assign(new Error('Invalid role'), { statusCode: 400 });
+ usersService.switchRole.mockRejectedValue(error);
+
+ await expect(usersController.switchRole(req, res)).rejects.toMatchObject({ statusCode: 400 });
+ });
+
+ it('should propagate 409 if already has role', async () => {
+ const { req, res } = createMocks({ body: { role: 'listener' } });
+ const error = Object.assign(new Error('Already has role'), { statusCode: 409 });
+ usersService.switchRole.mockRejectedValue(error);
+
+ await expect(usersController.switchRole(req, res)).rejects.toMatchObject({ statusCode: 409 });
});
});
// ========================================
- // uploadMyAvatar tests
+ // uploadMyAvatar
// ========================================
describe('uploadMyAvatar', () => {
it('should throw 400 if no file provided', async () => {
- const { req, res } = createMocks();
- req.file = null;
+ const { req, res } = createMocks({ file: null });
- try {
- await usersController.uploadMyAvatar(req, res);
- throw new Error('Should have thrown error');
- } catch (err) {
- expect(err.statusCode).toBe(400);
- expect(err.message).toContain('No image file provided');
- }
+ await expect(usersController.uploadMyAvatar(req, res)).rejects.toMatchObject({
+ statusCode: 400,
+ message: expect.stringContaining('No image file provided'),
+ });
});
it('should upload avatar if file provided', async () => {
- const { req, res } = createMocks();
- req.file = { filename: 'avatar.jpg' };
- res.status.mockReturnThis();
+ const { req, res } = createMocks({ file: { filename: 'avatar.jpg' } });
usersService.uploadMyAvatar.mockResolvedValue({
...fixtures.mockUser,
profile_picture: 'https://cdn.rythmify.com/avatars/user-123.jpg',
@@ -201,26 +245,20 @@ describe('Users Controller', () => {
});
// ========================================
- // uploadMyCoverPhoto tests
+ // uploadMyCoverPhoto
// ========================================
describe('uploadMyCoverPhoto', () => {
it('should throw 400 if no file provided', async () => {
- const { req, res } = createMocks();
- req.file = null;
+ const { req, res } = createMocks({ file: null });
- try {
- await usersController.uploadMyCoverPhoto(req, res);
- throw new Error('Should have thrown error');
- } catch (err) {
- expect(err.statusCode).toBe(400);
- expect(err.message).toContain('No image file provided');
- }
+ await expect(usersController.uploadMyCoverPhoto(req, res)).rejects.toMatchObject({
+ statusCode: 400,
+ message: expect.stringContaining('No image file provided'),
+ });
});
it('should upload cover photo if file provided', async () => {
- const { req, res } = createMocks();
- req.file = { filename: 'cover.jpg' };
- res.status.mockReturnThis();
+ const { req, res } = createMocks({ file: { filename: 'cover.jpg' } });
usersService.uploadMyCoverPhoto.mockResolvedValue({
...fixtures.mockUser,
cover_photo: 'https://cdn.rythmify.com/covers/user-123.jpg',
@@ -232,14 +270,13 @@ describe('Users Controller', () => {
expect(res.json).toHaveBeenCalled();
});
});
-});
- // ========================================
- // deleteMyAvatar tests
+
+ // ========================================
+ // deleteMyAvatar
// ========================================
describe('deleteMyAvatar', () => {
it('should call service with user id', async () => {
const { req, res } = createMocks();
- res.status.mockReturnThis();
usersService.deleteMyAvatar.mockResolvedValue({ profile_picture: null });
await usersController.deleteMyAvatar(req, res);
@@ -248,238 +285,143 @@ describe('Users Controller', () => {
expect(res.json).toHaveBeenCalled();
});
- it('should handle 404 no avatar', async () => {
+ it('should propagate 404 if no avatar', async () => {
const { req, res } = createMocks();
- const error = new Error('No avatar to delete');
- error.statusCode = 404;
+ const error = Object.assign(new Error('No avatar to delete'), { statusCode: 404 });
usersService.deleteMyAvatar.mockRejectedValue(error);
- try {
- await usersController.deleteMyAvatar(req, res);
- throw new Error('Should have thrown error');
- } catch (err) {
- expect(err.statusCode).toBe(404);
- }
+ await expect(usersController.deleteMyAvatar(req, res)).rejects.toMatchObject({ statusCode: 404 });
});
});
// ========================================
- // getMe tests
+ // deleteMyCoverPhoto
// ========================================
- describe('getMe', () => {
- it('should return user profile', async () => {
+ describe('deleteMyCoverPhoto', () => {
+ it('should call service with user id', async () => {
const { req, res } = createMocks();
- res.status.mockReturnThis();
- usersService.getMe.mockResolvedValue(fixtures.mockUser);
+ usersService.deleteMyCoverPhoto.mockResolvedValue({ cover_photo: null });
- await usersController.getMe(req, res);
+ await usersController.deleteMyCoverPhoto(req, res);
- expect(usersService.getMe).toHaveBeenCalledWith('user-123');
- expect(res.json).toHaveBeenCalledWith({
- data: fixtures.mockUser,
- message: 'Own profile returned successfully.',
- });
+ expect(usersService.deleteMyCoverPhoto).toHaveBeenCalledWith('user-123');
+ expect(res.json).toHaveBeenCalled();
});
- it('should handle service errors', async () => {
+ it('should propagate 404 if no cover photo', async () => {
const { req, res } = createMocks();
- const error = new Error('User not found');
- error.statusCode = 404;
- usersService.getMe.mockRejectedValue(error);
+ const error = Object.assign(new Error('No cover photo to delete'), { statusCode: 404 });
+ usersService.deleteMyCoverPhoto.mockRejectedValue(error);
- try {
- await usersController.getMe(req, res);
- throw new Error('Should have thrown error');
- } catch (err) {
- expect(err.statusCode).toBe(404);
- }
+ await expect(usersController.deleteMyCoverPhoto(req, res)).rejects.toMatchObject({ statusCode: 404 });
});
});
// ========================================
- // getUserById tests
+ // getMyWebProfile
// ========================================
- describe('getUserById', () => {
- it('should return user profile by id', async () => {
+ describe('getMyWebProfile', () => {
+ it('should return 200 with web profiles', async () => {
const { req, res } = createMocks();
- req.params = { user_id: 'user-456' };
- res.status.mockReturnThis();
- usersService.getUserById.mockResolvedValue(fixtures.mockUser);
+ usersService.getMyWebProfile.mockResolvedValue(fixtures.mockWebProfiles);
- await usersController.getUserById(req, res);
+ await usersController.getMyWebProfile(req, res);
- expect(usersService.getUserById).toHaveBeenCalledWith('user-456', 'user-123');
+ expect(usersService.getMyWebProfile).toHaveBeenCalledWith('user-123');
+ expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
- data: fixtures.mockUser,
- message: 'User profile returned successfully.',
+ data: fixtures.mockWebProfiles,
+ message: 'Web profiles returned successfully.',
});
});
- it('should handle 404 user not found', async () => {
+ it('should return empty array if no profiles', async () => {
const { req, res } = createMocks();
- req.params = { user_id: 'user-999' };
- const error = new Error('User not found');
- error.statusCode = 404;
- usersService.getUserById.mockRejectedValue(error);
+ usersService.getMyWebProfile.mockResolvedValue([]);
- try {
- await usersController.getUserById(req, res);
- throw new Error('Should have thrown error');
- } catch (err) {
- expect(err.statusCode).toBe(404);
- }
+ await usersController.getMyWebProfile(req, res);
+
+ expect(res.json).toHaveBeenCalledWith({
+ data: [],
+ message: 'Web profiles returned successfully.',
+ });
});
});
// ========================================
- // updateMe tests
+ // addWebProfile
// ========================================
- describe('updateMe', () => {
- it('should update profile with valid data', async () => {
- const { req, res } = createMocks();
- req.body = { display_name: 'New Name', bio: 'New bio' };
- res.status.mockReturnThis();
- usersService.updateMe.mockResolvedValue({
- ...fixtures.mockUser,
- display_name: 'New Name',
- bio: 'New bio',
- });
-
- await usersController.updateMe(req, res);
-
- expect(usersService.updateMe).toHaveBeenCalledWith('user-123', {
- display_name: 'New Name',
- bio: 'New bio',
- });
- expect(res.json).toHaveBeenCalled();
- });
-
- it('should update only provided fields', async () => {
- const { req, res } = createMocks();
- req.body = { username: 'newusername' };
- res.status.mockReturnThis();
- usersService.updateMe.mockResolvedValue(fixtures.mockUser);
+ describe('addWebProfile', () => {
+ it('should create profile with valid data', async () => {
+ const { req, res } = createMocks({ body: { platform: 'Twitter', url: 'https://twitter.com/user' } });
+ usersService.addWebProfile.mockResolvedValue(fixtures.mockWebProfile);
- await usersController.updateMe(req, res);
+ await usersController.addWebProfile(req, res);
- expect(usersService.updateMe).toHaveBeenCalledWith('user-123', {
- username: 'newusername',
+ expect(usersService.addWebProfile).toHaveBeenCalledWith('user-123', 'Twitter', 'https://twitter.com/user');
+ expect(res.status).toHaveBeenCalledWith(201);
+ expect(res.json).toHaveBeenCalledWith({
+ data: fixtures.mockWebProfile,
+ message: 'Web profile link created.',
});
});
- it('should handle service errors', async () => {
- const { req, res } = createMocks();
- req.body = { display_name: 'New Name' };
- const error = new Error('Validation failed');
- error.statusCode = 400;
- usersService.updateMe.mockRejectedValue(error);
+ it('should propagate 409 conflict error', async () => {
+ const { req, res } = createMocks({ body: { platform: 'Twitter', url: 'https://twitter.com/user' } });
+ const error = Object.assign(new Error('Already exists'), { statusCode: 409 });
+ usersService.addWebProfile.mockRejectedValue(error);
- try {
- await usersController.updateMe(req, res);
- throw new Error('Should have thrown error');
- } catch (err) {
- expect(err.statusCode).toBe(400);
- }
+ await expect(usersController.addWebProfile(req, res)).rejects.toMatchObject({ statusCode: 409 });
});
});
// ========================================
- // updateMyAccount tests
+ // deleteWebProfile
// ========================================
- describe('updateMyAccount', () => {
- it('should update account with date_of_birth and gender', async () => {
- const { req, res } = createMocks();
- req.body = { date_of_birth: '1990-01-01', gender: 'female' };
- res.status.mockReturnThis();
- usersService.updateMyAccount.mockResolvedValue({
- ...fixtures.mockUser,
- date_of_birth: '1990-01-01',
- gender: 'female',
- });
+ describe('deleteWebProfile', () => {
+ it('should delete profile with correct profile_id', async () => {
+ const { req, res } = createMocks({ params: { profile_id: 'profile-1' } });
+ usersService.deleteWebProfile.mockResolvedValue({ id: 'profile-1' });
- await usersController.updateMyAccount(req, res);
+ await usersController.deleteWebProfile(req, res);
- expect(usersService.updateMyAccount).toHaveBeenCalledWith('user-123', {
- date_of_birth: '1990-01-01',
- gender: 'female',
- });
+ expect(usersService.deleteWebProfile).toHaveBeenCalledWith('user-123', 'profile-1');
expect(res.json).toHaveBeenCalled();
});
- it('should update only provided account fields', async () => {
- const { req, res } = createMocks();
- req.body = { gender: 'male' };
- res.status.mockReturnThis();
- usersService.updateMyAccount.mockResolvedValue(fixtures.mockUser);
-
- await usersController.updateMyAccount(req, res);
-
- expect(usersService.updateMyAccount).toHaveBeenCalledWith('user-123', {
- gender: 'male',
- });
- });
- });
-
- // ========================================
- // switchRole tests
- // ========================================
- describe('switchRole', () => {
- it('should switch user role', async () => {
- const { req, res } = createMocks();
- req.body = { role: 'artist' };
- res.status.mockReturnThis();
- usersService.switchRole.mockResolvedValue({
- ...fixtures.mockUser,
- role: 'artist',
- });
-
- await usersController.switchRole(req, res);
+ it('should propagate 404 if profile not found', async () => {
+ const { req, res } = createMocks({ params: { profile_id: 'profile-999' } });
+ const error = Object.assign(new Error('Not found'), { statusCode: 404 });
+ usersService.deleteWebProfile.mockRejectedValue(error);
- expect(usersService.switchRole).toHaveBeenCalledWith('user-123', 'artist');
- expect(res.json).toHaveBeenCalled();
+ await expect(usersController.deleteWebProfile(req, res)).rejects.toMatchObject({ statusCode: 404 });
});
- it('should handle invalid role', async () => {
- const { req, res } = createMocks();
- req.body = { role: 'invalid_role' };
- const error = new Error('Invalid role');
- error.statusCode = 400;
- usersService.switchRole.mockRejectedValue(error);
+ it('should propagate 403 permission denied', async () => {
+ const { req, res } = createMocks({ params: { profile_id: 'profile-1' } });
+ const error = Object.assign(new Error('Forbidden'), { statusCode: 403 });
+ usersService.deleteWebProfile.mockRejectedValue(error);
- try {
- await usersController.switchRole(req, res);
- throw new Error('Should have thrown error');
- } catch (err) {
- expect(err.statusCode).toBe(400);
- }
+ await expect(usersController.deleteWebProfile(req, res)).rejects.toMatchObject({ statusCode: 403 });
});
});
// ========================================
- // updatePrivacy tests
+ // updatePrivacy
// ========================================
describe('updatePrivacy', () => {
it('should throw 400 if is_private is missing', async () => {
- const { req, res } = createMocks();
- req.body = {};
+ const { req, res } = createMocks({ body: {} });
- try {
- await usersController.updatePrivacy(req, res);
- throw new Error('Should have thrown error');
- } catch (err) {
- expect(err.statusCode).toBe(400);
- expect(err.message).toContain('is_private field is required');
- }
+ await expect(usersController.updatePrivacy(req, res)).rejects.toMatchObject({
+ statusCode: 400,
+ message: expect.stringContaining('is_private field is required'),
+ });
});
it('should set profile to private', async () => {
- const { req, res } = createMocks();
- req.body = { is_private: true };
- res.status.mockReturnThis();
- usersService.updatePrivacy.mockResolvedValue({
- ...fixtures.mockUser,
- is_private: true,
- });
+ const { req, res } = createMocks({ body: { is_private: true } });
+ usersService.updatePrivacy.mockResolvedValue({ ...fixtures.mockUser, is_private: true });
await usersController.updatePrivacy(req, res);
@@ -488,13 +430,8 @@ describe('Users Controller', () => {
});
it('should set profile to public', async () => {
- const { req, res } = createMocks();
- req.body = { is_private: false };
- res.status.mockReturnThis();
- usersService.updatePrivacy.mockResolvedValue({
- ...fixtures.mockUser,
- is_private: false,
- });
+ const { req, res } = createMocks({ body: { is_private: false } });
+ usersService.updatePrivacy.mockResolvedValue({ ...fixtures.mockUser, is_private: false });
await usersController.updatePrivacy(req, res);
@@ -504,13 +441,12 @@ describe('Users Controller', () => {
});
// ========================================
- // getMyGenres tests
+ // getMyGenres
// ========================================
describe('getMyGenres', () => {
it('should return favorite genres', async () => {
const { req, res } = createMocks();
- res.status.mockReturnThis();
- const mockGenres = ['Rock', 'Jazz', 'Pop'];
+ const mockGenres = [{ id: 'genre-1', name: 'Rock' }];
usersService.getMyGenres.mockResolvedValue(mockGenres);
await usersController.getMyGenres(req, res);
@@ -524,7 +460,6 @@ describe('Users Controller', () => {
it('should return empty array if no genres', async () => {
const { req, res } = createMocks();
- res.status.mockReturnThis();
usersService.getMyGenres.mockResolvedValue([]);
await usersController.getMyGenres(req, res);
@@ -537,42 +472,30 @@ describe('Users Controller', () => {
});
// ========================================
- // replaceMyGenres tests
+ // replaceMyGenres
// ========================================
describe('replaceMyGenres', () => {
- it('should throw 400 if genres is not array', async () => {
- const { req, res } = createMocks();
- req.body = { genres: 'Rock' };
+ it('should throw 400 if genres is not an array', async () => {
+ const { req, res } = createMocks({ body: { genres: 'Rock' } });
- try {
- await usersController.replaceMyGenres(req, res);
- throw new Error('Should have thrown error');
- } catch (err) {
- expect(err.statusCode).toBe(400);
- expect(err.message).toContain('genres must be an array');
- }
+ await expect(usersController.replaceMyGenres(req, res)).rejects.toMatchObject({
+ statusCode: 400,
+ message: expect.stringContaining('genres must be an array'),
+ });
});
it('should throw 400 if genres exceeds 10 items', async () => {
- const { req, res } = createMocks();
- req.body = {
- genres: Array(11).fill('Genre'),
- };
+ const { req, res } = createMocks({ body: { genres: Array(11).fill('genre-1') } });
- try {
- await usersController.replaceMyGenres(req, res);
- throw new Error('Should have thrown error');
- } catch (err) {
- expect(err.statusCode).toBe(400);
- expect(err.message).toContain('Maximum of 10 genres allowed');
- }
+ await expect(usersController.replaceMyGenres(req, res)).rejects.toMatchObject({
+ statusCode: 400,
+ message: expect.stringContaining('Maximum of 10 genres allowed'),
+ });
});
it('should update genres with valid data', async () => {
- const { req, res } = createMocks();
- const genres = ['Rock', 'Jazz', 'Pop'];
- req.body = { genres };
- res.status.mockReturnThis();
+ const genres = ['genre-1', 'genre-2'];
+ const { req, res } = createMocks({ body: { genres } });
usersService.replaceMyGenres.mockResolvedValue(genres);
await usersController.replaceMyGenres(req, res);
@@ -580,45 +503,41 @@ describe('Users Controller', () => {
expect(usersService.replaceMyGenres).toHaveBeenCalledWith('user-123', genres);
expect(res.json).toHaveBeenCalled();
});
+
+ it('should allow empty genres array', async () => {
+ const { req, res } = createMocks({ body: { genres: [] } });
+ usersService.replaceMyGenres.mockResolvedValue([]);
+
+ await usersController.replaceMyGenres(req, res);
+
+ expect(usersService.replaceMyGenres).toHaveBeenCalledWith('user-123', []);
+ });
});
// ========================================
- // completeOnboarding tests
+ // completeOnboarding
// ========================================
describe('completeOnboarding', () => {
it('should complete onboarding with all fields', async () => {
- const { req, res } = createMocks();
- req.body = {
+ const body = {
display_name: 'John Doe',
gender: 'male',
date_of_birth: '1990-01-01',
bio: 'Music lover',
city: 'New York',
- country: 'USA',
+ country: 'US',
};
- res.status.mockReturnThis();
- usersService.completeOnboarding.mockResolvedValue({
- ...fixtures.mockUser,
- ...req.body,
- });
+ const { req, res } = createMocks({ body });
+ usersService.completeOnboarding.mockResolvedValue({ ...fixtures.mockUser, ...body });
await usersController.completeOnboarding(req, res);
- expect(usersService.completeOnboarding).toHaveBeenCalledWith('user-123', {
- display_name: 'John Doe',
- gender: 'male',
- date_of_birth: '1990-01-01',
- bio: 'Music lover',
- city: 'New York',
- country: 'USA',
- });
+ expect(usersService.completeOnboarding).toHaveBeenCalledWith('user-123', body);
expect(res.json).toHaveBeenCalled();
});
it('should complete onboarding with partial fields', async () => {
- const { req, res } = createMocks();
- req.body = { display_name: 'Jane Doe', bio: 'Artist' };
- res.status.mockReturnThis();
+ const { req, res } = createMocks({ body: { display_name: 'Jane Doe', bio: 'Artist' } });
usersService.completeOnboarding.mockResolvedValue(fixtures.mockUser);
await usersController.completeOnboarding(req, res);
@@ -630,9 +549,7 @@ describe('Users Controller', () => {
});
it('should handle empty body', async () => {
- const { req, res } = createMocks();
- req.body = {};
- res.status.mockReturnThis();
+ const { req, res } = createMocks({ body: {} });
usersService.completeOnboarding.mockResolvedValue(fixtures.mockUser);
await usersController.completeOnboarding(req, res);
@@ -640,6 +557,111 @@ describe('Users Controller', () => {
expect(usersService.completeOnboarding).toHaveBeenCalledWith('user-123', {});
expect(res.json).toHaveBeenCalled();
});
+
+ it('should propagate 409 if already completed', async () => {
+ const { req, res } = createMocks({ body: { display_name: 'John' } });
+ const error = Object.assign(new Error('Already completed'), { statusCode: 409 });
+ usersService.completeOnboarding.mockRejectedValue(error);
+
+ await expect(usersController.completeOnboarding(req, res)).rejects.toMatchObject({ statusCode: 409 });
+ });
+ });
+
+ // ========================================
+ // getMyContentSettings
+ // ========================================
+ describe('getMyContentSettings', () => {
+ it('should return content settings', async () => {
+ const { req, res } = createMocks();
+ const mockSettings = { rss_title: 'My Podcast', rss_language: 'en' };
+ usersService.getMyContentSettings.mockResolvedValue(mockSettings);
+
+ await usersController.getMyContentSettings(req, res);
+
+ expect(usersService.getMyContentSettings).toHaveBeenCalledWith('user-123');
+ expect(res.json).toHaveBeenCalledWith({
+ data: mockSettings,
+ message: 'Content settings returned successfully.',
+ });
+ });
+ });
+
+ // ========================================
+ // updateMyContentSettings
+ // ========================================
+ describe('updateMyContentSettings', () => {
+ it('should update content settings', async () => {
+ const { req, res } = createMocks({ body: { rss_title: 'New Title', rss_language: 'ar' } });
+ const updatedSettings = { rss_title: 'New Title', rss_language: 'ar' };
+ usersService.updateMyContentSettings.mockResolvedValue(updatedSettings);
+
+ await usersController.updateMyContentSettings(req, res);
+
+ expect(usersService.updateMyContentSettings).toHaveBeenCalledWith('user-123', {
+ rss_title: 'New Title',
+ rss_language: 'ar',
+ });
+ expect(res.json).toHaveBeenCalled();
+ });
+
+ it('should ignore undefined fields', async () => {
+ const { req, res } = createMocks({ body: { rss_title: 'Title' } });
+ usersService.updateMyContentSettings.mockResolvedValue({ rss_title: 'Title' });
+
+ await usersController.updateMyContentSettings(req, res);
+
+ const calledWith = usersService.updateMyContentSettings.mock.calls[0][1];
+ expect(calledWith).not.toHaveProperty('rss_language');
+ });
+ });
+
+ // ========================================
+ // getMyPrivacySettings
+ // ========================================
+ describe('getMyPrivacySettings', () => {
+ it('should return privacy settings', async () => {
+ const { req, res } = createMocks();
+ const mockSettings = { receive_messages_from_anyone: true, show_as_top_fan: false };
+ usersService.getMyPrivacySettings.mockResolvedValue(mockSettings);
+
+ await usersController.getMyPrivacySettings(req, res);
+
+ expect(usersService.getMyPrivacySettings).toHaveBeenCalledWith('user-123');
+ expect(res.json).toHaveBeenCalledWith({
+ data: mockSettings,
+ message: 'Privacy settings returned successfully.',
+ });
+ });
});
-
\ No newline at end of file
+ // ========================================
+ // updateMyPrivacySettings
+ // ========================================
+ describe('updateMyPrivacySettings', () => {
+ it('should update privacy settings', async () => {
+ const { req, res } = createMocks({
+ body: { receive_messages_from_anyone: false, show_as_top_fan: true },
+ });
+ const updated = { receive_messages_from_anyone: false, show_as_top_fan: true };
+ usersService.updateMyPrivacySettings.mockResolvedValue(updated);
+
+ await usersController.updateMyPrivacySettings(req, res);
+
+ expect(usersService.updateMyPrivacySettings).toHaveBeenCalledWith('user-123', {
+ receive_messages_from_anyone: false,
+ show_as_top_fan: true,
+ });
+ expect(res.json).toHaveBeenCalled();
+ });
+
+ it('should ignore undefined fields', async () => {
+ const { req, res } = createMocks({ body: { show_as_top_fan: true } });
+ usersService.updateMyPrivacySettings.mockResolvedValue({ show_as_top_fan: true });
+
+ await usersController.updateMyPrivacySettings(req, res);
+
+ const calledWith = usersService.updateMyPrivacySettings.mock.calls[0][1];
+ expect(calledWith).not.toHaveProperty('receive_messages_from_anyone');
+ });
+ });
+});
\ No newline at end of file
diff --git a/tests/user/helpers/test-fixtures.js b/tests/user/helpers/test-fixtures.js
index dfb3939..d352fad 100644
--- a/tests/user/helpers/test-fixtures.js
+++ b/tests/user/helpers/test-fixtures.js
@@ -4,7 +4,7 @@
// ============================================================
const testFixtures = {
- // User fixtures
+ // Full private user (returned from findFullById — used in getMe)
mockUser: {
id: 'user-123',
email: 'test@example.com',
@@ -14,24 +14,43 @@ const testFixtures = {
last_name: 'User',
bio: 'Test bio',
city: 'Test City',
- country: 'Test Country',
+ country: 'EG',
gender: 'male',
date_of_birth: '2000-01-01',
role: 'listener',
- profile_picture: null,
- cover_photo: null,
+ profile_picture: 'https://cdn.rythmify.com/avatars/user-123.jpg',
+ cover_photo: 'https://cdn.rythmify.com/covers/user-123.jpg',
is_private: false,
is_verified: true,
is_suspended: false,
twofa_enabled: false,
- followers_count: 0,
- following_count: 0,
+ followers_count: 10,
+ following_count: 5,
last_login_at: new Date().toISOString(),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
- // Web profile fixtures
+ // Public user (returned from findPublicById — used in getUserById)
+ mockPublicUser: {
+ id: 'user-456',
+ display_name: 'Public User',
+ username: 'publicuser',
+ bio: 'Public bio',
+ city: 'Cairo',
+ country: 'EG',
+ gender: 'female',
+ role: 'artist',
+ profile_picture: null,
+ cover_photo: null,
+ is_private: false,
+ is_verified: true,
+ followers_count: 100,
+ following_count: 50,
+ created_at: new Date().toISOString(),
+ },
+
+ // Single web profile fixture
mockWebProfile: {
id: 'profile-1',
user_id: 'user-123',
@@ -40,6 +59,7 @@ const testFixtures = {
created_at: new Date().toISOString(),
},
+ // Multiple web profiles fixture
mockWebProfiles: [
{
id: 'profile-1',
@@ -55,14 +75,41 @@ const testFixtures = {
},
],
+ // Genre fixtures
+ mockGenres: [
+ { id: 'genre-1', name: 'Rock' },
+ { id: 'genre-2', name: 'Jazz' },
+ { id: 'genre-3', name: 'Pop' },
+ ],
+
+ // Content settings fixture
+ mockContentSettings: {
+ rss_title: 'My Podcast',
+ rss_language: 'en',
+ rss_category: 'Music',
+ rss_explicit: false,
+ rss_show_email: false,
+ default_include_in_rss: true,
+ default_license_type: 'all-rights-reserved',
+ },
+
+ // Privacy settings fixture
+ mockPrivacySettings: {
+ receive_messages_from_anyone: true,
+ show_activities_in_discovery: true,
+ show_as_top_fan: false,
+ show_top_fans_on_tracks: true,
+ },
+
// Error fixtures
errors: {
- notFound: { statusCode: 404, message: 'User not found', code: 'RESOURCE_NOT_FOUND' },
- unauthorized: { statusCode: 401, message: 'Unauthorized', code: 'AUTH_FAILED' },
- forbidden: { statusCode: 403, message: 'Forbidden', code: 'PERMISSION_DENIED' },
- conflict: { statusCode: 409, message: 'Conflict', code: 'RESOURCE_ALREADY_EXISTS' },
- badRequest: { statusCode: 400, message: 'Bad request', code: 'VALIDATION_FAILED' },
+ notFound: { statusCode: 404, message: 'User not found', code: 'RESOURCE_NOT_FOUND' },
+ unauthorized: { statusCode: 401, message: 'Unauthorized', code: 'AUTH_FAILED' },
+ forbidden: { statusCode: 403, message: 'Forbidden', code: 'PERMISSION_DENIED' },
+ conflict: { statusCode: 409, message: 'Conflict', code: 'RESOURCE_ALREADY_EXISTS' },
+ badRequest: { statusCode: 400, message: 'Bad request', code: 'VALIDATION_FAILED' },
+ private: { statusCode: 403, message: 'Private profile', code: 'RESOURCE_PRIVATE' },
},
};
-module.exports = testFixtures;
+module.exports = testFixtures;
\ No newline at end of file
diff --git a/tests/user/models/user.model.test.js b/tests/user/models/user.model.test.js
index 6787c3d..c9dff46 100644
--- a/tests/user/models/user.model.test.js
+++ b/tests/user/models/user.model.test.js
@@ -5,9 +5,9 @@
const fixtures = require('../helpers/test-fixtures');
-// Mock db before importing model
jest.mock('../../../src/config/db', () => ({
query: jest.fn(),
+ connect: jest.fn(),
}));
const db = require('../../../src/config/db');
@@ -19,55 +19,485 @@ describe('User Model', () => {
});
// ========================================
- // findWebProfilesByUserId tests
+ // findByEmail
// ========================================
- describe('findWebProfilesByUserId', () => {
- it('should return empty array if user has no profiles', async () => {
+ describe('findByEmail', () => {
+ it('should return null if user not found', async () => {
db.query.mockResolvedValue({ rows: [] });
+ const result = await userModel.findByEmail('notfound@example.com');
+ expect(result).toBeNull();
+ });
- const result = await userModel.findWebProfilesByUserId('user-123');
+ it('should return user if found', async () => {
+ db.query.mockResolvedValue({ rows: [fixtures.mockUser] });
+ const result = await userModel.findByEmail('test@example.com');
+ expect(result).toEqual(fixtures.mockUser);
+ });
- expect(result).toEqual([]);
+ it('should query with correct email param', async () => {
+ db.query.mockResolvedValue({ rows: [] });
+ await userModel.findByEmail('test@example.com');
expect(db.query).toHaveBeenCalledWith(
- expect.stringContaining('FROM web_profiles')
+ expect.stringContaining('WHERE email = $1'),
+ ['test@example.com']
);
});
+ });
- it('should return all web profiles for a user', async () => {
- db.query.mockResolvedValue({ rows: fixtures.mockWebProfiles });
+ // ========================================
+ // findByUsername
+ // ========================================
+ describe('findByUsername', () => {
+ it('should return null if username not found', async () => {
+ db.query.mockResolvedValue({ rows: [] });
+ const result = await userModel.findByUsername('ghostuser');
+ expect(result).toBeNull();
+ });
+
+ it('should return user if found', async () => {
+ db.query.mockResolvedValue({ rows: [fixtures.mockUser] });
+ const result = await userModel.findByUsername('testuser');
+ expect(result).toEqual(fixtures.mockUser);
+ });
+
+ it('should query with LOWER for case-insensitive match', async () => {
+ db.query.mockResolvedValue({ rows: [] });
+ await userModel.findByUsername('TestUser');
+ expect(db.query).toHaveBeenCalledWith(
+ expect.stringContaining('LOWER(username)'),
+ expect.any(Array)
+ );
+ });
+ });
+
+ // ========================================
+ // findByEmailOrUsername
+ // ========================================
+ describe('findByEmailOrUsername', () => {
+ it('should return null if no match found', async () => {
+ db.query.mockResolvedValue({ rows: [] });
+ const result = await userModel.findByEmailOrUsername('nobody@example.com');
+ expect(result).toBeNull();
+ });
+
+ it('should return user matched by email', async () => {
+ db.query.mockResolvedValue({ rows: [fixtures.mockUser] });
+ const result = await userModel.findByEmailOrUsername('test@example.com');
+ expect(result).toEqual(fixtures.mockUser);
+ });
+
+ it('should return user matched by username', async () => {
+ db.query.mockResolvedValue({ rows: [fixtures.mockUser] });
+ const result = await userModel.findByEmailOrUsername('testuser');
+ expect(result).toEqual(fixtures.mockUser);
+ });
+
+ it('should query with both email and username conditions', async () => {
+ db.query.mockResolvedValue({ rows: [] });
+ await userModel.findByEmailOrUsername('test@example.com');
+ expect(db.query).toHaveBeenCalledWith(
+ expect.stringContaining('email = $1'),
+ expect.any(Array)
+ );
+ expect(db.query).toHaveBeenCalledWith(
+ expect.stringContaining('LOWER(username)'),
+ expect.any(Array)
+ );
+ });
+ });
+
+ // ========================================
+ // findById
+ // ========================================
+ describe('findById', () => {
+ it('should return null if user not found', async () => {
+ db.query.mockResolvedValue({ rows: [] });
+ const result = await userModel.findById('user-999');
+ expect(result).toBeNull();
+ });
+
+ it('should return user if found', async () => {
+ db.query.mockResolvedValue({ rows: [fixtures.mockUser] });
+ const result = await userModel.findById('user-123');
+ expect(result).toEqual(fixtures.mockUser);
+ expect(result.id).toBe('user-123');
+ });
+
+ it('should query by id', async () => {
+ db.query.mockResolvedValue({ rows: [] });
+ await userModel.findById('user-123');
+ expect(db.query).toHaveBeenCalledWith(
+ expect.stringContaining('WHERE id = $1'),
+ ['user-123']
+ );
+ });
+ });
+
+ // ========================================
+ // findFullById
+ // ========================================
+ describe('findFullById', () => {
+ it('should return null if user not found', async () => {
+ db.query.mockResolvedValue({ rows: [] });
+ const result = await userModel.findFullById('user-999');
+ expect(result).toBeNull();
+ });
+
+ it('should return full user profile', async () => {
+ db.query.mockResolvedValue({ rows: [fixtures.mockUser] });
+ const result = await userModel.findFullById('user-123');
+ expect(result).toEqual(fixtures.mockUser);
+ });
+
+ it('should include private fields like twofa_enabled', async () => {
+ db.query.mockResolvedValue({ rows: [fixtures.mockUser] });
+ const result = await userModel.findFullById('user-123');
+ expect(result).toHaveProperty('twofa_enabled');
+ });
+ });
+
+ // ========================================
+ // findPublicById
+ // ========================================
+ describe('findPublicById', () => {
+ it('should return null if user not found', async () => {
+ db.query.mockResolvedValue({ rows: [] });
+ const result = await userModel.findPublicById('user-999');
+ expect(result).toBeNull();
+ });
+
+ it('should return public user profile', async () => {
+ db.query.mockResolvedValue({ rows: [fixtures.mockPublicUser] });
+ const result = await userModel.findPublicById('user-123');
+ expect(result).toEqual(fixtures.mockPublicUser);
+ });
+ });
+
+ // ========================================
+ // isFollowing
+ // ========================================
+ describe('isFollowing', () => {
+ it('should return false if not following', async () => {
+ db.query.mockResolvedValue({ rows: [] });
+ const result = await userModel.isFollowing('user-123', 'user-456');
+ expect(result).toBe(false);
+ });
+
+ it('should return true if following', async () => {
+ db.query.mockResolvedValue({ rows: [{ 1: 1 }] });
+ const result = await userModel.isFollowing('user-123', 'user-456');
+ expect(result).toBe(true);
+ });
+
+ it('should query follows table with correct params', async () => {
+ db.query.mockResolvedValue({ rows: [] });
+ await userModel.isFollowing('user-123', 'user-456');
+ expect(db.query).toHaveBeenCalledWith(
+ expect.stringContaining('FROM follows'),
+ ['user-123', 'user-456']
+ );
+ });
+ });
+
+ // ========================================
+ // create
+ // ========================================
+ describe('create', () => {
+ it('should return created user', async () => {
+ db.query.mockResolvedValue({ rows: [fixtures.mockUser] });
+ const result = await userModel.create({
+ email: 'test@example.com',
+ password_hashed: 'hashed',
+ display_name: 'Test User',
+ gender: 'male',
+ date_of_birth: '1990-01-01',
+ });
+ expect(result).toEqual(fixtures.mockUser);
+ });
+
+ it('should insert into users table', async () => {
+ db.query.mockResolvedValue({ rows: [fixtures.mockUser] });
+ await userModel.create({
+ email: 'test@example.com',
+ password_hashed: 'hashed',
+ display_name: 'Test User',
+ gender: 'male',
+ date_of_birth: '1990-01-01',
+ });
+ expect(db.query).toHaveBeenCalledWith(
+ expect.stringContaining('INSERT INTO users'),
+ expect.any(Array)
+ );
+ });
+ });
+
+ // ========================================
+ // markVerified
+ // ========================================
+ describe('markVerified', () => {
+ it('should call query with correct userId', async () => {
+ db.query.mockResolvedValue({});
+ await userModel.markVerified('user-123');
+ expect(db.query).toHaveBeenCalledWith(
+ expect.stringContaining('is_verified = true'),
+ ['user-123']
+ );
+ });
+ });
+
+ // ========================================
+ // updatePassword
+ // ========================================
+ describe('updatePassword', () => {
+ it('should update password with correct params', async () => {
+ db.query.mockResolvedValue({});
+ await userModel.updatePassword('user-123', 'newhashed');
+ expect(db.query).toHaveBeenCalledWith(
+ expect.stringContaining('password_hashed = $1'),
+ ['newhashed', 'user-123']
+ );
+ });
+ });
+
+ // ========================================
+ // updateProfile
+ // ========================================
+ describe('updateProfile', () => {
+ it('should return null if no allowed fields provided', async () => {
+ const result = await userModel.updateProfile('user-123', { unknown_field: 'value' });
+ expect(result).toBeNull();
+ expect(db.query).not.toHaveBeenCalled();
+ });
+
+ it('should update allowed fields only', async () => {
+ db.query.mockResolvedValue({ rows: [fixtures.mockUser] });
+ const result = await userModel.updateProfile('user-123', {
+ display_name: 'New Name',
+ bio: 'New bio',
+ unknown_field: 'ignored',
+ });
+ expect(result).toEqual(fixtures.mockUser);
+ expect(db.query).toHaveBeenCalledWith(
+ expect.stringContaining('UPDATE users SET'),
+ expect.arrayContaining(['New Name', 'New bio', 'user-123'])
+ );
+ });
+
+ it('should not include unknown fields in query', async () => {
+ db.query.mockResolvedValue({ rows: [fixtures.mockUser] });
+ await userModel.updateProfile('user-123', {
+ display_name: 'New Name',
+ malicious_field: 'DROP TABLE users',
+ });
+ expect(db.query).toHaveBeenCalledWith(
+ expect.not.stringContaining('malicious_field'),
+ expect.any(Array)
+ );
+ });
+ });
+
+ // ========================================
+ // updateAccount
+ // ========================================
+ describe('updateAccount', () => {
+ it('should return null if no allowed fields provided', async () => {
+ const result = await userModel.updateAccount('user-123', { username: 'ignored' });
+ expect(result).toBeNull();
+ expect(db.query).not.toHaveBeenCalled();
+ });
+
+ it('should update gender and date_of_birth', async () => {
+ db.query.mockResolvedValue({ rows: [fixtures.mockUser] });
+ await userModel.updateAccount('user-123', {
+ gender: 'female',
+ date_of_birth: '1995-05-05',
+ });
+ expect(db.query).toHaveBeenCalledWith(
+ expect.stringContaining('UPDATE users SET'),
+ expect.arrayContaining(['female', '1995-05-05', 'user-123'])
+ );
+ });
+ });
+
+ // ========================================
+ // updateRole
+ // ========================================
+ describe('updateRole', () => {
+ it('should return updated user with new role', async () => {
+ const updatedUser = { ...fixtures.mockUser, role: 'artist' };
+ db.query.mockResolvedValue({ rows: [updatedUser] });
+ const result = await userModel.updateRole('user-123', 'artist');
+ expect(result.role).toBe('artist');
+ });
+
+ it('should query with correct role and userId', async () => {
+ db.query.mockResolvedValue({ rows: [fixtures.mockUser] });
+ await userModel.updateRole('user-123', 'artist');
+ expect(db.query).toHaveBeenCalledWith(
+ expect.stringContaining('SET role = $1'),
+ ['artist', 'user-123']
+ );
+ });
+ });
+
+ // ========================================
+ // updatePrivacy
+ // ========================================
+ describe('updatePrivacy', () => {
+ it('should set is_private to true', async () => {
+ db.query.mockResolvedValue({ rows: [{ is_private: true }] });
+ const result = await userModel.updatePrivacy('user-123', true);
+ expect(result.is_private).toBe(true);
+ });
+
+ it('should set is_private to false', async () => {
+ db.query.mockResolvedValue({ rows: [{ is_private: false }] });
+ const result = await userModel.updatePrivacy('user-123', false);
+ expect(result.is_private).toBe(false);
+ });
+ it('should query with correct params', async () => {
+ db.query.mockResolvedValue({ rows: [{ is_private: true }] });
+ await userModel.updatePrivacy('user-123', true);
+ expect(db.query).toHaveBeenCalledWith(
+ expect.stringContaining('SET is_private = $1'),
+ [true, 'user-123']
+ );
+ });
+ });
+
+ // ========================================
+ // updateAvatar
+ // ========================================
+ describe('updateAvatar', () => {
+ it('should update user avatar', async () => {
+ const updatedUser = { ...fixtures.mockUser, profile_picture: 'https://cdn.example.com/avatar.jpg' };
+ db.query.mockResolvedValue({ rows: [updatedUser] });
+ const result = await userModel.updateAvatar('user-123', 'https://cdn.example.com/avatar.jpg');
+ expect(result).toEqual(updatedUser);
+ expect(db.query).toHaveBeenCalledWith(
+ expect.stringContaining('UPDATE users SET profile_picture'),
+ expect.arrayContaining(['https://cdn.example.com/avatar.jpg', 'user-123'])
+ );
+ });
+
+ it('should return null if user not found', async () => {
+ db.query.mockResolvedValue({ rows: [] });
+ const result = await userModel.updateAvatar('user-999', 'https://cdn.example.com/avatar.jpg');
+ expect(result).toBeNull();
+ });
+ });
+
+ // ========================================
+ // deleteAvatar
+ // ========================================
+ describe('deleteAvatar', () => {
+ it('should set avatar to null', async () => {
+ const updatedUser = { ...fixtures.mockUser, profile_picture: null };
+ db.query.mockResolvedValue({ rows: [updatedUser] });
+ const result = await userModel.deleteAvatar('user-123');
+ expect(result.profile_picture).toBeNull();
+ });
+
+ it('should query with correct userId', async () => {
+ db.query.mockResolvedValue({ rows: [fixtures.mockUser] });
+ await userModel.deleteAvatar('user-123');
+ expect(db.query).toHaveBeenCalledWith(
+ expect.stringContaining('profile_picture = NULL'),
+ ['user-123']
+ );
+ });
+ });
+
+ // ========================================
+ // updateCoverPhoto
+ // ========================================
+ describe('updateCoverPhoto', () => {
+ it('should update user cover photo', async () => {
+ const updatedUser = { ...fixtures.mockUser, cover_photo: 'https://cdn.example.com/cover.jpg' };
+ db.query.mockResolvedValue({ rows: [updatedUser] });
+ const result = await userModel.updateCoverPhoto('user-123', 'https://cdn.example.com/cover.jpg');
+ expect(result).toEqual(updatedUser);
+ expect(db.query).toHaveBeenCalledWith(
+ expect.stringContaining('UPDATE users SET cover_photo'),
+ expect.arrayContaining(['https://cdn.example.com/cover.jpg', 'user-123'])
+ );
+ });
+
+ it('should return null if user not found', async () => {
+ db.query.mockResolvedValue({ rows: [] });
+ const result = await userModel.updateCoverPhoto('user-999', 'https://cdn.example.com/cover.jpg');
+ expect(result).toBeNull();
+ });
+ });
+
+ // ========================================
+ // deleteCoverPhoto
+ // ========================================
+ describe('deleteCoverPhoto', () => {
+ it('should set cover photo to null', async () => {
+ const updatedUser = { ...fixtures.mockUser, cover_photo: null };
+ db.query.mockResolvedValue({ rows: [updatedUser] });
+ const result = await userModel.deleteCoverPhoto('user-123');
+ expect(result.cover_photo).toBeNull();
+ });
+
+ it('should query with correct userId', async () => {
+ db.query.mockResolvedValue({ rows: [fixtures.mockUser] });
+ await userModel.deleteCoverPhoto('user-123');
+ expect(db.query).toHaveBeenCalledWith(
+ expect.stringContaining('cover_photo = NULL'),
+ ['user-123']
+ );
+ });
+ });
+
+ // ========================================
+ // findWebProfilesByUserId
+ // ========================================
+ describe('findWebProfilesByUserId', () => {
+ it('should return empty array if user has no profiles', async () => {
+ db.query.mockResolvedValue({ rows: [] });
const result = await userModel.findWebProfilesByUserId('user-123');
+ expect(result).toEqual([]);
+ });
+ it('should return all web profiles for a user', async () => {
+ db.query.mockResolvedValue({ rows: fixtures.mockWebProfiles });
+ const result = await userModel.findWebProfilesByUserId('user-123');
expect(result).toEqual(fixtures.mockWebProfiles);
expect(result).toHaveLength(2);
});
+
+ it('should query from web_profiles table', async () => {
+ db.query.mockResolvedValue({ rows: [] });
+ await userModel.findWebProfilesByUserId('user-123');
+ expect(db.query).toHaveBeenCalledWith(
+ expect.stringContaining('FROM web_profiles'),
+ expect.any(Array)
+ );
+ });
});
// ========================================
- // findWebProfileByPlatform tests
+ // findWebProfileByPlatform
// ========================================
describe('findWebProfileByPlatform', () => {
it('should return null if platform does not exist', async () => {
db.query.mockResolvedValue({ rows: [] });
-
const result = await userModel.findWebProfileByPlatform('user-123', 'Twitter');
-
expect(result).toBeNull();
});
it('should return profile if platform exists', async () => {
db.query.mockResolvedValue({ rows: [{ id: 'profile-1' }] });
-
const result = await userModel.findWebProfileByPlatform('user-123', 'Twitter');
-
expect(result).toEqual({ id: 'profile-1' });
});
it('should query with correct SQL and params', async () => {
db.query.mockResolvedValue({ rows: [] });
-
await userModel.findWebProfileByPlatform('user-123', 'Twitter');
-
expect(db.query).toHaveBeenCalledWith(
expect.stringContaining('WHERE user_id = $1 AND platform = $2'),
['user-123', 'Twitter']
@@ -76,35 +506,25 @@ describe('User Model', () => {
});
// ========================================
- // createWebProfile tests
+ // createWebProfile
// ========================================
describe('createWebProfile', () => {
it('should return null if insert returns no rows', async () => {
db.query.mockResolvedValue({ rows: [] });
-
const result = await userModel.createWebProfile('user-123', 'Twitter', 'https://twitter.com/user');
-
expect(result).toBeNull();
});
it('should return created profile', async () => {
- const createdProfile = {
- id: 'profile-new',
- platform: 'LinkedIn',
- url: 'https://linkedin.com/in/user',
- };
+ const createdProfile = { id: 'profile-new', platform: 'LinkedIn', url: 'https://linkedin.com/in/user' };
db.query.mockResolvedValue({ rows: [createdProfile] });
-
const result = await userModel.createWebProfile('user-123', 'LinkedIn', 'https://linkedin.com/in/user');
-
expect(result).toEqual(createdProfile);
});
it('should insert with correct values', async () => {
db.query.mockResolvedValue({ rows: [fixtures.mockWebProfile] });
-
await userModel.createWebProfile('user-123', 'Twitter', 'https://twitter.com/testuser');
-
expect(db.query).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO web_profiles'),
['user-123', 'Twitter', 'https://twitter.com/testuser']
@@ -113,52 +533,42 @@ describe('User Model', () => {
});
// ========================================
- // findWebProfileById tests
+ // findWebProfileById
// ========================================
describe('findWebProfileById', () => {
it('should return null if profile not found', async () => {
db.query.mockResolvedValue({ rows: [] });
-
const result = await userModel.findWebProfileById('profile-999');
-
expect(result).toBeNull();
});
it('should return profile with user_id', async () => {
db.query.mockResolvedValue({ rows: [fixtures.mockWebProfile] });
-
const result = await userModel.findWebProfileById('profile-1');
-
expect(result).toEqual(fixtures.mockWebProfile);
expect(result.user_id).toBe('user-123');
});
});
// ========================================
- // deleteWebProfile tests
+ // deleteWebProfile
// ========================================
describe('deleteWebProfile', () => {
it('should return null if profile not found', async () => {
db.query.mockResolvedValue({ rows: [] });
-
const result = await userModel.deleteWebProfile('profile-999');
-
expect(result).toBeNull();
});
it('should return deleted profile id', async () => {
db.query.mockResolvedValue({ rows: [{ id: 'profile-1' }] });
-
const result = await userModel.deleteWebProfile('profile-1');
-
expect(result).toEqual({ id: 'profile-1' });
});
it('should delete profile by id', async () => {
db.query.mockResolvedValue({ rows: [{ id: 'profile-1' }] });
-
await userModel.deleteWebProfile('profile-1');
-
expect(db.query).toHaveBeenCalledWith(
expect.stringContaining('DELETE FROM web_profiles WHERE id = $1'),
['profile-1']
@@ -167,88 +577,140 @@ describe('User Model', () => {
});
// ========================================
- // findById tests
+ // updateLastLogin
// ========================================
- describe('findById', () => {
- it('should return null if user not found', async () => {
- db.query.mockResolvedValue({ rows: [] });
-
- const result = await userModel.findById('user-999');
-
- expect(result).toBeNull();
+ describe('updateLastLogin', () => {
+ it('should update last_login_at for user', async () => {
+ db.query.mockResolvedValue({});
+ await userModel.updateLastLogin('user-123');
+ expect(db.query).toHaveBeenCalledWith(
+ expect.stringContaining('last_login_at = now()'),
+ ['user-123']
+ );
});
+ });
- it('should return user if found', async () => {
- db.query.mockResolvedValue({ rows: [fixtures.mockUser] });
-
- const result = await userModel.findById('user-123');
-
- expect(result).toEqual(fixtures.mockUser);
- expect(result.id).toBe('user-123');
+ // ========================================
+ // setPendingEmail
+ // ========================================
+ describe('setPendingEmail', () => {
+ it('should set pending_email for user', async () => {
+ db.query.mockResolvedValue({});
+ await userModel.setPendingEmail('user-123', 'new@example.com');
+ expect(db.query).toHaveBeenCalledWith(
+ expect.stringContaining('pending_email'),
+ ['user-123', 'new@example.com']
+ );
});
});
// ========================================
- // updateAvatar tests
+ // applyPendingEmail
// ========================================
- describe('updateAvatar', () => {
- it('should update user avatar', async () => {
- const updatedUser = { ...fixtures.mockUser, profile_picture: 'https://cdn.example.com/avatar.jpg' };
- db.query.mockResolvedValue({ rows: [updatedUser] });
-
- const result = await userModel.updateAvatar('user-123', 'https://cdn.example.com/avatar.jpg');
+ describe('applyPendingEmail', () => {
+ it('should apply pending email and return updated email', async () => {
+ db.query.mockResolvedValue({ rows: [{ email: 'new@example.com' }] });
+ const result = await userModel.applyPendingEmail('user-123');
+ expect(result.email).toBe('new@example.com');
+ });
- expect(result).toEqual(updatedUser);
+ it('should clear pending_email after applying', async () => {
+ db.query.mockResolvedValue({ rows: [{ email: 'new@example.com' }] });
+ await userModel.applyPendingEmail('user-123');
expect(db.query).toHaveBeenCalledWith(
- expect.stringContaining('UPDATE users SET profile_picture'),
- expect.arrayContaining(['https://cdn.example.com/avatar.jpg', 'user-123'])
+ expect.stringContaining('pending_email = NULL'),
+ ['user-123']
);
});
});
// ========================================
- // deleteAvatar tests
+ // replaceGenres
// ========================================
- describe('deleteAvatar', () => {
- it('should set avatar to null', async () => {
- const updatedUser = { ...fixtures.mockUser, profile_picture: null };
- db.query.mockResolvedValue({ rows: [updatedUser] });
+ describe('replaceGenres', () => {
+ it('should delete old genres and insert new ones', async () => {
+ const mockClient = {
+ query: jest.fn().mockResolvedValue({ rows: [] }),
+ release: jest.fn(),
+ };
+ db.connect.mockResolvedValue(mockClient);
+ jest.spyOn(userModel, 'findGenresByUserId').mockResolvedValue([
+ { id: 'genre-1', name: 'Rock' },
+ ]);
- const result = await userModel.deleteAvatar('user-123');
+ await userModel.replaceGenres('user-123', ['genre-1', 'genre-2']);
- expect(result.profile_picture).toBeNull();
+ expect(mockClient.query).toHaveBeenCalledWith('BEGIN');
+ expect(mockClient.query).toHaveBeenCalledWith(
+ expect.stringContaining('DELETE FROM user_favorite_genres'),
+ ['user-123']
+ );
+ expect(mockClient.query).toHaveBeenCalledWith('COMMIT');
+ expect(mockClient.release).toHaveBeenCalled();
+ });
+
+ it('should rollback on error', async () => {
+ const mockClient = {
+ query: jest.fn()
+ .mockResolvedValueOnce({}) // BEGIN
+ .mockResolvedValueOnce({}) // DELETE
+ .mockRejectedValueOnce(new Error('DB error')), // INSERT fails
+ release: jest.fn(),
+ };
+ db.connect.mockResolvedValue(mockClient);
+
+ await expect(userModel.replaceGenres('user-123', ['genre-1'])).rejects.toThrow('DB error');
+
+ expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK');
+ expect(mockClient.release).toHaveBeenCalled();
});
});
// ========================================
- // updateCoverPhoto tests
+ // completeOnboarding
// ========================================
- describe('updateCoverPhoto', () => {
- it('should update user cover photo', async () => {
- const updatedUser = { ...fixtures.mockUser, cover_photo: 'https://cdn.example.com/cover.jpg' };
- db.query.mockResolvedValue({ rows: [updatedUser] });
-
- const result = await userModel.updateCoverPhoto('user-123', 'https://cdn.example.com/cover.jpg');
+ describe('completeOnboarding', () => {
+ it('should return null if no allowed fields provided', async () => {
+ const result = await userModel.completeOnboarding('user-123', { unknown: 'value' });
+ expect(result).toBeNull();
+ expect(db.query).not.toHaveBeenCalled();
+ });
- expect(result).toEqual(updatedUser);
+ it('should update onboarding fields', async () => {
+ db.query.mockResolvedValue({ rows: [fixtures.mockUser] });
+ const result = await userModel.completeOnboarding('user-123', {
+ display_name: 'John Doe',
+ gender: 'male',
+ date_of_birth: '1990-01-01',
+ });
+ expect(result).toEqual(fixtures.mockUser);
expect(db.query).toHaveBeenCalledWith(
- expect.stringContaining('UPDATE users SET cover_photo'),
- expect.arrayContaining(['https://cdn.example.com/cover.jpg', 'user-123'])
+ expect.stringContaining('UPDATE users SET'),
+ expect.arrayContaining(['John Doe', 'male', '1990-01-01', 'user-123'])
);
});
});
// ========================================
- // deleteCoverPhoto tests
+ // createOAuthUser
// ========================================
- describe('deleteCoverPhoto', () => {
- it('should set cover photo to null', async () => {
- const updatedUser = { ...fixtures.mockUser, cover_photo: null };
- db.query.mockResolvedValue({ rows: [updatedUser] });
-
- const result = await userModel.deleteCoverPhoto('user-123');
+ describe('createOAuthUser', () => {
+ it('should create user with is_verified true', async () => {
+ db.query.mockResolvedValue({ rows: [{ ...fixtures.mockUser, is_verified: true }] });
+ const result = await userModel.createOAuthUser({
+ email: 'oauth@example.com',
+ display_name: 'OAuth User',
+ });
+ expect(result.is_verified).toBe(true);
+ });
- expect(result.cover_photo).toBeNull();
+ it('should insert into users with correct params', async () => {
+ db.query.mockResolvedValue({ rows: [fixtures.mockUser] });
+ await userModel.createOAuthUser({ email: 'oauth@example.com', display_name: 'OAuth User' });
+ expect(db.query).toHaveBeenCalledWith(
+ expect.stringContaining('INSERT INTO users'),
+ ['oauth@example.com', 'OAuth User']
+ );
});
});
-});
+});
\ No newline at end of file
diff --git a/tests/user/services/users.service.test.js b/tests/user/services/users.service.test.js
index d50c286..4e2693d 100644
--- a/tests/user/services/users.service.test.js
+++ b/tests/user/services/users.service.test.js
@@ -5,11 +5,11 @@
const fixtures = require('../helpers/test-fixtures');
-// Mock the model layer before importing service
- jest.mock('../../../src/models/user.model', () => ({
+jest.mock('../../../src/models/user.model', () => ({
findById: jest.fn(),
findFullById: jest.fn(),
findPublicById: jest.fn(),
+ findByUsername: jest.fn(),
findWebProfilesByUserId: jest.fn(),
findWebProfileByPlatform: jest.fn(),
createWebProfile: jest.fn(),
@@ -24,6 +24,13 @@ const fixtures = require('../helpers/test-fixtures');
updateRole: jest.fn(),
updatePrivacy: jest.fn(),
isFollowing: jest.fn(),
+ findGenresByUserId: jest.fn(),
+ replaceGenres: jest.fn(),
+ completeOnboarding: jest.fn(),
+ findContentSettingsByUserId: jest.fn(),
+ updateContentSettings: jest.fn(),
+ findPrivacySettingsByUserId: jest.fn(),
+ updatePrivacySettings: jest.fn(),
}));
const userModel = require('../../../src/models/user.model');
@@ -35,140 +42,215 @@ describe('Users Service', () => {
});
// ========================================
- // getMyWebProfile tests
+ // getMe
// ========================================
- describe('getMyWebProfile', () => {
- it('should return empty array if no profiles exist', async () => {
- userModel.findWebProfilesByUserId.mockResolvedValue([]);
+ describe('getMe', () => {
+ it('should return full user profile', async () => {
+ userModel.findFullById.mockResolvedValue(fixtures.mockUser);
+ const result = await usersService.getMe('user-123');
+ expect(result).toEqual(fixtures.mockUser);
+ expect(userModel.findFullById).toHaveBeenCalledWith('user-123');
+ });
- const result = await usersService.getMyWebProfile('user-123');
+ it('should throw 404 if user not found', async () => {
+ userModel.findFullById.mockResolvedValue(null);
+ await expect(usersService.getMe('user-999')).rejects.toMatchObject({
+ statusCode: 404,
+ code: 'RESOURCE_NOT_FOUND',
+ });
+ });
+ });
- expect(result).toEqual([]);
- expect(userModel.findWebProfilesByUserId).toHaveBeenCalledWith('user-123');
+ // ========================================
+ // getUserById
+ // ========================================
+ describe('getUserById', () => {
+ it('should return public profile if not private', async () => {
+ userModel.findPublicById.mockResolvedValue({ ...fixtures.mockPublicUser, is_private: false });
+ const result = await usersService.getUserById('user-456', 'user-123');
+ expect(result).toBeDefined();
});
- it('should return all web profiles for user', async () => {
- userModel.findWebProfilesByUserId.mockResolvedValue(fixtures.mockWebProfiles);
+ it('should throw 404 if user not found', async () => {
+ userModel.findPublicById.mockResolvedValue(null);
+ await expect(usersService.getUserById('user-999', 'user-123')).rejects.toMatchObject({
+ statusCode: 404,
+ });
+ });
- const result = await usersService.getMyWebProfile('user-123');
+ it('should throw 403 if profile is private and no requester', async () => {
+ userModel.findPublicById.mockResolvedValue({ ...fixtures.mockPublicUser, is_private: true });
+ await expect(usersService.getUserById('user-456', null)).rejects.toMatchObject({
+ statusCode: 403,
+ code: 'RESOURCE_PRIVATE',
+ });
+ });
- expect(result).toEqual(fixtures.mockWebProfiles);
- expect(result).toHaveLength(2);
+ it('should return profile if requester is the owner', async () => {
+ userModel.findPublicById.mockResolvedValue({ ...fixtures.mockPublicUser, is_private: true });
+ const result = await usersService.getUserById('user-123', 'user-123');
+ expect(result).toBeDefined();
+ });
+
+ it('should return profile if requester is following the private user', async () => {
+ userModel.findPublicById.mockResolvedValue({ ...fixtures.mockPublicUser, is_private: true });
+ userModel.isFollowing.mockResolvedValue(true);
+ const result = await usersService.getUserById('user-456', 'user-123');
+ expect(result).toBeDefined();
+ });
+
+ it('should throw 403 if requester is not following private user', async () => {
+ userModel.findPublicById.mockResolvedValue({ ...fixtures.mockPublicUser, is_private: true });
+ userModel.isFollowing.mockResolvedValue(false);
+ await expect(usersService.getUserById('user-456', 'user-123')).rejects.toMatchObject({
+ statusCode: 403,
+ code: 'RESOURCE_PRIVATE',
+ });
});
});
// ========================================
- // addWebProfile tests
+ // updateMe
// ========================================
- describe('addWebProfile', () => {
- it('should throw 409 if platform already exists', async () => {
- userModel.findWebProfileByPlatform.mockResolvedValue({ id: 'profile-1', platform: 'Twitter' });
-
- await expect(
- usersService.addWebProfile('user-123', 'Twitter', 'https://twitter.com/newuser')
- ).rejects.toThrow('A profile for this platform already exists.');
-
- expect(userModel.findWebProfileByPlatform).toHaveBeenCalledWith('user-123', 'Twitter');
- expect(userModel.createWebProfile).not.toHaveBeenCalled();
+ describe('updateMe', () => {
+ it('should update profile with valid fields', async () => {
+ userModel.updateProfile.mockResolvedValue({ ...fixtures.mockUser, display_name: 'New Name' });
+ const result = await usersService.updateMe('user-123', { display_name: 'New Name' });
+ expect(result.display_name).toBe('New Name');
});
- it('should create profile if platform does not exist', async () => {
- const newProfile = {
- id: 'profile-new',
- platform: 'LinkedIn',
- url: 'https://linkedin.com/in/user',
- };
- userModel.findWebProfileByPlatform.mockResolvedValue(null);
- userModel.createWebProfile.mockResolvedValue(newProfile);
+ it('should throw 400 if nothing to update', async () => {
+ userModel.updateProfile.mockResolvedValue(null);
+ await expect(usersService.updateMe('user-123', {})).rejects.toMatchObject({
+ statusCode: 400,
+ code: 'VALIDATION_FAILED',
+ });
+ });
- const result = await usersService.addWebProfile('user-123', 'LinkedIn', 'https://linkedin.com/in/user');
+ it('should normalize username to lowercase', async () => {
+ userModel.findByUsername.mockResolvedValue(null);
+ userModel.updateProfile.mockResolvedValue(fixtures.mockUser);
+ await usersService.updateMe('user-123', { username: 'TestUser' });
+ expect(userModel.updateProfile).toHaveBeenCalledWith(
+ 'user-123',
+ expect.objectContaining({ username: 'testuser' })
+ );
+ });
- expect(result).toEqual(newProfile);
- expect(userModel.createWebProfile).toHaveBeenCalledWith('user-123', 'LinkedIn', 'https://linkedin.com/in/user');
+ it('should throw 409 if username already taken by another user', async () => {
+ userModel.findByUsername.mockResolvedValue({ id: 'other-user', username: 'takenuser' });
+ await expect(
+ usersService.updateMe('user-123', { username: 'takenuser' })
+ ).rejects.toMatchObject({ statusCode: 409, code: 'RESOURCE_ALREADY_EXISTS' });
});
- it('should throw 409 error with correct code', async () => {
- userModel.findWebProfileByPlatform.mockResolvedValue({ id: 'profile-1' });
+ it('should allow same username if it belongs to the same user', async () => {
+ userModel.findByUsername.mockResolvedValue({ id: 'user-123', username: 'testuser' });
+ userModel.updateProfile.mockResolvedValue(fixtures.mockUser);
+ const result = await usersService.updateMe('user-123', { username: 'testuser' });
+ expect(result).toBeDefined();
+ });
- try {
- await usersService.addWebProfile('user-123', 'Twitter', 'https://twitter.com/user');
- throw new Error('Should have thrown error');
- } catch (err) {
- expect(err.statusCode).toBe(409);
- expect(err.code).toBe('RESOURCE_ALREADY_EXISTS');
- }
+ it('should throw 400 if username is empty after trim', async () => {
+ await expect(
+ usersService.updateMe('user-123', { username: ' ' })
+ ).rejects.toMatchObject({ statusCode: 400, code: 'VALIDATION_FAILED' });
});
});
// ========================================
- // deleteWebProfile tests
+ // updateMyAccount
// ========================================
- describe('deleteWebProfile', () => {
- it('should throw 404 if profile not found', async () => {
- userModel.findWebProfileById.mockResolvedValue(null);
+ describe('updateMyAccount', () => {
+ it('should update account with valid fields', async () => {
+ userModel.updateAccount.mockResolvedValue({ ...fixtures.mockUser, gender: 'female' });
+ const result = await usersService.updateMyAccount('user-123', { gender: 'female' });
+ expect(result.gender).toBe('female');
+ });
+ it('should throw 400 for invalid gender', async () => {
await expect(
- usersService.deleteWebProfile('user-123', 'profile-999')
- ).rejects.toThrow('Web profile not found');
+ usersService.updateMyAccount('user-123', { gender: 'invalid' })
+ ).rejects.toMatchObject({ statusCode: 400, code: 'VALIDATION_FAILED' });
+ });
- expect(userModel.deleteWebProfile).not.toHaveBeenCalled();
+ it('should throw 400 for invalid date_of_birth format', async () => {
+ await expect(
+ usersService.updateMyAccount('user-123', { date_of_birth: 'not-a-date' })
+ ).rejects.toMatchObject({ statusCode: 400, code: 'VALIDATION_FAILED' });
});
- it('should throw 403 if user is not profile owner', async () => {
- userModel.findWebProfileById.mockResolvedValue({
- id: 'profile-1',
- user_id: 'different-user',
- platform: 'Twitter',
+ it('should throw 400 if user is under 13', async () => {
+ const underageDate = new Date();
+ underageDate.setFullYear(underageDate.getFullYear() - 10);
+ await expect(
+ usersService.updateMyAccount('user-123', { date_of_birth: underageDate.toISOString().split('T')[0] })
+ ).rejects.toMatchObject({ statusCode: 400, code: 'VALIDATION_FAILED' });
+ });
+
+ it('should throw 400 if nothing to update', async () => {
+ userModel.updateAccount.mockResolvedValue(null);
+ await expect(usersService.updateMyAccount('user-123', {})).rejects.toMatchObject({
+ statusCode: 400,
});
+ });
+ });
- await expect(
- usersService.deleteWebProfile('user-123', 'profile-1')
- ).rejects.toThrow('You are not allowed to delete this profile.');
+ // ========================================
+ // switchRole
+ // ========================================
+ describe('switchRole', () => {
+ it('should switch role to artist', async () => {
+ userModel.findById.mockResolvedValue({ ...fixtures.mockUser, role: 'listener' });
+ userModel.updateRole.mockResolvedValue({ ...fixtures.mockUser, role: 'artist' });
+ const result = await usersService.switchRole('user-123', 'artist');
+ expect(result.role).toBe('artist');
+ });
- expect(userModel.deleteWebProfile).not.toHaveBeenCalled();
+ it('should switch role to listener', async () => {
+ userModel.findById.mockResolvedValue({ ...fixtures.mockUser, role: 'artist' });
+ userModel.updateRole.mockResolvedValue({ ...fixtures.mockUser, role: 'listener' });
+ const result = await usersService.switchRole('user-123', 'listener');
+ expect(result.role).toBe('listener');
});
- it('should delete profile if user is owner', async () => {
- userModel.findWebProfileById.mockResolvedValue({
- id: 'profile-1',
- user_id: 'user-123',
- platform: 'Twitter',
+ it('should throw 400 for invalid role', async () => {
+ await expect(usersService.switchRole('user-123', 'admin')).rejects.toMatchObject({
+ statusCode: 400,
+ code: 'VALIDATION_FAILED',
});
- userModel.deleteWebProfile.mockResolvedValue({ id: 'profile-1' });
-
- const result = await usersService.deleteWebProfile('user-123', 'profile-1');
+ });
- expect(result).toEqual({ id: 'profile-1' });
- expect(userModel.deleteWebProfile).toHaveBeenCalledWith('profile-1');
+ it('should throw 400 for invalid role superadmin', async () => {
+ await expect(usersService.switchRole('user-123', 'superadmin')).rejects.toMatchObject({
+ statusCode: 400,
+ });
});
- it('should throw 403 error with correct code', async () => {
- userModel.findWebProfileById.mockResolvedValue({
- id: 'profile-1',
- user_id: 'different-user',
+ it('should throw 404 if user not found', async () => {
+ userModel.findById.mockResolvedValue(null);
+ await expect(usersService.switchRole('user-999', 'artist')).rejects.toMatchObject({
+ statusCode: 404,
});
+ });
- try {
- await usersService.deleteWebProfile('user-123', 'profile-1');
- throw new Error('Should have thrown error');
- } catch (err) {
- expect(err.statusCode).toBe(403);
- expect(err.code).toBe('PERMISSION_DENIED');
- }
+ it('should throw 409 if user already has the role', async () => {
+ userModel.findById.mockResolvedValue({ ...fixtures.mockUser, role: 'artist' });
+ await expect(usersService.switchRole('user-123', 'artist')).rejects.toMatchObject({
+ statusCode: 409,
+ });
});
});
// ========================================
- // uploadMyAvatar tests
+ // uploadMyAvatar
// ========================================
describe('uploadMyAvatar', () => {
it('should throw 404 if user not found', async () => {
userModel.findById.mockResolvedValue(null);
-
await expect(
usersService.uploadMyAvatar('user-999', { filename: 'avatar.jpg' })
- ).rejects.toThrow('User not found');
-
+ ).rejects.toMatchObject({ statusCode: 404 });
expect(userModel.updateAvatar).not.toHaveBeenCalled();
});
@@ -178,31 +260,91 @@ describe('Users Service', () => {
...fixtures.mockUser,
profile_picture: 'https://cdn.rythmify.com/avatars/user-123.jpg',
});
-
const result = await usersService.uploadMyAvatar('user-123', { filename: 'avatar.jpg' });
-
- expect(userModel.updateAvatar).toHaveBeenCalledWith('user-123', 'https://cdn.rythmify.com/avatars/user-123.jpg');
+ expect(userModel.updateAvatar).toHaveBeenCalledWith(
+ 'user-123',
+ 'https://cdn.rythmify.com/avatars/user-123.jpg'
+ );
expect(result.profile_picture).toBe('https://cdn.rythmify.com/avatars/user-123.jpg');
});
});
// ========================================
- // deleteMyCoverPhoto tests
+ // deleteMyAvatar
// ========================================
- describe('deleteMyCoverPhoto', () => {
+ describe('deleteMyAvatar', () => {
+ it('should throw 404 if user not found', async () => {
+ userModel.findById.mockResolvedValue(null);
+ await expect(usersService.deleteMyAvatar('user-999')).rejects.toMatchObject({
+ statusCode: 404,
+ });
+ expect(userModel.deleteAvatar).not.toHaveBeenCalled();
+ });
+
+ it('should throw 404 if user has no avatar', async () => {
+ userModel.findById.mockResolvedValue({ ...fixtures.mockUser, profile_picture: null });
+ await expect(usersService.deleteMyAvatar('user-123')).rejects.toMatchObject({
+ statusCode: 404,
+ });
+ expect(userModel.deleteAvatar).not.toHaveBeenCalled();
+ });
+
+ it('should delete avatar if exists', async () => {
+ userModel.findById.mockResolvedValue({
+ ...fixtures.mockUser,
+ profile_picture: 'https://cdn.example.com/avatar.jpg',
+ });
+ userModel.deleteAvatar.mockResolvedValue({ profile_picture: null });
+ const result = await usersService.deleteMyAvatar('user-123');
+ expect(result).toEqual({ profile_picture: null });
+ expect(userModel.deleteAvatar).toHaveBeenCalledWith('user-123');
+ });
+ });
+
+ // ========================================
+ // uploadMyCoverPhoto
+ // ========================================
+ describe('uploadMyCoverPhoto', () => {
it('should throw 404 if user not found', async () => {
userModel.findById.mockResolvedValue(null);
+ await expect(
+ usersService.uploadMyCoverPhoto('user-999', { filename: 'cover.jpg' })
+ ).rejects.toMatchObject({ statusCode: 404 });
+ expect(userModel.updateCoverPhoto).not.toHaveBeenCalled();
+ });
- await expect(usersService.deleteMyCoverPhoto('user-999')).rejects.toThrow('User not found');
+ it('should update cover photo with CDN URL', async () => {
+ userModel.findById.mockResolvedValue(fixtures.mockUser);
+ userModel.updateCoverPhoto.mockResolvedValue({
+ ...fixtures.mockUser,
+ cover_photo: 'https://cdn.rythmify.com/covers/user-123.jpg',
+ });
+ const result = await usersService.uploadMyCoverPhoto('user-123', { filename: 'cover.jpg' });
+ expect(userModel.updateCoverPhoto).toHaveBeenCalledWith(
+ 'user-123',
+ 'https://cdn.rythmify.com/covers/user-123.jpg'
+ );
+ expect(result.cover_photo).toBe('https://cdn.rythmify.com/covers/user-123.jpg');
+ });
+ });
+ // ========================================
+ // deleteMyCoverPhoto
+ // ========================================
+ describe('deleteMyCoverPhoto', () => {
+ it('should throw 404 if user not found', async () => {
+ userModel.findById.mockResolvedValue(null);
+ await expect(usersService.deleteMyCoverPhoto('user-999')).rejects.toMatchObject({
+ statusCode: 404,
+ });
expect(userModel.deleteCoverPhoto).not.toHaveBeenCalled();
});
it('should throw 404 if user has no cover photo', async () => {
userModel.findById.mockResolvedValue({ ...fixtures.mockUser, cover_photo: null });
-
- await expect(usersService.deleteMyCoverPhoto('user-123')).rejects.toThrow('No cover photo to delete');
-
+ await expect(usersService.deleteMyCoverPhoto('user-123')).rejects.toMatchObject({
+ statusCode: 404,
+ });
expect(userModel.deleteCoverPhoto).not.toHaveBeenCalled();
});
@@ -212,73 +354,281 @@ describe('Users Service', () => {
cover_photo: 'https://cdn.example.com/cover.jpg',
});
userModel.deleteCoverPhoto.mockResolvedValue({ cover_photo: null });
-
const result = await usersService.deleteMyCoverPhoto('user-123');
-
expect(result).toEqual({ cover_photo: null });
expect(userModel.deleteCoverPhoto).toHaveBeenCalledWith('user-123');
});
});
// ========================================
- // deleteMyAvatar tests
+ // getMyWebProfile
// ========================================
- describe('deleteMyAvatar', () => {
+ describe('getMyWebProfile', () => {
+ it('should return empty array if no profiles exist', async () => {
+ userModel.findWebProfilesByUserId.mockResolvedValue([]);
+ const result = await usersService.getMyWebProfile('user-123');
+ expect(result).toEqual([]);
+ expect(userModel.findWebProfilesByUserId).toHaveBeenCalledWith('user-123');
+ });
+
+ it('should return all web profiles for user', async () => {
+ userModel.findWebProfilesByUserId.mockResolvedValue(fixtures.mockWebProfiles);
+ const result = await usersService.getMyWebProfile('user-123');
+ expect(result).toEqual(fixtures.mockWebProfiles);
+ expect(result).toHaveLength(2);
+ });
+ });
+
+ // ========================================
+ // addWebProfile
+ // ========================================
+ describe('addWebProfile', () => {
+ it('should throw 409 if platform already exists', async () => {
+ userModel.findWebProfileByPlatform.mockResolvedValue({ id: 'profile-1', platform: 'Twitter' });
+ await expect(
+ usersService.addWebProfile('user-123', 'Twitter', 'https://twitter.com/newuser')
+ ).rejects.toMatchObject({ statusCode: 409, code: 'RESOURCE_ALREADY_EXISTS' });
+ expect(userModel.createWebProfile).not.toHaveBeenCalled();
+ });
+
+ it('should create profile if platform does not exist', async () => {
+ const newProfile = { id: 'profile-new', platform: 'LinkedIn', url: 'https://linkedin.com/in/user' };
+ userModel.findWebProfileByPlatform.mockResolvedValue(null);
+ userModel.createWebProfile.mockResolvedValue(newProfile);
+ const result = await usersService.addWebProfile('user-123', 'LinkedIn', 'https://linkedin.com/in/user');
+ expect(result).toEqual(newProfile);
+ expect(userModel.createWebProfile).toHaveBeenCalledWith('user-123', 'LinkedIn', 'https://linkedin.com/in/user');
+ });
+ });
+
+ // ========================================
+ // deleteWebProfile
+ // ========================================
+ describe('deleteWebProfile', () => {
+ it('should throw 404 if profile not found', async () => {
+ userModel.findWebProfileById.mockResolvedValue(null);
+ await expect(
+ usersService.deleteWebProfile('user-123', 'profile-999')
+ ).rejects.toMatchObject({ statusCode: 404 });
+ expect(userModel.deleteWebProfile).not.toHaveBeenCalled();
+ });
+
+ it('should throw 403 if user is not profile owner', async () => {
+ userModel.findWebProfileById.mockResolvedValue({
+ id: 'profile-1',
+ user_id: 'different-user',
+ platform: 'Twitter',
+ });
+ await expect(
+ usersService.deleteWebProfile('user-123', 'profile-1')
+ ).rejects.toMatchObject({ statusCode: 403, code: 'PERMISSION_DENIED' });
+ expect(userModel.deleteWebProfile).not.toHaveBeenCalled();
+ });
+
+ it('should delete profile if user is owner', async () => {
+ userModel.findWebProfileById.mockResolvedValue({ id: 'profile-1', user_id: 'user-123', platform: 'Twitter' });
+ userModel.deleteWebProfile.mockResolvedValue({ id: 'profile-1' });
+ const result = await usersService.deleteWebProfile('user-123', 'profile-1');
+ expect(result).toEqual({ id: 'profile-1' });
+ expect(userModel.deleteWebProfile).toHaveBeenCalledWith('profile-1');
+ });
+ });
+
+ // ========================================
+ // updatePrivacy
+ // ========================================
+ describe('updatePrivacy', () => {
it('should throw 404 if user not found', async () => {
userModel.findById.mockResolvedValue(null);
+ await expect(usersService.updatePrivacy('user-999', true)).rejects.toMatchObject({
+ statusCode: 404,
+ });
+ });
- await expect(usersService.deleteMyAvatar('user-999')).rejects.toThrow('User not found');
+ it('should throw 400 if already private', async () => {
+ userModel.findById.mockResolvedValue({ ...fixtures.mockUser, is_private: true });
+ await expect(usersService.updatePrivacy('user-123', true)).rejects.toMatchObject({
+ statusCode: 400,
+ code: 'VALIDATION_FAILED',
+ });
+ });
- expect(userModel.deleteAvatar).not.toHaveBeenCalled();
+ it('should throw 400 if already public', async () => {
+ userModel.findById.mockResolvedValue({ ...fixtures.mockUser, is_private: false });
+ await expect(usersService.updatePrivacy('user-123', false)).rejects.toMatchObject({
+ statusCode: 400,
+ });
});
- it('should throw 404 if user has no avatar', async () => {
- userModel.findById.mockResolvedValue({ ...fixtures.mockUser, profile_picture: null });
+ it('should update privacy to private', async () => {
+ userModel.findById.mockResolvedValue({ ...fixtures.mockUser, is_private: false });
+ userModel.updatePrivacy.mockResolvedValue({ is_private: true });
+ const result = await usersService.updatePrivacy('user-123', true);
+ expect(result.is_private).toBe(true);
+ });
+ });
- await expect(usersService.deleteMyAvatar('user-123')).rejects.toThrow('No avatar to delete');
+ // ========================================
+ // getMyGenres
+ // ========================================
+ describe('getMyGenres', () => {
+ it('should return genres for user', async () => {
+ const mockGenres = [{ id: 'genre-1', name: 'Rock' }];
+ userModel.findGenresByUserId.mockResolvedValue(mockGenres);
+ const result = await usersService.getMyGenres('user-123');
+ expect(result).toEqual(mockGenres);
+ expect(userModel.findGenresByUserId).toHaveBeenCalledWith('user-123');
+ });
- expect(userModel.deleteAvatar).not.toHaveBeenCalled();
+ it('should return empty array if no genres', async () => {
+ userModel.findGenresByUserId.mockResolvedValue([]);
+ const result = await usersService.getMyGenres('user-123');
+ expect(result).toEqual([]);
});
+ });
- it('should delete avatar if exists', async () => {
+ // ========================================
+ // replaceMyGenres
+ // ========================================
+ describe('replaceMyGenres', () => {
+ it('should throw 404 if user not found', async () => {
+ userModel.findById.mockResolvedValue(null);
+ await expect(usersService.replaceMyGenres('user-999', ['genre-1'])).rejects.toMatchObject({
+ statusCode: 404,
+ });
+ });
+
+ it('should replace genres successfully', async () => {
+ const updatedGenres = [{ id: 'genre-1', name: 'Rock' }];
+ userModel.findById.mockResolvedValue(fixtures.mockUser);
+ userModel.replaceGenres.mockResolvedValue(updatedGenres);
+ const result = await usersService.replaceMyGenres('user-123', ['genre-1']);
+ expect(result).toEqual(updatedGenres);
+ expect(userModel.replaceGenres).toHaveBeenCalledWith('user-123', ['genre-1']);
+ });
+ });
+
+ // ========================================
+ // completeOnboarding
+ // ========================================
+ describe('completeOnboarding', () => {
+ it('should throw 404 if user not found', async () => {
+ userModel.findById.mockResolvedValue(null);
+ await expect(
+ usersService.completeOnboarding('user-999', { display_name: 'John' })
+ ).rejects.toMatchObject({ statusCode: 404 });
+ });
+
+ it('should throw 409 if onboarding already completed', async () => {
userModel.findById.mockResolvedValue({
...fixtures.mockUser,
- profile_picture: 'https://cdn.example.com/avatar.jpg',
+ display_name: 'John',
+ gender: 'male',
+ date_of_birth: '1990-01-01',
});
- userModel.deleteAvatar.mockResolvedValue({ profile_picture: null });
+ await expect(
+ usersService.completeOnboarding('user-123', { display_name: 'John' })
+ ).rejects.toMatchObject({ statusCode: 409, code: 'ONBOARDING_ALREADY_COMPLETED' });
+ });
- const result = await usersService.deleteMyAvatar('user-123');
+ it('should complete onboarding if not done yet', async () => {
+ userModel.findById.mockResolvedValue({
+ ...fixtures.mockUser,
+ display_name: null,
+ gender: null,
+ date_of_birth: null,
+ });
+ userModel.completeOnboarding.mockResolvedValue(fixtures.mockUser);
+ const result = await usersService.completeOnboarding('user-123', {
+ display_name: 'John',
+ gender: 'male',
+ date_of_birth: '1990-01-01',
+ });
+ expect(result).toEqual(fixtures.mockUser);
+ });
+ });
- expect(result).toEqual({ profile_picture: null });
- expect(userModel.deleteAvatar).toHaveBeenCalledWith('user-123');
+ // ========================================
+ // getMyContentSettings
+ // ========================================
+ describe('getMyContentSettings', () => {
+ it('should return content settings', async () => {
+ const mockSettings = { rss_title: 'My Podcast', rss_language: 'en' };
+ userModel.findContentSettingsByUserId.mockResolvedValue(mockSettings);
+ const result = await usersService.getMyContentSettings('user-123');
+ expect(result).toEqual(mockSettings);
+ expect(userModel.findContentSettingsByUserId).toHaveBeenCalledWith('user-123');
});
});
// ========================================
- // updateMyCoverPhoto tests
+ // updateMyContentSettings
// ========================================
- describe('updateMyCoverPhoto', () => {
+ describe('updateMyContentSettings', () => {
it('should throw 404 if user not found', async () => {
userModel.findById.mockResolvedValue(null);
-
await expect(
- usersService.uploadMyCoverPhoto('user-999', { filename: 'cover.jpg' })
- ).rejects.toThrow('User not found');
+ usersService.updateMyContentSettings('user-999', { rss_title: 'Test' })
+ ).rejects.toMatchObject({ statusCode: 404 });
+ });
- expect(userModel.updateCoverPhoto).not.toHaveBeenCalled();
+ it('should throw 400 if nothing to update', async () => {
+ userModel.findById.mockResolvedValue(fixtures.mockUser);
+ userModel.updateContentSettings.mockResolvedValue(null);
+ await expect(
+ usersService.updateMyContentSettings('user-123', {})
+ ).rejects.toMatchObject({ statusCode: 400 });
});
- it('should update cover photo with CDN URL', async () => {
+ it('should update content settings successfully', async () => {
+ const updatedSettings = { rss_title: 'New Title' };
userModel.findById.mockResolvedValue(fixtures.mockUser);
- userModel.updateCoverPhoto.mockResolvedValue({
- ...fixtures.mockUser,
- cover_photo: 'https://cdn.rythmify.com/covers/user-123.jpg',
- });
+ userModel.updateContentSettings.mockResolvedValue(updatedSettings);
+ const result = await usersService.updateMyContentSettings('user-123', { rss_title: 'New Title' });
+ expect(result).toEqual(updatedSettings);
+ });
+ });
- const result = await usersService.uploadMyCoverPhoto('user-123', { filename: 'cover.jpg' });
+ // ========================================
+ // getMyPrivacySettings
+ // ========================================
+ describe('getMyPrivacySettings', () => {
+ it('should return privacy settings', async () => {
+ const mockSettings = { receive_messages_from_anyone: true };
+ userModel.findPrivacySettingsByUserId.mockResolvedValue(mockSettings);
+ const result = await usersService.getMyPrivacySettings('user-123');
+ expect(result).toEqual(mockSettings);
+ expect(userModel.findPrivacySettingsByUserId).toHaveBeenCalledWith('user-123');
+ });
+ });
- expect(userModel.updateCoverPhoto).toHaveBeenCalledWith('user-123', 'https://cdn.rythmify.com/covers/user-123.jpg');
- expect(result.cover_photo).toBe('https://cdn.rythmify.com/covers/user-123.jpg');
+ // ========================================
+ // updateMyPrivacySettings
+ // ========================================
+ describe('updateMyPrivacySettings', () => {
+ it('should throw 404 if user not found', async () => {
+ userModel.findById.mockResolvedValue(null);
+ await expect(
+ usersService.updateMyPrivacySettings('user-999', { receive_messages_from_anyone: true })
+ ).rejects.toMatchObject({ statusCode: 404 });
+ });
+
+ it('should throw 400 if nothing to update', async () => {
+ userModel.findById.mockResolvedValue(fixtures.mockUser);
+ userModel.updatePrivacySettings.mockResolvedValue(null);
+ await expect(
+ usersService.updateMyPrivacySettings('user-123', {})
+ ).rejects.toMatchObject({ statusCode: 400 });
+ });
+
+ it('should update privacy settings successfully', async () => {
+ const updatedSettings = { receive_messages_from_anyone: false };
+ userModel.findById.mockResolvedValue(fixtures.mockUser);
+ userModel.updatePrivacySettings.mockResolvedValue(updatedSettings);
+ const result = await usersService.updateMyPrivacySettings('user-123', {
+ receive_messages_from_anyone: false,
+ });
+ expect(result).toEqual(updatedSettings);
});
});
-});
+});
\ No newline at end of file
From e0ab6da43d6df58dbec088fb233d5ecbbb0670c6 Mon Sep 17 00:00:00 2001
From: Omar Hamza
Date: Sun, 22 Mar 2026 16:54:52 +0200
Subject: [PATCH 093/535] fix(users): use citext-aware email query and add
username index for performance
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Remove LOWER() wrapping on citext email column in findByEmail and
findByEmailOrUsername — direct equality uses the index correctly
- Add functional index on LOWER(username) via migration for fast
case-insensitive username login lookups"
---
...20260321000001-add-username-lower-index.js | 28 ++++++++++++++
src/models/user.model.js | 37 ++++++++++++-------
2 files changed, 51 insertions(+), 14 deletions(-)
create mode 100644 migrations/20260321000001-add-username-lower-index.js
diff --git a/migrations/20260321000001-add-username-lower-index.js b/migrations/20260321000001-add-username-lower-index.js
new file mode 100644
index 0000000..510290d
--- /dev/null
+++ b/migrations/20260321000001-add-username-lower-index.js
@@ -0,0 +1,28 @@
+'use strict';
+
+let dbm;
+let type;
+let seed;
+
+exports.setup = function (options, seedLink) {
+ dbm = options.dbmigrate;
+ type = dbm.dataType;
+ seed = seedLink;
+};
+
+exports.up = function (db) {
+ return db.runSql(`
+ CREATE INDEX IF NOT EXISTS idx_users_lower_username
+ ON users (LOWER(username));
+ `);
+};
+
+exports.down = function (db) {
+ return db.runSql(`
+ DROP INDEX IF EXISTS idx_users_lower_username;
+ `);
+};
+
+exports._meta = {
+ version: 1,
+};
\ No newline at end of file
diff --git a/src/models/user.model.js b/src/models/user.model.js
index ae09965..1b663ab 100644
--- a/src/models/user.model.js
+++ b/src/models/user.model.js
@@ -8,7 +8,9 @@ const db = require('../config/db');
// find user by email used for login and register (to check if email already exists)
exports.findByEmail = async (email) => {
const { rows } = await db.query(
- `SELECT * FROM users WHERE LOWER(email) = LOWER($1) AND deleted_at IS NULL`,
+ // ✅ FIX: email is citext — case-insensitive comparison is handled at the type level.
+ // Using LOWER() wrapping defeats the index. Direct equality uses it correctly.
+ `SELECT * FROM users WHERE email = $1 AND deleted_at IS NULL`,
[email]
);
return rows[0] || null;
@@ -26,8 +28,10 @@ exports.findByUsername = async (username) => {
// find user by email OR username in one query
exports.findByEmailOrUsername = async (identifier) => {
const { rows } = await db.query(
+ // ✅ FIX: email is citext so direct equality works and uses the index.
+ // username uses LOWER() which hits the functional index added in migration.
`SELECT * FROM users
- WHERE (LOWER(email) = LOWER($1) OR LOWER(username) = LOWER($1))
+ WHERE (email = $1 OR LOWER(username) = LOWER($1))
AND deleted_at IS NULL`,
[identifier]
);
@@ -182,6 +186,7 @@ exports.updateRole = async (userId, newRole) => {
);
return rows[0] || null;
};
+
exports.deleteAvatar = async (userId) => {
const { rows } = await db.query(
`UPDATE users SET profile_picture = NULL, updated_at = now()
@@ -201,6 +206,7 @@ exports.updateAvatar = async (userId, imagePath) => {
);
return rows[0] || null;
};
+
exports.updateCoverPhoto = async (userId, imagePath) => {
const { rows } = await db.query(
`UPDATE users SET cover_photo = $1, updated_at = now()
@@ -222,11 +228,13 @@ exports.deleteCoverPhoto = async (userId) => {
};
exports.findWebProfilesByUserId = async (userId) => {
- const { rows } = await db.query(`SELECT id, platform, url FROM web_profiles WHERE user_id = $1`, [
- userId,
- ]);
+ const { rows } = await db.query(
+ `SELECT id, platform, url FROM web_profiles WHERE user_id = $1`,
+ [userId]
+ );
return rows || [];
};
+
exports.findWebProfileByPlatform = async (userId, platform) => {
const { rows } = await db.query(
`SELECT id FROM web_profiles WHERE user_id = $1 AND platform = $2`,
@@ -246,9 +254,10 @@ exports.createWebProfile = async (userId, platform, url) => {
};
exports.deleteWebProfile = async (profileId) => {
- const { rows } = await db.query(`DELETE FROM web_profiles WHERE id = $1 RETURNING id`, [
- profileId,
- ]);
+ const { rows } = await db.query(
+ `DELETE FROM web_profiles WHERE id = $1 RETURNING id`,
+ [profileId]
+ );
return rows[0] || null;
};
@@ -315,6 +324,7 @@ exports.updateContentSettings = async (userId, settings) => {
);
return rows[0] || null;
};
+
exports.findPrivacySettingsByUserId = async (userId) => {
const { rows } = await db.query(
`SELECT receive_messages_from_anyone, show_activities_in_discovery,
@@ -374,10 +384,10 @@ exports.replaceGenres = async (userId, genreIds) => {
await client.query('BEGIN');
await client.query(`DELETE FROM user_favorite_genres WHERE user_id = $1`, [userId]);
for (const genreId of genreIds) {
- await client.query(`INSERT INTO user_favorite_genres (user_id, genre_id) VALUES ($1, $2)`, [
- userId,
- genreId,
- ]);
+ await client.query(
+ `INSERT INTO user_favorite_genres (user_id, genre_id) VALUES ($1, $2)`,
+ [userId, genreId]
+ );
}
await client.query('COMMIT');
return await exports.findGenresByUserId(userId);
@@ -416,7 +426,6 @@ exports.completeOnboarding = async (userId, fields) => {
return rows[0] || null;
};
-
exports.createOAuthUser = async ({ email, display_name }) => {
const { rows } = await db.query(
`INSERT INTO users (email, display_name, is_verified)
@@ -444,5 +453,5 @@ exports.applyPendingEmail = async (userId) => {
RETURNING email`,
[userId]
);
- return rows[0];
+ return rows[0];
};
\ No newline at end of file
From 6246f19e576bfd6c8f82ce245d4fbfc92822bc12 Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Mon, 23 Mar 2026 12:48:49 +0200
Subject: [PATCH 094/535] refactor(tracks): accept tag names in upload and
update requests feat(tags): add get all tags endpoint feat(genres): add get
all genres endpoint docs(api): update track tags and genres documentation
---
app.js | 5 +
openapi.yaml | 3166 +++++++++++++-------------
src/controllers/genres.controller.js | 15 +
src/controllers/tags.controller.js | 15 +
src/models/genre.model.js | 22 +
src/models/tag.model.js | 80 +-
src/routes/genres.routes.js | 9 +
src/routes/tags.routes.js | 9 +
src/services/genres.service.js | 16 +
src/services/tags.service.js | 16 +
src/services/tracks.service.js | 197 +-
11 files changed, 1948 insertions(+), 1602 deletions(-)
create mode 100644 src/controllers/genres.controller.js
create mode 100644 src/controllers/tags.controller.js
create mode 100644 src/models/genre.model.js
create mode 100644 src/routes/genres.routes.js
create mode 100644 src/routes/tags.routes.js
create mode 100644 src/services/genres.service.js
create mode 100644 src/services/tags.service.js
diff --git a/app.js b/app.js
index 88c8dee..24a886c 100644
--- a/app.js
+++ b/app.js
@@ -22,6 +22,8 @@ const messagesRoutes = require('./src/routes/messages.routes');
const notificationsRoutes = require('./src/routes/notifications.routes');
const adminRoutes = require('./src/routes/admin.routes');
const subscriptionsRoutes = require('./src/routes/subscriptions.routes');
+const tagsRoutes = require('./src/routes/tags.routes');
+const genresRoutes = require('./src/routes/genres.routes');
const { initBlobContainers } = require('./src/services/storage.service');
const app = express();
@@ -66,6 +68,8 @@ const API = '/api/v1';
app.use(`${API}/auth`, authRoutes); // Module 1 — BE-1 Omar Hamdy
app.use(`${API}/users`, usersRoutes); // Module 2 — BE-1 Omar Hamdy
app.use(`${API}/users`, followersRoutes); // Module 3 — BE-3 Beshoy Maher
+app.use(`${API}/tags`, tagsRoutes); // Module 4
+app.use(`${API}/genres`, genresRoutes); // Module 4
app.use(`${API}/tracks`, tracksRoutes); // Module 4 — BE-2 Saja
app.use(`${API}/me`, playbackRoutes); // Module 5 — BE-2 Saja
app.use(`${API}`, engagementRoutes); // Module 6 — BE-3 Beshoy Maher
@@ -76,6 +80,7 @@ app.use(`${API}/notifications`, notificationsRoutes); // Module 10 — BE-4 Alya
app.use(`${API}`, adminRoutes); // Module 11 — BE-5 Omar Hamza
app.use(`${API}/subscriptions`, subscriptionsRoutes); // Module 12 — BE-1 Omar Hamdy
+
// ── Centralised error handler (must be last) ───────────────
app.use(errorHandler);
diff --git a/openapi.yaml b/openapi.yaml
index eccc4de..62364d8 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -127,32 +127,32 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AuthRegisterRequest"
+ $ref: '#/components/schemas/AuthRegisterRequest'
responses:
- "201":
+ '201':
description: Account created and verification email sent.
content:
application/json:
schema:
- $ref: "#/components/schemas/AuthRegisterResponse"
+ $ref: '#/components/schemas/AuthRegisterResponse'
- "400":
- $ref: "#/components/responses/ValidationError"
+ '400':
+ $ref: '#/components/responses/ValidationError'
- "409":
+ '409':
description: Email already registered.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: AUTH_EMAIL_ALREADY_EXISTS
message: Email already registered.
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
/auth/verify-email:
post:
@@ -172,29 +172,29 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AuthVerifyEmailRequest"
+ $ref: '#/components/schemas/AuthVerifyEmailRequest'
responses:
- "200":
+ '200':
description: Email verified successfully (or already verified). Token(s) issued.
content:
application/json:
schema:
- $ref: "#/components/schemas/AuthVerifyEmailResponse"
+ $ref: '#/components/schemas/AuthVerifyEmailResponse'
- "400":
+ '400':
description: Invalid or expired verification token.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: AUTH_TOKEN_INVALID
message: Invalid or expired verification token
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
/auth/resend-verification:
post:
@@ -213,28 +213,28 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AuthResendVerificationRequest"
+ $ref: '#/components/schemas/AuthResendVerificationRequest'
responses:
- "200":
+ '200':
description: Verification email resent (or no-op if not applicable).
content:
application/json:
schema:
- $ref: "#/components/schemas/SuccessMessage"
+ $ref: '#/components/schemas/SuccessMessage'
example:
data:
success: true
message: If the email exists and is not verified, a verification link has been sent.
- "400":
- $ref: "#/components/responses/ValidationError"
+ '400':
+ $ref: '#/components/responses/ValidationError'
- "422":
- $ref: "#/components/responses/UnprocessableEntity"
+ '422':
+ $ref: '#/components/responses/UnprocessableEntity'
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
/auth/login:
post:
@@ -257,10 +257,10 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AuthLoginRequest"
+ $ref: '#/components/schemas/AuthLoginRequest'
responses:
- "200":
+ '200':
description: Logged in successfully.
headers:
Set-Cookie:
@@ -272,46 +272,46 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AuthLoginResponse"
+ $ref: '#/components/schemas/AuthLoginResponse'
- "400":
- $ref: "#/components/responses/ValidationError"
+ '400':
+ $ref: '#/components/responses/ValidationError'
- "401":
+ '401':
description: Invalid credentials.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: AUTH_INVALID_CREDENTIALS
message: Email/username or password incorrect
- "422":
+ '422':
description: Login blocked by business rules (e.g. email not verified).
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: AUTH_EMAIL_NOT_VERIFIED
message: Email must be verified before login
- "403":
+ '403':
description: Account suspended (admin action).
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: AUTH_ACCOUNT_SUSPENDED
message: Account suspended by admin
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
/auth/refresh:
post:
@@ -336,7 +336,7 @@ paths:
type: string
responses:
- "200":
+ '200':
description: New access token issued successfully.
headers:
Set-Cookie:
@@ -348,32 +348,32 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AuthRefreshResponse"
+ $ref: '#/components/schemas/AuthRefreshResponse'
- "401":
+ '401':
description: Missing/invalid/expired refresh token cookie.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: AUTH_REFRESH_TOKEN_INVALID
message: Refresh token is missing, invalid, or expired
- "403":
+ '403':
description: Account suspended (admin action).
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: AUTH_ACCOUNT_SUSPENDED
message: Account suspended by admin
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
/auth/logout:
post:
@@ -394,7 +394,7 @@ paths:
type: string
responses:
- "200":
+ '200':
description: Logged out successfully (refresh cookie cleared).
headers:
Set-Cookie:
@@ -406,14 +406,14 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/SuccessMessage"
+ $ref: '#/components/schemas/SuccessMessage'
example:
data:
success: true
message: Logged out successfully.
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
/auth/forgot-password:
post:
@@ -436,21 +436,21 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AuthForgotPasswordRequest"
+ $ref: '#/components/schemas/AuthForgotPasswordRequest'
responses:
- "200":
+ '200':
description: Password reset email request processed.
content:
application/json:
schema:
- $ref: "#/components/schemas/AuthForgotPasswordResponse"
+ $ref: '#/components/schemas/AuthForgotPasswordResponse'
- "400":
- $ref: "#/components/responses/ValidationError"
+ '400':
+ $ref: '#/components/responses/ValidationError'
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
/auth/reset-password:
post:
@@ -478,37 +478,37 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AuthResetPasswordRequest"
+ $ref: '#/components/schemas/AuthResetPasswordRequest'
responses:
- "200":
+ '200':
description: Password reset successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/SuccessMessage"
+ $ref: '#/components/schemas/SuccessMessage'
example:
data:
success: true
message: Password reset successfully.
- "400":
+ '400':
description: Validation error, including password confirmation mismatch.
- $ref: "#/components/responses/ValidationError"
+ $ref: '#/components/responses/ValidationError'
- "422":
+ '422':
description: Token invalid/expired/used/revoked (business rule violation).
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: AUTH_TOKEN_EXPIRED
message: Password reset token expired
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
/auth/change-email:
post:
@@ -527,39 +527,39 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AuthChangeEmailRequest"
+ $ref: '#/components/schemas/AuthChangeEmailRequest'
responses:
- "200":
+ '200':
description: Email change request processed.
content:
application/json:
schema:
- $ref: "#/components/schemas/SuccessMessage"
+ $ref: '#/components/schemas/SuccessMessage'
example:
data:
success: true
message: Verification email sent to the new address.
- "400":
- $ref: "#/components/responses/ValidationError"
+ '400':
+ $ref: '#/components/responses/ValidationError'
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "409":
+ '409':
description: Email already in use.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: AUTH_EMAIL_ALREADY_EXISTS
message: Email already registered.
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
/auth/verify-email-change:
post:
@@ -581,43 +581,43 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AuthVerifyEmailChangeRequest"
+ $ref: '#/components/schemas/AuthVerifyEmailChangeRequest'
responses:
- "200":
+ '200':
description: Email updated successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/AuthVerifyEmailChangeResponse"
+ $ref: '#/components/schemas/AuthVerifyEmailChangeResponse'
- "400":
- $ref: "#/components/responses/ValidationError"
+ '400':
+ $ref: '#/components/responses/ValidationError'
- "409":
+ '409':
description: Email already in use.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: AUTH_EMAIL_ALREADY_EXISTS
message: Email already registered.
- "422":
+ '422':
description: Token invalid/expired/used/revoked (business rule violation).
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: AUTH_TOKEN_EXPIRED
message: Email change token expired
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
/auth/google:
post:
@@ -654,10 +654,10 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AuthGoogleLoginRequest"
+ $ref: '#/components/schemas/AuthGoogleLoginRequest'
responses:
- "200":
+ '200':
description: Logged in successfully with Google.
headers:
Set-Cookie:
@@ -667,35 +667,35 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AuthGoogleLoginResponse"
+ $ref: '#/components/schemas/AuthGoogleLoginResponse'
- "400":
- $ref: "#/components/responses/ValidationError"
+ '400':
+ $ref: '#/components/responses/ValidationError'
- "401":
+ '401':
description: Google token invalid.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: AUTH_TOKEN_INVALID
message: Google token invalid
- "403":
+ '403':
description: Account suspended (admin action).
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: AUTH_ACCOUNT_SUSPENDED
message: Account suspended by admin
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
/auth/oauth/soundcloud:
get:
@@ -725,7 +725,7 @@ paths:
example: https://rythmify.com/settings/connections
responses:
- "302":
+ '302':
description: Redirect to SoundCloud consent screen.
/auth/oauth/soundcloud/callback:
@@ -766,7 +766,7 @@ paths:
type: string
responses:
- "200":
+ '200':
description: OAuth completed successfully. Rythmify tokens issued.
headers:
Set-Cookie:
@@ -776,32 +776,32 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AuthLoginResponse"
+ $ref: '#/components/schemas/AuthLoginResponse'
- "400":
+ '400':
description: Missing/invalid callback parameters or state mismatch.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: VALIDATION_FAILED
message: Missing or invalid OAuth callback parameters
- "422":
+ '422':
description: OAuth exchange failed or authorization denied.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: BUSINESS_OPERATION_NOT_ALLOWED
message: OAuth authorization was denied or token exchange failed
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
/auth/connections/{provider}:
delete:
@@ -829,40 +829,40 @@ paths:
enum: [google, facebook, apple]
example: google
responses:
- "200":
+ '200':
description: Provider disconnected successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/SuccessMessage"
+ $ref: '#/components/schemas/SuccessMessage'
example:
data:
success: true
message: Google account disconnected.
- "401":
- $ref: "#/components/responses/Unauthorized"
- "404":
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
description: No connection found for this provider on this account.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: RESOURCE_NOT_FOUND
message: No google connection found for this account.
- "422":
+ '422':
description: Cannot disconnect — this is the only login method and no password is set.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: BUSINESS_OPERATION_NOT_ALLOWED
message: Cannot disconnect the only login method. Set a password first.
- "500":
- $ref: "#/components/responses/InternalError"
+ '500':
+ $ref: '#/components/responses/InternalError'
# ==========================================================
# Module 2 — User Profile & Social Identity
@@ -890,26 +890,26 @@ paths:
type: string
format: uuid
responses:
- "200":
+ '200':
description: User profile returned successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/PublicUserResponse"
- "403":
+ $ref: '#/components/schemas/PublicUserResponse'
+ '403':
description: Profile is private — requester is not the owner or a follower.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: RESOURCE_PRIVATE
message: This profile is private.
- "404":
- $ref: "#/components/responses/NotFound"
- "500":
- $ref: "#/components/responses/InternalError"
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '500':
+ $ref: '#/components/responses/InternalError'
/users/me:
get:
@@ -925,16 +925,16 @@ paths:
security:
- bearerAuth: []
responses:
- "200":
+ '200':
description: Own profile returned successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/OwnProfileFullResponse"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "500":
- $ref: "#/components/responses/InternalError"
+ $ref: '#/components/schemas/OwnProfileFullResponse'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ $ref: '#/components/responses/InternalError'
patch:
tags: [Users]
@@ -958,30 +958,30 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/UpdateProfileRequest"
+ $ref: '#/components/schemas/UpdateProfileRequest'
responses:
- "200":
+ '200':
description: Profile updated successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/OwnProfileFullResponse"
- "400":
- $ref: "#/components/responses/ValidationError"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "409":
+ $ref: '#/components/schemas/OwnProfileFullResponse'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '409':
description: Username already taken.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: RESOURCE_ALREADY_EXISTS
message: Username already taken.
- "500":
- $ref: "#/components/responses/InternalError"
+ '500':
+ $ref: '#/components/responses/InternalError'
/users/me/account:
patch:
@@ -1003,25 +1003,25 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/UpdateAccountRequest"
+ $ref: '#/components/schemas/UpdateAccountRequest'
responses:
- "200":
+ '200':
description: Account settings updated successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/OwnProfileFullResponse"
- "400":
- $ref: "#/components/responses/ValidationError"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "500":
- $ref: "#/components/responses/InternalError"
+ $ref: '#/components/schemas/OwnProfileFullResponse'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ $ref: '#/components/responses/InternalError'
/users/me/role:
patch:
tags: [Users]
- summary: "Switch account role: Artist ↔ Listener"
+ summary: 'Switch account role: Artist ↔ Listener'
description: |
Switches the authenticated user's role between `artist` and `listener`.
@@ -1043,20 +1043,20 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/SwitchRoleRequest"
+ $ref: '#/components/schemas/SwitchRoleRequest'
responses:
- "200":
+ '200':
description: Role updated. Returns the full updated user object.
content:
application/json:
schema:
- $ref: "#/components/schemas/OwnProfileResponse"
- "400":
+ $ref: '#/components/schemas/OwnProfileResponse'
+ '400':
description: Invalid or non-self-assignable role value provided.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: VALIDATION_FAILED
@@ -1064,20 +1064,20 @@ paths:
details:
- field: role
issue: Must be one of artist, listener
- "401":
- $ref: "#/components/responses/Unauthorized"
- "409":
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '409':
description: User already has the requested role.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: RESOURCE_ALREADY_EXISTS
message: User already has the artist role.
- "500":
- $ref: "#/components/responses/InternalError"
+ '500':
+ $ref: '#/components/responses/InternalError'
/users/me/avatar:
post:
@@ -1099,40 +1099,40 @@ paths:
content:
multipart/form-data:
schema:
- $ref: "#/components/schemas/AvatarUploadRequest"
+ $ref: '#/components/schemas/AvatarUploadRequest'
responses:
- "200":
+ '200':
description: Avatar uploaded successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/AvatarUploadResponse"
- "400":
- $ref: "#/components/responses/ValidationError"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "413":
+ $ref: '#/components/schemas/AvatarUploadResponse'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '413':
description: File exceeds the 5 MB size limit.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: UPLOAD_FILE_TOO_LARGE
message: Image file must not exceed 5 MB.
- "415":
+ '415':
description: Unsupported image format — file is not JPEG, PNG, or WebP.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: UPLOAD_INVALID_FILE_TYPE
message: Unsupported file format. Accepted formats are JPEG, PNG, and WebP.
- "500":
- $ref: "#/components/responses/InternalError"
+ '500':
+ $ref: '#/components/responses/InternalError'
delete:
tags: [Users]
@@ -1144,30 +1144,30 @@ paths:
security:
- bearerAuth: []
responses:
- "200":
+ '200':
description: Profile picture removed.
content:
application/json:
schema:
- $ref: "#/components/schemas/SuccessMessage"
+ $ref: '#/components/schemas/SuccessMessage'
example:
data:
success: true
message: Profile picture removed.
- "401":
- $ref: "#/components/responses/Unauthorized"
- "404":
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
description: No profile picture set — nothing to delete.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: RESOURCE_NOT_FOUND
message: No profile picture is set on this account.
- "500":
- $ref: "#/components/responses/InternalError"
+ '500':
+ $ref: '#/components/responses/InternalError'
/users/me/cover:
post:
@@ -1189,40 +1189,40 @@ paths:
content:
multipart/form-data:
schema:
- $ref: "#/components/schemas/CoverUploadRequest"
+ $ref: '#/components/schemas/CoverUploadRequest'
responses:
- "200":
+ '200':
description: Cover photo uploaded successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/CoverUploadResponse"
- "400":
- $ref: "#/components/responses/ValidationError"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "413":
+ $ref: '#/components/schemas/CoverUploadResponse'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '413':
description: File exceeds the 10 MB size limit.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: UPLOAD_FILE_TOO_LARGE
message: Image file must not exceed 10 MB.
- "415":
+ '415':
description: Unsupported image format — file is not JPEG, PNG, or WebP.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: UPLOAD_INVALID_FILE_TYPE
message: Unsupported file format. Accepted formats are JPEG, PNG, and WebP.
- "500":
- $ref: "#/components/responses/InternalError"
+ '500':
+ $ref: '#/components/responses/InternalError'
delete:
tags: [Users]
@@ -1234,30 +1234,30 @@ paths:
security:
- bearerAuth: []
responses:
- "200":
+ '200':
description: Cover photo removed.
content:
application/json:
schema:
- $ref: "#/components/schemas/SuccessMessage"
+ $ref: '#/components/schemas/SuccessMessage'
example:
data:
success: true
message: Cover photo removed.
- "401":
- $ref: "#/components/responses/Unauthorized"
- "404":
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
description: No cover photo set — nothing to delete.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: RESOURCE_NOT_FOUND
message: No cover photo is set on this account.
- "500":
- $ref: "#/components/responses/InternalError"
+ '500':
+ $ref: '#/components/responses/InternalError'
/users/me/web-profiles:
get:
@@ -1270,16 +1270,16 @@ paths:
security:
- bearerAuth: []
responses:
- "200":
+ '200':
description: Array of web profile objects returned.
content:
application/json:
schema:
- $ref: "#/components/schemas/WebProfileListResponse"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "500":
- $ref: "#/components/responses/InternalError"
+ $ref: '#/components/schemas/WebProfileListResponse'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ $ref: '#/components/responses/InternalError'
post:
tags: [Users]
@@ -1297,30 +1297,30 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AddWebProfileRequest"
+ $ref: '#/components/schemas/AddWebProfileRequest'
responses:
- "201":
+ '201':
description: Web profile link created.
content:
application/json:
schema:
- $ref: "#/components/schemas/WebProfileCreatedResponse"
- "400":
- $ref: "#/components/responses/ValidationError"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "409":
+ $ref: '#/components/schemas/WebProfileCreatedResponse'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '409':
description: A profile for this platform already exists for this user.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: RESOURCE_ALREADY_EXISTS
message: A profile for the instagram platform already exists.
- "500":
- $ref: "#/components/responses/InternalError"
+ '500':
+ $ref: '#/components/responses/InternalError'
/users/me/web-profiles/{profile_id}:
delete:
@@ -1341,24 +1341,24 @@ paths:
type: string
format: uuid
responses:
- "200":
+ '200':
description: Web profile link removed.
content:
application/json:
schema:
- $ref: "#/components/schemas/SuccessMessage"
+ $ref: '#/components/schemas/SuccessMessage'
example:
data:
success: true
message: Web profile removed.
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
- $ref: "#/components/responses/Forbidden"
- "404":
- $ref: "#/components/responses/NotFound"
- "500":
- $ref: "#/components/responses/InternalError"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '500':
+ $ref: '#/components/responses/InternalError'
/users/me/privacy:
patch:
@@ -1379,20 +1379,20 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/UpdatePrivacyRequest"
+ $ref: '#/components/schemas/UpdatePrivacyRequest'
responses:
- "200":
+ '200':
description: Privacy setting updated.
content:
application/json:
schema:
- $ref: "#/components/schemas/UpdatePrivacyResponse"
- "400":
+ $ref: '#/components/schemas/UpdatePrivacyResponse'
+ '400':
description: Missing or invalid `is_private` value.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: VALIDATION_FAILED
@@ -1400,10 +1400,10 @@ paths:
details:
- field: is_private
issue: Must be a boolean value
- "401":
- $ref: "#/components/responses/Unauthorized"
- "500":
- $ref: "#/components/responses/InternalError"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ $ref: '#/components/responses/InternalError'
/users/me/content-settings:
get:
@@ -1416,16 +1416,16 @@ paths:
security:
- bearerAuth: []
responses:
- "200":
+ '200':
description: Content settings returned.
content:
application/json:
schema:
- $ref: "#/components/schemas/ContentSettingsResponse"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "500":
- $ref: "#/components/responses/InternalError"
+ $ref: '#/components/schemas/ContentSettingsResponse'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ $ref: '#/components/responses/InternalError'
patch:
tags: [Users]
@@ -1445,20 +1445,20 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/UpdateContentSettingsRequest"
+ $ref: '#/components/schemas/UpdateContentSettingsRequest'
responses:
- "200":
+ '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"
+ $ref: '#/components/schemas/ContentSettingsResponse'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ $ref: '#/components/responses/InternalError'
/users/me/privacy-settings:
get:
@@ -1471,16 +1471,16 @@ paths:
security:
- bearerAuth: []
responses:
- "200":
+ '200':
description: Privacy settings returned.
content:
application/json:
schema:
- $ref: "#/components/schemas/PrivacySettingsResponse"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "500":
- $ref: "#/components/responses/InternalError"
+ $ref: '#/components/schemas/PrivacySettingsResponse'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ $ref: '#/components/responses/InternalError'
patch:
tags: [Users]
@@ -1496,20 +1496,20 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/UpdatePrivacySettingsRequest"
+ $ref: '#/components/schemas/UpdatePrivacySettingsRequest'
responses:
- "200":
+ '200':
description: Privacy settings updated.
content:
application/json:
schema:
- $ref: "#/components/schemas/PrivacySettingsResponse"
- "400":
- $ref: "#/components/responses/ValidationError"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "500":
- $ref: "#/components/responses/InternalError"
+ $ref: '#/components/schemas/PrivacySettingsResponse'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ $ref: '#/components/responses/InternalError'
/users/me/genres:
get:
@@ -1522,16 +1522,16 @@ paths:
security:
- bearerAuth: []
responses:
- "200":
+ '200':
description: Favorite genres returned.
content:
application/json:
schema:
- $ref: "#/components/schemas/GenresResponse"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "500":
- $ref: "#/components/responses/InternalError"
+ $ref: '#/components/schemas/GenresResponse'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ $ref: '#/components/responses/InternalError'
put:
tags: [Users]
@@ -1552,20 +1552,20 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/ReplaceGenresRequest"
+ $ref: '#/components/schemas/ReplaceGenresRequest'
responses:
- "200":
+ '200':
description: Genres updated. Returns the saved list.
content:
application/json:
schema:
- $ref: "#/components/schemas/GenresResponse"
- "400":
+ $ref: '#/components/schemas/GenresResponse'
+ '400':
description: Array exceeds 10 items or contains unrecognized genre strings.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: VALIDATION_FAILED
@@ -1573,10 +1573,10 @@ paths:
details:
- field: genres
issue: Maximum of 10 genres allowed
- "401":
- $ref: "#/components/responses/Unauthorized"
- "500":
- $ref: "#/components/responses/InternalError"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ $ref: '#/components/responses/InternalError'
/users/me/onboarding:
patch:
@@ -1606,22 +1606,22 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/CompleteGoogleOnboardingRequest"
+ $ref: '#/components/schemas/CompleteGoogleOnboardingRequest'
responses:
- "200":
+ '200':
description: Profile onboarding completed successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/CompleteGoogleOnboardingResponse"
+ $ref: '#/components/schemas/CompleteGoogleOnboardingResponse'
- "400":
+ '400':
description: Invalid onboarding field value provided.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: VALIDATION_FAILED
@@ -1630,22 +1630,22 @@ paths:
- field: date_of_birth
issue: Must be a valid past date
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "409":
+ '409':
description: Onboarding already completed.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: ONBOARDING_ALREADY_COMPLETED
message: Profile onboarding has already been completed
- "500":
- $ref: "#/components/responses/InternalError"
+ '500':
+ $ref: '#/components/responses/InternalError'
# ==========================================================
# Module 4 — Audio Upload & Track Management
@@ -1661,18 +1661,43 @@ paths:
Notes:
- Public endpoint — no authentication required.
- Tags are returned in alphabetical order.
- - Use the tag IDs from this list when uploading or updating tracks.
+ - Clients should use the tag `name` values when uploading or updating tracks.
+ - `id` is returned for reference, but track write endpoints accept tag names.
operationId: listTags
security: []
responses:
- "200":
+ '200':
description: Tags fetched successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/TagListResponse"
- "500":
- $ref: "#/components/responses/InternalError"
+ $ref: '#/components/schemas/TagListResponse'
+ '500':
+ $ref: '#/components/responses/InternalError'
+
+ /genres:
+ get:
+ tags: [Tracks]
+ summary: Get all available platform genres
+ description: |
+ Returns the complete list of available genres that can be attached to tracks.
+
+ Notes:
+ - Public endpoint — no authentication required.
+ - Genres are returned in alphabetical order.
+ - Clients should use the genre `name` value when uploading or updating tracks.
+ - `id` is returned for reference.
+ operationId: listGenres
+ security: []
+ responses:
+ '200':
+ description: Genres fetched successfully.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/GenreListResponse'
+ '500':
+ $ref: '#/components/responses/InternalError'
/tracks:
post:
@@ -1695,29 +1720,29 @@ paths:
content:
multipart/form-data:
schema:
- $ref: "#/components/schemas/UploadTrackRequest"
+ $ref: '#/components/schemas/UploadTrackRequest'
responses:
- "201":
+ '201':
description: Track created and queued for processing.
content:
application/json:
schema:
- $ref: "#/components/schemas/UploadTrackResponse"
- "400":
- $ref: "#/components/responses/ValidationError"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
+ $ref: '#/components/schemas/UploadTrackResponse'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
# Upload limit reached or feature restricted (403).
- $ref: "#/components/responses/Forbidden"
- "413":
- $ref: "#/components/responses/FileTooLarge"
- "415":
- $ref: "#/components/responses/UnsupportedMediaType"
- "429":
- $ref: "#/components/responses/RateLimited"
- "500":
- $ref: "#/components/responses/InternalError"
+ $ref: '#/components/responses/Forbidden'
+ '413':
+ $ref: '#/components/responses/FileTooLarge'
+ '415':
+ $ref: '#/components/responses/UnsupportedMediaType'
+ '429':
+ $ref: '#/components/responses/RateLimited'
+ '500':
+ $ref: '#/components/responses/InternalError'
/tracks/me:
get:
@@ -1754,20 +1779,20 @@ paths:
in: query
description: Filter tracks by processing status.
schema:
- $ref: "#/components/schemas/TrackStatus"
+ $ref: '#/components/schemas/TrackStatus'
responses:
- "200":
+ '200':
description: Paginated list of the authenticated user's tracks.
content:
application/json:
schema:
- $ref: "#/components/schemas/TrackListResponse"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "429":
- $ref: "#/components/responses/RateLimited"
- "500":
- $ref: "#/components/responses/InternalError"
+ $ref: '#/components/schemas/TrackListResponse'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/RateLimited'
+ '500':
+ $ref: '#/components/responses/InternalError'
/tracks/{track_id}:
get:
@@ -1792,20 +1817,20 @@ paths:
type: string
format: uuid
responses:
- "200":
+ '200':
description: Track details returned successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/TrackResponse"
- "403":
- $ref: "#/components/responses/Forbidden" # Triggered when the track is private and the requester is not the owner (RESOURCE_PRIVATE)
- "404":
- $ref: "#/components/responses/NotFound"
- "429":
- $ref: "#/components/responses/RateLimited"
- "500":
- $ref: "#/components/responses/InternalError"
+ $ref: '#/components/schemas/TrackResponse'
+ '403':
+ $ref: '#/components/responses/Forbidden' # Triggered when the track is private and the requester is not the owner (RESOURCE_PRIVATE)
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '429':
+ $ref: '#/components/responses/RateLimited'
+ '500':
+ $ref: '#/components/responses/InternalError'
patch:
tags: [Tracks]
@@ -1832,26 +1857,26 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/UpdateTrackRequest"
+ $ref: '#/components/schemas/UpdateTrackRequest'
responses:
- "200":
+ '200':
description: Track metadata updated successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/TrackResponse"
- "400":
- $ref: "#/components/responses/ValidationError"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
- $ref: "#/components/responses/Forbidden"
- "404":
- $ref: "#/components/responses/NotFound"
- "429":
- $ref: "#/components/responses/RateLimited"
- "500":
- $ref: "#/components/responses/InternalError"
+ $ref: '#/components/schemas/TrackResponse'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '429':
+ $ref: '#/components/responses/RateLimited'
+ '500':
+ $ref: '#/components/responses/InternalError'
delete:
tags: [Tracks]
@@ -1875,18 +1900,18 @@ paths:
type: string
format: uuid
responses:
- "204":
+ '204':
description: Track deleted successfully. No response body.
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
- $ref: "#/components/responses/Forbidden"
- "404":
- $ref: "#/components/responses/NotFound"
- "429":
- $ref: "#/components/responses/RateLimited"
- "500":
- $ref: "#/components/responses/InternalError"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '429':
+ $ref: '#/components/responses/RateLimited'
+ '500':
+ $ref: '#/components/responses/InternalError'
/tracks/{track_id}/stream:
get:
@@ -1912,33 +1937,33 @@ paths:
type: string
format: uuid
responses:
- "200":
+ '200':
description: Streaming URL returned successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/TrackStreamResponse"
- "202":
+ $ref: '#/components/schemas/TrackStreamResponse'
+ '202':
description: Track is still being processed — retry later.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: BUSINESS_OPERATION_NOT_ALLOWED
message: Track is still processing. Please retry shortly.
- "403":
+ '403':
# Triggered when the requesting user is not the owner and the track is private
- $ref: "#/components/responses/Forbidden"
- "404":
- $ref: "#/components/responses/NotFound"
- "429":
- $ref: "#/components/responses/RateLimited"
- "503":
- $ref: "#/components/responses/TrackProcessingFailed"
- "500":
- $ref: "#/components/responses/InternalError"
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '429':
+ $ref: '#/components/responses/RateLimited'
+ '503':
+ $ref: '#/components/responses/TrackProcessingFailed'
+ '500':
+ $ref: '#/components/responses/InternalError'
/tracks/{track_id}/waveform:
get:
@@ -1961,33 +1986,33 @@ paths:
type: string
format: uuid
responses:
- "200":
+ '200':
description: Waveform peak data returned successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/TrackWaveformResponse"
- "202":
+ $ref: '#/components/schemas/TrackWaveformResponse'
+ '202':
description: Waveform not yet generated — track is still processing.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: BUSINESS_OPERATION_NOT_ALLOWED
message: Waveform is not yet available. Track is still processing.
- "403":
+ '403':
# Triggered when the requesting user is not the track owner and the track is private (RESOURCE_PRIVATE)
- $ref: "#/components/responses/Forbidden"
- "404":
- $ref: "#/components/responses/NotFound"
- "429":
- $ref: "#/components/responses/RateLimited"
- "500":
- $ref: "#/components/responses/InternalError"
- "503":
- $ref: "#/components/responses/TrackProcessingFailed"
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '429':
+ $ref: '#/components/responses/RateLimited'
+ '500':
+ $ref: '#/components/responses/InternalError'
+ '503':
+ $ref: '#/components/responses/TrackProcessingFailed'
/tracks/{track_id}/fan-leaderboard:
get:
@@ -2021,14 +2046,14 @@ paths:
enum: [top, first]
default: top
responses:
- "200":
+ '200':
description: Leaderboard returned successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/FanLeaderboardResponse"
- "404":
- $ref: "#/components/responses/NotFound"
+ $ref: '#/components/schemas/FanLeaderboardResponse'
+ '404':
+ $ref: '#/components/responses/NotFound'
/tracks/{track_id}/visibility:
patch:
@@ -2057,27 +2082,27 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/UpdateTrackVisibilityRequest"
+ $ref: '#/components/schemas/UpdateTrackVisibilityRequest'
responses:
- "200":
+ '200':
description: Track visibility updated successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/UpdateTrackVisibilityResponse"
- "400":
- $ref: "#/components/responses/ValidationError"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
+ $ref: '#/components/schemas/UpdateTrackVisibilityResponse'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
# Triggered when the requesting user is not the track owner (PERMISSION_NOT_OWNER)
- $ref: "#/components/responses/Forbidden"
- "404":
- $ref: "#/components/responses/NotFound"
- "429":
- $ref: "#/components/responses/RateLimited"
- "500":
- $ref: "#/components/responses/InternalError"
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '429':
+ $ref: '#/components/responses/RateLimited'
+ '500':
+ $ref: '#/components/responses/InternalError'
/users/{user_id}/tracks:
get:
@@ -2117,18 +2142,18 @@ paths:
minimum: 1
maximum: 100
responses:
- "200":
+ '200':
description: Paginated list of the user's public tracks.
content:
application/json:
schema:
- $ref: "#/components/schemas/TrackListResponse"
- "404":
- $ref: "#/components/responses/NotFound"
- "429":
- $ref: "#/components/responses/RateLimited"
- "500":
- $ref: "#/components/responses/InternalError"
+ $ref: '#/components/schemas/TrackListResponse'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '429':
+ $ref: '#/components/responses/RateLimited'
+ '500':
+ $ref: '#/components/responses/InternalError'
# ==========================================================
# Module 5 — Playback & Streaming Engine
@@ -2164,34 +2189,34 @@ paths:
type: string
format: uuid
responses:
- "200":
+ '200':
description: Playback access granted — stream or preview URL returned.
content:
application/json:
schema:
- $ref: "#/components/schemas/RecordPlayResponse"
- "202":
+ $ref: '#/components/schemas/RecordPlayResponse'
+ '202':
description: Track is still processing — retry later.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: BUSINESS_OPERATION_NOT_ALLOWED
message: Track is still processing. Please retry shortly.
- "403":
+ '403':
$ref:
- "#/components/responses/Forbidden"
+ '#/components/responses/Forbidden'
# Triggered when playback is blocked: account tier too low, track is private, geo-restricted, admin-hidden, or user is suspended
- "404":
- $ref: "#/components/responses/NotFound"
- "429":
- $ref: "#/components/responses/RateLimited"
- "503":
- $ref: "#/components/responses/TrackProcessingFailed"
- "500":
- $ref: "#/components/responses/InternalError"
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '429':
+ $ref: '#/components/responses/RateLimited'
+ '503':
+ $ref: '#/components/responses/TrackProcessingFailed'
+ '500':
+ $ref: '#/components/responses/InternalError'
/tracks/{track_id}/playback-state:
get:
@@ -2221,18 +2246,18 @@ paths:
type: string
format: uuid
responses:
- "200":
+ '200':
description: Playback access level returned successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/PlaybackStateResponse"
- "404":
- $ref: "#/components/responses/NotFound"
- "429":
- $ref: "#/components/responses/RateLimited"
- "500":
- $ref: "#/components/responses/InternalError"
+ $ref: '#/components/schemas/PlaybackStateResponse'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '429':
+ $ref: '#/components/responses/RateLimited'
+ '500':
+ $ref: '#/components/responses/InternalError'
/me/player/state:
get:
@@ -2250,18 +2275,18 @@ paths:
security:
- bearerAuth: []
responses:
- "200":
+ '200':
description: Player state returned successfully. `data` is `null` if no state saved yet.
content:
application/json:
schema:
- $ref: "#/components/schemas/PlayerStateResponse"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "429":
- $ref: "#/components/responses/RateLimited"
- "500":
- $ref: "#/components/responses/InternalError"
+ $ref: '#/components/schemas/PlayerStateResponse'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/RateLimited'
+ '500':
+ $ref: '#/components/responses/InternalError'
post:
tags: [Tracks]
@@ -2282,22 +2307,22 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/SavePlayerStateRequest"
+ $ref: '#/components/schemas/SavePlayerStateRequest'
responses:
- "200":
+ '200':
description: Player state saved successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/PlayerStateResponse"
- "400":
- $ref: "#/components/responses/ValidationError"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "429":
- $ref: "#/components/responses/RateLimited"
- "500":
- $ref: "#/components/responses/InternalError"
+ $ref: '#/components/schemas/PlayerStateResponse'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/RateLimited'
+ '500':
+ $ref: '#/components/responses/InternalError'
/me/history:
get:
@@ -2315,18 +2340,18 @@ paths:
security:
- bearerAuth: []
responses:
- "200":
+ '200':
description: Recently played tracks returned successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/RecentlyPlayedResponse"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "429":
- $ref: "#/components/responses/RateLimited"
- "500":
- $ref: "#/components/responses/InternalError"
+ $ref: '#/components/schemas/RecentlyPlayedResponse'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/RateLimited'
+ '500':
+ $ref: '#/components/responses/InternalError'
delete:
tags: [Tracks]
@@ -2343,14 +2368,14 @@ paths:
security:
- bearerAuth: []
responses:
- "204":
+ '204':
description: Listening history cleared successfully. No response body.
- "401":
- $ref: "#/components/responses/Unauthorized"
- "429":
- $ref: "#/components/responses/RateLimited"
- "500":
- $ref: "#/components/responses/InternalError"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/RateLimited'
+ '500':
+ $ref: '#/components/responses/InternalError'
/me/listening-history:
get:
@@ -2384,18 +2409,18 @@ paths:
minimum: 1
maximum: 100
responses:
- "200":
+ '200':
description: Paginated listening history returned successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/ListeningHistoryResponse"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "429":
- $ref: "#/components/responses/RateLimited"
- "500":
- $ref: "#/components/responses/InternalError"
+ $ref: '#/components/schemas/ListeningHistoryResponse'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/RateLimited'
+ '500':
+ $ref: '#/components/responses/InternalError'
# ==========================================================
# Module 7 — Sets & Playlists
# ==========================================================
@@ -2416,46 +2441,46 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/CreatePlaylistRequest"
+ $ref: '#/components/schemas/CreatePlaylistRequest'
responses:
- "201":
+ '201':
description: Playlist created successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/PlaylistResponse"
+ $ref: '#/components/schemas/PlaylistResponse'
example:
data:
playlist_id: 8d5a8f6c-7b4a-4c7a-9c25-9a9f1e3a12aa
owner_user_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
- name: "Gym Mix"
- description: "High energy tracks"
+ name: 'Gym Mix'
+ description: 'High energy tracks'
is_public: true
track_count: 0
like_count: 0
- created_at: "2026-03-06T10:00:00Z"
- message: "Playlist created successfully."
+ created_at: '2026-03-06T10:00:00Z'
+ message: 'Playlist created successfully.'
- "400":
- $ref: "#/components/responses/ValidationError"
+ '400':
+ $ref: '#/components/responses/ValidationError'
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "422":
+ '422':
description: Business rule violation (e.g., playlist limit reached).
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: BUSINESS_LIMIT_REACHED
message: Playlist limit reached for your current plan.
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
get:
tags: [Playlists]
@@ -2514,21 +2539,21 @@ paths:
default: 0
responses:
- "200":
+ '200':
description: Playlists returned successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/PlaylistListResponse"
+ $ref: '#/components/schemas/PlaylistListResponse'
- "400":
- $ref: "#/components/responses/ValidationError"
+ '400':
+ $ref: '#/components/responses/ValidationError'
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
/playlists/{playlist_id}:
get:
@@ -2572,40 +2597,40 @@ paths:
default: true
responses:
- "200":
+ '200':
description: Playlist returned successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/PlaylistGetResponse"
+ $ref: '#/components/schemas/PlaylistGetResponse'
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "403":
+ '403':
description: Not allowed to access this playlist (private and missing permission/token).
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: PLAYLIST_ACCESS_DENIED
message: You do not have access to this playlist.
- "404":
+ '404':
description: Playlist not found.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: PLAYLIST_NOT_FOUND
message: Playlist not found.
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
patch:
tags: [Playlists]
@@ -2636,68 +2661,68 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/UpdatePlaylistRequest"
+ $ref: '#/components/schemas/UpdatePlaylistRequest'
responses:
- "200":
+ '200':
description: Playlist updated successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/PlaylistResponse"
+ $ref: '#/components/schemas/PlaylistResponse'
example:
data:
playlist_id: 8d5a8f6c-7b4a-4c7a-9c25-9a9f1e3a12aa
owner_user_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
- name: "Updated Gym Mix"
- description: "Updated high energy tracks"
+ name: 'Updated Gym Mix'
+ description: 'Updated high energy tracks'
is_public: false
track_count: 12
like_count: 340
- created_at: "2026-03-06T10:00:00Z"
+ created_at: '2026-03-06T10:00:00Z'
message: Playlist updated successfully.
- "400":
- $ref: "#/components/responses/ValidationError"
+ '400':
+ $ref: '#/components/responses/ValidationError'
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "403":
+ '403':
description: Only the playlist owner can update this playlist.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: PLAYLIST_FORBIDDEN
message: You are not allowed to update this playlist.
- "404":
+ '404':
description: Playlist not found.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: PLAYLIST_NOT_FOUND
message: Playlist not found.
- "422":
+ '422':
description: Business rule violation.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: BUSINESS_RULE_VIOLATION
message: At least one updatable field must be provided.
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
delete:
tags: [Playlists]
@@ -2719,44 +2744,44 @@ paths:
format: uuid
responses:
- "200":
+ '200':
description: Playlist deleted successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/SuccessMessage"
+ $ref: '#/components/schemas/SuccessMessage'
example:
data:
success: true
message: Playlist deleted successfully.
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "403":
+ '403':
description: Only the playlist owner can delete this playlist.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: PLAYLIST_FORBIDDEN
message: You are not allowed to delete this playlist.
- "404":
+ '404':
description: Playlist not found.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: PLAYLIST_NOT_FOUND
message: Playlist not found.
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
/playlists/{playlist_id}/tracks:
post:
@@ -2788,60 +2813,60 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/AddTrackToPlaylistRequest"
+ $ref: '#/components/schemas/AddTrackToPlaylistRequest'
responses:
- "201":
+ '201':
description: Track added successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/PlaylistGetResponse"
+ $ref: '#/components/schemas/PlaylistGetResponse'
example:
data:
playlist_id: 8d5a8f6c-7b4a-4c7a-9c25-9a9f1e3a12aa
owner_user_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
- name: "Gym Mix"
- description: "High energy tracks"
+ name: 'Gym Mix'
+ description: 'High energy tracks'
is_public: true
- created_at: "2026-03-06T10:00:00Z"
+ created_at: '2026-03-06T10:00:00Z'
track_count: 3
like_count: 0
tracks:
- track_id: e5f6a7b8-c9d0-1234-efab-567890abcdef
position: 1
- added_at: "2026-03-06T10:01:00Z"
+ added_at: '2026-03-06T10:01:00Z'
- track_id: 11111111-2222-3333-4444-555555555555
position: 2
- added_at: "2026-03-06T10:02:00Z"
+ added_at: '2026-03-06T10:02:00Z'
- track_id: 99999999-aaaa-bbbb-cccc-dddddddddddd
position: 3
- added_at: "2026-03-06T10:03:00Z"
+ added_at: '2026-03-06T10:03:00Z'
message: Track added to playlist successfully.
- "400":
- $ref: "#/components/responses/ValidationError"
+ '400':
+ $ref: '#/components/responses/ValidationError'
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "403":
+ '403':
description: Only the playlist owner can modify this playlist.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: PLAYLIST_FORBIDDEN
message: You are not allowed to modify this playlist.
- "404":
+ '404':
description: Playlist or track not found.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
examples:
playlistNotFound:
value:
@@ -2854,30 +2879,30 @@ paths:
code: TRACK_NOT_FOUND
message: Track not found.
- "409":
+ '409':
description: Track already exists in the playlist.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: PLAYLIST_TRACK_ALREADY_EXISTS
message: Track already exists in this playlist.
- "422":
+ '422':
description: Business rule violation (invalid position).
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: PLAYLIST_POSITION_INVALID
message: Position must be between 1 and the next available position.
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
/playlists/{playlist_id}/tracks/reorder:
patch:
@@ -2905,60 +2930,60 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/ReorderPlaylistTracksRequest"
+ $ref: '#/components/schemas/ReorderPlaylistTracksRequest'
responses:
- "200":
+ '200':
description: Playlist tracks reordered successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/PlaylistGetResponse"
+ $ref: '#/components/schemas/PlaylistGetResponse'
example:
data:
playlist_id: 8d5a8f6c-7b4a-4c7a-9c25-9a9f1e3a12aa
owner_user_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
- name: "Gym Mix"
- description: "High energy tracks"
+ name: 'Gym Mix'
+ description: 'High energy tracks'
is_public: true
- created_at: "2026-03-06T10:00:00Z"
+ created_at: '2026-03-06T10:00:00Z'
track_count: 3
like_count: 0
tracks:
- track_id: 99999999-aaaa-bbbb-cccc-dddddddddddd
position: 1
- added_at: "2026-03-06T10:03:00Z"
+ added_at: '2026-03-06T10:03:00Z'
- track_id: e5f6a7b8-c9d0-1234-efab-567890abcdef
position: 2
- added_at: "2026-03-06T10:01:00Z"
+ added_at: '2026-03-06T10:01:00Z'
- track_id: 11111111-2222-3333-4444-555555555555
position: 3
- added_at: "2026-03-06T10:02:00Z"
+ added_at: '2026-03-06T10:02:00Z'
message: Playlist tracks reordered successfully.
- "400":
- $ref: "#/components/responses/ValidationError"
+ '400':
+ $ref: '#/components/responses/ValidationError'
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "403":
+ '403':
description: Only the playlist owner can modify this playlist.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: PLAYLIST_FORBIDDEN
message: You are not allowed to modify this playlist.
- "404":
+ '404':
description: Playlist not found (or track not found in playlist).
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
examples:
playlistNotFound:
value:
@@ -2971,12 +2996,12 @@ paths:
code: PLAYLIST_TRACK_NOT_FOUND
message: One or more tracks are not in this playlist.
- "422":
+ '422':
description: Business rule violation (bad positions).
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
examples:
positionsInvalid:
value:
@@ -2989,8 +3014,8 @@ paths:
code: PLAYLIST_REORDER_REQUIRES_FULL_LIST
message: Reorder requires a full list of playlist tracks.
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
/playlists/{playlist_id}/tracks/{track_id}:
delete:
@@ -3021,54 +3046,54 @@ paths:
format: uuid
responses:
- "200":
+ '200':
description: Track removed successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/PlaylistGetResponse"
+ $ref: '#/components/schemas/PlaylistGetResponse'
example:
data:
playlist_id: 8d5a8f6c-7b4a-4c7a-9c25-9a9f1e3a12aa
owner_user_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
- name: "Gym Mix"
- description: "High energy tracks"
+ name: 'Gym Mix'
+ description: 'High energy tracks'
is_public: true
- created_at: "2026-03-06T10:00:00Z"
+ created_at: '2026-03-06T10:00:00Z'
track_count: 2
like_count: 0
tracks:
- track_id: e5f6a7b8-c9d0-1234-efab-567890abcdef
position: 1
- added_at: "2026-03-06T10:01:00Z"
+ added_at: '2026-03-06T10:01:00Z'
- track_id: 11111111-2222-3333-4444-555555555555
position: 2
- added_at: "2026-03-06T10:02:00Z"
+ added_at: '2026-03-06T10:02:00Z'
message: Track removed from playlist successfully.
- "400":
- $ref: "#/components/responses/ValidationError"
+ '400':
+ $ref: '#/components/responses/ValidationError'
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "403":
+ '403':
description: Only the playlist owner can modify this playlist.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: PLAYLIST_FORBIDDEN
message: You are not allowed to modify this playlist.
- "404":
+ '404':
description: Playlist not found or track not in playlist.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
examples:
playlistNotFound:
value:
@@ -3081,8 +3106,8 @@ paths:
code: PLAYLIST_TRACK_NOT_FOUND
message: Track not found in this playlist.
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
/playlists/{playlist_id}/embed:
get:
@@ -3153,42 +3178,42 @@ paths:
default: 200
responses:
- "200":
+ '200':
description: Embed code generated successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/PlaylistEmbedResponse"
+ $ref: '#/components/schemas/PlaylistEmbedResponse'
example:
data:
embed_url: https://api.rythmify.com/embed/playlists/8d5a8f6c-7b4a-4c7a-9c25-9a9f1e3a12aa
iframe_html: ""
message: Playlist embed code generated successfully.
- "404":
+ '404':
description: Playlist not found.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: PLAYLIST_NOT_FOUND
message: Playlist not found.
- "403":
+ '403':
description: Playlist is private and no valid secret token was provided.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: PLAYLIST_ACCESS_DENIED
message: Private playlist requires a valid secret token.
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
# ==========================================================
# Module 7.5 — Home & Discovery
@@ -3221,12 +3246,12 @@ paths:
refreshed weekly by cron.
security: []
responses:
- "200":
+ '200':
description: Home page data returned.
content:
application/json:
schema:
- $ref: "#/components/schemas/HomeResponse"
+ $ref: '#/components/schemas/HomeResponse'
/home/trending-by-genre/{genre_id}:
get:
@@ -3255,14 +3280,14 @@ paths:
default: 20
maximum: 50
responses:
- "200":
+ '200':
description: Trending tracks for this genre.
content:
application/json:
schema:
- $ref: "#/components/schemas/TrendingByGenreSection"
- "404":
- $ref: "#/components/responses/NotFound"
+ $ref: '#/components/schemas/TrendingByGenreSection'
+ '404':
+ $ref: '#/components/responses/NotFound'
/home/stations:
get:
@@ -3286,7 +3311,7 @@ paths:
type: integer
default: 0
responses:
- "200":
+ '200':
description: Station list.
content:
application/json:
@@ -3296,7 +3321,7 @@ paths:
data:
type: array
items:
- $ref: "#/components/schemas/Station"
+ $ref: '#/components/schemas/Station'
/home/stations/{artist_id}/tracks:
get:
@@ -3331,7 +3356,7 @@ paths:
type: integer
default: 0
responses:
- "200":
+ '200':
description: Station tracks.
content:
application/json:
@@ -3342,13 +3367,13 @@ paths:
type: object
properties:
station:
- $ref: "#/components/schemas/Station"
+ $ref: '#/components/schemas/Station'
tracks:
type: array
items:
- $ref: "#/components/schemas/FeedTrack"
- "404":
- $ref: "#/components/responses/NotFound"
+ $ref: '#/components/schemas/FeedTrack'
+ '404':
+ $ref: '#/components/responses/NotFound'
/home/mixes/{mix_id}/tracks:
get:
@@ -3380,7 +3405,7 @@ paths:
type: integer
default: 0
responses:
- "200":
+ '200':
description: Mix tracks.
content:
application/json:
@@ -3391,15 +3416,15 @@ paths:
type: object
properties:
mix:
- $ref: "#/components/schemas/PersonalMix"
+ $ref: '#/components/schemas/PersonalMix'
tracks:
type: array
items:
- $ref: "#/components/schemas/FeedTrack"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "404":
- $ref: "#/components/responses/NotFound"
+ $ref: '#/components/schemas/FeedTrack'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
+ $ref: '#/components/responses/NotFound'
/home/hot-for-you:
get:
@@ -3416,7 +3441,7 @@ paths:
Cached per user until midnight UTC (valid_until).
security: []
responses:
- "200":
+ '200':
description: Hot track returned.
content:
application/json:
@@ -3424,7 +3449,7 @@ paths:
type: object
properties:
data:
- $ref: "#/components/schemas/HotForYou"
+ $ref: '#/components/schemas/HotForYou'
/home/artists-to-watch:
get:
@@ -3446,7 +3471,7 @@ paths:
default: 10
maximum: 20
responses:
- "200":
+ '200':
description: Buzzing playlists.
content:
application/json:
@@ -3456,7 +3481,7 @@ paths:
data:
type: array
items:
- $ref: "#/components/schemas/BuzzingPlaylist"
+ $ref: '#/components/schemas/BuzzingPlaylist'
# ==========================================================
# Module 8 — Feed, Search & Discovery
@@ -3504,7 +3529,7 @@ paths:
description: Filter feed by genre (e.g. electronic, lo-fi, hip-hop)
responses:
- "200":
+ '200':
description: Feed retrieved successfully
content:
application/json:
@@ -3514,12 +3539,12 @@ paths:
data:
type: array
items:
- $ref: "#/components/schemas/FeedTrack"
+ $ref: '#/components/schemas/FeedTrack'
pagination:
- $ref: "#/components/schemas/Pagination"
+ $ref: '#/components/schemas/Pagination'
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
/feed/trending:
get:
@@ -3562,7 +3587,7 @@ paths:
maximum: 100
responses:
- "200":
+ '200':
description: Trending tracks retrieved successfully
content:
application/json:
@@ -3572,12 +3597,12 @@ paths:
data:
type: array
items:
- $ref: "#/components/schemas/TrendingTrack"
+ $ref: '#/components/schemas/TrendingTrack'
pagination:
- $ref: "#/components/schemas/Pagination"
+ $ref: '#/components/schemas/Pagination'
- "400":
- $ref: "#/components/responses/ValidationError"
+ '400':
+ $ref: '#/components/responses/ValidationError'
/search:
get:
@@ -3634,7 +3659,7 @@ paths:
maximum: 100
responses:
- "200":
+ '200':
description: Search results retrieved successfully
content:
application/json:
@@ -3647,20 +3672,20 @@ paths:
tracks:
type: array
items:
- $ref: "#/components/schemas/TrackSearchResult"
+ $ref: '#/components/schemas/TrackSearchResult'
users:
type: array
items:
- $ref: "#/components/schemas/UserSearchResult"
+ $ref: '#/components/schemas/UserSearchResult'
playlists:
type: array
items:
- $ref: "#/components/schemas/PlaylistSearchResult"
+ $ref: '#/components/schemas/PlaylistSearchResult'
pagination:
- $ref: "#/components/schemas/Pagination"
+ $ref: '#/components/schemas/Pagination'
- "400":
- $ref: "#/components/responses/ValidationError"
+ '400':
+ $ref: '#/components/responses/ValidationError'
/resolve:
get:
@@ -3686,7 +3711,7 @@ paths:
example: https://rythmify.com/tracks/abc-123
responses:
- "200":
+ '200':
description: Permalink resolved successfully
content:
application/json:
@@ -3710,23 +3735,23 @@ paths:
description: Canonical Rythmify URL for this resource
example: https://rythmify.com/tracks/abc-123
- "400":
+ '400':
description: Invalid URL format
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: VALIDATION_FAILED
message: Invalid Rythmify URL
- "404":
+ '404':
description: Resource not found
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: RESOURCE_NOT_FOUND
@@ -3765,18 +3790,18 @@ paths:
minimum: 1
responses:
- "200":
+ '200':
description: Paginated list of conversations returned.
content:
application/json:
schema:
- $ref: "#/components/schemas/ConversationListResponse"
+ $ref: '#/components/schemas/ConversationListResponse'
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "500":
- $ref: "#/components/responses/InternalError"
+ '500':
+ $ref: '#/components/responses/InternalError'
/messages/conversations/{conversationId}:
get:
@@ -3814,24 +3839,24 @@ paths:
minimum: 1
responses:
- "200":
+ '200':
description: Conversation and messages returned.
content:
application/json:
schema:
- $ref: "#/components/schemas/ConversationDetailResponse"
+ $ref: '#/components/schemas/ConversationDetailResponse'
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "403":
- $ref: "#/components/responses/Forbidden"
+ '403':
+ $ref: '#/components/responses/Forbidden'
- "404":
- $ref: "#/components/responses/NotFound"
+ '404':
+ $ref: '#/components/responses/NotFound'
- "500":
- $ref: "#/components/responses/InternalError"
+ '500':
+ $ref: '#/components/responses/InternalError'
delete:
tags: [Messages]
@@ -3851,27 +3876,27 @@ paths:
format: uuid
responses:
- "200":
+ '200':
description: Conversation deleted for the current user.
content:
application/json:
schema:
- $ref: "#/components/schemas/SuccessMessage"
+ $ref: '#/components/schemas/SuccessMessage'
example:
success: true
message: Conversation deleted.
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "403":
- $ref: "#/components/responses/Forbidden"
+ '403':
+ $ref: '#/components/responses/Forbidden'
- "404":
- $ref: "#/components/responses/NotFound"
+ '404':
+ $ref: '#/components/responses/NotFound'
- "500":
- $ref: "#/components/responses/InternalError"
+ '500':
+ $ref: '#/components/responses/InternalError'
/messages/conversations/{conversationId}/messages:
post:
@@ -3903,44 +3928,44 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/SendMessageRequest"
+ $ref: '#/components/schemas/SendMessageRequest'
responses:
- "201":
+ '201':
description: Message sent successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/MessageCreatedResponse"
+ $ref: '#/components/schemas/MessageCreatedResponse'
- "400":
+ '400':
description: |
Message validation failed: body and resource both missing, body exceeds max length,
or resource has invalid type/id.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "403":
+ '403':
description: |
Sender is blocked by the recipient, or the recipient has disabled messages from non-followers.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
- "404":
- $ref: "#/components/responses/NotFound"
+ '404':
+ $ref: '#/components/responses/NotFound'
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
- "500":
- $ref: "#/components/responses/InternalError"
+ '500':
+ $ref: '#/components/responses/InternalError'
/messages/conversations/{conversationId}/messages/{messageId}:
delete:
@@ -3969,27 +3994,27 @@ paths:
format: uuid
responses:
- "200":
+ '200':
description: Message deleted.
content:
application/json:
schema:
- $ref: "#/components/schemas/SuccessMessage"
+ $ref: '#/components/schemas/SuccessMessage'
example:
success: true
message: Message deleted.
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "403":
- $ref: "#/components/responses/Forbidden"
+ '403':
+ $ref: '#/components/responses/Forbidden'
- "404":
- $ref: "#/components/responses/NotFound"
+ '404':
+ $ref: '#/components/responses/NotFound'
- "500":
- $ref: "#/components/responses/InternalError"
+ '500':
+ $ref: '#/components/responses/InternalError'
/messages/new:
post:
@@ -4005,53 +4030,53 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/StartConversationRequest"
+ $ref: '#/components/schemas/StartConversationRequest'
responses:
- "201":
+ '201':
description: Conversation created and first message sent.
content:
application/json:
schema:
- $ref: "#/components/schemas/ConversationCreatedResponse"
+ $ref: '#/components/schemas/ConversationCreatedResponse'
- "200":
+ '200':
description: |
Existing conversation found — message appended to existing thread.
content:
application/json:
schema:
- $ref: "#/components/schemas/MessageCreatedResponse"
+ $ref: '#/components/schemas/MessageCreatedResponse'
- "400":
+ '400':
description: Missing recipient, empty message body, or self-messaging attempt.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "403":
+ '403':
description: Recipient has blocked the sender.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
- "404":
+ '404':
description: Recipient user not found.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
- "500":
- $ref: "#/components/responses/InternalError"
+ '500':
+ $ref: '#/components/responses/InternalError'
/messages/unread-count:
get:
@@ -4063,18 +4088,18 @@ paths:
security:
- bearerAuth: []
responses:
- "200":
+ '200':
description: Unread message count returned.
content:
application/json:
schema:
- $ref: "#/components/schemas/UnreadCountResponse"
+ $ref: '#/components/schemas/UnreadCountResponse'
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "500":
- $ref: "#/components/responses/InternalError"
+ '500':
+ $ref: '#/components/responses/InternalError'
/messages/conversations/{conversationId}/messages/{messageId}/read:
patch:
@@ -4110,32 +4135,32 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/MarkMessageReadRequest"
+ $ref: '#/components/schemas/MarkMessageReadRequest'
responses:
- "200":
+ '200':
description: Message read state updated successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/MarkMessageReadResponse"
- "400":
+ $ref: '#/components/schemas/MarkMessageReadResponse'
+ '400':
description: Missing or invalid is_read value.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
- $ref: "#/components/responses/Forbidden" # Only the message recipient can change read state
- "404":
- $ref: "#/components/responses/NotFound"
- "409":
+ $ref: '#/components/schemas/ErrorResponse'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden' # Only the message recipient can change read state
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '409':
description: Message is already in the requested read state.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
# ==========================================================
# Module 10 — Messaging & Track Sharing
# ==========================================================
@@ -4178,16 +4203,16 @@ paths:
type: boolean
default: false
responses:
- "200":
+ '200':
description: Notification feed returned.
content:
application/json:
schema:
- $ref: "#/components/schemas/NotificationListResponse"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "500":
- $ref: "#/components/responses/InternalError"
+ $ref: '#/components/schemas/NotificationListResponse'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ $ref: '#/components/responses/InternalError'
/notifications/unread-count:
get:
@@ -4199,16 +4224,16 @@ paths:
security:
- bearerAuth: []
responses:
- "200":
+ '200':
description: Unread notification count returned.
content:
application/json:
schema:
- $ref: "#/components/schemas/UnreadCountResponse"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "500":
- $ref: "#/components/responses/InternalError"
+ $ref: '#/components/schemas/UnreadCountResponse'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ $ref: '#/components/responses/InternalError'
/notifications/read-all:
post:
@@ -4221,20 +4246,20 @@ paths:
security:
- bearerAuth: []
responses:
- "200":
+ '200':
description: All notifications marked as read.
content:
application/json:
schema:
- $ref: "#/components/schemas/SuccessMessage"
+ $ref: '#/components/schemas/SuccessMessage'
example:
data:
success: true
message: All notifications marked as read.
- "401":
- $ref: "#/components/responses/Unauthorized"
- "500":
- $ref: "#/components/responses/InternalError"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ $ref: '#/components/responses/InternalError'
/notifications/{notification_id}/read:
patch:
@@ -4255,24 +4280,24 @@ paths:
type: string
format: uuid
responses:
- "200":
+ '200':
description: Notification marked as read.
content:
application/json:
schema:
- $ref: "#/components/schemas/SuccessMessage"
+ $ref: '#/components/schemas/SuccessMessage'
example:
data:
success: true
message: Notification marked as read.
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
- $ref: "#/components/responses/Forbidden"
- "404":
- $ref: "#/components/responses/NotFound"
- "500":
- $ref: "#/components/responses/InternalError"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '500':
+ $ref: '#/components/responses/InternalError'
/notifications/{notification_id}:
delete:
@@ -4292,24 +4317,24 @@ paths:
type: string
format: uuid
responses:
- "200":
+ '200':
description: Notification deleted.
content:
application/json:
schema:
- $ref: "#/components/schemas/SuccessMessage"
+ $ref: '#/components/schemas/SuccessMessage'
example:
data:
success: true
message: Notification deleted.
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
- $ref: "#/components/responses/Forbidden"
- "404":
- $ref: "#/components/responses/NotFound"
- "500":
- $ref: "#/components/responses/InternalError"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '500':
+ $ref: '#/components/responses/InternalError'
/notifications/push/register:
post:
@@ -4328,24 +4353,24 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/RegisterPushTokenRequest"
+ $ref: '#/components/schemas/RegisterPushTokenRequest'
responses:
- "200":
+ '200':
description: Push token registered or refreshed successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/SuccessMessage"
+ $ref: '#/components/schemas/SuccessMessage'
example:
data:
success: true
message: Push token registered.
- "400":
+ '400':
description: Missing or malformed push token or platform value.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: VALIDATION_FAILED
@@ -4353,10 +4378,10 @@ paths:
details:
- field: platform
issue: Must be one of android, ios
- "401":
- $ref: "#/components/responses/Unauthorized"
- "500":
- $ref: "#/components/responses/InternalError"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ $ref: '#/components/responses/InternalError'
/notifications/push/unregister:
post:
@@ -4373,24 +4398,24 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/UnregisterPushTokenRequest"
+ $ref: '#/components/schemas/UnregisterPushTokenRequest'
responses:
- "200":
+ '200':
description: Push token removed.
content:
application/json:
schema:
- $ref: "#/components/schemas/SuccessMessage"
+ $ref: '#/components/schemas/SuccessMessage'
example:
data:
success: true
message: Push token unregistered.
- "400":
+ '400':
description: Missing token field.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: VALIDATION_FAILED
@@ -4398,20 +4423,20 @@ paths:
details:
- field: token
issue: This field is required
- "401":
- $ref: "#/components/responses/Unauthorized"
- "404":
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
description: Token not found for this user.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: RESOURCE_NOT_FOUND
message: Push token not found for this account.
- "500":
- $ref: "#/components/responses/InternalError"
+ '500':
+ $ref: '#/components/responses/InternalError'
/notifications/preferences:
get:
@@ -4424,16 +4449,16 @@ paths:
security:
- bearerAuth: []
responses:
- "200":
+ '200':
description: Notification preferences returned.
content:
application/json:
schema:
- $ref: "#/components/schemas/NotificationPreferencesResponse"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "500":
- $ref: "#/components/responses/InternalError"
+ $ref: '#/components/schemas/NotificationPreferencesResponse'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ $ref: '#/components/responses/InternalError'
patch:
tags: [Notifications]
@@ -4449,20 +4474,20 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/UpdateNotificationPreferencesRequest"
+ $ref: '#/components/schemas/UpdateNotificationPreferencesRequest'
responses:
- "200":
+ '200':
description: Preferences updated. Returns the full updated preferences object.
content:
application/json:
schema:
- $ref: "#/components/schemas/NotificationPreferencesResponse"
- "400":
+ $ref: '#/components/schemas/NotificationPreferencesResponse'
+ '400':
description: Invalid preference field or non-boolean value provided.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: VALIDATION_FAILED
@@ -4470,10 +4495,10 @@ paths:
details:
- field: like_push
issue: Must be a boolean value
- "401":
- $ref: "#/components/responses/Unauthorized"
- "500":
- $ref: "#/components/responses/InternalError"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ $ref: '#/components/responses/InternalError'
# ==========================================================
# Module 11 — Moderation & Admin Dashboard
@@ -4503,7 +4528,7 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/ReportRequest"
+ $ref: '#/components/schemas/ReportRequest'
examples:
report_track:
summary: Report a track for copyright
@@ -4521,7 +4546,7 @@ paths:
description: This account is posting spam comments on multiple tracks.
responses:
- "201":
+ '201':
description: Report submitted successfully
content:
application/json:
@@ -4529,19 +4554,19 @@ paths:
type: object
properties:
data:
- $ref: "#/components/schemas/Report"
+ $ref: '#/components/schemas/Report'
message:
type: string
example: Report submitted successfully. Our team will review it shortly.
- "400":
+ '400':
description: |
Validation error — e.g. `copyright` reason used with `resource_type: user`,
or required fields missing.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: VALIDATION_FAILED
@@ -4550,33 +4575,33 @@ paths:
- field: reason
issue: Copyright reason is only valid for track reports.
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "404":
+ '404':
description: Reported resource not found
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: RESOURCE_NOT_FOUND
message: The reported resource was not found
- "409":
+ '409':
description: User already reported this resource
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: RESOURCE_ALREADY_EXISTS
message: You have already reported this resource
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
/admin/reports:
get:
@@ -4620,7 +4645,7 @@ paths:
maximum: 100
responses:
- "200":
+ '200':
description: Reports retrieved successfully
content:
application/json:
@@ -4630,15 +4655,15 @@ paths:
data:
type: array
items:
- $ref: "#/components/schemas/ReportDetailed"
+ $ref: '#/components/schemas/ReportDetailed'
pagination:
- $ref: "#/components/schemas/Pagination"
+ $ref: '#/components/schemas/Pagination'
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "403":
- $ref: "#/components/responses/Forbidden"
+ '403':
+ $ref: '#/components/responses/Forbidden'
/admin/reports/{id}:
# [F4] THIS ENTIRE ENDPOINT WAS MISSING — now added
@@ -4662,7 +4687,7 @@ paths:
description: Report ID
responses:
- "200":
+ '200':
description: Report retrieved successfully
content:
application/json:
@@ -4670,16 +4695,16 @@ paths:
type: object
properties:
data:
- $ref: "#/components/schemas/ReportDetailed"
+ $ref: '#/components/schemas/ReportDetailed'
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "403":
- $ref: "#/components/responses/Forbidden"
+ '403':
+ $ref: '#/components/responses/Forbidden'
- "404":
- $ref: "#/components/responses/NotFound"
+ '404':
+ $ref: '#/components/responses/NotFound'
patch:
tags: [Admin]
@@ -4720,7 +4745,7 @@ paths:
admin_note: Copyright holder confirmed. Content removed.
responses:
- "200":
+ '200':
description: Report updated successfully
content:
application/json:
@@ -4741,7 +4766,7 @@ paths:
resolved_at:
type: string
format: date-time
- example: "2026-03-06T08:30:00Z"
+ example: '2026-03-06T08:30:00Z'
message:
type: string
example: Report status updated successfully
@@ -4749,20 +4774,20 @@ paths:
data:
id: r1234567-89ab-cdef-0123-456789abcdef
status: resolved
- resolved_at: "2026-03-06T08:30:00Z"
+ resolved_at: '2026-03-06T08:30:00Z'
message: Report status updated successfully
- "400":
- $ref: "#/components/responses/ValidationError"
+ '400':
+ $ref: '#/components/responses/ValidationError'
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "403":
- $ref: "#/components/responses/Forbidden"
+ '403':
+ $ref: '#/components/responses/Forbidden'
- "404":
- $ref: "#/components/responses/NotFound"
+ '404':
+ $ref: '#/components/responses/NotFound'
/admin/tracks/{id}:
# [F7] DELETE now returns 204 No Content (was 200 with body)
@@ -4787,17 +4812,17 @@ paths:
description: Track ID
responses:
- "204":
+ '204':
description: Track deleted successfully — no response body
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "403":
- $ref: "#/components/responses/Forbidden"
+ '403':
+ $ref: '#/components/responses/Forbidden'
- "404":
- $ref: "#/components/responses/NotFound"
+ '404':
+ $ref: '#/components/responses/NotFound'
patch:
tags: [Admin]
@@ -4838,7 +4863,7 @@ paths:
reason: Copyright violation reported and confirmed.
responses:
- "200":
+ '200':
description: Track visibility updated
content:
application/json:
@@ -4862,17 +4887,17 @@ paths:
is_hidden: true
message: Track hidden successfully
- "400":
- $ref: "#/components/responses/ValidationError"
+ '400':
+ $ref: '#/components/responses/ValidationError'
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "403":
- $ref: "#/components/responses/Forbidden"
+ '403':
+ $ref: '#/components/responses/Forbidden'
- "404":
- $ref: "#/components/responses/NotFound"
+ '404':
+ $ref: '#/components/responses/NotFound'
/admin/users/{id}/suspend:
patch: # [F8] was: post
@@ -4912,7 +4937,7 @@ paths:
reason: Repeated copyright violations
responses:
- "200":
+ '200':
description: Account suspended successfully
content:
application/json:
@@ -4941,27 +4966,27 @@ paths:
id: u1234567-89ab-cdef-0123-456789abcdef
status: suspended
reason: Repeated copyright violations
- suspended_at: "2026-03-05T20:00:00Z"
+ suspended_at: '2026-03-05T20:00:00Z'
message: Account suspended successfully
- "400":
- $ref: "#/components/responses/ValidationError"
+ '400':
+ $ref: '#/components/responses/ValidationError'
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "403":
- $ref: "#/components/responses/Forbidden"
+ '403':
+ $ref: '#/components/responses/Forbidden'
- "404":
- $ref: "#/components/responses/NotFound"
+ '404':
+ $ref: '#/components/responses/NotFound'
- "422":
+ '422':
description: Cannot suspend admin account
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: BUSINESS_OPERATION_NOT_ALLOWED
@@ -4988,7 +5013,7 @@ paths:
description: User ID to reinstate
responses:
- "200":
+ '200':
description: Account reinstated successfully
content:
application/json:
@@ -5014,17 +5039,17 @@ paths:
data:
id: u1234567-89ab-cdef-0123-456789abcdef
status: active
- reinstated_at: "2026-03-06T10:00:00Z"
+ reinstated_at: '2026-03-06T10:00:00Z'
message: Account reinstated successfully
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "403":
- $ref: "#/components/responses/Forbidden"
+ '403':
+ $ref: '#/components/responses/Forbidden'
- "404":
- $ref: "#/components/responses/NotFound"
+ '404':
+ $ref: '#/components/responses/NotFound'
/admin/analytics:
get:
@@ -5047,7 +5072,7 @@ paths:
description: Reporting period
responses:
- "200":
+ '200':
description: Analytics retrieved successfully
content:
application/json:
@@ -5055,16 +5080,16 @@ paths:
type: object
properties:
data:
- $ref: "#/components/schemas/AnalyticsSummary"
+ $ref: '#/components/schemas/AnalyticsSummary'
- "400":
- $ref: "#/components/responses/ValidationError"
+ '400':
+ $ref: '#/components/responses/ValidationError'
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "403":
- $ref: "#/components/responses/Forbidden"
+ '403':
+ $ref: '#/components/responses/Forbidden'
# ==========================================================
# Module 12 — Premium Subscription & Payments
@@ -5087,31 +5112,31 @@ paths:
security: []
responses:
- "200":
+ '200':
description: Subscription plans fetched successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/SubscriptionPlanListResponse"
+ $ref: '#/components/schemas/SubscriptionPlanListResponse'
example:
data:
items:
- subscription_plan_id: 1
name: free
- price: "0.00"
+ price: '0.00'
duration_days: null
track_limit: 3
playlist_limit: 2
- subscription_plan_id: 2
name: premium
- price: "4.99"
+ price: '4.99'
duration_days: 30
track_limit: null
playlist_limit: null
message: Subscription plans fetched successfully.
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
/subscriptions/me:
get:
@@ -5128,35 +5153,35 @@ paths:
renewal behavior, and premium access state.
operationId: getMySubscription
responses:
- "200":
+ '200':
description: Current subscription fetched successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/MySubscriptionResponse"
+ $ref: '#/components/schemas/MySubscriptionResponse'
example:
data:
user_subscription_id: 15
- user_id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
+ user_id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
status: active
- start_date: "2026-03-01"
- end_date: "2026-03-31"
+ start_date: '2026-03-01'
+ end_date: '2026-03-31'
auto_renew: true
- created_at: "2026-03-01T09:30:00Z"
+ created_at: '2026-03-01T09:30:00Z'
plan:
subscription_plan_id: 2
name: premium
- price: "4.99"
+ price: '4.99'
duration_days: 30
track_limit: null
playlist_limit: null
message: Current subscription fetched successfully.
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
/subscriptions/checkout:
post:
@@ -5177,61 +5202,61 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/SubscriptionCheckoutRequest"
+ $ref: '#/components/schemas/SubscriptionCheckoutRequest'
responses:
- "200":
+ '200':
description: Checkout session created successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/SubscriptionCheckoutResponse"
+ $ref: '#/components/schemas/SubscriptionCheckoutResponse'
example:
data:
transaction_id: 27
user_subscription_id: 18
checkout_status: pending
payment_method: mock
- payment_url: "https://checkout.stripe.com/pay/mock-session-123"
+ payment_url: 'https://checkout.stripe.com/pay/mock-session-123'
plan:
subscription_plan_id: 2
name: premium
- price: "4.99"
+ price: '4.99'
duration_days: 30
track_limit: null
playlist_limit: null
message: Checkout session created successfully.
- "400":
- $ref: "#/components/responses/ValidationError"
+ '400':
+ $ref: '#/components/responses/ValidationError'
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "409":
+ '409':
description: User already has an active premium subscription.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: SUBSCRIPTION_ALREADY_ACTIVE
message: You already have an active premium subscription.
- "422":
+ '422':
description: Business rule violation.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: BUSINESS_RULE_VIOLATION
message: Selected plan is not eligible for checkout.
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
/subscriptions/cancel:
post:
@@ -5247,47 +5272,47 @@ paths:
- If the user is already on the free plan or has no active premium subscription, the request is rejected.
operationId: cancelMySubscription
responses:
- "200":
+ '200':
description: Subscription canceled successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/CancelSubscriptionResponse"
+ $ref: '#/components/schemas/CancelSubscriptionResponse'
example:
data:
user_subscription_id: 18
status: canceled
auto_renew: false
- end_date: "2026-03-31"
+ end_date: '2026-03-31'
message: Subscription canceled successfully.
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "404":
+ '404':
description: No active premium subscription found for the current user.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: SUBSCRIPTION_NOT_FOUND
message: No active premium subscription found.
- "409":
+ '409':
description: Subscription is already canceled or not eligible for cancellation.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: SUBSCRIPTION_ALREADY_CANCELED
message: Subscription is already canceled.
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
/subscriptions/transactions:
get:
@@ -5326,46 +5351,46 @@ paths:
required: false
description: Filter transactions by payment status.
schema:
- $ref: "#/components/schemas/PaymentStatus"
+ $ref: '#/components/schemas/PaymentStatus'
responses:
- "200":
+ '200':
description: Subscription transactions fetched successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/SubscriptionTransactionListResponse"
+ $ref: '#/components/schemas/SubscriptionTransactionListResponse'
example:
data:
items:
- transaction_id: 27
user_subscription_id: 18
- amount: "4.99"
+ amount: '4.99'
payment_method: mock
payment_status: paid
- paid_at: "2026-03-01T09:35:00Z"
- created_at: "2026-03-01T09:30:00Z"
+ paid_at: '2026-03-01T09:35:00Z'
+ created_at: '2026-03-01T09:30:00Z'
- transaction_id: 19
user_subscription_id: 12
- amount: "4.99"
+ amount: '4.99'
payment_method: mock
payment_status: failed
paid_at: null
- created_at: "2026-02-01T08:10:00Z"
+ created_at: '2026-02-01T08:10:00Z'
meta:
limit: 20
offset: 0
total: 2
message: Subscription transactions fetched successfully.
- "400":
- $ref: "#/components/responses/ValidationError"
+ '400':
+ $ref: '#/components/responses/ValidationError'
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
/subscriptions/mock-confirm/{transaction_id}:
post:
@@ -5388,48 +5413,48 @@ paths:
type: integer
responses:
- "200":
+ '200':
description: Mock payment confirmed successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/MockConfirmSubscriptionResponse"
+ $ref: '#/components/schemas/MockConfirmSubscriptionResponse'
example:
data:
transaction_id: 27
payment_status: paid
- paid_at: "2026-03-06T14:20:00Z"
+ paid_at: '2026-03-06T14:20:00Z'
subscription:
user_subscription_id: 18
status: active
auto_renew: true
- start_date: "2026-03-06"
- end_date: "2026-04-05"
+ start_date: '2026-03-06'
+ end_date: '2026-04-05'
message: Mock payment confirmed and subscription activated successfully.
- "400":
- $ref: "#/components/responses/ValidationError"
+ '400':
+ $ref: '#/components/responses/ValidationError'
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "404":
+ '404':
description: Transaction not found.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: TRANSACTION_NOT_FOUND
message: Transaction not found.
- "409":
+ '409':
description: Transaction is not eligible for confirmation.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
examples:
alreadyPaid:
value:
@@ -5442,8 +5467,8 @@ paths:
code: TRANSACTION_NOT_PENDING
message: Only pending transactions can be confirmed.
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
# ==========================================================
@@ -5459,52 +5484,52 @@ paths:
operationId: followUser
description: Follow a user. Idempotent if already following.
parameters:
- - $ref: "#/components/parameters/UserId"
+ - $ref: '#/components/parameters/UserId'
responses:
- "200":
+ '200':
description: Already following — no change.
content:
application/json:
schema:
- $ref: "#/components/schemas/MessageResponse"
+ $ref: '#/components/schemas/MessageResponse'
example:
message: You are already following this user.
- "201":
+ '201':
description: Followed successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/FollowResponse"
+ $ref: '#/components/schemas/FollowResponse'
example:
data:
follower_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
followed_id: b2c3d4e5-f6a7-8901-bcde-f12345678901
- created_at: "2025-03-05T14:00:00Z"
+ created_at: '2025-03-05T14:00:00Z'
message: You are now following this user.
- "400":
+ '400':
description: Cannot follow yourself.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: FOLLOW_SELF
message: You cannot follow yourself.
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
description: A block relationship exists between the two users.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: PERMISSION_DENIED
message: You cannot follow this user.
- "404":
- $ref: "#/components/responses/NotFound"
+ '404':
+ $ref: '#/components/responses/NotFound'
delete:
tags:
- Follow
@@ -5512,24 +5537,24 @@ paths:
operationId: unfollowUser
description: Unfollow a user. Idempotent if not following.
parameters:
- - $ref: "#/components/parameters/UserId"
+ - $ref: '#/components/parameters/UserId'
responses:
- "204":
+ '204':
description: Unfollowed (or was not following) — no body.
- "400":
+ '400':
description: Cannot unfollow yourself.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: FOLLOW_SELF
message: You cannot unfollow yourself.
- "401":
- $ref: "#/components/responses/Unauthorized"
- "404":
- $ref: "#/components/responses/NotFound"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
+ $ref: '#/components/responses/NotFound'
/users/{user_id}/followers:
get:
@@ -5539,22 +5564,22 @@ paths:
operationId: getFollowers
description: Returns paginated list of users who follow the given user.
parameters:
- - $ref: "#/components/parameters/UserId"
- - $ref: "#/components/parameters/Limit"
- - $ref: "#/components/parameters/Offset"
+ - $ref: '#/components/parameters/UserId'
+ - $ref: '#/components/parameters/Limit'
+ - $ref: '#/components/parameters/Offset'
responses:
- "200":
+ '200':
description: Paginated followers list.
content:
application/json:
schema:
- $ref: "#/components/schemas/UserListResponse"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
- $ref: "#/components/responses/Forbidden"
- "404":
- $ref: "#/components/responses/NotFound"
+ $ref: '#/components/schemas/UserListResponse'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
/users/{user_id}/following:
get:
@@ -5564,22 +5589,22 @@ paths:
operationId: getFollowing
description: Returns paginated list of users the given user follows.
parameters:
- - $ref: "#/components/parameters/UserId"
- - $ref: "#/components/parameters/Limit"
- - $ref: "#/components/parameters/Offset"
+ - $ref: '#/components/parameters/UserId'
+ - $ref: '#/components/parameters/Limit'
+ - $ref: '#/components/parameters/Offset'
responses:
- "200":
+ '200':
description: Paginated following list.
content:
application/json:
schema:
- $ref: "#/components/schemas/UserListResponse"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
- $ref: "#/components/responses/Forbidden"
- "404":
- $ref: "#/components/responses/NotFound"
+ $ref: '#/components/schemas/UserListResponse'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
/users/{user_id}/follow-status:
get:
@@ -5587,13 +5612,12 @@ paths:
- Follow
summary: Check relationship status between me and a user
operationId: getFollowStatus
- description:
- Returns follow/block status between the authenticated user and the
+ description: Returns follow/block status between the authenticated user and the
target.
parameters:
- - $ref: "#/components/parameters/UserId"
+ - $ref: '#/components/parameters/UserId'
responses:
- "200":
+ '200':
description: Relationship status.
content:
application/json:
@@ -5601,17 +5625,17 @@ paths:
type: object
properties:
data:
- $ref: "#/components/schemas/FollowStatus"
+ $ref: '#/components/schemas/FollowStatus'
example:
data:
is_following: true
is_followed_by: false
is_blocking: false
is_blocked_by: false
- "401":
- $ref: "#/components/responses/Unauthorized"
- "404":
- $ref: "#/components/responses/NotFound"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
+ $ref: '#/components/responses/NotFound'
/users/suggested:
get:
@@ -5619,21 +5643,20 @@ paths:
- Follow
summary: Get suggested users to follow
operationId: getSuggestedUsers
- description:
- Rule-based user suggestions (friends of friends, shared genres, popular
+ description: Rule-based user suggestions (friends of friends, shared genres, popular
users).
parameters:
- - $ref: "#/components/parameters/Limit"
- - $ref: "#/components/parameters/Offset"
+ - $ref: '#/components/parameters/Limit'
+ - $ref: '#/components/parameters/Offset'
responses:
- "200":
+ '200':
description: Paginated suggested users list.
content:
application/json:
schema:
- $ref: "#/components/schemas/UserListResponse"
- "401":
- $ref: "#/components/responses/Unauthorized"
+ $ref: '#/components/schemas/UserListResponse'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
/users/{user_id}/block:
post:
@@ -5643,42 +5666,42 @@ paths:
operationId: blockUser
description: Block a user. Removes existing follows in both directions.
parameters:
- - $ref: "#/components/parameters/UserId"
+ - $ref: '#/components/parameters/UserId'
responses:
- "200":
+ '200':
description: Already blocked — no change.
content:
application/json:
schema:
- $ref: "#/components/schemas/MessageResponse"
+ $ref: '#/components/schemas/MessageResponse'
example:
message: User is already blocked.
- "201":
+ '201':
description: User blocked successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/BlockResponse"
+ $ref: '#/components/schemas/BlockResponse'
example:
data:
blocker_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
blocked_id: b2c3d4e5-f6a7-8901-bcde-f12345678901
- created_at: "2025-03-05T14:00:00Z"
+ created_at: '2025-03-05T14:00:00Z'
message: User has been blocked.
- "400":
+ '400':
description: Cannot block yourself.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: BLOCK_SELF
message: You cannot block yourself.
- "401":
- $ref: "#/components/responses/Unauthorized"
- "404":
- $ref: "#/components/responses/NotFound"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
+ $ref: '#/components/responses/NotFound'
delete:
tags:
- Block
@@ -5686,14 +5709,14 @@ paths:
operationId: unblockUser
description: Unblock a user. Does not restore previous follows.
parameters:
- - $ref: "#/components/parameters/UserId"
+ - $ref: '#/components/parameters/UserId'
responses:
- "204":
+ '204':
description: Unblocked (or was not blocking) — no body.
- "401":
- $ref: "#/components/responses/Unauthorized"
- "404":
- $ref: "#/components/responses/NotFound"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
+ $ref: '#/components/responses/NotFound'
/users/me/blocked:
get:
@@ -5703,17 +5726,17 @@ paths:
operationId: getBlockedUsers
description: Returns paginated list of users blocked by the authenticated user.
parameters:
- - $ref: "#/components/parameters/Limit"
- - $ref: "#/components/parameters/Offset"
+ - $ref: '#/components/parameters/Limit'
+ - $ref: '#/components/parameters/Offset'
responses:
- "200":
+ '200':
description: Paginated blocked users list.
content:
application/json:
schema:
- $ref: "#/components/schemas/UserListResponse"
- "401":
- $ref: "#/components/responses/Unauthorized"
+ $ref: '#/components/schemas/UserListResponse'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
/users/me/following/search:
get:
@@ -5747,19 +5770,19 @@ paths:
minLength: 0
maxLength: 100
example: ahmed
- - $ref: "#/components/parameters/Limit"
- - $ref: "#/components/parameters/Offset"
+ - $ref: '#/components/parameters/Limit'
+ - $ref: '#/components/parameters/Offset'
responses:
- "200":
+ '200':
description: Search results from following list returned.
content:
application/json:
schema:
- $ref: "#/components/schemas/FollowingSearchResponse"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "500":
- $ref: "#/components/responses/InternalError"
+ $ref: '#/components/schemas/FollowingSearchResponse'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ $ref: '#/components/responses/InternalError'
# ==========================================================
# Module 6 — Engagement & Social Interactions
@@ -5772,35 +5795,35 @@ paths:
operationId: likeTrack
summary: Like a track
parameters:
- - $ref: "#/components/parameters/TrackId"
+ - $ref: '#/components/parameters/TrackId'
responses:
- "200":
+ '200':
description: Already liked — no change.
content:
application/json:
schema:
- $ref: "#/components/schemas/MessageResponse"
+ $ref: '#/components/schemas/MessageResponse'
example:
message: You have already liked this track.
- "201":
+ '201':
description: Track liked successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/LikeResponse"
+ $ref: '#/components/schemas/LikeResponse'
example:
data:
like_id: f1e2d3c4-b5a6-7890-abcd-ef1234567890
user_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
track_id: e5f6a7b8-c9d0-1234-efab-567890abcdef
- created_at: "2025-03-05T14:05:00Z"
+ created_at: '2025-03-05T14:05:00Z'
message: Track liked.
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
- $ref: "#/components/responses/Forbidden"
- "404":
- $ref: "#/components/responses/NotFound"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
delete:
tags:
- Likes
@@ -5808,14 +5831,14 @@ paths:
operationId: unlikeTrack
description: Remove a like from a track.
parameters:
- - $ref: "#/components/parameters/TrackId"
+ - $ref: '#/components/parameters/TrackId'
responses:
- "204":
+ '204':
description: Unliked (or was not liked) — no body.
- "401":
- $ref: "#/components/responses/Unauthorized"
- "404":
- $ref: "#/components/responses/NotFound"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
+ $ref: '#/components/responses/NotFound'
/tracks/{track_id}/likers:
get:
@@ -5825,22 +5848,22 @@ paths:
operationId: getTrackLikers
description: Returns paginated list of users who liked the track.
parameters:
- - $ref: "#/components/parameters/TrackId"
- - $ref: "#/components/parameters/Limit"
- - $ref: "#/components/parameters/Offset"
+ - $ref: '#/components/parameters/TrackId'
+ - $ref: '#/components/parameters/Limit'
+ - $ref: '#/components/parameters/Offset'
responses:
- "200":
+ '200':
description: Paginated likers list.
content:
application/json:
schema:
- $ref: "#/components/schemas/UserListResponse"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
- $ref: "#/components/responses/Forbidden"
- "404":
- $ref: "#/components/responses/NotFound"
+ $ref: '#/components/schemas/UserListResponse'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
/tracks/{track_id}/repost:
post:
@@ -5848,49 +5871,48 @@ paths:
- Reposts
summary: Repost a track
operationId: repostTrack
- description:
- Repost a track to the authenticated user's feed. Cannot repost own
+ description: Repost a track to the authenticated user's feed. Cannot repost own
track or private tracks.
parameters:
- - $ref: "#/components/parameters/TrackId"
+ - $ref: '#/components/parameters/TrackId'
responses:
- "200":
+ '200':
description: Already reposted — no change.
content:
application/json:
schema:
- $ref: "#/components/schemas/MessageResponse"
+ $ref: '#/components/schemas/MessageResponse'
example:
message: You have already reposted this track.
- "201":
+ '201':
description: Track reposted successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/RepostResponse"
+ $ref: '#/components/schemas/RepostResponse'
example:
data:
repost_id: d4e5f6a7-b8c9-0123-defa-b567890abcde
user_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
track_id: e5f6a7b8-c9d0-1234-efab-567890abcdef
- created_at: "2025-03-05T14:10:00Z"
+ created_at: '2025-03-05T14:10:00Z'
message: Track reposted.
- "400":
+ '400':
description: Cannot repost your own track.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: REPOST_OWN_TRACK
message: You cannot repost your own track.
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
- $ref: "#/components/responses/Forbidden"
- "404":
- $ref: "#/components/responses/NotFound"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
delete:
tags:
- Reposts
@@ -5898,14 +5920,14 @@ paths:
operationId: removeRepost
description: Remove a repost.
parameters:
- - $ref: "#/components/parameters/TrackId"
+ - $ref: '#/components/parameters/TrackId'
responses:
- "204":
+ '204':
description: Repost removed (or did not exist) — no body.
- "401":
- $ref: "#/components/responses/Unauthorized"
- "404":
- $ref: "#/components/responses/NotFound"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
+ $ref: '#/components/responses/NotFound'
/tracks/{track_id}/reposters:
get:
@@ -5915,22 +5937,22 @@ paths:
operationId: getTrackReposters
description: Returns paginated list of users who reposted the track.
parameters:
- - $ref: "#/components/parameters/TrackId"
- - $ref: "#/components/parameters/Limit"
- - $ref: "#/components/parameters/Offset"
+ - $ref: '#/components/parameters/TrackId'
+ - $ref: '#/components/parameters/Limit'
+ - $ref: '#/components/parameters/Offset'
responses:
- "200":
+ '200':
description: Paginated reposters list.
content:
application/json:
schema:
- $ref: "#/components/schemas/UserListResponse"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
- $ref: "#/components/responses/Forbidden"
- "404":
- $ref: "#/components/responses/NotFound"
+ $ref: '#/components/schemas/UserListResponse'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
/tracks/{track_id}/comments:
get:
@@ -5940,22 +5962,22 @@ paths:
operationId: getTrackComments
description: Returns paginated top-level comments ordered by timestamp.
parameters:
- - $ref: "#/components/parameters/TrackId"
- - $ref: "#/components/parameters/Limit"
- - $ref: "#/components/parameters/Offset"
+ - $ref: '#/components/parameters/TrackId'
+ - $ref: '#/components/parameters/Limit'
+ - $ref: '#/components/parameters/Offset'
responses:
- "200":
+ '200':
description: Paginated top-level comments.
content:
application/json:
schema:
- $ref: "#/components/schemas/CommentListResponse"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
- $ref: "#/components/responses/Forbidden"
- "404":
- $ref: "#/components/responses/NotFound"
+ $ref: '#/components/schemas/CommentListResponse'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
post:
tags:
- Comments
@@ -5963,28 +5985,28 @@ paths:
operationId: createComment
description: Post a timestamped comment on a track.
parameters:
- - $ref: "#/components/parameters/TrackId"
+ - $ref: '#/components/parameters/TrackId'
requestBody:
required: true
content:
application/json:
schema:
- $ref: "#/components/schemas/CreateCommentRequest"
+ $ref: '#/components/schemas/CreateCommentRequest'
responses:
- "201":
+ '201':
description: Comment created.
content:
application/json:
schema:
- $ref: "#/components/schemas/CommentCreatedResponse"
- "400":
- $ref: "#/components/responses/ValidationError"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
- $ref: "#/components/responses/Forbidden"
- "404":
- $ref: "#/components/responses/NotFound"
+ $ref: '#/components/schemas/CommentCreatedResponse'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
/comments/{comment_id}:
get:
@@ -5994,20 +6016,20 @@ paths:
operationId: getComment
description: Get a single comment by ID.
parameters:
- - $ref: "#/components/parameters/CommentId"
+ - $ref: '#/components/parameters/CommentId'
responses:
- "200":
+ '200':
description: Comment returned.
content:
application/json:
schema:
- $ref: "#/components/schemas/CommentSingleResponse"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
- $ref: "#/components/responses/Forbidden"
- "404":
- $ref: "#/components/responses/NotFound"
+ $ref: '#/components/schemas/CommentSingleResponse'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
patch:
tags:
- Comments
@@ -6015,55 +6037,54 @@ paths:
operationId: updateComment
description: Edit a comment body. Only the author can edit. Timestamp is immutable.
parameters:
- - $ref: "#/components/parameters/CommentId"
+ - $ref: '#/components/parameters/CommentId'
requestBody:
required: true
content:
application/json:
schema:
- $ref: "#/components/schemas/UpdateCommentRequest"
+ $ref: '#/components/schemas/UpdateCommentRequest'
responses:
- "200":
+ '200':
description: Comment updated.
content:
application/json:
schema:
- $ref: "#/components/schemas/CommentSingleResponse"
- "400":
- $ref: "#/components/responses/ValidationError"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
+ $ref: '#/components/schemas/CommentSingleResponse'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
description: Requester is not the comment author.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: PERMISSION_NOT_OWNER
message: You can only edit your own comments.
- "404":
- $ref: "#/components/responses/NotFound"
+ '404':
+ $ref: '#/components/responses/NotFound'
delete:
tags:
- Comments
summary: Delete a comment
operationId: deleteComment
- description:
- Delete a comment and all its replies. Author, track owner, or admin
+ description: Delete a comment and all its replies. Author, track owner, or admin
only.
parameters:
- - $ref: "#/components/parameters/CommentId"
+ - $ref: '#/components/parameters/CommentId'
responses:
- "204":
+ '204':
description: Comment (and its replies) deleted — no body.
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
- $ref: "#/components/responses/Forbidden"
- "404":
- $ref: "#/components/responses/NotFound"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
/comments/{comment_id}/replies:
get:
@@ -6073,30 +6094,30 @@ paths:
operationId: getCommentReplies
description: Returns paginated replies for a comment. One level deep only.
parameters:
- - $ref: "#/components/parameters/CommentId"
- - $ref: "#/components/parameters/Limit"
- - $ref: "#/components/parameters/Offset"
+ - $ref: '#/components/parameters/CommentId'
+ - $ref: '#/components/parameters/Limit'
+ - $ref: '#/components/parameters/Offset'
responses:
- "200":
+ '200':
description: Paginated replies.
content:
application/json:
schema:
- $ref: "#/components/schemas/CommentListResponse"
- "400":
+ $ref: '#/components/schemas/CommentListResponse'
+ '400':
description: Target comment is itself a reply — cannot list replies of a reply.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: REPLY_TO_REPLY
message: Replies cannot be nested more than one level deep.
- "401":
- $ref: "#/components/responses/Unauthorized"
- "404":
- $ref: "#/components/responses/NotFound"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
+ $ref: '#/components/responses/NotFound'
post:
tags:
- Comments
@@ -6104,26 +6125,26 @@ paths:
operationId: replyToComment
description: Reply to a top-level comment. Cannot reply to a reply.
parameters:
- - $ref: "#/components/parameters/CommentId"
+ - $ref: '#/components/parameters/CommentId'
requestBody:
required: true
content:
application/json:
schema:
- $ref: "#/components/schemas/CreateReplyRequest"
+ $ref: '#/components/schemas/CreateReplyRequest'
responses:
- "201":
+ '201':
description: Reply posted.
content:
application/json:
schema:
- $ref: "#/components/schemas/CommentCreatedResponse"
- "400":
+ $ref: '#/components/schemas/CommentCreatedResponse'
+ '400':
description: Nesting depth exceeded or validation error.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
examples:
reply_to_reply:
summary: Nesting depth exceeded
@@ -6140,12 +6161,12 @@ paths:
details:
- field: content
issue: Must be between 1 and 500 characters.
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
- $ref: "#/components/responses/Forbidden"
- "404":
- $ref: "#/components/responses/NotFound"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
/comments/{comment_id}/like:
post:
@@ -6155,34 +6176,34 @@ paths:
operationId: likeComment
description: Like a comment. Idempotent if already liked.
parameters:
- - $ref: "#/components/parameters/CommentId"
+ - $ref: '#/components/parameters/CommentId'
responses:
- "200":
+ '200':
description: Already liked — no change.
content:
application/json:
schema:
- $ref: "#/components/schemas/MessageResponse"
+ $ref: '#/components/schemas/MessageResponse'
example:
message: You have already liked this comment.
- "201":
+ '201':
description: Comment liked.
content:
application/json:
schema:
- $ref: "#/components/schemas/CommentLikeResponse"
+ $ref: '#/components/schemas/CommentLikeResponse'
example:
data:
user_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
comment_id: c1d2e3f4-a5b6-7890-abcd-ef1234567890
- created_at: "2025-03-05T14:20:00Z"
+ created_at: '2025-03-05T14:20:00Z'
message: Comment liked.
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
- $ref: "#/components/responses/Forbidden"
- "404":
- $ref: "#/components/responses/NotFound"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
delete:
tags:
- Likes
@@ -6190,14 +6211,14 @@ paths:
operationId: unlikeComment
description: Remove a like from a comment.
parameters:
- - $ref: "#/components/parameters/CommentId"
+ - $ref: '#/components/parameters/CommentId'
responses:
- "204":
+ '204':
description: Unliked (or was not liked) — no body.
- "401":
- $ref: "#/components/responses/Unauthorized"
- "404":
- $ref: "#/components/responses/NotFound"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
+ $ref: '#/components/responses/NotFound'
# ==========================================================
# Module 6 — Playlist Likes & Reposts
@@ -6211,35 +6232,35 @@ paths:
operationId: likePlaylist
description: Like a playlist. Idempotent if already liked.
parameters:
- - $ref: "#/components/parameters/PlaylistId"
+ - $ref: '#/components/parameters/PlaylistId'
responses:
- "200":
+ '200':
description: Already liked — no change.
content:
application/json:
schema:
- $ref: "#/components/schemas/MessageResponse"
+ $ref: '#/components/schemas/MessageResponse'
example:
message: You have already liked this playlist.
- "201":
+ '201':
description: Playlist liked successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/PlaylistLikeResponse"
+ $ref: '#/components/schemas/PlaylistLikeResponse'
example:
data:
like_id: f1e2d3c4-b5a6-7890-abcd-ef1234567890
user_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
playlist_id: 8d5a8f6c-7b4a-4c7a-9c25-9a9f1e3a12aa
- created_at: "2025-03-05T14:05:00Z"
+ created_at: '2025-03-05T14:05:00Z'
message: Playlist liked.
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
- $ref: "#/components/responses/Forbidden"
- "404":
- $ref: "#/components/responses/NotFound"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
delete:
tags:
- Likes
@@ -6247,14 +6268,14 @@ paths:
operationId: unlikePlaylist
description: Remove a like from a playlist.
parameters:
- - $ref: "#/components/parameters/PlaylistId"
+ - $ref: '#/components/parameters/PlaylistId'
responses:
- "204":
+ '204':
description: Unliked (or was not liked) — no body.
- "401":
- $ref: "#/components/responses/Unauthorized"
- "404":
- $ref: "#/components/responses/NotFound"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
+ $ref: '#/components/responses/NotFound'
/playlists/{playlist_id}/likers:
get:
@@ -6264,22 +6285,22 @@ paths:
operationId: getPlaylistLikers
description: Returns paginated list of users who liked the playlist.
parameters:
- - $ref: "#/components/parameters/PlaylistId"
- - $ref: "#/components/parameters/Limit"
- - $ref: "#/components/parameters/Offset"
+ - $ref: '#/components/parameters/PlaylistId'
+ - $ref: '#/components/parameters/Limit'
+ - $ref: '#/components/parameters/Offset'
responses:
- "200":
+ '200':
description: Paginated likers list.
content:
application/json:
schema:
- $ref: "#/components/schemas/UserListResponse"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
- $ref: "#/components/responses/Forbidden"
- "404":
- $ref: "#/components/responses/NotFound"
+ $ref: '#/components/schemas/UserListResponse'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
/playlists/{playlist_id}/repost:
post:
@@ -6289,45 +6310,45 @@ paths:
operationId: repostPlaylist
description: Repost a playlist to the authenticated user's profile. Cannot repost own playlist or private playlists.
parameters:
- - $ref: "#/components/parameters/PlaylistId"
+ - $ref: '#/components/parameters/PlaylistId'
responses:
- "200":
+ '200':
description: Already reposted — no change.
content:
application/json:
schema:
- $ref: "#/components/schemas/MessageResponse"
+ $ref: '#/components/schemas/MessageResponse'
example:
message: You have already reposted this playlist.
- "201":
+ '201':
description: Playlist reposted successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/PlaylistRepostResponse"
+ $ref: '#/components/schemas/PlaylistRepostResponse'
example:
data:
repost_id: d4e5f6a7-b8c9-0123-defa-b567890abcde
user_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
playlist_id: 8d5a8f6c-7b4a-4c7a-9c25-9a9f1e3a12aa
- created_at: "2025-03-05T14:10:00Z"
+ created_at: '2025-03-05T14:10:00Z'
message: Playlist reposted.
- "400":
+ '400':
description: Cannot repost your own playlist.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: REPOST_OWN_PLAYLIST
message: You cannot repost your own playlist.
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
- $ref: "#/components/responses/Forbidden"
- "404":
- $ref: "#/components/responses/NotFound"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
delete:
tags:
- Reposts
@@ -6335,14 +6356,14 @@ paths:
operationId: removePlaylistRepost
description: Remove a repost of a playlist.
parameters:
- - $ref: "#/components/parameters/PlaylistId"
+ - $ref: '#/components/parameters/PlaylistId'
responses:
- "204":
+ '204':
description: Repost removed (or did not exist) — no body.
- "401":
- $ref: "#/components/responses/Unauthorized"
- "404":
- $ref: "#/components/responses/NotFound"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
+ $ref: '#/components/responses/NotFound'
/playlists/{playlist_id}/reposters:
get:
@@ -6352,22 +6373,22 @@ paths:
operationId: getPlaylistReposters
description: Returns paginated list of users who reposted the playlist.
parameters:
- - $ref: "#/components/parameters/PlaylistId"
- - $ref: "#/components/parameters/Limit"
- - $ref: "#/components/parameters/Offset"
+ - $ref: '#/components/parameters/PlaylistId'
+ - $ref: '#/components/parameters/Limit'
+ - $ref: '#/components/parameters/Offset'
responses:
- "200":
+ '200':
description: Paginated reposters list.
content:
application/json:
schema:
- $ref: "#/components/schemas/UserListResponse"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
- $ref: "#/components/responses/Forbidden"
- "404":
- $ref: "#/components/responses/NotFound"
+ $ref: '#/components/schemas/UserListResponse'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
+ '404':
+ $ref: '#/components/responses/NotFound'
components:
# ----------------------------------------------------------
@@ -6448,13 +6469,13 @@ components:
location:
type: string
nullable: true
- example: "Cairo, EG"
+ example: 'Cairo, EG'
gender:
allOf:
- - $ref: "#/components/schemas/GenderType"
+ - $ref: '#/components/schemas/GenderType'
nullable: true
role:
- $ref: "#/components/schemas/UserRole"
+ $ref: '#/components/schemas/UserRole'
profile_picture:
type: string
nullable: true
@@ -6469,7 +6490,7 @@ components:
date_of_birth:
type: string
format: date
- example: "2003-08-17"
+ example: '2003-08-17'
is_verified:
type: boolean
default: false
@@ -6493,8 +6514,7 @@ components:
AuthRegisterRequest:
type: object
- required:
- [email, password, display_name, captcha_token, gender, date_of_birth]
+ required: [email, password, display_name, captcha_token, gender, date_of_birth]
properties:
email:
type: string
@@ -6510,11 +6530,11 @@ components:
maxLength: 50
example: cairokee
gender:
- $ref: "#/components/schemas/GenderType"
+ $ref: '#/components/schemas/GenderType'
date_of_birth:
type: string
format: date
- example: "2003-08-17"
+ example: '2003-08-17'
captcha_token:
type: string
description: CAPTCHA token provided by the client.
@@ -6524,7 +6544,7 @@ components:
type: object
properties:
data:
- $ref: "#/components/schemas/AuthRegisterResponseData"
+ $ref: '#/components/schemas/AuthRegisterResponseData'
message:
type: string
example: Account created. Please verify your email.
@@ -6545,31 +6565,21 @@ components:
type: string
example: cairokee
gender:
- $ref: "#/components/schemas/GenderType"
+ $ref: '#/components/schemas/GenderType'
role:
- $ref: "#/components/schemas/UserRole"
+ $ref: '#/components/schemas/UserRole'
is_verified:
type: boolean
example: false
date_of_birth:
type: string
format: date
- example: "2003-08-17"
+ example: '2003-08-17'
created_at:
type: string
format: date-time
example: 2026-03-05T12:00:00Z
- required:
- [
- user_id,
- email,
- display_name,
- role,
- is_verified,
- created_at,
- gender,
- date_of_birth,
- ]
+ required: [user_id, email, display_name, role, is_verified, created_at, gender, date_of_birth]
AuthVerifyEmailRequest:
type: object
@@ -6600,7 +6610,7 @@ components:
required: [data]
properties:
data:
- $ref: "#/components/schemas/AccessTokenResponse"
+ $ref: '#/components/schemas/AccessTokenResponse'
message:
type: string
example: Email verified successfully.
@@ -6663,11 +6673,11 @@ components:
example: cairokee
gender:
allOf:
- - $ref: "#/components/schemas/GenderType"
+ - $ref: '#/components/schemas/GenderType'
nullable: true
description: Gender. May be null for OAuth users until profile completion.
role:
- $ref: "#/components/schemas/UserRole"
+ $ref: '#/components/schemas/UserRole'
is_verified:
type: boolean
example: true
@@ -6687,7 +6697,7 @@ components:
description: Access token lifetime in seconds (15 minutes).
example: 900
user:
- $ref: "#/components/schemas/UserSummary"
+ $ref: '#/components/schemas/UserSummary'
is_new_user:
type: boolean
description: |
@@ -6701,7 +6711,7 @@ components:
required: [data]
properties:
data:
- $ref: "#/components/schemas/AuthLoginResponseData"
+ $ref: '#/components/schemas/AuthLoginResponseData'
message:
type: string
example: Logged in successfully.
@@ -6711,7 +6721,7 @@ components:
required: [data]
properties:
data:
- $ref: "#/components/schemas/AccessTokenResponse"
+ $ref: '#/components/schemas/AccessTokenResponse'
message:
type: string
example: Access token refreshed successfully.
@@ -6779,9 +6789,9 @@ components:
example: soundcloudummy
gender:
allOf:
- - $ref: "#/components/schemas/GenderType"
+ - $ref: '#/components/schemas/GenderType'
role:
- $ref: "#/components/schemas/UserRole"
+ $ref: '#/components/schemas/UserRole'
is_verified:
type: boolean
example: true
@@ -6797,11 +6807,11 @@ components:
maxLength: 50
example: soundcloudummy
gender:
- $ref: "#/components/schemas/GenderType"
+ $ref: '#/components/schemas/GenderType'
date_of_birth:
type: string
format: date
- example: "2003-08-17"
+ example: '2003-08-17'
bio:
type: string
nullable: true
@@ -6811,7 +6821,7 @@ components:
type: string
nullable: true
maxLength: 100
- example: "Cairo, EG"
+ example: 'Cairo, EG'
additionalProperties: false
AuthGoogleLoginResponseData:
@@ -6834,7 +6844,7 @@ components:
complete missing profile fields.
example: true
user:
- $ref: "#/components/schemas/AuthGoogleUserSummary"
+ $ref: '#/components/schemas/AuthGoogleUserSummary'
required: [access_token, token_type, expires_in, is_new_user, user]
AuthGoogleLoginResponse:
@@ -6842,7 +6852,7 @@ components:
required: [data]
properties:
data:
- $ref: "#/components/schemas/AuthGoogleLoginResponseData"
+ $ref: '#/components/schemas/AuthGoogleLoginResponseData'
message:
type: string
example: Logged in successfully with Google.
@@ -6873,7 +6883,7 @@ components:
type: boolean
example: true
user:
- $ref: "#/components/schemas/User"
+ $ref: '#/components/schemas/User'
message:
type: string
example: Profile onboarding completed successfully.
@@ -6937,7 +6947,7 @@ components:
username:
type: string
nullable: true
- pattern: "^[a-z0-9_-]+$"
+ pattern: '^[a-z0-9_-]+$'
minLength: 3
maxLength: 30
description: Unique URL-safe handle. Profile URL is https://rythmify.com/{username}
@@ -6952,13 +6962,13 @@ components:
location:
type: string
nullable: true
- example: "Cairo, EG"
+ example: 'Cairo, EG'
gender:
allOf:
- - $ref: "#/components/schemas/GenderType"
+ - $ref: '#/components/schemas/GenderType'
nullable: true
role:
- $ref: "#/components/schemas/UserRole"
+ $ref: '#/components/schemas/UserRole'
profile_picture:
type: string
nullable: true
@@ -7003,8 +7013,7 @@ components:
example: b2c3d4e5-f6a7-8901-bcde-f12345678901
platform:
type: string
- enum:
- [instagram, twitter, youtube, tiktok, soundcloud, website, other]
+ enum: [instagram, twitter, youtube, tiktok, soundcloud, website, other]
example: instagram
url:
type: string
@@ -7036,7 +7045,7 @@ components:
avatar:
type: string
format: binary
- description: "Image file — JPEG, PNG, or WebP — max 5 MB."
+ description: 'Image file — JPEG, PNG, or WebP — max 5 MB.'
CoverUploadRequest:
type: object
@@ -7045,7 +7054,7 @@ components:
cover:
type: string
format: binary
- description: "Image file — JPEG, PNG, or WebP — max 10 MB."
+ description: 'Image file — JPEG, PNG, or WebP — max 10 MB.'
AddWebProfileRequest:
type: object
@@ -7053,8 +7062,7 @@ components:
properties:
platform:
type: string
- enum:
- [instagram, twitter, youtube, tiktok, soundcloud, website, other]
+ enum: [instagram, twitter, youtube, tiktok, soundcloud, website, other]
example: instagram
url:
type: string
@@ -7067,7 +7075,7 @@ components:
properties:
is_private:
type: boolean
- description: "true = make profile private | false = make profile public"
+ description: 'true = make profile private | false = make profile public'
example: true
ReplaceGenresRequest:
@@ -7077,7 +7085,7 @@ components:
genres:
type: array
maxItems: 10
- description: "Full replacement list. Max 10 items. Send [] to clear all."
+ description: 'Full replacement list. Max 10 items. Send [] to clear all.'
example: [hip-hop, electronic, jazz]
items:
type: string
@@ -7090,7 +7098,7 @@ components:
type: object
properties:
data:
- $ref: "#/components/schemas/PublicUser"
+ $ref: '#/components/schemas/PublicUser'
required: [data]
OwnProfileResponse:
@@ -7100,7 +7108,7 @@ components:
private fields. Returned after a successful role switch.
properties:
data:
- $ref: "#/components/schemas/User"
+ $ref: '#/components/schemas/User'
message:
type: string
example: Role updated successfully.
@@ -7144,14 +7152,14 @@ components:
data:
type: array
items:
- $ref: "#/components/schemas/WebProfile"
+ $ref: '#/components/schemas/WebProfile'
required: [data]
WebProfileCreatedResponse:
type: object
properties:
data:
- $ref: "#/components/schemas/WebProfile"
+ $ref: '#/components/schemas/WebProfile'
message:
type: string
example: Web profile link created.
@@ -7207,7 +7215,7 @@ components:
username:
type: string
nullable: true
- pattern: "^[a-z0-9_-]+$"
+ pattern: '^[a-z0-9_-]+$'
minLength: 3
maxLength: 30
description: Unique URL-safe handle. Profile URL is https://rythmify.com/{username}
@@ -7238,11 +7246,11 @@ components:
example: EG
gender:
allOf:
- - $ref: "#/components/schemas/GenderType"
+ - $ref: '#/components/schemas/GenderType'
nullable: true
description: Gender. May be null for OAuth users until profile completion.
role:
- $ref: "#/components/schemas/UserRole"
+ $ref: '#/components/schemas/UserRole'
profile_picture:
type: string
nullable: true
@@ -7260,7 +7268,7 @@ components:
type: string
nullable: true
format: date
- example: "2003-08-17"
+ example: '2003-08-17'
is_verified:
type: boolean
default: false
@@ -7273,12 +7281,12 @@ components:
created_at:
type: string
format: date-time
- example: "2025-01-01T12:00:00Z"
+ example: '2025-01-01T12:00:00Z'
updated_at:
type: string
nullable: true
format: date-time
- example: "2026-03-12T10:00:00Z"
+ example: '2026-03-12T10:00:00Z'
required:
- id
- email
@@ -7294,7 +7302,7 @@ components:
type: object
properties:
data:
- $ref: "#/components/schemas/OwnUser"
+ $ref: '#/components/schemas/OwnUser'
message:
type: string
example: Profile updated successfully.
@@ -7311,7 +7319,7 @@ components:
example: Soundwave
username:
type: string
- pattern: "^[a-z0-9_-]+$"
+ pattern: '^[a-z0-9_-]+$'
minLength: 3
maxLength: 30
example: soundwave_cairo
@@ -7351,9 +7359,9 @@ components:
type: string
nullable: true
format: date
- example: "2003-08-17"
+ example: '2003-08-17'
gender:
- $ref: "#/components/schemas/GenderType"
+ $ref: '#/components/schemas/GenderType'
additionalProperties: false
# ----------------------------------------------------------
@@ -7370,11 +7378,11 @@ components:
rss_language:
type: string
description: BCP 47 language code
- example: "en"
+ example: 'en'
rss_category:
type: string
nullable: true
- example: "Music"
+ example: 'Music'
rss_explicit:
type: boolean
description: Mark RSS feed as explicit
@@ -7396,7 +7404,7 @@ components:
type: object
properties:
data:
- $ref: "#/components/schemas/ContentSettings"
+ $ref: '#/components/schemas/ContentSettings'
required: [data]
UpdateContentSettingsRequest:
@@ -7462,7 +7470,7 @@ components:
type: object
properties:
data:
- $ref: "#/components/schemas/PrivacySettings"
+ $ref: '#/components/schemas/PrivacySettings'
required: [data]
UpdatePrivacySettingsRequest:
@@ -7524,7 +7532,7 @@ components:
items:
type: array
items:
- $ref: "#/components/schemas/FollowingSearchResult"
+ $ref: '#/components/schemas/FollowingSearchResult'
total:
type: integer
description: Total number of following users matching the query.
@@ -7556,12 +7564,12 @@ components:
id:
type: string
format: uuid
- description: Unique tag ID — use this when attaching tags to a track upload.
- example: "aaa11111-bbbb-cccc-dddd-eeeeeeeeeeee"
+ description: Unique tag ID.
+ example: 'aaa11111-bbbb-cccc-dddd-eeeeeeeeeeee'
name:
type: string
- description: Human-readable tag name (always lowercase).
- example: "chill"
+ description: Human-readable unique tag name used in track upload/update requests.
+ example: 'chill'
required: [id, name]
TagListData:
@@ -7571,7 +7579,7 @@ components:
type: array
description: All available tags, ordered alphabetically.
items:
- $ref: "#/components/schemas/Tag"
+ $ref: '#/components/schemas/Tag'
required: [items]
TagListResponse:
@@ -7579,11 +7587,46 @@ components:
required: [data]
properties:
data:
- $ref: "#/components/schemas/TagListData"
+ $ref: '#/components/schemas/TagListData'
message:
type: string
example: Tags fetched successfully.
+ Genre:
+ type: object
+ description: A single platform genre.
+ properties:
+ id:
+ type: string
+ format: uuid
+ description: Unique genre ID.
+ example: '11111111-2222-3333-4444-555555555555'
+ name:
+ type: string
+ description: Human-readable unique genre name used in track upload/update requests.
+ example: 'Pop'
+ required: [id, name]
+
+ GenreListData:
+ type: object
+ properties:
+ items:
+ type: array
+ description: All available genres, ordered alphabetically.
+ items:
+ $ref: '#/components/schemas/Genre'
+ required: [items]
+
+ GenreListResponse:
+ type: object
+ required: [data]
+ properties:
+ data:
+ $ref: '#/components/schemas/GenreListData'
+ message:
+ type: string
+ example: Genres fetched successfully.
+
Track:
type: object
description: Full track object (Not all fields are required)
@@ -7594,27 +7637,22 @@ components:
example: e5f6a7b8-c9d0-1234-efab-567890abcdef
title:
type: string
- example: "Summer Vibes"
+ example: 'Summer Vibes'
description:
type: string
nullable: true
- example: "A chill electronic track"
+ example: 'A chill electronic track'
genre:
type: string
nullable: true
- example: "Electronic"
+ example: 'Electronic'
tags:
type: array
- description: List of tag IDs selected from GET /tags.
+ description: List of tag names attached to the track.
maxItems: 10
items:
type: string
- format: uuid
- example:
- [
- "aaa11111-bbbb-cccc-dddd-eeeeeeeeeeee",
- "bbb22222-cccc-dddd-eeee-ffffffffffff",
- ]
+ example: ['chill', 'ambient']
duration:
type: integer
nullable: true
@@ -7631,7 +7669,7 @@ components:
description: Audio bitrate in kbps. Null until processing completes.
example: 320
status:
- $ref: "#/components/schemas/TrackStatus"
+ $ref: '#/components/schemas/TrackStatus'
is_public:
type: boolean
description: Whether the track is publicly visible and searchable.
@@ -7661,71 +7699,71 @@ components:
type: string
format: uri
description: Original uploaded file URL (owner-only access).
- example: "https://cdn.rythmify.com/tracks/e5f6a7b8/original.mp3"
+ example: 'https://cdn.rythmify.com/tracks/e5f6a7b8/original.mp3'
stream_url:
type: string
format: uri
nullable: true
description: Transcoded stream URL. Null while processing.
- example: "https://cdn.rythmify.com/tracks/e5f6a7b8/stream.mp3"
+ example: 'https://cdn.rythmify.com/tracks/e5f6a7b8/stream.mp3'
preview_url:
type: string
format: uri
nullable: true
description: 30-second preview clip URL. Null while processing.
- example: "https://cdn.rythmify.com/tracks/e5f6a7b8/preview.mp3"
+ example: 'https://cdn.rythmify.com/tracks/e5f6a7b8/preview.mp3'
waveform_url:
type: string
format: uri
nullable: true
description: URL to the waveform data JSON. Null while processing.
- example: "https://cdn.rythmify.com/tracks/e5f6a7b8/waveform.json"
+ example: 'https://cdn.rythmify.com/tracks/e5f6a7b8/waveform.json'
created_at:
type: string
format: date-time
- example: "2026-03-06T10:00:00Z"
+ example: '2026-03-06T10:00:00Z'
updated_at:
type: string
format: date-time
nullable: true
- example: "2026-03-06T12:00:00Z"
+ example: '2026-03-06T12:00:00Z'
# Advanced metadata fields
artists:
type: string
nullable: true
description: Comma-separated featured artist names
- example: "Ahmed Sami, DJ Karim"
+ example: 'Ahmed Sami, DJ Karim'
buy_link:
type: string
format: uri
nullable: true
- example: "https://example.com/buy/track"
+ example: 'https://example.com/buy/track'
record_label:
type: string
nullable: true
maxLength: 200
- example: "Cairo Records"
+ example: 'Cairo Records'
publisher:
type: string
nullable: true
maxLength: 200
- example: "Sami Publishing"
+ example: 'Sami Publishing'
release_date:
type: string
format: date
nullable: true
- example: "2026-03-01"
+ example: '2026-03-01'
isrc:
type: string
nullable: true
maxLength: 12
- example: "EGA262600001"
+ example: 'EGA262600001'
p_line:
type: string
nullable: true
maxLength: 200
- example: "℗ 2026 Cairo Records"
+ example: '℗ 2026 Cairo Records'
explicit_content:
type: boolean
default: false
@@ -7772,8 +7810,8 @@ components:
type: string
minLength: 2
maxLength: 2
- pattern: "^[A-Z]{2}$"
- example: ["US", "UK"]
+ pattern: '^[A-Z]{2}$'
+ example: ['US', 'UK']
required:
- id
@@ -7829,26 +7867,28 @@ components:
type: string
minLength: 1
maxLength: 200
- example: "Summer Vibes"
+ example: 'Summer Vibes'
description:
type: string
nullable: true
maxLength: 2000
- example: "A chill electronic track."
+ example: 'A chill electronic track.'
genre:
type: string
nullable: true
maxLength: 100
- example: "Electronic"
+ example: 'Electronic'
tags:
type: array
- description: Tag IDs from GET /tags. Maximum 10.
+ description: |
+ Tag names to attach to the track.
+ - Each item must be a string.
+ - Names are matched case-insensitively by the backend.
+ - Maximum 10 tags allowed.
maxItems: 10
items:
type: string
- format: uuid
- example:
- - "aaa11111-bbbb-cccc-dddd-eeeeeeeeeeee"
+ example: ['chill', 'ambient']
is_public:
type: boolean
description: Whether the track is publicly visible. Defaults to true.
@@ -7862,7 +7902,7 @@ components:
type: string
nullable: true
description: Comma-separated featured artist names (e.g., "Ahmed Sami, DJ Karim")
- example: "Ahmed Sami, DJ Karim"
+ example: 'Ahmed Sami, DJ Karim'
# ── Advanced metadata ─────────────────────────────────
buy_link:
@@ -7870,17 +7910,17 @@ components:
format: uri
nullable: true
description: External purchase/download link
- example: "https://example.com/buy/summer-vibes"
+ example: 'https://example.com/buy/summer-vibes'
record_label:
type: string
nullable: true
maxLength: 200
- example: "Cairo Records"
+ example: 'Cairo Records'
publisher:
type: string
nullable: true
maxLength: 200
- example: "Sami Publishing"
+ example: 'Sami Publishing'
release_date:
type: string
format: date
@@ -7888,17 +7928,17 @@ components:
description: |
Public release date. If omitted, defaults to today.
Can be past, present, or future.
- example: "2026-03-01"
+ example: '2026-03-01'
isrc:
type: string
nullable: true
maxLength: 12
- example: "EGA262600001"
+ example: 'EGA262600001'
p_line:
type: string
nullable: true
maxLength: 200
- example: "℗ 2026 Cairo Records"
+ example: '℗ 2026 Cairo Records'
explicit_content:
type: boolean
description: Mark as explicit (contains explicit language/themes)
@@ -7965,9 +8005,9 @@ components:
type: string
minLength: 2
maxLength: 2
- pattern: "^[A-Z]{2}$"
+ pattern: '^[A-Z]{2}$'
maxItems: 250
- example: ["US", "UK", "CA"]
+ example: ['US', 'UK', 'CA']
required:
- audio_file
@@ -7984,7 +8024,7 @@ components:
type: string
minLength: 1
maxLength: 200
- example: "Summer Vibes (Remastered)"
+ example: 'Summer Vibes (Remastered)'
description:
type: string
nullable: true
@@ -7995,11 +8035,13 @@ components:
maxLength: 100
tags:
type: array
- description: Replaces the full tag list. Pass [] to clear all tags.
+ description: |
+ Replaces the full tag list using tag names.
+ Pass [] to clear all tags.
maxItems: 10
items:
type: string
- format: uuid
+ example: ['chill', 'ambient']
is_public:
type: boolean
nullable: true
@@ -8010,7 +8052,7 @@ components:
artists:
type: string
nullable: true
- example: "Ahmed Sami, DJ Karim"
+ example: 'Ahmed Sami, DJ Karim'
buy_link:
type: string
format: uri
@@ -8078,7 +8120,7 @@ components:
type: string
minLength: 2
maxLength: 2
- pattern: "^[A-Z]{2}$"
+ pattern: '^[A-Z]{2}$'
maxItems: 250
UpdateTrackVisibilityRequest:
@@ -8086,7 +8128,7 @@ components:
properties:
is_public:
type: boolean
- description: "`true` to make the track public; `false` to make it private (owner-only)."
+ description: '`true` to make the track public; `false` to make it private (owner-only).'
example: false
required:
- is_public
@@ -8099,10 +8141,10 @@ components:
type: object
properties:
data:
- $ref: "#/components/schemas/Track"
+ $ref: '#/components/schemas/Track'
message:
type: string
- example: "Track uploaded successfully."
+ example: 'Track uploaded successfully.'
required:
- data
- message
@@ -8111,7 +8153,7 @@ components:
type: object
properties:
data:
- $ref: "#/components/schemas/Track"
+ $ref: '#/components/schemas/Track'
required:
- data
@@ -8121,9 +8163,9 @@ components:
data:
type: array
items:
- $ref: "#/components/schemas/TrackSummary"
+ $ref: '#/components/schemas/TrackSummary'
pagination:
- $ref: "#/components/schemas/Pagination"
+ $ref: '#/components/schemas/Pagination'
required:
- data
- pagination
@@ -8137,12 +8179,12 @@ components:
track_id:
type: string
format: uuid
- example: "e5f6a7b8-c9d0-1234-efab-567890abcdef"
+ example: 'e5f6a7b8-c9d0-1234-efab-567890abcdef'
stream_url:
type: string
format: uri
description: Transcoded streaming URL ready for playback.
- example: "https://cdn.rythmify.com/tracks/e5f6a7b8/stream.mp3"
+ example: 'https://cdn.rythmify.com/tracks/e5f6a7b8/stream.mp3'
required:
- track_id
- stream_url
@@ -8158,7 +8200,7 @@ components:
track_id:
type: string
format: uuid
- example: "e5f6a7b8-c9d0-1234-efab-567890abcdef"
+ example: 'e5f6a7b8-c9d0-1234-efab-567890abcdef'
peaks:
type: array
description: Normalized amplitude values in `[0.0, 1.0]`.
@@ -8182,7 +8224,7 @@ components:
type: integer
example: 1
user:
- $ref: "#/components/schemas/PublicUser"
+ $ref: '#/components/schemas/PublicUser'
play_count:
type: integer
description: |
@@ -8192,7 +8234,7 @@ components:
type: string
format: date-time
description: Timestamp of this user's first ever play of the track.
- example: "2026-01-15T09:12:00Z"
+ example: '2026-01-15T09:12:00Z'
required:
- rank
- user
@@ -8212,10 +8254,10 @@ components:
type: array
maxItems: 5
items:
- $ref: "#/components/schemas/FanLeaderboardEntry"
+ $ref: '#/components/schemas/FanLeaderboardEntry'
my_entry:
allOf:
- - $ref: "#/components/schemas/FanLeaderboardEntry"
+ - $ref: '#/components/schemas/FanLeaderboardEntry'
nullable: true
description: |
The authenticated user's own rank and count, even if outside top 5.
@@ -8235,7 +8277,7 @@ components:
track_id:
type: string
format: uuid
- example: "e5f6a7b8-c9d0-1234-efab-567890abcdef"
+ example: 'e5f6a7b8-c9d0-1234-efab-567890abcdef'
is_public:
type: boolean
example: false
@@ -8244,7 +8286,7 @@ components:
- is_public
message:
type: string
- example: "Track visibility updated."
+ example: 'Track visibility updated.'
required:
- data
- message
@@ -8322,7 +8364,7 @@ components:
type: string
format: uuid
description: UUID of the track currently loaded in the player.
- example: "e5f6a7b8-c9d0-1234-efab-567890abcdef"
+ example: 'e5f6a7b8-c9d0-1234-efab-567890abcdef'
position_seconds:
type: number
format: float
@@ -8343,8 +8385,8 @@ components:
type: string
format: uuid
example:
- - "e5f6a7b8-c9d0-1234-efab-567890abcdef"
- - "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
+ - 'e5f6a7b8-c9d0-1234-efab-567890abcdef'
+ - 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
required:
- track_id
- position_seconds
@@ -8363,9 +8405,9 @@ components:
track_id:
type: string
format: uuid
- example: "e5f6a7b8-c9d0-1234-efab-567890abcdef"
+ example: 'e5f6a7b8-c9d0-1234-efab-567890abcdef'
access_level:
- $ref: "#/components/schemas/PlaybackAccessLevel"
+ $ref: '#/components/schemas/PlaybackAccessLevel'
url:
type: string
format: uri
@@ -8373,7 +8415,7 @@ components:
Stream or preview URL depending on `access_level`.
- `full` → `stream_url`
- `preview` → `preview_url`
- example: "https://cdn.rythmify.com/tracks/e5f6a7b8/stream.mp3"
+ example: 'https://cdn.rythmify.com/tracks/e5f6a7b8/stream.mp3'
required:
- track_id
- access_level
@@ -8391,14 +8433,14 @@ components:
track_id:
type: string
format: uuid
- example: "e5f6a7b8-c9d0-1234-efab-567890abcdef"
+ example: 'e5f6a7b8-c9d0-1234-efab-567890abcdef'
access_level:
- $ref: "#/components/schemas/PlaybackAccessLevel"
+ $ref: '#/components/schemas/PlaybackAccessLevel'
block_reason:
description: Present only when `access_level` is `blocked`. Null otherwise.
nullable: true
allOf:
- - $ref: "#/components/schemas/PlaybackBlockReason"
+ - $ref: '#/components/schemas/PlaybackBlockReason'
required:
- track_id
- access_level
@@ -8412,7 +8454,7 @@ components:
track_id:
type: string
format: uuid
- example: "e5f6a7b8-c9d0-1234-efab-567890abcdef"
+ example: 'e5f6a7b8-c9d0-1234-efab-567890abcdef'
position_seconds:
type: number
format: float
@@ -8427,12 +8469,12 @@ components:
type: string
format: uuid
example:
- - "e5f6a7b8-c9d0-1234-efab-567890abcdef"
- - "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
+ - 'e5f6a7b8-c9d0-1234-efab-567890abcdef'
+ - 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
saved_at:
type: string
format: date-time
- example: "2026-03-06T14:22:00Z"
+ example: '2026-03-06T14:22:00Z'
required:
- track_id
- position_seconds
@@ -8444,7 +8486,7 @@ components:
data:
nullable: true
allOf:
- - $ref: "#/components/schemas/PlayerState"
+ - $ref: '#/components/schemas/PlayerState'
required:
- data
@@ -8455,13 +8497,13 @@ components:
id:
type: string
format: uuid
- example: "f1a2b3c4-d5e6-7890-abcd-ef1234567890"
+ example: 'f1a2b3c4-d5e6-7890-abcd-ef1234567890'
track:
- $ref: "#/components/schemas/TrackSummary"
+ $ref: '#/components/schemas/TrackSummary'
played_at:
type: string
format: date-time
- example: "2026-03-06T11:45:00Z"
+ example: '2026-03-06T11:45:00Z'
required:
- id
- track
@@ -8472,11 +8514,11 @@ components:
description: A deduplicated recently played entry — one per track, most recent play only.
properties:
track:
- $ref: "#/components/schemas/TrackSummary"
+ $ref: '#/components/schemas/TrackSummary'
last_played_at:
type: string
format: date-time
- example: "2026-03-06T11:45:00Z"
+ example: '2026-03-06T11:45:00Z'
required:
- track
- last_played_at
@@ -8489,7 +8531,7 @@ components:
type: array
maxItems: 20
items:
- $ref: "#/components/schemas/RecentlyPlayedEntry"
+ $ref: '#/components/schemas/RecentlyPlayedEntry'
required:
- data
@@ -8500,9 +8542,9 @@ components:
data:
type: array
items:
- $ref: "#/components/schemas/ListeningHistoryEntry"
+ $ref: '#/components/schemas/ListeningHistoryEntry'
pagination:
- $ref: "#/components/schemas/Pagination"
+ $ref: '#/components/schemas/Pagination'
required:
- data
- pagination
@@ -8519,13 +8561,13 @@ components:
description: Playlist name.
minLength: 1
maxLength: 100
- example: "Gym Mix"
+ example: 'Gym Mix'
description:
type: string
description: Optional playlist description.
maxLength: 500
nullable: true
- example: "High energy tracks"
+ example: 'High energy tracks'
is_public:
type: boolean
description: |
@@ -8545,7 +8587,7 @@ components:
example: a1b2c3d4-e5f6-7890-abcd-ef1234567890
display_name:
type: string
- example: "cairokee"
+ example: 'cairokee'
required: [id, display_name]
Playlist:
@@ -8602,21 +8644,21 @@ components:
PlaylistDetails:
allOf:
- - $ref: "#/components/schemas/Playlist"
+ - $ref: '#/components/schemas/Playlist'
- type: object
properties:
tracks:
type: array
description: Ordered tracks in the playlist (by position).
items:
- $ref: "#/components/schemas/PlaylistTrackItem"
+ $ref: '#/components/schemas/PlaylistTrackItem'
PlaylistGetResponse:
type: object
required: [data]
properties:
data:
- $ref: "#/components/schemas/PlaylistDetails"
+ $ref: '#/components/schemas/PlaylistDetails'
message:
type: string
example: Playlist fetched successfully.
@@ -8626,10 +8668,10 @@ components:
required: [data]
properties:
data:
- $ref: "#/components/schemas/Playlist"
+ $ref: '#/components/schemas/Playlist'
message:
type: string
- example: "Playlist created successfully."
+ example: 'Playlist created successfully.'
PlaylistListMeta:
type: object
@@ -8652,9 +8694,9 @@ components:
items:
type: array
items:
- $ref: "#/components/schemas/Playlist"
+ $ref: '#/components/schemas/Playlist'
meta:
- $ref: "#/components/schemas/PlaylistListMeta"
+ $ref: '#/components/schemas/PlaylistListMeta'
required: [items, meta]
PlaylistListResponse:
@@ -8662,7 +8704,7 @@ components:
required: [data]
properties:
data:
- $ref: "#/components/schemas/PlaylistListData"
+ $ref: '#/components/schemas/PlaylistListData'
message:
type: string
example: Playlists fetched successfully.
@@ -8676,13 +8718,13 @@ components:
description: Updated playlist name.
minLength: 1
maxLength: 100
- example: "Updated Gym Mix"
+ example: 'Updated Gym Mix'
description:
type: string
description: Updated playlist description.
maxLength: 500
nullable: true
- example: "Updated high energy tracks"
+ example: 'Updated high energy tracks'
is_public:
type: boolean
description: |
@@ -8734,7 +8776,7 @@ components:
minItems: 1
description: Full ordered list of tracks with their new positions.
items:
- $ref: "#/components/schemas/ReorderPlaylistTracksRequestItem"
+ $ref: '#/components/schemas/ReorderPlaylistTracksRequestItem'
additionalProperties: false
PlaylistEmbedData:
@@ -8755,7 +8797,7 @@ components:
required: [data]
properties:
data:
- $ref: "#/components/schemas/PlaylistEmbedData"
+ $ref: '#/components/schemas/PlaylistEmbedData'
message:
type: string
example: Playlist embed code generated successfully.
@@ -8813,7 +8855,7 @@ components:
TrendingTrack:
allOf:
- - $ref: "#/components/schemas/FeedTrack"
+ - $ref: '#/components/schemas/FeedTrack'
- type: object
properties:
rank:
@@ -8850,7 +8892,7 @@ components:
type: string
example: Based on SZA
seed_artist:
- $ref: "#/components/schemas/UserSummary"
+ $ref: '#/components/schemas/UserSummary'
cover_image:
type: string
format: uri
@@ -8894,7 +8936,7 @@ components:
type: string
format: date-time
description: When this mix was last regenerated.
- example: "2026-03-12T00:00:00Z"
+ example: '2026-03-12T00:00:00Z'
required: [id, label, flavor, track_count, generated_at]
BuzzingPlaylist:
@@ -8938,7 +8980,7 @@ components:
Unauthenticated: global #1 trending track. Refreshes daily at midnight UTC.
properties:
track:
- $ref: "#/components/schemas/FeedTrack"
+ $ref: '#/components/schemas/FeedTrack'
reason:
type: string
description: Human-readable reason shown under the card.
@@ -8947,7 +8989,7 @@ components:
type: string
format: date-time
description: When this recommendation expires (next midnight UTC).
- example: "2026-03-13T00:00:00Z"
+ example: '2026-03-13T00:00:00Z'
required: [track, valid_until]
TrendingByGenreSection:
@@ -8965,7 +9007,7 @@ components:
type: array
description: Top trending tracks in this genre, ordered by trend_score desc.
items:
- $ref: "#/components/schemas/TrendingTrack"
+ $ref: '#/components/schemas/TrendingTrack'
required: [genre_name, tracks]
HomeResponse:
@@ -8996,7 +9038,7 @@ components:
example: Indie
required: [genre_name]
initial_tab:
- $ref: "#/components/schemas/TrendingByGenreSection"
+ $ref: '#/components/schemas/TrendingByGenreSection'
description: |
Pre-loaded data for the first tab. Remaining tabs lazy-load via
GET /home/trending-by-genre/{genre_id}.
@@ -9006,25 +9048,25 @@ components:
description: '"Mixed for {display_name}" — flavor: listening_history.'
maxItems: 6
items:
- $ref: "#/components/schemas/PersonalMix"
+ $ref: '#/components/schemas/PersonalMix'
more_of_what_you_like:
type: array
description: '"More of What You Like" — flavor: taste_profile.'
maxItems: 6
items:
- $ref: "#/components/schemas/PersonalMix"
+ $ref: '#/components/schemas/PersonalMix'
hot_for_you:
- $ref: "#/components/schemas/HotForYou"
+ $ref: '#/components/schemas/HotForYou'
discover_with_stations:
type: array
maxItems: 10
items:
- $ref: "#/components/schemas/Station"
+ $ref: '#/components/schemas/Station'
artists_to_watch:
type: array
maxItems: 10
items:
- $ref: "#/components/schemas/BuzzingPlaylist"
+ $ref: '#/components/schemas/BuzzingPlaylist'
required:
- trending_by_genre
- mixed_for_you
@@ -9038,7 +9080,7 @@ components:
TrackSearchResult:
allOf:
- - $ref: "#/components/schemas/FeedTrack"
+ - $ref: '#/components/schemas/FeedTrack'
- type: object
properties:
score:
@@ -9081,7 +9123,7 @@ components:
type: string
example: Summer Mix
owner:
- $ref: "#/components/schemas/PlaylistOwnerSummary"
+ $ref: '#/components/schemas/PlaylistOwnerSummary'
track_count:
type: integer
example: 12
@@ -9092,7 +9134,7 @@ components:
PlaylistSearchResult:
allOf:
- - $ref: "#/components/schemas/PlaylistSummary"
+ - $ref: '#/components/schemas/PlaylistSummary'
- type: object
properties:
score:
@@ -9112,9 +9154,9 @@ components:
format: uuid
example: c1d2e3f4-a5b6-7890-cdef-123456789abc
participant:
- $ref: "#/components/schemas/PublicUser"
+ $ref: '#/components/schemas/PublicUser'
last_message:
- $ref: "#/components/schemas/Message"
+ $ref: '#/components/schemas/Message'
unread_count:
type: integer
description: Number of unread messages in this conversation for the current user.
@@ -9151,7 +9193,7 @@ components:
example: Check out this track!
embed_type:
type: string
- description: "Type of embedded resource card, if any."
+ description: 'Type of embedded resource card, if any.'
nullable: true
enum:
- track
@@ -9212,7 +9254,7 @@ components:
type: string
nullable: true
maxLength: 2000
- example: "Hey, loved your latest track!"
+ example: 'Hey, loved your latest track!'
resource:
type: object
nullable: true
@@ -9236,7 +9278,7 @@ components:
type: boolean
example: true
data:
- $ref: "#/components/schemas/ConversationListResponse_data"
+ $ref: '#/components/schemas/ConversationListResponse_data'
ConversationDetailResponse:
type: object
properties:
@@ -9244,7 +9286,7 @@ components:
type: boolean
example: true
data:
- $ref: "#/components/schemas/ConversationDetailResponse_data"
+ $ref: '#/components/schemas/ConversationDetailResponse_data'
ConversationCreatedResponse:
type: object
properties:
@@ -9252,7 +9294,7 @@ components:
type: boolean
example: true
data:
- $ref: "#/components/schemas/ConversationCreatedResponse_data"
+ $ref: '#/components/schemas/ConversationCreatedResponse_data'
MessageCreatedResponse:
type: object
properties:
@@ -9260,7 +9302,7 @@ components:
type: boolean
example: true
data:
- $ref: "#/components/schemas/Message"
+ $ref: '#/components/schemas/Message'
UnreadCountResponse:
type: object
properties:
@@ -9268,7 +9310,7 @@ components:
type: boolean
example: true
data:
- $ref: "#/components/schemas/UnreadCountResponse_data"
+ $ref: '#/components/schemas/UnreadCountResponse_data'
MarkMessageReadRequest:
required:
- is_read
@@ -9308,27 +9350,27 @@ components:
items:
type: array
items:
- $ref: "#/components/schemas/Conversation"
+ $ref: '#/components/schemas/Conversation'
pagination:
- $ref: "#/components/schemas/Pagination"
+ $ref: '#/components/schemas/Pagination'
ConversationDetailResponse_data:
type: object
properties:
conversation:
- $ref: "#/components/schemas/Conversation"
+ $ref: '#/components/schemas/Conversation'
messages:
type: array
items:
- $ref: "#/components/schemas/Message"
+ $ref: '#/components/schemas/Message'
pagination:
- $ref: "#/components/schemas/Pagination"
+ $ref: '#/components/schemas/Pagination'
ConversationCreatedResponse_data:
type: object
properties:
conversation:
- $ref: "#/components/schemas/Conversation"
+ $ref: '#/components/schemas/Conversation'
message:
- $ref: "#/components/schemas/Message"
+ $ref: '#/components/schemas/Message'
UnreadCountResponse_data:
type: object
properties:
@@ -9352,7 +9394,7 @@ components:
description: The action that triggered this notification.
enum: [follow, like, repost, comment]
actor:
- $ref: "#/components/schemas/PublicUser"
+ $ref: '#/components/schemas/PublicUser'
resource_type:
type: string
description: The type of resource the action was performed on, if applicable.
@@ -9410,12 +9452,12 @@ components:
items:
type: array
items:
- $ref: "#/components/schemas/Notification"
+ $ref: '#/components/schemas/Notification'
unread_count:
type: integer
example: 7
pagination:
- $ref: "#/components/schemas/Pagination"
+ $ref: '#/components/schemas/Pagination'
NotificationPreferences:
type: object
@@ -9509,7 +9551,7 @@ components:
type: object
properties:
data:
- $ref: "#/components/schemas/NotificationPreferences"
+ $ref: '#/components/schemas/NotificationPreferences'
required: [data]
# ==========================================================
@@ -9577,7 +9619,7 @@ components:
ReportDetailed:
allOf:
- - $ref: "#/components/schemas/Report"
+ - $ref: '#/components/schemas/Report'
- type: object
properties:
description:
@@ -9679,11 +9721,11 @@ components:
description: Internal subscription plan ID.
example: 2
name:
- $ref: "#/components/schemas/SubscriptionPlanName"
+ $ref: '#/components/schemas/SubscriptionPlanName'
price:
type: string
description: Decimal amount represented as a string.
- example: "4.99"
+ example: '4.99'
duration_days:
type: integer
nullable: true
@@ -9713,7 +9755,7 @@ components:
items:
type: array
items:
- $ref: "#/components/schemas/SubscriptionPlan"
+ $ref: '#/components/schemas/SubscriptionPlan'
required:
- items
@@ -9721,7 +9763,7 @@ components:
type: object
properties:
data:
- $ref: "#/components/schemas/SubscriptionPlanListData"
+ $ref: '#/components/schemas/SubscriptionPlanListData'
message:
type: string
example: Subscription plans fetched successfully.
@@ -9739,20 +9781,20 @@ components:
type: string
format: uuid
description: User ID that owns this subscription.
- example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
+ example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
status:
- $ref: "#/components/schemas/UserSubscriptionStatus"
+ $ref: '#/components/schemas/UserSubscriptionStatus'
start_date:
type: string
format: date
description: Subscription start date.
- example: "2026-03-01"
+ example: '2026-03-01'
end_date:
type: string
format: date
nullable: true
description: Subscription end date. Null may be used for free/default access.
- example: "2026-03-31"
+ example: '2026-03-31'
auto_renew:
type: boolean
description: Whether the subscription should renew automatically.
@@ -9761,9 +9803,9 @@ components:
type: string
format: date-time
description: When the user subscription record was created.
- example: "2026-03-01T09:30:00Z"
+ example: '2026-03-01T09:30:00Z'
plan:
- $ref: "#/components/schemas/SubscriptionPlan"
+ $ref: '#/components/schemas/SubscriptionPlan'
required:
- user_subscription_id
- user_id
@@ -9777,7 +9819,7 @@ components:
type: object
properties:
data:
- $ref: "#/components/schemas/MySubscription"
+ $ref: '#/components/schemas/MySubscription'
message:
type: string
example: Current subscription fetched successfully.
@@ -9833,16 +9875,16 @@ components:
description: Internal user subscription record created or reserved for this checkout.
example: 18
checkout_status:
- $ref: "#/components/schemas/SubscriptionCheckoutStatus"
+ $ref: '#/components/schemas/SubscriptionCheckoutStatus'
payment_method:
- $ref: "#/components/schemas/PaymentMethod"
+ $ref: '#/components/schemas/PaymentMethod'
payment_url:
type: string
format: uri
description: Mock Stripe checkout URL.
- example: "https://checkout.stripe.com/pay/mock-session-123"
+ example: 'https://checkout.stripe.com/pay/mock-session-123'
plan:
- $ref: "#/components/schemas/SubscriptionPlan"
+ $ref: '#/components/schemas/SubscriptionPlan'
required:
- transaction_id
- user_subscription_id
@@ -9855,7 +9897,7 @@ components:
type: object
properties:
data:
- $ref: "#/components/schemas/SubscriptionCheckoutData"
+ $ref: '#/components/schemas/SubscriptionCheckoutData'
message:
type: string
example: Checkout session created successfully.
@@ -9870,7 +9912,7 @@ components:
description: Internal user subscription ID.
example: 18
status:
- $ref: "#/components/schemas/UserSubscriptionStatus"
+ $ref: '#/components/schemas/UserSubscriptionStatus'
auto_renew:
type: boolean
description: Whether the subscription will renew automatically.
@@ -9880,7 +9922,7 @@ components:
format: date
nullable: true
description: Date when premium access ends.
- example: "2026-03-31"
+ example: '2026-03-31'
required:
- user_subscription_id
- status
@@ -9891,7 +9933,7 @@ components:
type: object
properties:
data:
- $ref: "#/components/schemas/CancelSubscriptionData"
+ $ref: '#/components/schemas/CancelSubscriptionData'
message:
type: string
example: Subscription canceled successfully.
@@ -9921,22 +9963,22 @@ components:
amount:
type: string
description: Decimal transaction amount represented as a string.
- example: "4.99"
+ example: '4.99'
payment_method:
- $ref: "#/components/schemas/PaymentMethod"
+ $ref: '#/components/schemas/PaymentMethod'
payment_status:
- $ref: "#/components/schemas/PaymentStatus"
+ $ref: '#/components/schemas/PaymentStatus'
paid_at:
type: string
format: date-time
nullable: true
description: Timestamp when the transaction was marked as paid.
- example: "2026-03-01T09:35:00Z"
+ example: '2026-03-01T09:35:00Z'
created_at:
type: string
format: date-time
description: Timestamp when the transaction record was created.
- example: "2026-03-01T09:30:00Z"
+ example: '2026-03-01T09:30:00Z'
required:
- transaction_id
- user_subscription_id
@@ -9970,9 +10012,9 @@ components:
items:
type: array
items:
- $ref: "#/components/schemas/SubscriptionTransaction"
+ $ref: '#/components/schemas/SubscriptionTransaction'
meta:
- $ref: "#/components/schemas/SubscriptionTransactionListMeta"
+ $ref: '#/components/schemas/SubscriptionTransactionListMeta'
required:
- items
- meta
@@ -9981,7 +10023,7 @@ components:
type: object
properties:
data:
- $ref: "#/components/schemas/SubscriptionTransactionListData"
+ $ref: '#/components/schemas/SubscriptionTransactionListData'
message:
type: string
example: Subscription transactions fetched successfully.
@@ -9996,14 +10038,14 @@ components:
description: Confirmed transaction ID.
example: 27
payment_status:
- $ref: "#/components/schemas/PaymentStatus"
+ $ref: '#/components/schemas/PaymentStatus'
paid_at:
type: string
format: date-time
description: Timestamp when the transaction was confirmed as paid.
- example: "2026-03-06T14:20:00Z"
+ example: '2026-03-06T14:20:00Z'
subscription:
- $ref: "#/components/schemas/MockConfirmedSubscription"
+ $ref: '#/components/schemas/MockConfirmedSubscription'
required:
- transaction_id
- payment_status
@@ -10018,7 +10060,7 @@ components:
description: Activated user subscription ID.
example: 18
status:
- $ref: "#/components/schemas/UserSubscriptionStatus"
+ $ref: '#/components/schemas/UserSubscriptionStatus'
auto_renew:
type: boolean
description: Whether the subscription will renew automatically.
@@ -10027,13 +10069,13 @@ components:
type: string
format: date
description: Subscription activation date.
- example: "2026-03-06"
+ example: '2026-03-06'
end_date:
type: string
format: date
nullable: true
description: Date when premium access ends.
- example: "2026-04-05"
+ example: '2026-04-05'
required:
- user_subscription_id
- status
@@ -10045,7 +10087,7 @@ components:
type: object
properties:
data:
- $ref: "#/components/schemas/MockConfirmSubscriptionData"
+ $ref: '#/components/schemas/MockConfirmSubscriptionData'
message:
type: string
example: Mock payment confirmed and subscription activated successfully.
@@ -10061,7 +10103,7 @@ components:
type: object
properties:
error:
- $ref: "#/components/schemas/ErrorObject"
+ $ref: '#/components/schemas/ErrorObject'
required: [error]
ErrorObject:
@@ -10079,7 +10121,7 @@ components:
type: array
nullable: true
items:
- $ref: "#/components/schemas/ErrorDetail"
+ $ref: '#/components/schemas/ErrorDetail'
required: [code, message]
ErrorDetail:
@@ -10148,9 +10190,9 @@ components:
items:
type: array
items:
- $ref: "#/components/schemas/UserSummary"
+ $ref: '#/components/schemas/UserSummary'
meta:
- $ref: "#/components/schemas/ListMeta"
+ $ref: '#/components/schemas/ListMeta'
required:
- data
@@ -10171,7 +10213,7 @@ components:
created_at:
type: string
format: date-time
- example: "2025-03-05T14:00:00Z"
+ example: '2025-03-05T14:00:00Z'
message:
type: string
example: You are now following this user.
@@ -10182,26 +10224,22 @@ components:
properties:
is_following:
type: boolean
- description:
- Row exists in `follows` where `follower_id = me AND followed_id
+ description: Row exists in `follows` where `follower_id = me AND followed_id
= target`.
example: true
is_followed_by:
type: boolean
- description:
- Row exists in `follows` where `follower_id = target AND followed_id
+ description: Row exists in `follows` where `follower_id = target AND followed_id
= me`.
example: false
is_blocking:
type: boolean
- description:
- Row exists in `blocks` where `blocker_id = me AND blocked_id =
+ description: Row exists in `blocks` where `blocker_id = me AND blocked_id =
target`.
example: false
is_blocked_by:
type: boolean
- description:
- Row exists in `blocks` where `blocker_id = target AND blocked_id
+ description: Row exists in `blocks` where `blocker_id = target AND blocked_id
= me`.
example: false
@@ -10222,7 +10260,7 @@ components:
created_at:
type: string
format: date-time
- example: "2025-03-05T14:00:00Z"
+ example: '2025-03-05T14:00:00Z'
message:
type: string
example: User has been blocked.
@@ -10248,7 +10286,7 @@ components:
created_at:
type: string
format: date-time
- example: "2025-03-05T14:05:00Z"
+ example: '2025-03-05T14:05:00Z'
message:
type: string
example: Track liked.
@@ -10270,7 +10308,7 @@ components:
created_at:
type: string
format: date-time
- example: "2025-03-05T14:20:00Z"
+ example: '2025-03-05T14:20:00Z'
message:
type: string
example: Comment liked.
@@ -10296,7 +10334,7 @@ components:
created_at:
type: string
format: date-time
- example: "2025-03-05T14:10:00Z"
+ example: '2025-03-05T14:10:00Z'
message:
type: string
example: Track reposted.
@@ -10322,7 +10360,7 @@ components:
created_at:
type: string
format: date-time
- example: "2025-03-05T14:05:00Z"
+ example: '2025-03-05T14:05:00Z'
message:
type: string
example: Playlist liked.
@@ -10348,7 +10386,7 @@ components:
created_at:
type: string
format: date-time
- example: "2025-03-05T14:10:00Z"
+ example: '2025-03-05T14:10:00Z'
message:
type: string
example: Playlist reposted.
@@ -10373,7 +10411,7 @@ components:
type: string
format: uuid
nullable: true
- description: "null for top-level comments, parent comment_id for replies."
+ description: 'null for top-level comments, parent comment_id for replies.'
example: null
content:
type: string
@@ -10398,11 +10436,11 @@ components:
description: Whether the current user liked this comment.
example: false
author:
- $ref: "#/components/schemas/UserSummary"
+ $ref: '#/components/schemas/UserSummary'
created_at:
type: string
format: date-time
- example: "2025-03-05T14:15:00Z"
+ example: '2025-03-05T14:15:00Z'
CommentListResponse:
type: object
@@ -10413,9 +10451,9 @@ components:
items:
type: array
items:
- $ref: "#/components/schemas/Comment"
+ $ref: '#/components/schemas/Comment'
meta:
- $ref: "#/components/schemas/ListMeta"
+ $ref: '#/components/schemas/ListMeta'
required:
- data
@@ -10423,13 +10461,13 @@ components:
type: object
properties:
data:
- $ref: "#/components/schemas/Comment"
+ $ref: '#/components/schemas/Comment'
CommentCreatedResponse:
type: object
properties:
data:
- $ref: "#/components/schemas/Comment"
+ $ref: '#/components/schemas/Comment'
message:
type: string
example: Comment posted.
@@ -10483,7 +10521,7 @@ components:
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: VALIDATION_FAILED
@@ -10497,7 +10535,7 @@ components:
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: AUTH_TOKEN_MISSING
@@ -10508,7 +10546,7 @@ components:
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: INTERNAL_SERVER_ERROR
@@ -10519,7 +10557,7 @@ components:
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: UPLOAD_PROCESSING_FAILED
@@ -10530,7 +10568,7 @@ components:
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: UPLOAD_FILE_TOO_LARGE
@@ -10541,7 +10579,7 @@ components:
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: UPLOAD_INVALID_FILE_TYPE
@@ -10557,7 +10595,7 @@ components:
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: RATE_LIMIT_EXCEEDED
@@ -10568,7 +10606,7 @@ components:
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: BUSINESS_RULE_VIOLATION
@@ -10591,7 +10629,7 @@ components:
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: PERMISSION_DENIED
@@ -10602,7 +10640,7 @@ components:
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
example:
error:
code: RESOURCE_NOT_FOUND
diff --git a/src/controllers/genres.controller.js b/src/controllers/genres.controller.js
new file mode 100644
index 0000000..b1d7224
--- /dev/null
+++ b/src/controllers/genres.controller.js
@@ -0,0 +1,15 @@
+// ============================================================
+// controllers/genres.controller.js
+// ============================================================
+const { success } = require('../utils/api-response');
+const genresService = require('../services/genres.service');
+
+const getAllGenres = async (req, res) => {
+ const data = await genresService.getAllGenres();
+
+ return success(res, data, 'Genres fetched successfully', 200);
+};
+
+module.exports = {
+ getAllGenres,
+};
\ No newline at end of file
diff --git a/src/controllers/tags.controller.js b/src/controllers/tags.controller.js
new file mode 100644
index 0000000..429d163
--- /dev/null
+++ b/src/controllers/tags.controller.js
@@ -0,0 +1,15 @@
+// ============================================================
+// controllers/tags.controller.js
+// ============================================================
+const { success } = require('../utils/api-response');
+const tagsService = require('../services/tags.service');
+
+const getAllTags = async (req, res) => {
+ const data = await tagsService.getAllTags();
+
+ return success(res, data, 'Tags fetched successfully', 200);
+};
+
+module.exports = {
+ getAllTags,
+};
\ No newline at end of file
diff --git a/src/models/genre.model.js b/src/models/genre.model.js
new file mode 100644
index 0000000..fcfaa5b
--- /dev/null
+++ b/src/models/genre.model.js
@@ -0,0 +1,22 @@
+// ============================================================
+// models/genre.model.js — PostgreSQL queries for Genres
+// Entity attributes: Genre_id, Name
+// All SQL lives HERE — no SQL outside models/
+// ============================================================
+const db = require('../config/db');
+
+const getAllGenres = async () => {
+ const { rows } = await db.query(
+ `
+ SELECT id::text AS id, name
+ FROM genres
+ ORDER BY name ASC
+ `
+ );
+
+ return rows;
+};
+
+module.exports = {
+ getAllGenres,
+};
\ No newline at end of file
diff --git a/src/models/tag.model.js b/src/models/tag.model.js
index e1a7be4..20569f3 100644
--- a/src/models/tag.model.js
+++ b/src/models/tag.model.js
@@ -5,9 +5,77 @@
// ============================================================
const db = require('../config/db');
-// TODO: Implement query methods
-// Example:
-// exports.findById = async (id) => {
-// const { rows } = await db.query('SELECT * FROM tags WHERE id = $1', [id]);
-// return rows[0] || null;
-// };
+const findByNames = async (tagNames) => {
+ const normalized = [...new Set(tagNames.map((name) => name.trim().toLowerCase()))];
+
+ if (!normalized.length) {
+ return [];
+ }
+
+ const { rows } = await db.query(
+ `
+ SELECT id::text AS id, name
+ FROM tags
+ WHERE LOWER(name) = ANY($1::text[])
+ ORDER BY name ASC
+ `,
+ [normalized]
+ );
+
+ return rows;
+};
+
+const findByName = async (name) => {
+ const { rows } = await db.query(
+ `
+ SELECT id::text AS id, name
+ FROM tags
+ WHERE LOWER(name) = LOWER($1)
+ LIMIT 1
+ `,
+ [name]
+ );
+
+ return rows[0] || null;
+};
+
+const findByIds = async (tagIds) => {
+ const uniqueIds = [...new Set((tagIds || []).map(String))];
+
+ if (!uniqueIds.length) {
+ return [];
+ }
+
+ const { rows } = await db.query(
+ `
+ SELECT id::text AS id, name
+ FROM tags
+ WHERE id::text = ANY($1::text[])
+ `,
+ [uniqueIds]
+ );
+
+ const byId = new Map(rows.map((row) => [row.id, row]));
+
+ // preserve input order
+ return uniqueIds.map((id) => byId.get(id)).filter(Boolean);
+};
+
+const getAllTags = async () => {
+ const { rows } = await db.query(
+ `
+ SELECT id::text AS id, name
+ FROM tags
+ ORDER BY name ASC
+ `
+ );
+
+ return rows;
+};
+
+module.exports = {
+ findByNames,
+ findByName,
+ findByIds,
+ getAllTags,
+};
\ No newline at end of file
diff --git a/src/routes/genres.routes.js b/src/routes/genres.routes.js
new file mode 100644
index 0000000..07a3e56
--- /dev/null
+++ b/src/routes/genres.routes.js
@@ -0,0 +1,9 @@
+const express = require('express');
+const router = express.Router();
+const controller = require('../controllers/genres.controller');
+const asyncHandler = require('../utils/async-handler');
+
+// GET /api/v1/genres
+router.get('/', asyncHandler(controller.getAllGenres));
+
+module.exports = router;
\ No newline at end of file
diff --git a/src/routes/tags.routes.js b/src/routes/tags.routes.js
new file mode 100644
index 0000000..3dd23b8
--- /dev/null
+++ b/src/routes/tags.routes.js
@@ -0,0 +1,9 @@
+const express = require('express');
+const router = express.Router();
+const controller = require('../controllers/tags.controller');
+const asyncHandler = require('../utils/async-handler');
+
+// GET /api/v1/tags
+router.get('/', asyncHandler(controller.getAllTags));
+
+module.exports = router;
\ No newline at end of file
diff --git a/src/services/genres.service.js b/src/services/genres.service.js
new file mode 100644
index 0000000..ae1bc09
--- /dev/null
+++ b/src/services/genres.service.js
@@ -0,0 +1,16 @@
+// ============================================================
+// services/genres.service.js
+// ============================================================
+const genreModel = require('../models/genre.model');
+
+const getAllGenres = async () => {
+ const genres = await genreModel.getAllGenres();
+
+ return {
+ items: genres,
+ };
+};
+
+module.exports = {
+ getAllGenres,
+};
\ No newline at end of file
diff --git a/src/services/tags.service.js b/src/services/tags.service.js
new file mode 100644
index 0000000..f57511b
--- /dev/null
+++ b/src/services/tags.service.js
@@ -0,0 +1,16 @@
+// ============================================================
+// services/tags.service.js
+// ============================================================
+const tagModel = require('../models/tag.model');
+
+const getAllTags = async () => {
+ const tags = await tagModel.getAllTags();
+
+ return {
+ items: tags,
+ };
+};
+
+module.exports = {
+ getAllTags,
+};
\ No newline at end of file
diff --git a/src/services/tracks.service.js b/src/services/tracks.service.js
index 7327231..a369151 100644
--- a/src/services/tracks.service.js
+++ b/src/services/tracks.service.js
@@ -7,10 +7,14 @@
const AppError = require('../utils/app-error.js');
const tracksModel = require('../models/track.model.js');
+const tagModel = require('../models/tag.model.js');
const storageService = require('./storage.service.js');
const GEO_RESTRICTION_TYPES = ['worldwide', 'exclusive_regions', 'blocked_regions'];
+const looksLikeDbId = (value) =>
+ typeof value === 'string' && value.includes('-');
+
const resolveGeoSettings = ({
geoRestrictionTypeInput,
geoRegionsInput,
@@ -28,7 +32,8 @@ const resolveGeoSettings = ({
throw new AppError('Invalid geo_restriction_type', 400, 'VALIDATION_FAILED');
}
- const geoRegions = geoRegionsInput !== undefined ? parseArray(geoRegionsInput) : currentGeoRegions;
+ const geoRegions =
+ geoRegionsInput !== undefined ? parseArray(geoRegionsInput) : currentGeoRegions;
if (!Array.isArray(geoRegions)) {
throw new AppError('geo_regions must be an array', 400, 'VALIDATION_FAILED');
@@ -89,14 +94,152 @@ const parseArray = (v) => {
const clean = (v) => (v === undefined || v === null || v === '' ? null : v);
+const parseStrictArray = (v, fieldName) => {
+ if (v === undefined) return undefined;
+ if (Array.isArray(v)) return v;
+
+ try {
+ const parsed = JSON.parse(v);
+ if (!Array.isArray(parsed)) {
+ throw new Error();
+ }
+ return parsed;
+ } catch {
+ throw new AppError(`${fieldName} must be a valid array`, 400, 'VALIDATION_FAILED');
+ }
+};
+
+const normalizeTagNames = (rawTags) => {
+ const normalized = rawTags.map((tag) => {
+ if (typeof tag !== 'string') {
+ throw new AppError('Each tag must be a string', 400, 'VALIDATION_FAILED');
+ }
+
+ const cleaned = tag.trim().toLowerCase();
+
+ if (!cleaned) {
+ throw new AppError('Tag names cannot be empty', 400, 'VALIDATION_FAILED');
+ }
+
+ return cleaned;
+ });
+
+ const unique = [...new Set(normalized)];
+
+ if (unique.length > 10) {
+ throw new AppError('Maximum 10 tags allowed', 400, 'VALIDATION_FAILED');
+ }
+
+ return unique;
+};
+
+const resolveTagsFromInput = async (rawTags) => {
+ if (rawTags === undefined) {
+ return undefined;
+ }
+
+ const parsed = parseStrictArray(rawTags, 'tags');
+ const tagNames = normalizeTagNames(parsed);
+
+ if (!tagNames.length) {
+ return {
+ tagNames: [],
+ tagIds: [],
+ };
+ }
+
+ const rows = await tagModel.findByNames(tagNames);
+ const foundNames = new Set(rows.map((row) => row.name.toLowerCase()));
+ const missing = tagNames.filter((name) => !foundNames.has(name));
+
+ if (missing.length) {
+ throw new AppError(`Unknown tag(s): ${missing.join(', ')}`, 400, 'VALIDATION_FAILED');
+ }
+
+ const idByName = new Map(rows.map((row) => [row.name.toLowerCase(), row.id]));
+
+ return {
+ tagNames,
+ tagIds: tagNames.map((name) => idByName.get(name)),
+ };
+};
+
+const hydrateTagNamesByIds = async (tagIds) => {
+ const ids = (tagIds || []).map(String);
+
+ if (!ids.length) {
+ return [];
+ }
+
+ const rows = await tagModel.findByIds(ids);
+ const nameById = new Map(rows.map((row) => [String(row.id), row.name]));
+
+ return ids.map((id) => nameById.get(id)).filter(Boolean);
+};
+
+const mapTrackTagsToNames = async (track) => {
+ if (!track || !Array.isArray(track.tags)) {
+ return track;
+ }
+
+ if (!track.tags.length) {
+ return { ...track, tags: [] };
+ }
+
+ const hasAnyDbIds = track.tags.some((tag) => looksLikeDbId(tag));
+
+ if (!hasAnyDbIds) {
+ return track;
+ }
+
+ const resolvedTags = await hydrateTagNamesByIds(
+ track.tags.filter((tag) => looksLikeDbId(tag))
+ );
+
+ const resolvedIdTags = track.tags.filter((tag) => looksLikeDbId(tag));
+ const nameById = new Map(
+ resolvedIdTags.map((id, index) => [String(id), resolvedTags[index]])
+ );
+
+ return {
+ ...track,
+ tags: track.tags.map((tag) => nameById.get(String(tag)) || tag),
+ };
+};
+
+const mapTrackListTagsToNames = async (tracks) => {
+ if (!Array.isArray(tracks) || !tracks.length) return tracks;
+
+ const allIds = [
+ ...new Set(
+ tracks
+ .flatMap((track) => (Array.isArray(track.tags) ? track.tags : []))
+ .filter(looksLikeDbId)
+ .map(String)
+ ),
+ ];
+
+ if (!allIds.length) return tracks;
+
+ const rows = await tagModel.findByIds(allIds);
+ const nameById = new Map(rows.map((row) => [String(row.id), row.name]));
+
+ return tracks.map((track) => {
+ if (!Array.isArray(track.tags)) return track;
+
+ return {
+ ...track,
+ tags: track.tags.map((tag) => nameById.get(String(tag)) || tag),
+ };
+ });
+};
+
const uploadTrack = async ({ user, audioFile, coverImageFile, body }) => {
const userId = user?.sub || user?.id || user?.user_id;
if (!userId) throw new AppError('Authenticated user not found', 401, 'AUTH_TOKEN_INVALID');
- const tagIds = parseArray(body.tags || body.tag_ids);
- if (tagIds.length > 10) {
- throw new AppError('Maximum 10 tags allowed', 400, 'VALIDATION_FAILED');
- }
+ const resolvedTags = await resolveTagsFromInput(body.tags);
+ const tagIds = resolvedTags?.tagIds || [];
let genreId = null;
if (body.genre) {
@@ -164,11 +307,9 @@ const uploadTrack = async ({ user, audioFile, coverImageFile, body }) => {
await tracksModel.addTrackArtists(createdTrack.id, [userId]);
- const tags = await tracksModel.getTagIdsByTrackId(createdTrack.id);
-
return {
...createdTrack,
- tags,
+ tags: resolvedTags?.tagNames || [],
};
};
@@ -181,17 +322,15 @@ const getTrackById = async (trackId, requesterUserId = null) => {
const isOwner = requesterUserId === track.user_id;
- // Hidden tracks should look nonexistent to non-owners
if (track.is_hidden && !isOwner) {
throw new AppError('Track not found', 404, 'TRACK_NOT_FOUND');
}
- // Private tracks are owner-only
if (!track.is_public && !isOwner) {
throw new AppError('This track is private', 403, 'RESOURCE_PRIVATE');
}
- return track;
+ return mapTrackTagsToNames(track);
};
const updateTrackVisibility = async (trackId, userId, isPublic) => {
@@ -199,7 +338,6 @@ const updateTrackVisibility = async (trackId, userId, isPublic) => {
throw new AppError('is_public must be a boolean', 400, 'VALIDATION_ERROR');
}
- // Reuse the read method you already added for GET /tracks/:track_id
const track = await tracksModel.findTrackByIdWithDetails(trackId);
if (!track) {
@@ -241,7 +379,7 @@ const getMyTracks = async (userId, query = {}) => {
});
return {
- items,
+ items: await mapTrackListTagsToNames(items),
page,
limit,
total,
@@ -263,6 +401,7 @@ const deleteTrack = async (trackId, userId) => {
'PERMISSION_NOT_OWNER'
);
}
+
await storageService.deleteManyByUrls([
track.audio_url,
track.stream_url,
@@ -278,7 +417,6 @@ const deleteTrack = async (trackId, userId) => {
}
};
-// cover image can be updated when sent as multipart/form-data
const updateTrack = async ({ trackId, userId, payload, coverImageFile }) => {
const track = await tracksModel.findTrackByIdWithDetails(trackId);
@@ -313,15 +451,9 @@ const updateTrack = async ({ trackId, userId, payload, coverImageFile }) => {
}
}
- let tagIds;
+ let resolvedTags;
if (payload.tags !== undefined) {
- tagIds = parseArray(payload.tags);
- if (!Array.isArray(tagIds)) {
- throw new AppError('tags must be an array', 400, 'VALIDATION_FAILED');
- }
- if (tagIds.length > 10) {
- throw new AppError('Maximum 10 tags allowed', 400, 'VALIDATION_FAILED');
- }
+ resolvedTags = await resolveTagsFromInput(payload.tags);
}
const updateData = {};
@@ -424,6 +556,7 @@ const updateTrack = async ({ trackId, userId, payload, coverImageFile }) => {
if (!hasScalarUpdates && !hasTagUpdates) {
throw new AppError('No valid fields provided to update', 400, 'VALIDATION_FAILED');
}
+
const updatedRow = hasScalarUpdates
? await tracksModel.updateTrackFields(trackId, updateData)
: track;
@@ -433,7 +566,7 @@ const updateTrack = async ({ trackId, userId, payload, coverImageFile }) => {
}
if (hasTagUpdates) {
- await tracksModel.replaceTrackTags(trackId, tagIds);
+ await tracksModel.replaceTrackTags(trackId, resolvedTags.tagIds);
}
const finalTrack = await tracksModel.findTrackByIdWithDetails(trackId);
@@ -451,23 +584,23 @@ const updateTrack = async ({ trackId, userId, payload, coverImageFile }) => {
await storageService.deleteAllVersionsByUrl(track.cover_image);
}
- return finalTrack;
+ if (hasTagUpdates) {
+ return {
+ ...finalTrack,
+ tags: resolvedTags.tagNames,
+ };
+ }
+
+ return mapTrackTagsToNames(finalTrack);
};
-// doesn't enforce geo restrictions yet
const getTrackStream = async (trackId, requesterUserId = null) => {
const track = await getTrackById(trackId, requesterUserId);
- // uncomment when audio processing is implemented
- // if (track.status === 'processing') {
- // throw new AppError('Track is still processing', 202, 'TRACK_PROCESSING');
- // }
-
if (track.status === 'failed') {
throw new AppError('Track processing failed', 503, 'UPLOAD_PROCESSING_FAILED');
}
- // For now, we return the audio_url as stream_url since processing is not implemented yet
const playableUrl = track.stream_url || track.audio_url;
if (!playableUrl) {
@@ -492,4 +625,4 @@ module.exports = {
//
// TODO Add track upload limit validations based on subscription plan
-//
+//
\ No newline at end of file
From a4f291a5d1f953f0e138c79784ed2e40df2e6fbd Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Mon, 23 Mar 2026 16:39:41 +0200
Subject: [PATCH 095/535] fix(api): reconcile openapi after merge
---
openapi.yaml | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/openapi.yaml b/openapi.yaml
index 224a11f..855d8b0 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -9345,6 +9345,16 @@ components:
format: uuid
description: UUID of the user to create/fetch a conversation with.
example: b2c3d4e5-f6a7-8901-bcde-f12345678901
+
+ EnsureConversationResponse:
+ type: object
+ properties:
+ success:
+ type: boolean
+ example: true
+ data:
+ $ref: "#/components/schemas/EnsureConversationResponse_data"
+
ConversationListResponse:
type: object
properties:
@@ -9452,6 +9462,12 @@ components:
type: integer
example: 5
+ EnsureConversationResponse_data:
+ type: object
+ properties:
+ conversation:
+ $ref: "#/components/schemas/Conversation"
+
# ==========================================================
# Module 10 — Real-Time Notifications (Schemas)
# ==========================================================
From edc35c12ad551ccadde99e7354e1c80951187c04 Mon Sep 17 00:00:00 2001
From: Mohamed jameel alabasy
Date: Tue, 24 Mar 2026 14:37:22 +0200
Subject: [PATCH 096/535] feat(pipeline): add backend development and main CI
workflows
---
.github/workflows/backend-dev.yml | 179 +++++++++++++++++++++
.github/workflows/backend-main.yml | 250 +++++++++++++++++++++++++++++
2 files changed, 429 insertions(+)
create mode 100644 .github/workflows/backend-dev.yml
create mode 100644 .github/workflows/backend-main.yml
diff --git a/.github/workflows/backend-dev.yml b/.github/workflows/backend-dev.yml
new file mode 100644
index 0000000..d3bd52f
--- /dev/null
+++ b/.github/workflows/backend-dev.yml
@@ -0,0 +1,179 @@
+name: Backend — Dev Pipeline
+
+on:
+ pull_request:
+ branches:
+ - development
+
+jobs:
+ # JOB 1 — Lint, format, and validate
+ validate:
+ name: Lint & Validate
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout backend repo
+ uses: actions/checkout@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Validate package files
+ run: |
+ test -f package.json || { echo "MISSING: package.json"; exit 1; }
+ test -f package-lock.json || { echo "MISSING: package-lock.json"; exit 1; }
+ echo "Package files present."
+
+ - name: Run ESLint
+ run: npm run lint
+
+ - name: Run Prettier format check
+ run: npx prettier --check src/
+
+ - name: Validate OpenAPI spec exists and is valid YAML
+ run: |
+ test -f openapi.yaml || { echo "MISSING: openapi.yaml"; exit 1; }
+ node -e "
+ const fs = require('fs');
+ const content = fs.readFileSync('openapi.yaml', 'utf8');
+ if (!content.includes('openapi:')) {
+ console.error('openapi.yaml missing openapi: field');
+ process.exit(1);
+ }
+ console.log('openapi.yaml present and contains openapi field.');
+ "
+
+ # JOB 2 — Docker build and integration smoke check
+ build-and-smoke:
+ name: Docker Build & Smoke Check
+ runs-on: ubuntu-latest
+ needs: validate
+
+ env:
+ NODE_ENV: development
+ PORT: 8080
+ BASE_URL: http://localhost:8080
+ CLIENT_URL: http://localhost:5173
+ APP_URL: http://localhost:5173
+
+ POSTGRES_USER: dev
+ POSTGRES_PASSWORD: devpassword
+ POSTGRES_DB: rythmify_dev
+ DATABASE_URL: postgresql://dev:devpassword@localhost:5432/rythmify_dev
+
+ AZURE_STORAGE_CONNECTION_STRING: 'DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://localhost:10000/devstoreaccount1;'
+ BLOB_CONTAINER_AUDIO: audio
+ BLOB_CONTAINER_MEDIA: media
+
+ JWT_SECRET: ${{ secrets.JWT_SECRET }}
+ JWT_ACCESS_EXPIRES_IN: 15m
+ JWT_REFRESH_EXPIRES_IN: 7d
+ GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
+ GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
+ GOOGLE_CALLBACK_URL: http://localhost:8080/api/v1/auth/google/callback
+ GMAIL_USER: ${{ secrets.GMAIL_USER }}
+ GMAIL_APP_PASSWORD: ${{ secrets.GMAIL_APP_PASSWORD }}
+ EMAIL_FROM: 'Rythmify '
+ RECAPTCHA_SECRET: ${{ secrets.RECAPTCHA_SECRET }}
+
+ services:
+ postgres:
+ image: postgres:16
+ env:
+ POSTGRES_USER: dev
+ POSTGRES_PASSWORD: devpassword
+ POSTGRES_DB: rythmify_dev
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 5s
+ --health-timeout 5s
+ --health-retries 10
+
+ steps:
+ - name: Checkout backend repo
+ uses: actions/checkout@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Wait for PostgreSQL to be ready
+ run: |
+ echo "Waiting for PostgreSQL..."
+ for i in $(seq 1 20); do
+ pg_isready -h localhost -U dev -d rythmify_dev && echo "PostgreSQL ready." && break
+ echo "Attempt $i — not ready yet, waiting 3s..."
+ sleep 3
+ done
+ pg_isready -h localhost -U dev -d rythmify_dev || { echo "PostgreSQL failed to become healthy."; exit 1; }
+
+ - name: Run migrations
+ run: npm run migrate:up
+
+ - name: Validate seed file exists
+ run: |
+ test -f seeds/seed.sql || { echo "MISSING: seeds/seed.sql"; exit 1; }
+ echo "Seed file present."
+
+ - name: Build backend Docker image
+ run: docker build -t rythmify-backend:ci .
+
+ - name: Start backend container
+ run: |
+ docker run -d \
+ --name rythmify_backend_ci \
+ --network host \
+ -e NODE_ENV=${NODE_ENV} \
+ -e PORT=${PORT} \
+ -e BASE_URL=${BASE_URL} \
+ -e CLIENT_URL=${CLIENT_URL} \
+ -e DATABASE_URL=${DATABASE_URL} \
+ -e AZURE_STORAGE_CONNECTION_STRING="${AZURE_STORAGE_CONNECTION_STRING}" \
+ -e BLOB_CONTAINER_AUDIO=${BLOB_CONTAINER_AUDIO} \
+ -e BLOB_CONTAINER_MEDIA=${BLOB_CONTAINER_MEDIA} \
+ -e JWT_SECRET=${JWT_SECRET} \
+ -e JWT_ACCESS_EXPIRES_IN=${JWT_ACCESS_EXPIRES_IN} \
+ -e JWT_REFRESH_EXPIRES_IN=${JWT_REFRESH_EXPIRES_IN} \
+ -e GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} \
+ -e GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} \
+ -e GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL} \
+ -e GMAIL_USER=${GMAIL_USER} \
+ -e GMAIL_APP_PASSWORD=${GMAIL_APP_PASSWORD} \
+ -e EMAIL_FROM="${EMAIL_FROM}" \
+ -e RECAPTCHA_SECRET=${RECAPTCHA_SECRET} \
+ rythmify-backend:ci
+ echo "Backend container started."
+
+ - name: Wait for backend health endpoint
+ run: |
+ echo "Waiting for backend /health..."
+ for i in $(seq 1 30); do
+ curl -sf http://localhost:8080/health > /dev/null && echo "Backend is healthy." && break
+ echo "Attempt $i — not ready yet, waiting 3s..."
+ sleep 3
+ done
+ response=$(curl -sf http://localhost:8080/health) || { echo "Health check failed."; exit 1; }
+ echo "Response: $response"
+ echo "$response" | grep -q '"status":"ok"' || { echo "Unexpected health response."; exit 1; }
+ echo "Smoke check passed."
+
+ - name: Collect container logs on failure
+ if: failure()
+ run: docker logs rythmify_backend_ci 2>&1 || true
+
+ - name: Stop and remove backend container
+ if: always()
+ run: docker rm -f rythmify_backend_ci 2>/dev/null || true
diff --git a/.github/workflows/backend-main.yml b/.github/workflows/backend-main.yml
new file mode 100644
index 0000000..09afcc7
--- /dev/null
+++ b/.github/workflows/backend-main.yml
@@ -0,0 +1,250 @@
+name: Backend — Main Pipeline
+
+on:
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ # JOB 1 — Lint, format, and validate
+ validate:
+ name: Lint & Validate
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout backend repo
+ uses: actions/checkout@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Validate package files
+ run: |
+ test -f package.json || { echo "MISSING: package.json"; exit 1; }
+ test -f package-lock.json || { echo "MISSING: package-lock.json"; exit 1; }
+ echo "Package files present."
+
+ - name: Run ESLint
+ run: npm run lint
+
+ - name: Run Prettier format check
+ run: npx prettier --check src/
+
+ - name: Validate OpenAPI spec exists and is valid YAML
+ run: |
+ test -f openapi.yaml || { echo "MISSING: openapi.yaml"; exit 1; }
+ node -e "
+ const fs = require('fs');
+ const content = fs.readFileSync('openapi.yaml', 'utf8');
+ if (!content.includes('openapi:')) {
+ console.error('openapi.yaml missing openapi: field');
+ process.exit(1);
+ }
+ if (!content.includes('paths:')) {
+ console.error('openapi.yaml missing paths: field — spec may be incomplete');
+ process.exit(1);
+ }
+ console.log('openapi.yaml validation passed.');
+ "
+
+ # JOB 2 — Unit tests with coverage gate
+ unit-tests:
+ name: Unit Tests & Coverage
+ runs-on: ubuntu-latest
+ needs: validate
+
+ env:
+ NODE_ENV: development
+ PORT: 8080
+ DATABASE_URL: postgresql://dev:devpassword@localhost:5432/rythmify_dev
+ AZURE_STORAGE_CONNECTION_STRING: 'DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://localhost:10000/devstoreaccount1;'
+ BLOB_CONTAINER_AUDIO: audio
+ BLOB_CONTAINER_MEDIA: media
+ JWT_SECRET: ${{ secrets.JWT_SECRET }}
+ JWT_ACCESS_EXPIRES_IN: 15m
+ JWT_REFRESH_EXPIRES_IN: 7d
+ GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
+ GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
+ GOOGLE_CALLBACK_URL: http://localhost:8080/api/v1/auth/google/callback
+ GMAIL_USER: ${{ secrets.GMAIL_USER }}
+ GMAIL_APP_PASSWORD: ${{ secrets.GMAIL_APP_PASSWORD }}
+ EMAIL_FROM: 'Rythmify '
+ RECAPTCHA_SECRET: ${{ secrets.RECAPTCHA_SECRET }}
+
+ services:
+ postgres:
+ image: postgres:16
+ env:
+ POSTGRES_USER: dev
+ POSTGRES_PASSWORD: devpassword
+ POSTGRES_DB: rythmify_dev
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 5s
+ --health-timeout 5s
+ --health-retries 10
+
+ steps:
+ - name: Checkout backend repo
+ uses: actions/checkout@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Wait for PostgreSQL to be ready
+ run: |
+ for i in $(seq 1 20); do
+ pg_isready -h localhost -U dev -d rythmify_dev && echo "PostgreSQL ready." && break
+ sleep 3
+ done
+ pg_isready -h localhost -U dev -d rythmify_dev || { echo "PostgreSQL failed."; exit 1; }
+
+ - name: Run migrations
+ run: npm run migrate:up
+
+ - name: Run unit tests with coverage
+ run: npx jest --runInBand --coverage --coverageThreshold='{"global":{"lines":40,"functions":40,"branches":40,"statements":40}}'
+
+ - name: Upload coverage report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: coverage-report
+ path: coverage/
+ retention-days: 7
+
+ # JOB 3 — Docker build and integration smoke check
+ build-and-smoke:
+ name: Docker Build & Smoke Check
+ runs-on: ubuntu-latest
+ needs: unit-tests
+
+ env:
+ NODE_ENV: development
+ PORT: 8080
+ BASE_URL: http://localhost:8080
+ CLIENT_URL: http://localhost:5173
+ APP_URL: http://localhost:5173
+ POSTGRES_USER: dev
+ POSTGRES_PASSWORD: devpassword
+ POSTGRES_DB: rythmify_dev
+ DATABASE_URL: postgresql://dev:devpassword@localhost:5432/rythmify_dev
+ AZURE_STORAGE_CONNECTION_STRING: 'DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://localhost:10000/devstoreaccount1;'
+ BLOB_CONTAINER_AUDIO: audio
+ BLOB_CONTAINER_MEDIA: media
+ JWT_SECRET: ${{ secrets.JWT_SECRET }}
+ JWT_ACCESS_EXPIRES_IN: 15m
+ JWT_REFRESH_EXPIRES_IN: 7d
+ GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
+ GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
+ GOOGLE_CALLBACK_URL: http://localhost:8080/api/v1/auth/google/callback
+ GMAIL_USER: ${{ secrets.GMAIL_USER }}
+ GMAIL_APP_PASSWORD: ${{ secrets.GMAIL_APP_PASSWORD }}
+ EMAIL_FROM: 'Rythmify '
+ RECAPTCHA_SECRET: ${{ secrets.RECAPTCHA_SECRET }}
+
+ services:
+ postgres:
+ image: postgres:16
+ env:
+ POSTGRES_USER: dev
+ POSTGRES_PASSWORD: devpassword
+ POSTGRES_DB: rythmify_dev
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 5s
+ --health-timeout 5s
+ --health-retries 10
+
+ steps:
+ - name: Checkout backend repo
+ uses: actions/checkout@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Wait for PostgreSQL to be ready
+ run: |
+ for i in $(seq 1 20); do
+ pg_isready -h localhost -U dev -d rythmify_dev && echo "PostgreSQL ready." && break
+ sleep 3
+ done
+ pg_isready -h localhost -U dev -d rythmify_dev || { echo "PostgreSQL failed."; exit 1; }
+
+ - name: Run migrations
+ run: npm run migrate:up
+
+ - name: Run seeds
+ run: psql $DATABASE_URL -f seeds/seed.sql
+
+ - name: Build release-ready Docker image
+ run: docker build -t rythmify-backend:release .
+
+ - name: Start backend container
+ run: |
+ docker run -d \
+ --name rythmify_backend_ci \
+ --network host \
+ -e NODE_ENV=${NODE_ENV} \
+ -e PORT=${PORT} \
+ -e BASE_URL=${BASE_URL} \
+ -e CLIENT_URL=${CLIENT_URL} \
+ -e DATABASE_URL=${DATABASE_URL} \
+ -e AZURE_STORAGE_CONNECTION_STRING="${AZURE_STORAGE_CONNECTION_STRING}" \
+ -e BLOB_CONTAINER_AUDIO=${BLOB_CONTAINER_AUDIO} \
+ -e BLOB_CONTAINER_MEDIA=${BLOB_CONTAINER_MEDIA} \
+ -e JWT_SECRET=${JWT_SECRET} \
+ -e JWT_ACCESS_EXPIRES_IN=${JWT_ACCESS_EXPIRES_IN} \
+ -e JWT_REFRESH_EXPIRES_IN=${JWT_REFRESH_EXPIRES_IN} \
+ -e GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} \
+ -e GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} \
+ -e GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL} \
+ -e GMAIL_USER=${GMAIL_USER} \
+ -e GMAIL_APP_PASSWORD=${GMAIL_APP_PASSWORD} \
+ -e EMAIL_FROM="${EMAIL_FROM}" \
+ -e RECAPTCHA_SECRET=${RECAPTCHA_SECRET} \
+ rythmify-backend:release
+ echo "Backend container started."
+
+ - name: Wait for backend health endpoint
+ run: |
+ echo "Waiting for backend /health..."
+ for i in $(seq 1 30); do
+ curl -sf http://localhost:8080/health > /dev/null && echo "Backend is healthy." && break
+ echo "Attempt $i — not ready yet, waiting 3s..."
+ sleep 3
+ done
+ response=$(curl -sf http://localhost:8080/health) || { echo "Health check failed."; exit 1; }
+ echo "Response: $response"
+ echo "$response" | grep -q '"status":"ok"' || { echo "Unexpected health response."; exit 1; }
+ echo "Smoke check passed."
+
+ - name: Collect container logs on failure
+ if: failure()
+ run: docker logs rythmify_backend_ci 2>&1 || true
+
+ - name: Stop and remove backend container
+ if: always()
+ run: docker rm -f rythmify_backend_ci 2>/dev/null || true
From 23201a8bac1ec9ac3c7a3f1ed170aa456e2e1dc3 Mon Sep 17 00:00:00 2001
From: Mohamed jameel alabasy
Date: Tue, 24 Mar 2026 15:26:22 +0200
Subject: [PATCH 097/535] fix(pipeline): change dependency installation from
npm ci to npm install
---
.github/workflows/backend-dev.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/backend-dev.yml b/.github/workflows/backend-dev.yml
index d3bd52f..9708acd 100644
--- a/.github/workflows/backend-dev.yml
+++ b/.github/workflows/backend-dev.yml
@@ -22,7 +22,7 @@ jobs:
cache: npm
- name: Install dependencies
- run: npm ci
+ run: npm install
- name: Validate package files
run: |
@@ -108,7 +108,7 @@ jobs:
cache: npm
- name: Install dependencies
- run: npm ci
+ run: npm install
- name: Wait for PostgreSQL to be ready
run: |
From 69c9d74d3de401ba5a5266ae04ca840a94afc343 Mon Sep 17 00:00:00 2001
From: Mohamed jameel alabasy
Date: Tue, 24 Mar 2026 15:29:29 +0200
Subject: [PATCH 098/535] fix(storage): remove duplicate blobHTTPHeaders
definition and clean up code
---
src/services/storage.service.js | 7 +------
1 file changed, 1 insertion(+), 6 deletions(-)
diff --git a/src/services/storage.service.js b/src/services/storage.service.js
index 10230f0..5d52614 100644
--- a/src/services/storage.service.js
+++ b/src/services/storage.service.js
@@ -39,9 +39,6 @@ const uploadBlob = async (file, key, type) => {
blobHTTPHeaders: {
blobContentType: file.mimetype,
},
- blobHTTPHeaders: {
- blobContentType: file.mimetype,
- },
});
return {
@@ -93,7 +90,6 @@ const deleteObject = async (key, _versionId = null, type = 'audio') => {
});
return result.succeeded ? 1 : 0;
-
};
const deleteAllVersionsByUrl = async (fileUrl) => {
@@ -107,7 +103,6 @@ const deleteAllVersionsByUrl = async (fileUrl) => {
deleteSnapshots: 'include',
});
-
return result.succeeded ? 1 : 0;
};
@@ -128,4 +123,4 @@ module.exports = {
deleteObject,
deleteAllVersionsByUrl,
deleteManyByUrls,
-};
\ No newline at end of file
+};
From dee540350cf2ee754ba25495048e206d30d571e8 Mon Sep 17 00:00:00 2001
From: Mohamed jameel alabasy
Date: Tue, 24 Mar 2026 15:32:42 +0200
Subject: [PATCH 099/535] refactor: clean up code formatting and remove
unnecessary whitespace across multiple files
---
src/config/blob.js | 2 +-
src/controllers/auth.controller.js | 9 +---
src/controllers/genres.controller.js | 2 +-
src/controllers/messages.controller.js | 30 +++++------
src/controllers/tags.controller.js | 2 +-
src/controllers/tracks.controller.js | 11 ++--
src/middleware/error-handler.js | 2 +-
src/middleware/multer.js | 32 +++++-------
src/middleware/rate-limiter.js | 2 +-
src/middleware/roles.js | 14 ++---
src/models/genre.model.js | 2 +-
src/models/message.model.js | 13 ++---
src/models/oauth-connection.model.js | 20 ++++++--
src/models/tag.model.js | 2 +-
src/models/track.model.js | 71 +++++++++++++++-----------
src/models/verification-token.model.js | 4 +-
src/routes/genres.routes.js | 2 +-
src/routes/messages.routes.js | 33 +++++++++---
src/routes/tags.routes.js | 2 +-
src/routes/users.routes.js | 1 -
src/services/auth.service.js | 21 +++-----
src/services/genres.service.js | 2 +-
src/services/tags.service.js | 2 +-
src/services/tracks.service.js | 13 ++---
src/sockets/messages.socket.js | 48 ++++++++---------
src/utils/api-response.js | 10 +++-
src/utils/app-error.js | 6 +--
src/utils/mailer.js | 7 +--
28 files changed, 191 insertions(+), 174 deletions(-)
diff --git a/src/config/blob.js b/src/config/blob.js
index e139959..0450eec 100644
--- a/src/config/blob.js
+++ b/src/config/blob.js
@@ -15,4 +15,4 @@ async function initBlobContainers() {
console.log('Blob containers initialized');
}
-module.exports = { blobServiceClient, initBlobContainers };
\ No newline at end of file
+module.exports = { blobServiceClient, initBlobContainers };
diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js
index e12a86c..e461a82 100644
--- a/src/controllers/auth.controller.js
+++ b/src/controllers/auth.controller.js
@@ -176,7 +176,6 @@ exports.resetPassword = async (req, res) => {
return success(res, { success: true }, 'Password has been reset successfully.');
};
-
exports.resendVerification = async (req, res) => {
const { email, captcha_token } = req.body;
@@ -209,11 +208,7 @@ exports.changeEmail = async (req, res) => {
new_email: new_email.trim().toLowerCase(),
});
- return success(
- res,
- { success: true },
- 'Verification email sent to the new address.'
- );
+ return success(res, { success: true }, 'Verification email sent to the new address.');
};
exports.verifyEmailChange = async (req, res) => {
@@ -266,4 +261,4 @@ exports.googleLogin = async (req, res) => {
},
'Logged in successfully with Google.'
);
-};
\ No newline at end of file
+};
diff --git a/src/controllers/genres.controller.js b/src/controllers/genres.controller.js
index b1d7224..e8ae729 100644
--- a/src/controllers/genres.controller.js
+++ b/src/controllers/genres.controller.js
@@ -12,4 +12,4 @@ const getAllGenres = async (req, res) => {
module.exports = {
getAllGenres,
-};
\ No newline at end of file
+};
diff --git a/src/controllers/messages.controller.js b/src/controllers/messages.controller.js
index 46114a0..3d7c0e9 100644
--- a/src/controllers/messages.controller.js
+++ b/src/controllers/messages.controller.js
@@ -1,4 +1,3 @@
-
const messagesService = require('../services/messages.service');
const { success, error } = require('../utils/api-response');
@@ -29,7 +28,12 @@ exports.startConversation = async (req, res) => {
});
if (isNew) {
- return success(res, { conversation, message }, 'Conversation created and first message sent.', 201);
+ return success(
+ res,
+ { conversation, message },
+ 'Conversation created and first message sent.',
+ 201
+ );
}
return success(res, { message }, 'Message sent.', 200);
@@ -68,13 +72,12 @@ exports.listConversations = async (req, res) => {
return success(res, data, 'Conversations fetched successfully.');
};
-
// GET /messages/conversations/:conversationId
exports.getConversation = async (req, res) => {
- const userId = getAuthenticatedUserId(req, res);
+ const userId = getAuthenticatedUserId(req, res);
if (!userId) return;
const { conversationId } = req.params;
- const { page, limit } = req.query;
+ const { page, limit } = req.query;
const data = await messagesService.getConversation({
conversationId,
@@ -86,10 +89,9 @@ exports.getConversation = async (req, res) => {
return success(res, data, 'Conversation fetched successfully.');
};
-
// POST /messages/conversations/:conversationId/messages
exports.sendMessage = async (req, res) => {
- const senderId = getAuthenticatedUserId(req, res);
+ const senderId = getAuthenticatedUserId(req, res);
if (!senderId) return;
const { conversationId } = req.params;
const { body, resource } = req.body;
@@ -104,7 +106,6 @@ exports.sendMessage = async (req, res) => {
return success(res, message, 'Message sent.', 201);
};
-
// GET /messages/unread-count
exports.getUnreadCount = async (req, res) => {
const userId = getAuthenticatedUserId(req, res);
@@ -115,13 +116,12 @@ exports.getUnreadCount = async (req, res) => {
return success(res, data, 'Unread message count fetched successfully.');
};
-
// PATCH /messages/conversations/:conversationId/messages/:messageId/read
exports.markMessageReadState = async (req, res) => {
- const userId = getAuthenticatedUserId(req, res);
+ const userId = getAuthenticatedUserId(req, res);
if (!userId) return;
const { conversationId, messageId } = req.params;
- const { is_read } = req.body;
+ const { is_read } = req.body;
if (is_read === undefined || is_read === null) {
return error(res, 'VALIDATION_FAILED', 'is_read is required.', 400);
@@ -141,10 +141,9 @@ exports.markMessageReadState = async (req, res) => {
return success(res, data, 'Message read state updated successfully.');
};
-
// DELETE /messages/conversations/:conversationId/messages/:messageId
exports.deleteMessage = async (req, res) => {
- const userId = getAuthenticatedUserId(req, res);
+ const userId = getAuthenticatedUserId(req, res);
if (!userId) return;
const { conversationId, messageId } = req.params;
@@ -153,14 +152,13 @@ exports.deleteMessage = async (req, res) => {
return success(res, null, 'Message deleted.');
};
-
// DELETE /messages/conversations/:conversationId
exports.deleteConversation = async (req, res) => {
- const userId = getAuthenticatedUserId(req, res);
+ const userId = getAuthenticatedUserId(req, res);
if (!userId) return;
const { conversationId } = req.params;
await messagesService.deleteConversation({ conversationId, userId });
return success(res, null, 'Conversation deleted.');
-};
\ No newline at end of file
+};
diff --git a/src/controllers/tags.controller.js b/src/controllers/tags.controller.js
index 429d163..24c2028 100644
--- a/src/controllers/tags.controller.js
+++ b/src/controllers/tags.controller.js
@@ -12,4 +12,4 @@ const getAllTags = async (req, res) => {
module.exports = {
getAllTags,
-};
\ No newline at end of file
+};
diff --git a/src/controllers/tracks.controller.js b/src/controllers/tracks.controller.js
index 957a14f..21e1a42 100644
--- a/src/controllers/tracks.controller.js
+++ b/src/controllers/tracks.controller.js
@@ -30,13 +30,13 @@ const uploadTrack = async (req, res) => {
};
const getTrackById = async (req, res) => {
- const {track_id} = req.params;
+ const { track_id } = req.params;
const requesterUserId = req.user?.id ?? req.user?.sub ?? req.user?.user_id ?? null;
const track = await tracksService.getTrackById(track_id, requesterUserId);
return success(res, track, 'Track fetched successfully', 200);
-}
+};
const updateTrackVisibility = async (req, res) => {
const { track_id } = req.params;
@@ -88,10 +88,7 @@ const updateTrack = async (req, res) => {
const getTrackStream = async (req, res) => {
const requesterUserId = req.user?.sub || req.user?.id || req.user?.user_id || null;
- const data = await tracksService.getTrackStream(
- req.params.track_id,
- requesterUserId
- );
+ const data = await tracksService.getTrackStream(req.params.track_id, requesterUserId);
return res.status(200).json({
success: true,
@@ -106,5 +103,5 @@ module.exports = {
getMyTracks,
deleteTrack,
updateTrack,
- getTrackStream
+ getTrackStream,
};
diff --git a/src/middleware/error-handler.js b/src/middleware/error-handler.js
index 0a5904c..7d0818d 100644
--- a/src/middleware/error-handler.js
+++ b/src/middleware/error-handler.js
@@ -24,4 +24,4 @@ const errorHandler = (err, req, res, next) => {
return error(res, code, message, statusCode);
};
-module.exports = errorHandler;
\ No newline at end of file
+module.exports = errorHandler;
diff --git a/src/middleware/multer.js b/src/middleware/multer.js
index f14a660..ff0e1b8 100644
--- a/src/middleware/multer.js
+++ b/src/middleware/multer.js
@@ -18,7 +18,7 @@ const audioTypes = [
'audio/flac',
'audio/x-flac',
'audio/aac',
- 'audio/mp4'
+ 'audio/mp4',
];
const imageTypes = ['image/jpeg', 'image/png', 'image/webp'];
@@ -27,39 +27,35 @@ const imageExts = ['.jpg', '.jpeg', '.png', '.webp'];
const isAllowedAudio = (file) => {
const ext = path.extname(file.originalname).toLowerCase();
- return audioTypes.includes(file.mimetype) ||
- (file.mimetype === 'application/octet-stream' && audioExts.includes(ext));
+ return (
+ audioTypes.includes(file.mimetype) ||
+ (file.mimetype === 'application/octet-stream' && audioExts.includes(ext))
+ );
};
const isAllowedImage = (file) => {
const ext = path.extname(file.originalname).toLowerCase();
- return imageTypes.includes(file.mimetype) ||
- (file.mimetype === 'application/octet-stream' && imageExts.includes(ext));
+ return (
+ imageTypes.includes(file.mimetype) ||
+ (file.mimetype === 'application/octet-stream' && imageExts.includes(ext))
+ );
};
const audioFilter = (req, file, cb) => {
- isAllowedAudio(file)
- ? cb(null, true)
- : cb(new Error('Invalid audio format'), false);
+ isAllowedAudio(file) ? cb(null, true) : cb(new Error('Invalid audio format'), false);
};
const imageFilter = (req, file, cb) => {
- isAllowedImage(file)
- ? cb(null, true)
- : cb(new Error('Invalid image format'), false);
+ isAllowedImage(file) ? cb(null, true) : cb(new Error('Invalid image format'), false);
};
const trackFilesFilter = (req, file, cb) => {
if (file.fieldname === 'audio_file') {
- return isAllowedAudio(file)
- ? cb(null, true)
- : cb(new Error('Invalid audio format'), false);
+ return isAllowedAudio(file) ? cb(null, true) : cb(new Error('Invalid audio format'), false);
}
if (file.fieldname === 'cover_image') {
- return isAllowedImage(file)
- ? cb(null, true)
- : cb(new Error('Invalid image format'), false);
+ return isAllowedImage(file) ? cb(null, true) : cb(new Error('Invalid image format'), false);
}
return cb(new Error('Unexpected file field'), false);
@@ -83,4 +79,4 @@ const uploadTrackFiles = multer({
limits: { fileSize: env.MAX_FILE_SIZE_AUDIO },
});
-module.exports = { uploadAudio, uploadImage, uploadTrackFiles };
\ No newline at end of file
+module.exports = { uploadAudio, uploadImage, uploadTrackFiles };
diff --git a/src/middleware/rate-limiter.js b/src/middleware/rate-limiter.js
index 0c3a8d9..fe94ffb 100644
--- a/src/middleware/rate-limiter.js
+++ b/src/middleware/rate-limiter.js
@@ -28,4 +28,4 @@ const refreshLimiter = rateLimit({
message: { success: false, message: 'Too many refresh attempts.' },
});
-module.exports = { generalLimiter, authLimiter, refreshLimiter, uploadLimiter };
\ No newline at end of file
+module.exports = { generalLimiter, authLimiter, refreshLimiter, uploadLimiter };
diff --git a/src/middleware/roles.js b/src/middleware/roles.js
index ceec7ed..f264124 100644
--- a/src/middleware/roles.js
+++ b/src/middleware/roles.js
@@ -4,11 +4,13 @@
// ============================================================
const { error } = require('../utils/api-response');
-const requireRole = (...roles) => (req, res, next) => {
- if (!req.user || !roles.includes(req.user.role)) {
- return error(res, 'Forbidden: insufficient permissions', 403);
- }
- next();
-};
+const requireRole =
+ (...roles) =>
+ (req, res, next) => {
+ if (!req.user || !roles.includes(req.user.role)) {
+ return error(res, 'Forbidden: insufficient permissions', 403);
+ }
+ next();
+ };
module.exports = { requireRole };
diff --git a/src/models/genre.model.js b/src/models/genre.model.js
index fcfaa5b..135d723 100644
--- a/src/models/genre.model.js
+++ b/src/models/genre.model.js
@@ -19,4 +19,4 @@ const getAllGenres = async () => {
module.exports = {
getAllGenres,
-};
\ No newline at end of file
+};
diff --git a/src/models/message.model.js b/src/models/message.model.js
index 836fa65..49900b0 100644
--- a/src/models/message.model.js
+++ b/src/models/message.model.js
@@ -1,4 +1,3 @@
-
const db = require('../config/db');
// ------------------------------------------------------------
@@ -7,7 +6,6 @@ const db = require('../config/db');
// Conversation queries
-
/**
* Find an existing conversation between two users.
* Uses LEAST/GREATEST to normalize pair order in SQL
@@ -16,7 +14,7 @@ exports.findConversationByPair = async (userAId, userBId) => {
const { rows } = await db.query(
`SELECT * FROM conversations
WHERE user_a_id = LEAST($1::uuid, $2::uuid)
- AND user_b_id = GREATEST($1::uuid, $2::uuid)`, //userA is recipient if senderId > recipientId, else userA is sender
+ AND user_b_id = GREATEST($1::uuid, $2::uuid)`, //userA is recipient if senderId > recipientId, else userA is sender
[userAId, userBId]
);
return rows[0] || null;
@@ -74,10 +72,8 @@ exports.createMessage = async ({ conversationId, senderId, body, embedType, embe
return rows[0];
};
-
// Block & preference checks
-
/**
* Returns true if blockerID has blocked blockedId. ////another module responisbility?? BESHOY
*/
@@ -107,7 +103,8 @@ exports.getMessagesFromPreference = async (userId) => {
/**
* Returns true if followerId is following followingId.
*/
-exports.isFollowing = async (followerId, followingId) => { //another module responisbility?? BESHOY
+exports.isFollowing = async (followerId, followingId) => {
+ //another module responisbility?? BESHOY
const { rows } = await db.query(
`SELECT 1 FROM follows
WHERE follower_id = $1 AND following_id = $2`,
@@ -116,7 +113,6 @@ exports.isFollowing = async (followerId, followingId) => { //anothe
return rows.length > 0;
};
-
/**
* Returns the user row (id only) if the user exists and is not deleted/suspended.
*/
@@ -222,7 +218,6 @@ exports.countConversationsByUserId = async (userId) => {
return rows[0].total;
};
-
// ------------------------------------------------------------
// Endpoint 3 — Get a single conversation GET /messages/conversations/:conversationId
// ------------------------------------------------------------
@@ -447,4 +442,4 @@ exports.restoreConversationForUser = async (conversationId, isUserA) => {
WHERE id = $1`,
[conversationId]
);
-};
\ No newline at end of file
+};
diff --git a/src/models/oauth-connection.model.js b/src/models/oauth-connection.model.js
index fe36e4d..f6d4350 100644
--- a/src/models/oauth-connection.model.js
+++ b/src/models/oauth-connection.model.js
@@ -15,13 +15,27 @@ exports.findByProvider = async (provider, providerUserId) => {
};
// Create a new OAuth connection linking a Rythmify user to a provider
-exports.create = async ({ user_id, provider, provider_user_id, access_token, refresh_token, expires_at }) => {
+exports.create = async ({
+ user_id,
+ provider,
+ provider_user_id,
+ access_token,
+ refresh_token,
+ expires_at,
+}) => {
const { rows } = await db.query(
`INSERT INTO oauth_connections
(user_id, provider, provider_user_id, access_token, refresh_token, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
- [user_id, provider, provider_user_id, access_token || null, refresh_token || null, expires_at || null]
+ [
+ user_id,
+ provider,
+ provider_user_id,
+ access_token || null,
+ refresh_token || null,
+ expires_at || null,
+ ]
);
return rows[0];
};
@@ -34,4 +48,4 @@ exports.updateTokens = async ({ user_id, provider, access_token, refresh_token,
WHERE user_id = $1 AND provider = $2`,
[user_id, provider, access_token || null, refresh_token || null, expires_at || null]
);
-};
\ No newline at end of file
+};
diff --git a/src/models/tag.model.js b/src/models/tag.model.js
index 20569f3..e1c9bc9 100644
--- a/src/models/tag.model.js
+++ b/src/models/tag.model.js
@@ -78,4 +78,4 @@ module.exports = {
findByName,
findByIds,
getAllTags,
-};
\ No newline at end of file
+};
diff --git a/src/models/track.model.js b/src/models/track.model.js
index 802d5bd..d21104b 100644
--- a/src/models/track.model.js
+++ b/src/models/track.model.js
@@ -27,11 +27,33 @@ const createTrack = async (t) => {
`;
const values = [
- t.title, t.description, t.genre_id, t.cover_image, t.audio_url, t.file_size, t.status, t.is_public, t.user_id,
- t.release_date, t.isrc, t.p_line, t.buy_link, t.record_label, t.publisher,
- t.explicit_content, t.license_type,
- t.enable_downloads, t.enable_offline_listening, t.include_in_rss_feed, t.display_embed_code, t.enable_app_playback,
- t.allow_comments, t.show_comments_public, t.show_insights_public, t.geo_restriction_type, JSON.stringify(t.geo_regions || []),
+ t.title,
+ t.description,
+ t.genre_id,
+ t.cover_image,
+ t.audio_url,
+ t.file_size,
+ t.status,
+ t.is_public,
+ t.user_id,
+ t.release_date,
+ t.isrc,
+ t.p_line,
+ t.buy_link,
+ t.record_label,
+ t.publisher,
+ t.explicit_content,
+ t.license_type,
+ t.enable_downloads,
+ t.enable_offline_listening,
+ t.include_in_rss_feed,
+ t.display_embed_code,
+ t.enable_app_playback,
+ t.allow_comments,
+ t.show_comments_public,
+ t.show_insights_public,
+ t.geo_restriction_type,
+ JSON.stringify(t.geo_regions || []),
];
const result = await db.query(query, values);
@@ -59,10 +81,9 @@ const addTrackArtists = async (trackId, artistIds) => {
const getGenreIdByName = async (genreName) => {
if (!genreName) return null;
- const result = await db.query(
- `SELECT id FROM genres WHERE LOWER(name) = LOWER($1) LIMIT 1`,
- [genreName]
- );
+ const result = await db.query(`SELECT id FROM genres WHERE LOWER(name) = LOWER($1) LIMIT 1`, [
+ genreName,
+ ]);
return result.rows[0]?.id || null;
};
@@ -308,11 +329,9 @@ const updateTrackFields = async (trackId, updates) => {
const setClauses = entries.map(([key], index) => `"${key}" = $${index + 2}`);
const values = [
- trackId,
- ...entries.map(([key, value]) =>
- key === 'geo_regions' ? JSON.stringify(value) : value
- ),
-];
+ trackId,
+ ...entries.map(([key, value]) => (key === 'geo_regions' ? JSON.stringify(value) : value)),
+ ];
const query = `
UPDATE tracks
@@ -326,28 +345,22 @@ const updateTrackFields = async (trackId, updates) => {
};
const replaceTrackTags = async (trackId, tagIds) => {
- await db.query(
- `DELETE FROM track_tags WHERE track_id = $1`,
- [trackId]
- );
+ await db.query(`DELETE FROM track_tags WHERE track_id = $1`, [trackId]);
if (!tagIds || !tagIds.length) {
return;
}
for (const tagId of tagIds) {
- await db.query(
- `INSERT INTO track_tags (track_id, tag_id) VALUES ($1, $2)`,
- [trackId, tagId]
- );
+ await db.query(`INSERT INTO track_tags (track_id, tag_id) VALUES ($1, $2)`, [trackId, tagId]);
}
};
-module.exports = {
- createTrack,
- addTrackTags,
- addTrackArtists,
- getGenreIdByName,
+module.exports = {
+ createTrack,
+ addTrackTags,
+ addTrackArtists,
+ getGenreIdByName,
getTagIdsByTrackId,
findTrackByIdWithDetails,
updateTrackVisibility,
@@ -355,5 +368,5 @@ module.exports = {
softDeleteTrack,
deleteTrackPermanently,
updateTrackFields,
- replaceTrackTags
-};
\ No newline at end of file
+ replaceTrackTags,
+};
diff --git a/src/models/verification-token.model.js b/src/models/verification-token.model.js
index c00fcdd..4f9a3d1 100644
--- a/src/models/verification-token.model.js
+++ b/src/models/verification-token.model.js
@@ -5,7 +5,6 @@
// ============================================================
const db = require('../config/db');
-
// Create a new verification token Used in: register, resend-verification, forgot-password
exports.create = async ({ user_id, token, type, expires_at }) => {
const { rows } = await db.query(
@@ -17,7 +16,6 @@ exports.create = async ({ user_id, token, type, expires_at }) => {
return rows[0];
};
-
// Find a valid token must staisfy all (4 conditions) Used in: verify-email, reset-password
exports.findValidToken = async (token, type) => {
const { rows } = await db.query(
@@ -53,4 +51,4 @@ exports.revokeAllForUser = async (userId, type) => {
AND used_at IS NULL`,
[userId, type]
);
-};
\ No newline at end of file
+};
diff --git a/src/routes/genres.routes.js b/src/routes/genres.routes.js
index 07a3e56..f094235 100644
--- a/src/routes/genres.routes.js
+++ b/src/routes/genres.routes.js
@@ -6,4 +6,4 @@ const asyncHandler = require('../utils/async-handler');
// GET /api/v1/genres
router.get('/', asyncHandler(controller.getAllGenres));
-module.exports = router;
\ No newline at end of file
+module.exports = router;
diff --git a/src/routes/messages.routes.js b/src/routes/messages.routes.js
index b2ce6b8..80fe1fb 100644
--- a/src/routes/messages.routes.js
+++ b/src/routes/messages.routes.js
@@ -1,4 +1,3 @@
-
const express = require('express');
const router = express.Router();
const controller = require('../controllers/messages.controller');
@@ -15,21 +14,41 @@ router.post('/conversations/ensure', authenticate, asyncHandler(controller.ensur
router.get('/conversations', authenticate, asyncHandler(controller.listConversations));
// GET /messages/conversations/:conversationId — Get a single conversation with messages
-router.get('/conversations/:conversationId', authenticate, asyncHandler(controller.getConversation));
+router.get(
+ '/conversations/:conversationId',
+ authenticate,
+ asyncHandler(controller.getConversation)
+);
// POST /messages/conversations/:conversationId/messages — Send a message in a conversation
-router.post('/conversations/:conversationId/messages', authenticate, asyncHandler(controller.sendMessage));
+router.post(
+ '/conversations/:conversationId/messages',
+ authenticate,
+ asyncHandler(controller.sendMessage)
+);
// GET /messages/unread-count — Get total unread message count
router.get('/unread-count', authenticate, asyncHandler(controller.getUnreadCount));
// PATCH /messages/conversations/:conversationId/messages/:messageId/read — Mark a message as read/unread
-router.patch('/conversations/:conversationId/messages/:messageId/read', authenticate, asyncHandler(controller.markMessageReadState));
+router.patch(
+ '/conversations/:conversationId/messages/:messageId/read',
+ authenticate,
+ asyncHandler(controller.markMessageReadState)
+);
// DELETE /messages/conversations/:conversationId/messages/:messageId — Delete a specific message
-router.delete('/conversations/:conversationId/messages/:messageId', authenticate, asyncHandler(controller.deleteMessage));
+router.delete(
+ '/conversations/:conversationId/messages/:messageId',
+ authenticate,
+ asyncHandler(controller.deleteMessage)
+);
// DELETE /messages/conversations/:conversationId — Soft delete a conversation
-router.delete('/conversations/:conversationId', authenticate, asyncHandler(controller.deleteConversation));
+router.delete(
+ '/conversations/:conversationId',
+ authenticate,
+ asyncHandler(controller.deleteConversation)
+);
-module.exports = router;
\ No newline at end of file
+module.exports = router;
diff --git a/src/routes/tags.routes.js b/src/routes/tags.routes.js
index 3dd23b8..32859eb 100644
--- a/src/routes/tags.routes.js
+++ b/src/routes/tags.routes.js
@@ -6,4 +6,4 @@ const asyncHandler = require('../utils/async-handler');
// GET /api/v1/tags
router.get('/', asyncHandler(controller.getAllTags));
-module.exports = router;
\ No newline at end of file
+module.exports = router;
diff --git a/src/routes/users.routes.js b/src/routes/users.routes.js
index 2d83d9c..7219a10 100644
--- a/src/routes/users.routes.js
+++ b/src/routes/users.routes.js
@@ -52,7 +52,6 @@ router.get('/me/genres', authenticate, asyncHandler(controller.getMyGenres));
router.put('/me/genres', authenticate, asyncHandler(controller.replaceMyGenres));
router.patch('/me/onboarding', authenticate, asyncHandler(controller.completeOnboarding));
-
router.get('/:user_id', optionalAuthenticate, asyncHandler(controller.getUserById));
module.exports = router;
diff --git a/src/services/auth.service.js b/src/services/auth.service.js
index 3754cc8..ad75f24 100644
--- a/src/services/auth.service.js
+++ b/src/services/auth.service.js
@@ -17,8 +17,6 @@ const { signAccessToken, signRefreshToken, verifyToken } = require('../config/jw
const env = require('../config/env');
const { randomUUID } = require('crypto');
-
-
//=====================================
// Registration and Email verification
//=====================================
@@ -124,13 +122,12 @@ const buildAuthResponse = ({ user, accessToken, refreshToken }) => ({
is_new_user: false,
});
-
const createAndStoreRefreshToken = async (userId) => {
- const refreshToken = signRefreshToken({
+ const refreshToken = signRefreshToken({
sub: userId,
- jti: randomUUID()
+ jti: randomUUID(),
});
-
+
const refreshExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
await refreshTokenModel.create({
@@ -229,11 +226,7 @@ exports.refresh = async ({ refresh_token }) => {
});
if (!result) {
- throw new AppError(
- 'Refresh token already used',
- 401,
- 'AUTH_REFRESH_TOKEN_INVALID'
- );
+ throw new AppError('Refresh token already used', 401, 'AUTH_REFRESH_TOKEN_INVALID');
}
const accessToken = signAccessToken({ sub: user.id, role: user.role });
@@ -411,7 +404,7 @@ exports.verifyEmailChange = async ({ token }) => {
// ============================================================
const googleClient = new OAuth2Client(env.GOOGLE_CLIENT_ID);
exports.googleLogin = async ({ id_token }) => {
- // verify the token from google
+ // verify the token from google
let payload;
try {
const ticket = await googleClient.verifyIdToken({
@@ -430,7 +423,7 @@ exports.googleLogin = async ({ id_token }) => {
const existingConnection = await oauthConnectionModel.findByProvider('google', providerUserId);
if (existingConnection) {
- // we need to only update the token
+ // we need to only update the token
const user = await userModel.findById(existingConnection.user_id);
if (user.is_suspended) {
@@ -487,4 +480,4 @@ exports.googleLogin = async ({ id_token }) => {
is_new_user,
user,
};
-};
\ No newline at end of file
+};
diff --git a/src/services/genres.service.js b/src/services/genres.service.js
index ae1bc09..52cc3d2 100644
--- a/src/services/genres.service.js
+++ b/src/services/genres.service.js
@@ -13,4 +13,4 @@ const getAllGenres = async () => {
module.exports = {
getAllGenres,
-};
\ No newline at end of file
+};
diff --git a/src/services/tags.service.js b/src/services/tags.service.js
index f57511b..8fa5849 100644
--- a/src/services/tags.service.js
+++ b/src/services/tags.service.js
@@ -13,4 +13,4 @@ const getAllTags = async () => {
module.exports = {
getAllTags,
-};
\ No newline at end of file
+};
diff --git a/src/services/tracks.service.js b/src/services/tracks.service.js
index a369151..a1b94c5 100644
--- a/src/services/tracks.service.js
+++ b/src/services/tracks.service.js
@@ -12,8 +12,7 @@ const storageService = require('./storage.service.js');
const GEO_RESTRICTION_TYPES = ['worldwide', 'exclusive_regions', 'blocked_regions'];
-const looksLikeDbId = (value) =>
- typeof value === 'string' && value.includes('-');
+const looksLikeDbId = (value) => typeof value === 'string' && value.includes('-');
const resolveGeoSettings = ({
geoRestrictionTypeInput,
@@ -192,14 +191,10 @@ const mapTrackTagsToNames = async (track) => {
return track;
}
- const resolvedTags = await hydrateTagNamesByIds(
- track.tags.filter((tag) => looksLikeDbId(tag))
- );
+ const resolvedTags = await hydrateTagNamesByIds(track.tags.filter((tag) => looksLikeDbId(tag)));
const resolvedIdTags = track.tags.filter((tag) => looksLikeDbId(tag));
- const nameById = new Map(
- resolvedIdTags.map((id, index) => [String(id), resolvedTags[index]])
- );
+ const nameById = new Map(resolvedIdTags.map((id, index) => [String(id), resolvedTags[index]]));
return {
...track,
@@ -625,4 +620,4 @@ module.exports = {
//
// TODO Add track upload limit validations based on subscription plan
-//
\ No newline at end of file
+//
diff --git a/src/sockets/messages.socket.js b/src/sockets/messages.socket.js
index 7b5b261..45e66f1 100644
--- a/src/sockets/messages.socket.js
+++ b/src/sockets/messages.socket.js
@@ -24,7 +24,6 @@ const isValidUUID = (val) =>
// ------------------------------------------------------------
const registerMessageHandlers = (io, socket) => {
-
// userId extracted from JWT-verified payload set by server.js middleware
const userId = socket.user?.sub;
const ensureAuthenticated = () => {
@@ -64,7 +63,6 @@ const registerMessageHandlers = (io, socket) => {
// because this is a socket-only event with no HTTP equivalent
// ----------------------------------------------------------
socket.on('message:join', async ({ conversationId } = {}) => {
-
if (!ensureAuthenticated()) {
return;
}
@@ -152,29 +150,32 @@ const registerMessageHandlers = (io, socket) => {
// Client emits AFTER
// PATCH /messages/conversations/:conversationId/messages/:messageId/read succeeds
// ----------------------------------------------------------
- socket.on('message:read', async ({ conversationId, messageId, isRead, conversationUnreadCount } = {}) => {
- if (!ensureAuthenticated()) {
- return;
- }
+ socket.on(
+ 'message:read',
+ async ({ conversationId, messageId, isRead, conversationUnreadCount } = {}) => {
+ if (!ensureAuthenticated()) {
+ return;
+ }
- if (!isValidUUID(messageId) || typeof isRead !== 'boolean') {
- return socket.emit('error', { message: 'Invalid payload.' });
- }
+ if (!isValidUUID(messageId) || typeof isRead !== 'boolean') {
+ return socket.emit('error', { message: 'Invalid payload.' });
+ }
- const canAccess = await ensureParticipantInConversation(conversationId);
- if (!canAccess) {
- return;
- }
+ const canAccess = await ensureParticipantInConversation(conversationId);
+ if (!canAccess) {
+ return;
+ }
- const room = `conversation:${conversationId}`;
- socket.to(room).emit('message:read_updated', {
- conversationId,
- messageId,
- isRead,
- conversationUnreadCount,
- });
- console.log(`[Socket.IO] message:read in ${room} by user: ${userId}`);
- });
+ const room = `conversation:${conversationId}`;
+ socket.to(room).emit('message:read_updated', {
+ conversationId,
+ messageId,
+ isRead,
+ conversationUnreadCount,
+ });
+ console.log(`[Socket.IO] message:read in ${room} by user: ${userId}`);
+ }
+ );
// ----------------------------------------------------------
// Typing indicator
@@ -212,7 +213,6 @@ const registerMessageHandlers = (io, socket) => {
const room = `conversation:${conversationId}`;
socket.to(room).emit('message:stop_typing', { conversationId, userId });
});
-
};
-module.exports = { registerMessageHandlers };
\ No newline at end of file
+module.exports = { registerMessageHandlers };
diff --git a/src/utils/api-response.js b/src/utils/api-response.js
index 741e53b..44b5603 100644
--- a/src/utils/api-response.js
+++ b/src/utils/api-response.js
@@ -8,10 +8,16 @@ const success = (res, data = null, message = 'OK', statusCode = 200) => {
return res.status(statusCode).json({ data, message });
};
-const error = (res, code = 'INTERNAL_ERROR', message = 'Something went wrong', statusCode = 500, details = null) => {
+const error = (
+ res,
+ code = 'INTERNAL_ERROR',
+ message = 'Something went wrong',
+ statusCode = 500,
+ details = null
+) => {
const body = { error: { code, message } };
if (details) body.error.details = details;
return res.status(statusCode).json(body);
};
-module.exports = { success, error };
\ No newline at end of file
+module.exports = { success, error };
diff --git a/src/utils/app-error.js b/src/utils/app-error.js
index 05e425e..f9cd99d 100644
--- a/src/utils/app-error.js
+++ b/src/utils/app-error.js
@@ -6,11 +6,11 @@
class AppError extends Error {
constructor(message, statusCode, code) {
super(message);
- this.statusCode = statusCode;
- this.code = code;
+ this.statusCode = statusCode;
+ this.code = code;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
-module.exports = AppError;
\ No newline at end of file
+module.exports = AppError;
diff --git a/src/utils/mailer.js b/src/utils/mailer.js
index 84ccd30..e55bc1b 100644
--- a/src/utils/mailer.js
+++ b/src/utils/mailer.js
@@ -1,6 +1,6 @@
// ============================================================
// utils/mailer.js — Nodemailer transporter + email templates
-// Uses Gmail App Password
+// Uses Gmail App Password
// ============================================================
const nodemailer = require('nodemailer');
const env = require('../config/env');
@@ -13,7 +13,6 @@ const transporter = nodemailer.createTransport({
},
});
-
const baseTemplate = ({ title, previewText, bodyContent }) => `
@@ -196,7 +195,6 @@ const sendPasswordResetEmail = async (to, { displayName, token }) => {
});
};
-
// email change verification
const sendEmailChangeEmail = async (to, { displayName, token }) => {
const link = `${env.APP_URL}/verify-email-change?token=${token}`;
@@ -231,10 +229,9 @@ const sendEmailChangeEmail = async (to, { displayName, token }) => {
});
};
-
module.exports = {
sendVerificationEmail,
sendResendVerificationEmail,
sendPasswordResetEmail,
sendEmailChangeEmail,
-};
\ No newline at end of file
+};
From 8dca180a459b9d868a098e81117f3bd94eade711 Mon Sep 17 00:00:00 2001
From: Omar Hamza
Date: Tue, 24 Mar 2026 16:23:01 +0200
Subject: [PATCH 100/535] chore(deps): sync package-lock and fix dependency
inconsistencies
- Resolved mismatch between package.json and package-lock.json
- Ran npm install to update and synchronize lock file
- Applied npm audit fix for safe dependency updates
- Ensured compatibility with npm ci for CI/CD pipelines
- No breaking changes introduced
---
package-lock.json | 3422 +++++++++++++--------------------------------
1 file changed, 995 insertions(+), 2427 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index c6c2764..b2f533b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,10 +8,8 @@
"name": "rythmify-backend",
"version": "1.0.0",
"dependencies": {
- "@aws-sdk/client-s3": "^3.1009.0",
- "@aws-sdk/s3-request-presigner": "^3.1008.0",
+ "@azure/storage-blob": "12.17.0",
"bcryptjs": "^2.4.3",
- "cloudinary": "^1.41.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"db-migrate": "^0.11.14",
@@ -39,2698 +37,1276 @@
"supertest": "^7.0.0"
}
},
- "node_modules/@aws-crypto/crc32": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
- "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==",
- "license": "Apache-2.0",
+ "node_modules/@azure/abort-controller": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.1.0.tgz",
+ "integrity": "sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw==",
"dependencies": {
- "@aws-crypto/util": "^5.2.0",
- "@aws-sdk/types": "^3.222.0",
- "tslib": "^2.6.2"
+ "tslib": "^2.2.0"
},
"engines": {
- "node": ">=16.0.0"
- }
- },
- "node_modules/@aws-crypto/crc32c": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz",
- "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-crypto/util": "^5.2.0",
- "@aws-sdk/types": "^3.222.0",
- "tslib": "^2.6.2"
+ "node": ">=12.0.0"
}
},
- "node_modules/@aws-crypto/sha1-browser": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz",
- "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==",
- "license": "Apache-2.0",
+ "node_modules/@azure/core-auth": {
+ "version": "1.10.1",
+ "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz",
+ "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==",
"dependencies": {
- "@aws-crypto/supports-web-crypto": "^5.2.0",
- "@aws-crypto/util": "^5.2.0",
- "@aws-sdk/types": "^3.222.0",
- "@aws-sdk/util-locate-window": "^3.0.0",
- "@smithy/util-utf8": "^2.0.0",
+ "@azure/abort-controller": "^2.1.2",
+ "@azure/core-util": "^1.13.0",
"tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
}
},
- "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz",
- "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
- "license": "Apache-2.0",
+ "node_modules/@azure/core-auth/node_modules/@azure/abort-controller": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
+ "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
- "node": ">=14.0.0"
+ "node": ">=18.0.0"
}
},
- "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz",
- "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
- "license": "Apache-2.0",
- "dependencies": {
- "@smithy/is-array-buffer": "^2.2.0",
- "tslib": "^2.6.2"
+ "node_modules/@azure/core-http": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@azure/core-http/-/core-http-3.0.5.tgz",
+ "integrity": "sha512-T8r2q/c3DxNu6mEJfPuJtptUVqwchxzjj32gKcnMi06rdiVONS9rar7kT9T2Am+XvER7uOzpsP79WsqNbdgdWg==",
+ "deprecated": "This package is no longer supported. Please refer to https://github.com/Azure/azure-sdk-for-js/blob/490ce4dfc5b98ba290dee3b33a6d0876c5f138e2/sdk/core/README.md",
+ "dependencies": {
+ "@azure/abort-controller": "^1.0.0",
+ "@azure/core-auth": "^1.3.0",
+ "@azure/core-tracing": "1.0.0-preview.13",
+ "@azure/core-util": "^1.1.1",
+ "@azure/logger": "^1.0.0",
+ "@types/node-fetch": "^2.5.0",
+ "@types/tunnel": "^0.0.3",
+ "form-data": "^4.0.0",
+ "node-fetch": "^2.6.7",
+ "process": "^0.11.10",
+ "tslib": "^2.2.0",
+ "tunnel": "^0.0.6",
+ "uuid": "^8.3.0",
+ "xml2js": "^0.5.0"
},
"engines": {
- "node": ">=14.0.0"
+ "node": ">=18.0.0"
}
},
- "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz",
- "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
- "license": "Apache-2.0",
+ "node_modules/@azure/core-http/node_modules/node-fetch": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+ "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"dependencies": {
- "@smithy/util-buffer-from": "^2.2.0",
- "tslib": "^2.6.2"
+ "whatwg-url": "^5.0.0"
},
"engines": {
- "node": ">=14.0.0"
+ "node": "4.x || >=6.0.0"
+ },
+ "peerDependencies": {
+ "encoding": "^0.1.0"
+ },
+ "peerDependenciesMeta": {
+ "encoding": {
+ "optional": true
+ }
}
},
- "node_modules/@aws-crypto/sha256-browser": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz",
- "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-crypto/sha256-js": "^5.2.0",
- "@aws-crypto/supports-web-crypto": "^5.2.0",
- "@aws-crypto/util": "^5.2.0",
- "@aws-sdk/types": "^3.222.0",
- "@aws-sdk/util-locate-window": "^3.0.0",
- "@smithy/util-utf8": "^2.0.0",
- "tslib": "^2.6.2"
+ "node_modules/@azure/core-http/node_modules/uuid": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+ "bin": {
+ "uuid": "dist/bin/uuid"
}
},
- "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz",
- "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
- "license": "Apache-2.0",
+ "node_modules/@azure/core-lro": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz",
+ "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==",
"dependencies": {
+ "@azure/abort-controller": "^2.0.0",
+ "@azure/core-util": "^1.2.0",
+ "@azure/logger": "^1.0.0",
"tslib": "^2.6.2"
},
"engines": {
- "node": ">=14.0.0"
+ "node": ">=18.0.0"
}
},
- "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz",
- "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
- "license": "Apache-2.0",
+ "node_modules/@azure/core-lro/node_modules/@azure/abort-controller": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
+ "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==",
"dependencies": {
- "@smithy/is-array-buffer": "^2.2.0",
"tslib": "^2.6.2"
},
"engines": {
- "node": ">=14.0.0"
+ "node": ">=18.0.0"
}
},
- "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz",
- "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
- "license": "Apache-2.0",
+ "node_modules/@azure/core-paging": {
+ "version": "1.6.2",
+ "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.6.2.tgz",
+ "integrity": "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==",
"dependencies": {
- "@smithy/util-buffer-from": "^2.2.0",
"tslib": "^2.6.2"
},
"engines": {
- "node": ">=14.0.0"
+ "node": ">=18.0.0"
}
},
- "node_modules/@aws-crypto/sha256-js": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz",
- "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==",
- "license": "Apache-2.0",
+ "node_modules/@azure/core-tracing": {
+ "version": "1.0.0-preview.13",
+ "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.0.0-preview.13.tgz",
+ "integrity": "sha512-KxDlhXyMlh2Jhj2ykX6vNEU0Vou4nHr025KoSEiz7cS3BNiHNaZcdECk/DmLkEB0as5T7b/TpRcehJ5yV6NeXQ==",
"dependencies": {
- "@aws-crypto/util": "^5.2.0",
- "@aws-sdk/types": "^3.222.0",
- "tslib": "^2.6.2"
+ "@opentelemetry/api": "^1.0.1",
+ "tslib": "^2.2.0"
},
"engines": {
- "node": ">=16.0.0"
- }
- },
- "node_modules/@aws-crypto/supports-web-crypto": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz",
- "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==",
- "license": "Apache-2.0",
- "dependencies": {
- "tslib": "^2.6.2"
+ "node": ">=12.0.0"
}
},
- "node_modules/@aws-crypto/util": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz",
- "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==",
- "license": "Apache-2.0",
+ "node_modules/@azure/core-util": {
+ "version": "1.13.1",
+ "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz",
+ "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==",
"dependencies": {
- "@aws-sdk/types": "^3.222.0",
- "@smithy/util-utf8": "^2.0.0",
+ "@azure/abort-controller": "^2.1.2",
+ "@typespec/ts-http-runtime": "^0.3.0",
"tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
}
},
- "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz",
- "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
- "license": "Apache-2.0",
+ "node_modules/@azure/core-util/node_modules/@azure/abort-controller": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
+ "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
- "node": ">=14.0.0"
+ "node": ">=18.0.0"
}
},
- "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz",
- "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
- "license": "Apache-2.0",
+ "node_modules/@azure/logger": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz",
+ "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==",
"dependencies": {
- "@smithy/is-array-buffer": "^2.2.0",
+ "@typespec/ts-http-runtime": "^0.3.0",
"tslib": "^2.6.2"
},
"engines": {
- "node": ">=14.0.0"
+ "node": ">=20.0.0"
}
},
- "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz",
- "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
- "license": "Apache-2.0",
+ "node_modules/@azure/storage-blob": {
+ "version": "12.17.0",
+ "resolved": "https://registry.npmjs.org/@azure/storage-blob/-/storage-blob-12.17.0.tgz",
+ "integrity": "sha512-sM4vpsCpcCApagRW5UIjQNlNylo02my2opgp0Emi8x888hZUvJ3dN69Oq20cEGXkMUWnoCrBaB0zyS3yeB87sQ==",
"dependencies": {
- "@smithy/util-buffer-from": "^2.2.0",
- "tslib": "^2.6.2"
+ "@azure/abort-controller": "^1.0.0",
+ "@azure/core-http": "^3.0.0",
+ "@azure/core-lro": "^2.2.0",
+ "@azure/core-paging": "^1.1.1",
+ "@azure/core-tracing": "1.0.0-preview.13",
+ "@azure/logger": "^1.0.0",
+ "events": "^3.0.0",
+ "tslib": "^2.2.0"
},
"engines": {
"node": ">=14.0.0"
}
},
- "node_modules/@aws-sdk/client-s3": {
- "version": "3.1009.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1009.0.tgz",
- "integrity": "sha512-luy8CxallkoiGWTqU86ca/BbvkWJjs0oala7uIIRN1JtQxMb5i4Yl/PBZVcQFhbK9kQi0PK0GfD8gIpLkI91fw==",
- "license": "Apache-2.0",
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "dev": true,
"dependencies": {
- "@aws-crypto/sha1-browser": "5.2.0",
- "@aws-crypto/sha256-browser": "5.2.0",
- "@aws-crypto/sha256-js": "5.2.0",
- "@aws-sdk/core": "^3.973.20",
- "@aws-sdk/credential-provider-node": "^3.972.21",
- "@aws-sdk/middleware-bucket-endpoint": "^3.972.8",
- "@aws-sdk/middleware-expect-continue": "^3.972.8",
- "@aws-sdk/middleware-flexible-checksums": "^3.973.6",
- "@aws-sdk/middleware-host-header": "^3.972.8",
- "@aws-sdk/middleware-location-constraint": "^3.972.8",
- "@aws-sdk/middleware-logger": "^3.972.8",
- "@aws-sdk/middleware-recursion-detection": "^3.972.8",
- "@aws-sdk/middleware-sdk-s3": "^3.972.20",
- "@aws-sdk/middleware-ssec": "^3.972.8",
- "@aws-sdk/middleware-user-agent": "^3.972.21",
- "@aws-sdk/region-config-resolver": "^3.972.8",
- "@aws-sdk/signature-v4-multi-region": "^3.996.8",
- "@aws-sdk/types": "^3.973.6",
- "@aws-sdk/util-endpoints": "^3.996.5",
- "@aws-sdk/util-user-agent-browser": "^3.972.8",
- "@aws-sdk/util-user-agent-node": "^3.973.7",
- "@smithy/config-resolver": "^4.4.11",
- "@smithy/core": "^3.23.11",
- "@smithy/eventstream-serde-browser": "^4.2.12",
- "@smithy/eventstream-serde-config-resolver": "^4.3.12",
- "@smithy/eventstream-serde-node": "^4.2.12",
- "@smithy/fetch-http-handler": "^5.3.15",
- "@smithy/hash-blob-browser": "^4.2.13",
- "@smithy/hash-node": "^4.2.12",
- "@smithy/hash-stream-node": "^4.2.12",
- "@smithy/invalid-dependency": "^4.2.12",
- "@smithy/md5-js": "^4.2.12",
- "@smithy/middleware-content-length": "^4.2.12",
- "@smithy/middleware-endpoint": "^4.4.25",
- "@smithy/middleware-retry": "^4.4.42",
- "@smithy/middleware-serde": "^4.2.14",
- "@smithy/middleware-stack": "^4.2.12",
- "@smithy/node-config-provider": "^4.3.12",
- "@smithy/node-http-handler": "^4.4.16",
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/smithy-client": "^4.12.5",
- "@smithy/types": "^4.13.1",
- "@smithy/url-parser": "^4.2.12",
- "@smithy/util-base64": "^4.3.2",
- "@smithy/util-body-length-browser": "^4.2.2",
- "@smithy/util-body-length-node": "^4.2.3",
- "@smithy/util-defaults-mode-browser": "^4.3.41",
- "@smithy/util-defaults-mode-node": "^4.2.44",
- "@smithy/util-endpoints": "^3.3.3",
- "@smithy/util-middleware": "^4.2.12",
- "@smithy/util-retry": "^4.2.12",
- "@smithy/util-stream": "^4.5.19",
- "@smithy/util-utf8": "^4.2.2",
- "@smithy/util-waiter": "^4.2.13",
- "tslib": "^2.6.2"
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
},
"engines": {
- "node": ">=20.0.0"
+ "node": ">=6.9.0"
}
},
- "node_modules/@aws-sdk/core": {
- "version": "3.973.20",
- "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.20.tgz",
- "integrity": "sha512-i3GuX+lowD892F3IuJf8o6AbyDupMTdyTxQrCJGcn71ni5hTZ82L4nQhcdumxZ7XPJRJJVHS/CR3uYOIIs0PVA==",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-sdk/types": "^3.973.6",
- "@aws-sdk/xml-builder": "^3.972.11",
- "@smithy/core": "^3.23.11",
- "@smithy/node-config-provider": "^4.3.12",
- "@smithy/property-provider": "^4.2.12",
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/signature-v4": "^5.3.12",
- "@smithy/smithy-client": "^4.12.5",
- "@smithy/types": "^4.13.1",
- "@smithy/util-base64": "^4.3.2",
- "@smithy/util-middleware": "^4.2.12",
- "@smithy/util-utf8": "^4.2.2",
- "tslib": "^2.6.2"
- },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
+ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+ "dev": true,
"engines": {
- "node": ">=20.0.0"
+ "node": ">=6.9.0"
}
},
- "node_modules/@aws-sdk/crc64-nvme": {
- "version": "3.972.5",
- "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.5.tgz",
- "integrity": "sha512-2VbTstbjKdT+yKi8m7b3a9CiVac+pL/IY2PHJwsaGkkHmuuqkJZIErPck1h6P3T9ghQMLSdMPyW6Qp7Di5swFg==",
- "license": "Apache-2.0",
+ "node_modules/@babel/core": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "dev": true,
"dependencies": {
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
},
"engines": {
- "node": ">=20.0.0"
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
}
},
- "node_modules/@aws-sdk/credential-provider-env": {
- "version": "3.972.18",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.18.tgz",
- "integrity": "sha512-X0B8AlQY507i5DwjLByeU2Af4ARsl9Vr84koDcXCbAkplmU+1xBFWxEPrWRAoh56waBne/yJqEloSwvRf4x6XA==",
- "license": "Apache-2.0",
+ "node_modules/@babel/generator": {
+ "version": "7.29.1",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+ "dev": true,
"dependencies": {
- "@aws-sdk/core": "^3.973.20",
- "@aws-sdk/types": "^3.973.6",
- "@smithy/property-provider": "^4.2.12",
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
},
"engines": {
- "node": ">=20.0.0"
+ "node": ">=6.9.0"
}
},
- "node_modules/@aws-sdk/credential-provider-http": {
- "version": "3.972.20",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.20.tgz",
- "integrity": "sha512-ey9Lelj001+oOfrbKmS6R2CJAiXX7QKY4Vj9VJv6L2eE6/VjD8DocHIoYqztTm70xDLR4E1jYPTKfIui+eRNDA==",
- "license": "Apache-2.0",
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "dev": true,
"dependencies": {
- "@aws-sdk/core": "^3.973.20",
- "@aws-sdk/types": "^3.973.6",
- "@smithy/fetch-http-handler": "^5.3.15",
- "@smithy/node-http-handler": "^4.4.16",
- "@smithy/property-provider": "^4.2.12",
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/smithy-client": "^4.12.5",
- "@smithy/types": "^4.13.1",
- "@smithy/util-stream": "^4.5.19",
- "tslib": "^2.6.2"
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
},
"engines": {
- "node": ">=20.0.0"
+ "node": ">=6.9.0"
}
},
- "node_modules/@aws-sdk/credential-provider-ini": {
- "version": "3.972.20",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.20.tgz",
- "integrity": "sha512-5flXSnKHMloObNF+9N0cupKegnH1Z37cdVlpETVgx8/rAhCe+VNlkcZH3HDg2SDn9bI765S+rhNPXGDJJPfbtA==",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-sdk/core": "^3.973.20",
- "@aws-sdk/credential-provider-env": "^3.972.18",
- "@aws-sdk/credential-provider-http": "^3.972.20",
- "@aws-sdk/credential-provider-login": "^3.972.20",
- "@aws-sdk/credential-provider-process": "^3.972.18",
- "@aws-sdk/credential-provider-sso": "^3.972.20",
- "@aws-sdk/credential-provider-web-identity": "^3.972.20",
- "@aws-sdk/nested-clients": "^3.996.10",
- "@aws-sdk/types": "^3.973.6",
- "@smithy/credential-provider-imds": "^4.2.12",
- "@smithy/property-provider": "^4.2.12",
- "@smithy/shared-ini-file-loader": "^4.4.7",
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
- },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
"engines": {
- "node": ">=20.0.0"
+ "node": ">=6.9.0"
}
},
- "node_modules/@aws-sdk/credential-provider-login": {
- "version": "3.972.20",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.20.tgz",
- "integrity": "sha512-gEWo54nfqp2jABMu6HNsjVC4hDLpg9HC8IKSJnp0kqWtxIJYHTmiLSsIfI4ScQjxEwpB+jOOH8dOLax1+hy/Hw==",
- "license": "Apache-2.0",
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "dev": true,
"dependencies": {
- "@aws-sdk/core": "^3.973.20",
- "@aws-sdk/nested-clients": "^3.996.10",
- "@aws-sdk/types": "^3.973.6",
- "@smithy/property-provider": "^4.2.12",
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/shared-ini-file-loader": "^4.4.7",
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
},
"engines": {
- "node": ">=20.0.0"
+ "node": ">=6.9.0"
}
},
- "node_modules/@aws-sdk/credential-provider-node": {
- "version": "3.972.21",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.21.tgz",
- "integrity": "sha512-hah8if3/B/Q+LBYN5FukyQ1Mym6PLPDsBOBsIgNEYD6wLyZg0UmUF/OKIVC3nX9XH8TfTPuITK+7N/jenVACWA==",
- "license": "Apache-2.0",
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "dev": true,
"dependencies": {
- "@aws-sdk/credential-provider-env": "^3.972.18",
- "@aws-sdk/credential-provider-http": "^3.972.20",
- "@aws-sdk/credential-provider-ini": "^3.972.20",
- "@aws-sdk/credential-provider-process": "^3.972.18",
- "@aws-sdk/credential-provider-sso": "^3.972.20",
- "@aws-sdk/credential-provider-web-identity": "^3.972.20",
- "@aws-sdk/types": "^3.973.6",
- "@smithy/credential-provider-imds": "^4.2.12",
- "@smithy/property-provider": "^4.2.12",
- "@smithy/shared-ini-file-loader": "^4.4.7",
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
},
"engines": {
- "node": ">=20.0.0"
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
}
},
- "node_modules/@aws-sdk/credential-provider-process": {
- "version": "3.972.18",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.18.tgz",
- "integrity": "sha512-Tpl7SRaPoOLT32jbTWchPsn52hYYgJ0kpiFgnwk8pxTANQdUymVSZkzFvv1+oOgZm1CrbQUP9MBeoMZ9IzLZjA==",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-sdk/core": "^3.973.20",
- "@aws-sdk/types": "^3.973.6",
- "@smithy/property-provider": "^4.2.12",
- "@smithy/shared-ini-file-loader": "^4.4.7",
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
- },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "dev": true,
"engines": {
- "node": ">=20.0.0"
+ "node": ">=6.9.0"
}
},
- "node_modules/@aws-sdk/credential-provider-sso": {
- "version": "3.972.20",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.20.tgz",
- "integrity": "sha512-p+R+PYR5Z7Gjqf/6pvbCnzEHcqPCpLzR7Yf127HjJ6EAb4hUcD+qsNRnuww1sB/RmSeCLxyay8FMyqREw4p1RA==",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-sdk/core": "^3.973.20",
- "@aws-sdk/nested-clients": "^3.996.10",
- "@aws-sdk/token-providers": "3.1009.0",
- "@aws-sdk/types": "^3.973.6",
- "@smithy/property-provider": "^4.2.12",
- "@smithy/shared-ini-file-loader": "^4.4.7",
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
- },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
"engines": {
- "node": ">=20.0.0"
+ "node": ">=6.9.0"
}
},
- "node_modules/@aws-sdk/credential-provider-web-identity": {
- "version": "3.972.20",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.20.tgz",
- "integrity": "sha512-rWCmh8o7QY4CsUj63qopzMzkDq/yPpkrpb+CnjBEFSOg/02T/we7sSTVg4QsDiVS9uwZ8VyONhq98qt+pIh3KA==",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-sdk/core": "^3.973.20",
- "@aws-sdk/nested-clients": "^3.996.10",
- "@aws-sdk/types": "^3.973.6",
- "@smithy/property-provider": "^4.2.12",
- "@smithy/shared-ini-file-loader": "^4.4.7",
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
- },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
"engines": {
- "node": ">=20.0.0"
+ "node": ">=6.9.0"
}
},
- "node_modules/@aws-sdk/middleware-bucket-endpoint": {
- "version": "3.972.8",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.8.tgz",
- "integrity": "sha512-WR525Rr2QJSETa9a050isktyWi/4yIGcmY3BQ1kpHqb0LqUglQHCS8R27dTJxxWNZvQ0RVGtEZjTCbZJpyF3Aw==",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-sdk/types": "^3.973.6",
- "@aws-sdk/util-arn-parser": "^3.972.3",
- "@smithy/node-config-provider": "^4.3.12",
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/types": "^4.13.1",
- "@smithy/util-config-provider": "^4.2.2",
- "tslib": "^2.6.2"
- },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
"engines": {
- "node": ">=20.0.0"
+ "node": ">=6.9.0"
}
},
- "node_modules/@aws-sdk/middleware-expect-continue": {
- "version": "3.972.8",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.8.tgz",
- "integrity": "sha512-5DTBTiotEES1e2jOHAq//zyzCjeMB78lEHd35u15qnrid4Nxm7diqIf9fQQ3Ov0ChH1V3Vvt13thOnrACmfGVQ==",
- "license": "Apache-2.0",
+ "node_modules/@babel/helpers": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
+ "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
+ "dev": true,
"dependencies": {
- "@aws-sdk/types": "^3.973.6",
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.28.6"
},
"engines": {
- "node": ">=20.0.0"
+ "node": ">=6.9.0"
}
},
- "node_modules/@aws-sdk/middleware-flexible-checksums": {
- "version": "3.973.6",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.973.6.tgz",
- "integrity": "sha512-0nYEgkJH7Yt9k+nZJyllTghnkKaz17TWFcr5Mi0XMVMzYlF4ytDZADQpF2/iJo36cKL5AYSzRsvlykE4M/ErTA==",
- "license": "Apache-2.0",
+ "node_modules/@babel/parser": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
+ "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
+ "dev": true,
"dependencies": {
- "@aws-crypto/crc32": "5.2.0",
- "@aws-crypto/crc32c": "5.2.0",
- "@aws-crypto/util": "5.2.0",
- "@aws-sdk/core": "^3.973.20",
- "@aws-sdk/crc64-nvme": "^3.972.5",
- "@aws-sdk/types": "^3.973.6",
- "@smithy/is-array-buffer": "^4.2.2",
- "@smithy/node-config-provider": "^4.3.12",
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/types": "^4.13.1",
- "@smithy/util-middleware": "^4.2.12",
- "@smithy/util-stream": "^4.5.19",
- "@smithy/util-utf8": "^4.2.2",
- "tslib": "^2.6.2"
+ "@babel/types": "^7.29.0"
},
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "node_modules/@aws-sdk/middleware-host-header": {
- "version": "3.972.8",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.8.tgz",
- "integrity": "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ==",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-sdk/types": "^3.973.6",
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
+ "bin": {
+ "parser": "bin/babel-parser.js"
},
"engines": {
- "node": ">=20.0.0"
+ "node": ">=6.0.0"
}
},
- "node_modules/@aws-sdk/middleware-location-constraint": {
- "version": "3.972.8",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.8.tgz",
- "integrity": "sha512-KaUoFuoFPziIa98DSQsTPeke1gvGXlc5ZGMhy+b+nLxZ4A7jmJgLzjEF95l8aOQN2T/qlPP3MrAyELm8ExXucw==",
- "license": "Apache-2.0",
+ "node_modules/@babel/plugin-syntax-async-generators": {
+ "version": "7.8.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz",
+ "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==",
+ "dev": true,
"dependencies": {
- "@aws-sdk/types": "^3.973.6",
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
+ "@babel/helper-plugin-utils": "^7.8.0"
},
- "engines": {
- "node": ">=20.0.0"
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@aws-sdk/middleware-logger": {
- "version": "3.972.8",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.8.tgz",
- "integrity": "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA==",
- "license": "Apache-2.0",
+ "node_modules/@babel/plugin-syntax-bigint": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz",
+ "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==",
+ "dev": true,
"dependencies": {
- "@aws-sdk/types": "^3.973.6",
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
+ "@babel/helper-plugin-utils": "^7.8.0"
},
- "engines": {
- "node": ">=20.0.0"
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@aws-sdk/middleware-recursion-detection": {
- "version": "3.972.8",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.8.tgz",
- "integrity": "sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA==",
- "license": "Apache-2.0",
+ "node_modules/@babel/plugin-syntax-class-properties": {
+ "version": "7.12.13",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz",
+ "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==",
+ "dev": true,
"dependencies": {
- "@aws-sdk/types": "^3.973.6",
- "@aws/lambda-invoke-store": "^0.2.2",
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
+ "@babel/helper-plugin-utils": "^7.12.13"
},
- "engines": {
- "node": ">=20.0.0"
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@aws-sdk/middleware-sdk-s3": {
- "version": "3.972.20",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.20.tgz",
- "integrity": "sha512-yhva/xL5H4tWQgsBjwV+RRD0ByCzg0TcByDCLp3GXdn/wlyRNfy8zsswDtCvr1WSKQkSQYlyEzPuWkJG0f5HvQ==",
- "license": "Apache-2.0",
+ "node_modules/@babel/plugin-syntax-class-static-block": {
+ "version": "7.14.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz",
+ "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==",
+ "dev": true,
"dependencies": {
- "@aws-sdk/core": "^3.973.20",
- "@aws-sdk/types": "^3.973.6",
- "@aws-sdk/util-arn-parser": "^3.972.3",
- "@smithy/core": "^3.23.11",
- "@smithy/node-config-provider": "^4.3.12",
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/signature-v4": "^5.3.12",
- "@smithy/smithy-client": "^4.12.5",
- "@smithy/types": "^4.13.1",
- "@smithy/util-config-provider": "^4.2.2",
- "@smithy/util-middleware": "^4.2.12",
- "@smithy/util-stream": "^4.5.19",
- "@smithy/util-utf8": "^4.2.2",
- "tslib": "^2.6.2"
+ "@babel/helper-plugin-utils": "^7.14.5"
},
"engines": {
- "node": ">=20.0.0"
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@aws-sdk/middleware-ssec": {
- "version": "3.972.8",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.8.tgz",
- "integrity": "sha512-wqlK0yO/TxEC2UsY9wIlqeeutF6jjLe0f96Pbm40XscTo57nImUk9lBcw0dPgsm0sppFtAkSlDrfpK+pC30Wqw==",
- "license": "Apache-2.0",
+ "node_modules/@babel/plugin-syntax-import-attributes": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz",
+ "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==",
+ "dev": true,
"dependencies": {
- "@aws-sdk/types": "^3.973.6",
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
- "node": ">=20.0.0"
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@aws-sdk/middleware-user-agent": {
- "version": "3.972.21",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.21.tgz",
- "integrity": "sha512-62XRl1GDYPpkt7cx1AX1SPy9wgNE9Iw/NPuurJu4lmhCWS7sGKO+kS53TQ8eRmIxy3skmvNInnk0ZbWrU5Dpyg==",
- "license": "Apache-2.0",
+ "node_modules/@babel/plugin-syntax-import-meta": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz",
+ "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==",
+ "dev": true,
"dependencies": {
- "@aws-sdk/core": "^3.973.20",
- "@aws-sdk/types": "^3.973.6",
- "@aws-sdk/util-endpoints": "^3.996.5",
- "@smithy/core": "^3.23.11",
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/types": "^4.13.1",
- "@smithy/util-retry": "^4.2.12",
- "tslib": "^2.6.2"
+ "@babel/helper-plugin-utils": "^7.10.4"
},
- "engines": {
- "node": ">=20.0.0"
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@aws-sdk/nested-clients": {
- "version": "3.996.10",
- "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.10.tgz",
- "integrity": "sha512-SlDol5Z+C7Ivnc2rKGqiqfSUmUZzY1qHfVs9myt/nxVwswgfpjdKahyTzLTx802Zfq0NFRs7AejwKzzzl5Co2w==",
- "license": "Apache-2.0",
+ "node_modules/@babel/plugin-syntax-json-strings": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz",
+ "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==",
+ "dev": true,
"dependencies": {
- "@aws-crypto/sha256-browser": "5.2.0",
- "@aws-crypto/sha256-js": "5.2.0",
- "@aws-sdk/core": "^3.973.20",
- "@aws-sdk/middleware-host-header": "^3.972.8",
- "@aws-sdk/middleware-logger": "^3.972.8",
- "@aws-sdk/middleware-recursion-detection": "^3.972.8",
- "@aws-sdk/middleware-user-agent": "^3.972.21",
- "@aws-sdk/region-config-resolver": "^3.972.8",
- "@aws-sdk/types": "^3.973.6",
- "@aws-sdk/util-endpoints": "^3.996.5",
- "@aws-sdk/util-user-agent-browser": "^3.972.8",
- "@aws-sdk/util-user-agent-node": "^3.973.7",
- "@smithy/config-resolver": "^4.4.11",
- "@smithy/core": "^3.23.11",
- "@smithy/fetch-http-handler": "^5.3.15",
- "@smithy/hash-node": "^4.2.12",
- "@smithy/invalid-dependency": "^4.2.12",
- "@smithy/middleware-content-length": "^4.2.12",
- "@smithy/middleware-endpoint": "^4.4.25",
- "@smithy/middleware-retry": "^4.4.42",
- "@smithy/middleware-serde": "^4.2.14",
- "@smithy/middleware-stack": "^4.2.12",
- "@smithy/node-config-provider": "^4.3.12",
- "@smithy/node-http-handler": "^4.4.16",
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/smithy-client": "^4.12.5",
- "@smithy/types": "^4.13.1",
- "@smithy/url-parser": "^4.2.12",
- "@smithy/util-base64": "^4.3.2",
- "@smithy/util-body-length-browser": "^4.2.2",
- "@smithy/util-body-length-node": "^4.2.3",
- "@smithy/util-defaults-mode-browser": "^4.3.41",
- "@smithy/util-defaults-mode-node": "^4.2.44",
- "@smithy/util-endpoints": "^3.3.3",
- "@smithy/util-middleware": "^4.2.12",
- "@smithy/util-retry": "^4.2.12",
- "@smithy/util-utf8": "^4.2.2",
- "tslib": "^2.6.2"
+ "@babel/helper-plugin-utils": "^7.8.0"
},
- "engines": {
- "node": ">=20.0.0"
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@aws-sdk/region-config-resolver": {
- "version": "3.972.8",
- "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.8.tgz",
- "integrity": "sha512-1eD4uhTDeambO/PNIDVG19A6+v4NdD7xzwLHDutHsUqz0B+i661MwQB2eYO4/crcCvCiQG4SRm1k81k54FEIvw==",
- "license": "Apache-2.0",
+ "node_modules/@babel/plugin-syntax-jsx": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz",
+ "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==",
+ "dev": true,
"dependencies": {
- "@aws-sdk/types": "^3.973.6",
- "@smithy/config-resolver": "^4.4.11",
- "@smithy/node-config-provider": "^4.3.12",
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
- "node": ">=20.0.0"
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@aws-sdk/s3-request-presigner": {
- "version": "3.1008.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1008.0.tgz",
- "integrity": "sha512-YZMG/5X2TVegzLjw6H5MIIeAUlp+JtkomKOITIZ9P9XS21hRZthRmFO4eJZe0xVLGfuMYZPUYSsiD2eEQuWdQw==",
- "license": "Apache-2.0",
+ "node_modules/@babel/plugin-syntax-logical-assignment-operators": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz",
+ "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==",
+ "dev": true,
"dependencies": {
- "@aws-sdk/signature-v4-multi-region": "^3.996.7",
- "@aws-sdk/types": "^3.973.5",
- "@aws-sdk/util-format-url": "^3.972.7",
- "@smithy/middleware-endpoint": "^4.4.23",
- "@smithy/protocol-http": "^5.3.11",
- "@smithy/smithy-client": "^4.12.3",
- "@smithy/types": "^4.13.0",
- "tslib": "^2.6.2"
+ "@babel/helper-plugin-utils": "^7.10.4"
},
- "engines": {
- "node": ">=20.0.0"
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@aws-sdk/signature-v4-multi-region": {
- "version": "3.996.8",
- "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.8.tgz",
- "integrity": "sha512-n1qYFD+tbqZuyskVaxUE+t10AUz9g3qzDw3Tp6QZDKmqsjfDmZBd4GIk2EKJJNtcCBtE5YiUjDYA+3djFAFBBg==",
- "license": "Apache-2.0",
+ "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz",
+ "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==",
+ "dev": true,
"dependencies": {
- "@aws-sdk/middleware-sdk-s3": "^3.972.20",
- "@aws-sdk/types": "^3.973.6",
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/signature-v4": "^5.3.12",
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
+ "@babel/helper-plugin-utils": "^7.8.0"
},
- "engines": {
- "node": ">=20.0.0"
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@aws-sdk/token-providers": {
- "version": "3.1009.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1009.0.tgz",
- "integrity": "sha512-KCPLuTqN9u0Rr38Arln78fRG9KXpzsPWmof+PZzfAHMMQq2QED6YjQrkrfiH7PDefLWEposY1o4/eGwrmKA4JA==",
- "license": "Apache-2.0",
+ "node_modules/@babel/plugin-syntax-numeric-separator": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz",
+ "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==",
+ "dev": true,
"dependencies": {
- "@aws-sdk/core": "^3.973.20",
- "@aws-sdk/nested-clients": "^3.996.10",
- "@aws-sdk/types": "^3.973.6",
- "@smithy/property-provider": "^4.2.12",
- "@smithy/shared-ini-file-loader": "^4.4.7",
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
+ "@babel/helper-plugin-utils": "^7.10.4"
},
- "engines": {
- "node": ">=20.0.0"
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@aws-sdk/types": {
- "version": "3.973.6",
- "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.6.tgz",
- "integrity": "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw==",
- "license": "Apache-2.0",
+ "node_modules/@babel/plugin-syntax-object-rest-spread": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz",
+ "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==",
+ "dev": true,
"dependencies": {
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
+ "@babel/helper-plugin-utils": "^7.8.0"
},
- "engines": {
- "node": ">=20.0.0"
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@aws-sdk/util-arn-parser": {
- "version": "3.972.3",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz",
- "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==",
- "license": "Apache-2.0",
+ "node_modules/@babel/plugin-syntax-optional-catch-binding": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz",
+ "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==",
+ "dev": true,
"dependencies": {
- "tslib": "^2.6.2"
+ "@babel/helper-plugin-utils": "^7.8.0"
},
- "engines": {
- "node": ">=20.0.0"
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@aws-sdk/util-endpoints": {
- "version": "3.996.5",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.5.tgz",
- "integrity": "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw==",
- "license": "Apache-2.0",
+ "node_modules/@babel/plugin-syntax-optional-chaining": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz",
+ "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==",
+ "dev": true,
"dependencies": {
- "@aws-sdk/types": "^3.973.6",
- "@smithy/types": "^4.13.1",
- "@smithy/url-parser": "^4.2.12",
- "@smithy/util-endpoints": "^3.3.3",
- "tslib": "^2.6.2"
+ "@babel/helper-plugin-utils": "^7.8.0"
},
- "engines": {
- "node": ">=20.0.0"
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@aws-sdk/util-format-url": {
- "version": "3.972.7",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.7.tgz",
- "integrity": "sha512-V+PbnWfUl93GuFwsOHsAq7hY/fnm9kElRqR8IexIJr5Rvif9e614X5sGSyz3mVSf1YAZ+VTy63W1/pGdA55zyA==",
- "license": "Apache-2.0",
+ "node_modules/@babel/plugin-syntax-private-property-in-object": {
+ "version": "7.14.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz",
+ "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==",
+ "dev": true,
"dependencies": {
- "@aws-sdk/types": "^3.973.5",
- "@smithy/querystring-builder": "^4.2.11",
- "@smithy/types": "^4.13.0",
- "tslib": "^2.6.2"
+ "@babel/helper-plugin-utils": "^7.14.5"
},
"engines": {
- "node": ">=20.0.0"
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@aws-sdk/util-locate-window": {
- "version": "3.965.5",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz",
- "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==",
- "license": "Apache-2.0",
+ "node_modules/@babel/plugin-syntax-top-level-await": {
+ "version": "7.14.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz",
+ "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==",
+ "dev": true,
"dependencies": {
- "tslib": "^2.6.2"
+ "@babel/helper-plugin-utils": "^7.14.5"
},
"engines": {
- "node": ">=20.0.0"
- }
- },
- "node_modules/@aws-sdk/util-user-agent-browser": {
- "version": "3.972.8",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.8.tgz",
- "integrity": "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA==",
- "license": "Apache-2.0",
- "dependencies": {
- "@aws-sdk/types": "^3.973.6",
- "@smithy/types": "^4.13.1",
- "bowser": "^2.11.0",
- "tslib": "^2.6.2"
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@aws-sdk/util-user-agent-node": {
- "version": "3.973.7",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.7.tgz",
- "integrity": "sha512-Hz6EZMUAEzqUd7e+vZ9LE7mn+5gMbxltXy18v+YSFY+9LBJz15wkNZvw5JqfX3z0FS9n3bgUtz3L5rAsfh4YlA==",
- "license": "Apache-2.0",
+ "node_modules/@babel/plugin-syntax-typescript": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz",
+ "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==",
+ "dev": true,
"dependencies": {
- "@aws-sdk/middleware-user-agent": "^3.972.21",
- "@aws-sdk/types": "^3.973.6",
- "@smithy/node-config-provider": "^4.3.12",
- "@smithy/types": "^4.13.1",
- "@smithy/util-config-provider": "^4.2.2",
- "tslib": "^2.6.2"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
- "node": ">=20.0.0"
+ "node": ">=6.9.0"
},
"peerDependencies": {
- "aws-crt": ">=1.0.0"
- },
- "peerDependenciesMeta": {
- "aws-crt": {
- "optional": true
- }
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@aws-sdk/xml-builder": {
- "version": "3.972.11",
- "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.11.tgz",
- "integrity": "sha512-iitV/gZKQMvY9d7ovmyFnFuTHbBAtrmLnvaSb/3X8vOKyevwtpmEtyc8AdhVWZe0pI/1GsHxlEvQeOePFzy7KQ==",
- "license": "Apache-2.0",
+ "node_modules/@babel/template": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "dev": true,
"dependencies": {
- "@smithy/types": "^4.13.1",
- "fast-xml-parser": "5.4.1",
- "tslib": "^2.6.2"
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
},
"engines": {
- "node": ">=20.0.0"
- }
- },
- "node_modules/@aws/lambda-invoke-store": {
- "version": "0.2.4",
- "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz",
- "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==",
- "license": "Apache-2.0",
- "engines": {
- "node": ">=18.0.0"
+ "node": ">=6.9.0"
}
},
- "node_modules/@babel/code-frame": {
+ "node_modules/@babel/traverse": {
"version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
- "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
- "dev": true,
- "dependencies": {
- "@babel/helper-validator-identifier": "^7.28.5",
- "js-tokens": "^4.0.0",
- "picocolors": "^1.1.1"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/compat-data": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
- "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
- "dev": true,
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/core": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
- "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
- "dev": true,
- "peer": true,
- "dependencies": {
- "@babel/code-frame": "^7.29.0",
- "@babel/generator": "^7.29.0",
- "@babel/helper-compilation-targets": "^7.28.6",
- "@babel/helper-module-transforms": "^7.28.6",
- "@babel/helpers": "^7.28.6",
- "@babel/parser": "^7.29.0",
- "@babel/template": "^7.28.6",
- "@babel/traverse": "^7.29.0",
- "@babel/types": "^7.29.0",
- "@jridgewell/remapping": "^2.3.5",
- "convert-source-map": "^2.0.0",
- "debug": "^4.1.0",
- "gensync": "^1.0.0-beta.2",
- "json5": "^2.2.3",
- "semver": "^6.3.1"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/babel"
- }
- },
- "node_modules/@babel/generator": {
- "version": "7.29.1",
- "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
- "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
- "dev": true,
- "dependencies": {
- "@babel/parser": "^7.29.0",
- "@babel/types": "^7.29.0",
- "@jridgewell/gen-mapping": "^0.3.12",
- "@jridgewell/trace-mapping": "^0.3.28",
- "jsesc": "^3.0.2"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-compilation-targets": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
- "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
- "dev": true,
- "dependencies": {
- "@babel/compat-data": "^7.28.6",
- "@babel/helper-validator-option": "^7.27.1",
- "browserslist": "^4.24.0",
- "lru-cache": "^5.1.1",
- "semver": "^6.3.1"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-globals": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
- "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
- "dev": true,
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-module-imports": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
- "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
- "dev": true,
- "dependencies": {
- "@babel/traverse": "^7.28.6",
- "@babel/types": "^7.28.6"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-module-transforms": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
- "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
- "dev": true,
- "dependencies": {
- "@babel/helper-module-imports": "^7.28.6",
- "@babel/helper-validator-identifier": "^7.28.5",
- "@babel/traverse": "^7.28.6"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0"
- }
- },
- "node_modules/@babel/helper-plugin-utils": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
- "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
- "dev": true,
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-string-parser": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
- "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
- "dev": true,
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-validator-identifier": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
- "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
- "dev": true,
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-validator-option": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
- "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
- "dev": true,
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helpers": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
- "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
- "dev": true,
- "dependencies": {
- "@babel/template": "^7.28.6",
- "@babel/types": "^7.28.6"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/parser": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
- "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
- "dev": true,
- "dependencies": {
- "@babel/types": "^7.29.0"
- },
- "bin": {
- "parser": "bin/babel-parser.js"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@babel/plugin-syntax-async-generators": {
- "version": "7.8.4",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz",
- "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==",
- "dev": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.8.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/plugin-syntax-bigint": {
- "version": "7.8.3",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz",
- "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==",
- "dev": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.8.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/plugin-syntax-class-properties": {
- "version": "7.12.13",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz",
- "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==",
- "dev": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.12.13"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/plugin-syntax-class-static-block": {
- "version": "7.14.5",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz",
- "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==",
- "dev": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.14.5"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/plugin-syntax-import-attributes": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz",
- "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==",
- "dev": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.28.6"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/plugin-syntax-import-meta": {
- "version": "7.10.4",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz",
- "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==",
- "dev": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.10.4"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/plugin-syntax-json-strings": {
- "version": "7.8.3",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz",
- "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==",
- "dev": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.8.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/plugin-syntax-jsx": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz",
- "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==",
- "dev": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.28.6"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/plugin-syntax-logical-assignment-operators": {
- "version": "7.10.4",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz",
- "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==",
- "dev": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.10.4"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": {
- "version": "7.8.3",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz",
- "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==",
- "dev": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.8.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/plugin-syntax-numeric-separator": {
- "version": "7.10.4",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz",
- "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==",
- "dev": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.10.4"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/plugin-syntax-object-rest-spread": {
- "version": "7.8.3",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz",
- "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==",
- "dev": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.8.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/plugin-syntax-optional-catch-binding": {
- "version": "7.8.3",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz",
- "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==",
- "dev": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.8.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/plugin-syntax-optional-chaining": {
- "version": "7.8.3",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz",
- "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==",
- "dev": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.8.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/plugin-syntax-private-property-in-object": {
- "version": "7.14.5",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz",
- "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==",
- "dev": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.14.5"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/plugin-syntax-top-level-await": {
- "version": "7.14.5",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz",
- "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==",
- "dev": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.14.5"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/plugin-syntax-typescript": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz",
- "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==",
- "dev": true,
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.28.6"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/template": {
- "version": "7.28.6",
- "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
- "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
- "dev": true,
- "dependencies": {
- "@babel/code-frame": "^7.28.6",
- "@babel/parser": "^7.28.6",
- "@babel/types": "^7.28.6"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/traverse": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
- "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
- "dev": true,
- "dependencies": {
- "@babel/code-frame": "^7.29.0",
- "@babel/generator": "^7.29.0",
- "@babel/helper-globals": "^7.28.0",
- "@babel/parser": "^7.29.0",
- "@babel/template": "^7.28.6",
- "@babel/types": "^7.29.0",
- "debug": "^4.3.1"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/types": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
- "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
- "dev": true,
- "dependencies": {
- "@babel/helper-string-parser": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.28.5"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@bcoe/v8-coverage": {
- "version": "0.2.3",
- "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
- "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
- "dev": true
- },
- "node_modules/@colors/colors": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
- "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==",
- "license": "MIT",
- "engines": {
- "node": ">=0.1.90"
- }
- },
- "node_modules/@eslint-community/eslint-utils": {
- "version": "4.9.1",
- "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
- "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
- "dev": true,
- "dependencies": {
- "eslint-visitor-keys": "^3.4.3"
- },
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- },
- "peerDependencies": {
- "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
- }
- },
- "node_modules/@eslint-community/regexpp": {
- "version": "4.12.2",
- "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
- "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
- "dev": true,
- "engines": {
- "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
- }
- },
- "node_modules/@eslint/eslintrc": {
- "version": "2.1.4",
- "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
- "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
- "dev": true,
- "dependencies": {
- "ajv": "^6.12.4",
- "debug": "^4.3.2",
- "espree": "^9.6.0",
- "globals": "^13.19.0",
- "ignore": "^5.2.0",
- "import-fresh": "^3.2.1",
- "js-yaml": "^4.1.0",
- "minimatch": "^3.1.2",
- "strip-json-comments": "^3.1.1"
- },
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/@eslint/js": {
- "version": "8.57.1",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
- "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
- "dev": true,
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- }
- },
- "node_modules/@humanwhocodes/config-array": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
- "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==",
- "deprecated": "Use @eslint/config-array instead",
- "dev": true,
- "dependencies": {
- "@humanwhocodes/object-schema": "^2.0.3",
- "debug": "^4.3.1",
- "minimatch": "^3.0.5"
- },
- "engines": {
- "node": ">=10.10.0"
- }
- },
- "node_modules/@humanwhocodes/module-importer": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
- "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
- "dev": true,
- "engines": {
- "node": ">=12.22"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/nzakas"
- }
- },
- "node_modules/@humanwhocodes/object-schema": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
- "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
- "deprecated": "Use @eslint/object-schema instead",
- "dev": true
- },
- "node_modules/@istanbuljs/load-nyc-config": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
- "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==",
- "dev": true,
- "dependencies": {
- "camelcase": "^5.3.1",
- "find-up": "^4.1.0",
- "get-package-type": "^0.1.0",
- "js-yaml": "^3.13.1",
- "resolve-from": "^5.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": {
- "version": "1.0.10",
- "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
- "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
- "dev": true,
- "dependencies": {
- "sprintf-js": "~1.0.2"
- }
- },
- "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
- "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
- "dev": true,
- "dependencies": {
- "locate-path": "^5.0.0",
- "path-exists": "^4.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
- "version": "3.14.2",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
- "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
- "dev": true,
- "dependencies": {
- "argparse": "^1.0.7",
- "esprima": "^4.0.0"
- },
- "bin": {
- "js-yaml": "bin/js-yaml.js"
- }
- },
- "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
- "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
- "dev": true,
- "dependencies": {
- "p-locate": "^4.1.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
- "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
- "dev": true,
- "dependencies": {
- "p-try": "^2.0.0"
- },
- "engines": {
- "node": ">=6"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
- "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
- "dev": true,
- "dependencies": {
- "p-limit": "^2.2.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
- "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
- "dev": true,
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/@istanbuljs/schema": {
- "version": "0.1.3",
- "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
- "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
- "dev": true,
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/@jest/console": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz",
- "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==",
- "dev": true,
- "dependencies": {
- "@jest/types": "^29.6.3",
- "@types/node": "*",
- "chalk": "^4.0.0",
- "jest-message-util": "^29.7.0",
- "jest-util": "^29.7.0",
- "slash": "^3.0.0"
- },
- "engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
- }
- },
- "node_modules/@jest/core": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz",
- "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==",
- "dev": true,
- "dependencies": {
- "@jest/console": "^29.7.0",
- "@jest/reporters": "^29.7.0",
- "@jest/test-result": "^29.7.0",
- "@jest/transform": "^29.7.0",
- "@jest/types": "^29.6.3",
- "@types/node": "*",
- "ansi-escapes": "^4.2.1",
- "chalk": "^4.0.0",
- "ci-info": "^3.2.0",
- "exit": "^0.1.2",
- "graceful-fs": "^4.2.9",
- "jest-changed-files": "^29.7.0",
- "jest-config": "^29.7.0",
- "jest-haste-map": "^29.7.0",
- "jest-message-util": "^29.7.0",
- "jest-regex-util": "^29.6.3",
- "jest-resolve": "^29.7.0",
- "jest-resolve-dependencies": "^29.7.0",
- "jest-runner": "^29.7.0",
- "jest-runtime": "^29.7.0",
- "jest-snapshot": "^29.7.0",
- "jest-util": "^29.7.0",
- "jest-validate": "^29.7.0",
- "jest-watcher": "^29.7.0",
- "micromatch": "^4.0.4",
- "pretty-format": "^29.7.0",
- "slash": "^3.0.0",
- "strip-ansi": "^6.0.0"
- },
- "engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
- },
- "peerDependencies": {
- "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
- },
- "peerDependenciesMeta": {
- "node-notifier": {
- "optional": true
- }
- }
- },
- "node_modules/@jest/environment": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz",
- "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==",
- "dev": true,
- "dependencies": {
- "@jest/fake-timers": "^29.7.0",
- "@jest/types": "^29.6.3",
- "@types/node": "*",
- "jest-mock": "^29.7.0"
- },
- "engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
- }
- },
- "node_modules/@jest/expect": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz",
- "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==",
- "dev": true,
- "dependencies": {
- "expect": "^29.7.0",
- "jest-snapshot": "^29.7.0"
- },
- "engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
- }
- },
- "node_modules/@jest/expect-utils": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz",
- "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==",
- "dev": true,
- "dependencies": {
- "jest-get-type": "^29.6.3"
- },
- "engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
- }
- },
- "node_modules/@jest/fake-timers": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz",
- "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==",
- "dev": true,
- "dependencies": {
- "@jest/types": "^29.6.3",
- "@sinonjs/fake-timers": "^10.0.2",
- "@types/node": "*",
- "jest-message-util": "^29.7.0",
- "jest-mock": "^29.7.0",
- "jest-util": "^29.7.0"
- },
- "engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
- }
- },
- "node_modules/@jest/globals": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz",
- "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==",
- "dev": true,
- "dependencies": {
- "@jest/environment": "^29.7.0",
- "@jest/expect": "^29.7.0",
- "@jest/types": "^29.6.3",
- "jest-mock": "^29.7.0"
- },
- "engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
- }
- },
- "node_modules/@jest/reporters": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz",
- "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==",
- "dev": true,
- "dependencies": {
- "@bcoe/v8-coverage": "^0.2.3",
- "@jest/console": "^29.7.0",
- "@jest/test-result": "^29.7.0",
- "@jest/transform": "^29.7.0",
- "@jest/types": "^29.6.3",
- "@jridgewell/trace-mapping": "^0.3.18",
- "@types/node": "*",
- "chalk": "^4.0.0",
- "collect-v8-coverage": "^1.0.0",
- "exit": "^0.1.2",
- "glob": "^7.1.3",
- "graceful-fs": "^4.2.9",
- "istanbul-lib-coverage": "^3.0.0",
- "istanbul-lib-instrument": "^6.0.0",
- "istanbul-lib-report": "^3.0.0",
- "istanbul-lib-source-maps": "^4.0.0",
- "istanbul-reports": "^3.1.3",
- "jest-message-util": "^29.7.0",
- "jest-util": "^29.7.0",
- "jest-worker": "^29.7.0",
- "slash": "^3.0.0",
- "string-length": "^4.0.1",
- "strip-ansi": "^6.0.0",
- "v8-to-istanbul": "^9.0.1"
- },
- "engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
- },
- "peerDependencies": {
- "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
- },
- "peerDependenciesMeta": {
- "node-notifier": {
- "optional": true
- }
- }
- },
- "node_modules/@jest/schemas": {
- "version": "29.6.3",
- "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
- "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
- "dev": true,
- "dependencies": {
- "@sinclair/typebox": "^0.27.8"
- },
- "engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
- }
- },
- "node_modules/@jest/source-map": {
- "version": "29.6.3",
- "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz",
- "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==",
- "dev": true,
- "dependencies": {
- "@jridgewell/trace-mapping": "^0.3.18",
- "callsites": "^3.0.0",
- "graceful-fs": "^4.2.9"
- },
- "engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
- }
- },
- "node_modules/@jest/test-result": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz",
- "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==",
- "dev": true,
- "dependencies": {
- "@jest/console": "^29.7.0",
- "@jest/types": "^29.6.3",
- "@types/istanbul-lib-coverage": "^2.0.0",
- "collect-v8-coverage": "^1.0.0"
- },
- "engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
- }
- },
- "node_modules/@jest/test-sequencer": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz",
- "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==",
- "dev": true,
- "dependencies": {
- "@jest/test-result": "^29.7.0",
- "graceful-fs": "^4.2.9",
- "jest-haste-map": "^29.7.0",
- "slash": "^3.0.0"
- },
- "engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
- }
- },
- "node_modules/@jest/transform": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz",
- "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==",
- "dev": true,
- "dependencies": {
- "@babel/core": "^7.11.6",
- "@jest/types": "^29.6.3",
- "@jridgewell/trace-mapping": "^0.3.18",
- "babel-plugin-istanbul": "^6.1.1",
- "chalk": "^4.0.0",
- "convert-source-map": "^2.0.0",
- "fast-json-stable-stringify": "^2.1.0",
- "graceful-fs": "^4.2.9",
- "jest-haste-map": "^29.7.0",
- "jest-regex-util": "^29.6.3",
- "jest-util": "^29.7.0",
- "micromatch": "^4.0.4",
- "pirates": "^4.0.4",
- "slash": "^3.0.0",
- "write-file-atomic": "^4.0.2"
- },
- "engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
- }
- },
- "node_modules/@jest/types": {
- "version": "29.6.3",
- "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz",
- "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==",
- "dev": true,
- "dependencies": {
- "@jest/schemas": "^29.6.3",
- "@types/istanbul-lib-coverage": "^2.0.0",
- "@types/istanbul-reports": "^3.0.0",
- "@types/node": "*",
- "@types/yargs": "^17.0.8",
- "chalk": "^4.0.0"
- },
- "engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
- }
- },
- "node_modules/@jridgewell/gen-mapping": {
- "version": "0.3.13",
- "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
- "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
- "dev": true,
- "dependencies": {
- "@jridgewell/sourcemap-codec": "^1.5.0",
- "@jridgewell/trace-mapping": "^0.3.24"
- }
- },
- "node_modules/@jridgewell/remapping": {
- "version": "2.3.5",
- "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
- "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
- "dev": true,
- "dependencies": {
- "@jridgewell/gen-mapping": "^0.3.5",
- "@jridgewell/trace-mapping": "^0.3.24"
- }
- },
- "node_modules/@jridgewell/resolve-uri": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
- "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
- "dev": true,
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.5.5",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
- "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
- "dev": true
- },
- "node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.31",
- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
- "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
- "dev": true,
- "dependencies": {
- "@jridgewell/resolve-uri": "^3.1.0",
- "@jridgewell/sourcemap-codec": "^1.4.14"
- }
- },
- "node_modules/@noble/hashes": {
- "version": "1.8.0",
- "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
- "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
- "dev": true,
- "engines": {
- "node": "^14.21.3 || >=16"
- },
- "funding": {
- "url": "https://paulmillr.com/funding/"
- }
- },
- "node_modules/@nodelib/fs.scandir": {
- "version": "2.1.5",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
- "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
- "dev": true,
- "dependencies": {
- "@nodelib/fs.stat": "2.0.5",
- "run-parallel": "^1.1.9"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@nodelib/fs.stat": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
- "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
- "dev": true,
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@nodelib/fs.walk": {
- "version": "1.2.8",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
- "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
- "dev": true,
- "dependencies": {
- "@nodelib/fs.scandir": "2.1.5",
- "fastq": "^1.6.0"
- },
- "engines": {
- "node": ">= 8"
- }
- },
- "node_modules/@paralleldrive/cuid2": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
- "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==",
- "dev": true,
- "dependencies": {
- "@noble/hashes": "^1.1.5"
- }
- },
- "node_modules/@sinclair/typebox": {
- "version": "0.27.10",
- "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz",
- "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==",
- "dev": true
- },
- "node_modules/@sinonjs/commons": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz",
- "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==",
- "dev": true,
- "dependencies": {
- "type-detect": "4.0.8"
- }
- },
- "node_modules/@sinonjs/fake-timers": {
- "version": "10.3.0",
- "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz",
- "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==",
- "dev": true,
- "dependencies": {
- "@sinonjs/commons": "^3.0.0"
- }
- },
- "node_modules/@smithy/abort-controller": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.12.tgz",
- "integrity": "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q==",
- "license": "Apache-2.0",
- "dependencies": {
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=18.0.0"
- }
- },
- "node_modules/@smithy/chunked-blob-reader": {
- "version": "5.2.2",
- "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.2.tgz",
- "integrity": "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==",
- "license": "Apache-2.0",
- "dependencies": {
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=18.0.0"
- }
- },
- "node_modules/@smithy/chunked-blob-reader-native": {
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.3.tgz",
- "integrity": "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==",
- "license": "Apache-2.0",
- "dependencies": {
- "@smithy/util-base64": "^4.3.2",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=18.0.0"
- }
- },
- "node_modules/@smithy/config-resolver": {
- "version": "4.4.11",
- "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.11.tgz",
- "integrity": "sha512-YxFiiG4YDAtX7WMN7RuhHZLeTmRRAOyCbr+zB8e3AQzHPnUhS8zXjB1+cniPVQI3xbWsQPM0X2aaIkO/ME0ymw==",
- "license": "Apache-2.0",
- "dependencies": {
- "@smithy/node-config-provider": "^4.3.12",
- "@smithy/types": "^4.13.1",
- "@smithy/util-config-provider": "^4.2.2",
- "@smithy/util-endpoints": "^3.3.3",
- "@smithy/util-middleware": "^4.2.12",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=18.0.0"
- }
- },
- "node_modules/@smithy/core": {
- "version": "3.23.11",
- "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.11.tgz",
- "integrity": "sha512-952rGf7hBRnhUIaeLp6q4MptKW8sPFe5VvkoZ5qIzFAtx6c/QZ/54FS3yootsyUSf9gJX/NBqEBNdNR7jMIlpQ==",
- "license": "Apache-2.0",
- "dependencies": {
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/types": "^4.13.1",
- "@smithy/url-parser": "^4.2.12",
- "@smithy/util-base64": "^4.3.2",
- "@smithy/util-body-length-browser": "^4.2.2",
- "@smithy/util-middleware": "^4.2.12",
- "@smithy/util-stream": "^4.5.19",
- "@smithy/util-utf8": "^4.2.2",
- "@smithy/uuid": "^1.1.2",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=18.0.0"
- }
- },
- "node_modules/@smithy/credential-provider-imds": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.12.tgz",
- "integrity": "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg==",
- "license": "Apache-2.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+ "dev": true,
"dependencies": {
- "@smithy/node-config-provider": "^4.3.12",
- "@smithy/property-provider": "^4.2.12",
- "@smithy/types": "^4.13.1",
- "@smithy/url-parser": "^4.2.12",
- "tslib": "^2.6.2"
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0",
+ "debug": "^4.3.1"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=6.9.0"
}
},
- "node_modules/@smithy/eventstream-codec": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.12.tgz",
- "integrity": "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA==",
- "license": "Apache-2.0",
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dev": true,
"dependencies": {
- "@aws-crypto/crc32": "5.2.0",
- "@smithy/types": "^4.13.1",
- "@smithy/util-hex-encoding": "^4.2.2",
- "tslib": "^2.6.2"
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=6.9.0"
}
},
- "node_modules/@smithy/eventstream-serde-browser": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.12.tgz",
- "integrity": "sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A==",
- "license": "Apache-2.0",
- "dependencies": {
- "@smithy/eventstream-serde-universal": "^4.2.12",
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=18.0.0"
- }
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "0.2.3",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
+ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
+ "dev": true
},
- "node_modules/@smithy/eventstream-serde-config-resolver": {
- "version": "4.3.12",
- "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.12.tgz",
- "integrity": "sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q==",
- "license": "Apache-2.0",
- "dependencies": {
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
- },
+ "node_modules/@colors/colors": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
+ "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==",
+ "license": "MIT",
"engines": {
- "node": ">=18.0.0"
+ "node": ">=0.1.90"
}
},
- "node_modules/@smithy/eventstream-serde-node": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.12.tgz",
- "integrity": "sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA==",
- "license": "Apache-2.0",
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
+ "dev": true,
"dependencies": {
- "@smithy/eventstream-serde-universal": "^4.2.12",
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
+ "eslint-visitor-keys": "^3.4.3"
},
"engines": {
- "node": ">=18.0.0"
- }
- },
- "node_modules/@smithy/eventstream-serde-universal": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.12.tgz",
- "integrity": "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ==",
- "license": "Apache-2.0",
- "dependencies": {
- "@smithy/eventstream-codec": "^4.2.12",
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
- "engines": {
- "node": ">=18.0.0"
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
}
},
- "node_modules/@smithy/fetch-http-handler": {
- "version": "5.3.15",
- "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.15.tgz",
- "integrity": "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A==",
- "license": "Apache-2.0",
- "dependencies": {
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/querystring-builder": "^4.2.12",
- "@smithy/types": "^4.13.1",
- "@smithy/util-base64": "^4.3.2",
- "tslib": "^2.6.2"
- },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+ "dev": true,
"engines": {
- "node": ">=18.0.0"
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
}
},
- "node_modules/@smithy/hash-blob-browser": {
- "version": "4.2.13",
- "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.13.tgz",
- "integrity": "sha512-YrF4zWKh+ghLuquldj6e/RzE3xZYL8wIPfkt0MqCRphVICjyyjH8OwKD7LLlKpVEbk4FLizFfC1+gwK6XQdR3g==",
- "license": "Apache-2.0",
+ "node_modules/@eslint/eslintrc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
+ "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
+ "dev": true,
"dependencies": {
- "@smithy/chunked-blob-reader": "^5.2.2",
- "@smithy/chunked-blob-reader-native": "^4.2.3",
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
+ "ajv": "^6.12.4",
+ "debug": "^4.3.2",
+ "espree": "^9.6.0",
+ "globals": "^13.19.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.0",
+ "minimatch": "^3.1.2",
+ "strip-json-comments": "^3.1.1"
},
"engines": {
- "node": ">=18.0.0"
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
}
},
- "node_modules/@smithy/hash-node": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.12.tgz",
- "integrity": "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w==",
- "license": "Apache-2.0",
- "dependencies": {
- "@smithy/types": "^4.13.1",
- "@smithy/util-buffer-from": "^4.2.2",
- "@smithy/util-utf8": "^4.2.2",
- "tslib": "^2.6.2"
- },
+ "node_modules/@eslint/js": {
+ "version": "8.57.1",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
+ "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
+ "dev": true,
"engines": {
- "node": ">=18.0.0"
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
- "node_modules/@smithy/hash-stream-node": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.12.tgz",
- "integrity": "sha512-O3YbmGExeafuM/kP7Y8r6+1y0hIh3/zn6GROx0uNlB54K9oihAL75Qtc+jFfLNliTi6pxOAYZrRKD9A7iA6UFw==",
- "license": "Apache-2.0",
+ "node_modules/@humanwhocodes/config-array": {
+ "version": "0.13.0",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
+ "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==",
+ "deprecated": "Use @eslint/config-array instead",
+ "dev": true,
"dependencies": {
- "@smithy/types": "^4.13.1",
- "@smithy/util-utf8": "^4.2.2",
- "tslib": "^2.6.2"
+ "@humanwhocodes/object-schema": "^2.0.3",
+ "debug": "^4.3.1",
+ "minimatch": "^3.0.5"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=10.10.0"
}
},
- "node_modules/@smithy/invalid-dependency": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.12.tgz",
- "integrity": "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g==",
- "license": "Apache-2.0",
- "dependencies": {
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
- },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
"engines": {
- "node": ">=18.0.0"
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
}
},
- "node_modules/@smithy/is-array-buffer": {
- "version": "4.2.2",
- "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz",
- "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==",
- "license": "Apache-2.0",
+ "node_modules/@humanwhocodes/object-schema": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
+ "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
+ "deprecated": "Use @eslint/object-schema instead",
+ "dev": true
+ },
+ "node_modules/@istanbuljs/load-nyc-config": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
+ "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==",
+ "dev": true,
"dependencies": {
- "tslib": "^2.6.2"
+ "camelcase": "^5.3.1",
+ "find-up": "^4.1.0",
+ "get-package-type": "^0.1.0",
+ "js-yaml": "^3.13.1",
+ "resolve-from": "^5.0.0"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=8"
}
},
- "node_modules/@smithy/md5-js": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.12.tgz",
- "integrity": "sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ==",
- "license": "Apache-2.0",
+ "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+ "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+ "dev": true,
"dependencies": {
- "@smithy/types": "^4.13.1",
- "@smithy/util-utf8": "^4.2.2",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=18.0.0"
+ "sprintf-js": "~1.0.2"
}
},
- "node_modules/@smithy/middleware-content-length": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.12.tgz",
- "integrity": "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA==",
- "license": "Apache-2.0",
+ "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
"dependencies": {
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=8"
}
},
- "node_modules/@smithy/middleware-endpoint": {
- "version": "4.4.25",
- "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.25.tgz",
- "integrity": "sha512-dqjLwZs2eBxIUG6Qtw8/YZ4DvzHGIf0DA18wrgtfP6a50UIO7e2nY0FPdcbv5tVJKqWCCU5BmGMOUwT7Puan+A==",
- "license": "Apache-2.0",
+ "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
+ "version": "3.14.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
+ "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
+ "dev": true,
"dependencies": {
- "@smithy/core": "^3.23.11",
- "@smithy/middleware-serde": "^4.2.14",
- "@smithy/node-config-provider": "^4.3.12",
- "@smithy/shared-ini-file-loader": "^4.4.7",
- "@smithy/types": "^4.13.1",
- "@smithy/url-parser": "^4.2.12",
- "@smithy/util-middleware": "^4.2.12",
- "tslib": "^2.6.2"
+ "argparse": "^1.0.7",
+ "esprima": "^4.0.0"
},
- "engines": {
- "node": ">=18.0.0"
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
}
},
- "node_modules/@smithy/middleware-retry": {
- "version": "4.4.42",
- "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.42.tgz",
- "integrity": "sha512-vbwyqHRIpIZutNXZpLAozakzamcINaRCpEy1MYmK6xBeW3xN+TyPRA123GjXnuxZIjc9848MRRCugVMTXxC4Eg==",
- "license": "Apache-2.0",
+ "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
"dependencies": {
- "@smithy/node-config-provider": "^4.3.12",
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/service-error-classification": "^4.2.12",
- "@smithy/smithy-client": "^4.12.5",
- "@smithy/types": "^4.13.1",
- "@smithy/util-middleware": "^4.2.12",
- "@smithy/util-retry": "^4.2.12",
- "@smithy/uuid": "^1.1.2",
- "tslib": "^2.6.2"
+ "p-locate": "^4.1.0"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=8"
}
},
- "node_modules/@smithy/middleware-serde": {
- "version": "4.2.14",
- "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.14.tgz",
- "integrity": "sha512-+CcaLoLa5apzSRtloOyG7lQvkUw2ZDml3hRh4QiG9WyEPfW5Ke/3tPOPiPjUneuT59Tpn8+c3RVaUvvkkwqZwg==",
- "license": "Apache-2.0",
+ "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
"dependencies": {
- "@smithy/core": "^3.23.11",
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
+ "p-try": "^2.0.0"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/@smithy/middleware-stack": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.12.tgz",
- "integrity": "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw==",
- "license": "Apache-2.0",
+ "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
"dependencies": {
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
+ "p-limit": "^2.2.0"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=8"
}
},
- "node_modules/@smithy/node-config-provider": {
- "version": "4.3.12",
- "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.12.tgz",
- "integrity": "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw==",
- "license": "Apache-2.0",
- "dependencies": {
- "@smithy/property-provider": "^4.2.12",
- "@smithy/shared-ini-file-loader": "^4.4.7",
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
- },
+ "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+ "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+ "dev": true,
"engines": {
- "node": ">=18.0.0"
+ "node": ">=8"
}
},
- "node_modules/@smithy/node-http-handler": {
- "version": "4.4.16",
- "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.16.tgz",
- "integrity": "sha512-ULC8UCS/HivdCB3jhi+kLFYe4B5gxH2gi9vHBfEIiRrT2jfKiZNiETJSlzRtE6B26XbBHjPtc8iZKSNqMol9bw==",
- "license": "Apache-2.0",
- "dependencies": {
- "@smithy/abort-controller": "^4.2.12",
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/querystring-builder": "^4.2.12",
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
- },
+ "node_modules/@istanbuljs/schema": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
+ "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
+ "dev": true,
"engines": {
- "node": ">=18.0.0"
+ "node": ">=8"
}
},
- "node_modules/@smithy/property-provider": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.12.tgz",
- "integrity": "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A==",
- "license": "Apache-2.0",
+ "node_modules/@jest/console": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz",
+ "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==",
+ "dev": true,
"dependencies": {
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
+ "@jest/types": "^29.6.3",
+ "@types/node": "*",
+ "chalk": "^4.0.0",
+ "jest-message-util": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "slash": "^3.0.0"
},
"engines": {
- "node": ">=18.0.0"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
- "node_modules/@smithy/protocol-http": {
- "version": "5.3.12",
- "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.12.tgz",
- "integrity": "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw==",
- "license": "Apache-2.0",
+ "node_modules/@jest/core": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz",
+ "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==",
+ "dev": true,
"dependencies": {
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
+ "@jest/console": "^29.7.0",
+ "@jest/reporters": "^29.7.0",
+ "@jest/test-result": "^29.7.0",
+ "@jest/transform": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "@types/node": "*",
+ "ansi-escapes": "^4.2.1",
+ "chalk": "^4.0.0",
+ "ci-info": "^3.2.0",
+ "exit": "^0.1.2",
+ "graceful-fs": "^4.2.9",
+ "jest-changed-files": "^29.7.0",
+ "jest-config": "^29.7.0",
+ "jest-haste-map": "^29.7.0",
+ "jest-message-util": "^29.7.0",
+ "jest-regex-util": "^29.6.3",
+ "jest-resolve": "^29.7.0",
+ "jest-resolve-dependencies": "^29.7.0",
+ "jest-runner": "^29.7.0",
+ "jest-runtime": "^29.7.0",
+ "jest-snapshot": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "jest-validate": "^29.7.0",
+ "jest-watcher": "^29.7.0",
+ "micromatch": "^4.0.4",
+ "pretty-format": "^29.7.0",
+ "slash": "^3.0.0",
+ "strip-ansi": "^6.0.0"
},
"engines": {
- "node": ">=18.0.0"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ },
+ "peerDependencies": {
+ "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
+ },
+ "peerDependenciesMeta": {
+ "node-notifier": {
+ "optional": true
+ }
}
},
- "node_modules/@smithy/querystring-builder": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.12.tgz",
- "integrity": "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg==",
- "license": "Apache-2.0",
+ "node_modules/@jest/environment": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz",
+ "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==",
+ "dev": true,
"dependencies": {
- "@smithy/types": "^4.13.1",
- "@smithy/util-uri-escape": "^4.2.2",
- "tslib": "^2.6.2"
+ "@jest/fake-timers": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "@types/node": "*",
+ "jest-mock": "^29.7.0"
},
"engines": {
- "node": ">=18.0.0"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
- "node_modules/@smithy/querystring-parser": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.12.tgz",
- "integrity": "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw==",
- "license": "Apache-2.0",
+ "node_modules/@jest/expect": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz",
+ "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==",
+ "dev": true,
"dependencies": {
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
+ "expect": "^29.7.0",
+ "jest-snapshot": "^29.7.0"
},
"engines": {
- "node": ">=18.0.0"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
- "node_modules/@smithy/service-error-classification": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.12.tgz",
- "integrity": "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ==",
- "license": "Apache-2.0",
+ "node_modules/@jest/expect-utils": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz",
+ "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==",
+ "dev": true,
"dependencies": {
- "@smithy/types": "^4.13.1"
+ "jest-get-type": "^29.6.3"
},
"engines": {
- "node": ">=18.0.0"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
- "node_modules/@smithy/shared-ini-file-loader": {
- "version": "4.4.7",
- "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.7.tgz",
- "integrity": "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw==",
- "license": "Apache-2.0",
+ "node_modules/@jest/fake-timers": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz",
+ "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==",
+ "dev": true,
"dependencies": {
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
+ "@jest/types": "^29.6.3",
+ "@sinonjs/fake-timers": "^10.0.2",
+ "@types/node": "*",
+ "jest-message-util": "^29.7.0",
+ "jest-mock": "^29.7.0",
+ "jest-util": "^29.7.0"
},
"engines": {
- "node": ">=18.0.0"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
- "node_modules/@smithy/signature-v4": {
- "version": "5.3.12",
- "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.12.tgz",
- "integrity": "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw==",
- "license": "Apache-2.0",
+ "node_modules/@jest/globals": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz",
+ "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==",
+ "dev": true,
"dependencies": {
- "@smithy/is-array-buffer": "^4.2.2",
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/types": "^4.13.1",
- "@smithy/util-hex-encoding": "^4.2.2",
- "@smithy/util-middleware": "^4.2.12",
- "@smithy/util-uri-escape": "^4.2.2",
- "@smithy/util-utf8": "^4.2.2",
- "tslib": "^2.6.2"
+ "@jest/environment": "^29.7.0",
+ "@jest/expect": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "jest-mock": "^29.7.0"
},
"engines": {
- "node": ">=18.0.0"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
- "node_modules/@smithy/smithy-client": {
- "version": "4.12.5",
- "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.5.tgz",
- "integrity": "sha512-UqwYawyqSr/aog8mnLnfbPurS0gi4G7IYDcD28cUIBhsvWs1+rQcL2IwkUQ+QZ7dibaoRzhNF99fAQ9AUcO00w==",
- "license": "Apache-2.0",
+ "node_modules/@jest/reporters": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz",
+ "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==",
+ "dev": true,
"dependencies": {
- "@smithy/core": "^3.23.11",
- "@smithy/middleware-endpoint": "^4.4.25",
- "@smithy/middleware-stack": "^4.2.12",
- "@smithy/protocol-http": "^5.3.12",
- "@smithy/types": "^4.13.1",
- "@smithy/util-stream": "^4.5.19",
- "tslib": "^2.6.2"
+ "@bcoe/v8-coverage": "^0.2.3",
+ "@jest/console": "^29.7.0",
+ "@jest/test-result": "^29.7.0",
+ "@jest/transform": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "@jridgewell/trace-mapping": "^0.3.18",
+ "@types/node": "*",
+ "chalk": "^4.0.0",
+ "collect-v8-coverage": "^1.0.0",
+ "exit": "^0.1.2",
+ "glob": "^7.1.3",
+ "graceful-fs": "^4.2.9",
+ "istanbul-lib-coverage": "^3.0.0",
+ "istanbul-lib-instrument": "^6.0.0",
+ "istanbul-lib-report": "^3.0.0",
+ "istanbul-lib-source-maps": "^4.0.0",
+ "istanbul-reports": "^3.1.3",
+ "jest-message-util": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "jest-worker": "^29.7.0",
+ "slash": "^3.0.0",
+ "string-length": "^4.0.1",
+ "strip-ansi": "^6.0.0",
+ "v8-to-istanbul": "^9.0.1"
},
"engines": {
- "node": ">=18.0.0"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ },
+ "peerDependencies": {
+ "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
+ },
+ "peerDependenciesMeta": {
+ "node-notifier": {
+ "optional": true
+ }
}
},
- "node_modules/@smithy/types": {
- "version": "4.13.1",
- "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz",
- "integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==",
- "license": "Apache-2.0",
+ "node_modules/@jest/schemas": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
+ "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
+ "dev": true,
"dependencies": {
- "tslib": "^2.6.2"
+ "@sinclair/typebox": "^0.27.8"
},
"engines": {
- "node": ">=18.0.0"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
- "node_modules/@smithy/url-parser": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.12.tgz",
- "integrity": "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA==",
- "license": "Apache-2.0",
+ "node_modules/@jest/source-map": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz",
+ "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==",
+ "dev": true,
"dependencies": {
- "@smithy/querystring-parser": "^4.2.12",
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
+ "@jridgewell/trace-mapping": "^0.3.18",
+ "callsites": "^3.0.0",
+ "graceful-fs": "^4.2.9"
},
"engines": {
- "node": ">=18.0.0"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
- "node_modules/@smithy/util-base64": {
- "version": "4.3.2",
- "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz",
- "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==",
- "license": "Apache-2.0",
+ "node_modules/@jest/test-result": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz",
+ "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==",
+ "dev": true,
"dependencies": {
- "@smithy/util-buffer-from": "^4.2.2",
- "@smithy/util-utf8": "^4.2.2",
- "tslib": "^2.6.2"
+ "@jest/console": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "@types/istanbul-lib-coverage": "^2.0.0",
+ "collect-v8-coverage": "^1.0.0"
},
"engines": {
- "node": ">=18.0.0"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
- "node_modules/@smithy/util-body-length-browser": {
- "version": "4.2.2",
- "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz",
- "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==",
- "license": "Apache-2.0",
+ "node_modules/@jest/test-sequencer": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz",
+ "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==",
+ "dev": true,
"dependencies": {
- "tslib": "^2.6.2"
+ "@jest/test-result": "^29.7.0",
+ "graceful-fs": "^4.2.9",
+ "jest-haste-map": "^29.7.0",
+ "slash": "^3.0.0"
},
"engines": {
- "node": ">=18.0.0"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
- "node_modules/@smithy/util-body-length-node": {
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz",
- "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==",
- "license": "Apache-2.0",
+ "node_modules/@jest/transform": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz",
+ "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==",
+ "dev": true,
"dependencies": {
- "tslib": "^2.6.2"
+ "@babel/core": "^7.11.6",
+ "@jest/types": "^29.6.3",
+ "@jridgewell/trace-mapping": "^0.3.18",
+ "babel-plugin-istanbul": "^6.1.1",
+ "chalk": "^4.0.0",
+ "convert-source-map": "^2.0.0",
+ "fast-json-stable-stringify": "^2.1.0",
+ "graceful-fs": "^4.2.9",
+ "jest-haste-map": "^29.7.0",
+ "jest-regex-util": "^29.6.3",
+ "jest-util": "^29.7.0",
+ "micromatch": "^4.0.4",
+ "pirates": "^4.0.4",
+ "slash": "^3.0.0",
+ "write-file-atomic": "^4.0.2"
},
"engines": {
- "node": ">=18.0.0"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
- "node_modules/@smithy/util-buffer-from": {
- "version": "4.2.2",
- "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz",
- "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==",
- "license": "Apache-2.0",
+ "node_modules/@jest/types": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz",
+ "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==",
+ "dev": true,
"dependencies": {
- "@smithy/is-array-buffer": "^4.2.2",
- "tslib": "^2.6.2"
+ "@jest/schemas": "^29.6.3",
+ "@types/istanbul-lib-coverage": "^2.0.0",
+ "@types/istanbul-reports": "^3.0.0",
+ "@types/node": "*",
+ "@types/yargs": "^17.0.8",
+ "chalk": "^4.0.0"
},
"engines": {
- "node": ">=18.0.0"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
- "node_modules/@smithy/util-config-provider": {
- "version": "4.2.2",
- "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz",
- "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==",
- "license": "Apache-2.0",
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
"dependencies": {
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=18.0.0"
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
}
},
- "node_modules/@smithy/util-defaults-mode-browser": {
- "version": "4.3.41",
- "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.41.tgz",
- "integrity": "sha512-M1w1Ux0rSVvBOxIIiqbxvZvhnjQ+VUjJrugtORE90BbadSTH+jsQL279KRL3Hv0w69rE7EuYkV/4Lepz/NBW9g==",
- "license": "Apache-2.0",
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
"dependencies": {
- "@smithy/property-provider": "^4.2.12",
- "@smithy/smithy-client": "^4.12.5",
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=18.0.0"
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
}
},
- "node_modules/@smithy/util-defaults-mode-node": {
- "version": "4.2.44",
- "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.44.tgz",
- "integrity": "sha512-YPze3/lD1KmWuZsl9JlfhcgGLX7AXhSoaCDtiPntUjNW5/YY0lOHjkcgxyE9x/h5vvS1fzDifMGjzqnNlNiqOQ==",
- "license": "Apache-2.0",
- "dependencies": {
- "@smithy/config-resolver": "^4.4.11",
- "@smithy/credential-provider-imds": "^4.2.12",
- "@smithy/node-config-provider": "^4.3.12",
- "@smithy/property-provider": "^4.2.12",
- "@smithy/smithy-client": "^4.12.5",
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
- },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
"engines": {
- "node": ">=18.0.0"
+ "node": ">=6.0.0"
}
},
- "node_modules/@smithy/util-endpoints": {
- "version": "3.3.3",
- "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.3.tgz",
- "integrity": "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig==",
- "license": "Apache-2.0",
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
"dependencies": {
- "@smithy/node-config-provider": "^4.3.12",
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=18.0.0"
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
}
},
- "node_modules/@smithy/util-hex-encoding": {
- "version": "4.2.2",
- "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz",
- "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==",
- "license": "Apache-2.0",
- "dependencies": {
- "tslib": "^2.6.2"
- },
+ "node_modules/@noble/hashes": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
+ "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
+ "dev": true,
"engines": {
- "node": ">=18.0.0"
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
}
},
- "node_modules/@smithy/util-middleware": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.12.tgz",
- "integrity": "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ==",
- "license": "Apache-2.0",
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
"dependencies": {
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">= 8"
}
},
- "node_modules/@smithy/util-retry": {
- "version": "4.2.12",
- "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.12.tgz",
- "integrity": "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ==",
- "license": "Apache-2.0",
- "dependencies": {
- "@smithy/service-error-classification": "^4.2.12",
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
- },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
"engines": {
- "node": ">=18.0.0"
+ "node": ">= 8"
}
},
- "node_modules/@smithy/util-stream": {
- "version": "4.5.19",
- "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.19.tgz",
- "integrity": "sha512-v4sa+3xTweL1CLO2UP0p7tvIMH/Rq1X4KKOxd568mpe6LSLMQCnDHs4uv7m3ukpl3HvcN2JH6jiCS0SNRXKP/w==",
- "license": "Apache-2.0",
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
"dependencies": {
- "@smithy/fetch-http-handler": "^5.3.15",
- "@smithy/node-http-handler": "^4.4.16",
- "@smithy/types": "^4.13.1",
- "@smithy/util-base64": "^4.3.2",
- "@smithy/util-buffer-from": "^4.2.2",
- "@smithy/util-hex-encoding": "^4.2.2",
- "@smithy/util-utf8": "^4.2.2",
- "tslib": "^2.6.2"
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">= 8"
}
},
- "node_modules/@smithy/util-uri-escape": {
- "version": "4.2.2",
- "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz",
- "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==",
- "license": "Apache-2.0",
- "dependencies": {
- "tslib": "^2.6.2"
- },
+ "node_modules/@opentelemetry/api": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
+ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"engines": {
- "node": ">=18.0.0"
+ "node": ">=8.0.0"
}
},
- "node_modules/@smithy/util-utf8": {
- "version": "4.2.2",
- "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz",
- "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==",
- "license": "Apache-2.0",
+ "node_modules/@paralleldrive/cuid2": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
+ "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==",
+ "dev": true,
"dependencies": {
- "@smithy/util-buffer-from": "^4.2.2",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=18.0.0"
+ "@noble/hashes": "^1.1.5"
}
},
- "node_modules/@smithy/util-waiter": {
- "version": "4.2.13",
- "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.13.tgz",
- "integrity": "sha512-2zdZ9DTHngRtcYxJK1GUDxruNr53kv5W2Lupe0LMU+Imr6ohQg8M2T14MNkj1Y0wS3FFwpgpGQyvuaMF7CiTmQ==",
- "license": "Apache-2.0",
+ "node_modules/@sinclair/typebox": {
+ "version": "0.27.10",
+ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz",
+ "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==",
+ "dev": true
+ },
+ "node_modules/@sinonjs/commons": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz",
+ "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==",
+ "dev": true,
"dependencies": {
- "@smithy/abort-controller": "^4.2.12",
- "@smithy/types": "^4.13.1",
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=18.0.0"
+ "type-detect": "4.0.8"
}
},
- "node_modules/@smithy/uuid": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz",
- "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==",
- "license": "Apache-2.0",
+ "node_modules/@sinonjs/fake-timers": {
+ "version": "10.3.0",
+ "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz",
+ "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==",
+ "dev": true,
"dependencies": {
- "tslib": "^2.6.2"
- },
- "engines": {
- "node": ">=18.0.0"
+ "@sinonjs/commons": "^3.0.0"
}
},
"node_modules/@socket.io/component-emitter": {
@@ -2828,12 +1404,29 @@
"undici-types": "~7.18.0"
}
},
+ "node_modules/@types/node-fetch": {
+ "version": "2.6.13",
+ "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
+ "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
+ "dependencies": {
+ "@types/node": "*",
+ "form-data": "^4.0.4"
+ }
+ },
"node_modules/@types/stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
"integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==",
"dev": true
},
+ "node_modules/@types/tunnel": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/@types/tunnel/-/tunnel-0.0.3.tgz",
+ "integrity": "sha512-sOUTGn6h1SfQ+gbgqC364jLFBw2lnFqkgF3q0WovEHRLMrVD1sd5aufqi/aJObLekJO+Aq5z646U4Oxy6shXMA==",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -2857,6 +1450,19 @@
"integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==",
"dev": true
},
+ "node_modules/@typespec/ts-http-runtime": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.4.tgz",
+ "integrity": "sha512-CI0NhTrz4EBaa0U+HaaUZrJhPoso8sG7ZFya8uQoBA57fjzrjRSv87ekCjLZOFExN+gXE/z0xuN2QfH4H2HrLQ==",
+ "dependencies": {
+ "http-proxy-agent": "^7.0.0",
+ "https-proxy-agent": "^7.0.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@@ -2880,7 +1486,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3024,8 +1629,7 @@
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
- "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
- "dev": true
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/babel-jest": {
"version": "29.7.0",
@@ -3267,12 +1871,6 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
- "node_modules/bowser": {
- "version": "2.14.1",
- "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
- "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
- "license": "MIT"
- },
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -3314,7 +1912,6 @@
"url": "https://github.com/sponsors/ai"
}
],
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -3527,29 +2124,6 @@
"node": ">=12"
}
},
- "node_modules/cloudinary": {
- "version": "1.41.3",
- "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-1.41.3.tgz",
- "integrity": "sha512-4o84y+E7dbif3lMns+p3UW6w6hLHEifbX/7zBJvaih1E9QNMZITENQ14GPYJC4JmhygYXsuuBb9bRA3xWEoOfg==",
- "license": "MIT",
- "dependencies": {
- "cloudinary-core": "^2.13.0",
- "core-js": "^3.30.1",
- "lodash": "^4.17.21",
- "q": "^1.5.1"
- },
- "engines": {
- "node": ">=0.6"
- }
- },
- "node_modules/cloudinary-core": {
- "version": "2.14.1",
- "resolved": "https://registry.npmjs.org/cloudinary-core/-/cloudinary-core-2.14.1.tgz",
- "integrity": "sha512-57rgZSQD2cJsz1rga6M7jIDQuEAzkwvq63vTvs3/I8rNpGLyHMoKoIvBkNS0Guv5RZ9KDReJhI2LmElk4D9U1g==",
- "peerDependencies": {
- "lodash": ">=4.0"
- }
- },
"node_modules/co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@@ -3595,7 +2169,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
- "dev": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
@@ -3695,16 +2268,6 @@
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
"dev": true
},
- "node_modules/core-js": {
- "version": "3.48.0",
- "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz",
- "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==",
- "hasInstallScript": true,
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/core-js"
- }
- },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@@ -4066,7 +2629,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
- "dev": true,
"engines": {
"node": ">=0.4.0"
}
@@ -4264,7 +2826,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
- "dev": true,
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
@@ -4307,7 +2868,6 @@
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -4466,6 +3026,14 @@
"node": ">= 0.6"
}
},
+ "node_modules/events": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
+ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
+ "engines": {
+ "node": ">=0.8.x"
+ }
+ },
"node_modules/execa": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
@@ -4624,40 +3192,6 @@
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"dev": true
},
- "node_modules/fast-xml-builder": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.3.tgz",
- "integrity": "sha512-1o60KoFw2+LWKQu3IdcfcFlGTW4dpqEWmjhYec6H82AYZU2TVBXep6tMl8Z1Y+wM+ZrzCwe3BZ9Vyd9N2rIvmg==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/NaturalIntelligence"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "path-expression-matcher": "^1.1.3"
- }
- },
- "node_modules/fast-xml-parser": {
- "version": "5.4.1",
- "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz",
- "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/NaturalIntelligence"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "fast-xml-builder": "^1.0.0",
- "strnum": "^2.1.2"
- },
- "bin": {
- "fxparser": "src/cli/cli.js"
- }
- },
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -4794,16 +3328,15 @@
}
},
"node_modules/flatted": {
- "version": "3.4.1",
- "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
- "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
+ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
- "dev": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
@@ -5110,7 +3643,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
- "dev": true,
"dependencies": {
"has-symbols": "^1.0.3"
},
@@ -5165,6 +3697,18 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
@@ -6248,8 +4792,7 @@
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
- "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
- "peer": true
+ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="
},
"node_modules/lodash.defaults": {
"version": "4.2.0",
@@ -6942,21 +5485,6 @@
"node": ">=8"
}
},
- "node_modules/path-expression-matcher": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz",
- "integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/NaturalIntelligence"
- }
- ],
- "license": "MIT",
- "engines": {
- "node": ">=14.0.0"
- }
- },
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@@ -6994,7 +5522,6 @@
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
- "peer": true,
"dependencies": {
"pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0",
@@ -7248,6 +5775,14 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
+ "node_modules/process": {
+ "version": "0.11.10",
+ "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
+ "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -7325,16 +5860,6 @@
}
]
},
- "node_modules/q": {
- "version": "1.5.1",
- "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz",
- "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==",
- "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)",
- "engines": {
- "node": ">=0.6.0",
- "teleport": ">=0.2.0"
- }
- },
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
@@ -7618,6 +6143,14 @@
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
+ "node_modules/sax": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
+ "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
+ "engines": {
+ "node": ">=11.0.0"
+ }
+ },
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -7849,9 +6382,9 @@
}
},
"node_modules/socket.io-parser": {
- "version": "4.2.5",
- "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
- "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
+ "version": "4.2.6",
+ "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
+ "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
@@ -8048,18 +6581,6 @@
"node": ">=12.*"
}
},
- "node_modules/strnum": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz",
- "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/NaturalIntelligence"
- }
- ],
- "license": "MIT"
- },
"node_modules/superagent": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
@@ -8193,12 +6714,25 @@
"nodetouch": "bin/nodetouch.js"
}
},
+ "node_modules/tr46": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
+ },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
+ "node_modules/tunnel": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz",
+ "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==",
+ "engines": {
+ "node": ">=0.6.11 <=0.7.0 || >=0.7.3"
+ }
+ },
"node_modules/tunnel-ssh": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/tunnel-ssh/-/tunnel-ssh-4.1.6.tgz",
@@ -8409,6 +6943,20 @@
"node": ">= 8"
}
},
+ "node_modules/webidl-conversions": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
+ },
+ "node_modules/whatwg-url": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+ "dependencies": {
+ "tr46": "~0.0.3",
+ "webidl-conversions": "^3.0.0"
+ }
+ },
"node_modules/when": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/when/-/when-2.0.1.tgz",
@@ -8526,6 +7074,26 @@
}
}
},
+ "node_modules/xml2js": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
+ "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
+ "dependencies": {
+ "sax": ">=0.6.0",
+ "xmlbuilder": "~11.0.0"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/xmlbuilder": {
+ "version": "11.0.1",
+ "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
+ "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
From b80cc0caa0c9dec1effb8bcb9f5751c3a350a1e4 Mon Sep 17 00:00:00 2001
From: Mohamed jameel alabasy
Date: Tue, 24 Mar 2026 17:11:28 +0200
Subject: [PATCH 101/535] fix(pipeline): changed npm install to npm ci
---
.github/workflows/backend-dev.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/backend-dev.yml b/.github/workflows/backend-dev.yml
index 9708acd..d3bd52f 100644
--- a/.github/workflows/backend-dev.yml
+++ b/.github/workflows/backend-dev.yml
@@ -22,7 +22,7 @@ jobs:
cache: npm
- name: Install dependencies
- run: npm install
+ run: npm ci
- name: Validate package files
run: |
@@ -108,7 +108,7 @@ jobs:
cache: npm
- name: Install dependencies
- run: npm install
+ run: npm ci
- name: Wait for PostgreSQL to be ready
run: |
From e519d89bd677673ce9c6960a406bf53ffd02ea54 Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Fri, 27 Mar 2026 16:12:48 +0200
Subject: [PATCH 102/535] test(tracks): add unit tests for visibility and
delete service flows
---
tests/tracks.test.js | 170 +++++++++++++++++++++++++++++++++++++++++--
1 file changed, 164 insertions(+), 6 deletions(-)
diff --git a/tests/tracks.test.js b/tests/tracks.test.js
index 016072c..e803903 100644
--- a/tests/tracks.test.js
+++ b/tests/tracks.test.js
@@ -2,13 +2,171 @@
// tests/tracks.test.js — Unit & integration tests
// Mirrors: src/routes/tracks.routes.js + src/services/tracks.service.js
// ============================================================
-const request = require('supertest');
-const app = require('../app');
+jest.mock('../src/models/track.model.js', () => ({
+ findTrackByIdWithDetails: jest.fn(),
+ updateTrackVisibility: jest.fn(),
+ deleteTrackPermanently: jest.fn(),
+}));
-describe('tracks module', () => {
- it('should be defined', () => {
- expect(app).toBeDefined();
+jest.mock('../src/services/storage.service.js', () => ({
+ deleteManyByUrls: jest.fn(),
+}));
+
+
+const tracksModel = require('../src/models/track.model.js');
+const tracksService = require('../src/services/tracks.service.js');
+const storageService = require('../src/services/storage.service.js');
+
+describe('tracksService.updateTrackVisibility', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('updates visibility when requester is the owner', async () => {
+ // make fake db responses
+ tracksModel.findTrackByIdWithDetails.mockResolvedValue({
+ id: 'track-1',
+ user_id: 'user-1',
+ });
+ tracksModel.updateTrackVisibility.mockResolvedValue({
+ id: 'track-1',
+ is_public: false,
+ });
+
+ const result = await tracksService.updateTrackVisibility('track-1', 'user-1', false);
+
+ expect(result).toEqual({
+ track_id: 'track-1',
+ is_public: false,
+ });
+
+ expect(tracksModel.findTrackByIdWithDetails).toHaveBeenCalledWith('track-1');
+ expect(tracksModel.updateTrackVisibility).toHaveBeenCalledWith('track-1', false);
});
- // TODO: Add test cases here
+ it('throws 400 when is_public is not boolean', async () => {
+ await expect(
+ tracksService.updateTrackVisibility('track-1', 'user-1', 'false')
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ code: 'VALIDATION_ERROR',
+ });
+
+ expect(tracksModel.findTrackByIdWithDetails).not.toHaveBeenCalled();
+ expect(tracksModel.updateTrackVisibility).not.toHaveBeenCalled();
+ });
+
+ it('throws 404 when track not found', async () => {
+ tracksModel.findTrackByIdWithDetails.mockResolvedValue(null);
+
+ await expect(
+ tracksService.updateTrackVisibility('track-1', 'user-1', false)
+ ).rejects.toMatchObject({
+ statusCode: 404,
+ code: 'TRACK_NOT_FOUND',
+ });
+
+ expect(tracksModel.updateTrackVisibility).not.toHaveBeenCalled();
+ });
+
+ it('throws 403 when requester is not the owner', async () => {
+ tracksModel.findTrackByIdWithDetails.mockResolvedValue({
+ id: 'track-1',
+ user_id: 'user-1',
+ });
+
+ await expect(
+ tracksService.updateTrackVisibility('track-1', 'user-2', false)
+ ).rejects.toMatchObject({
+ statusCode: 403,
+ code: 'PERMISSION_NOT_OWNER',
+ });
+
+ expect(tracksModel.updateTrackVisibility).not.toHaveBeenCalled();
+ });
});
+
+describe('tracksService.deleteTrack', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('deletes audio and derived assets then removes the track when requester is the owner', async () => {
+
+ tracksModel.findTrackByIdWithDetails.mockResolvedValue({
+ id: 'track-1',
+ user_id: 'user-1',
+ audio_url: 'audio-url',
+ stream_url: 'stream-url',
+ preview_url: 'preview-url',
+ waveform_url: 'waveform-url',
+ cover_image: 'cover-url',
+ });
+
+ tracksModel.deleteTrackPermanently.mockResolvedValue({
+ id: 'track-1',
+ });
+
+
+ const result = await tracksService.deleteTrack('track-1', 'user-1');
+
+ expect(result).toBeUndefined();
+ expect(storageService.deleteManyByUrls).toHaveBeenCalledWith([
+ 'audio-url',
+ 'stream-url',
+ 'preview-url',
+ 'waveform-url',
+ 'cover-url',
+ ]);
+ expect(tracksModel.findTrackByIdWithDetails).toHaveBeenCalledWith('track-1');
+ expect(tracksModel.deleteTrackPermanently).toHaveBeenCalledWith('track-1');
+ });
+
+ it('throws 404 when track not found', async () => {
+ tracksModel.findTrackByIdWithDetails.mockResolvedValue(null);
+
+ await expect(
+ tracksService.deleteTrack('track-1', 'user-1')
+ ).rejects.toMatchObject({
+ statusCode: 404,
+ code: 'TRACK_NOT_FOUND',
+ });
+
+ expect(tracksModel.deleteTrackPermanently).not.toHaveBeenCalled();
+ expect(storageService.deleteManyByUrls).not.toHaveBeenCalled();
+ });
+
+ it('throws 403 when requester is not the owner', async () => {
+ tracksModel.findTrackByIdWithDetails.mockResolvedValue({
+ id: 'track-1',
+ user_id: 'user-1',
+ });
+
+ await expect(
+ tracksService.deleteTrack('track-1', 'user-2')
+ ).rejects.toMatchObject({
+ statusCode: 403,
+ code: 'PERMISSION_NOT_OWNER',
+ });
+
+ expect(storageService.deleteManyByUrls).not.toHaveBeenCalled();
+ expect(tracksModel.deleteTrackPermanently).not.toHaveBeenCalled();
+ });
+
+ it('throws 404 when permanent delete reports no deleted track', async () => {
+ tracksModel.findTrackByIdWithDetails.mockResolvedValue({
+ id: 'track-1',
+ user_id: 'user-1',
+ });
+ tracksModel.deleteTrackPermanently.mockResolvedValue(null);
+
+ await expect(
+ tracksService.deleteTrack('track-1', 'user-1')
+ ).rejects.toMatchObject({
+ statusCode: 404,
+ code: 'TRACK_NOT_FOUND',
+ });
+
+ expect(tracksModel.deleteTrackPermanently).toHaveBeenCalledWith('track-1');
+ });
+});
\ No newline at end of file
From 013464a1df471844b617143ef88e93fb7c10c501 Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Fri, 27 Mar 2026 18:26:12 +0200
Subject: [PATCH 103/535] fix(tracks): return 204 no content for delete track
endpoint
---
src/controllers/tracks.controller.js | 2 +-
tests/tracks.test.js | 5 ++++-
2 files changed, 5 insertions(+), 2 deletions(-)
diff --git a/src/controllers/tracks.controller.js b/src/controllers/tracks.controller.js
index 21e1a42..71d6895 100644
--- a/src/controllers/tracks.controller.js
+++ b/src/controllers/tracks.controller.js
@@ -66,7 +66,7 @@ const deleteTrack = async (req, res) => {
await tracksService.deleteTrack(track_id, userId);
- return success(res, null, 'Track deleted successfully', 200);
+ return success(res, null, 'Track deleted successfully', 204);
};
const updateTrack = async (req, res) => {
diff --git a/tests/tracks.test.js b/tests/tracks.test.js
index e803903..163dd28 100644
--- a/tests/tracks.test.js
+++ b/tests/tracks.test.js
@@ -17,13 +17,15 @@ const tracksModel = require('../src/models/track.model.js');
const tracksService = require('../src/services/tracks.service.js');
const storageService = require('../src/services/storage.service.js');
+
+// Test Update Track Visibility -
describe('tracksService.updateTrackVisibility', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('updates visibility when requester is the owner', async () => {
- // make fake db responses
+ // Make fake db responses
tracksModel.findTrackByIdWithDetails.mockResolvedValue({
id: 'track-1',
user_id: 'user-1',
@@ -86,6 +88,7 @@ describe('tracksService.updateTrackVisibility', () => {
});
});
+// Test Delete Track -
describe('tracksService.deleteTrack', () => {
beforeEach(() => {
jest.clearAllMocks();
From 81835045c8d348bae070bf1f11a8f53fe788c0ae Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Fri, 27 Mar 2026 18:43:33 +0200
Subject: [PATCH 104/535] test(tracks): add unit tests for getTrackById
---
tests/tracks.test.js | 103 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 103 insertions(+)
diff --git a/tests/tracks.test.js b/tests/tracks.test.js
index 163dd28..f7d6c9d 100644
--- a/tests/tracks.test.js
+++ b/tests/tracks.test.js
@@ -6,6 +6,7 @@ jest.mock('../src/models/track.model.js', () => ({
findTrackByIdWithDetails: jest.fn(),
updateTrackVisibility: jest.fn(),
deleteTrackPermanently: jest.fn(),
+ findMyTracks: jest.fn(),
}));
jest.mock('../src/services/storage.service.js', () => ({
@@ -172,4 +173,106 @@ describe('tracksService.deleteTrack', () => {
expect(tracksModel.deleteTrackPermanently).toHaveBeenCalledWith('track-1');
});
+});
+
+// Testing getTrackByID
+describe('tracksService.getTrackById', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('returns the track when it exists and is public', async () => {
+ tracksModel.findTrackByIdWithDetails.mockResolvedValue({
+ id: 'track-1',
+ user_id: 'user-1',
+ is_public: true,
+ is_hidden: false,
+ title: 'My Track',
+ });
+
+ const result = await tracksService.getTrackById('track-1', null);
+
+ expect(result).toEqual({
+ id: 'track-1',
+ user_id: 'user-1',
+ is_public: true,
+ is_hidden: false,
+ title: 'My Track',
+ });
+
+ expect(tracksModel.findTrackByIdWithDetails).toHaveBeenCalledWith('track-1');
+ });
+
+ it('returns the track when requester is the owner even if track is private', async () => {
+ tracksModel.findTrackByIdWithDetails.mockResolvedValue({
+ id: 'track-1',
+ user_id: 'user-1',
+ is_public: false,
+ is_hidden: false,
+ title: 'Private Track',
+ });
+
+ const result = await tracksService.getTrackById('track-1', 'user-1');
+
+ expect(result).toEqual({
+ id: 'track-1',
+ user_id: 'user-1',
+ is_public: false,
+ is_hidden: false,
+ title: 'Private Track',
+ });
+
+ expect(tracksModel.findTrackByIdWithDetails).toHaveBeenCalledWith('track-1');
+ });
+
+ it('throws 404 when track not found', async () => {
+ tracksModel.findTrackByIdWithDetails.mockResolvedValue(null);
+
+ await expect(
+ tracksService.getTrackById('track-1', null)
+ ).rejects.toMatchObject({
+ statusCode: 404,
+ code: 'TRACK_NOT_FOUND',
+ });
+
+ expect(tracksModel.findTrackByIdWithDetails).toHaveBeenCalledWith('track-1');
+ });
+
+ it('throws 404 when track is hidden and requester is not the owner', async () => {
+ tracksModel.findTrackByIdWithDetails.mockResolvedValue({
+ id: 'track-1',
+ user_id: 'user-1',
+ is_public: true,
+ is_hidden: true,
+ title: 'Hidden Track',
+ });
+
+ await expect(
+ tracksService.getTrackById('track-1', 'user-2')
+ ).rejects.toMatchObject({
+ statusCode: 404,
+ code: 'TRACK_NOT_FOUND',
+ });
+
+ expect(tracksModel.findTrackByIdWithDetails).toHaveBeenCalledWith('track-1');
+ });
+
+ it('throws 403 when track is private and requester is not the owner', async () => {
+ tracksModel.findTrackByIdWithDetails.mockResolvedValue({
+ id: 'track-1',
+ user_id: 'user-1',
+ is_public: false,
+ is_hidden: false,
+ title: 'Private Track',
+ });
+
+ await expect(
+ tracksService.getTrackById('track-1', 'user-2')
+ ).rejects.toMatchObject({
+ statusCode: 403,
+ code: 'RESOURCE_PRIVATE',
+ });
+
+ expect(tracksModel.findTrackByIdWithDetails).toHaveBeenCalledWith('track-1');
+ });
});
\ No newline at end of file
From 524d0caeb133156523e1d76c3cd0c8b9061ea8f3 Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Fri, 27 Mar 2026 22:32:53 +0200
Subject: [PATCH 105/535] fix(tracks): return 204 no content for delete track
endpoint
---
src/controllers/tracks.controller.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/controllers/tracks.controller.js b/src/controllers/tracks.controller.js
index 71d6895..ca5d46a 100644
--- a/src/controllers/tracks.controller.js
+++ b/src/controllers/tracks.controller.js
@@ -66,7 +66,7 @@ const deleteTrack = async (req, res) => {
await tracksService.deleteTrack(track_id, userId);
- return success(res, null, 'Track deleted successfully', 204);
+ return res.status(204).send();
};
const updateTrack = async (req, res) => {
From 41a3b582aa7f9c1b100016097bb9d08b667f4de7 Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Fri, 27 Mar 2026 23:08:58 +0200
Subject: [PATCH 106/535] test(tracks): add unit tests for getMyTracks
---
tests/tracks.test.js | 152 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 152 insertions(+)
diff --git a/tests/tracks.test.js b/tests/tracks.test.js
index f7d6c9d..eaa86ca 100644
--- a/tests/tracks.test.js
+++ b/tests/tracks.test.js
@@ -275,4 +275,156 @@ describe('tracksService.getTrackById', () => {
expect(tracksModel.findTrackByIdWithDetails).toHaveBeenCalledWith('track-1');
});
+});
+
+// Testing getMyTracks
+describe('tracksService.getMyTracks', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('returns paginated tracks with default page and limit', async () => {
+ tracksModel.findMyTracks.mockResolvedValue({
+ items: [
+ {
+ id: 'track-1',
+ user_id: 'user-1',
+ title: 'Track One',
+ },
+ {
+ id: 'track-2',
+ user_id: 'user-1',
+ title: 'Track Two',
+ },
+ ],
+ total: 2,
+ });
+
+ const result = await tracksService.getMyTracks('user-1', {});
+
+ expect(result).toEqual({
+ items: [
+ {
+ id: 'track-1',
+ user_id: 'user-1',
+ title: 'Track One',
+ },
+ {
+ id: 'track-2',
+ user_id: 'user-1',
+ title: 'Track Two',
+ },
+ ],
+ page: 1,
+ limit: 20,
+ total: 2,
+ total_pages: 1,
+ });
+
+ expect(tracksModel.findMyTracks).toHaveBeenCalledWith('user-1', {
+ limit: 20,
+ offset: 0,
+ status: null,
+ });
+ });
+
+ it('throws 400 when status is invalid', async () => {
+ await expect(
+ tracksService.getMyTracks('user-1', { status: 'draft' })
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ code: 'VALIDATION_ERROR',
+ });
+
+ expect(tracksModel.findMyTracks).not.toHaveBeenCalled();
+ });
+
+ it('uses the provided valid status filter', async () => {
+ tracksModel.findMyTracks.mockResolvedValue({
+ items: [
+ {
+ id: 'track-1',
+ user_id: 'user-1',
+ title: 'Ready Track',
+ status: 'ready',
+ },
+ ],
+ total: 1,
+ });
+
+ const result = await tracksService.getMyTracks('user-1', { status: 'ready' });
+
+ expect(result).toEqual({
+ items: [
+ {
+ id: 'track-1',
+ user_id: 'user-1',
+ title: 'Ready Track',
+ status: 'ready',
+ },
+ ],
+ page: 1,
+ limit: 20,
+ total: 1,
+ total_pages: 1,
+ });
+
+ expect(tracksModel.findMyTracks).toHaveBeenCalledWith('user-1', {
+ limit: 20,
+ offset: 0,
+ status: 'ready',
+ });
+ });
+
+ it('clamps page to minimum 1 and limit to maximum 100', async () => {
+ tracksModel.findMyTracks.mockResolvedValue({
+ items: [],
+ total: 150,
+ });
+
+ const result = await tracksService.getMyTracks('user-1', {
+ page: '0',
+ limit: '500',
+ });
+
+ expect(result).toEqual({
+ items: [],
+ page: 1,
+ limit: 100,
+ total: 150,
+ total_pages: 2,
+ });
+
+ expect(tracksModel.findMyTracks).toHaveBeenCalledWith('user-1', {
+ limit: 100,
+ offset: 0,
+ status: null,
+ });
+ });
+
+ it('calculates offset correctly for later pages', async () => {
+ tracksModel.findMyTracks.mockResolvedValue({
+ items: [],
+ total: 50,
+ });
+
+ const result = await tracksService.getMyTracks('user-1', {
+ page: '3',
+ limit: '10',
+ });
+
+ expect(result).toEqual({
+ items: [],
+ page: 3,
+ limit: 10,
+ total: 50,
+ total_pages: 5,
+ });
+
+ expect(tracksModel.findMyTracks).toHaveBeenCalledWith('user-1', {
+ limit: 10,
+ offset: 20,
+ status: null,
+ });
+ });
});
\ No newline at end of file
From d2f895288b1c9df417464689e5f273b71917e4b0 Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Fri, 27 Mar 2026 23:18:03 +0200
Subject: [PATCH 107/535] test(tracks): add unit tests for getTrackStream
---
tests/tracks.test.js | 91 ++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 91 insertions(+)
diff --git a/tests/tracks.test.js b/tests/tracks.test.js
index eaa86ca..31330d9 100644
--- a/tests/tracks.test.js
+++ b/tests/tracks.test.js
@@ -427,4 +427,95 @@ describe('tracksService.getMyTracks', () => {
status: null,
});
});
+});
+
+// Testing getTrackStream
+describe('tracksService.getTrackStream', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('returns stream_url when stream_url exists', async () => {
+ tracksModel.findTrackByIdWithDetails.mockResolvedValue({
+ id: 'track-1',
+ user_id: 'user-1',
+ is_public: true,
+ is_hidden: false,
+ status: 'ready',
+ stream_url: 'stream-url',
+ audio_url: 'audio-url',
+ });
+
+ const result = await tracksService.getTrackStream('track-1', null);
+
+ expect(result).toEqual({
+ track_id: 'track-1',
+ stream_url: 'stream-url',
+ });
+
+ expect(tracksModel.findTrackByIdWithDetails).toHaveBeenCalledWith('track-1');
+ });
+
+ it('falls back to audio_url when stream_url is missing', async () => {
+ tracksModel.findTrackByIdWithDetails.mockResolvedValue({
+ id: 'track-1',
+ user_id: 'user-1',
+ is_public: true,
+ is_hidden: false,
+ status: 'ready',
+ stream_url: null,
+ audio_url: 'audio-url',
+ });
+
+ const result = await tracksService.getTrackStream('track-1', null);
+
+ expect(result).toEqual({
+ track_id: 'track-1',
+ stream_url: 'audio-url',
+ });
+
+ expect(tracksModel.findTrackByIdWithDetails).toHaveBeenCalledWith('track-1');
+ });
+
+ it('throws 503 when track status is failed', async () => {
+ tracksModel.findTrackByIdWithDetails.mockResolvedValue({
+ id: 'track-1',
+ user_id: 'user-1',
+ is_public: true,
+ is_hidden: false,
+ status: 'failed',
+ stream_url: null,
+ audio_url: 'audio-url',
+ });
+
+ await expect(
+ tracksService.getTrackStream('track-1', null)
+ ).rejects.toMatchObject({
+ statusCode: 503,
+ code: 'UPLOAD_PROCESSING_FAILED',
+ });
+
+ expect(tracksModel.findTrackByIdWithDetails).toHaveBeenCalledWith('track-1');
+ });
+
+ it('throws 500 when no playable audio is available', async () => {
+ tracksModel.findTrackByIdWithDetails.mockResolvedValue({
+ id: 'track-1',
+ user_id: 'user-1',
+ is_public: true,
+ is_hidden: false,
+ status: 'ready',
+ stream_url: null,
+ audio_url: null,
+ });
+
+ await expect(
+ tracksService.getTrackStream('track-1', null)
+ ).rejects.toMatchObject({
+ statusCode: 500,
+ code: 'STREAM_URL_MISSING',
+ });
+
+ expect(tracksModel.findTrackByIdWithDetails).toHaveBeenCalledWith('track-1');
+ });
});
\ No newline at end of file
From 6958717861efdd9621216a44cab4f6472f591b08 Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Fri, 27 Mar 2026 23:33:07 +0200
Subject: [PATCH 108/535] test(tracks): add unit tests for updateTrack
---
tests/tracks.test.js | 413 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 413 insertions(+)
diff --git a/tests/tracks.test.js b/tests/tracks.test.js
index 31330d9..dd7ae6c 100644
--- a/tests/tracks.test.js
+++ b/tests/tracks.test.js
@@ -7,6 +7,8 @@ jest.mock('../src/models/track.model.js', () => ({
updateTrackVisibility: jest.fn(),
deleteTrackPermanently: jest.fn(),
findMyTracks: jest.fn(),
+ updateTrackFields: jest.fn(),
+ getGenreIdByName: jest.fn(),
}));
jest.mock('../src/services/storage.service.js', () => ({
@@ -518,4 +520,415 @@ describe('tracksService.getTrackStream', () => {
expect(tracksModel.findTrackByIdWithDetails).toHaveBeenCalledWith('track-1');
});
+});
+
+// testing updateTrack
+describe('tracksService.updateTrack', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('throws 404 when track not found', async () => {
+ tracksModel.findTrackByIdWithDetails.mockResolvedValue(null);
+
+ await expect(
+ tracksService.updateTrack({
+ trackId: 'track-1',
+ userId: 'user-1',
+ payload: { title: 'New Title' },
+ coverImageFile: null,
+ })
+ ).rejects.toMatchObject({
+ statusCode: 404,
+ code: 'TRACK_NOT_FOUND',
+ });
+
+ expect(tracksModel.updateTrackFields).not.toHaveBeenCalled();
+ });
+
+ it('throws 403 when requester is not the owner', async () => {
+ tracksModel.findTrackByIdWithDetails.mockResolvedValue({
+ id: 'track-1',
+ user_id: 'user-1',
+ });
+
+ await expect(
+ tracksService.updateTrack({
+ trackId: 'track-1',
+ userId: 'user-2',
+ payload: { title: 'New Title' },
+ coverImageFile: null,
+ })
+ ).rejects.toMatchObject({
+ statusCode: 403,
+ code: 'PERMISSION_NOT_OWNER',
+ });
+
+ expect(tracksModel.updateTrackFields).not.toHaveBeenCalled();
+ });
+
+ it('throws 400 when payload is empty and no cover image is provided', async () => {
+ tracksModel.findTrackByIdWithDetails.mockResolvedValue({
+ id: 'track-1',
+ user_id: 'user-1',
+ });
+
+ await expect(
+ tracksService.updateTrack({
+ trackId: 'track-1',
+ userId: 'user-1',
+ payload: {},
+ coverImageFile: null,
+ })
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ code: 'VALIDATION_ERROR',
+ });
+
+ expect(tracksModel.updateTrackFields).not.toHaveBeenCalled();
+ });
+
+ it('throws 400 when no valid fields are provided to update', async () => {
+ tracksModel.findTrackByIdWithDetails.mockResolvedValue({
+ id: 'track-1',
+ user_id: 'user-1',
+ });
+
+ await expect(
+ tracksService.updateTrack({
+ trackId: 'track-1',
+ userId: 'user-1',
+ payload: { unknown_field: 'x' },
+ coverImageFile: null,
+ })
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ code: 'VALIDATION_FAILED',
+ });
+
+ expect(tracksModel.updateTrackFields).not.toHaveBeenCalled();
+ });
+
+ it('updates scalar fields and returns the final track', async () => {
+ tracksModel.findTrackByIdWithDetails
+ .mockResolvedValueOnce({
+ id: 'track-1',
+ user_id: 'user-1',
+ is_public: true,
+ explicit_content: false,
+ enable_downloads: false,
+ enable_offline_listening: false,
+ include_in_rss_feed: true,
+ display_embed_code: true,
+ enable_app_playback: true,
+ allow_comments: true,
+ show_comments_public: true,
+ show_insights_public: true,
+ })
+ .mockResolvedValueOnce({
+ id: 'track-1',
+ user_id: 'user-1',
+ title: 'New Title',
+ description: 'New Description',
+ is_public: false,
+ });
+
+ tracksModel.updateTrackFields.mockResolvedValue({
+ id: 'track-1',
+ });
+
+ const result = await tracksService.updateTrack({
+ trackId: 'track-1',
+ userId: 'user-1',
+ payload: {
+ title: 'New Title',
+ description: 'New Description',
+ is_public: false,
+ },
+ coverImageFile: null,
+ });
+
+ expect(result).toEqual({
+ id: 'track-1',
+ user_id: 'user-1',
+ title: 'New Title',
+ description: 'New Description',
+ is_public: false,
+ });
+
+ expect(tracksModel.updateTrackFields).toHaveBeenCalledWith('track-1', {
+ title: 'New Title',
+ description: 'New Description',
+ is_public: false,
+ });
+ });
+
+ it('throws 404 when updateTrackFields returns null after scalar update', async () => {
+ tracksModel.findTrackByIdWithDetails.mockResolvedValue({
+ id: 'track-1',
+ user_id: 'user-1',
+ is_public: true,
+ explicit_content: false,
+ enable_downloads: false,
+ enable_offline_listening: false,
+ include_in_rss_feed: true,
+ display_embed_code: true,
+ enable_app_playback: true,
+ allow_comments: true,
+ show_comments_public: true,
+ show_insights_public: true,
+ });
+
+ tracksModel.updateTrackFields.mockResolvedValue(null);
+
+ await expect(
+ tracksService.updateTrack({
+ trackId: 'track-1',
+ userId: 'user-1',
+ payload: { title: 'New Title' },
+ coverImageFile: null,
+ })
+ ).rejects.toMatchObject({
+ statusCode: 404,
+ code: 'TRACK_NOT_FOUND',
+ });
+ });
+
+ it('throws 404 when final track lookup after update returns null', async () => {
+ tracksModel.findTrackByIdWithDetails
+ .mockResolvedValueOnce({
+ id: 'track-1',
+ user_id: 'user-1',
+ is_public: true,
+ explicit_content: false,
+ enable_downloads: false,
+ enable_offline_listening: false,
+ include_in_rss_feed: true,
+ display_embed_code: true,
+ enable_app_playback: true,
+ allow_comments: true,
+ show_comments_public: true,
+ show_insights_public: true,
+ })
+ .mockResolvedValueOnce(null);
+
+ tracksModel.updateTrackFields.mockResolvedValue({
+ id: 'track-1',
+ });
+
+ await expect(
+ tracksService.updateTrack({
+ trackId: 'track-1',
+ userId: 'user-1',
+ payload: { title: 'New Title' },
+ coverImageFile: null,
+ })
+ ).rejects.toMatchObject({
+ statusCode: 404,
+ code: 'TRACK_NOT_FOUND',
+ });
+ });
+});
+
+describe('tracksService.updateTrack validations', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('throws 400 when title is empty after trimming', async () => {
+ tracksModel.findTrackByIdWithDetails.mockResolvedValue({
+ id: 'track-1',
+ user_id: 'user-1',
+ is_public: true,
+ explicit_content: false,
+ enable_downloads: false,
+ enable_offline_listening: false,
+ include_in_rss_feed: true,
+ display_embed_code: true,
+ enable_app_playback: true,
+ allow_comments: true,
+ show_comments_public: true,
+ show_insights_public: true,
+ });
+
+ await expect(
+ tracksService.updateTrack({
+ trackId: 'track-1',
+ userId: 'user-1',
+ payload: { title: ' ' },
+ coverImageFile: null,
+ })
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ code: 'VALIDATION_FAILED',
+ });
+
+ expect(tracksModel.updateTrackFields).not.toHaveBeenCalled();
+ });
+
+ it('throws 400 when license_type is invalid', async () => {
+ tracksModel.findTrackByIdWithDetails.mockResolvedValue({
+ id: 'track-1',
+ user_id: 'user-1',
+ is_public: true,
+ explicit_content: false,
+ enable_downloads: false,
+ enable_offline_listening: false,
+ include_in_rss_feed: true,
+ display_embed_code: true,
+ enable_app_playback: true,
+ allow_comments: true,
+ show_comments_public: true,
+ show_insights_public: true,
+ });
+
+ await expect(
+ tracksService.updateTrack({
+ trackId: 'track-1',
+ userId: 'user-1',
+ payload: { license_type: 'pirated' },
+ coverImageFile: null,
+ })
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ code: 'VALIDATION_FAILED',
+ });
+
+ expect(tracksModel.updateTrackFields).not.toHaveBeenCalled();
+ });
+});
+
+describe('tracksService.updateTrack genre and geo validations', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('throws 400 when genre is invalid', async () => {
+ tracksModel.findTrackByIdWithDetails.mockResolvedValue({
+ id: 'track-1',
+ user_id: 'user-1',
+ geo_restriction_type: 'worldwide',
+ geo_regions: [],
+ });
+
+ tracksModel.getGenreIdByName.mockResolvedValue(null);
+
+ await expect(
+ tracksService.updateTrack({
+ trackId: 'track-1',
+ userId: 'user-1',
+ payload: { genre: 'not-a-real-genre' },
+ coverImageFile: null,
+ })
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ code: 'VALIDATION_FAILED',
+ });
+
+ expect(tracksModel.updateTrackFields).not.toHaveBeenCalled();
+ });
+
+ it('throws 400 when geo_restriction_type is invalid', async () => {
+ tracksModel.findTrackByIdWithDetails.mockResolvedValue({
+ id: 'track-1',
+ user_id: 'user-1',
+ geo_restriction_type: 'worldwide',
+ geo_regions: [],
+ });
+
+ await expect(
+ tracksService.updateTrack({
+ trackId: 'track-1',
+ userId: 'user-1',
+ payload: {
+ geo_restriction_type: 'bad_type',
+ geo_regions: [],
+ },
+ coverImageFile: null,
+ })
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ code: 'VALIDATION_FAILED',
+ });
+
+ expect(tracksModel.updateTrackFields).not.toHaveBeenCalled();
+ });
+
+ it('throws 400 when geo_regions is not an array', async () => {
+ tracksModel.findTrackByIdWithDetails.mockResolvedValue({
+ id: 'track-1',
+ user_id: 'user-1',
+ geo_restriction_type: 'worldwide',
+ geo_regions: [],
+ });
+
+ await expect(
+ tracksService.updateTrack({
+ trackId: 'track-1',
+ userId: 'user-1',
+ payload: {
+ geo_restriction_type: 'blocked_regions',
+ geo_regions: '"EG"',
+ },
+ coverImageFile: null,
+ })
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ code: 'VALIDATION_FAILED',
+ });
+
+ expect(tracksModel.updateTrackFields).not.toHaveBeenCalled();
+ });
+
+ it('throws 400 when geo_regions is provided with worldwide', async () => {
+ tracksModel.findTrackByIdWithDetails.mockResolvedValue({
+ id: 'track-1',
+ user_id: 'user-1',
+ geo_restriction_type: 'worldwide',
+ geo_regions: [],
+ });
+
+ await expect(
+ tracksService.updateTrack({
+ trackId: 'track-1',
+ userId: 'user-1',
+ payload: {
+ geo_restriction_type: 'worldwide',
+ geo_regions: ['EG'],
+ },
+ coverImageFile: null,
+ })
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ code: 'VALIDATION_FAILED',
+ });
+
+ expect(tracksModel.updateTrackFields).not.toHaveBeenCalled();
+ });
+
+ it('throws 400 when geo_regions is missing for exclusive_regions', async () => {
+ tracksModel.findTrackByIdWithDetails.mockResolvedValue({
+ id: 'track-1',
+ user_id: 'user-1',
+ geo_restriction_type: 'worldwide',
+ geo_regions: [],
+ });
+
+ await expect(
+ tracksService.updateTrack({
+ trackId: 'track-1',
+ userId: 'user-1',
+ payload: {
+ geo_restriction_type: 'exclusive_regions',
+ geo_regions: [],
+ },
+ coverImageFile: null,
+ })
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ code: 'VALIDATION_FAILED',
+ });
+
+ expect(tracksModel.updateTrackFields).not.toHaveBeenCalled();
+ });
});
\ No newline at end of file
From b5d8995cbd4686e6c4b32f248a1dfc4934ffca62 Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Sat, 28 Mar 2026 00:20:49 +0200
Subject: [PATCH 109/535] test(tracks): add unit tests for uploadTrack
---
tests/tracks.controller.test.js | 110 ++++
...tracks.test.js => tracks.services.test.js} | 468 ++++++++++++++++--
2 files changed, 543 insertions(+), 35 deletions(-)
create mode 100644 tests/tracks.controller.test.js
rename tests/{tracks.test.js => tracks.services.test.js} (67%)
diff --git a/tests/tracks.controller.test.js b/tests/tracks.controller.test.js
new file mode 100644
index 0000000..a1375e0
--- /dev/null
+++ b/tests/tracks.controller.test.js
@@ -0,0 +1,110 @@
+jest.mock('../src/services/tracks.service', () => ({
+ uploadTrack: jest.fn(),
+}));
+
+jest.mock('../src/utils/api-response', () => ({
+ success: jest.fn(),
+}));
+
+const tracksController = require('../src/controllers/tracks.controller');
+const tracksService = require('../src/services/tracks.service');
+const { success } = require('../src/utils/api-response');
+
+describe('tracksController.uploadTrack', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('throws 400 when audio file is missing', async () => {
+ const req = {
+ body: { title: 'My Song' },
+ files: {},
+ user: { id: 'user-1' },
+ };
+ const res = {};
+
+ await expect(
+ tracksController.uploadTrack(req, res)
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ code: 'VALIDATION_FAILED',
+ });
+
+ expect(tracksService.uploadTrack).not.toHaveBeenCalled();
+ });
+
+ it('throws 400 when title is missing', async () => {
+ const req = {
+ body: {},
+ files: {
+ audio_file: [{ originalname: 'song.mp3', size: 123 }],
+ },
+ user: { id: 'user-1' },
+ };
+ const res = {};
+
+ await expect(
+ tracksController.uploadTrack(req, res)
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ code: 'VALIDATION_FAILED',
+ });
+
+ expect(tracksService.uploadTrack).not.toHaveBeenCalled();
+ });
+
+ it('throws 400 when title is blank', async () => {
+ const req = {
+ body: { title: ' ' },
+ files: {
+ audio_file: [{ originalname: 'song.mp3', size: 123 }],
+ },
+ user: { id: 'user-1' },
+ };
+ const res = {};
+
+ await expect(
+ tracksController.uploadTrack(req, res)
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ code: 'VALIDATION_FAILED',
+ });
+
+ expect(tracksService.uploadTrack).not.toHaveBeenCalled();
+ });
+
+ it('calls service and returns 201 when request is valid', async () => {
+ const req = {
+ body: { title: 'My Song', description: 'desc' },
+ files: {
+ audio_file: [{ originalname: 'song.mp3', size: 123 }],
+ cover_image: [{ originalname: 'cover.jpg', size: 55 }],
+ },
+ user: { id: 'user-1' },
+ };
+ const res = {};
+
+ const createdTrack = {
+ id: 'track-1',
+ title: 'My Song',
+ };
+
+ tracksService.uploadTrack.mockResolvedValue(createdTrack);
+
+ await tracksController.uploadTrack(req, res);
+
+ expect(tracksService.uploadTrack).toHaveBeenCalledWith({
+ user: req.user,
+ audioFile: req.files.audio_file[0],
+ coverImageFile: req.files.cover_image[0],
+ body: req.body,
+ });
+
+ expect(success).toHaveBeenCalledWith(
+ res,
+ createdTrack,
+ 'Track created and queued for processing.',
+ 201
+ );
+ });
+});
\ No newline at end of file
diff --git a/tests/tracks.test.js b/tests/tracks.services.test.js
similarity index 67%
rename from tests/tracks.test.js
rename to tests/tracks.services.test.js
index dd7ae6c..29fa34e 100644
--- a/tests/tracks.test.js
+++ b/tests/tracks.services.test.js
@@ -9,17 +9,26 @@ jest.mock('../src/models/track.model.js', () => ({
findMyTracks: jest.fn(),
updateTrackFields: jest.fn(),
getGenreIdByName: jest.fn(),
+ createTrack: jest.fn(),
+ addTrackTags: jest.fn(),
+ addTrackArtists: jest.fn(),
+}));
+
+jest.mock('../src/models/tag.model.js', () => ({
+ findByNames: jest.fn(),
+ findByIds: jest.fn(),
}));
jest.mock('../src/services/storage.service.js', () => ({
deleteManyByUrls: jest.fn(),
+ uploadTrack: jest.fn(),
+ uploadImage: jest.fn(),
}));
-
const tracksModel = require('../src/models/track.model.js');
const tracksService = require('../src/services/tracks.service.js');
const storageService = require('../src/services/storage.service.js');
-
+const tagModel = require('../src/models/tag.model.js');
// Test Update Track Visibility -
describe('tracksService.updateTrackVisibility', () => {
@@ -91,14 +100,13 @@ describe('tracksService.updateTrackVisibility', () => {
});
});
-// Test Delete Track -
+// Test Delete Track -
describe('tracksService.deleteTrack', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('deletes audio and derived assets then removes the track when requester is the owner', async () => {
-
tracksModel.findTrackByIdWithDetails.mockResolvedValue({
id: 'track-1',
user_id: 'user-1',
@@ -113,7 +121,6 @@ describe('tracksService.deleteTrack', () => {
id: 'track-1',
});
-
const result = await tracksService.deleteTrack('track-1', 'user-1');
expect(result).toBeUndefined();
@@ -130,14 +137,12 @@ describe('tracksService.deleteTrack', () => {
it('throws 404 when track not found', async () => {
tracksModel.findTrackByIdWithDetails.mockResolvedValue(null);
-
- await expect(
- tracksService.deleteTrack('track-1', 'user-1')
- ).rejects.toMatchObject({
+
+ await expect(tracksService.deleteTrack('track-1', 'user-1')).rejects.toMatchObject({
statusCode: 404,
code: 'TRACK_NOT_FOUND',
});
-
+
expect(tracksModel.deleteTrackPermanently).not.toHaveBeenCalled();
expect(storageService.deleteManyByUrls).not.toHaveBeenCalled();
});
@@ -148,9 +153,7 @@ describe('tracksService.deleteTrack', () => {
user_id: 'user-1',
});
- await expect(
- tracksService.deleteTrack('track-1', 'user-2')
- ).rejects.toMatchObject({
+ await expect(tracksService.deleteTrack('track-1', 'user-2')).rejects.toMatchObject({
statusCode: 403,
code: 'PERMISSION_NOT_OWNER',
});
@@ -166,9 +169,7 @@ describe('tracksService.deleteTrack', () => {
});
tracksModel.deleteTrackPermanently.mockResolvedValue(null);
- await expect(
- tracksService.deleteTrack('track-1', 'user-1')
- ).rejects.toMatchObject({
+ await expect(tracksService.deleteTrack('track-1', 'user-1')).rejects.toMatchObject({
statusCode: 404,
code: 'TRACK_NOT_FOUND',
});
@@ -230,9 +231,7 @@ describe('tracksService.getTrackById', () => {
it('throws 404 when track not found', async () => {
tracksModel.findTrackByIdWithDetails.mockResolvedValue(null);
- await expect(
- tracksService.getTrackById('track-1', null)
- ).rejects.toMatchObject({
+ await expect(tracksService.getTrackById('track-1', null)).rejects.toMatchObject({
statusCode: 404,
code: 'TRACK_NOT_FOUND',
});
@@ -249,9 +248,7 @@ describe('tracksService.getTrackById', () => {
title: 'Hidden Track',
});
- await expect(
- tracksService.getTrackById('track-1', 'user-2')
- ).rejects.toMatchObject({
+ await expect(tracksService.getTrackById('track-1', 'user-2')).rejects.toMatchObject({
statusCode: 404,
code: 'TRACK_NOT_FOUND',
});
@@ -268,9 +265,7 @@ describe('tracksService.getTrackById', () => {
title: 'Private Track',
});
- await expect(
- tracksService.getTrackById('track-1', 'user-2')
- ).rejects.toMatchObject({
+ await expect(tracksService.getTrackById('track-1', 'user-2')).rejects.toMatchObject({
statusCode: 403,
code: 'RESOURCE_PRIVATE',
});
@@ -331,9 +326,7 @@ describe('tracksService.getMyTracks', () => {
});
it('throws 400 when status is invalid', async () => {
- await expect(
- tracksService.getMyTracks('user-1', { status: 'draft' })
- ).rejects.toMatchObject({
+ await expect(tracksService.getMyTracks('user-1', { status: 'draft' })).rejects.toMatchObject({
statusCode: 400,
code: 'VALIDATION_ERROR',
});
@@ -490,9 +483,7 @@ describe('tracksService.getTrackStream', () => {
audio_url: 'audio-url',
});
- await expect(
- tracksService.getTrackStream('track-1', null)
- ).rejects.toMatchObject({
+ await expect(tracksService.getTrackStream('track-1', null)).rejects.toMatchObject({
statusCode: 503,
code: 'UPLOAD_PROCESSING_FAILED',
});
@@ -511,9 +502,7 @@ describe('tracksService.getTrackStream', () => {
audio_url: null,
});
- await expect(
- tracksService.getTrackStream('track-1', null)
- ).rejects.toMatchObject({
+ await expect(tracksService.getTrackStream('track-1', null)).rejects.toMatchObject({
statusCode: 500,
code: 'STREAM_URL_MISSING',
});
@@ -931,4 +920,413 @@ describe('tracksService.updateTrack genre and geo validations', () => {
expect(tracksModel.updateTrackFields).not.toHaveBeenCalled();
});
-});
\ No newline at end of file
+});
+
+// Testing uploadTrack
+describe('tracksService.uploadTrack validations', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('throws 401 when authenticated user id is missing', async () => {
+ const audioFile = {
+ originalname: 'song.mp3',
+ size: 123,
+ };
+
+ await expect(
+ tracksService.uploadTrack({
+ user: {},
+ audioFile,
+ coverImageFile: null,
+ body: {
+ title: 'My Song',
+ },
+ })
+ ).rejects.toMatchObject({
+ statusCode: 401,
+ code: 'AUTH_TOKEN_INVALID',
+ });
+
+ expect(storageService.uploadTrack).not.toHaveBeenCalled();
+ expect(tracksModel.createTrack).not.toHaveBeenCalled();
+ });
+
+ it('throws 400 when tags is not a valid array', async () => {
+ const audioFile = {
+ originalname: 'song.mp3',
+ size: 123,
+ };
+
+ await expect(
+ tracksService.uploadTrack({
+ user: { id: 'user-1' },
+ audioFile,
+ coverImageFile: null,
+ body: {
+ title: 'My Song',
+ tags: 'not-json',
+ },
+ })
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ code: 'VALIDATION_FAILED',
+ });
+
+ expect(tagModel.findByNames).not.toHaveBeenCalled();
+ expect(storageService.uploadTrack).not.toHaveBeenCalled();
+ expect(tracksModel.createTrack).not.toHaveBeenCalled();
+ });
+
+ it('throws 400 when a tag is not a string', async () => {
+ const audioFile = {
+ originalname: 'song.mp3',
+ size: 123,
+ };
+
+ await expect(
+ tracksService.uploadTrack({
+ user: { id: 'user-1' },
+ audioFile,
+ coverImageFile: null,
+ body: {
+ title: 'My Song',
+ tags: JSON.stringify(['chill', 123]),
+ },
+ })
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ code: 'VALIDATION_FAILED',
+ });
+
+ expect(tagModel.findByNames).not.toHaveBeenCalled();
+ expect(storageService.uploadTrack).not.toHaveBeenCalled();
+ expect(tracksModel.createTrack).not.toHaveBeenCalled();
+ });
+
+ it('throws 400 when a tag name is empty', async () => {
+ const audioFile = {
+ originalname: 'song.mp3',
+ size: 123,
+ };
+
+ await expect(
+ tracksService.uploadTrack({
+ user: { id: 'user-1' },
+ audioFile,
+ coverImageFile: null,
+ body: {
+ title: 'My Song',
+ tags: JSON.stringify(['chill', ' ']),
+ },
+ })
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ code: 'VALIDATION_FAILED',
+ });
+
+ expect(tagModel.findByNames).not.toHaveBeenCalled();
+ expect(storageService.uploadTrack).not.toHaveBeenCalled();
+ expect(tracksModel.createTrack).not.toHaveBeenCalled();
+ });
+
+ it('throws 400 when more than 10 tags are provided', async () => {
+ const audioFile = {
+ originalname: 'song.mp3',
+ size: 123,
+ };
+
+ await expect(
+ tracksService.uploadTrack({
+ user: { id: 'user-1' },
+ audioFile,
+ coverImageFile: null,
+ body: {
+ title: 'My Song',
+ tags: JSON.stringify([
+ 't1',
+ 't2',
+ 't3',
+ 't4',
+ 't5',
+ 't6',
+ 't7',
+ 't8',
+ 't9',
+ 't10',
+ 't11',
+ ]),
+ },
+ })
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ code: 'VALIDATION_FAILED',
+ });
+
+ expect(tagModel.findByNames).not.toHaveBeenCalled();
+ expect(storageService.uploadTrack).not.toHaveBeenCalled();
+ expect(tracksModel.createTrack).not.toHaveBeenCalled();
+ });
+
+ it('throws 400 when unknown tags are provided', async () => {
+ const audioFile = {
+ originalname: 'song.mp3',
+ size: 123,
+ };
+
+ tagModel.findByNames.mockResolvedValue([{ id: 'tag-1', name: 'chill' }]);
+
+ await expect(
+ tracksService.uploadTrack({
+ user: { id: 'user-1' },
+ audioFile,
+ coverImageFile: null,
+ body: {
+ title: 'My Song',
+ tags: JSON.stringify(['chill', 'ambient']),
+ },
+ })
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ code: 'VALIDATION_FAILED',
+ });
+
+ expect(tagModel.findByNames).toHaveBeenCalledWith(['chill', 'ambient']);
+ expect(storageService.uploadTrack).not.toHaveBeenCalled();
+ expect(tracksModel.createTrack).not.toHaveBeenCalled();
+ });
+
+ it('throws 400 when genre is invalid', async () => {
+ const audioFile = {
+ originalname: 'song.mp3',
+ size: 123,
+ };
+
+ tracksModel.getGenreIdByName.mockResolvedValue(null);
+
+ await expect(
+ tracksService.uploadTrack({
+ user: { id: 'user-1' },
+ audioFile,
+ coverImageFile: null,
+ body: {
+ title: 'My Song',
+ genre: 'not-a-real-genre',
+ },
+ })
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ code: 'VALIDATION_FAILED',
+ });
+
+ expect(tracksModel.getGenreIdByName).toHaveBeenCalledWith('not-a-real-genre');
+ expect(storageService.uploadTrack).not.toHaveBeenCalled();
+ expect(tracksModel.createTrack).not.toHaveBeenCalled();
+ });
+
+ it('creates a track, links tags and artist, and returns the created track with tag names', async () => {
+ const audioFile = {
+ originalname: 'song.mp3',
+ size: 12345,
+ };
+
+ const coverImageFile = {
+ originalname: 'cover.jpg',
+ size: 555,
+ };
+
+ tagModel.findByNames.mockResolvedValue([
+ { id: 'tag-1', name: 'chill' },
+ { id: 'tag-2', name: 'ambient' },
+ ]);
+
+ tracksModel.getGenreIdByName.mockResolvedValue('genre-1');
+
+ storageService.uploadTrack.mockResolvedValue({
+ url: 'audio-url',
+ });
+
+ storageService.uploadImage.mockResolvedValue({
+ url: 'cover-url',
+ });
+
+ tracksModel.createTrack.mockResolvedValue({
+ id: 'track-1',
+ title: 'My Song',
+ description: 'desc',
+ genre_id: 'genre-1',
+ cover_image: 'cover-url',
+ audio_url: 'audio-url',
+ file_size: 12345,
+ status: 'processing',
+ is_public: true,
+ user_id: 'user-1',
+ license_type: 'all_rights_reserved',
+ geo_restriction_type: 'worldwide',
+ geo_regions: [],
+ });
+
+ const result = await tracksService.uploadTrack({
+ user: { id: 'user-1' },
+ audioFile,
+ coverImageFile,
+ body: {
+ title: 'My Song',
+ description: 'desc',
+ genre: 'Pop',
+ tags: JSON.stringify(['chill', 'ambient']),
+ is_public: true,
+ },
+ });
+
+ expect(tagModel.findByNames).toHaveBeenCalledWith(['chill', 'ambient']);
+ expect(tracksModel.getGenreIdByName).toHaveBeenCalledWith('Pop');
+ expect(storageService.uploadTrack).toHaveBeenCalled();
+ expect(storageService.uploadImage).toHaveBeenCalled();
+ expect(tracksModel.createTrack).toHaveBeenCalledWith({
+ title: 'My Song',
+ description: 'desc',
+ genre_id: 'genre-1',
+ cover_image: 'cover-url',
+ audio_url: 'audio-url',
+ file_size: 12345,
+ status: 'processing',
+ is_public: true,
+
+ release_date: null,
+ isrc: null,
+ p_line: null,
+ buy_link: null,
+ record_label: null,
+ publisher: null,
+ explicit_content: false,
+ license_type: 'all_rights_reserved',
+
+ enable_downloads: false,
+ enable_offline_listening: false,
+ include_in_rss_feed: true,
+ display_embed_code: true,
+ enable_app_playback: true,
+
+ allow_comments: true,
+ show_comments_public: true,
+ show_insights_public: true,
+ geo_restriction_type: 'worldwide',
+ geo_regions: [],
+ user_id: 'user-1',
+ });
+
+ expect(tracksModel.addTrackTags).toHaveBeenCalledWith('track-1', ['tag-1', 'tag-2']);
+ expect(tracksModel.addTrackArtists).toHaveBeenCalledWith('track-1', ['user-1']);
+
+ expect(result).toEqual({
+ id: 'track-1',
+ title: 'My Song',
+ description: 'desc',
+ genre_id: 'genre-1',
+ cover_image: 'cover-url',
+ audio_url: 'audio-url',
+ file_size: 12345,
+ status: 'processing',
+ is_public: true,
+ user_id: 'user-1',
+ license_type: 'all_rights_reserved',
+ geo_restriction_type: 'worldwide',
+ geo_regions: [],
+ tags: ['chill', 'ambient'],
+ });
+ });
+ it('creates a track without cover image, tags, or genre and uses defaults', async () => {
+ const audioFile = {
+ originalname: 'song.mp3',
+ size: 12345,
+ };
+
+ storageService.uploadTrack.mockResolvedValue({
+ url: 'audio-url',
+ });
+
+ tracksModel.createTrack.mockResolvedValue({
+ id: 'track-1',
+ title: 'My Song',
+ description: null,
+ genre_id: null,
+ cover_image: null,
+ audio_url: 'audio-url',
+ file_size: 12345,
+ status: 'processing',
+ is_public: true,
+ user_id: 'user-1',
+ license_type: 'all_rights_reserved',
+ geo_restriction_type: 'worldwide',
+ geo_regions: [],
+ });
+
+ const result = await tracksService.uploadTrack({
+ user: { id: 'user-1' },
+ audioFile,
+ coverImageFile: null,
+ body: {
+ title: 'My Song',
+ },
+ });
+
+ expect(tagModel.findByNames).not.toHaveBeenCalled();
+ expect(tracksModel.getGenreIdByName).not.toHaveBeenCalled();
+ expect(storageService.uploadTrack).toHaveBeenCalled();
+ expect(storageService.uploadImage).not.toHaveBeenCalled();
+
+ expect(tracksModel.createTrack).toHaveBeenCalledWith({
+ title: 'My Song',
+ description: null,
+ genre_id: null,
+ cover_image: null,
+ audio_url: 'audio-url',
+ file_size: 12345,
+ status: 'processing',
+ is_public: true,
+
+ release_date: null,
+ isrc: null,
+ p_line: null,
+ buy_link: null,
+ record_label: null,
+ publisher: null,
+ explicit_content: false,
+ license_type: 'all_rights_reserved',
+
+ enable_downloads: false,
+ enable_offline_listening: false,
+ include_in_rss_feed: true,
+ display_embed_code: true,
+ enable_app_playback: true,
+
+ allow_comments: true,
+ show_comments_public: true,
+ show_insights_public: true,
+ geo_restriction_type: 'worldwide',
+ geo_regions: [],
+ user_id: 'user-1',
+ });
+
+ expect(tracksModel.addTrackTags).not.toHaveBeenCalled();
+ expect(tracksModel.addTrackArtists).toHaveBeenCalledWith('track-1', ['user-1']);
+
+ expect(result).toEqual({
+ id: 'track-1',
+ title: 'My Song',
+ description: null,
+ genre_id: null,
+ cover_image: null,
+ audio_url: 'audio-url',
+ file_size: 12345,
+ status: 'processing',
+ is_public: true,
+ user_id: 'user-1',
+ license_type: 'all_rights_reserved',
+ geo_restriction_type: 'worldwide',
+ geo_regions: [],
+ tags: [],
+ });
+ });
+});
From 3d21ae5e497fe59b904379df55d29ae8482c1a50 Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Sat, 28 Mar 2026 01:02:05 +0200
Subject: [PATCH 110/535] test(tracks): add unit tests for controller ans
services
---
tests/tracks.controller.test.js | 547 ++++++++++-
...ervices.test.js => tracks.service.test.js} | 889 +++++++++++++++++-
2 files changed, 1415 insertions(+), 21 deletions(-)
rename tests/{tracks.services.test.js => tracks.service.test.js} (59%)
diff --git a/tests/tracks.controller.test.js b/tests/tracks.controller.test.js
index a1375e0..5c0d0c1 100644
--- a/tests/tracks.controller.test.js
+++ b/tests/tracks.controller.test.js
@@ -1,5 +1,11 @@
jest.mock('../src/services/tracks.service', () => ({
uploadTrack: jest.fn(),
+ getTrackById: jest.fn(),
+ updateTrackVisibility: jest.fn(),
+ getMyTracks: jest.fn(),
+ deleteTrack: jest.fn(),
+ updateTrack: jest.fn(),
+ getTrackStream: jest.fn(),
}));
jest.mock('../src/utils/api-response', () => ({
@@ -12,7 +18,7 @@ const { success } = require('../src/utils/api-response');
describe('tracksController.uploadTrack', () => {
beforeEach(() => {
- jest.clearAllMocks();
+ jest.resetAllMocks();
});
it('throws 400 when audio file is missing', async () => {
@@ -23,9 +29,7 @@ describe('tracksController.uploadTrack', () => {
};
const res = {};
- await expect(
- tracksController.uploadTrack(req, res)
- ).rejects.toMatchObject({
+ await expect(tracksController.uploadTrack(req, res)).rejects.toMatchObject({
statusCode: 400,
code: 'VALIDATION_FAILED',
});
@@ -43,9 +47,7 @@ describe('tracksController.uploadTrack', () => {
};
const res = {};
- await expect(
- tracksController.uploadTrack(req, res)
- ).rejects.toMatchObject({
+ await expect(tracksController.uploadTrack(req, res)).rejects.toMatchObject({
statusCode: 400,
code: 'VALIDATION_FAILED',
});
@@ -63,9 +65,7 @@ describe('tracksController.uploadTrack', () => {
};
const res = {};
- await expect(
- tracksController.uploadTrack(req, res)
- ).rejects.toMatchObject({
+ await expect(tracksController.uploadTrack(req, res)).rejects.toMatchObject({
statusCode: 400,
code: 'VALIDATION_FAILED',
});
@@ -107,4 +107,529 @@ describe('tracksController.uploadTrack', () => {
201
);
});
-});
\ No newline at end of file
+});
+
+describe('tracksController.getTrackById', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('calls service with track_id and null requester when req.user is missing', async () => {
+ const req = {
+ params: { track_id: 'track-1' },
+ };
+ const res = {};
+
+ const track = {
+ id: 'track-1',
+ title: 'My Track',
+ };
+
+ tracksService.getTrackById.mockResolvedValue(track);
+
+ await tracksController.getTrackById(req, res);
+
+ expect(tracksService.getTrackById).toHaveBeenCalledWith('track-1', null);
+ expect(success).toHaveBeenCalledWith(res, track, 'Track fetched successfully', 200);
+ });
+
+ it('uses req.user.id as requester user id when present', async () => {
+ const req = {
+ params: { track_id: 'track-1' },
+ user: { id: 'user-1' },
+ };
+ const res = {};
+
+ const track = {
+ id: 'track-1',
+ title: 'My Track',
+ };
+
+ tracksService.getTrackById.mockResolvedValue(track);
+
+ await tracksController.getTrackById(req, res);
+
+ expect(tracksService.getTrackById).toHaveBeenCalledWith('track-1', 'user-1');
+ expect(success).toHaveBeenCalledWith(res, track, 'Track fetched successfully', 200);
+ });
+
+ it('falls back to req.user.sub when req.user.id is missing', async () => {
+ const req = {
+ params: { track_id: 'track-1' },
+ user: { sub: 'user-sub-1' },
+ };
+ const res = {};
+
+ const track = {
+ id: 'track-1',
+ title: 'My Track',
+ };
+
+ tracksService.getTrackById.mockResolvedValue(track);
+
+ await tracksController.getTrackById(req, res);
+
+ expect(tracksService.getTrackById).toHaveBeenCalledWith('track-1', 'user-sub-1');
+ expect(success).toHaveBeenCalledWith(res, track, 'Track fetched successfully', 200);
+ });
+});
+
+describe('tracksController.updateTrackVisibility', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('calls service with track_id, resolved user id, and is_public then returns success', async () => {
+ const req = {
+ params: { track_id: 'track-1' },
+ body: { is_public: false },
+ user: { id: 'user-1' },
+ };
+ const res = {};
+
+ const updatedTrack = {
+ track_id: 'track-1',
+ is_public: false,
+ };
+
+ tracksService.updateTrackVisibility.mockResolvedValue(updatedTrack);
+
+ await tracksController.updateTrackVisibility(req, res);
+
+ expect(tracksService.updateTrackVisibility).toHaveBeenCalledWith('track-1', 'user-1', false);
+
+ expect(success).toHaveBeenCalledWith(
+ res,
+ updatedTrack,
+ 'Track visibility updated successfully',
+ 200
+ );
+ });
+
+ it('falls back to req.user.sub when req.user.id is missing', async () => {
+ const req = {
+ params: { track_id: 'track-1' },
+ body: { is_public: true },
+ user: { sub: 'user-sub-1' },
+ };
+ const res = {};
+
+ const updatedTrack = {
+ track_id: 'track-1',
+ is_public: true,
+ };
+
+ tracksService.updateTrackVisibility.mockResolvedValue(updatedTrack);
+
+ await tracksController.updateTrackVisibility(req, res);
+
+ expect(tracksService.updateTrackVisibility).toHaveBeenCalledWith('track-1', 'user-sub-1', true);
+
+ expect(success).toHaveBeenCalledWith(
+ res,
+ updatedTrack,
+ 'Track visibility updated successfully',
+ 200
+ );
+ });
+
+ it('falls back to req.user.user_id when id and sub are missing', async () => {
+ const req = {
+ params: { track_id: 'track-1' },
+ body: { is_public: true },
+ user: { user_id: 'user-legacy-1' },
+ };
+ const res = {};
+
+ const updatedTrack = {
+ track_id: 'track-1',
+ is_public: true,
+ };
+
+ tracksService.updateTrackVisibility.mockResolvedValue(updatedTrack);
+
+ await tracksController.updateTrackVisibility(req, res);
+
+ expect(tracksService.updateTrackVisibility).toHaveBeenCalledWith(
+ 'track-1',
+ 'user-legacy-1',
+ true
+ );
+
+ expect(success).toHaveBeenCalledWith(
+ res,
+ updatedTrack,
+ 'Track visibility updated successfully',
+ 200
+ );
+ });
+});
+
+describe('tracksController.getMyTracks', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('passes page, limit, and status query params to service and returns success', async () => {
+ const req = {
+ query: {
+ page: '2',
+ limit: '10',
+ status: 'ready',
+ },
+ user: { id: 'user-1' },
+ };
+ const res = {};
+
+ const result = {
+ items: [],
+ page: 2,
+ limit: 10,
+ total: 0,
+ total_pages: 0,
+ };
+
+ tracksService.getMyTracks.mockResolvedValue(result);
+
+ await tracksController.getMyTracks(req, res);
+
+ expect(tracksService.getMyTracks).toHaveBeenCalledWith('user-1', {
+ page: '2',
+ limit: '10',
+ status: 'ready',
+ });
+
+ expect(success).toHaveBeenCalledWith(res, result, 'My tracks fetched successfully', 200);
+ });
+
+ it('passes undefined query values through when query is empty', async () => {
+ const req = {
+ query: {},
+ user: { id: 'user-1' },
+ };
+ const res = {};
+
+ const result = {
+ items: [],
+ page: 1,
+ limit: 20,
+ total: 0,
+ total_pages: 0,
+ };
+
+ tracksService.getMyTracks.mockResolvedValue(result);
+
+ await tracksController.getMyTracks(req, res);
+
+ expect(tracksService.getMyTracks).toHaveBeenCalledWith('user-1', {
+ page: undefined,
+ limit: undefined,
+ status: undefined,
+ });
+
+ expect(success).toHaveBeenCalledWith(res, result, 'My tracks fetched successfully', 200);
+ });
+
+ it('falls back to req.user.sub when req.user.id is missing', async () => {
+ const req = {
+ query: {},
+ user: { sub: 'user-sub-1' },
+ };
+ const res = {};
+
+ const result = {
+ items: [],
+ page: 1,
+ limit: 20,
+ total: 0,
+ total_pages: 0,
+ };
+
+ tracksService.getMyTracks.mockResolvedValue(result);
+
+ await tracksController.getMyTracks(req, res);
+
+ expect(tracksService.getMyTracks).toHaveBeenCalledWith('user-sub-1', {
+ page: undefined,
+ limit: undefined,
+ status: undefined,
+ });
+
+ expect(success).toHaveBeenCalledWith(res, result, 'My tracks fetched successfully', 200);
+ });
+});
+
+describe('tracksController.deleteTrack', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('calls service with track_id and req.user.id then returns 204 with no content', async () => {
+ const req = {
+ params: { track_id: 'track-1' },
+ user: { id: 'user-1' },
+ };
+
+ const res = {
+ status: jest.fn().mockReturnThis(),
+ send: jest.fn(),
+ };
+
+ tracksService.deleteTrack.mockResolvedValue(undefined);
+
+ await tracksController.deleteTrack(req, res);
+
+ expect(tracksService.deleteTrack).toHaveBeenCalledWith('track-1', 'user-1');
+ expect(res.status).toHaveBeenCalledWith(204);
+ expect(res.send).toHaveBeenCalled();
+ expect(success).not.toHaveBeenCalled();
+ });
+
+ it('falls back to req.user.sub when req.user.id is missing', async () => {
+ const req = {
+ params: { track_id: 'track-1' },
+ user: { sub: 'user-sub-1' },
+ };
+
+ const res = {
+ status: jest.fn().mockReturnThis(),
+ send: jest.fn(),
+ };
+
+ tracksService.deleteTrack.mockResolvedValue(undefined);
+
+ await tracksController.deleteTrack(req, res);
+
+ expect(tracksService.deleteTrack).toHaveBeenCalledWith('track-1', 'user-sub-1');
+ expect(res.status).toHaveBeenCalledWith(204);
+ expect(res.send).toHaveBeenCalled();
+ expect(success).not.toHaveBeenCalled();
+ });
+
+ it('falls back to req.user.user_id when id and sub are missing', async () => {
+ const req = {
+ params: { track_id: 'track-1' },
+ user: { user_id: 'legacy-user-1' },
+ };
+
+ const res = {
+ status: jest.fn().mockReturnThis(),
+ send: jest.fn(),
+ };
+
+ tracksService.deleteTrack.mockResolvedValue(undefined);
+
+ await tracksController.deleteTrack(req, res);
+
+ expect(tracksService.deleteTrack).toHaveBeenCalledWith('track-1', 'legacy-user-1');
+ expect(res.status).toHaveBeenCalledWith(204);
+ expect(res.send).toHaveBeenCalled();
+ expect(success).not.toHaveBeenCalled();
+ });
+});
+
+describe('tracksController.updateTrack', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('calls service with trackId, req.body, req.file, and req.user.sub first', async () => {
+ const req = {
+ params: { track_id: 'track-1' },
+ body: { title: 'New Title', is_public: false },
+ file: { originalname: 'cover.jpg', size: 55 },
+ user: { id: 'user-1', sub: 'user-sub-1' },
+ };
+
+ const res = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn(),
+ };
+
+ const updatedTrack = {
+ id: 'track-1',
+ title: 'New Title',
+ };
+
+ tracksService.updateTrack.mockResolvedValue(updatedTrack);
+
+ await tracksController.updateTrack(req, res);
+
+ expect(tracksService.updateTrack).toHaveBeenCalledWith({
+ trackId: 'track-1',
+ userId: 'user-sub-1',
+ payload: req.body,
+ coverImageFile: req.file,
+ });
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.json).toHaveBeenCalledWith({
+ success: true,
+ data: updatedTrack,
+ });
+ });
+
+ it('falls back to req.user.id when req.user.sub is missing', async () => {
+ const req = {
+ params: { track_id: 'track-1' },
+ body: { title: 'New Title' },
+ file: null,
+ user: { id: 'user-1' },
+ };
+
+ const res = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn(),
+ };
+
+ const updatedTrack = {
+ id: 'track-1',
+ title: 'New Title',
+ };
+
+ tracksService.updateTrack.mockResolvedValue(updatedTrack);
+
+ await tracksController.updateTrack(req, res);
+
+ expect(tracksService.updateTrack).toHaveBeenCalledWith({
+ trackId: 'track-1',
+ userId: 'user-1',
+ payload: req.body,
+ coverImageFile: null,
+ });
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.json).toHaveBeenCalledWith({
+ success: true,
+ data: updatedTrack,
+ });
+ });
+
+ it('falls back to req.user.user_id when sub and id are missing', async () => {
+ const req = {
+ params: { track_id: 'track-1' },
+ body: { title: 'New Title' },
+ file: null,
+ user: { user_id: 'legacy-user-1' },
+ };
+
+ const res = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn(),
+ };
+
+ const updatedTrack = {
+ id: 'track-1',
+ title: 'New Title',
+ };
+
+ tracksService.updateTrack.mockResolvedValue(updatedTrack);
+
+ await tracksController.updateTrack(req, res);
+
+ expect(tracksService.updateTrack).toHaveBeenCalledWith({
+ trackId: 'track-1',
+ userId: 'legacy-user-1',
+ payload: req.body,
+ coverImageFile: null,
+ });
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.json).toHaveBeenCalledWith({
+ success: true,
+ data: updatedTrack,
+ });
+ });
+});
+
+describe('tracksController.getTrackStream', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('calls service with req.user.sub first and returns json success response', async () => {
+ const req = {
+ params: { track_id: 'track-1' },
+ user: { id: 'user-1', sub: 'user-sub-1' },
+ };
+
+ const res = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn(),
+ };
+
+ const streamData = {
+ track_id: 'track-1',
+ stream_url: 'stream-url',
+ };
+
+ tracksService.getTrackStream.mockResolvedValue(streamData);
+
+ await tracksController.getTrackStream(req, res);
+
+ expect(tracksService.getTrackStream).toHaveBeenCalledWith('track-1', 'user-sub-1');
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.json).toHaveBeenCalledWith({
+ success: true,
+ data: streamData,
+ });
+ });
+
+ it('falls back to req.user.id when req.user.sub is missing', async () => {
+ const req = {
+ params: { track_id: 'track-1' },
+ user: { id: 'user-1' },
+ };
+
+ const res = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn(),
+ };
+
+ const streamData = {
+ track_id: 'track-1',
+ stream_url: 'stream-url',
+ };
+
+ tracksService.getTrackStream.mockResolvedValue(streamData);
+
+ await tracksController.getTrackStream(req, res);
+
+ expect(tracksService.getTrackStream).toHaveBeenCalledWith('track-1', 'user-1');
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.json).toHaveBeenCalledWith({
+ success: true,
+ data: streamData,
+ });
+ });
+
+ it('passes null requester when req.user is missing', async () => {
+ const req = {
+ params: { track_id: 'track-1' },
+ };
+
+ const res = {
+ status: jest.fn().mockReturnThis(),
+ json: jest.fn(),
+ };
+
+ const streamData = {
+ track_id: 'track-1',
+ stream_url: 'stream-url',
+ };
+
+ tracksService.getTrackStream.mockResolvedValue(streamData);
+
+ await tracksController.getTrackStream(req, res);
+
+ expect(tracksService.getTrackStream).toHaveBeenCalledWith('track-1', null);
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.json).toHaveBeenCalledWith({
+ success: true,
+ data: streamData,
+ });
+ });
+});
diff --git a/tests/tracks.services.test.js b/tests/tracks.service.test.js
similarity index 59%
rename from tests/tracks.services.test.js
rename to tests/tracks.service.test.js
index 29fa34e..3459711 100644
--- a/tests/tracks.services.test.js
+++ b/tests/tracks.service.test.js
@@ -12,6 +12,7 @@ jest.mock('../src/models/track.model.js', () => ({
createTrack: jest.fn(),
addTrackTags: jest.fn(),
addTrackArtists: jest.fn(),
+ replaceTrackTags: jest.fn(),
}));
jest.mock('../src/models/tag.model.js', () => ({
@@ -23,6 +24,7 @@ jest.mock('../src/services/storage.service.js', () => ({
deleteManyByUrls: jest.fn(),
uploadTrack: jest.fn(),
uploadImage: jest.fn(),
+ deleteAllVersionsByUrl: jest.fn(),
}));
const tracksModel = require('../src/models/track.model.js');
@@ -33,9 +35,8 @@ const tagModel = require('../src/models/tag.model.js');
// Test Update Track Visibility -
describe('tracksService.updateTrackVisibility', () => {
beforeEach(() => {
- jest.clearAllMocks();
+ jest.resetAllMocks();
});
-
it('updates visibility when requester is the owner', async () => {
// Make fake db responses
tracksModel.findTrackByIdWithDetails.mockResolvedValue({
@@ -103,7 +104,7 @@ describe('tracksService.updateTrackVisibility', () => {
// Test Delete Track -
describe('tracksService.deleteTrack', () => {
beforeEach(() => {
- jest.clearAllMocks();
+ jest.resetAllMocks();
});
it('deletes audio and derived assets then removes the track when requester is the owner', async () => {
@@ -181,7 +182,7 @@ describe('tracksService.deleteTrack', () => {
// Testing getTrackByID
describe('tracksService.getTrackById', () => {
beforeEach(() => {
- jest.clearAllMocks();
+ jest.resetAllMocks();
});
it('returns the track when it exists and is public', async () => {
@@ -277,7 +278,7 @@ describe('tracksService.getTrackById', () => {
// Testing getMyTracks
describe('tracksService.getMyTracks', () => {
beforeEach(() => {
- jest.clearAllMocks();
+ jest.resetAllMocks();
});
it('returns paginated tracks with default page and limit', async () => {
@@ -427,7 +428,7 @@ describe('tracksService.getMyTracks', () => {
// Testing getTrackStream
describe('tracksService.getTrackStream', () => {
beforeEach(() => {
- jest.clearAllMocks();
+ jest.resetAllMocks();
});
it('returns stream_url when stream_url exists', async () => {
@@ -514,7 +515,7 @@ describe('tracksService.getTrackStream', () => {
// testing updateTrack
describe('tracksService.updateTrack', () => {
beforeEach(() => {
- jest.clearAllMocks();
+ jest.resetAllMocks();
});
it('throws 404 when track not found', async () => {
@@ -721,7 +722,7 @@ describe('tracksService.updateTrack', () => {
describe('tracksService.updateTrack validations', () => {
beforeEach(() => {
- jest.clearAllMocks();
+ jest.resetAllMocks();
});
it('throws 400 when title is empty after trimming', async () => {
@@ -789,7 +790,7 @@ describe('tracksService.updateTrack validations', () => {
describe('tracksService.updateTrack genre and geo validations', () => {
beforeEach(() => {
- jest.clearAllMocks();
+ jest.resetAllMocks();
});
it('throws 400 when genre is invalid', async () => {
@@ -922,10 +923,154 @@ describe('tracksService.updateTrack genre and geo validations', () => {
});
});
+describe('tracksService.updateTrack tag replacement and cover cleanup', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('replaces tags and returns resolved tag names when tags are updated', async () => {
+ tracksModel.findTrackByIdWithDetails
+ .mockResolvedValueOnce({
+ id: 'track-1',
+ user_id: 'user-1',
+ title: 'Old Title',
+ })
+ .mockResolvedValueOnce({
+ id: 'track-1',
+ user_id: 'user-1',
+ title: 'Old Title',
+ tags: ['tag-1', 'tag-2'],
+ });
+
+ tagModel.findByNames.mockResolvedValue([
+ { id: 'tag-1', name: 'chill' },
+ { id: 'tag-2', name: 'ambient' },
+ ]);
+
+ const result = await tracksService.updateTrack({
+ trackId: 'track-1',
+ userId: 'user-1',
+ payload: {
+ tags: JSON.stringify(['Chill', ' ambient ']),
+ },
+ coverImageFile: null,
+ });
+
+ expect(tagModel.findByNames).toHaveBeenCalledWith(['chill', 'ambient']);
+ expect(tracksModel.updateTrackFields).not.toHaveBeenCalled();
+ expect(tracksModel.replaceTrackTags).toHaveBeenCalledWith('track-1', ['tag-1', 'tag-2']);
+
+ expect(result).toEqual({
+ id: 'track-1',
+ user_id: 'user-1',
+ title: 'Old Title',
+ tags: ['chill', 'ambient'],
+ });
+ });
+
+ it('replaces tags with an empty list when payload.tags is empty array', async () => {
+ tracksModel.findTrackByIdWithDetails
+ .mockResolvedValueOnce({
+ id: 'track-1',
+ user_id: 'user-1',
+ title: 'Old Title',
+ })
+ .mockResolvedValueOnce({
+ id: 'track-1',
+ user_id: 'user-1',
+ title: 'Old Title',
+ tags: [],
+ });
+
+ const result = await tracksService.updateTrack({
+ trackId: 'track-1',
+ userId: 'user-1',
+ payload: {
+ tags: JSON.stringify([]),
+ },
+ coverImageFile: null,
+ });
+
+ expect(tagModel.findByNames).not.toHaveBeenCalled();
+ expect(tracksModel.updateTrackFields).not.toHaveBeenCalled();
+ expect(tracksModel.replaceTrackTags).toHaveBeenCalledWith('track-1', []);
+
+ expect(result).toEqual({
+ id: 'track-1',
+ user_id: 'user-1',
+ title: 'Old Title',
+ tags: [],
+ });
+ });
+
+ it('uploads a new cover and deletes old cover versions when the cover changes', async () => {
+ const coverImageFile = {
+ originalname: 'cover.jpg',
+ size: 555,
+ };
+
+ tracksModel.findTrackByIdWithDetails
+ .mockResolvedValueOnce({
+ id: 'track-1',
+ user_id: 'user-1',
+ cover_image: 'old-cover-url',
+ is_public: true,
+ explicit_content: false,
+ enable_downloads: false,
+ enable_offline_listening: false,
+ include_in_rss_feed: true,
+ display_embed_code: true,
+ enable_app_playback: true,
+ allow_comments: true,
+ show_comments_public: true,
+ show_insights_public: true,
+ geo_restriction_type: 'worldwide',
+ geo_regions: [],
+ })
+ .mockResolvedValueOnce({
+ id: 'track-1',
+ user_id: 'user-1',
+ cover_image: 'new-cover-url',
+ title: 'Old Title',
+ });
+
+ storageService.uploadImage.mockResolvedValue({
+ url: 'new-cover-url',
+ });
+
+ tracksModel.updateTrackFields.mockResolvedValue({
+ id: 'track-1',
+ });
+
+ const result = await tracksService.updateTrack({
+ trackId: 'track-1',
+ userId: 'user-1',
+ payload: {},
+ coverImageFile,
+ });
+
+ expect(storageService.uploadImage).toHaveBeenCalled();
+ expect(tracksModel.updateTrackFields).toHaveBeenCalledWith(
+ 'track-1',
+ expect.objectContaining({
+ cover_image: 'new-cover-url',
+ })
+ );
+ expect(storageService.deleteAllVersionsByUrl).toHaveBeenCalledWith('old-cover-url');
+
+ expect(result).toEqual({
+ id: 'track-1',
+ user_id: 'user-1',
+ cover_image: 'new-cover-url',
+ title: 'Old Title',
+ });
+ });
+});
+
// Testing uploadTrack
describe('tracksService.uploadTrack validations', () => {
beforeEach(() => {
- jest.clearAllMocks();
+ jest.resetAllMocks();
});
it('throws 401 when authenticated user id is missing', async () => {
@@ -1330,3 +1475,727 @@ describe('tracksService.uploadTrack validations', () => {
});
});
});
+
+// testing uploadTrack geo validations
+describe('tracksService.uploadTrack geo validations', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('throws 400 when geo_restriction_type is invalid', async () => {
+ const audioFile = {
+ originalname: 'song.mp3',
+ size: 12345,
+ };
+
+ storageService.uploadTrack.mockResolvedValue({
+ url: 'audio-url',
+ });
+
+ await expect(
+ tracksService.uploadTrack({
+ user: { id: 'user-1' },
+ audioFile,
+ coverImageFile: null,
+ body: {
+ title: 'My Song',
+ geo_restriction_type: 'bad_type',
+ geo_regions: [],
+ },
+ })
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ code: 'VALIDATION_FAILED',
+ });
+
+ expect(storageService.uploadTrack).toHaveBeenCalled();
+ expect(storageService.uploadImage).not.toHaveBeenCalled();
+ expect(tracksModel.createTrack).not.toHaveBeenCalled();
+ });
+
+ it('throws 400 when geo_regions is not an array', async () => {
+ const audioFile = {
+ originalname: 'song.mp3',
+ size: 12345,
+ };
+
+ storageService.uploadTrack.mockResolvedValue({
+ url: 'audio-url',
+ });
+
+ await expect(
+ tracksService.uploadTrack({
+ user: { id: 'user-1' },
+ audioFile,
+ coverImageFile: null,
+ body: {
+ title: 'My Song',
+ geo_restriction_type: 'blocked_regions',
+ geo_regions: JSON.stringify('EG'),
+ },
+ })
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ code: 'VALIDATION_FAILED',
+ });
+
+ expect(storageService.uploadTrack).toHaveBeenCalled();
+ expect(storageService.uploadImage).not.toHaveBeenCalled();
+ expect(tracksModel.createTrack).not.toHaveBeenCalled();
+ });
+
+ it('throws 400 when geo_regions is provided with worldwide', async () => {
+ const audioFile = {
+ originalname: 'song.mp3',
+ size: 12345,
+ };
+
+ storageService.uploadTrack.mockResolvedValue({
+ url: 'audio-url',
+ });
+
+ await expect(
+ tracksService.uploadTrack({
+ user: { id: 'user-1' },
+ audioFile,
+ coverImageFile: null,
+ body: {
+ title: 'My Song',
+ geo_restriction_type: 'worldwide',
+ geo_regions: ['EG'],
+ },
+ })
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ code: 'VALIDATION_FAILED',
+ });
+
+ expect(storageService.uploadTrack).toHaveBeenCalled();
+ expect(storageService.uploadImage).not.toHaveBeenCalled();
+ expect(tracksModel.createTrack).not.toHaveBeenCalled();
+ });
+
+ it('throws 400 when geo_regions is missing for exclusive_regions', async () => {
+ const audioFile = {
+ originalname: 'song.mp3',
+ size: 12345,
+ };
+
+ storageService.uploadTrack.mockResolvedValue({
+ url: 'audio-url',
+ });
+
+ await expect(
+ tracksService.uploadTrack({
+ user: { id: 'user-1' },
+ audioFile,
+ coverImageFile: null,
+ body: {
+ title: 'My Song',
+ geo_restriction_type: 'exclusive_regions',
+ geo_regions: [],
+ },
+ })
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ code: 'VALIDATION_FAILED',
+ });
+
+ expect(storageService.uploadTrack).toHaveBeenCalled();
+ expect(storageService.uploadImage).not.toHaveBeenCalled();
+ expect(tracksModel.createTrack).not.toHaveBeenCalled();
+ });
+
+ it('throws 400 when geo_regions is missing for blocked_regions', async () => {
+ const audioFile = {
+ originalname: 'song.mp3',
+ size: 12345,
+ };
+
+ storageService.uploadTrack.mockResolvedValue({
+ url: 'audio-url',
+ });
+
+ await expect(
+ tracksService.uploadTrack({
+ user: { id: 'user-1' },
+ audioFile,
+ coverImageFile: null,
+ body: {
+ title: 'My Song',
+ geo_restriction_type: 'blocked_regions',
+ geo_regions: [],
+ },
+ })
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ code: 'VALIDATION_FAILED',
+ });
+
+ expect(storageService.uploadTrack).toHaveBeenCalled();
+ expect(storageService.uploadImage).not.toHaveBeenCalled();
+ expect(tracksModel.createTrack).not.toHaveBeenCalled();
+ });
+ it('throws when audio upload fails', async () => {
+ const audioFile = {
+ originalname: 'song.mp3',
+ size: 12345,
+ };
+
+ storageService.uploadTrack.mockRejectedValue(new Error('audio upload failed'));
+
+ await expect(
+ tracksService.uploadTrack({
+ user: { id: 'user-1' },
+ audioFile,
+ coverImageFile: null,
+ body: {
+ title: 'My Song',
+ },
+ })
+ ).rejects.toThrow('audio upload failed');
+
+ expect(storageService.uploadTrack).toHaveBeenCalled();
+ expect(storageService.uploadImage).not.toHaveBeenCalled();
+ expect(tracksModel.createTrack).not.toHaveBeenCalled();
+ expect(tracksModel.addTrackTags).not.toHaveBeenCalled();
+ expect(tracksModel.addTrackArtists).not.toHaveBeenCalled();
+ });
+ it('throws when cover upload fails after audio upload succeeds', async () => {
+ const audioFile = {
+ originalname: 'song.mp3',
+ size: 12345,
+ };
+
+ const coverImageFile = {
+ originalname: 'cover.jpg',
+ size: 555,
+ };
+
+ storageService.uploadTrack.mockResolvedValue({
+ url: 'audio-url',
+ });
+
+ storageService.uploadImage.mockRejectedValue(new Error('cover upload failed'));
+
+ await expect(
+ tracksService.uploadTrack({
+ user: { id: 'user-1' },
+ audioFile,
+ coverImageFile,
+ body: {
+ title: 'My Song',
+ },
+ })
+ ).rejects.toThrow('cover upload failed');
+
+ expect(storageService.uploadTrack).toHaveBeenCalled();
+ expect(storageService.uploadImage).toHaveBeenCalled();
+ expect(tracksModel.createTrack).not.toHaveBeenCalled();
+ expect(tracksModel.addTrackTags).not.toHaveBeenCalled();
+ expect(tracksModel.addTrackArtists).not.toHaveBeenCalled();
+ });
+
+ it('throws when createTrack fails after uploads', async () => {
+ const audioFile = {
+ originalname: 'song.mp3',
+ size: 12345,
+ };
+
+ const coverImageFile = {
+ originalname: 'cover.jpg',
+ size: 555,
+ };
+
+ storageService.uploadTrack.mockResolvedValue({
+ url: 'audio-url',
+ });
+
+ storageService.uploadImage.mockResolvedValue({
+ url: 'cover-url',
+ });
+
+ tracksModel.createTrack.mockRejectedValue(new Error('create track failed'));
+
+ await expect(
+ tracksService.uploadTrack({
+ user: { id: 'user-1' },
+ audioFile,
+ coverImageFile,
+ body: {
+ title: 'My Song',
+ },
+ })
+ ).rejects.toThrow('create track failed');
+
+ expect(storageService.uploadTrack).toHaveBeenCalled();
+ expect(storageService.uploadImage).toHaveBeenCalled();
+ expect(tracksModel.createTrack).toHaveBeenCalled();
+ expect(tracksModel.addTrackTags).not.toHaveBeenCalled();
+ expect(tracksModel.addTrackArtists).not.toHaveBeenCalled();
+ });
+
+ it('throws when addTrackTags fails after track creation', async () => {
+ const audioFile = {
+ originalname: 'song.mp3',
+ size: 12345,
+ };
+
+ tagModel.findByNames.mockResolvedValue([
+ { id: 'tag-1', name: 'chill' },
+ { id: 'tag-2', name: 'ambient' },
+ ]);
+
+ storageService.uploadTrack.mockResolvedValue({
+ url: 'audio-url',
+ });
+
+ tracksModel.createTrack.mockResolvedValue({
+ id: 'track-1',
+ title: 'My Song',
+ });
+
+ tracksModel.addTrackTags.mockRejectedValue(new Error('add tags failed'));
+
+ await expect(
+ tracksService.uploadTrack({
+ user: { id: 'user-1' },
+ audioFile,
+ coverImageFile: null,
+ body: {
+ title: 'My Song',
+ tags: JSON.stringify(['chill', 'ambient']),
+ },
+ })
+ ).rejects.toThrow('add tags failed');
+
+ expect(tagModel.findByNames).toHaveBeenCalledWith(['chill', 'ambient']);
+ expect(storageService.uploadTrack).toHaveBeenCalled();
+ expect(tracksModel.createTrack).toHaveBeenCalled();
+ expect(tracksModel.addTrackTags).toHaveBeenCalledWith('track-1', ['tag-1', 'tag-2']);
+ expect(tracksModel.addTrackArtists).not.toHaveBeenCalled();
+ });
+
+ it('throws when addTrackArtists fails after track creation', async () => {
+ const audioFile = {
+ originalname: 'song.mp3',
+ size: 12345,
+ };
+
+ storageService.uploadTrack.mockResolvedValue({
+ url: 'audio-url',
+ });
+
+ tracksModel.createTrack.mockResolvedValue({
+ id: 'track-1',
+ title: 'My Song',
+ });
+
+ tracksModel.addTrackArtists.mockRejectedValue(new Error('add artist failed'));
+
+ await expect(
+ tracksService.uploadTrack({
+ user: { id: 'user-1' },
+ audioFile,
+ coverImageFile: null,
+ body: {
+ title: 'My Song',
+ },
+ })
+ ).rejects.toThrow('add artist failed');
+
+ expect(storageService.uploadTrack).toHaveBeenCalled();
+ expect(tracksModel.createTrack).toHaveBeenCalled();
+ expect(tracksModel.addTrackTags).not.toHaveBeenCalled();
+ expect(tracksModel.addTrackArtists).toHaveBeenCalledWith('track-1', ['user-1']);
+ });
+ it('throws 400 when more than 250 geo regions are provided', async () => {
+ const audioFile = {
+ originalname: 'song.mp3',
+ size: 12345,
+ };
+
+ const geoRegions = Array.from({ length: 251 }, () => 'EG');
+
+ storageService.uploadTrack.mockResolvedValue({
+ url: 'audio-url',
+ });
+
+ await expect(
+ tracksService.uploadTrack({
+ user: { id: 'user-1' },
+ audioFile,
+ coverImageFile: null,
+ body: {
+ title: 'My Song',
+ geo_restriction_type: 'blocked_regions',
+ geo_regions: geoRegions,
+ },
+ })
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ code: 'VALIDATION_FAILED',
+ });
+
+ expect(storageService.uploadTrack).toHaveBeenCalled();
+ expect(storageService.uploadImage).not.toHaveBeenCalled();
+ expect(tracksModel.createTrack).not.toHaveBeenCalled();
+ });
+
+ it('throws 400 when a geo region code is invalid', async () => {
+ const audioFile = {
+ originalname: 'song.mp3',
+ size: 12345,
+ };
+
+ storageService.uploadTrack.mockResolvedValue({
+ url: 'audio-url',
+ });
+
+ await expect(
+ tracksService.uploadTrack({
+ user: { id: 'user-1' },
+ audioFile,
+ coverImageFile: null,
+ body: {
+ title: 'My Song',
+ geo_restriction_type: 'blocked_regions',
+ geo_regions: ['EG', 'egy'],
+ },
+ })
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ code: 'VALIDATION_FAILED',
+ });
+
+ expect(storageService.uploadTrack).toHaveBeenCalled();
+ expect(storageService.uploadImage).not.toHaveBeenCalled();
+ expect(tracksModel.createTrack).not.toHaveBeenCalled();
+ });
+});
+
+// testing tag name validations
+describe('tracksService tag name hydration', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('getTrackById maps tag ids to tag names', async () => {
+ tracksModel.findTrackByIdWithDetails.mockResolvedValue({
+ id: 'track-1',
+ user_id: 'user-1',
+ is_public: true,
+ is_hidden: false,
+ title: 'My Track',
+ tags: ['tag-1', 'tag-2'],
+ });
+
+ tagModel.findByIds.mockResolvedValue([
+ { id: 'tag-1', name: 'chill' },
+ { id: 'tag-2', name: 'ambient' },
+ ]);
+
+ const result = await tracksService.getTrackById('track-1', null);
+
+ expect(tagModel.findByIds).toHaveBeenCalledWith(['tag-1', 'tag-2']);
+ expect(result).toEqual({
+ id: 'track-1',
+ user_id: 'user-1',
+ is_public: true,
+ is_hidden: false,
+ title: 'My Track',
+ tags: ['chill', 'ambient'],
+ });
+ });
+
+ it('getMyTracks maps tag ids to tag names for each returned track', async () => {
+ tracksModel.findMyTracks.mockResolvedValue({
+ items: [
+ {
+ id: 'track-1',
+ user_id: 'user-1',
+ title: 'Track One',
+ tags: ['tag-1', 'tag-2'],
+ },
+ {
+ id: 'track-2',
+ user_id: 'user-1',
+ title: 'Track Two',
+ tags: ['tag-2'],
+ },
+ ],
+ total: 2,
+ });
+
+ tagModel.findByIds.mockResolvedValue([
+ { id: 'tag-1', name: 'chill' },
+ { id: 'tag-2', name: 'ambient' },
+ ]);
+
+ const result = await tracksService.getMyTracks('user-1', {});
+
+ expect(tagModel.findByIds).toHaveBeenCalledWith(['tag-1', 'tag-2']);
+ expect(result).toEqual({
+ items: [
+ {
+ id: 'track-1',
+ user_id: 'user-1',
+ title: 'Track One',
+ tags: ['chill', 'ambient'],
+ },
+ {
+ id: 'track-2',
+ user_id: 'user-1',
+ title: 'Track Two',
+ tags: ['ambient'],
+ },
+ ],
+ page: 1,
+ limit: 20,
+ total: 2,
+ total_pages: 1,
+ });
+ });
+
+ it('getTrackById leaves tag names unchanged when tags are already names', async () => {
+ tracksModel.findTrackByIdWithDetails.mockResolvedValue({
+ id: 'track-1',
+ user_id: 'user-1',
+ is_public: true,
+ is_hidden: false,
+ title: 'My Track',
+ tags: ['chill', 'ambient'],
+ });
+
+ const result = await tracksService.getTrackById('track-1', null);
+
+ expect(tagModel.findByIds).not.toHaveBeenCalled();
+ expect(result).toEqual({
+ id: 'track-1',
+ user_id: 'user-1',
+ is_public: true,
+ is_hidden: false,
+ title: 'My Track',
+ tags: ['chill', 'ambient'],
+ });
+ });
+});
+
+describe('tracksService boolean conversion and tag normalization', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('uploadTrack converts string booleans correctly', async () => {
+ const audioFile = {
+ originalname: 'song.mp3',
+ size: 12345,
+ };
+
+ storageService.uploadTrack.mockResolvedValue({
+ url: 'audio-url',
+ });
+
+ tracksModel.createTrack.mockResolvedValue({
+ id: 'track-1',
+ title: 'My Song',
+ audio_url: 'audio-url',
+ status: 'processing',
+ user_id: 'user-1',
+ });
+
+ await tracksService.uploadTrack({
+ user: { id: 'user-1' },
+ audioFile,
+ coverImageFile: null,
+ body: {
+ title: 'My Song',
+ is_public: 'false',
+ explicit_content: 'true',
+ enable_downloads: 'true',
+ enable_offline_listening: 'true',
+ include_in_rss_feed: 'false',
+ display_embed_code: 'false',
+ enable_app_playback: 'false',
+ allow_comments: 'false',
+ show_comments_public: 'false',
+ show_insights_public: 'false',
+ },
+ });
+
+ expect(tracksModel.createTrack).toHaveBeenCalledWith(
+ expect.objectContaining({
+ is_public: false,
+ explicit_content: true,
+ enable_downloads: true,
+ enable_offline_listening: true,
+ include_in_rss_feed: false,
+ display_embed_code: false,
+ enable_app_playback: false,
+ allow_comments: false,
+ show_comments_public: false,
+ show_insights_public: false,
+ })
+ );
+ });
+
+ it('uploadTrack uses defaults when boolean-like fields are empty strings', async () => {
+ const audioFile = {
+ originalname: 'song.mp3',
+ size: 12345,
+ };
+
+ storageService.uploadTrack.mockResolvedValue({
+ url: 'audio-url',
+ });
+
+ tracksModel.createTrack.mockResolvedValue({
+ id: 'track-1',
+ title: 'My Song',
+ audio_url: 'audio-url',
+ status: 'processing',
+ user_id: 'user-1',
+ });
+
+ await tracksService.uploadTrack({
+ user: { id: 'user-1' },
+ audioFile,
+ coverImageFile: null,
+ body: {
+ title: 'My Song',
+ is_public: '',
+ explicit_content: '',
+ enable_downloads: '',
+ include_in_rss_feed: '',
+ allow_comments: '',
+ },
+ });
+
+ expect(tracksModel.createTrack).toHaveBeenCalledWith(
+ expect.objectContaining({
+ is_public: true,
+ explicit_content: false,
+ enable_downloads: false,
+ include_in_rss_feed: true,
+ allow_comments: true,
+ })
+ );
+ });
+
+ it('updateTrack converts string booleans correctly', async () => {
+ tracksModel.findTrackByIdWithDetails
+ .mockResolvedValueOnce({
+ id: 'track-1',
+ user_id: 'user-1',
+ is_public: true,
+ explicit_content: false,
+ enable_downloads: false,
+ enable_offline_listening: false,
+ include_in_rss_feed: true,
+ display_embed_code: true,
+ enable_app_playback: true,
+ allow_comments: true,
+ show_comments_public: true,
+ show_insights_public: true,
+ geo_restriction_type: 'worldwide',
+ geo_regions: [],
+ })
+ .mockResolvedValueOnce({
+ id: 'track-1',
+ user_id: 'user-1',
+ is_public: false,
+ explicit_content: true,
+ enable_downloads: true,
+ enable_offline_listening: true,
+ include_in_rss_feed: false,
+ display_embed_code: false,
+ enable_app_playback: false,
+ allow_comments: false,
+ show_comments_public: false,
+ show_insights_public: false,
+ });
+
+ tracksModel.updateTrackFields.mockResolvedValue({
+ id: 'track-1',
+ });
+
+ await tracksService.updateTrack({
+ trackId: 'track-1',
+ userId: 'user-1',
+ payload: {
+ is_public: 'false',
+ explicit_content: 'true',
+ enable_downloads: 'true',
+ enable_offline_listening: 'true',
+ include_in_rss_feed: 'false',
+ display_embed_code: 'false',
+ enable_app_playback: 'false',
+ allow_comments: 'false',
+ show_comments_public: 'false',
+ show_insights_public: 'false',
+ },
+ coverImageFile: null,
+ });
+
+ expect(tracksModel.updateTrackFields).toHaveBeenCalledWith(
+ 'track-1',
+ expect.objectContaining({
+ is_public: false,
+ explicit_content: true,
+ enable_downloads: true,
+ enable_offline_listening: true,
+ include_in_rss_feed: false,
+ display_embed_code: false,
+ enable_app_playback: false,
+ allow_comments: false,
+ show_comments_public: false,
+ show_insights_public: false,
+ })
+ );
+ });
+
+ it('uploadTrack normalizes and deduplicates tags before lookup', async () => {
+ const audioFile = {
+ originalname: 'song.mp3',
+ size: 12345,
+ };
+
+ tagModel.findByNames.mockResolvedValue([
+ { id: 'tag-1', name: 'chill' },
+ { id: 'tag-2', name: 'ambient' },
+ ]);
+
+ storageService.uploadTrack.mockResolvedValue({
+ url: 'audio-url',
+ });
+
+ tracksModel.createTrack.mockResolvedValue({
+ id: 'track-1',
+ title: 'My Song',
+ audio_url: 'audio-url',
+ status: 'processing',
+ user_id: 'user-1',
+ });
+
+ const result = await tracksService.uploadTrack({
+ user: { id: 'user-1' },
+ audioFile,
+ coverImageFile: null,
+ body: {
+ title: 'My Song',
+ tags: JSON.stringify([' Chill ', 'ambient', 'CHILL']),
+ },
+ });
+
+ expect(tagModel.findByNames).toHaveBeenCalledWith(['chill', 'ambient']);
+ expect(tracksModel.addTrackTags).toHaveBeenCalledWith('track-1', ['tag-1', 'tag-2']);
+ expect(result).toEqual({
+ id: 'track-1',
+ title: 'My Song',
+ audio_url: 'audio-url',
+ status: 'processing',
+ user_id: 'user-1',
+ tags: ['chill', 'ambient'],
+ });
+ });
+});
From 151b77756fdc04149a5d69398ce45984ff45c913 Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Sat, 28 Mar 2026 01:09:03 +0200
Subject: [PATCH 111/535] test(tracks): add unit tests for tracks model
---
tests/tracks.model.test.js | 168 +++++++++++++++++++++++++++++++++++++
1 file changed, 168 insertions(+)
create mode 100644 tests/tracks.model.test.js
diff --git a/tests/tracks.model.test.js b/tests/tracks.model.test.js
new file mode 100644
index 0000000..d474479
--- /dev/null
+++ b/tests/tracks.model.test.js
@@ -0,0 +1,168 @@
+jest.mock('../src/config/db', () => ({
+ query: jest.fn(),
+}));
+
+const db = require('../src/config/db');
+const tracksModel = require('../src/models/track.model.js');
+
+describe('tracksModel.updateTrackFields', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('returns null when no allowed update fields are provided', async () => {
+ const result = await tracksModel.updateTrackFields('track-1', {
+ unknown_field: 'x',
+ });
+
+ expect(result).toBeNull();
+ expect(db.query).not.toHaveBeenCalled();
+ });
+
+ it('updates allowed fields and stringifies geo_regions', async () => {
+ db.query.mockResolvedValue({
+ rows: [{ id: 'track-1', title: 'New Title' }],
+ });
+
+ const result = await tracksModel.updateTrackFields('track-1', {
+ title: 'New Title',
+ description: 'New Description',
+ geo_regions: ['EG', 'SA'],
+ });
+
+ expect(result).toEqual({
+ id: 'track-1',
+ title: 'New Title',
+ });
+
+ expect(db.query).toHaveBeenCalledTimes(1);
+
+ const [sql, params] = db.query.mock.calls[0];
+
+ expect(sql).toContain('UPDATE tracks');
+ expect(sql).toContain('"title" = $2');
+ expect(sql).toContain('"description" = $3');
+ expect(sql).toContain('"geo_regions" = $4');
+ expect(sql).toContain('WHERE id = $1 AND deleted_at IS NULL');
+
+ expect(params).toEqual([
+ 'track-1',
+ 'New Title',
+ 'New Description',
+ JSON.stringify(['EG', 'SA']),
+ ]);
+ });
+});
+
+describe('tracksModel.replaceTrackTags', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('deletes old track tags and inserts the new ones', async () => {
+ db.query.mockResolvedValue({});
+
+ await tracksModel.replaceTrackTags('track-1', ['tag-1', 'tag-2']);
+
+ expect(db.query).toHaveBeenCalledTimes(3);
+
+ expect(db.query).toHaveBeenNthCalledWith(1, 'DELETE FROM track_tags WHERE track_id = $1', [
+ 'track-1',
+ ]);
+
+ expect(db.query).toHaveBeenNthCalledWith(
+ 2,
+ 'INSERT INTO track_tags (track_id, tag_id) VALUES ($1, $2)',
+ ['track-1', 'tag-1']
+ );
+
+ expect(db.query).toHaveBeenNthCalledWith(
+ 3,
+ 'INSERT INTO track_tags (track_id, tag_id) VALUES ($1, $2)',
+ ['track-1', 'tag-2']
+ );
+ });
+
+ it('only deletes old tags when new tagIds is empty', async () => {
+ db.query.mockResolvedValue({});
+
+ await tracksModel.replaceTrackTags('track-1', []);
+
+ expect(db.query).toHaveBeenCalledTimes(1);
+ expect(db.query).toHaveBeenCalledWith('DELETE FROM track_tags WHERE track_id = $1', [
+ 'track-1',
+ ]);
+ });
+});
+
+describe('tracksModel.findMyTracks', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('returns items and total without status filter', async () => {
+ db.query
+ .mockResolvedValueOnce({
+ rows: [{ id: 'track-1', title: 'Track One' }],
+ })
+ .mockResolvedValueOnce({
+ rows: [{ total: 1 }],
+ });
+
+ const result = await tracksModel.findMyTracks('user-1', {
+ limit: 10,
+ offset: 20,
+ status: null,
+ });
+
+ expect(result).toEqual({
+ items: [{ id: 'track-1', title: 'Track One' }],
+ total: 1,
+ });
+
+ expect(db.query).toHaveBeenCalledTimes(2);
+
+ const [itemsSql, itemsParams] = db.query.mock.calls[0];
+ const [countSql, countParams] = db.query.mock.calls[1];
+
+ expect(itemsSql).toContain('WHERE t.user_id = $1 AND t.deleted_at IS NULL');
+ expect(itemsSql).toContain('LIMIT $2 OFFSET $3');
+ expect(itemsParams).toEqual(['user-1', 10, 20]);
+
+ expect(countSql).toContain('WHERE t.user_id = $1 AND t.deleted_at IS NULL');
+ expect(countParams).toEqual(['user-1']);
+ });
+
+ it('adds status filter to both queries when status is provided', async () => {
+ db.query
+ .mockResolvedValueOnce({
+ rows: [{ id: 'track-1', title: 'Ready Track', status: 'ready' }],
+ })
+ .mockResolvedValueOnce({
+ rows: [{ total: 1 }],
+ });
+
+ const result = await tracksModel.findMyTracks('user-1', {
+ limit: 10,
+ offset: 0,
+ status: 'ready',
+ });
+
+ expect(result).toEqual({
+ items: [{ id: 'track-1', title: 'Ready Track', status: 'ready' }],
+ total: 1,
+ });
+
+ expect(db.query).toHaveBeenCalledTimes(2);
+
+ const [itemsSql, itemsParams] = db.query.mock.calls[0];
+ const [countSql, countParams] = db.query.mock.calls[1];
+
+ expect(itemsSql).toContain('t.status = $2');
+ expect(itemsSql).toContain('LIMIT $3 OFFSET $4');
+ expect(itemsParams).toEqual(['user-1', 'ready', 10, 0]);
+
+ expect(countSql).toContain('t.status = $2');
+ expect(countParams).toEqual(['user-1', 'ready']);
+ });
+});
From 827c28832a468d642ee7d2a384fa85d8dbb8dfff Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Sat, 28 Mar 2026 01:11:24 +0200
Subject: [PATCH 112/535] test(tracks): add unit tests for get tags/genres
---
tests/tag.model.test.js | 46 +++++++++++++++++++
tests/tags-genres.controller.test.js | 68 ++++++++++++++++++++++++++++
2 files changed, 114 insertions(+)
create mode 100644 tests/tag.model.test.js
create mode 100644 tests/tags-genres.controller.test.js
diff --git a/tests/tag.model.test.js b/tests/tag.model.test.js
new file mode 100644
index 0000000..4cead2f
--- /dev/null
+++ b/tests/tag.model.test.js
@@ -0,0 +1,46 @@
+// tests/tag.model.test.js
+jest.mock('../src/config/db', () => ({
+ query: jest.fn(),
+}));
+
+const db = require('../src/config/db');
+const tagModel = require('../src/models/tag.model');
+
+describe('tagModel.findByNames', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('normalizes, deduplicates, and queries lowercase tag names', async () => {
+ db.query.mockResolvedValue({
+ rows: [
+ { id: 'tag-1', name: 'ambient' },
+ { id: 'tag-2', name: 'chill' },
+ ],
+ });
+
+ const result = await tagModel.findByNames([' Chill ', 'ambient', 'CHILL']);
+
+ expect(db.query).toHaveBeenCalledTimes(1);
+
+ const [sql, params] = db.query.mock.calls[0];
+
+ expect(sql).toContain('FROM tags');
+ expect(sql).toContain('WHERE LOWER(name) = ANY($1::text[])');
+ expect(sql).toContain('ORDER BY name ASC');
+
+ expect(params).toEqual([['chill', 'ambient']]);
+
+ expect(result).toEqual([
+ { id: 'tag-1', name: 'ambient' },
+ { id: 'tag-2', name: 'chill' },
+ ]);
+ });
+
+ it('returns empty array and does not query when normalized input is empty', async () => {
+ const result = await tagModel.findByNames([]);
+
+ expect(result).toEqual([]);
+ expect(db.query).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/tags-genres.controller.test.js b/tests/tags-genres.controller.test.js
new file mode 100644
index 0000000..7e9d9d7
--- /dev/null
+++ b/tests/tags-genres.controller.test.js
@@ -0,0 +1,68 @@
+// tests/tags-genres.controller.test.js
+jest.mock('../src/services/tags.service', () => ({
+ getAllTags: jest.fn(),
+}));
+
+jest.mock('../src/services/genres.service', () => ({
+ getAllGenres: jest.fn(),
+}));
+
+jest.mock('../src/utils/api-response', () => ({
+ success: jest.fn(),
+}));
+
+const tagsController = require('../src/controllers/tags.controller');
+const genresController = require('../src/controllers/genres.controller');
+const tagsService = require('../src/services/tags.service');
+const genresService = require('../src/services/genres.service');
+const { success } = require('../src/utils/api-response');
+
+describe('tagsController.getAllTags', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('returns all tags with success response', async () => {
+ const req = {};
+ const res = {};
+
+ const data = {
+ items: [
+ { id: 'tag-1', name: 'ambient' },
+ { id: 'tag-2', name: 'chill' },
+ ],
+ };
+
+ tagsService.getAllTags.mockResolvedValue(data);
+
+ await tagsController.getAllTags(req, res);
+
+ expect(tagsService.getAllTags).toHaveBeenCalled();
+ expect(success).toHaveBeenCalledWith(res, data, 'Tags fetched successfully', 200);
+ });
+});
+
+describe('genresController.getAllGenres', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('returns all genres with success response', async () => {
+ const req = {};
+ const res = {};
+
+ const data = {
+ items: [
+ { id: 'genre-1', name: 'Pop' },
+ { id: 'genre-2', name: 'Rock' },
+ ],
+ };
+
+ genresService.getAllGenres.mockResolvedValue(data);
+
+ await genresController.getAllGenres(req, res);
+
+ expect(genresService.getAllGenres).toHaveBeenCalled();
+ expect(success).toHaveBeenCalledWith(res, data, 'Genres fetched successfully', 200);
+ });
+});
From 4c6a50944cc80ffd6bbab6155f00f70a11c58862 Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Sat, 28 Mar 2026 01:32:07 +0200
Subject: [PATCH 113/535] test(tracks): expand controller and model coverage to
exceed target
---
tests/genre.model.test.js | 34 +++
tests/tag.model.test.js | 110 ++++++++++
tests/tracks.model.test.js | 433 +++++++++++++++++++++++++++++++++++++
3 files changed, 577 insertions(+)
create mode 100644 tests/genre.model.test.js
diff --git a/tests/genre.model.test.js b/tests/genre.model.test.js
new file mode 100644
index 0000000..046cca8
--- /dev/null
+++ b/tests/genre.model.test.js
@@ -0,0 +1,34 @@
+jest.mock('../src/config/db', () => ({
+ query: jest.fn(),
+}));
+
+const db = require('../src/config/db');
+const genreModel = require('../src/models/genre.model');
+
+describe('genreModel.getAllGenres', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('returns all genres ordered by name', async () => {
+ db.query.mockResolvedValue({
+ rows: [
+ { id: 'genre-1', name: 'Pop' },
+ { id: 'genre-2', name: 'Rock' },
+ ],
+ });
+
+ const result = await genreModel.getAllGenres();
+
+ expect(result).toEqual([
+ { id: 'genre-1', name: 'Pop' },
+ { id: 'genre-2', name: 'Rock' },
+ ]);
+
+ const [sql] = db.query.mock.calls[0];
+
+ expect(sql).toContain('SELECT id::text AS id, name');
+ expect(sql).toContain('FROM genres');
+ expect(sql).toContain('ORDER BY name ASC');
+ });
+});
\ No newline at end of file
diff --git a/tests/tag.model.test.js b/tests/tag.model.test.js
index 4cead2f..2d91ab5 100644
--- a/tests/tag.model.test.js
+++ b/tests/tag.model.test.js
@@ -44,3 +44,113 @@ describe('tagModel.findByNames', () => {
expect(db.query).not.toHaveBeenCalled();
});
});
+
+describe('tagModel.findByName', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('returns tag row when tag exists', async () => {
+ db.query.mockResolvedValue({
+ rows: [{ id: 'tag-1', name: 'chill' }],
+ });
+
+ const result = await tagModel.findByName('chill');
+
+ expect(result).toEqual({
+ id: 'tag-1',
+ name: 'chill',
+ });
+
+ const [sql, params] = db.query.mock.calls[0];
+
+ expect(sql).toContain('SELECT id::text AS id, name');
+ expect(sql).toContain('FROM tags');
+ expect(sql).toContain('WHERE LOWER(name) = LOWER($1)');
+ expect(sql).toContain('LIMIT 1');
+
+ expect(params).toEqual(['chill']);
+ });
+
+ it('returns null when tag does not exist', async () => {
+ db.query.mockResolvedValue({
+ rows: [],
+ });
+
+ const result = await tagModel.findByName('unknown');
+
+ expect(result).toBeNull();
+ });
+});
+
+describe('tagModel.findByIds', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('returns empty array when tagIds is empty', async () => {
+ const result = await tagModel.findByIds([]);
+
+ expect(result).toEqual([]);
+ expect(db.query).not.toHaveBeenCalled();
+ });
+
+ it('deduplicates ids and preserves input order in returned rows', async () => {
+ db.query.mockResolvedValue({
+ rows: [
+ { id: 'tag-2', name: 'ambient' },
+ { id: 'tag-1', name: 'chill' },
+ ],
+ });
+
+ const result = await tagModel.findByIds(['tag-1', 'tag-2', 'tag-1']);
+
+ const [sql, params] = db.query.mock.calls[0];
+
+ expect(sql).toContain('WHERE id::text = ANY($1::text[])');
+ expect(params).toEqual([['tag-1', 'tag-2']]);
+
+ expect(result).toEqual([
+ { id: 'tag-1', name: 'chill' },
+ { id: 'tag-2', name: 'ambient' },
+ ]);
+ });
+
+ it('filters out ids that are not returned by the query', async () => {
+ db.query.mockResolvedValue({
+ rows: [{ id: 'tag-1', name: 'chill' }],
+ });
+
+ const result = await tagModel.findByIds(['tag-1', 'tag-2']);
+
+ expect(result).toEqual([{ id: 'tag-1', name: 'chill' }]);
+ });
+});
+
+describe('tagModel.getAllTags', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('returns all tags ordered by name', async () => {
+ db.query.mockResolvedValue({
+ rows: [
+ { id: 'tag-1', name: 'ambient' },
+ { id: 'tag-2', name: 'chill' },
+ ],
+ });
+
+ const result = await tagModel.getAllTags();
+
+ expect(result).toEqual([
+ { id: 'tag-1', name: 'ambient' },
+ { id: 'tag-2', name: 'chill' },
+ ]);
+
+ const [sql] = db.query.mock.calls[0];
+
+ expect(sql).toContain('SELECT id::text AS id, name');
+ expect(sql).toContain('FROM tags');
+ expect(sql).toContain('ORDER BY name ASC');
+ });
+});
diff --git a/tests/tracks.model.test.js b/tests/tracks.model.test.js
index d474479..8ecf91b 100644
--- a/tests/tracks.model.test.js
+++ b/tests/tracks.model.test.js
@@ -166,3 +166,436 @@ describe('tracksModel.findMyTracks', () => {
expect(countParams).toEqual(['user-1', 'ready']);
});
});
+
+describe('tracksModel.getGenreIdByName', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('returns null when genreName is missing', async () => {
+ const result = await tracksModel.getGenreIdByName(null);
+
+ expect(result).toBeNull();
+ expect(db.query).not.toHaveBeenCalled();
+ });
+
+ it('returns genre id when genre exists', async () => {
+ db.query.mockResolvedValue({
+ rows: [{ id: 'genre-1' }],
+ });
+
+ const result = await tracksModel.getGenreIdByName('Pop');
+
+ expect(result).toBe('genre-1');
+ expect(db.query).toHaveBeenCalledWith(
+ 'SELECT id FROM genres WHERE LOWER(name) = LOWER($1) LIMIT 1',
+ ['Pop']
+ );
+ });
+
+ it('returns null when genre does not exist', async () => {
+ db.query.mockResolvedValue({
+ rows: [],
+ });
+
+ const result = await tracksModel.getGenreIdByName('Unknown');
+
+ expect(result).toBeNull();
+ expect(db.query).toHaveBeenCalledWith(
+ 'SELECT id FROM genres WHERE LOWER(name) = LOWER($1) LIMIT 1',
+ ['Unknown']
+ );
+ });
+});
+
+describe('tracksModel.getTagIdsByTrackId', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('returns tag ids in query order', async () => {
+ db.query.mockResolvedValue({
+ rows: [{ tag_id: 'tag-1' }, { tag_id: 'tag-2' }],
+ });
+
+ const result = await tracksModel.getTagIdsByTrackId('track-1');
+
+ expect(result).toEqual(['tag-1', 'tag-2']);
+ expect(db.query).toHaveBeenCalledWith(
+ 'SELECT tag_id FROM track_tags WHERE track_id = $1 ORDER BY created_at ASC',
+ ['track-1']
+ );
+ });
+
+ it('returns empty array when no tag ids exist', async () => {
+ db.query.mockResolvedValue({
+ rows: [],
+ });
+
+ const result = await tracksModel.getTagIdsByTrackId('track-1');
+
+ expect(result).toEqual([]);
+ expect(db.query).toHaveBeenCalledWith(
+ 'SELECT tag_id FROM track_tags WHERE track_id = $1 ORDER BY created_at ASC',
+ ['track-1']
+ );
+ });
+});
+
+describe('tracksModel.updateTrackVisibility', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('returns updated row when update succeeds', async () => {
+ db.query.mockResolvedValue({
+ rows: [{ id: 'track-1', is_public: false }],
+ });
+
+ const result = await tracksModel.updateTrackVisibility('track-1', false);
+
+ expect(result).toEqual({
+ id: 'track-1',
+ is_public: false,
+ });
+
+ const [sql, params] = db.query.mock.calls[0];
+
+ expect(sql).toContain('UPDATE tracks');
+ expect(sql).toContain('is_public = $2');
+ expect(sql).toContain('updated_at = NOW()');
+ expect(sql).toContain('WHERE id = $1');
+ expect(sql).toContain('deleted_at IS NULL');
+ expect(sql).toContain('RETURNING id, is_public');
+
+ expect(params).toEqual(['track-1', false]);
+ });
+
+ it('returns null when no row is updated', async () => {
+ db.query.mockResolvedValue({
+ rows: [],
+ });
+
+ const result = await tracksModel.updateTrackVisibility('track-1', true);
+
+ expect(result).toBeNull();
+ });
+});
+
+describe('tracksModel.softDeleteTrack', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('returns deleted row id when soft delete succeeds', async () => {
+ db.query.mockResolvedValue({
+ rows: [{ id: 'track-1' }],
+ });
+
+ const result = await tracksModel.softDeleteTrack('track-1');
+
+ expect(result).toEqual({ id: 'track-1' });
+
+ const [sql, params] = db.query.mock.calls[0];
+
+ expect(sql).toContain('UPDATE tracks');
+ expect(sql).toContain('deleted_at = NOW()');
+ expect(sql).toContain('updated_at = NOW()');
+ expect(sql).toContain('WHERE id = $1');
+ expect(sql).toContain('deleted_at IS NULL');
+ expect(sql).toContain('RETURNING id');
+
+ expect(params).toEqual(['track-1']);
+ });
+
+ it('returns null when soft delete affects no rows', async () => {
+ db.query.mockResolvedValue({
+ rows: [],
+ });
+
+ const result = await tracksModel.softDeleteTrack('track-1');
+
+ expect(result).toBeNull();
+ });
+});
+
+describe('tracksModel.deleteTrackPermanently', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('returns deleted row id when hard delete succeeds', async () => {
+ db.query.mockResolvedValue({
+ rows: [{ id: 'track-1' }],
+ });
+
+ const result = await tracksModel.deleteTrackPermanently('track-1');
+
+ expect(result).toEqual({ id: 'track-1' });
+
+ const [sql, params] = db.query.mock.calls[0];
+
+ expect(sql).toContain('DELETE FROM tracks');
+ expect(sql).toContain('WHERE id = $1');
+ expect(sql).toContain('deleted_at IS NULL');
+ expect(sql).toContain('RETURNING id');
+
+ expect(params).toEqual(['track-1']);
+ });
+
+ it('returns null when hard delete affects no rows', async () => {
+ db.query.mockResolvedValue({
+ rows: [],
+ });
+
+ const result = await tracksModel.deleteTrackPermanently('track-1');
+
+ expect(result).toBeNull();
+ });
+});
+
+describe('tracksModel.createTrack', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('inserts a track and stringifies geo_regions', async () => {
+ const trackData = {
+ title: 'My Song',
+ description: 'desc',
+ genre_id: 'genre-1',
+ cover_image: 'cover-url',
+ audio_url: 'audio-url',
+ file_size: 12345,
+ status: 'processing',
+ is_public: true,
+ user_id: 'user-1',
+ release_date: null,
+ isrc: null,
+ p_line: null,
+ buy_link: null,
+ record_label: null,
+ publisher: null,
+ explicit_content: false,
+ license_type: 'all_rights_reserved',
+ enable_downloads: false,
+ enable_offline_listening: false,
+ include_in_rss_feed: true,
+ display_embed_code: true,
+ enable_app_playback: true,
+ allow_comments: true,
+ show_comments_public: true,
+ show_insights_public: true,
+ geo_restriction_type: 'worldwide',
+ geo_regions: ['EG', 'SA'],
+ };
+
+ db.query.mockResolvedValue({
+ rows: [{ id: 'track-1', title: 'My Song' }],
+ });
+
+ const result = await tracksModel.createTrack(trackData);
+
+ expect(result).toEqual({
+ id: 'track-1',
+ title: 'My Song',
+ });
+
+ expect(db.query).toHaveBeenCalledTimes(1);
+
+ const [sql, params] = db.query.mock.calls[0];
+
+ expect(sql).toContain('INSERT INTO tracks');
+ expect(sql).toContain('title, description, genre_id, cover_image');
+ expect(sql).toContain('audio_url, stream_url, preview_url');
+ expect(sql).toContain('geo_restriction_type, geo_regions');
+ expect(sql).toContain('RETURNING *');
+
+ expect(params).toEqual([
+ 'My Song',
+ 'desc',
+ 'genre-1',
+ 'cover-url',
+ 'audio-url',
+ 12345,
+ 'processing',
+ true,
+ 'user-1',
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ false,
+ 'all_rights_reserved',
+ false,
+ false,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ 'worldwide',
+ JSON.stringify(['EG', 'SA']),
+ ]);
+ });
+
+ it('uses empty geo_regions array when geo_regions is missing', async () => {
+ db.query.mockResolvedValue({
+ rows: [{ id: 'track-1' }],
+ });
+
+ await tracksModel.createTrack({
+ title: 'My Song',
+ description: null,
+ genre_id: null,
+ cover_image: null,
+ audio_url: 'audio-url',
+ file_size: 12345,
+ status: 'processing',
+ is_public: true,
+ user_id: 'user-1',
+ release_date: null,
+ isrc: null,
+ p_line: null,
+ buy_link: null,
+ record_label: null,
+ publisher: null,
+ explicit_content: false,
+ license_type: 'all_rights_reserved',
+ enable_downloads: false,
+ enable_offline_listening: false,
+ include_in_rss_feed: true,
+ display_embed_code: true,
+ enable_app_playback: true,
+ allow_comments: true,
+ show_comments_public: true,
+ show_insights_public: true,
+ geo_restriction_type: 'worldwide',
+ });
+
+ const [, params] = db.query.mock.calls[0];
+
+ expect(params[26]).toBe(JSON.stringify([]));
+ });
+});
+
+describe('tracksModel.addTrackTags', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('inserts each tag with ON CONFLICT DO NOTHING', async () => {
+ db.query.mockResolvedValue({});
+
+ await tracksModel.addTrackTags('track-1', ['tag-1', 'tag-2']);
+
+ expect(db.query).toHaveBeenCalledTimes(2);
+
+ expect(db.query).toHaveBeenNthCalledWith(
+ 1,
+ 'INSERT INTO track_tags (track_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
+ ['track-1', 'tag-1']
+ );
+
+ expect(db.query).toHaveBeenNthCalledWith(
+ 2,
+ 'INSERT INTO track_tags (track_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
+ ['track-1', 'tag-2']
+ );
+ });
+
+ it('does nothing when tagIds is empty', async () => {
+ await tracksModel.addTrackTags('track-1', []);
+
+ expect(db.query).not.toHaveBeenCalled();
+ });
+});
+
+describe('tracksModel.addTrackArtists', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('inserts each artist with incrementing position and ON CONFLICT DO NOTHING', async () => {
+ db.query.mockResolvedValue({});
+
+ await tracksModel.addTrackArtists('track-1', ['artist-1', 'artist-2']);
+
+ expect(db.query).toHaveBeenCalledTimes(2);
+
+ expect(db.query).toHaveBeenNthCalledWith(
+ 1,
+ 'INSERT INTO track_artists (track_id, artist_id, position) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING',
+ ['track-1', 'artist-1', 1]
+ );
+
+ expect(db.query).toHaveBeenNthCalledWith(
+ 2,
+ 'INSERT INTO track_artists (track_id, artist_id, position) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING',
+ ['track-1', 'artist-2', 2]
+ );
+ });
+
+ it('does nothing when artistIds is empty', async () => {
+ await tracksModel.addTrackArtists('track-1', []);
+
+ expect(db.query).not.toHaveBeenCalled();
+ });
+});
+
+describe('tracksModel.findTrackByIdWithDetails', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('returns the first matching track row', async () => {
+ db.query.mockResolvedValue({
+ rows: [
+ {
+ id: 'track-1',
+ title: 'My Song',
+ genre: 'Pop',
+ tags: ['tag-1', 'tag-2'],
+ },
+ ],
+ });
+
+ const result = await tracksModel.findTrackByIdWithDetails('track-1');
+
+ expect(result).toEqual({
+ id: 'track-1',
+ title: 'My Song',
+ genre: 'Pop',
+ tags: ['tag-1', 'tag-2'],
+ });
+
+ expect(db.query).toHaveBeenCalledTimes(1);
+
+ const [sql, params] = db.query.mock.calls[0];
+
+ expect(sql).toContain('SELECT');
+ expect(sql).toContain('FROM tracks t');
+ expect(sql).toContain('LEFT JOIN genres g');
+ expect(sql).toContain('LEFT JOIN LATERAL');
+ expect(sql).toContain('array_agg(tag.id::text ORDER BY tag.id::text) AS tags');
+ expect(sql).toContain('WHERE t.id = $1');
+ expect(sql).toContain('t.deleted_at IS NULL');
+ expect(sql).toContain('LIMIT 1');
+
+ expect(params).toEqual(['track-1']);
+ });
+
+ it('returns null when no track is found', async () => {
+ db.query.mockResolvedValue({
+ rows: [],
+ });
+
+ const result = await tracksModel.findTrackByIdWithDetails('track-1');
+
+ expect(result).toBeNull();
+ expect(db.query).toHaveBeenCalledWith(expect.any(String), ['track-1']);
+ });
+});
From cfdf8a3ae899b67d3f431d9bd9834b2bb6c1ab98 Mon Sep 17 00:00:00 2001
From: Beshoy205
Date: Sat, 28 Mar 2026 22:18:45 +0200
Subject: [PATCH 114/535] feat(followers): implement social graph and follow
management endpoints
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
GET /users/{user_id}/followers — List a user's followers
GET /users/{user_id}/following — List users that a user follows
GET /users/{user_id}/follow-status — Check relationship status between me and a user
GET /users/suggested — Get suggested users to follow
GET /users/me/following/search — Search within my following list
---
src/controllers/followers.controller.js | 111 ++++++++++++++++++--
src/models/follow.model.js | 132 ++++++++++++++++++++++++
src/routes/followers.routes.js | 11 +-
src/services/followers.service.js | 53 +++++++++-
4 files changed, 290 insertions(+), 17 deletions(-)
create mode 100644 src/models/follow.model.js
diff --git a/src/controllers/followers.controller.js b/src/controllers/followers.controller.js
index 251c1c7..3e66d21 100644
--- a/src/controllers/followers.controller.js
+++ b/src/controllers/followers.controller.js
@@ -4,11 +4,106 @@
// Receives validated requests → calls service → returns HTTP response
// ============================================================
const followersService = require('../services/followers.service');
-const { success, error } = require('../utils/api-response');
-
-// TODO: Implement controller methods
-// Example:
-// exports.getAll = async (req, res) => {
-// const data = await followersService.getAll(req.query);
-// return success(res, data);
-// };
+const { success } = require('../utils/api-response');
+const AppError = require('../utils/app-error');
+
+exports.getFollowing = async (req, res) => {
+ const userId = req.params.user_id;
+ const limit = parseInt(req.query.limit) || 10;
+ const offset = parseInt(req.query.offset) || 0;
+ const result = await followersService.getFollowing(userId, limit, offset);
+
+ // Format response as UserListResponse with items and meta
+ const responseData = {
+ items: result.items,
+ meta: {
+ limit: result.limit,
+ offset: result.offset,
+ total: result.total
+ }
+ };
+
+ return success(res, responseData, 'Following list returned successfully.', 200);
+}
+
+exports.getFollowers = async (req, res) => {
+ const userId = req.params.user_id;
+ const limit = parseInt(req.query.limit) || 10;
+ const offset = parseInt(req.query.offset) || 0;
+ const result = await followersService.getFollowers(userId, limit, offset);
+
+ // Format response as UserListResponse with items and meta
+ const responseData = {
+ items: result.items,
+ meta: {
+ limit: result.limit,
+ offset: result.offset,
+ total: result.total
+ }
+ };
+
+ return success(res, responseData, 'Followers list returned successfully.', 200);
+};
+
+exports.getMyFollowing = async (req, res) => {
+ const userId = req.user.id;
+ const searchQuery = req.query.q;
+ const limit = parseInt(req.query.limit) || 10;
+ const offset = parseInt(req.query.offset) || 0;
+
+ // If no search query, return all following (paginated)
+ if (!searchQuery) {
+ const result = await followersService.getFollowing(userId, limit, offset);
+
+ // Format response as UserListResponse
+ const responseData = {
+ items: result.items,
+ meta: {
+ limit: result.limit,
+ offset: result.offset,
+ total: result.total
+ }
+ };
+
+ return success(res, responseData, 'Following list returned successfully.', 200);
+ }
+
+ // If search query provided, search within following
+ const result = await followersService.searchMyFollowing(userId, searchQuery, limit, offset);
+
+ // Format response as FollowingSearchResponse
+ const responseData = {
+ items: result.items,
+ total: result.total,
+ query: searchQuery
+ };
+
+ return success(res, responseData, 'Search results returned successfully.', 200);
+};
+
+exports.getSuggestedUsersToFollow = async (req, res) => {
+ const userId = req.user.id;
+ const limit = parseInt(req.query.limit) || 10;
+ const offset = parseInt(req.query.offset) || 0;
+ const result = await followersService.getSuggestedUsersToFollow(userId, limit, offset);
+
+ // Format response as UserListResponse
+ const responseData = {
+ items: result.items,
+ meta: {
+ limit: result.limit,
+ offset: result.offset,
+ total: result.total
+ }
+ };
+
+ return success(res, responseData, 'Suggested users returned successfully.', 200);
+};
+
+exports.getFollowStatus = async (req, res) => {
+ const userId = req.user.id;
+ const targetUserId = req.params.user_id;
+ const data = await followersService.getFollowStatus(userId, targetUserId);
+ return success(res, data, 'Follow status returned successfully.', 200);
+};
+
diff --git a/src/models/follow.model.js b/src/models/follow.model.js
new file mode 100644
index 0000000..a329a30
--- /dev/null
+++ b/src/models/follow.model.js
@@ -0,0 +1,132 @@
+const db = require('../config/db');
+
+exports.getFollowing = async (userId, limit, offset) => {
+ const query = `
+ SELECT u.id, u.display_name, u.username, u.profile_picture
+ FROM followers f
+ JOIN users u ON f.following_id = u.id
+ WHERE f.follower_id = $1
+ ORDER BY u.display_name ASC
+ LIMIT $2 OFFSET $3
+ `;
+ const { rows } = await db.query(query, [userId, limit, offset]);
+
+ // Get total count
+ const countQuery = `
+ SELECT COUNT(*) as total FROM followers WHERE follower_id = $1
+ `;
+ const { rows: countRows } = await db.query(countQuery, [userId]);
+ const total = parseInt(countRows[0].total);
+
+ return { items: rows, total, limit, offset };
+};
+
+exports.getFollowers = async (userId, limit, offset) => {
+ const query = `
+ SELECT u.id, u.display_name, u.username, u.profile_picture
+ FROM followers f
+ JOIN users u ON f.follower_id = u.id
+ WHERE f.following_id = $1
+ ORDER BY u.display_name ASC
+ LIMIT $2 OFFSET $3
+ `;
+ const { rows } = await db.query(query, [userId, limit, offset]);
+
+ // Get total count
+ const countQuery = `
+ SELECT COUNT(*) as total FROM followers WHERE following_id = $1
+ `;
+ const { rows: countRows } = await db.query(countQuery, [userId]);
+ const total = parseInt(countRows[0].total);
+
+ return { items: rows, total, limit, offset };
+};
+
+exports.searchMyFollowing = async (userId, query, limit, offset) => {
+ const searchQuery = `
+ SELECT u.id, u.display_name, u.username, u.profile_picture
+ FROM followers f
+ JOIN users u ON f.following_id = u.id
+ WHERE f.follower_id = $1 AND (u.display_name ILIKE $2 OR u.username ILIKE $2)
+ ORDER BY u.display_name ASC
+ LIMIT $3 OFFSET $4
+ `;
+ const { rows } = await db.query(searchQuery, [userId, `%${query}%`, limit, offset]);
+
+ // Get total count for search results
+ const countQuery = `
+ SELECT COUNT(*) as total FROM followers f
+ JOIN users u ON f.following_id = u.id
+ WHERE f.follower_id = $1 AND (u.display_name ILIKE $2 OR u.username ILIKE $2)
+ `;
+ const { rows: countRows } = await db.query(countQuery, [userId, `%${query}%`]);
+ const total = parseInt(countRows[0].total);
+
+ return { items: rows, total, query };
+};
+
+exports.getSuggestedUsers = async (userId, limit, offset) => {
+ const query = `
+ SELECT DISTINCT u.id, u.display_name, u.username, u.profile_picture, u.followers_count
+ FROM users u
+ WHERE u.id != $1
+ AND u.id NOT IN (
+ SELECT following_id FROM followers WHERE follower_id = $1
+ )
+ AND u.deleted_at IS NULL
+ ORDER BY u.followers_count DESC
+ LIMIT $2 OFFSET $3
+ `;
+ const { rows } = await db.query(query, [userId, limit, offset]);
+
+ // Get total count
+ const countQuery = `
+ SELECT COUNT(DISTINCT u.id) as total FROM users u
+ WHERE u.id != $1
+ AND u.id NOT IN (
+ SELECT following_id FROM followers WHERE follower_id = $1
+ )
+ AND u.deleted_at IS NULL
+ `;
+ const { rows: countRows } = await db.query(countQuery, [userId]);
+ const total = parseInt(countRows[0].total);
+
+ return { items: rows, total, limit, offset };
+};
+
+exports.getFollowStatus = async (userId, targetUserId) => {
+ const followingQuery = `
+ SELECT 1 FROM followers
+ WHERE follower_id = $1 AND following_id = $2
+ `;
+ const { rows: followingRows } = await db.query(followingQuery, [userId, targetUserId]);
+ const isFollowing = followingRows.length > 0;
+
+ const followedByQuery = `
+ SELECT 1 FROM followers
+ WHERE follower_id = $1 AND following_id = $2
+ `;
+ const { rows: followedByRows } = await db.query(followedByQuery, [targetUserId, userId]);
+ const isFollowedBy = followedByRows.length > 0;
+
+ const blockingQuery = `
+ SELECT 1 FROM blocks
+ WHERE blocker_id = $1 AND blocked_id = $2
+ `;
+ const { rows: blockingRows } = await db.query(blockingQuery, [userId, targetUserId]);
+ const isBlocking = blockingRows.length > 0;
+
+ const blockedByQuery = `
+ SELECT 1 FROM blocks
+ WHERE blocker_id = $1 AND blocked_id = $2
+ `;
+ const { rows: blockedByRows } = await db.query(blockedByQuery, [targetUserId, userId]);
+ const isBlockedBy = blockedByRows.length > 0;
+
+ return {
+ is_following: isFollowing,
+ is_followed_by: isFollowedBy,
+ is_blocking: isBlocking,
+ is_blocked_by: isBlockedBy
+ };
+};
diff --git a/src/routes/followers.routes.js b/src/routes/followers.routes.js
index d801eb6..df1b4ce 100644
--- a/src/routes/followers.routes.js
+++ b/src/routes/followers.routes.js
@@ -9,8 +9,11 @@ const controller = require('../controllers/followers.controller');
const { authenticate } = require('../middleware/auth');
const asyncHandler = require('../utils/async-handler');
-// TODO: Add route definitions here
-// Example:
-// router.get('/', authenticate, asyncHandler(controller.getAll));
+// Specific routes (more specific first)
+router.get('/me/following', authenticate, asyncHandler(controller.getMyFollowing));
+router.get('/suggested', authenticate, asyncHandler(controller.getSuggestedUsersToFollow));
+router.get('/:user_id/follow-status', authenticate, asyncHandler(controller.getFollowStatus));
+router.get('/:user_id/following', authenticate, asyncHandler(controller.getFollowing));
+router.get('/:user_id/followers', authenticate, asyncHandler(controller.getFollowers));
-module.exports = router;
+ module.exports = router;
\ No newline at end of file
diff --git a/src/services/followers.service.js b/src/services/followers.service.js
index 3449d3a..449f766 100644
--- a/src/services/followers.service.js
+++ b/src/services/followers.service.js
@@ -5,8 +5,51 @@
// No direct SQL here — delegate to models/
// ============================================================
-// TODO: Implement service methods
-// Example:
-// exports.getAll = async (query) => {
-// return await followersModel.findAll(query);
-// };
+const followModel = require('../models/follow.model');
+const userModel = require('../models/user.model');
+const AppError = require('../utils/app-error');
+
+exports.getFollowing = async (userId, limit, offset) => {
+ const user = await userModel.findById(userId);
+ if (!user) {
+ throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+ return await followModel.getFollowing(userId, limit, offset);
+};
+
+exports.getFollowers = async (userId, limit, offset) => {
+ const user = await userModel.findById(userId);
+ if (!user) {
+ throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+ return await followModel.getFollowers(userId, limit, offset);
+};
+
+exports.searchMyFollowing = async (userId, query, limit, offset) => {
+ const user = await userModel.findById(userId);
+ if (!user) {
+ throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+ return await followModel.searchMyFollowing(userId, query, limit, offset);
+};
+
+exports.getSuggestedUsersToFollow = async (userId, limit, offset) => {
+ const user = await userModel.findById(userId);
+ if (!user) {
+ throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+ return await followModel.getSuggestedUsers(userId, limit, offset);
+};
+
+exports.getFollowStatus = async (userId, targetUserId) => {
+ const user = await userModel.findById(userId);
+ if (!user) {
+ throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+ const targetUser = await userModel.findById(targetUserId);
+ if (!targetUser) {
+ throw new AppError('Target user not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+ return await followModel.getFollowStatus(userId, targetUserId);
+};
+
From c4cdaf389b50f7677d7562b2d3dfa70fa4a220ec Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Sun, 29 Mar 2026 00:50:01 +0200
Subject: [PATCH 115/535] feat(upload): add ogg and webm audio support
---
src/middleware/multer.js | 9 ++++++---
src/services/tracks.service.js | 3 +++
2 files changed, 9 insertions(+), 3 deletions(-)
diff --git a/src/middleware/multer.js b/src/middleware/multer.js
index ff0e1b8..55594a0 100644
--- a/src/middleware/multer.js
+++ b/src/middleware/multer.js
@@ -1,6 +1,6 @@
// ============================================================
// middleware/multer.js — File upload config
-// Audio: 100 MB (MP3, WAV, FLAC, AAC) | Images: 5 MB (JPG, PNG, WEBP)
+// Audio: 100 MB (MP3, WAV, FLAC, AAC, M4A, OGG, WEBM) | Images: 5 MB (JPG, PNG, WEBP)
// ============================================================
const multer = require('multer');
const path = require('path');
@@ -19,10 +19,13 @@ const audioTypes = [
'audio/x-flac',
'audio/aac',
'audio/mp4',
+ 'audio/ogg',
+ 'audio/webm',
];
+
const imageTypes = ['image/jpeg', 'image/png', 'image/webp'];
-const audioExts = ['.mp3', '.wav', '.flac', '.aac', '.m4a'];
+const audioExts = ['.mp3', '.wav', '.flac', '.aac', '.m4a', '.ogg', '.webm'];
const imageExts = ['.jpg', '.jpeg', '.png', '.webp'];
const isAllowedAudio = (file) => {
@@ -79,4 +82,4 @@ const uploadTrackFiles = multer({
limits: { fileSize: env.MAX_FILE_SIZE_AUDIO },
});
-module.exports = { uploadAudio, uploadImage, uploadTrackFiles };
+module.exports = { uploadAudio, uploadImage, uploadTrackFiles };
\ No newline at end of file
diff --git a/src/services/tracks.service.js b/src/services/tracks.service.js
index a1b94c5..2b203cf 100644
--- a/src/services/tracks.service.js
+++ b/src/services/tracks.service.js
@@ -14,6 +14,7 @@ const GEO_RESTRICTION_TYPES = ['worldwide', 'exclusive_regions', 'blocked_region
const looksLikeDbId = (value) => typeof value === 'string' && value.includes('-');
+// Geo settings validations
const resolveGeoSettings = ({
geoRestrictionTypeInput,
geoRegionsInput,
@@ -75,12 +76,14 @@ const resolveGeoSettings = ({
};
};
+// convert string to boolean
const toBool = (v, d) => {
if (v === undefined || v === null || v === '') return d;
if (typeof v === 'boolean') return v;
return String(v).toLowerCase() === 'true';
};
+// return array from json
const parseArray = (v) => {
if (!v) return [];
if (Array.isArray(v)) return v;
From 5e17734df46c21916293ebce334131117aaf986b Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Sun, 29 Mar 2026 21:09:09 +0200
Subject: [PATCH 116/535] fix(tracks): create missing custom tags on upload and
update
---
src/models/track.model.js | 52 +++++++++++++++++++++++++++++++++-
src/services/tracks.service.js | 19 +++++++------
2 files changed, 61 insertions(+), 10 deletions(-)
diff --git a/src/models/track.model.js b/src/models/track.model.js
index d21104b..38a3760 100644
--- a/src/models/track.model.js
+++ b/src/models/track.model.js
@@ -97,6 +97,52 @@ const getTagIdsByTrackId = async (trackId) => {
return result.rows.map((row) => row.tag_id);
};
+const findOrCreateTagsByNames = async (tagNames) => {
+ if (!tagNames || !tagNames.length) {
+ return [];
+ }
+
+ const normalizedNames = [...new Set(tagNames.map((name) => String(name).trim().toLowerCase()))];
+
+ const existingResult = await db.query(
+ `
+ SELECT id, LOWER(name::text) AS name
+ FROM tags
+ WHERE LOWER(name::text) = ANY($1::text[])
+ `,
+ [normalizedNames]
+ );
+
+ const existingByName = new Map(existingResult.rows.map((row) => [row.name, row.id]));
+ const missingNames = normalizedNames.filter((name) => !existingByName.has(name));
+
+ for (const name of missingNames) {
+ await db.query(
+ `
+ INSERT INTO tags (name)
+ SELECT $1::text
+ WHERE NOT EXISTS (
+ SELECT 1
+ FROM tags
+ WHERE LOWER(name::text) = $1::text
+ )
+ `,
+ [name]
+ );
+ }
+
+ const finalResult = await db.query(
+ `
+ SELECT id, LOWER(name::text) AS name
+ FROM tags
+ WHERE LOWER(name::text) = ANY($1::text[])
+ `,
+ [normalizedNames]
+ );
+
+ return finalResult.rows;
+};
+
const findTrackByIdWithDetails = async (trackId) => {
const query = `
SELECT
@@ -352,7 +398,10 @@ const replaceTrackTags = async (trackId, tagIds) => {
}
for (const tagId of tagIds) {
- await db.query(`INSERT INTO track_tags (track_id, tag_id) VALUES ($1, $2)`, [trackId, tagId]);
+ await db.query(
+ `INSERT INTO track_tags (track_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
+ [trackId, tagId]
+ );
}
};
@@ -362,6 +411,7 @@ module.exports = {
addTrackArtists,
getGenreIdByName,
getTagIdsByTrackId,
+ findOrCreateTagsByNames,
findTrackByIdWithDetails,
updateTrackVisibility,
findMyTracks,
diff --git a/src/services/tracks.service.js b/src/services/tracks.service.js
index 2b203cf..8b347ca 100644
--- a/src/services/tracks.service.js
+++ b/src/services/tracks.service.js
@@ -150,19 +150,20 @@ const resolveTagsFromInput = async (rawTags) => {
};
}
- const rows = await tagModel.findByNames(tagNames);
- const foundNames = new Set(rows.map((row) => row.name.toLowerCase()));
- const missing = tagNames.filter((name) => !foundNames.has(name));
-
- if (missing.length) {
- throw new AppError(`Unknown tag(s): ${missing.join(', ')}`, 400, 'VALIDATION_FAILED');
- }
-
+ const rows = await tracksModel.findOrCreateTagsByNames(tagNames);
const idByName = new Map(rows.map((row) => [row.name.toLowerCase(), row.id]));
return {
tagNames,
- tagIds: tagNames.map((name) => idByName.get(name)),
+ tagIds: tagNames.map((name) => {
+ const tagId = idByName.get(name);
+
+ if (!tagId) {
+ throw new AppError(`Failed to resolve tag: ${name}`, 500, 'TAG_RESOLUTION_FAILED');
+ }
+
+ return tagId;
+ }),
};
};
From ba18b605c5ec8c97eddd677e499c32921a2b1991 Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Wed, 1 Apr 2026 20:06:38 +0200
Subject: [PATCH 117/535] test(tracks): add coverage for tag creation and
update branches
---
tests/tracks.model.test.js | 141 ++++++++++++++++++++++++++++++++++-
tests/tracks.service.test.js | 45 +++--------
2 files changed, 148 insertions(+), 38 deletions(-)
diff --git a/tests/tracks.model.test.js b/tests/tracks.model.test.js
index 8ecf91b..681af93 100644
--- a/tests/tracks.model.test.js
+++ b/tests/tracks.model.test.js
@@ -72,13 +72,13 @@ describe('tracksModel.replaceTrackTags', () => {
expect(db.query).toHaveBeenNthCalledWith(
2,
- 'INSERT INTO track_tags (track_id, tag_id) VALUES ($1, $2)',
+ 'INSERT INTO track_tags (track_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
['track-1', 'tag-1']
);
expect(db.query).toHaveBeenNthCalledWith(
3,
- 'INSERT INTO track_tags (track_id, tag_id) VALUES ($1, $2)',
+ 'INSERT INTO track_tags (track_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
['track-1', 'tag-2']
);
});
@@ -599,3 +599,140 @@ describe('tracksModel.findTrackByIdWithDetails', () => {
expect(db.query).toHaveBeenCalledWith(expect.any(String), ['track-1']);
});
});
+
+describe('tracksModel.findOrCreateTagsByNames', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('returns empty array and does not query when tagNames is empty', async () => {
+ const result = await tracksModel.findOrCreateTagsByNames([]);
+
+ expect(result).toEqual([]);
+ expect(db.query).not.toHaveBeenCalled();
+ });
+
+ it('normalizes, deduplicates, and returns existing tags without inserting', async () => {
+ db.query
+ .mockResolvedValueOnce({
+ rows: [
+ { id: 'tag-1', name: 'chill' },
+ { id: 'tag-2', name: 'ambient' },
+ ],
+ })
+ .mockResolvedValueOnce({
+ rows: [
+ { id: 'tag-1', name: 'chill' },
+ { id: 'tag-2', name: 'ambient' },
+ ],
+ });
+
+ const result = await tracksModel.findOrCreateTagsByNames([' Chill ', 'ambient', 'CHILL']);
+
+ expect(result).toEqual([
+ { id: 'tag-1', name: 'chill' },
+ { id: 'tag-2', name: 'ambient' },
+ ]);
+
+ expect(db.query).toHaveBeenCalledTimes(2);
+
+ expect(db.query).toHaveBeenNthCalledWith(
+ 1,
+ expect.stringContaining('SELECT id, LOWER(name::text) AS name'),
+ [['chill', 'ambient']]
+ );
+
+ expect(db.query).toHaveBeenNthCalledWith(
+ 2,
+ expect.stringContaining('SELECT id, LOWER(name::text) AS name'),
+ [['chill', 'ambient']]
+ );
+ });
+ it('inserts only missing normalized tags and returns final rows', async () => {
+ db.query
+ .mockResolvedValueOnce({
+ rows: [{ id: 'tag-1', name: 'chill' }],
+ })
+ .mockResolvedValueOnce({})
+ .mockResolvedValueOnce({
+ rows: [
+ { id: 'tag-1', name: 'chill' },
+ { id: 'tag-2', name: 'ambient' },
+ ],
+ });
+
+ const result = await tracksModel.findOrCreateTagsByNames(['Chill', ' ambient ']);
+
+ expect(result).toEqual([
+ { id: 'tag-1', name: 'chill' },
+ { id: 'tag-2', name: 'ambient' },
+ ]);
+
+ expect(db.query).toHaveBeenCalledTimes(3);
+
+ expect(db.query).toHaveBeenNthCalledWith(
+ 1,
+ expect.stringContaining('WHERE LOWER(name::text) = ANY($1::text[])'),
+ [['chill', 'ambient']]
+ );
+
+ expect(db.query).toHaveBeenNthCalledWith(
+ 2,
+ expect.stringContaining('INSERT INTO tags (name)'),
+ ['ambient']
+ );
+
+ expect(db.query).toHaveBeenNthCalledWith(
+ 3,
+ expect.stringContaining('WHERE LOWER(name::text) = ANY($1::text[])'),
+ [['chill', 'ambient']]
+ );
+ });
+
+ it('inserts each missing tag when multiple tags are missing', async () => {
+ db.query
+ .mockResolvedValueOnce({
+ rows: [],
+ })
+ .mockResolvedValueOnce({})
+ .mockResolvedValueOnce({})
+ .mockResolvedValueOnce({
+ rows: [
+ { id: 'tag-1', name: 'chill' },
+ { id: 'tag-2', name: 'ambient' },
+ ],
+ });
+
+ const result = await tracksModel.findOrCreateTagsByNames(['Chill', 'Ambient']);
+
+ expect(result).toEqual([
+ { id: 'tag-1', name: 'chill' },
+ { id: 'tag-2', name: 'ambient' },
+ ]);
+
+ expect(db.query).toHaveBeenCalledTimes(4);
+ expect(db.query).toHaveBeenNthCalledWith(
+ 2,
+ expect.stringContaining('INSERT INTO tags (name)'),
+ ['chill']
+ );
+ expect(db.query).toHaveBeenNthCalledWith(
+ 3,
+ expect.stringContaining('INSERT INTO tags (name)'),
+ ['ambient']
+ );
+ });
+
+ it('returns null when update query runs but no row is returned', async () => {
+ db.query.mockResolvedValue({
+ rows: [],
+ });
+
+ const result = await tracksModel.updateTrackFields('track-1', {
+ title: 'New Title',
+ });
+
+ expect(result).toBeNull();
+ expect(db.query).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/tests/tracks.service.test.js b/tests/tracks.service.test.js
index 3459711..e35a554 100644
--- a/tests/tracks.service.test.js
+++ b/tests/tracks.service.test.js
@@ -13,6 +13,7 @@ jest.mock('../src/models/track.model.js', () => ({
addTrackTags: jest.fn(),
addTrackArtists: jest.fn(),
replaceTrackTags: jest.fn(),
+ findOrCreateTagsByNames: jest.fn(),
}));
jest.mock('../src/models/tag.model.js', () => ({
@@ -942,7 +943,7 @@ describe('tracksService.updateTrack tag replacement and cover cleanup', () => {
tags: ['tag-1', 'tag-2'],
});
- tagModel.findByNames.mockResolvedValue([
+ tracksModel.findOrCreateTagsByNames.mockResolvedValue([
{ id: 'tag-1', name: 'chill' },
{ id: 'tag-2', name: 'ambient' },
]);
@@ -956,7 +957,7 @@ describe('tracksService.updateTrack tag replacement and cover cleanup', () => {
coverImageFile: null,
});
- expect(tagModel.findByNames).toHaveBeenCalledWith(['chill', 'ambient']);
+ expect(tracksModel.findOrCreateTagsByNames).toHaveBeenCalledWith(['chill', 'ambient']);
expect(tracksModel.updateTrackFields).not.toHaveBeenCalled();
expect(tracksModel.replaceTrackTags).toHaveBeenCalledWith('track-1', ['tag-1', 'tag-2']);
@@ -1213,34 +1214,6 @@ describe('tracksService.uploadTrack validations', () => {
expect(tracksModel.createTrack).not.toHaveBeenCalled();
});
- it('throws 400 when unknown tags are provided', async () => {
- const audioFile = {
- originalname: 'song.mp3',
- size: 123,
- };
-
- tagModel.findByNames.mockResolvedValue([{ id: 'tag-1', name: 'chill' }]);
-
- await expect(
- tracksService.uploadTrack({
- user: { id: 'user-1' },
- audioFile,
- coverImageFile: null,
- body: {
- title: 'My Song',
- tags: JSON.stringify(['chill', 'ambient']),
- },
- })
- ).rejects.toMatchObject({
- statusCode: 400,
- code: 'VALIDATION_FAILED',
- });
-
- expect(tagModel.findByNames).toHaveBeenCalledWith(['chill', 'ambient']);
- expect(storageService.uploadTrack).not.toHaveBeenCalled();
- expect(tracksModel.createTrack).not.toHaveBeenCalled();
- });
-
it('throws 400 when genre is invalid', async () => {
const audioFile = {
originalname: 'song.mp3',
@@ -1280,7 +1253,7 @@ describe('tracksService.uploadTrack validations', () => {
size: 555,
};
- tagModel.findByNames.mockResolvedValue([
+ tracksModel.findOrCreateTagsByNames.mockResolvedValue([
{ id: 'tag-1', name: 'chill' },
{ id: 'tag-2', name: 'ambient' },
]);
@@ -1324,7 +1297,7 @@ describe('tracksService.uploadTrack validations', () => {
},
});
- expect(tagModel.findByNames).toHaveBeenCalledWith(['chill', 'ambient']);
+ expect(tracksModel.findOrCreateTagsByNames).toHaveBeenCalledWith(['chill', 'ambient']);
expect(tracksModel.getGenreIdByName).toHaveBeenCalledWith('Pop');
expect(storageService.uploadTrack).toHaveBeenCalled();
expect(storageService.uploadImage).toHaveBeenCalled();
@@ -1741,7 +1714,7 @@ describe('tracksService.uploadTrack geo validations', () => {
size: 12345,
};
- tagModel.findByNames.mockResolvedValue([
+ tracksModel.findOrCreateTagsByNames.mockResolvedValue([
{ id: 'tag-1', name: 'chill' },
{ id: 'tag-2', name: 'ambient' },
]);
@@ -1769,7 +1742,7 @@ describe('tracksService.uploadTrack geo validations', () => {
})
).rejects.toThrow('add tags failed');
- expect(tagModel.findByNames).toHaveBeenCalledWith(['chill', 'ambient']);
+ expect(tracksModel.findOrCreateTagsByNames).toHaveBeenCalledWith(['chill', 'ambient']);
expect(storageService.uploadTrack).toHaveBeenCalled();
expect(tracksModel.createTrack).toHaveBeenCalled();
expect(tracksModel.addTrackTags).toHaveBeenCalledWith('track-1', ['tag-1', 'tag-2']);
@@ -2160,7 +2133,7 @@ describe('tracksService boolean conversion and tag normalization', () => {
size: 12345,
};
- tagModel.findByNames.mockResolvedValue([
+ tracksModel.findOrCreateTagsByNames.mockResolvedValue([
{ id: 'tag-1', name: 'chill' },
{ id: 'tag-2', name: 'ambient' },
]);
@@ -2187,7 +2160,7 @@ describe('tracksService boolean conversion and tag normalization', () => {
},
});
- expect(tagModel.findByNames).toHaveBeenCalledWith(['chill', 'ambient']);
+ expect(tracksModel.findOrCreateTagsByNames).toHaveBeenCalledWith(['chill', 'ambient']);
expect(tracksModel.addTrackTags).toHaveBeenCalledWith('track-1', ['tag-1', 'tag-2']);
expect(result).toEqual({
id: 'track-1',
From 936a439d11d8d4879888ea030d1a23ce30677bb3 Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Wed, 1 Apr 2026 21:53:28 +0200
Subject: [PATCH 118/535] fix(upload): improve multer file validation responses
---
src/middleware/multer.js | 28 +++++++++++++++++++++++-----
1 file changed, 23 insertions(+), 5 deletions(-)
diff --git a/src/middleware/multer.js b/src/middleware/multer.js
index 55594a0..27d0571 100644
--- a/src/middleware/multer.js
+++ b/src/middleware/multer.js
@@ -5,6 +5,7 @@
const multer = require('multer');
const path = require('path');
const env = require('../config/env');
+const AppError = require('../utils/app-error');
const storage = multer.memoryStorage();
@@ -44,24 +45,41 @@ const isAllowedImage = (file) => {
);
};
+const invalidAudioError = () =>
+ new AppError(
+ 'Unsupported file format. Accepted formats are MP3, WAV, FLAC, AAC, M4A, OGG, and WEBM.',
+ 415,
+ 'UPLOAD_INVALID_FILE_TYPE'
+ );
+
+const invalidImageError = () =>
+ new AppError(
+ 'Unsupported file format. Accepted formats are JPEG, PNG, and WEBP.',
+ 415,
+ 'UPLOAD_INVALID_FILE_TYPE'
+ );
+
+const unexpectedFieldError = () =>
+ new AppError('Unexpected file field', 400, 'VALIDATION_FAILED');
+
const audioFilter = (req, file, cb) => {
- isAllowedAudio(file) ? cb(null, true) : cb(new Error('Invalid audio format'), false);
+ return isAllowedAudio(file) ? cb(null, true) : cb(invalidAudioError(), false);
};
const imageFilter = (req, file, cb) => {
- isAllowedImage(file) ? cb(null, true) : cb(new Error('Invalid image format'), false);
+ return isAllowedImage(file) ? cb(null, true) : cb(invalidImageError(), false);
};
const trackFilesFilter = (req, file, cb) => {
if (file.fieldname === 'audio_file') {
- return isAllowedAudio(file) ? cb(null, true) : cb(new Error('Invalid audio format'), false);
+ return isAllowedAudio(file) ? cb(null, true) : cb(invalidAudioError(), false);
}
if (file.fieldname === 'cover_image') {
- return isAllowedImage(file) ? cb(null, true) : cb(new Error('Invalid image format'), false);
+ return isAllowedImage(file) ? cb(null, true) : cb(invalidImageError(), false);
}
- return cb(new Error('Unexpected file field'), false);
+ return cb(unexpectedFieldError(), false);
};
const uploadAudio = multer({
From 09a9ddda2c0979636fd58a8b4a54f76f3598e29d Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Wed, 1 Apr 2026 21:58:38 +0200
Subject: [PATCH 119/535] test(tracks): moved tracks test files in tests/tracks
---
tests/{ => tracks}/tracks.controller.test.js | 10 +++++-----
tests/{ => tracks}/tracks.model.test.js | 6 +++---
tests/{ => tracks}/tracks.service.test.js | 14 +++++++-------
3 files changed, 15 insertions(+), 15 deletions(-)
rename tests/{ => tracks}/tracks.controller.test.js (98%)
rename tests/{ => tracks}/tracks.model.test.js (99%)
rename tests/{ => tracks}/tracks.service.test.js (99%)
diff --git a/tests/tracks.controller.test.js b/tests/tracks/tracks.controller.test.js
similarity index 98%
rename from tests/tracks.controller.test.js
rename to tests/tracks/tracks.controller.test.js
index 5c0d0c1..5436bdc 100644
--- a/tests/tracks.controller.test.js
+++ b/tests/tracks/tracks.controller.test.js
@@ -1,4 +1,4 @@
-jest.mock('../src/services/tracks.service', () => ({
+jest.mock('../../src/services/tracks.service', () => ({
uploadTrack: jest.fn(),
getTrackById: jest.fn(),
updateTrackVisibility: jest.fn(),
@@ -8,13 +8,13 @@ jest.mock('../src/services/tracks.service', () => ({
getTrackStream: jest.fn(),
}));
-jest.mock('../src/utils/api-response', () => ({
+jest.mock('../../src/utils/api-response', () => ({
success: jest.fn(),
}));
-const tracksController = require('../src/controllers/tracks.controller');
-const tracksService = require('../src/services/tracks.service');
-const { success } = require('../src/utils/api-response');
+const tracksController = require('../../src/controllers/tracks.controller');
+const tracksService = require('../../src/services/tracks.service');
+const { success } = require('../../src/utils/api-response');
describe('tracksController.uploadTrack', () => {
beforeEach(() => {
diff --git a/tests/tracks.model.test.js b/tests/tracks/tracks.model.test.js
similarity index 99%
rename from tests/tracks.model.test.js
rename to tests/tracks/tracks.model.test.js
index 681af93..273ed4d 100644
--- a/tests/tracks.model.test.js
+++ b/tests/tracks/tracks.model.test.js
@@ -1,9 +1,9 @@
-jest.mock('../src/config/db', () => ({
+jest.mock('../../src/config/db', () => ({
query: jest.fn(),
}));
-const db = require('../src/config/db');
-const tracksModel = require('../src/models/track.model.js');
+const db = require('../../src/config/db.js');
+const tracksModel = require('../../src/models/track.model.js');
describe('tracksModel.updateTrackFields', () => {
beforeEach(() => {
diff --git a/tests/tracks.service.test.js b/tests/tracks/tracks.service.test.js
similarity index 99%
rename from tests/tracks.service.test.js
rename to tests/tracks/tracks.service.test.js
index e35a554..e2242eb 100644
--- a/tests/tracks.service.test.js
+++ b/tests/tracks/tracks.service.test.js
@@ -2,7 +2,7 @@
// tests/tracks.test.js — Unit & integration tests
// Mirrors: src/routes/tracks.routes.js + src/services/tracks.service.js
// ============================================================
-jest.mock('../src/models/track.model.js', () => ({
+jest.mock('../../src/models/track.model.js', () => ({
findTrackByIdWithDetails: jest.fn(),
updateTrackVisibility: jest.fn(),
deleteTrackPermanently: jest.fn(),
@@ -16,22 +16,22 @@ jest.mock('../src/models/track.model.js', () => ({
findOrCreateTagsByNames: jest.fn(),
}));
-jest.mock('../src/models/tag.model.js', () => ({
+jest.mock('../../src/models/tag.model.js', () => ({
findByNames: jest.fn(),
findByIds: jest.fn(),
}));
-jest.mock('../src/services/storage.service.js', () => ({
+jest.mock('../../src/services/storage.service.js', () => ({
deleteManyByUrls: jest.fn(),
uploadTrack: jest.fn(),
uploadImage: jest.fn(),
deleteAllVersionsByUrl: jest.fn(),
}));
-const tracksModel = require('../src/models/track.model.js');
-const tracksService = require('../src/services/tracks.service.js');
-const storageService = require('../src/services/storage.service.js');
-const tagModel = require('../src/models/tag.model.js');
+const tracksModel = require('../../src/models/track.model.js');
+const tracksService = require('../../src/services/tracks.service.js');
+const storageService = require('../../src/services/storage.service.js');
+const tagModel = require('../../src/models/tag.model.js');
// Test Update Track Visibility -
describe('tracksService.updateTrackVisibility', () => {
From 05b786fb64b43229446632c9dd367609a21f7c50 Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Wed, 1 Apr 2026 22:11:27 +0200
Subject: [PATCH 120/535] style(upload): format multer middleware
---
src/middleware/multer.js | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/src/middleware/multer.js b/src/middleware/multer.js
index 27d0571..b1890e7 100644
--- a/src/middleware/multer.js
+++ b/src/middleware/multer.js
@@ -59,8 +59,7 @@ const invalidImageError = () =>
'UPLOAD_INVALID_FILE_TYPE'
);
-const unexpectedFieldError = () =>
- new AppError('Unexpected file field', 400, 'VALIDATION_FAILED');
+const unexpectedFieldError = () => new AppError('Unexpected file field', 400, 'VALIDATION_FAILED');
const audioFilter = (req, file, cb) => {
return isAllowedAudio(file) ? cb(null, true) : cb(invalidAudioError(), false);
@@ -100,4 +99,4 @@ const uploadTrackFiles = multer({
limits: { fileSize: env.MAX_FILE_SIZE_AUDIO },
});
-module.exports = { uploadAudio, uploadImage, uploadTrackFiles };
\ No newline at end of file
+module.exports = { uploadAudio, uploadImage, uploadTrackFiles };
From 2078d25b9c3d1e89234cdbc0df3ea097723608a5 Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Wed, 1 Apr 2026 22:48:14 +0200
Subject: [PATCH 121/535] Migrations(Playlist table new attributes)
-Run migrations
-added attributes: release_date,genre_id,subtype in playlist table
---
...60401223000-update-playlists-and-albums.js | 35 +++++++++++++++++++
1 file changed, 35 insertions(+)
create mode 100644 migrations/20260401223000-update-playlists-and-albums.js
diff --git a/migrations/20260401223000-update-playlists-and-albums.js b/migrations/20260401223000-update-playlists-and-albums.js
new file mode 100644
index 0000000..33417d6
--- /dev/null
+++ b/migrations/20260401223000-update-playlists-and-albums.js
@@ -0,0 +1,35 @@
+'use strict';
+
+exports.setup = function(options, seedLink) {};
+
+exports.up = async function(db) {
+ await db.runSql(`
+ ALTER TABLE "playlists"
+ ADD COLUMN IF NOT EXISTS "release_date" date,
+ ADD COLUMN IF NOT EXISTS "genre_id" uuid REFERENCES "genres" ("id") ON DELETE SET NULL,
+ ADD COLUMN IF NOT EXISTS "subtype" varchar(20) NOT NULL DEFAULT 'playlist';
+ `);
+
+ await db.runSql(`
+ CREATE INDEX IF NOT EXISTS playlists_genre_id_idx
+ ON "playlists" ("genre_id");
+ `);
+
+ await db.runSql(`
+ CREATE UNIQUE INDEX IF NOT EXISTS playlist_tracks_playlist_id_position_idx
+ ON "playlist_tracks" ("playlist_id", "position");
+ `);
+};
+
+exports.down = async function(db) {
+ await db.runSql(`DROP INDEX IF EXISTS playlist_tracks_playlist_id_position_idx;`);
+ await db.runSql(`DROP INDEX IF EXISTS playlists_genre_id_idx;`);
+ await db.runSql(`
+ ALTER TABLE "playlists"
+ DROP COLUMN IF EXISTS "release_date",
+ DROP COLUMN IF EXISTS "genre_id",
+ DROP COLUMN IF EXISTS "subtype";
+ `);
+};
+
+exports._meta = { version: 1 };
\ No newline at end of file
From 9865b6df9792c242c868e9f4078e284ab01dcb16 Mon Sep 17 00:00:00 2001
From: basboosa05 <166862890+basboosa05@users.noreply.github.com>
Date: Wed, 1 Apr 2026 23:06:32 +0200
Subject: [PATCH 122/535] Add GitHub OAuth support
Add end-to-end GitHub OAuth login flow: new migration to add 'github' to oauth_provider enum; environment variables (GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_REDIRECT_URI) and provider constant; new routes GET /auth/oauth/github and /auth/oauth/github/callback. Implement controller logic to generate auth URL, store a CSRF state cookie, handle the callback, clear state cookie, and set httpOnly refresh cookie. Implement service helpers to create the GitHub auth URL, exchange code for an access token, fetch user profile/emails, link or create users and oauth connections, update tokens, and return signed access/refresh tokens. Also re-enable verifyCaptcha calls in a couple of places.
---
migrations/20260401205550-add-github-enum.js | 28 +++
src/config/env.js | 5 +
src/constants/oauth-providers.js | 1 +
src/controllers/auth.controller.js | 82 +++++++++
src/routes/auth.routes.js | 4 +-
src/services/auth.service.js | 172 ++++++++++++++++++-
6 files changed, 289 insertions(+), 3 deletions(-)
create mode 100644 migrations/20260401205550-add-github-enum.js
diff --git a/migrations/20260401205550-add-github-enum.js b/migrations/20260401205550-add-github-enum.js
new file mode 100644
index 0000000..cb5dce7
--- /dev/null
+++ b/migrations/20260401205550-add-github-enum.js
@@ -0,0 +1,28 @@
+// migrations/XXXXXXXXXXXXXX-add-github-to-oauth-provider.js
+'use strict';
+
+let dbm;
+let type;
+let seed;
+
+exports.setup = function(options, seedLink) {
+ dbm = options.dbmigrate;
+ type = dbm.dataType;
+ seed = seedLink;
+};
+
+exports.up = async function(db) {
+ // Add 'github' to the oauth_provider enum
+ await db.runSql(`
+ ALTER TYPE oauth_provider ADD VALUE IF NOT EXISTS 'github';
+ `);
+};
+
+exports.down = async function(db) {
+ // For simplicity, we'll just log a warning
+ console.warn('Cannot remove enum value "github" without recreating the type');
+};
+
+exports._meta = {
+ version: 1
+};
diff --git a/src/config/env.js b/src/config/env.js
index 3bedea8..88814c9 100644
--- a/src/config/env.js
+++ b/src/config/env.js
@@ -36,4 +36,9 @@ module.exports = {
MAX_FILE_SIZE_AUDIO: 100 * 1024 * 1024, // 100 MB
MAX_FILE_SIZE_IMAGE: 5 * 1024 * 1024, // 5 MB
+
+
+ GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID,
+ GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET,
+ GITHUB_REDIRECT_URI: process.env.GITHUB_REDIRECT_URI,
};
diff --git a/src/constants/oauth-providers.js b/src/constants/oauth-providers.js
index 2a18ed0..294cb1e 100644
--- a/src/constants/oauth-providers.js
+++ b/src/constants/oauth-providers.js
@@ -3,4 +3,5 @@ module.exports = {
FACEBOOK: 'facebook',
APPLE: 'apple',
SOUNDCLOUD: 'soundcloud',
+ GITHUB: 'github',
};
diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js
index e461a82..dc68d1a 100644
--- a/src/controllers/auth.controller.js
+++ b/src/controllers/auth.controller.js
@@ -262,3 +262,85 @@ exports.googleLogin = async (req, res) => {
'Logged in successfully with Google.'
);
};
+
+
+// GET /auth/oauth/github
+// Generates the GitHub consent URL and redirects the user there.
+// Stores CSRF `state` in a short-lived httpOnly cookie.
+exports.githubOAuth = async (req, res) => {
+ const { authUrl, state } = authService.githubGetAuthUrl();
+
+ // Store state for CSRF validation when GitHub redirects back.
+ // sameSite:'lax' is required — 'strict' would drop the cookie
+ // on the cross-site redirect back from GitHub.
+ res.cookie('gh_oauth_state', state, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
+ maxAge: 10 * 60 * 1000, // 10 minutes
+ });
+
+ return res.redirect(authUrl);
+};
+
+// ── GET /auth/oauth/github/callback ─────────────────────────
+// GitHub redirects here after the user approves or denies access.
+exports.githubOAuthCallback = async (req, res) => {
+ const { code, state, error: oauthError } = req.query;
+
+// denied access
+ if (oauthError || !code) {
+ return res.redirect(
+ `${process.env.CLIENT_URL}/login?error=github_denied`
+ );
+ }
+
+ // Validate presence and type of required query params
+ if (typeof code !== 'string' || typeof state !== 'string') {
+ return res.status(400).json({
+ error: {
+ code: 'VALIDATION_FAILED',
+ message: 'Missing or invalid OAuth callback parameters',
+ },
+ });
+ }
+
+ // CSRF protection: compare state param with stored cookie value
+ const storedState = req.cookies?.gh_oauth_state;
+
+ res.clearCookie('gh_oauth_state', {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
+ });
+
+
+ const result = await authService.githubCallback({ code, state, storedState });
+
+
+ res.cookie('refresh_token', result.refreshToken, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'strict',
+ maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
+ });
+
+ return success(
+ res,
+ {
+ access_token: result.accessToken,
+ token_type: 'Bearer',
+ expires_in: parseDurationToSeconds(process.env.JWT_ACCESS_EXPIRES_IN),
+ is_new_user: result.is_new_user,
+ user: {
+ user_id: result.user.id,
+ email: result.user.email,
+ display_name: result.user.display_name,
+ gender: result.user.gender,
+ role: result.user.role,
+ is_verified: result.user.is_verified,
+ },
+ },
+ 'Logged in successfully with GitHub.'
+ );
+};
diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js
index 679da42..0f9f8c8 100644
--- a/src/routes/auth.routes.js
+++ b/src/routes/auth.routes.js
@@ -20,6 +20,8 @@ router.post('/verify-email-change', authLimiter, asyncHandler(controller.verifyE
router.post('/change-email', authenticate, asyncHandler(controller.changeEmail));
router.post('/google', authLimiter, asyncHandler(controller.googleLogin));
-// router.post('/resend-verification', asyncHandler(controller.resendVerification));
+// GitHub OAuth routes
+router.get('/oauth/github', asyncHandler(controller.githubOAuth));
+router.get('/oauth/github/callback', asyncHandler(controller.githubOAuthCallback));
module.exports = router;
diff --git a/src/services/auth.service.js b/src/services/auth.service.js
index ad75f24..531e42f 100644
--- a/src/services/auth.service.js
+++ b/src/services/auth.service.js
@@ -16,6 +16,7 @@ const AppError = require('../utils/app-error');
const { signAccessToken, signRefreshToken, verifyToken } = require('../config/jwt');
const env = require('../config/env');
const { randomUUID } = require('crypto');
+const crypto = require('crypto');
//=====================================
// Registration and Email verification
@@ -52,7 +53,7 @@ exports.register = async ({
}
const displayNameTrimmed = display_name?.trim();
// Verify CAPTCHA i can't get a token to test with it now so imma comment it out for now :)
- //await verifyCaptcha(captcha_token);
+ await verifyCaptcha(captcha_token);
// Check duplicate email
const existing = await userModel.findByEmail(normalizedEmail);
@@ -321,7 +322,7 @@ exports.resetPassword = async ({ token, new_password, logout_all = true }) => {
// Resend Verification Email
// ============================================================
exports.resendVerification = async ({ email, captcha_token }) => {
- // await verifyCaptcha(captcha_token); uncomment upon integration with frontend
+ await verifyCaptcha(captcha_token); //uncomment upon integration with frontend
const user = await userModel.findByEmail(email);
@@ -481,3 +482,170 @@ exports.googleLogin = async ({ id_token }) => {
user,
};
};
+
+
+/**
+ * Generates the GitHub authorization URL + a CSRF state token.
+ *
+ * Called by: GET /auth/oauth/github
+ * The controller stores `state` in a short-lived httpOnly cookie,
+ * then redirects the user to the returned `authUrl`.
+ */
+exports.githubGetAuthUrl = () => {
+ const state = crypto.randomBytes(16).toString('hex');
+
+ const params = new URLSearchParams({
+ client_id: env.GITHUB_CLIENT_ID,
+ redirect_uri: env.GITHUB_REDIRECT_URI,
+ scope: 'read:user user:email',
+ state,
+ });
+
+ const authUrl = `https://github.com/login/oauth/authorize?${params.toString()}`;
+
+ return { authUrl, state };
+};
+
+// ============================================================
+// GitHub OAuth — Step 2: Handle the callback
+// ============================================================
+
+
+exports.githubCallback = async ({ code, state, storedState }) => {
+
+ if (!state || !storedState || state !== storedState) {
+ throw new AppError(
+ 'Missing or invalid OAuth callback parameters',
+ 400,
+ 'VALIDATION_FAILED'
+ );
+ }
+
+ let ghAccessToken;
+ try {
+ const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json',
+ },
+ body: JSON.stringify({
+ client_id: env.GITHUB_CLIENT_ID,
+ client_secret: env.GITHUB_CLIENT_SECRET,
+ code,
+ redirect_uri: env.GITHUB_REDIRECT_URI,
+ }),
+ });
+
+ const tokenData = await tokenRes.json();
+
+ if (tokenData.error || !tokenData.access_token) {
+ console.error('[GitHub] Token exchange failed:', tokenData);
+ throw new Error('Token exchange failed');
+ }
+
+ ghAccessToken = tokenData.access_token;
+ } catch {
+ throw new AppError(
+ 'OAuth authorization was denied or token exchange failed',
+ 422,
+ 'BUSINESS_OPERATION_NOT_ALLOWED'
+ );
+ }
+
+ let ghProfile;
+ try {
+ const profileRes = await fetch('https://api.github.com/user', {
+ headers: {
+ Authorization: `Bearer ${ghAccessToken}`,
+ Accept: 'application/vnd.github+json',
+ },
+ });
+ if (!profileRes.ok) throw new Error('Profile fetch failed');
+ ghProfile = await profileRes.json();
+
+ } catch {
+ throw new AppError(
+ 'OAuth authorization was denied or token exchange failed',
+ 422,
+ 'BUSINESS_OPERATION_NOT_ALLOWED'
+ );
+ }
+
+ let primaryEmail = ghProfile.email;
+
+ if (!primaryEmail) {
+ try {
+ const emailsRes = await fetch('https://api.github.com/user/emails', {
+ headers: {
+ Authorization: `Bearer ${ghAccessToken}`,
+ Accept: 'application/vnd.github+json',
+ },
+ });
+ const emails = await emailsRes.json();
+ // Find the primary verified email
+ const primary = emails.find((e) => e.primary && e.verified);
+ primaryEmail = primary?.email ?? null;
+ } catch {
+ // Non-fatal — we'll proceed with null email
+ primaryEmail = null;
+ }
+ }
+
+ const providerUserId = String(ghProfile.id); // GitHub user ID as string
+ const displayName = ghProfile.name || ghProfile.login || `gh_user_${providerUserId}`;
+
+
+ const existingConnection = await oauthConnectionModel.findByProvider('github', providerUserId);
+
+ if (existingConnection) {
+ const user = await userModel.findById(existingConnection.user_id);
+
+ if (user.is_suspended) {
+ throw new AppError('Account suspended by admin', 403, 'AUTH_ACCOUNT_SUSPENDED');
+ }
+
+ await oauthConnectionModel.updateTokens({
+ user_id: user.id,
+ provider: 'github',
+ access_token: ghAccessToken,
+ refresh_token: null,
+ expires_at: null, // GitHub tokens don't expire unless revoked
+ });
+
+ const accessToken = signAccessToken({ sub: user.id, role: user.role });
+ const refreshToken = await createAndStoreRefreshToken(user.id);
+
+ return { accessToken, refreshToken, is_new_user: false, user };
+ }
+
+
+ let user = primaryEmail ? await userModel.findByEmail(primaryEmail) : null;
+ let is_new_user = false;
+
+ if (!user) {
+ user = await userModel.createOAuthUser({
+ email: primaryEmail ?? null,
+ display_name: displayName,
+ });
+ is_new_user = true;
+ }
+
+ if (user.is_suspended) {
+ throw new AppError('Account suspended by admin', 403, 'AUTH_ACCOUNT_SUSPENDED');
+ }
+
+ await oauthConnectionModel.create({
+ user_id: user.id,
+ provider: 'github',
+ provider_user_id: providerUserId,
+ access_token: ghAccessToken,
+ refresh_token: null,
+ expires_at: null,
+ });
+
+ const accessToken = signAccessToken({ sub: user.id, role: user.role });
+ const refreshToken = await createAndStoreRefreshToken(user.id);
+
+ return { accessToken, refreshToken, is_new_user, user };
+};
From 65c3ceb50a46cb45e822a7c269d235019eeec20e Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Wed, 1 Apr 2026 23:35:08 +0200
Subject: [PATCH 123/535] Update
migrations/20260401223000-update-playlists-and-albums.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
...60401223000-update-playlists-and-albums.js | 22 +++++++++++++++++++
1 file changed, 22 insertions(+)
diff --git a/migrations/20260401223000-update-playlists-and-albums.js b/migrations/20260401223000-update-playlists-and-albums.js
index 33417d6..b23e308 100644
--- a/migrations/20260401223000-update-playlists-and-albums.js
+++ b/migrations/20260401223000-update-playlists-and-albums.js
@@ -15,6 +15,28 @@ exports.up = async function(db) {
ON "playlists" ("genre_id");
`);
+ // Ensure there are no duplicate non-NULL (playlist_id, position) values
+ // before creating the UNIQUE index. For each duplicate group, we keep the
+ // first row (by id) and set position = NULL on the others so they no longer
+ // conflict with the uniqueness constraint.
+ await db.runSql(`
+ WITH duplicates AS (
+ SELECT
+ id,
+ ROW_NUMBER() OVER (
+ PARTITION BY playlist_id, position
+ ORDER BY id
+ ) AS rn
+ FROM "playlist_tracks"
+ WHERE position IS NOT NULL
+ )
+ UPDATE "playlist_tracks" pt
+ SET position = NULL
+ FROM duplicates d
+ WHERE pt.id = d.id
+ AND d.rn > 1;
+ `);
+
await db.runSql(`
CREATE UNIQUE INDEX IF NOT EXISTS playlist_tracks_playlist_id_position_idx
ON "playlist_tracks" ("playlist_id", "position");
From 5a284eb8f279e190a0b5c7928d2594cc8ad59f2e Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Wed, 1 Apr 2026 23:35:23 +0200
Subject: [PATCH 124/535] Update
migrations/20260401223000-update-playlists-and-albums.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
migrations/20260401223000-update-playlists-and-albums.js | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/migrations/20260401223000-update-playlists-and-albums.js b/migrations/20260401223000-update-playlists-and-albums.js
index b23e308..2ca2c22 100644
--- a/migrations/20260401223000-update-playlists-and-albums.js
+++ b/migrations/20260401223000-update-playlists-and-albums.js
@@ -39,7 +39,8 @@ exports.up = async function(db) {
await db.runSql(`
CREATE UNIQUE INDEX IF NOT EXISTS playlist_tracks_playlist_id_position_idx
- ON "playlist_tracks" ("playlist_id", "position");
+ ON "playlist_tracks" ("playlist_id", "position")
+ WHERE "position" IS NOT NULL;
`);
};
From 6eda1320d5e1b39d8c9230d94c6c4a6c78ab28cf Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Wed, 1 Apr 2026 23:35:30 +0200
Subject: [PATCH 125/535] Update
migrations/20260401223000-update-playlists-and-albums.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
migrations/20260401223000-update-playlists-and-albums.js | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/migrations/20260401223000-update-playlists-and-albums.js b/migrations/20260401223000-update-playlists-and-albums.js
index 2ca2c22..d6b99f1 100644
--- a/migrations/20260401223000-update-playlists-and-albums.js
+++ b/migrations/20260401223000-update-playlists-and-albums.js
@@ -1,7 +1,14 @@
'use strict';
-exports.setup = function(options, seedLink) {};
+var dbm;
+var type;
+var seed;
+exports.setup = function(options, seedLink) {
+ dbm = options.dbmigrate;
+ type = dbm.dataType;
+ seed = seedLink;
+};
exports.up = async function(db) {
await db.runSql(`
ALTER TABLE "playlists"
From ecf5a08e2910b743b35f76526c9d14459fa2204d Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Thu, 2 Apr 2026 00:00:34 +0200
Subject: [PATCH 126/535] feat(tracks): add secret_token migration for private
track links
---
.../20260401215114-add-track-secret-token.js | 65 +++++++++++++++++++
1 file changed, 65 insertions(+)
create mode 100644 migrations/20260401215114-add-track-secret-token.js
diff --git a/migrations/20260401215114-add-track-secret-token.js b/migrations/20260401215114-add-track-secret-token.js
new file mode 100644
index 0000000..66a6bed
--- /dev/null
+++ b/migrations/20260401215114-add-track-secret-token.js
@@ -0,0 +1,65 @@
+'use strict';
+
+var dbm;
+var type;
+var seed;
+
+/**
+ * We receive the dbmigrate dependency from dbmigrate initially.
+ * This enables us to not have to rely on NODE_PATH.
+ */
+exports.setup = function (options, seedLink) {
+ dbm = options.dbmigrate;
+ type = dbm.dataType;
+ seed = seedLink;
+};
+
+exports.up = async function(db) {
+ await db.runSql(`
+ ALTER TABLE "tracks"
+ ADD COLUMN "secret_token" varchar;
+ `);
+
+ await db.runSql(`
+ UPDATE "tracks"
+ SET "secret_token" = gen_random_uuid()::text
+ WHERE "is_public" = false
+ AND "secret_token" IS NULL;
+ `);
+
+ await db.runSql(`
+ CREATE UNIQUE INDEX "tracks_secret_token_unique"
+ ON "tracks" ("secret_token")
+ WHERE "secret_token" IS NOT NULL;
+ `);
+
+ await db.runSql(`
+ ALTER TABLE "tracks"
+ ADD CONSTRAINT "tracks_private_requires_secret_token"
+ CHECK (
+ ("is_public" = true)
+ OR
+ ("is_public" = false AND "secret_token" IS NOT NULL)
+ );
+ `);
+};
+
+exports.down = async function (db) {
+ await db.runSql(`
+ ALTER TABLE "tracks"
+ DROP CONSTRAINT IF EXISTS "tracks_private_requires_secret_token";
+ `);
+
+ await db.runSql(`
+ DROP INDEX IF EXISTS "tracks_secret_token_unique";
+ `);
+
+ await db.runSql(`
+ ALTER TABLE "tracks"
+ DROP COLUMN IF EXISTS "secret_token";
+ `);
+};
+
+exports._meta = {
+ version: 1,
+};
From 46bd06465b12f7c352dedf410e392c75f776eddd Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Thu, 2 Apr 2026 00:03:01 +0200
Subject: [PATCH 127/535] fix(tracks): fixed new migration file
add-track-secret-token
---
migrations/20260401215114-add-track-secret-token.js | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/migrations/20260401215114-add-track-secret-token.js b/migrations/20260401215114-add-track-secret-token.js
index 66a6bed..36c7de7 100644
--- a/migrations/20260401215114-add-track-secret-token.js
+++ b/migrations/20260401215114-add-track-secret-token.js
@@ -1,8 +1,8 @@
'use strict';
-var dbm;
-var type;
-var seed;
+let dbm;
+let type;
+let seed;
/**
* We receive the dbmigrate dependency from dbmigrate initially.
@@ -14,7 +14,7 @@ exports.setup = function (options, seedLink) {
seed = seedLink;
};
-exports.up = async function(db) {
+exports.up = async function (db) {
await db.runSql(`
ALTER TABLE "tracks"
ADD COLUMN "secret_token" varchar;
From 73b1b622aa0c9de8f6736e2b01245f1412199dbe Mon Sep 17 00:00:00 2001
From: basboosa05 <166862890+basboosa05@users.noreply.github.com>
Date: Thu, 2 Apr 2026 00:37:14 +0200
Subject: [PATCH 128/535] Normalize whitespace/formatting in auth and env files
Apply non-functional style cleanups across env.js, auth.controller.js, and auth.service.js: remove stray blank lines, normalize spacing and indentation in objects and comments, align cookie option properties, and reflow various lines for consistency (e.g. GitHub OAuth flow, token response payload, header fields, error throw). No behavioral or logic changes were made.
---
src/config/env.js | 1 -
src/controllers/auth.controller.js | 39 +++++++++----------
src/services/auth.service.js | 60 +++++++++++++-----------------
3 files changed, 42 insertions(+), 58 deletions(-)
diff --git a/src/config/env.js b/src/config/env.js
index 88814c9..10d9c7d 100644
--- a/src/config/env.js
+++ b/src/config/env.js
@@ -37,7 +37,6 @@ module.exports = {
MAX_FILE_SIZE_AUDIO: 100 * 1024 * 1024, // 100 MB
MAX_FILE_SIZE_IMAGE: 5 * 1024 * 1024, // 5 MB
-
GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID,
GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET,
GITHUB_REDIRECT_URI: process.env.GITHUB_REDIRECT_URI,
diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js
index dc68d1a..b0f9bed 100644
--- a/src/controllers/auth.controller.js
+++ b/src/controllers/auth.controller.js
@@ -263,8 +263,7 @@ exports.googleLogin = async (req, res) => {
);
};
-
-// GET /auth/oauth/github
+// GET /auth/oauth/github
// Generates the GitHub consent URL and redirects the user there.
// Stores CSRF `state` in a short-lived httpOnly cookie.
exports.githubOAuth = async (req, res) => {
@@ -275,9 +274,9 @@ exports.githubOAuth = async (req, res) => {
// on the cross-site redirect back from GitHub.
res.cookie('gh_oauth_state', state, {
httpOnly: true,
- secure: process.env.NODE_ENV === 'production',
+ secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
- maxAge: 10 * 60 * 1000, // 10 minutes
+ maxAge: 10 * 60 * 1000, // 10 minutes
});
return res.redirect(authUrl);
@@ -288,18 +287,16 @@ exports.githubOAuth = async (req, res) => {
exports.githubOAuthCallback = async (req, res) => {
const { code, state, error: oauthError } = req.query;
-// denied access
+ // denied access
if (oauthError || !code) {
- return res.redirect(
- `${process.env.CLIENT_URL}/login?error=github_denied`
- );
+ return res.redirect(`${process.env.CLIENT_URL}/login?error=github_denied`);
}
// Validate presence and type of required query params
if (typeof code !== 'string' || typeof state !== 'string') {
return res.status(400).json({
error: {
- code: 'VALIDATION_FAILED',
+ code: 'VALIDATION_FAILED',
message: 'Missing or invalid OAuth callback parameters',
},
});
@@ -310,35 +307,33 @@ exports.githubOAuthCallback = async (req, res) => {
res.clearCookie('gh_oauth_state', {
httpOnly: true,
- secure: process.env.NODE_ENV === 'production',
+ secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
});
-
const result = await authService.githubCallback({ code, state, storedState });
-
res.cookie('refresh_token', result.refreshToken, {
httpOnly: true,
- secure: process.env.NODE_ENV === 'production',
+ secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
- maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
+ maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
return success(
res,
{
access_token: result.accessToken,
- token_type: 'Bearer',
- expires_in: parseDurationToSeconds(process.env.JWT_ACCESS_EXPIRES_IN),
- is_new_user: result.is_new_user,
+ token_type: 'Bearer',
+ expires_in: parseDurationToSeconds(process.env.JWT_ACCESS_EXPIRES_IN),
+ is_new_user: result.is_new_user,
user: {
- user_id: result.user.id,
- email: result.user.email,
+ user_id: result.user.id,
+ email: result.user.email,
display_name: result.user.display_name,
- gender: result.user.gender,
- role: result.user.role,
- is_verified: result.user.is_verified,
+ gender: result.user.gender,
+ role: result.user.role,
+ is_verified: result.user.is_verified,
},
},
'Logged in successfully with GitHub.'
diff --git a/src/services/auth.service.js b/src/services/auth.service.js
index 531e42f..df93c7e 100644
--- a/src/services/auth.service.js
+++ b/src/services/auth.service.js
@@ -322,7 +322,7 @@ exports.resetPassword = async ({ token, new_password, logout_all = true }) => {
// Resend Verification Email
// ============================================================
exports.resendVerification = async ({ email, captcha_token }) => {
- await verifyCaptcha(captcha_token); //uncomment upon integration with frontend
+ await verifyCaptcha(captcha_token); //uncomment upon integration with frontend
const user = await userModel.findByEmail(email);
@@ -483,21 +483,20 @@ exports.googleLogin = async ({ id_token }) => {
};
};
-
/**
* Generates the GitHub authorization URL + a CSRF state token.
*
* Called by: GET /auth/oauth/github
* The controller stores `state` in a short-lived httpOnly cookie,
- * then redirects the user to the returned `authUrl`.
+ * then redirects the user to the returned `authUrl`.
*/
exports.githubGetAuthUrl = () => {
const state = crypto.randomBytes(16).toString('hex');
const params = new URLSearchParams({
- client_id: env.GITHUB_CLIENT_ID,
+ client_id: env.GITHUB_CLIENT_ID,
redirect_uri: env.GITHUB_REDIRECT_URI,
- scope: 'read:user user:email',
+ scope: 'read:user user:email',
state,
});
@@ -510,15 +509,9 @@ exports.githubGetAuthUrl = () => {
// GitHub OAuth — Step 2: Handle the callback
// ============================================================
-
exports.githubCallback = async ({ code, state, storedState }) => {
-
if (!state || !storedState || state !== storedState) {
- throw new AppError(
- 'Missing or invalid OAuth callback parameters',
- 400,
- 'VALIDATION_FAILED'
- );
+ throw new AppError('Missing or invalid OAuth callback parameters', 400, 'VALIDATION_FAILED');
}
let ghAccessToken;
@@ -527,13 +520,13 @@ exports.githubCallback = async ({ code, state, storedState }) => {
method: 'POST',
headers: {
'Content-Type': 'application/json',
- 'Accept': 'application/json',
+ Accept: 'application/json',
},
body: JSON.stringify({
- client_id: env.GITHUB_CLIENT_ID,
+ client_id: env.GITHUB_CLIENT_ID,
client_secret: env.GITHUB_CLIENT_SECRET,
code,
- redirect_uri: env.GITHUB_REDIRECT_URI,
+ redirect_uri: env.GITHUB_REDIRECT_URI,
}),
});
@@ -558,12 +551,11 @@ exports.githubCallback = async ({ code, state, storedState }) => {
const profileRes = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${ghAccessToken}`,
- Accept: 'application/vnd.github+json',
+ Accept: 'application/vnd.github+json',
},
});
if (!profileRes.ok) throw new Error('Profile fetch failed');
ghProfile = await profileRes.json();
-
} catch {
throw new AppError(
'OAuth authorization was denied or token exchange failed',
@@ -579,7 +571,7 @@ exports.githubCallback = async ({ code, state, storedState }) => {
const emailsRes = await fetch('https://api.github.com/user/emails', {
headers: {
Authorization: `Bearer ${ghAccessToken}`,
- Accept: 'application/vnd.github+json',
+ Accept: 'application/vnd.github+json',
},
});
const emails = await emailsRes.json();
@@ -593,8 +585,7 @@ exports.githubCallback = async ({ code, state, storedState }) => {
}
const providerUserId = String(ghProfile.id); // GitHub user ID as string
- const displayName = ghProfile.name || ghProfile.login || `gh_user_${providerUserId}`;
-
+ const displayName = ghProfile.name || ghProfile.login || `gh_user_${providerUserId}`;
const existingConnection = await oauthConnectionModel.findByProvider('github', providerUserId);
@@ -606,26 +597,25 @@ exports.githubCallback = async ({ code, state, storedState }) => {
}
await oauthConnectionModel.updateTokens({
- user_id: user.id,
- provider: 'github',
- access_token: ghAccessToken,
- refresh_token: null,
- expires_at: null, // GitHub tokens don't expire unless revoked
+ user_id: user.id,
+ provider: 'github',
+ access_token: ghAccessToken,
+ refresh_token: null,
+ expires_at: null, // GitHub tokens don't expire unless revoked
});
- const accessToken = signAccessToken({ sub: user.id, role: user.role });
+ const accessToken = signAccessToken({ sub: user.id, role: user.role });
const refreshToken = await createAndStoreRefreshToken(user.id);
return { accessToken, refreshToken, is_new_user: false, user };
}
-
- let user = primaryEmail ? await userModel.findByEmail(primaryEmail) : null;
+ let user = primaryEmail ? await userModel.findByEmail(primaryEmail) : null;
let is_new_user = false;
if (!user) {
user = await userModel.createOAuthUser({
- email: primaryEmail ?? null,
+ email: primaryEmail ?? null,
display_name: displayName,
});
is_new_user = true;
@@ -636,15 +626,15 @@ exports.githubCallback = async ({ code, state, storedState }) => {
}
await oauthConnectionModel.create({
- user_id: user.id,
- provider: 'github',
+ user_id: user.id,
+ provider: 'github',
provider_user_id: providerUserId,
- access_token: ghAccessToken,
- refresh_token: null,
- expires_at: null,
+ access_token: ghAccessToken,
+ refresh_token: null,
+ expires_at: null,
});
- const accessToken = signAccessToken({ sub: user.id, role: user.role });
+ const accessToken = signAccessToken({ sub: user.id, role: user.role });
const refreshToken = await createAndStoreRefreshToken(user.id);
return { accessToken, refreshToken, is_new_user, user };
From 25a0df04e8580a719206905fa639819acafdae85 Mon Sep 17 00:00:00 2001
From: Mohamed jameel alabasy
Date: Thu, 2 Apr 2026 01:34:22 +0200
Subject: [PATCH 129/535] feat(pipeline): add main CD pipeline for production
deployment
---
.github/workflows/backend-cd-dev.yml | 94 +++++++++++++++++++++++++++
.github/workflows/backend-cd-main.yml | 94 +++++++++++++++++++++++++++
2 files changed, 188 insertions(+)
create mode 100644 .github/workflows/backend-cd-dev.yml
create mode 100644 .github/workflows/backend-cd-main.yml
diff --git a/.github/workflows/backend-cd-dev.yml b/.github/workflows/backend-cd-dev.yml
new file mode 100644
index 0000000..5afacf7
--- /dev/null
+++ b/.github/workflows/backend-cd-dev.yml
@@ -0,0 +1,94 @@
+name: Backend — Dev CD Pipeline
+
+on:
+ push:
+ branches:
+ - development
+
+jobs:
+ deploy-dev:
+ name: Build, Push & Deploy (Development)
+ runs-on: ubuntu-latest
+ environment: development
+
+ steps:
+ - name: Checkout backend repo
+ uses: actions/checkout@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Log in to Azure Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ secrets.ACR_LOGIN_SERVER }}
+ username: ${{ secrets.ACR_USERNAME }}
+ password: ${{ secrets.ACR_PASSWORD }}
+
+ - name: Build and push Docker image
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ push: true
+ tags: |
+ ${{ secrets.ACR_LOGIN_SERVER }}/backend:dev
+ ${{ secrets.ACR_LOGIN_SERVER }}/backend:dev-${{ github.sha }}
+
+ - name: Run migrations against Azure PostgreSQL (dev)
+ env:
+ DATABASE_URL: ${{ secrets.DATABASE_URL }}
+ run: npm run migrate:up
+
+ - name: Install Azure CLI
+ run: |
+ curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
+
+ - name: Log in to Azure
+ uses: azure/login@v1
+ with:
+ creds: ${{ secrets.AZURE_CREDENTIALS }}
+
+ - name: Update Container App with new image
+ run: |
+ az containerapp update \
+ --name ${{ secrets.CONTAINER_APP_NAME }} \
+ --resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
+ --image ${{ secrets.ACR_LOGIN_SERVER }}/backend:dev-${{ github.sha }} \
+ --set-env-vars \
+ NODE_ENV=development \
+ PORT=8080 \
+ DATABASE_URL=secretref:database-url \
+ AZURE_STORAGE_CONNECTION_STRING=secretref:storage-connection-string
+
+ - name: Wait for deployment to stabilize
+ run: sleep 30
+
+ - name: Smoke check dev deployment
+ run: |
+ DEV_URL=$(az containerapp show \
+ --name ${{ secrets.CONTAINER_APP_NAME }} \
+ --resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
+ --query "properties.configuration.ingress.fqdn" \
+ --output tsv)
+ echo "Dev URL: https://$DEV_URL"
+ for i in $(seq 1 15); do
+ response=$(curl -sf "https://$DEV_URL/health") && echo "Health OK: $response" && break
+ echo "Attempt $i — not ready, waiting 10s..."
+ sleep 10
+ done
+ curl -sf "https://$DEV_URL/health" | grep -q '"status":"ok"' || { echo "Dev smoke check failed."; exit 1; }
+ echo "Dev deployment smoke check passed."
+
+ - name: Collect deployment logs on failure
+ if: failure()
+ run: |
+ az containerapp logs show \
+ --name ${{ secrets.CONTAINER_APP_NAME }} \
+ --resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
+ --tail 50 || true
diff --git a/.github/workflows/backend-cd-main.yml b/.github/workflows/backend-cd-main.yml
new file mode 100644
index 0000000..4b8f4f7
--- /dev/null
+++ b/.github/workflows/backend-cd-main.yml
@@ -0,0 +1,94 @@
+name: Backend — Main CD Pipeline
+
+on:
+ push:
+ branches:
+ - main
+
+jobs:
+ deploy-prod:
+ name: Build, Push & Deploy (Production)
+ runs-on: ubuntu-latest
+ environment: main
+
+ steps:
+ - name: Checkout backend repo
+ uses: actions/checkout@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Log in to Azure Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ secrets.ACR_LOGIN_SERVER }}
+ username: ${{ secrets.ACR_USERNAME }}
+ password: ${{ secrets.ACR_PASSWORD }}
+
+ - name: Build and push Docker image
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ push: true
+ tags: |
+ ${{ secrets.ACR_LOGIN_SERVER }}/backend:latest
+ ${{ secrets.ACR_LOGIN_SERVER }}/backend:${{ github.sha }}
+
+ - name: Run migrations against Azure PostgreSQL (prod)
+ env:
+ DATABASE_URL: ${{ secrets.DATABASE_URL }}
+ run: npm run migrate:up
+
+ - name: Install Azure CLI
+ run: |
+ curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
+
+ - name: Log in to Azure
+ uses: azure/login@v1
+ with:
+ creds: ${{ secrets.AZURE_CREDENTIALS }}
+
+ - name: Update Container App with new image
+ run: |
+ az containerapp update \
+ --name ${{ secrets.CONTAINER_APP_NAME }} \
+ --resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
+ --image ${{ secrets.ACR_LOGIN_SERVER }}/backend:${{ github.sha }} \
+ --set-env-vars \
+ NODE_ENV=production \
+ PORT=8080 \
+ DATABASE_URL=secretref:database-url \
+ AZURE_STORAGE_CONNECTION_STRING=secretref:storage-connection-string
+
+ - name: Wait for deployment to stabilize
+ run: sleep 30
+
+ - name: Smoke check prod deployment
+ run: |
+ PROD_URL=$(az containerapp show \
+ --name ${{ secrets.CONTAINER_APP_NAME }} \
+ --resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
+ --query "properties.configuration.ingress.fqdn" \
+ --output tsv)
+ echo "Prod URL: https://$PROD_URL"
+ for i in $(seq 1 15); do
+ response=$(curl -sf "https://$PROD_URL/health") && echo "Health OK: $response" && break
+ echo "Attempt $i — not ready, waiting 10s..."
+ sleep 10
+ done
+ curl -sf "https://$PROD_URL/health" | grep -q '"status":"ok"' || { echo "Prod smoke check failed."; exit 1; }
+ echo "Production deployment smoke check passed."
+
+ - name: Collect deployment logs on failure
+ if: failure()
+ run: |
+ az containerapp logs show \
+ --name ${{ secrets.CONTAINER_APP_NAME }} \
+ --resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
+ --tail 50 || true
From 17db9b96114ae37e0c5d777bd69bf84270c1b8e1 Mon Sep 17 00:00:00 2001
From: Mohamed jameel alabasy
Date: Thu, 2 Apr 2026 01:45:24 +0200
Subject: [PATCH 130/535] fix(pipeline): update Azure login steps in CD
pipelines for consistency
---
.github/workflows/backend-cd-dev.yml | 16 ++++++----------
.github/workflows/backend-cd-main.yml | 16 ++++++----------
2 files changed, 12 insertions(+), 20 deletions(-)
diff --git a/.github/workflows/backend-cd-dev.yml b/.github/workflows/backend-cd-dev.yml
index 5afacf7..485e264 100644
--- a/.github/workflows/backend-cd-dev.yml
+++ b/.github/workflows/backend-cd-dev.yml
@@ -24,12 +24,13 @@ jobs:
- name: Install dependencies
run: npm ci
- - name: Log in to Azure Container Registry
- uses: docker/login-action@v3
+ - name: Log in to Azure
+ uses: azure/login@v1
with:
- registry: ${{ secrets.ACR_LOGIN_SERVER }}
- username: ${{ secrets.ACR_USERNAME }}
- password: ${{ secrets.ACR_PASSWORD }}
+ creds: ${{ secrets.AZURE_CREDENTIALS }}
+
+ - name: Log in to Azure Container Registry
+ run: az acr login --name rythmifyacr
- name: Build and push Docker image
uses: docker/build-push-action@v5
@@ -49,11 +50,6 @@ jobs:
run: |
curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
- - name: Log in to Azure
- uses: azure/login@v1
- with:
- creds: ${{ secrets.AZURE_CREDENTIALS }}
-
- name: Update Container App with new image
run: |
az containerapp update \
diff --git a/.github/workflows/backend-cd-main.yml b/.github/workflows/backend-cd-main.yml
index 4b8f4f7..48d6db8 100644
--- a/.github/workflows/backend-cd-main.yml
+++ b/.github/workflows/backend-cd-main.yml
@@ -24,12 +24,13 @@ jobs:
- name: Install dependencies
run: npm ci
- - name: Log in to Azure Container Registry
- uses: docker/login-action@v3
+ - name: Log in to Azure
+ uses: azure/login@v1
with:
- registry: ${{ secrets.ACR_LOGIN_SERVER }}
- username: ${{ secrets.ACR_USERNAME }}
- password: ${{ secrets.ACR_PASSWORD }}
+ creds: ${{ secrets.AZURE_CREDENTIALS }}
+
+ - name: Log in to Azure Container Registry
+ run: az acr login --name rythmifyacr
- name: Build and push Docker image
uses: docker/build-push-action@v5
@@ -49,11 +50,6 @@ jobs:
run: |
curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
- - name: Log in to Azure
- uses: azure/login@v1
- with:
- creds: ${{ secrets.AZURE_CREDENTIALS }}
-
- name: Update Container App with new image
run: |
az containerapp update \
From 2c3426035fea5da063e1522388394640b5d80c9a Mon Sep 17 00:00:00 2001
From: Mohamed jameel alabasy
Date: Thu, 2 Apr 2026 02:26:37 +0200
Subject: [PATCH 131/535] fix(pipeline): update Azure login and Docker image
build steps for consistency
---
.github/workflows/backend-cd-dev.yml | 20 ++++++++------------
.github/workflows/backend-cd-main.yml | 20 ++++++++------------
2 files changed, 16 insertions(+), 24 deletions(-)
diff --git a/.github/workflows/backend-cd-dev.yml b/.github/workflows/backend-cd-dev.yml
index 485e264..662d3a7 100644
--- a/.github/workflows/backend-cd-dev.yml
+++ b/.github/workflows/backend-cd-dev.yml
@@ -30,32 +30,28 @@ jobs:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Log in to Azure Container Registry
- run: az acr login --name rythmifyacr
+ run: az acr login --name rythmifyacr-e8avged9d0cea2fb
- name: Build and push Docker image
- uses: docker/build-push-action@v5
- with:
- context: .
- push: true
- tags: |
- ${{ secrets.ACR_LOGIN_SERVER }}/backend:dev
- ${{ secrets.ACR_LOGIN_SERVER }}/backend:dev-${{ github.sha }}
+ run: |
+ docker build -t ${{ secrets.ACR_LOGIN_SERVER }}/backend:dev .
+ docker tag ${{ secrets.ACR_LOGIN_SERVER }}/backend:dev ${{ secrets.ACR_LOGIN_SERVER }}/backend:dev-${{ github.sha }}
+ docker push ${{ secrets.ACR_LOGIN_SERVER }}/backend:dev
+ docker push ${{ secrets.ACR_LOGIN_SERVER }}/backend:dev-${{ github.sha }}
- name: Run migrations against Azure PostgreSQL (dev)
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: npm run migrate:up
- - name: Install Azure CLI
- run: |
- curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
-
- name: Update Container App with new image
run: |
az containerapp update \
--name ${{ secrets.CONTAINER_APP_NAME }} \
--resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
--image ${{ secrets.ACR_LOGIN_SERVER }}/backend:dev-${{ github.sha }} \
+ --registry-server ${{ secrets.ACR_LOGIN_SERVER }} \
+ --registry-identity system \
--set-env-vars \
NODE_ENV=development \
PORT=8080 \
diff --git a/.github/workflows/backend-cd-main.yml b/.github/workflows/backend-cd-main.yml
index 48d6db8..0f9f3af 100644
--- a/.github/workflows/backend-cd-main.yml
+++ b/.github/workflows/backend-cd-main.yml
@@ -30,32 +30,28 @@ jobs:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Log in to Azure Container Registry
- run: az acr login --name rythmifyacr
+ run: az acr login --name rythmifyacr-e8avged9d0cea2fb
- name: Build and push Docker image
- uses: docker/build-push-action@v5
- with:
- context: .
- push: true
- tags: |
- ${{ secrets.ACR_LOGIN_SERVER }}/backend:latest
- ${{ secrets.ACR_LOGIN_SERVER }}/backend:${{ github.sha }}
+ run: |
+ docker build -t ${{ secrets.ACR_LOGIN_SERVER }}/backend:latest .
+ docker tag ${{ secrets.ACR_LOGIN_SERVER }}/backend:latest ${{ secrets.ACR_LOGIN_SERVER }}/backend:${{ github.sha }}
+ docker push ${{ secrets.ACR_LOGIN_SERVER }}/backend:latest
+ docker push ${{ secrets.ACR_LOGIN_SERVER }}/backend:${{ github.sha }}
- name: Run migrations against Azure PostgreSQL (prod)
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: npm run migrate:up
- - name: Install Azure CLI
- run: |
- curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
-
- name: Update Container App with new image
run: |
az containerapp update \
--name ${{ secrets.CONTAINER_APP_NAME }} \
--resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
--image ${{ secrets.ACR_LOGIN_SERVER }}/backend:${{ github.sha }} \
+ --registry-server ${{ secrets.ACR_LOGIN_SERVER }} \
+ --registry-identity system \
--set-env-vars \
NODE_ENV=production \
PORT=8080 \
From ca6c57ab7aa057bec311926bc14eaf61ebc8a7b6 Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Thu, 2 Apr 2026 02:28:24 +0200
Subject: [PATCH 132/535] feat(tracks): add secret-token access for private
tracks
---
openapi.yaml | 78 +++++++++++++++-----------
src/controllers/tracks.controller.js | 10 +++-
src/models/track.model.js | 20 ++++---
src/services/tracks.service.js | 43 +++++++++++---
tests/tracks/tracks.controller.test.js | 12 ++--
tests/tracks/tracks.model.test.js | 3 +-
tests/tracks/tracks.service.test.js | 20 ++++---
7 files changed, 119 insertions(+), 67 deletions(-)
diff --git a/openapi.yaml b/openapi.yaml
index 855d8b0..7d6dee0 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -1803,7 +1803,9 @@ paths:
Notes:
- Public tracks are accessible to anyone (no auth required).
- - Private tracks (`is_public: false`) are only accessible to their owner. Unauthenticated requests or requests from non-owners return `403` with `RESOURCE_PRIVATE`.
+ - Private tracks (`is_public: false`) are accessible to:
+ 1. the track owner, or
+ 2. anyone who provides a valid `secret_token` query parameter.
- Admin-hidden tracks (`is_hidden: true`) return `404` for non-owners.
- Tracks still in `processing` state are returned with `null` values for `stream_url`, `preview_url`, `waveform_url`, `duration`, and `bitrate`.
operationId: getTrack
@@ -1816,6 +1818,12 @@ paths:
schema:
type: string
format: uuid
+ - name: secret_token
+ in: query
+ required: false
+ description: Required only to access a private track when the requester is not the owner.
+ schema:
+ type: string
responses:
'200':
description: Track details returned successfully.
@@ -1841,6 +1849,7 @@ paths:
Notes:
- Only the track owner can update a track. Non-owners receive `403` with `PERMISSION_NOT_OWNER`.
- All fields are optional — only provided fields are updated (partial update).
+ - Track privacy is not updated here. Use `PATCH /tracks/{track_id}/visibility` to switch between public and private.
operationId: updateTrack
security:
- bearerAuth: []
@@ -1922,7 +1931,9 @@ paths:
Notes:
- Public endpoint — no authentication required for public tracks.
- - Private tracks are only accessible to their owner. Non-owner requests return `403` with `RESOURCE_PRIVATE`.
+ - Private tracks are accessible to:
+ 1. the track owner, or
+ 2. anyone who provides a valid `secret_token` query parameter.
- Admin-hidden tracks (`is_hidden: true`) return `404` for non-owners.
- If the track is still processing (`status: processing`), `stream_url` will be `null` and a `202 Accepted` is returned to signal the caller to retry later.
- If transcoding failed (`status: failed`), `503` is returned with `UPLOAD_PROCESSING_FAILED`.
@@ -1936,6 +1947,12 @@ paths:
schema:
type: string
format: uuid
+ - name: secret_token
+ in: query
+ required: false
+ description: Required only to access a private track when the requester is not the owner.
+ schema:
+ type: string
responses:
'200':
description: Streaming URL returned successfully.
@@ -2064,7 +2081,7 @@ paths:
Notes:
- Only the track owner can change visibility. Non-owners receive `403`.
- - A private track (`is_public: false`) is excluded from search results and public feeds.
+ - A private track (`is_public: false`) is excluded from search results and public feeds, but remains accessible through its private link.
- This endpoint only updates `is_public` — use `PATCH /tracks/:id` to update other metadata.
operationId: updateTrackVisibility
security:
@@ -4093,52 +4110,52 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/EnsureConversationRequest"
+ $ref: '#/components/schemas/EnsureConversationRequest'
responses:
- "201":
+ '201':
description: Conversation created.
content:
application/json:
schema:
- $ref: "#/components/schemas/EnsureConversationResponse"
+ $ref: '#/components/schemas/EnsureConversationResponse'
- "200":
+ '200':
description: Conversation fetched successfully.
content:
application/json:
schema:
- $ref: "#/components/schemas/EnsureConversationResponse"
+ $ref: '#/components/schemas/EnsureConversationResponse'
- "400":
+ '400':
description: Validation failure (for example, missing recipient_id or self-messaging).
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
- "401":
- $ref: "#/components/responses/Unauthorized"
+ '401':
+ $ref: '#/components/responses/Unauthorized'
- "403":
+ '403':
description: Recipient policy blocks this sender.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
- "404":
+ '404':
description: Recipient not found.
content:
application/json:
schema:
- $ref: "#/components/schemas/ErrorResponse"
+ $ref: '#/components/schemas/ErrorResponse'
- "429":
- $ref: "#/components/responses/RateLimited"
+ '429':
+ $ref: '#/components/responses/RateLimited'
- "500":
- $ref: "#/components/responses/InternalError"
+ '500':
+ $ref: '#/components/responses/InternalError'
/messages/unread-count:
get:
@@ -5837,7 +5854,7 @@ paths:
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/Offset'
responses:
- "200":
+ '200':
description: Search results from following list returned.
content:
application/json:
@@ -8106,13 +8123,6 @@ components:
items:
type: string
example: ['chill', 'ambient']
- is_public:
- type: boolean
- nullable: true
- cover_image:
- type: string
- format: binary
- nullable: true
artists:
type: string
nullable: true
@@ -8192,7 +8202,7 @@ components:
properties:
is_public:
type: boolean
- description: '`true` to make the track public; `false` to make it private (owner-only).'
+ description: '`true` to make the track public; `false` to make it private and accessible only via its private link.'
example: false
required:
- is_public
@@ -9353,7 +9363,7 @@ components:
type: boolean
example: true
data:
- $ref: "#/components/schemas/EnsureConversationResponse_data"
+ $ref: '#/components/schemas/EnsureConversationResponse_data'
ConversationListResponse:
type: object
@@ -9378,7 +9388,7 @@ components:
type: boolean
example: true
data:
- $ref: "#/components/schemas/ConversationCreatedResponse_data"
+ $ref: '#/components/schemas/ConversationCreatedResponse_data'
MessageCreatedResponse:
type: object
properties:
@@ -9454,7 +9464,7 @@ components:
conversation:
$ref: '#/components/schemas/Conversation'
message:
- $ref: "#/components/schemas/Message"
+ $ref: '#/components/schemas/Message'
UnreadCountResponse_data:
type: object
properties:
@@ -9466,7 +9476,7 @@ components:
type: object
properties:
conversation:
- $ref: "#/components/schemas/Conversation"
+ $ref: '#/components/schemas/Conversation'
# ==========================================================
# Module 10 — Real-Time Notifications (Schemas)
@@ -10798,4 +10808,4 @@ components:
type: integer
minimum: 0
default: 0
- example: 0
\ No newline at end of file
+ example: 0
diff --git a/src/controllers/tracks.controller.js b/src/controllers/tracks.controller.js
index ca5d46a..eb37a2a 100644
--- a/src/controllers/tracks.controller.js
+++ b/src/controllers/tracks.controller.js
@@ -31,9 +31,10 @@ const uploadTrack = async (req, res) => {
const getTrackById = async (req, res) => {
const { track_id } = req.params;
+ const { secret_token } = req.query || {};
const requesterUserId = req.user?.id ?? req.user?.sub ?? req.user?.user_id ?? null;
- const track = await tracksService.getTrackById(track_id, requesterUserId);
+ const track = await tracksService.getTrackById(track_id, requesterUserId, secret_token || null);
return success(res, track, 'Track fetched successfully', 200);
};
@@ -87,8 +88,13 @@ const updateTrack = async (req, res) => {
const getTrackStream = async (req, res) => {
const requesterUserId = req.user?.sub || req.user?.id || req.user?.user_id || null;
+ const { secret_token } = req.query || {};
- const data = await tracksService.getTrackStream(req.params.track_id, requesterUserId);
+ const data = await tracksService.getTrackStream(
+ req.params.track_id,
+ requesterUserId,
+ secret_token || null
+ );
return res.status(200).json({
success: true,
diff --git a/src/models/track.model.js b/src/models/track.model.js
index 38a3760..e0d1437 100644
--- a/src/models/track.model.js
+++ b/src/models/track.model.js
@@ -9,7 +9,7 @@ const createTrack = async (t) => {
const query = `
INSERT INTO tracks (
title, description, genre_id, cover_image, waveform_url, audio_url, stream_url, preview_url,
- duration, file_size, bitrate, status, is_public, user_id,
+ duration, file_size, bitrate, status, is_public, secret_token, user_id,
release_date, isrc, p_line, buy_link, record_label, publisher,
explicit_content, license_type,
enable_downloads, enable_offline_listening, include_in_rss_feed, display_embed_code, enable_app_playback,
@@ -17,11 +17,11 @@ const createTrack = async (t) => {
)
VALUES (
$1,$2,$3,$4,NULL,$5,NULL,NULL,
- NULL,$6,NULL,$7,$8,$9,
- $10,$11,$12,$13,$14,$15,
- $16,$17,
- $18,$19,$20,$21,$22,
- $23,$24,$25,$26,$27
+ NULL,$6,NULL,$7,$8,$9,$10,
+ $11,$12,$13,$14,$15,$16,
+ $17,$18,
+ $19,$20,$21,$22,$23,
+ $24,$25,$26,$27,$28
)
RETURNING *;
`;
@@ -35,6 +35,7 @@ const createTrack = async (t) => {
t.file_size,
t.status,
t.is_public,
+ t.secret_token,
t.user_id,
t.release_date,
t.isrc,
@@ -160,6 +161,7 @@ const findTrackByIdWithDetails = async (trackId) => {
t.bitrate,
t.status,
t.is_public,
+ t.secret_token,
t.is_trending,
t.is_featured,
t.is_hidden,
@@ -208,18 +210,19 @@ const findTrackByIdWithDetails = async (trackId) => {
return rows[0] || null;
};
-const updateTrackVisibility = async (trackId, isPublic) => {
+const updateTrackVisibility = async (trackId, isPublic, secretToken) => {
const query = `
UPDATE tracks
SET
is_public = $2,
+ secret_token = $3,
updated_at = NOW()
WHERE id = $1
AND deleted_at IS NULL
RETURNING id, is_public
`;
- const { rows } = await db.query(query, [trackId, isPublic]);
+ const { rows } = await db.query(query, [trackId, isPublic, secretToken]);
return rows[0] || null;
};
@@ -346,7 +349,6 @@ const updateTrackFields = async (trackId, updates) => {
'description',
'genre_id',
'cover_image',
- 'is_public',
'buy_link',
'record_label',
'publisher',
diff --git a/src/services/tracks.service.js b/src/services/tracks.service.js
index 8b347ca..24d064c 100644
--- a/src/services/tracks.service.js
+++ b/src/services/tracks.service.js
@@ -9,6 +9,7 @@ const AppError = require('../utils/app-error.js');
const tracksModel = require('../models/track.model.js');
const tagModel = require('../models/tag.model.js');
const storageService = require('./storage.service.js');
+const crypto = require('crypto');
const GEO_RESTRICTION_TYPES = ['worldwide', 'exclusive_regions', 'blocked_regions'];
@@ -94,6 +95,9 @@ const parseArray = (v) => {
}
};
+// generate a secret token for private tracks
+const generateSecretToken = () => crypto.randomBytes(24).toString('hex');
+
const clean = (v) => (v === undefined || v === null || v === '' ? null : v);
const parseStrictArray = (v, fieldName) => {
@@ -265,6 +269,9 @@ const uploadTrack = async ({ user, audioFile, coverImageFile, body }) => {
currentGeoRegions: [],
});
+ const isPublic = toBool(body.is_public, true);
+ const secretToken = isPublic ? null : generateSecretToken();
+
const trackData = {
title: body.title.trim(),
description: clean(body.description),
@@ -273,7 +280,8 @@ const uploadTrack = async ({ user, audioFile, coverImageFile, body }) => {
audio_url: uploadedAudio.url,
file_size: audioFile.size,
status: 'processing',
- is_public: toBool(body.is_public, true),
+ is_public: isPublic,
+ secret_token: secretToken,
release_date: clean(body.release_date),
isrc: clean(body.isrc),
@@ -312,7 +320,7 @@ const uploadTrack = async ({ user, audioFile, coverImageFile, body }) => {
};
};
-const getTrackById = async (trackId, requesterUserId = null) => {
+const getTrackById = async (trackId, requesterUserId = null, secretToken = null) => {
const track = await tracksModel.findTrackByIdWithDetails(trackId);
if (!track) {
@@ -320,16 +328,20 @@ const getTrackById = async (trackId, requesterUserId = null) => {
}
const isOwner = requesterUserId === track.user_id;
+ const hasValidPrivateLink =
+ !track.is_public && !!secretToken && !!track.secret_token && secretToken === track.secret_token;
if (track.is_hidden && !isOwner) {
throw new AppError('Track not found', 404, 'TRACK_NOT_FOUND');
}
- if (!track.is_public && !isOwner) {
+ if (!track.is_public && !isOwner && !hasValidPrivateLink) {
throw new AppError('This track is private', 403, 'RESOURCE_PRIVATE');
}
- return mapTrackTagsToNames(track);
+ const { secret_token, ...safeTrack } = track;
+
+ return mapTrackTagsToNames(safeTrack);
};
const updateTrackVisibility = async (trackId, userId, isPublic) => {
@@ -351,7 +363,9 @@ const updateTrackVisibility = async (trackId, userId, isPublic) => {
);
}
- const updatedTrack = await tracksModel.updateTrackVisibility(trackId, isPublic);
+ const secretToken = isPublic ? track.secret_token : track.secret_token || generateSecretToken();
+
+ const updatedTrack = await tracksModel.updateTrackVisibility(trackId, isPublic, secretToken);
return {
track_id: updatedTrack.id,
@@ -462,7 +476,11 @@ const updateTrack = async ({ trackId, userId, payload, coverImageFile }) => {
if (payload.genre !== undefined) updateData.genre_id = genreId;
if (payload.is_public !== undefined) {
- updateData.is_public = toBool(payload.is_public, track.is_public);
+ throw new AppError(
+ 'Use PATCH /tracks/:track_id/visibility to change track privacy',
+ 400,
+ 'VALIDATION_FAILED'
+ );
}
if (payload.buy_link !== undefined) updateData.buy_link = clean(payload.buy_link);
@@ -593,8 +611,17 @@ const updateTrack = async ({ trackId, userId, payload, coverImageFile }) => {
return mapTrackTagsToNames(finalTrack);
};
-const getTrackStream = async (trackId, requesterUserId = null) => {
- const track = await getTrackById(trackId, requesterUserId);
+const getTrackStream = async (trackId, requesterUserId = null, secretToken = null) => {
+ const track = await getTrackById(trackId, requesterUserId, secretToken);
+
+ // uncomment when handled
+ // if (track.status === 'processing') {
+ // throw new AppError(
+ // 'Track is still processing. Please retry shortly.',
+ // 202,
+ // 'BUSINESS_OPERATION_NOT_ALLOWED'
+ // );
+ // }
if (track.status === 'failed') {
throw new AppError('Track processing failed', 503, 'UPLOAD_PROCESSING_FAILED');
diff --git a/tests/tracks/tracks.controller.test.js b/tests/tracks/tracks.controller.test.js
index 5436bdc..47d1762 100644
--- a/tests/tracks/tracks.controller.test.js
+++ b/tests/tracks/tracks.controller.test.js
@@ -129,7 +129,7 @@ describe('tracksController.getTrackById', () => {
await tracksController.getTrackById(req, res);
- expect(tracksService.getTrackById).toHaveBeenCalledWith('track-1', null);
+ expect(tracksService.getTrackById).toHaveBeenCalledWith('track-1', null, null);
expect(success).toHaveBeenCalledWith(res, track, 'Track fetched successfully', 200);
});
@@ -149,7 +149,7 @@ describe('tracksController.getTrackById', () => {
await tracksController.getTrackById(req, res);
- expect(tracksService.getTrackById).toHaveBeenCalledWith('track-1', 'user-1');
+ expect(tracksService.getTrackById).toHaveBeenCalledWith('track-1', 'user-1', null);
expect(success).toHaveBeenCalledWith(res, track, 'Track fetched successfully', 200);
});
@@ -169,7 +169,7 @@ describe('tracksController.getTrackById', () => {
await tracksController.getTrackById(req, res);
- expect(tracksService.getTrackById).toHaveBeenCalledWith('track-1', 'user-sub-1');
+ expect(tracksService.getTrackById).toHaveBeenCalledWith('track-1', 'user-sub-1', null);
expect(success).toHaveBeenCalledWith(res, track, 'Track fetched successfully', 200);
});
});
@@ -567,7 +567,7 @@ describe('tracksController.getTrackStream', () => {
await tracksController.getTrackStream(req, res);
- expect(tracksService.getTrackStream).toHaveBeenCalledWith('track-1', 'user-sub-1');
+ expect(tracksService.getTrackStream).toHaveBeenCalledWith('track-1', 'user-sub-1', null);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
@@ -596,7 +596,7 @@ describe('tracksController.getTrackStream', () => {
await tracksController.getTrackStream(req, res);
- expect(tracksService.getTrackStream).toHaveBeenCalledWith('track-1', 'user-1');
+ expect(tracksService.getTrackStream).toHaveBeenCalledWith('track-1', 'user-1', null);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
@@ -624,7 +624,7 @@ describe('tracksController.getTrackStream', () => {
await tracksController.getTrackStream(req, res);
- expect(tracksService.getTrackStream).toHaveBeenCalledWith('track-1', null);
+ expect(tracksService.getTrackStream).toHaveBeenCalledWith('track-1', null, null);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
diff --git a/tests/tracks/tracks.model.test.js b/tests/tracks/tracks.model.test.js
index 273ed4d..72c092c 100644
--- a/tests/tracks/tracks.model.test.js
+++ b/tests/tracks/tracks.model.test.js
@@ -420,6 +420,7 @@ describe('tracksModel.createTrack', () => {
12345,
'processing',
true,
+ undefined,
'user-1',
null,
null,
@@ -478,7 +479,7 @@ describe('tracksModel.createTrack', () => {
const [, params] = db.query.mock.calls[0];
- expect(params[26]).toBe(JSON.stringify([]));
+ expect(params[27]).toBe(JSON.stringify([]));
});
});
diff --git a/tests/tracks/tracks.service.test.js b/tests/tracks/tracks.service.test.js
index e2242eb..6506eea 100644
--- a/tests/tracks/tracks.service.test.js
+++ b/tests/tracks/tracks.service.test.js
@@ -43,6 +43,7 @@ describe('tracksService.updateTrackVisibility', () => {
tracksModel.findTrackByIdWithDetails.mockResolvedValue({
id: 'track-1',
user_id: 'user-1',
+ secret_token: null,
});
tracksModel.updateTrackVisibility.mockResolvedValue({
id: 'track-1',
@@ -57,7 +58,11 @@ describe('tracksService.updateTrackVisibility', () => {
});
expect(tracksModel.findTrackByIdWithDetails).toHaveBeenCalledWith('track-1');
- expect(tracksModel.updateTrackVisibility).toHaveBeenCalledWith('track-1', false);
+ expect(tracksModel.updateTrackVisibility).toHaveBeenCalledWith(
+ 'track-1',
+ false,
+ expect.any(String)
+ );
});
it('throws 400 when is_public is not boolean', async () => {
@@ -621,7 +626,7 @@ describe('tracksService.updateTrack', () => {
user_id: 'user-1',
title: 'New Title',
description: 'New Description',
- is_public: false,
+ is_public: true,
});
tracksModel.updateTrackFields.mockResolvedValue({
@@ -634,7 +639,6 @@ describe('tracksService.updateTrack', () => {
payload: {
title: 'New Title',
description: 'New Description',
- is_public: false,
},
coverImageFile: null,
});
@@ -644,13 +648,12 @@ describe('tracksService.updateTrack', () => {
user_id: 'user-1',
title: 'New Title',
description: 'New Description',
- is_public: false,
+ is_public: true,
});
expect(tracksModel.updateTrackFields).toHaveBeenCalledWith('track-1', {
title: 'New Title',
description: 'New Description',
- is_public: false,
});
});
@@ -1310,6 +1313,8 @@ describe('tracksService.uploadTrack validations', () => {
file_size: 12345,
status: 'processing',
is_public: true,
+ secret_token: null,
+ user_id: 'user-1',
release_date: null,
isrc: null,
@@ -1403,6 +1408,8 @@ describe('tracksService.uploadTrack validations', () => {
file_size: 12345,
status: 'processing',
is_public: true,
+ secret_token: null,
+ user_id: 'user-1',
release_date: null,
isrc: null,
@@ -1999,6 +2006,7 @@ describe('tracksService boolean conversion and tag normalization', () => {
expect(tracksModel.createTrack).toHaveBeenCalledWith(
expect.objectContaining({
is_public: false,
+ secret_token: expect.any(String),
explicit_content: true,
enable_downloads: true,
enable_offline_listening: true,
@@ -2096,7 +2104,6 @@ describe('tracksService boolean conversion and tag normalization', () => {
trackId: 'track-1',
userId: 'user-1',
payload: {
- is_public: 'false',
explicit_content: 'true',
enable_downloads: 'true',
enable_offline_listening: 'true',
@@ -2113,7 +2120,6 @@ describe('tracksService boolean conversion and tag normalization', () => {
expect(tracksModel.updateTrackFields).toHaveBeenCalledWith(
'track-1',
expect.objectContaining({
- is_public: false,
explicit_content: true,
enable_downloads: true,
enable_offline_listening: true,
From f3e1f16a67e1900b3e7a68bbb50b2f25f2c9ebfb Mon Sep 17 00:00:00 2001
From: Mohamed jameel alabasy
Date: Thu, 2 Apr 2026 02:36:50 +0200
Subject: [PATCH 133/535] fix(pipeline): update Azure Container Registry login
method for security
---
.github/workflows/backend-cd-dev.yml | 5 ++++-
.github/workflows/backend-cd-main.yml | 5 ++++-
2 files changed, 8 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/backend-cd-dev.yml b/.github/workflows/backend-cd-dev.yml
index 662d3a7..e810bef 100644
--- a/.github/workflows/backend-cd-dev.yml
+++ b/.github/workflows/backend-cd-dev.yml
@@ -30,7 +30,10 @@ jobs:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Log in to Azure Container Registry
- run: az acr login --name rythmifyacr-e8avged9d0cea2fb
+ run: |
+ echo ${{ secrets.ACR_PASSWORD }} | docker login ${{ secrets.ACR_LOGIN_SERVER }} \
+ --username ${{ secrets.ACR_USERNAME }} \
+ --password-stdin
- name: Build and push Docker image
run: |
diff --git a/.github/workflows/backend-cd-main.yml b/.github/workflows/backend-cd-main.yml
index 0f9f3af..c8ebed9 100644
--- a/.github/workflows/backend-cd-main.yml
+++ b/.github/workflows/backend-cd-main.yml
@@ -30,7 +30,10 @@ jobs:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Log in to Azure Container Registry
- run: az acr login --name rythmifyacr-e8avged9d0cea2fb
+ run: |
+ echo ${{ secrets.ACR_PASSWORD }} | docker login ${{ secrets.ACR_LOGIN_SERVER }} \
+ --username ${{ secrets.ACR_USERNAME }} \
+ --password-stdin
- name: Build and push Docker image
run: |
From c878504aa462b4dd4981ef24d870ee31fd1a50e4 Mon Sep 17 00:00:00 2001
From: Mohamed jameel alabasy
Date: Thu, 2 Apr 2026 02:45:41 +0200
Subject: [PATCH 134/535] fix(pipeline): use registry set for ACR auth in CD
pipelines
---
.github/workflows/backend-cd-dev.yml | 16 ++++++++--------
.github/workflows/backend-cd-main.yml | 16 ++++++++--------
2 files changed, 16 insertions(+), 16 deletions(-)
diff --git a/.github/workflows/backend-cd-dev.yml b/.github/workflows/backend-cd-dev.yml
index e810bef..1dfd740 100644
--- a/.github/workflows/backend-cd-dev.yml
+++ b/.github/workflows/backend-cd-dev.yml
@@ -49,17 +49,17 @@ jobs:
- name: Update Container App with new image
run: |
+ az containerapp registry set \
+ --name ${{ secrets.CONTAINER_APP_NAME }} \
+ --resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
+ --server ${{ secrets.ACR_LOGIN_SERVER }} \
+ --username ${{ secrets.ACR_USERNAME }} \
+ --password ${{ secrets.ACR_PASSWORD }}
+
az containerapp update \
--name ${{ secrets.CONTAINER_APP_NAME }} \
--resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
- --image ${{ secrets.ACR_LOGIN_SERVER }}/backend:dev-${{ github.sha }} \
- --registry-server ${{ secrets.ACR_LOGIN_SERVER }} \
- --registry-identity system \
- --set-env-vars \
- NODE_ENV=development \
- PORT=8080 \
- DATABASE_URL=secretref:database-url \
- AZURE_STORAGE_CONNECTION_STRING=secretref:storage-connection-string
+ --image ${{ secrets.ACR_LOGIN_SERVER }}/backend:dev-${{ github.sha }}
- name: Wait for deployment to stabilize
run: sleep 30
diff --git a/.github/workflows/backend-cd-main.yml b/.github/workflows/backend-cd-main.yml
index c8ebed9..a088713 100644
--- a/.github/workflows/backend-cd-main.yml
+++ b/.github/workflows/backend-cd-main.yml
@@ -49,17 +49,17 @@ jobs:
- name: Update Container App with new image
run: |
+ az containerapp registry set \
+ --name ${{ secrets.CONTAINER_APP_NAME }} \
+ --resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
+ --server ${{ secrets.ACR_LOGIN_SERVER }} \
+ --username ${{ secrets.ACR_USERNAME }} \
+ --password ${{ secrets.ACR_PASSWORD }}
+
az containerapp update \
--name ${{ secrets.CONTAINER_APP_NAME }} \
--resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
- --image ${{ secrets.ACR_LOGIN_SERVER }}/backend:${{ github.sha }} \
- --registry-server ${{ secrets.ACR_LOGIN_SERVER }} \
- --registry-identity system \
- --set-env-vars \
- NODE_ENV=production \
- PORT=8080 \
- DATABASE_URL=secretref:database-url \
- AZURE_STORAGE_CONNECTION_STRING=secretref:storage-connection-string
+ --image ${{ secrets.ACR_LOGIN_SERVER }}/backend:dev-${{ github.sha }}
- name: Wait for deployment to stabilize
run: sleep 30
From 7d0ba78c2d814cf416c888cb5367250ef47be657 Mon Sep 17 00:00:00 2001
From: Mohamed jameel alabasy
Date: Thu, 2 Apr 2026 02:56:17 +0200
Subject: [PATCH 135/535] fix(pipeline): use correct url for smoke check
---
.github/workflows/backend-cd-dev.yml | 16 +++++++++++++---
.github/workflows/backend-cd-main.yml | 18 ++++++++++++++----
2 files changed, 27 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/backend-cd-dev.yml b/.github/workflows/backend-cd-dev.yml
index 1dfd740..2117dc6 100644
--- a/.github/workflows/backend-cd-dev.yml
+++ b/.github/workflows/backend-cd-dev.yml
@@ -47,7 +47,15 @@ jobs:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: npm run migrate:up
- - name: Update Container App with new image
+ - name: Run seeds against Azure PostgreSQL (dev)
+ env:
+ DATABASE_URL: ${{ secrets.DATABASE_URL }}
+ run: |
+ test -f seeds/seed.sql || { echo "MISSING: seeds/seed.sql"; exit 1; }
+ psql $DATABASE_URL -f seeds/seed.sql
+ echo "Seeding complete."
+
+ - name: Register ACR credentials with Container App
run: |
az containerapp registry set \
--name ${{ secrets.CONTAINER_APP_NAME }} \
@@ -56,6 +64,8 @@ jobs:
--username ${{ secrets.ACR_USERNAME }} \
--password ${{ secrets.ACR_PASSWORD }}
+ - name: Update Container App with new image
+ run: |
az containerapp update \
--name ${{ secrets.CONTAINER_APP_NAME }} \
--resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
@@ -73,11 +83,11 @@ jobs:
--output tsv)
echo "Dev URL: https://$DEV_URL"
for i in $(seq 1 15); do
- response=$(curl -sf "https://$DEV_URL/health") && echo "Health OK: $response" && break
+ response=$(curl -sf "https://$DEV_URL/api/v1/health") && echo "Health OK: $response" && break
echo "Attempt $i — not ready, waiting 10s..."
sleep 10
done
- curl -sf "https://$DEV_URL/health" | grep -q '"status":"ok"' || { echo "Dev smoke check failed."; exit 1; }
+ curl -sf "https://$DEV_URL/api/v1/health" | grep -q '"status":"ok"' || { echo "Dev smoke check failed."; exit 1; }
echo "Dev deployment smoke check passed."
- name: Collect deployment logs on failure
diff --git a/.github/workflows/backend-cd-main.yml b/.github/workflows/backend-cd-main.yml
index a088713..90de178 100644
--- a/.github/workflows/backend-cd-main.yml
+++ b/.github/workflows/backend-cd-main.yml
@@ -47,7 +47,15 @@ jobs:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: npm run migrate:up
- - name: Update Container App with new image
+ - name: Run seeds against Azure PostgreSQL (prod)
+ env:
+ DATABASE_URL: ${{ secrets.DATABASE_URL }}
+ run: |
+ test -f seeds/seed.sql || { echo "MISSING: seeds/seed.sql"; exit 1; }
+ psql $DATABASE_URL -f seeds/seed.sql
+ echo "Seeding complete."
+
+ - name: Register ACR credentials with Container App
run: |
az containerapp registry set \
--name ${{ secrets.CONTAINER_APP_NAME }} \
@@ -56,10 +64,12 @@ jobs:
--username ${{ secrets.ACR_USERNAME }} \
--password ${{ secrets.ACR_PASSWORD }}
+ - name: Update Container App with new image
+ run: |
az containerapp update \
--name ${{ secrets.CONTAINER_APP_NAME }} \
--resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
- --image ${{ secrets.ACR_LOGIN_SERVER }}/backend:dev-${{ github.sha }}
+ --image ${{ secrets.ACR_LOGIN_SERVER }}/backend:${{ github.sha }}
- name: Wait for deployment to stabilize
run: sleep 30
@@ -73,11 +83,11 @@ jobs:
--output tsv)
echo "Prod URL: https://$PROD_URL"
for i in $(seq 1 15); do
- response=$(curl -sf "https://$PROD_URL/health") && echo "Health OK: $response" && break
+ response=$(curl -sf "https://$PROD_URL/api/v1/health") && echo "Health OK: $response" && break
echo "Attempt $i — not ready, waiting 10s..."
sleep 10
done
- curl -sf "https://$PROD_URL/health" | grep -q '"status":"ok"' || { echo "Prod smoke check failed."; exit 1; }
+ curl -sf "https://$PROD_URL/api/v1/health" | grep -q '"status":"ok"' || { echo "Prod smoke check failed."; exit 1; }
echo "Production deployment smoke check passed."
- name: Collect deployment logs on failure
From ae3bf1b9df718d159d732942d6275c1e6aa7eefe Mon Sep 17 00:00:00 2001
From: Mohamed jameel alabasy
Date: Thu, 2 Apr 2026 03:03:44 +0200
Subject: [PATCH 136/535] chore(pipeline): fix seeding problem
---
.github/workflows/backend-cd-main.yml | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/backend-cd-main.yml b/.github/workflows/backend-cd-main.yml
index 90de178..289d264 100644
--- a/.github/workflows/backend-cd-main.yml
+++ b/.github/workflows/backend-cd-main.yml
@@ -47,13 +47,13 @@ jobs:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: npm run migrate:up
- - name: Run seeds against Azure PostgreSQL (prod)
- env:
- DATABASE_URL: ${{ secrets.DATABASE_URL }}
- run: |
- test -f seeds/seed.sql || { echo "MISSING: seeds/seed.sql"; exit 1; }
- psql $DATABASE_URL -f seeds/seed.sql
- echo "Seeding complete."
+ # - name: Run seeds against Azure PostgreSQL (prod)
+ # env:
+ # DATABASE_URL: ${{ secrets.DATABASE_URL }}
+ # run: |
+ # test -f seeds/seed.sql || { echo "MISSING: seeds/seed.sql"; exit 1; }
+ # psql $DATABASE_URL -f seeds/seed.sql
+ # echo "Seeding complete."
- name: Register ACR credentials with Container App
run: |
From d3bd947ed54484311e55d167802e2e9e70cf826f Mon Sep 17 00:00:00 2001
From: Mohamed jameel alabasy
Date: Thu, 2 Apr 2026 03:08:28 +0200
Subject: [PATCH 137/535] chore(pipeline): fix seeding problem in production
and development
---
.github/workflows/backend-cd-dev.yml | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/backend-cd-dev.yml b/.github/workflows/backend-cd-dev.yml
index 2117dc6..091ae5a 100644
--- a/.github/workflows/backend-cd-dev.yml
+++ b/.github/workflows/backend-cd-dev.yml
@@ -47,13 +47,13 @@ jobs:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: npm run migrate:up
- - name: Run seeds against Azure PostgreSQL (dev)
- env:
- DATABASE_URL: ${{ secrets.DATABASE_URL }}
- run: |
- test -f seeds/seed.sql || { echo "MISSING: seeds/seed.sql"; exit 1; }
- psql $DATABASE_URL -f seeds/seed.sql
- echo "Seeding complete."
+ # - name: Run seeds against Azure PostgreSQL (dev)
+ # env:
+ # DATABASE_URL: ${{ secrets.DATABASE_URL }}
+ # run: |
+ # test -f seeds/seed.sql || { echo "MISSING: seeds/seed.sql"; exit 1; }
+ # psql $DATABASE_URL -f seeds/seed.sql
+ # echo "Seeding complete."
- name: Register ACR credentials with Container App
run: |
From 062908dcfa286ac762b25de4ce71de0d04a44625 Mon Sep 17 00:00:00 2001
From: Mohamed jameel alabasy
Date: Thu, 2 Apr 2026 09:11:08 +0200
Subject: [PATCH 138/535] fix(pipeline): fixed env varaibles
---
.github/workflows/backend-cd-dev.yml | 29 ++++++++++++++++++++++++++++
1 file changed, 29 insertions(+)
diff --git a/.github/workflows/backend-cd-dev.yml b/.github/workflows/backend-cd-dev.yml
index 091ae5a..e3e6f41 100644
--- a/.github/workflows/backend-cd-dev.yml
+++ b/.github/workflows/backend-cd-dev.yml
@@ -24,6 +24,10 @@ jobs:
- name: Install dependencies
run: npm ci
+ # - name: Install PostgreSQL client
+ # run: |
+ # sudo apt-get update -qq
+ # sudo apt-get install -y postgresql-client
- name: Log in to Azure
uses: azure/login@v1
with:
@@ -64,6 +68,31 @@ jobs:
--username ${{ secrets.ACR_USERNAME }} \
--password ${{ secrets.ACR_PASSWORD }}
+ - name: Set Container App environment variables
+ run: |
+ az containerapp update \
+ --name ${{ secrets.CONTAINER_APP_NAME }} \
+ --resource-group ${{ secrets.AZURE_RESOURCE_GROUP }} \
+ --set-env-vars \
+ NODE_ENV=development \
+ PORT=8080 \
+ BLOB_CONTAINER_AUDIO=audio \
+ BLOB_CONTAINER_MEDIA=media \
+ JWT_ACCESS_EXPIRES_IN=15m \
+ JWT_REFRESH_EXPIRES_IN=7d \
+ "EMAIL_FROM=Rythmify " \
+ BASE_URL=https://${{ secrets.CONTAINER_APP_URL }} \
+ CLIENT_URL=https://rythmify-frontend.azurewebsites.net \
+ GOOGLE_CALLBACK_URL=https://${{ secrets.CONTAINER_APP_URL }}/api/v1/auth/google/callback \
+ JWT_SECRET=secretref:jwt-secret \
+ AZURE_STORAGE_CONNECTION_STRING=secretref:storage-connection-string \
+ DATABASE_URL=secretref:database-url \
+ GOOGLE_CLIENT_ID=secretref:google-client-id \
+ GOOGLE_CLIENT_SECRET=secretref:google-client-secret \
+ GMAIL_USER=secretref:gmail-user \
+ GMAIL_APP_PASSWORD=secretref:gmail-app-password \
+ RECAPTCHA_SECRET=secretref:recaptcha-secret
+
- name: Update Container App with new image
run: |
az containerapp update \
From bbb3a2dfa13e1171925a3515fb556db94e67d40c Mon Sep 17 00:00:00 2001
From: Mohamed jameel alabasy
Date: Thu, 2 Apr 2026 09:23:50 +0200
Subject: [PATCH 139/535] fix(pipeline): fixed smoke check
---
.github/workflows/backend-cd-dev.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/backend-cd-dev.yml b/.github/workflows/backend-cd-dev.yml
index e3e6f41..4b81457 100644
--- a/.github/workflows/backend-cd-dev.yml
+++ b/.github/workflows/backend-cd-dev.yml
@@ -112,11 +112,11 @@ jobs:
--output tsv)
echo "Dev URL: https://$DEV_URL"
for i in $(seq 1 15); do
- response=$(curl -sf "https://$DEV_URL/api/v1/health") && echo "Health OK: $response" && break
+ response=$(curl -sf "https://$DEV_URL/health") && echo "Health OK: $response" && break
echo "Attempt $i — not ready, waiting 10s..."
sleep 10
done
- curl -sf "https://$DEV_URL/api/v1/health" | grep -q '"status":"ok"' || { echo "Dev smoke check failed."; exit 1; }
+ curl -sf "https://$DEV_URL/health" | grep -q '"status":"ok"' || { echo "Dev smoke check failed."; exit 1; }
echo "Dev deployment smoke check passed."
- name: Collect deployment logs on failure
From da3a56fa71ea2b2bd6fc6c40e4154cb0e65c1b81 Mon Sep 17 00:00:00 2001
From: Mohamed jameel alabasy
Date: Thu, 2 Apr 2026 09:42:04 +0200
Subject: [PATCH 140/535] fix(pipeline): fixed seeding problem
---
.github/workflows/backend-cd-dev.yml | 22 +++++++++++-----------
1 file changed, 11 insertions(+), 11 deletions(-)
diff --git a/.github/workflows/backend-cd-dev.yml b/.github/workflows/backend-cd-dev.yml
index 4b81457..87f9594 100644
--- a/.github/workflows/backend-cd-dev.yml
+++ b/.github/workflows/backend-cd-dev.yml
@@ -24,10 +24,10 @@ jobs:
- name: Install dependencies
run: npm ci
- # - name: Install PostgreSQL client
- # run: |
- # sudo apt-get update -qq
- # sudo apt-get install -y postgresql-client
+ - name: Install PostgreSQL client
+ run: |
+ sudo apt-get update -qq
+ sudo apt-get install -y postgresql-client
- name: Log in to Azure
uses: azure/login@v1
with:
@@ -51,13 +51,13 @@ jobs:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: npm run migrate:up
- # - name: Run seeds against Azure PostgreSQL (dev)
- # env:
- # DATABASE_URL: ${{ secrets.DATABASE_URL }}
- # run: |
- # test -f seeds/seed.sql || { echo "MISSING: seeds/seed.sql"; exit 1; }
- # psql $DATABASE_URL -f seeds/seed.sql
- # echo "Seeding complete."
+ - name: Run seeds against Azure PostgreSQL (dev)
+ env:
+ DATABASE_URL: ${{ secrets.DATABASE_URL }}
+ run: |
+ test -f seeds/seed.sql || { echo "MISSING: seeds/seed.sql"; exit 1; }
+ psql $DATABASE_URL -f seeds/seed.sql
+ echo "Seeding complete."
- name: Register ACR credentials with Container App
run: |
From 1b63b62394f19b606d38a5bc3cb420c7641e04d6 Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Thu, 2 Apr 2026 19:20:37 +0200
Subject: [PATCH 141/535] feat(tracks): add track processing pipeline
scaffolding
---
openapi.yaml | 6 +
src/controllers/tracks.controller.js | 14 ++
src/models/track.model.js | 48 +++++
src/routes/tracks.routes.js | 1 +
src/services/storage.service.js | 59 ++++++
src/services/track-processing.service.js | 229 +++++++++++++++++++++++
src/services/tracks.service.js | 31 +++
7 files changed, 388 insertions(+)
create mode 100644 src/services/track-processing.service.js
diff --git a/openapi.yaml b/openapi.yaml
index 7d6dee0..724184e 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -2002,6 +2002,12 @@ paths:
schema:
type: string
format: uuid
+ - name: secret_token
+ in: query
+ required: false
+ description: Required only to access a private track when the requester is not the owner.
+ schema:
+ type: string
responses:
'200':
description: Waveform peak data returned successfully.
diff --git a/src/controllers/tracks.controller.js b/src/controllers/tracks.controller.js
index eb37a2a..16b1ddd 100644
--- a/src/controllers/tracks.controller.js
+++ b/src/controllers/tracks.controller.js
@@ -102,6 +102,19 @@ const getTrackStream = async (req, res) => {
});
};
+const getTrackWaveform = async (req, res) => {
+ const requesterUserId = req.user?.sub || req.user?.id || req.user?.user_id || null;
+ const { secret_token } = req.query || {};
+
+ const data = await tracksService.getTrackWaveform(
+ req.params.track_id,
+ requesterUserId,
+ secret_token || null
+ );
+
+ return success(res, data, 'Track waveform fetched successfully', 200);
+};
+
module.exports = {
uploadTrack,
getTrackById,
@@ -110,4 +123,5 @@ module.exports = {
deleteTrack,
updateTrack,
getTrackStream,
+ getTrackWaveform,
};
diff --git a/src/models/track.model.js b/src/models/track.model.js
index e0d1437..10b9a13 100644
--- a/src/models/track.model.js
+++ b/src/models/track.model.js
@@ -407,6 +407,52 @@ const replaceTrackTags = async (trackId, tagIds) => {
}
};
+const updateTrackProcessingAssets = async (
+ trackId,
+ { duration, bitrate, streamUrl, previewUrl, waveformUrl }
+) => {
+ const query = `
+ UPDATE tracks
+ SET
+ duration = $2,
+ bitrate = $3,
+ stream_url = $4,
+ preview_url = $5,
+ waveform_url = $6,
+ status = 'ready',
+ updated_at = NOW()
+ WHERE id = $1
+ AND deleted_at IS NULL
+ RETURNING id, status, duration, bitrate, stream_url, preview_url, waveform_url
+ `;
+
+ const { rows } = await db.query(query, [
+ trackId,
+ duration,
+ bitrate,
+ streamUrl,
+ previewUrl,
+ waveformUrl,
+ ]);
+
+ return rows[0] || null;
+};
+
+const markTrackProcessingFailed = async (trackId) => {
+ const query = `
+ UPDATE tracks
+ SET
+ status = 'failed',
+ updated_at = NOW()
+ WHERE id = $1
+ AND deleted_at IS NULL
+ RETURNING id, status
+ `;
+
+ const { rows } = await db.query(query, [trackId]);
+ return rows[0] || null;
+};
+
module.exports = {
createTrack,
addTrackTags,
@@ -421,4 +467,6 @@ module.exports = {
deleteTrackPermanently,
updateTrackFields,
replaceTrackTags,
+ updateTrackProcessingAssets,
+ markTrackProcessingFailed,
};
diff --git a/src/routes/tracks.routes.js b/src/routes/tracks.routes.js
index 27d8754..f02697d 100644
--- a/src/routes/tracks.routes.js
+++ b/src/routes/tracks.routes.js
@@ -32,5 +32,6 @@ router.patch(
asyncHandler(controller.updateTrack)
);
router.get('/:track_id/stream', optionalAuthenticate, asyncHandler(controller.getTrackStream));
+router.get('/:track_id/waveform', optionalAuthenticate, asyncHandler(controller.getTrackWaveform));
module.exports = router;
diff --git a/src/services/storage.service.js b/src/services/storage.service.js
index 5d52614..b8de92f 100644
--- a/src/services/storage.service.js
+++ b/src/services/storage.service.js
@@ -114,6 +114,61 @@ const deleteManyByUrls = async (urls = []) => {
}
};
+// convert readable stream to one complete file object (buffer)
+const streamToBuffer = async (readable) => {
+ const chunks = [];
+
+ for await (const chunk of readable) {
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ }
+
+ return Buffer.concat(chunks);
+};
+
+// download file from blob and return as buffer
+const downloadBlobToBuffer = async (fileUrl) => {
+ const parsed = parseAzureBlobUrl(fileUrl);
+ if (!parsed) {
+ throw new Error(`Invalid Azure blob URL: ${fileUrl}`);
+ }
+
+ const containerClient = getContainerClient(parsed.containerName);
+ const blockBlobClient = containerClient.getBlockBlobClient(parsed.blobName);
+
+ const response = await blockBlobClient.download();
+ return streamToBuffer(response.readableStreamBody);
+};
+
+// upload user files after being processed
+const uploadBuffer = async (buffer, key, type, contentType) => {
+ const containerClient = getContainerClient(type);
+ await containerClient.createIfNotExists();
+
+ const blockBlobClient = containerClient.getBlockBlobClient(key);
+
+ await blockBlobClient.uploadData(buffer, {
+ blobHTTPHeaders: {
+ blobContentType: contentType,
+ },
+ });
+
+ return {
+ key,
+ url: blockBlobClient.url,
+ versionId: null,
+ };
+};
+
+const uploadGeneratedAudio = async (buffer, key, contentType = 'audio/mpeg') => {
+ return uploadBuffer(buffer, key, 'audio', contentType);
+};
+
+// for uploading waveform
+const uploadJson = async (data, key) => {
+ const body = Buffer.from(JSON.stringify(data), 'utf8');
+ return uploadBuffer(body, key, 'media', 'application/json');
+};
+
module.exports = {
blobServiceClient,
initBlobContainers,
@@ -123,4 +178,8 @@ module.exports = {
deleteObject,
deleteAllVersionsByUrl,
deleteManyByUrls,
+ streamToBuffer,
+ downloadBlobToBuffer,
+ uploadGeneratedAudio,
+ uploadJson,
};
diff --git a/src/services/track-processing.service.js b/src/services/track-processing.service.js
new file mode 100644
index 0000000..8ecfddc
--- /dev/null
+++ b/src/services/track-processing.service.js
@@ -0,0 +1,229 @@
+// ============================================================
+// services/track-processing.service.js
+// Real audio post-upload processing
+// - reads uploaded audio
+// - extracts duration + bitrate
+// - generates 30s preview
+// - generates waveform JSON
+// - marks track ready / failed
+// ============================================================
+
+const { spawn } = require('child_process');
+const fs = require('fs/promises');
+const os = require('os');
+const path = require('path');
+
+const tracksModel = require('../models/track.model');
+const storageService = require('./storage.service');
+
+const FFMPEG_BIN = 'ffmpeg';
+const FFPROBE_BIN = 'ffprobe';
+
+const PREVIEW_SECONDS = 30;
+const WAVEFORM_SAMPLES = 200;
+const PCM_SAMPLE_RATE = 8000;
+
+const runCommand = (bin, args) =>
+ new Promise((resolve, reject) => {
+ const child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
+
+ let stdout = '';
+ let stderr = '';
+
+ child.stdout.on('data', (chunk) => {
+ stdout += chunk.toString();
+ });
+
+ child.stderr.on('data', (chunk) => {
+ stderr += chunk.toString();
+ });
+
+ child.on('error', (err) => {
+ reject(err);
+ });
+
+ child.on('close', (code) => {
+ if (code !== 0) {
+ return reject(
+ new Error(`${bin} exited with code ${code}\n${stderr || stdout}`.trim())
+ );
+ }
+
+ resolve({ stdout, stderr });
+ });
+ });
+
+// runs ffprobe in terminal to get duration and bitrate of the audio file
+const getAudioMetadata = async (inputPath) => {
+ const { stdout } = await runCommand(FFPROBE_BIN, [
+ '-v',
+ 'error',
+ '-print_format',
+ 'json',
+ '-show_format',
+ '-show_streams',
+ inputPath,
+ ]);
+
+ const parsed = JSON.parse(stdout);
+ const audioStream = (parsed.streams || []).find((stream) => stream.codec_type === 'audio');
+ const format = parsed.format || {};
+
+ const durationSeconds = Math.round(
+ Number(format.duration || audioStream?.duration || 0)
+ );
+
+ const bitrateBps = Number(format.bit_rate || audioStream?.bit_rate || 0);
+ const bitrateKbps = bitrateBps > 0 ? Math.round(bitrateBps / 1000) : null;
+
+ return {
+ duration: Number.isFinite(durationSeconds) ? durationSeconds : 0,
+ bitrate: bitrateKbps,
+ };
+};
+
+// keeps first 30 seconds of the track for preview, converts to mp3 at 128kbps
+const generatePreviewFile = async (inputPath, outputPath) => {
+ await runCommand(FFMPEG_BIN, [
+ '-y',
+ '-i',
+ inputPath,
+ '-t',
+ String(PREVIEW_SECONDS),
+ '-vn',
+ '-acodec',
+ 'mp3',
+ '-b:a',
+ '128k',
+ outputPath,
+ ]);
+};
+
+// converts original audio to raw PCM mono file at 8kHz sample rate for waveform generation
+const exportPcmMonoFile = async (inputPath, outputPath) => {
+ await runCommand(FFMPEG_BIN, [
+ '-y',
+ '-i',
+ inputPath,
+ '-vn',
+ '-ac',
+ '1',
+ '-ar',
+ String(PCM_SAMPLE_RATE),
+ '-f',
+ 's16le',
+ outputPath,
+ ]);
+};
+
+// generates actual waveform data and normalizes to 0-1 range based on 16-bit PCM max value
+const buildWaveformFromPcm = (buffer, sampleCount = WAVEFORM_SAMPLES) => {
+ if (!buffer || buffer.length < 2) {
+ return Array.from({ length: sampleCount }, () => 0);
+ }
+
+ const sampleTotal = Math.floor(buffer.length / 2);
+
+ if (sampleTotal === 0) {
+ return Array.from({ length: sampleCount }, () => 0);
+ }
+
+ const bucketSize = Math.max(1, Math.floor(sampleTotal / sampleCount));
+ const waveform = [];
+
+ for (let bucket = 0; bucket < sampleCount; bucket += 1) {
+ const startSample = bucket * bucketSize;
+ const endSample =
+ bucket === sampleCount - 1
+ ? sampleTotal
+ : Math.min(sampleTotal, startSample + bucketSize);
+
+ let peak = 0;
+
+ for (let i = startSample; i < endSample; i += 1) {
+ const value = Math.abs(buffer.readInt16LE(i * 2));
+ if (value > peak) peak = value;
+ }
+
+ waveform.push(Number((peak / 32767).toFixed(3)));
+ }
+
+ return waveform;
+};
+
+// deletes temp files after processing is done or if any step fails
+const cleanupDirectory = async (dirPath) => {
+ if (!dirPath) return;
+ await fs.rm(dirPath, { recursive: true, force: true });
+};
+
+// main processing function - runs all steps and updates track record with new asset URLs and metadata
+const processTrackAssets = async ({ trackId, userId, audioUrl }) => {
+ let tempDir;
+
+ try {
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'rythmify-track-'));
+
+ const inputPath = path.join(tempDir, 'input-audio');
+ const previewPath = path.join(tempDir, 'preview.mp3');
+ const pcmPath = path.join(tempDir, 'waveform.pcm');
+
+ const originalAudioBuffer = await storageService.downloadBlobToBuffer(audioUrl);
+ await fs.writeFile(inputPath, originalAudioBuffer);
+
+ const { duration, bitrate } = await getAudioMetadata(inputPath);
+
+ await generatePreviewFile(inputPath, previewPath);
+ await exportPcmMonoFile(inputPath, pcmPath);
+
+ const previewBuffer = await fs.readFile(previewPath);
+ const pcmBuffer = await fs.readFile(pcmPath);
+
+ const waveform = buildWaveformFromPcm(pcmBuffer, WAVEFORM_SAMPLES);
+
+ const previewUpload = await storageService.uploadGeneratedAudio(
+ previewBuffer,
+ `tracks/${userId}/${trackId}/preview.mp3`,
+ 'audio/mpeg'
+ );
+
+ const waveformUpload = await storageService.uploadJson(
+ waveform,
+ `tracks/${userId}/${trackId}/waveform.json`
+ );
+
+ const updatedTrack = await tracksModel.updateTrackProcessingAssets(trackId, {
+ duration,
+ bitrate,
+ streamUrl: audioUrl, // keep original as stream for now
+ previewUrl: previewUpload.url,
+ waveformUrl: waveformUpload.url,
+ });
+
+ return {
+ trackId,
+ status: updatedTrack?.status || 'ready',
+ duration,
+ bitrate,
+ stream_url: audioUrl,
+ preview_url: previewUpload.url,
+ waveform_url: waveformUpload.url,
+ };
+ } catch (err) {
+ await tracksModel.markTrackProcessingFailed(trackId);
+ throw err;
+ } finally {
+ await cleanupDirectory(tempDir);
+ }
+};
+
+const processTrackInBackground = ({ trackId, userId, audioUrl }) => {
+ void processTrackAssets({ trackId, userId, audioUrl }).catch((err) => {
+ console.error(`[TRACK_PROCESSING_FAILED] track=${trackId}`, err.message);
+ });
+};
+
+module.exports = {
+ processTrackAssets,
+ processTrackInBackground,
+};
\ No newline at end of file
diff --git a/src/services/tracks.service.js b/src/services/tracks.service.js
index 24d064c..692552b 100644
--- a/src/services/tracks.service.js
+++ b/src/services/tracks.service.js
@@ -9,6 +9,7 @@ const AppError = require('../utils/app-error.js');
const tracksModel = require('../models/track.model.js');
const tagModel = require('../models/tag.model.js');
const storageService = require('./storage.service.js');
+const { processTrackInBackground } = require('./track-processing.service');
const crypto = require('crypto');
const GEO_RESTRICTION_TYPES = ['worldwide', 'exclusive_regions', 'blocked_regions'];
@@ -307,6 +308,12 @@ const uploadTrack = async ({ user, audioFile, coverImageFile, body }) => {
};
const createdTrack = await tracksModel.createTrack(trackData);
+
+ processTrackInBackground({
+ trackId: createdTrack.id,
+ userId,
+ audioUrl: createdTrack.audio_url,
+ });
if (tagIds.length) {
await tracksModel.addTrackTags(createdTrack.id, tagIds);
@@ -639,10 +646,34 @@ const getTrackStream = async (trackId, requesterUserId = null, secretToken = nul
};
};
+const { generateMockWaveform } = require('../utils/waveform');
+
+const getTrackWaveform = async (trackId, requesterUserId = null, secretToken = null) => {
+ const track = await getTrackById(trackId, requesterUserId, secretToken);
+
+ if (track.status === 'processing') {
+ throw new AppError(
+ 'Waveform is not yet available. Track is still processing.',
+ 202,
+ 'BUSINESS_OPERATION_NOT_ALLOWED'
+ );
+ }
+
+ if (track.status === 'failed') {
+ throw new AppError('Track processing failed', 503, 'UPLOAD_PROCESSING_FAILED');
+ }
+
+ return {
+ track_id: track.id,
+ waveform: generateMockWaveform(200),
+ };
+};
+
module.exports = {
uploadTrack,
getTrackById,
updateTrackVisibility,
+ getTrackWaveform,
getMyTracks,
deleteTrack,
updateTrack,
From 0e38eb3be378dfed836dabee59fcad703b9c55c9 Mon Sep 17 00:00:00 2001
From: Omar Hamdy
Date: Thu, 2 Apr 2026 21:16:57 +0200
Subject: [PATCH 142/535] fix(tracks): replaced normal errors with AppError
---
src/services/track-processing.service.js | 46 +++++++++++++++++-------
src/services/tracks.service.js | 17 +++++----
2 files changed, 42 insertions(+), 21 deletions(-)
diff --git a/src/services/track-processing.service.js b/src/services/track-processing.service.js
index 8ecfddc..c31f252 100644
--- a/src/services/track-processing.service.js
+++ b/src/services/track-processing.service.js
@@ -13,6 +13,7 @@ const fs = require('fs/promises');
const os = require('os');
const path = require('path');
+const AppError = require('../utils/app-error');
const tracksModel = require('../models/track.model');
const storageService = require('./storage.service');
@@ -39,13 +40,23 @@ const runCommand = (bin, args) =>
});
child.on('error', (err) => {
- reject(err);
+ reject(
+ new AppError(
+ `Failed to start ${bin}: ${err.message}`,
+ 500,
+ 'TRACK_PROCESSING_COMMAND_FAILED'
+ )
+ );
});
child.on('close', (code) => {
if (code !== 0) {
return reject(
- new Error(`${bin} exited with code ${code}\n${stderr || stdout}`.trim())
+ new AppError(
+ `${bin} exited with code ${code}\n${stderr || stdout}`.trim(),
+ 500,
+ 'TRACK_PROCESSING_COMMAND_FAILED'
+ )
);
}
@@ -65,13 +76,21 @@ const getAudioMetadata = async (inputPath) => {
inputPath,
]);
- const parsed = JSON.parse(stdout);
+ let parsed;
+ try {
+ parsed = JSON.parse(stdout);
+ } catch {
+ throw new AppError(
+ 'Failed to parse ffprobe output',
+ 500,
+ 'TRACK_PROCESSING_METADATA_PARSE_FAILED'
+ );
+ }
+
const audioStream = (parsed.streams || []).find((stream) => stream.codec_type === 'audio');
const format = parsed.format || {};
- const durationSeconds = Math.round(
- Number(format.duration || audioStream?.duration || 0)
- );
+ const durationSeconds = Math.round(Number(format.duration || audioStream?.duration || 0));
const bitrateBps = Number(format.bit_rate || audioStream?.bit_rate || 0);
const bitrateKbps = bitrateBps > 0 ? Math.round(bitrateBps / 1000) : null;
@@ -134,9 +153,7 @@ const buildWaveformFromPcm = (buffer, sampleCount = WAVEFORM_SAMPLES) => {
for (let bucket = 0; bucket < sampleCount; bucket += 1) {
const startSample = bucket * bucketSize;
const endSample =
- bucket === sampleCount - 1
- ? sampleTotal
- : Math.min(sampleTotal, startSample + bucketSize);
+ bucket === sampleCount - 1 ? sampleTotal : Math.min(sampleTotal, startSample + bucketSize);
let peak = 0;
@@ -195,7 +212,7 @@ const processTrackAssets = async ({ trackId, userId, audioUrl }) => {
const updatedTrack = await tracksModel.updateTrackProcessingAssets(trackId, {
duration,
bitrate,
- streamUrl: audioUrl, // keep original as stream for now
+ streamUrl: audioUrl,
previewUrl: previewUpload.url,
waveformUrl: waveformUpload.url,
});
@@ -211,7 +228,12 @@ const processTrackAssets = async ({ trackId, userId, audioUrl }) => {
};
} catch (err) {
await tracksModel.markTrackProcessingFailed(trackId);
- throw err;
+
+ if (err instanceof AppError) {
+ throw err;
+ }
+
+ throw new AppError(err.message || 'Track processing failed', 500, 'TRACK_PROCESSING_FAILED');
} finally {
await cleanupDirectory(tempDir);
}
@@ -226,4 +248,4 @@ const processTrackInBackground = ({ trackId, userId, audioUrl }) => {
module.exports = {
processTrackAssets,
processTrackInBackground,
-};
\ No newline at end of file
+};
diff --git a/src/services/tracks.service.js b/src/services/tracks.service.js
index 692552b..ec6a209 100644
--- a/src/services/tracks.service.js
+++ b/src/services/tracks.service.js
@@ -621,14 +621,13 @@ const updateTrack = async ({ trackId, userId, payload, coverImageFile }) => {
const getTrackStream = async (trackId, requesterUserId = null, secretToken = null) => {
const track = await getTrackById(trackId, requesterUserId, secretToken);
- // uncomment when handled
- // if (track.status === 'processing') {
- // throw new AppError(
- // 'Track is still processing. Please retry shortly.',
- // 202,
- // 'BUSINESS_OPERATION_NOT_ALLOWED'
- // );
- // }
+ if (track.status === 'processing') {
+ throw new AppError(
+ 'Track is still processing. Please retry shortly.',
+ 202,
+ 'BUSINESS_OPERATION_NOT_ALLOWED'
+ );
+ }
if (track.status === 'failed') {
throw new AppError('Track processing failed', 503, 'UPLOAD_PROCESSING_FAILED');
@@ -665,7 +664,7 @@ const getTrackWaveform = async (trackId, requesterUserId = null, secretToken = n
return {
track_id: track.id,
- waveform: generateMockWaveform(200),
+ peaks: generateMockWaveform(200),
};
};
From 0603d1fbdd39312a6d6ad6289e29418eaab0a3f8 Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Thu, 2 Apr 2026 21:46:44 +0200
Subject: [PATCH 143/535] feat(playlists): add POST /playlists
Logic: Implemented POST /playlists with a minimalist approach (Name & Privacy only).
Security: Added automatic secret_token generation (UUID v4) for private sets.
Docs: Updated openapi.yaml to match the request body and response schema.
---
openapi.yaml | 64 +++++++++++++++++++
src/controllers/playlists.controller.js | 60 +++++++++++++----
src/models/playlist.model.js | 43 +++++++++++--
src/routes/playlists.routes.js | 18 ++++--
src/services/playlists.service.js | 85 +++++++++++++++++++++++--
5 files changed, 241 insertions(+), 29 deletions(-)
diff --git a/openapi.yaml b/openapi.yaml
index 7d6dee0..0615c7e 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -2459,6 +2459,9 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/CreatePlaylistRequest'
+ multipart/form-data:
+ schema:
+ $ref: '#/components/schemas/CreatePlaylistMultipartRequest'
responses:
'201':
@@ -2474,9 +2477,15 @@ paths:
name: 'Gym Mix'
description: 'High energy tracks'
is_public: true
+ cover_image: 'https://cdn.rythmify.com/playlists/8d5a8f6c-7b4a-4c7a-9c25-9a9f1e3a12aa/cover.jpg'
+ subtype: 'playlist'
+ release_date: '2026-03-06'
+ genre_id: 6c6d6f9f-2e4a-4c0e-84e6-1738a29edb8d
track_count: 0
like_count: 0
+ repost_count: 0
created_at: '2026-03-06T10:00:00Z'
+ updated_at: '2026-03-06T10:00:00Z'
message: 'Playlist created successfully.'
'400':
@@ -8650,6 +8659,34 @@ components:
- false: secret/private playlist (shareable via secret link/token)
default: true
example: true
+ release_date:
+ type: string
+ format: date
+ nullable: true
+ description: Optional release date for music-set style playlists.
+ example: '2026-03-06'
+ genre_id:
+ type: string
+ format: uuid
+ nullable: true
+ description: Optional genre identifier.
+ example: 6c6d6f9f-2e4a-4c0e-84e6-1738a29edb8d
+ subtype:
+ type: string
+ description: Optional playlist subtype.
+ enum: [playlist, album, ep, single, compilation]
+ default: playlist
+ example: playlist
+
+ CreatePlaylistMultipartRequest:
+ allOf:
+ - $ref: '#/components/schemas/CreatePlaylistRequest'
+ - type: object
+ properties:
+ cover_image:
+ type: string
+ format: binary
+ description: Optional playlist cover image file.
PlaylistOwnerSummary:
type: object
@@ -8682,13 +8719,36 @@ components:
type: string
nullable: true
example: High energy tracks for workouts
+ cover_image:
+ type: string
+ nullable: true
+ description: Public URL for the uploaded cover image.
+ example: https://cdn.rythmify.com/playlists/8d5a8f6c-7b4a-4c7a-9c25-9a9f1e3a12aa/cover.jpg
+ subtype:
+ type: string
+ enum: [playlist, album, ep, single, compilation]
+ example: playlist
is_public:
type: boolean
example: true
+ release_date:
+ type: string
+ format: date
+ nullable: true
+ example: 2026-03-06
+ genre_id:
+ type: string
+ format: uuid
+ nullable: true
+ example: 6c6d6f9f-2e4a-4c0e-84e6-1738a29edb8d
created_at:
type: string
format: date-time
example: 2026-03-06T10:00:00Z
+ updated_at:
+ type: string
+ format: date-time
+ example: 2026-03-06T10:00:00Z
track_count:
type: integer
description: Derived value (not stored).
@@ -8697,6 +8757,10 @@ components:
type: integer
description: Derived value (not stored).
example: 340
+ repost_count:
+ type: integer
+ description: Derived value (not stored).
+ example: 20
required: [playlist_id, owner_user_id, name, is_public, created_at]
PlaylistTrackItem:
diff --git a/src/controllers/playlists.controller.js b/src/controllers/playlists.controller.js
index 8edae19..528f640 100644
--- a/src/controllers/playlists.controller.js
+++ b/src/controllers/playlists.controller.js
@@ -1,14 +1,52 @@
+const service = require('../services/playlists.service');
+const { success, error } = require('../utils/api-response');
+
+/**
+ * Helper to extract the authenticated user ID from the request.
+ * Matches the format used in messages.controller.js
+ */
+const getAuthenticatedUserId = (req, res) => {
+ const userId = req?.user?.sub;
+ if (!userId) {
+ error(res, 'UNAUTHORIZED', 'Authentication required.', 401);
+ return null;
+ }
+ return userId;
+};
+
// ============================================================
-// controllers/playlists.controller.js
-// Owner : Alyaa Mohamed (BE-4)
-// Receives validated requests → calls service → returns HTTP response
+// ENDPOINT 1 — POST /playlists
// ============================================================
-const playlistsService = require('../services/playlists.service');
-const { success, error } = require('../utils/api-response');
+exports.createPlaylist = async (req, res) => {
+ const userId = getAuthenticatedUserId(req, res);
+ if (!userId) return;
+
+ const { name, is_public } = req.body; // ONLY name and privacy
+
+ if (!name) {
+ return error(res, 'VALIDATION_FAILED', 'Playlist name is required.', 400);
+ }
+
+ if (!name || name.trim().length === 0) {
+ return error(res, 'VALIDATION_FAILED', 'Playlist name cannot be empty or just spaces.', 400);
+ }
+
+ // Handle privacy (defaulting to true/public if not provided)
+ const isPublicNormalized = is_public !== undefined
+ ? (is_public === true || is_public === 'true')
+ : true;
+
+ const data = await service.createPlaylist({
+ userId,
+ name,
+ isPublic: isPublicNormalized
+ });
-// TODO: Implement controller methods
-// Example:
-// exports.getAll = async (req, res) => {
-// const data = await playlistsService.getAll(req.query);
-// return success(res, data);
-// };
+ return success(res, data.playlist, 'Playlist created successfully.', 201);
+};
+// } catch (err) {
+// // If your service throws a specific AppError, the global error handler picks it up.
+// // If not, we handle unexpected errors here.
+// return error(res, err.code || 'INTERNAL_ERROR', err.message, err.statusCode || 500);
+// }
+// };
\ No newline at end of file
diff --git a/src/models/playlist.model.js b/src/models/playlist.model.js
index f9c473e..79cba43 100644
--- a/src/models/playlist.model.js
+++ b/src/models/playlist.model.js
@@ -5,9 +5,40 @@
// ============================================================
const db = require('../config/db');
-// TODO: Implement query methods
-// Example:
-// exports.findById = async (id) => {
-// const { rows } = await db.query('SELECT * FROM playlists WHERE id = $1', [id]);
-// return rows[0] || null;
-// };
+const BASE_SELECT = ` // Base SELECT query for playlists, used in multiple methods
+ SELECT
+ p.id AS playlist_id,
+ p.user_id AS owner_user_id,
+ p.name,
+ p.description,
+ p.cover_image,
+ p.type,
+ p.subtype,
+ p.is_public,
+ p.secret_token,
+ p.release_date,
+ p.genre_id,
+ p.like_count,
+ p.repost_count,
+ p.track_count,
+ p.created_at,
+ p.updated_at
+ FROM playlists p
+ WHERE p.deleted_at IS NULL
+`;
+
+// ============================================================
+// ENDPOINT 1 — POST /playlists (Create playlist)
+// ============================================================
+
+// Endpoint 1 — POST /playlists
+exports.create = async ({ userId, name, isPublic, secretToken, subtype }) => {
+ const { rows } = await db.query(
+ `INSERT INTO playlists
+ (user_id, name, is_public, secret_token, subtype, type)
+ VALUES ($1, $2, $3, $4, $5, 'regular')
+ RETURNING *`,
+ [userId, name, isPublic, secretToken, subtype]
+ );
+ return rows[0];
+};
diff --git a/src/routes/playlists.routes.js b/src/routes/playlists.routes.js
index d92f5ba..cd02551 100644
--- a/src/routes/playlists.routes.js
+++ b/src/routes/playlists.routes.js
@@ -3,14 +3,18 @@
// Owner : Alyaa Mohamed (BE-4)
// Modules: Module 7 — Playlists & Sets
// ============================================================
-const express = require('express');
-const router = express.Router();
-const controller = require('../controllers/playlists.controller');
-const { authenticate } = require('../middleware/auth');
+const express = require('express');
+const router = express.Router();
+const controller = require('../controllers/playlists.controller');
+const { authenticate, optionalAuthenticate } = require('../middleware/auth');
const asyncHandler = require('../utils/async-handler');
+const { uploadImage } = require('../middleware/multer');
-// TODO: Add route definitions here
-// Example:
-// router.get('/', authenticate, asyncHandler(controller.getAll));
+router.post(
+ '/',
+ authenticate,
+ uploadImage.single('cover_image'),
+ asyncHandler(controller.createPlaylist)
+);
module.exports = router;
diff --git a/src/services/playlists.service.js b/src/services/playlists.service.js
index 8a11f4d..48bc8ce 100644
--- a/src/services/playlists.service.js
+++ b/src/services/playlists.service.js
@@ -5,8 +5,83 @@
// No direct SQL here — delegate to models/
// ============================================================
-// TODO: Implement service methods
-// Example:
-// exports.getAll = async (query) => {
-// return await playlistsModel.findAll(query);
-// };
+const playlistModel = require('../models/playlist.model');
+const storageService = require('./storage.service');
+const AppError = require('../utils/app-error');
+const crypto = require('crypto');
+
+const VALID_SUBTYPES = ['playlist', 'album', 'ep', 'single', 'compilation'];
+
+// ── Helpers ──────────────────────────────────────────────────
+
+const generateSecretToken = () => crypto.randomBytes(24).toString('hex');
+
+const checkOwner = (playlist, userId) => {
+ if (playlist.owner_user_id !== userId) {
+ throw new AppError(
+ 'You are not allowed to modify this playlist.',
+ 403,
+ 'PLAYLIST_FORBIDDEN'
+ );
+ }
+};
+
+const checkAccess = (playlist, userId, secretToken) => {
+ if (!playlist.is_public) {
+ const isOwner = userId && playlist.owner_user_id === userId;
+ const hasToken = secretToken && playlist.secret_token === secretToken;
+ if (!isOwner && !hasToken) {
+ throw new AppError(
+ 'You do not have access to this playlist.',
+ 403,
+ 'PLAYLIST_ACCESS_DENIED'
+ );
+ }
+ }
+};
+
+const formatPlaylist = (p) => ({
+ playlist_id: p.playlist_id || p.id,
+ owner_user_id: p.owner_user_id || p.user_id,
+ name: p.name,
+ description: p.description,
+ cover_image: p.cover_image,
+ subtype: p.subtype,
+ is_public: p.is_public,
+ release_date: p.release_date,
+ genre_id: p.genre_id,
+ like_count: p.like_count,
+ repost_count: p.repost_count,
+ track_count: p.track_count,
+ created_at: p.created_at,
+ updated_at: p.updated_at,
+});
+
+// ── Upload helper (used by create & update) ───────────────────
+
+const uploadCoverImage = async (file, playlistId) => {
+ const ext = file.originalname.split('.').pop();
+ const key = `playlists/${playlistId}/cover.${ext}`;
+ const result = await storageService.uploadImage(file, key);
+ return result.url;
+};
+
+// ============================================================
+// ENDPOINT 1 — POST /playlists
+// ============================================================
+// Endpoint 1 — POST /playlists
+exports.createPlaylist = async ({ userId, name, isPublic }) => {
+ // 1. Logic: Private playlists get a secret sharing token automatically
+ const secretToken = isPublic === false ? generateSecretToken() : null;
+
+ // 2. Insert minimal record
+ const playlist = await playlistModel.create({
+ userId,
+ name,
+ isPublic,
+ secretToken,
+ subtype: 'playlist' // Defaulting to 'playlist' at creation
+ });
+
+ return { playlist: formatPlaylist(playlist) };
+};
From b6807fc07192a31c43010791f619f35a0c8f237a Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Thu, 2 Apr 2026 23:40:45 +0200
Subject: [PATCH 144/535] feat(playlist): add GET /playlists with library and
subtype filtering
- Updated GET /playlists to support library tabs: 'Created' vs 'Liked'.
- Implemented 'is_album_view' logic to separate standard playlists from Albums/EPs/Singles.
- Added 'subtype' filtering to all listing methods (Public, My Playlists, and Liked).
- Optimized SQL queries with a 'buildTypeFilter' helper and BASE_SELECT for consistency.
- Updated OpenAPI documentation to reflect new query parameters: 'filter', 'subtype', and 'is_album_view'.
---
openapi.yaml | 69 ++++-----
src/controllers/playlists.controller.js | 28 +++-
src/models/playlist.model.js | 198 ++++++++++++++++++++++--
src/routes/playlists.routes.js | 2 +
src/services/playlists.service.js | 74 ++++++++-
5 files changed, 311 insertions(+), 60 deletions(-)
diff --git a/openapi.yaml b/openapi.yaml
index 0615c7e..4f352a6 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -2459,9 +2459,6 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/CreatePlaylistRequest'
- multipart/form-data:
- schema:
- $ref: '#/components/schemas/CreatePlaylistMultipartRequest'
responses:
'201':
@@ -2516,7 +2513,12 @@ paths:
By default, this returns **public** playlists.
- To list the authenticated user's playlists, set `mine=true` and include Bearer auth.
+ To list the authenticated user's library, set `mine=true` and include Bearer auth.
+
+ Filters:
+ - `filter=created|liked` applies only when `mine=true`.
+ - `is_album_view=true` returns non-regular playlists (all subtypes except `playlist`).
+ - `subtype` filters by one subtype when `is_album_view` is not used.
operationId: listPlaylists
security: [] # public by default; auth required only when mine=true
@@ -2529,13 +2531,14 @@ paths:
type: boolean
default: false
- - name: owner_user_id
+ - name: filter
in: query
required: false
- description: Filter playlists by owner user ID (public only unless mine=true and requester is owner).
+ description: Library filter for authenticated requests (`mine=true`). Defaults to `created` when omitted.
schema:
type: string
- format: uuid
+ enum: [created, liked]
+ default: created
- name: q
in: query
@@ -2545,10 +2548,26 @@ paths:
type: string
maxLength: 100
+ - name: subtype
+ in: query
+ required: false
+ description: Filter by playlist subtype. Ignored when `is_album_view=true`.
+ schema:
+ type: string
+ enum: [playlist, album, ep, single, compilation]
+
+ - name: is_album_view
+ in: query
+ required: false
+ description: If true, returns all non-playlist subtypes (album/ep/single/compilation).
+ schema:
+ type: boolean
+ default: false
+
- name: limit
in: query
required: false
- description: Page size.
+ description: Page size (server-clamped to 1..50).
schema:
type: integer
minimum: 1
@@ -8645,12 +8664,6 @@ components:
minLength: 1
maxLength: 100
example: 'Gym Mix'
- description:
- type: string
- description: Optional playlist description.
- maxLength: 500
- nullable: true
- example: 'High energy tracks'
is_public:
type: boolean
description: |
@@ -8659,34 +8672,6 @@ components:
- false: secret/private playlist (shareable via secret link/token)
default: true
example: true
- release_date:
- type: string
- format: date
- nullable: true
- description: Optional release date for music-set style playlists.
- example: '2026-03-06'
- genre_id:
- type: string
- format: uuid
- nullable: true
- description: Optional genre identifier.
- example: 6c6d6f9f-2e4a-4c0e-84e6-1738a29edb8d
- subtype:
- type: string
- description: Optional playlist subtype.
- enum: [playlist, album, ep, single, compilation]
- default: playlist
- example: playlist
-
- CreatePlaylistMultipartRequest:
- allOf:
- - $ref: '#/components/schemas/CreatePlaylistRequest'
- - type: object
- properties:
- cover_image:
- type: string
- format: binary
- description: Optional playlist cover image file.
PlaylistOwnerSummary:
type: object
diff --git a/src/controllers/playlists.controller.js b/src/controllers/playlists.controller.js
index 528f640..1428a5b 100644
--- a/src/controllers/playlists.controller.js
+++ b/src/controllers/playlists.controller.js
@@ -44,9 +44,25 @@ exports.createPlaylist = async (req, res) => {
return success(res, data.playlist, 'Playlist created successfully.', 201);
};
-// } catch (err) {
-// // If your service throws a specific AppError, the global error handler picks it up.
-// // If not, we handle unexpected errors here.
-// return error(res, err.code || 'INTERNAL_ERROR', err.message, err.statusCode || 500);
-// }
-// };
\ No newline at end of file
+
+// ============================================================
+// ENDPOINT 2 — GET /playlists
+// ============================================================
+exports.listPlaylists = async (req, res) => {
+ const userId = req.user?.sub ?? null;
+ // Make sure these two are included in the destructuring:
+ const { mine, filter, q, limit, offset, subtype, is_album_view } = req.query;
+
+ const data = await service.listPlaylists({
+ requesterId: userId,
+ mine: mine === 'true',
+ filter,
+ isAlbumView: is_album_view === 'true', // Convert string "true" to boolean
+ subtype,
+ q,
+ limit,
+ offset,
+ });
+
+ return success(res, data, 'Playlists fetched successfully.');
+};
\ No newline at end of file
diff --git a/src/models/playlist.model.js b/src/models/playlist.model.js
index 79cba43..01aeae6 100644
--- a/src/models/playlist.model.js
+++ b/src/models/playlist.model.js
@@ -1,11 +1,9 @@
-// ============================================================
-// models/playlist.model.js — PostgreSQL queries for Playlist
-// Entity attributes: Playlist_id, User_Id, Name, Description, Is_public, Secret_token, Track_count, Like_count, Created_at
-// All SQL lives HERE — no SQL outside models/
-// ============================================================
const db = require('../config/db');
-const BASE_SELECT = ` // Base SELECT query for playlists, used in multiple methods
+// ------------------------------------------------------------
+// Base SELECT for consistency
+// ------------------------------------------------------------
+const BASE_SELECT = `
SELECT
p.id AS playlist_id,
p.user_id AS owner_user_id,
@@ -24,14 +22,29 @@ const BASE_SELECT = ` // Base SELECT query for playlists, used
p.created_at,
p.updated_at
FROM playlists p
- WHERE p.deleted_at IS NULL
`;
-// ============================================================
-// ENDPOINT 1 — POST /playlists (Create playlist)
-// ============================================================
+// ------------------------------------------------------------
+// Helper: Build Type Filter
+// Handles the "All is under albums if type is not playlist" logic
+// ------------------------------------------------------------
+const buildTypeFilter = (subtype, isAlbumView, currentIdx) => {
+ let clause = "";
+ const params = [];
+ let idx = currentIdx;
-// Endpoint 1 — POST /playlists
+ if (isAlbumView) {
+ clause = ` AND p.subtype != 'playlist'`;
+ } else if (subtype) {
+ clause = ` AND p.subtype = $${idx++}`;
+ params.push(subtype);
+ }
+ return { clause, params, nextIdx: idx };
+};
+
+// ------------------------------------------------------------
+// Endpoint 1 — Create
+// ------------------------------------------------------------
exports.create = async ({ userId, name, isPublic, secretToken, subtype }) => {
const { rows } = await db.query(
`INSERT INTO playlists
@@ -42,3 +55,166 @@ exports.create = async ({ userId, name, isPublic, secretToken, subtype }) => {
);
return rows[0];
};
+
+// ------------------------------------------------------------
+// Endpoint 2 — List (Public)
+// ------------------------------------------------------------
+exports.findPublicPlaylists = async ({ ownerUserId, q, subtype, isAlbumView, limit, offset }) => {
+ let params = [];
+ let idx = 1;
+ let where = `WHERE p.is_public = true AND p.deleted_at IS NULL AND p.type = 'regular'`;
+
+ if (ownerUserId) {
+ where += ` AND p.user_id = $${idx++}`;
+ params.push(ownerUserId);
+ }
+
+ const typeFilter = buildTypeFilter(subtype, isAlbumView, idx);
+ where += typeFilter.clause;
+ params.push(...typeFilter.params);
+ idx = typeFilter.nextIdx;
+
+ if (q) {
+ where += ` AND p.name ILIKE $${idx++}`;
+ params.push(`%${q}%`);
+ }
+
+ params.push(limit, offset);
+ const { rows } = await db.query(
+ `${BASE_SELECT} ${where} ORDER BY p.created_at DESC LIMIT $${idx++} OFFSET $${idx}`,
+ params
+ );
+ return rows;
+};
+
+exports.countPublicPlaylists = async ({ ownerUserId, q, subtype, isAlbumView }) => {
+ let params = [];
+ let idx = 1;
+ let where = `WHERE p.is_public = true AND p.deleted_at IS NULL AND p.type = 'regular'`;
+
+ if (ownerUserId) {
+ where += ` AND p.user_id = $${idx++}`;
+ params.push(ownerUserId);
+ }
+
+ const typeFilter = buildTypeFilter(subtype, isAlbumView, idx);
+ where += typeFilter.clause;
+ params.push(...typeFilter.params);
+ idx = typeFilter.nextIdx;
+
+ if (q) {
+ where += ` AND p.name ILIKE $${idx++}`;
+ params.push(`%${q}%`);
+ }
+
+ const { rows } = await db.query(`SELECT COUNT(*) FROM playlists p ${where}`, params);
+ return rows[0].count;
+};
+
+// ------------------------------------------------------------
+// Endpoint 2 — List (My Playlists)
+// ------------------------------------------------------------
+exports.findMyPlaylists = async ({ userId, q, subtype, isAlbumView, limit, offset }) => {
+ let params = [userId];
+ let idx = 2;
+ let where = `WHERE p.user_id = $1 AND p.deleted_at IS NULL AND p.type = 'regular'`;
+
+ const typeFilter = buildTypeFilter(subtype, isAlbumView, idx);
+ where += typeFilter.clause;
+ params.push(...typeFilter.params);
+ idx = typeFilter.nextIdx;
+
+ if (q) {
+ where += ` AND p.name ILIKE $${idx++}`;
+ params.push(`%${q}%`);
+ }
+
+ params.push(limit, offset);
+ const { rows } = await db.query(
+ `${BASE_SELECT} ${where} ORDER BY p.created_at DESC LIMIT $${idx++} OFFSET $${idx}`,
+ params
+ );
+ return rows;
+};
+
+exports.countMyPlaylists = async ({ userId, q, subtype, isAlbumView }) => {
+ let params = [userId];
+ let idx = 2;
+ let where = `WHERE p.user_id = $1 AND p.deleted_at IS NULL AND p.type = 'regular'`;
+
+ const typeFilter = buildTypeFilter(subtype, isAlbumView, idx);
+ where += typeFilter.clause;
+ params.push(...typeFilter.params);
+ idx = typeFilter.nextIdx;
+
+ if (q) {
+ where += ` AND p.name ILIKE $${idx++}`;
+ params.push(`%${q}%`);
+ }
+
+ const { rows } = await db.query(`SELECT COUNT(*) FROM playlists p ${where}`, params);
+ return rows[0].count;
+};
+
+// ------------------------------------------------------------
+// Endpoint 2 — List (Liked)
+// ------------------------------------------------------------
+exports.findLikedPlaylists = async ({ userId, q, subtype, isAlbumView, limit, offset }) => {
+ let params = [userId]; // User who performed the LIKE action
+ let idx = 2;
+
+ // WHERE clause only cares about who liked it (pl.user_id)
+ let where = `WHERE pl.user_id = $1 AND p.deleted_at IS NULL AND p.type = 'regular'`;
+
+ // Apply Subtype/Album logic
+ const typeFilter = buildTypeFilter(subtype, isAlbumView, idx);
+ where += typeFilter.clause;
+ params.push(...typeFilter.params);
+ idx = typeFilter.nextIdx;
+
+ // Apply Search
+ if (q) {
+ where += ` AND p.name ILIKE $${idx++}`;
+ params.push(`%${q}%`);
+ }
+
+ params.push(limit, offset);
+
+ // BASE_SELECT refers to the shared column list we defined earlier
+ const query = `
+ SELECT p.*, pl.created_at AS liked_at
+ FROM playlists p
+ INNER JOIN playlist_likes pl ON p.id = pl.playlist_id
+ ${where}
+ ORDER BY pl.created_at DESC
+ LIMIT $${idx++} OFFSET $${idx}
+ `;
+
+ const { rows } = await db.query(query, params);
+ return rows;
+};
+
+exports.countLikedPlaylists = async ({ userId, q, subtype, isAlbumView }) => {
+ let params = [userId];
+ let idx = 2;
+ let where = `WHERE pl.user_id = $1 AND p.deleted_at IS NULL AND p.type = 'regular'`;
+
+ const typeFilter = buildTypeFilter(subtype, isAlbumView, idx);
+ where += typeFilter.clause;
+ params.push(...typeFilter.params);
+ idx = typeFilter.nextIdx;
+
+ if (q) {
+ where += ` AND p.name ILIKE $${idx++}`;
+ params.push(`%${q}%`);
+ }
+
+ const { rows } = await db.query(
+ `SELECT COUNT(*)::int AS total
+ FROM playlists p
+ INNER JOIN playlist_likes pl ON p.id = pl.playlist_id
+ ${where}`,
+ params
+ );
+ return rows[0].total;
+};
\ No newline at end of file
diff --git a/src/routes/playlists.routes.js b/src/routes/playlists.routes.js
index cd02551..d095f08 100644
--- a/src/routes/playlists.routes.js
+++ b/src/routes/playlists.routes.js
@@ -17,4 +17,6 @@ router.post(
asyncHandler(controller.createPlaylist)
);
+router.get('/', optionalAuthenticate, asyncHandler(controller.listPlaylists));
+
module.exports = router;
diff --git a/src/services/playlists.service.js b/src/services/playlists.service.js
index 48bc8ce..b4005e4 100644
--- a/src/services/playlists.service.js
+++ b/src/services/playlists.service.js
@@ -69,7 +69,6 @@ const uploadCoverImage = async (file, playlistId) => {
// ============================================================
// ENDPOINT 1 — POST /playlists
// ============================================================
-// Endpoint 1 — POST /playlists
exports.createPlaylist = async ({ userId, name, isPublic }) => {
// 1. Logic: Private playlists get a secret sharing token automatically
const secretToken = isPublic === false ? generateSecretToken() : null;
@@ -85,3 +84,76 @@ exports.createPlaylist = async ({ userId, name, isPublic }) => {
return { playlist: formatPlaylist(playlist) };
};
+
+// ============================================================
+// ENDPOINT 2 — GET /playlists
+// ============================================================
+
+/**
+ * GET /playlists
+ * Lists playlists based on filters:
+ * - mine=true & filter=created: Playlists the user owns.
+ * - mine=true & filter=liked: Playlists the user has hearted.
+ * - isAlbumView=true: Shows everything that is NOT a regular playlist (Albums, EPs).
+ * - public: Playlists available to everyone (optionally by ownerUserId).
+ */
+exports.listPlaylists = async ({
+ requesterId,
+ mine,
+ filter,
+ ownerUserId,
+ q,
+ subtype,
+ isAlbumView,
+ limit,
+ offset
+}) => {
+ const safeLimit = Math.min(50, Math.max(1, parseInt(limit) || 20));
+ const safeOffset = Math.max(0, parseInt(offset) || 0);
+
+ if (mine && !requesterId) {
+ throw new AppError('Authentication required to list your personal playlists.', 401, 'UNAUTHORIZED');
+ }
+
+ // Common filters for the Model
+ const filters = {
+ q,
+ subtype,
+ isAlbumView,
+ limit: safeLimit,
+ offset: safeOffset
+ };
+
+ let items, total;
+
+ if (mine) {
+ if (filter === 'liked') {
+ // 1. Liked: Show anything I hearted (ignores who the owner is)
+ [items, total] = await Promise.all([
+ playlistModel.findLikedPlaylists({ userId: requesterId, ...filters }),
+ playlistModel.countLikedPlaylists({ userId: requesterId, ...filters }),
+ ]);
+ } else {
+ // 2. Created: Show specifically what I own
+ [items, total] = await Promise.all([
+ playlistModel.findMyPlaylists({ userId: requesterId, ...filters }),
+ playlistModel.countMyPlaylists({ userId: requesterId, ...filters }),
+ ]);
+ }
+ } else {
+ // 3. Public Discovery (General search or specific Artist profile)
+ [items, total] = await Promise.all([
+ playlistModel.findPublicPlaylists({ ownerUserId, ...filters }),
+ playlistModel.countPublicPlaylists({ ownerUserId, ...filters }),
+ ]);
+ }
+
+ return {
+ items: items.map(p => formatPlaylist(p)),
+ meta: {
+ limit: safeLimit,
+ offset: safeOffset,
+ total: parseInt(total)
+ },
+ };
+};
\ No newline at end of file
From 681aedaf63358be1ce8ad223f33a8006c99463ed Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Fri, 3 Apr 2026 02:18:36 +0200
Subject: [PATCH 145/535] feat(playlist): add GET /playlists/:id
- Created Endpoint 3: GET /playlists/:playlist_id for detailed metadata view.
- Implemented "Smart Cover" logic: Automatically returns the oldest track's cover image if the playlist has no custom image.
- Added 'include_tracks' query parameter to toggle between lightweight metadata and full tracklist response.
- Integrated privacy guard: Access restricted to owners and guests with a valid 'secret_token'.
- Optimized performance with 'getTopTrackArt' model helper for fast cover fallback when tracks are not requested.
- Fixed SQL Error 42703: Synchronized code with database column 'tracks.cover_image' (removed 'cover_image_url' alias).
- Handled cloud storage mapping: Prepared service layer for AWS/Azure file key to URL conversion.
---
openapi.yaml | 31 +++++++++++++--
src/controllers/playlists.controller.js | 21 ++++++++++-
src/models/playlist.model.js | 49 ++++++++++++++++++++++++
src/routes/playlists.routes.js | 5 +++
src/services/playlists.service.js | 50 +++++++++++++++++++++++++
5 files changed, 152 insertions(+), 4 deletions(-)
diff --git a/openapi.yaml b/openapi.yaml
index 4f352a6..a58278b 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -2607,6 +2607,9 @@ paths:
description: |
Returns playlist metadata and its tracks in order.
+ `cover_image` is always included in the response.
+ If no custom playlist cover exists, the backend may fall back to the first track cover.
+
Access rules:
- Public playlists: accessible to anyone.
- Private (secret) playlists: accessible if either:
@@ -2636,7 +2639,7 @@ paths:
- name: include_tracks
in: query
required: false
- description: If false, returns only playlist metadata (no tracks array).
+ description: If false, returns only playlist metadata (without tracks array).
schema:
type: boolean
default: true
@@ -2648,6 +2651,28 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/PlaylistGetResponse'
+ example:
+ data:
+ playlist_id: 8d5a8f6c-7b4a-4c7a-9c25-9a9f1e3a12aa
+ owner_user_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
+ name: 'Gym Mix'
+ description: 'High energy tracks'
+ is_public: true
+ cover_image: 'https://cdn.rythmify.com/playlists/8d5a8f6c-7b4a-4c7a-9c25-9a9f1e3a12aa/cover.jpg'
+ subtype: 'playlist'
+ track_count: 2
+ like_count: 15
+ repost_count: 1
+ created_at: '2026-03-06T10:00:00Z'
+ updated_at: '2026-03-06T10:30:00Z'
+ tracks:
+ - track_id: e5f6a7b8-c9d0-1234-efab-567890abcdef
+ position: 1
+ added_at: '2026-03-06T10:01:00Z'
+ - track_id: 11111111-c9d0-1234-efab-567890abcdef
+ position: 2
+ added_at: '2026-03-06T10:03:00Z'
+ message: Playlist details fetched successfully.
'401':
$ref: '#/components/responses/Unauthorized'
@@ -2660,8 +2685,8 @@ paths:
$ref: '#/components/schemas/ErrorResponse'
example:
error:
- code: PLAYLIST_ACCESS_DENIED
- message: You do not have access to this playlist.
+ code: PRIVATE_PLAYLIST_ACCESS_DENIED
+ message: This playlist is private.
'404':
description: Playlist not found.
diff --git a/src/controllers/playlists.controller.js b/src/controllers/playlists.controller.js
index 1428a5b..91ede8e 100644
--- a/src/controllers/playlists.controller.js
+++ b/src/controllers/playlists.controller.js
@@ -57,7 +57,8 @@ exports.listPlaylists = async (req, res) => {
requesterId: userId,
mine: mine === 'true',
filter,
- isAlbumView: is_album_view === 'true', // Convert string "true" to boolean
+ ownerUserId: req.query.owner_user_id ?? null,
+ isAlbumView: is_album_view === 'true',
subtype,
q,
limit,
@@ -65,4 +66,22 @@ exports.listPlaylists = async (req, res) => {
});
return success(res, data, 'Playlists fetched successfully.');
+};
+
+// ============================================================
+// ENDPOINT 2 — GET /playlists/{playlist_id}
+// ============================================================
+exports.getPlaylist = async (req, res) => {
+ const userId = req.user?.sub ?? null; // ID from JWT if present
+ const { playlist_id } = req.params;
+ const { secret_token, include_tracks } = req.query; // For sharing private playlists via links
+
+ const data = await service.getPlaylist({
+ playlistId: playlist_id,
+ userId,
+ secretToken: secret_token,
+ includeTracks: include_tracks === undefined ? true : include_tracks === 'true',
+ });
+
+ return success(res, data, 'Playlist details fetched successfully.');
};
\ No newline at end of file
diff --git a/src/models/playlist.model.js b/src/models/playlist.model.js
index 01aeae6..50a8024 100644
--- a/src/models/playlist.model.js
+++ b/src/models/playlist.model.js
@@ -42,6 +42,19 @@ const buildTypeFilter = (subtype, isAlbumView, currentIdx) => {
return { clause, params, nextIdx: idx };
};
+exports.getTopTrackArt = async (playlistId) => {
+ const query = `
+ SELECT t.cover_image
+ FROM playlist_tracks pt
+ JOIN tracks t ON pt.track_id = t.id
+ WHERE pt.playlist_id = $1 AND t.deleted_at IS NULL
+ ORDER BY pt.position ASC
+ LIMIT 1
+ `;
+ const { rows } = await db.query(query, [playlistId]);
+ return rows[0] || null;
+};
+
// ------------------------------------------------------------
// Endpoint 1 — Create
// ------------------------------------------------------------
@@ -217,4 +230,40 @@ exports.countLikedPlaylists = async ({ userId, q, subtype, isAlbumView }) => {
params
);
return rows[0].total;
+};
+
+// ============================================================
+// ENDPOINT 2 — GET /playlists/{playlist_id}
+// ============================================================
+/**
+ * Finds a single playlist by ID using the standardized BASE_SELECT.
+ */
+exports.findPlaylistById = async (playlistId) => {
+ // Using the BASE_SELECT you defined earlier
+ const query = `${BASE_SELECT} WHERE p.id = $1 AND p.deleted_at IS NULL`;
+ const { rows } = await db.query(query, [playlistId]);
+ return rows[0] || null;
+};
+
+/**
+ * Fetches all tracks currently in the playlist with track metadata.
+ */
+exports.findPlaylistTracks = async (playlistId) => {
+ const query = `
+ SELECT
+ pt.track_id,
+ pt.position,
+ pt.added_at,
+ t.title,
+ t.duration,
+ t.cover_image,
+ u.username AS artist_name
+ FROM playlist_tracks pt
+ JOIN tracks t ON pt.track_id = t.id
+ JOIN users u ON t.user_id = u.id
+ WHERE pt.playlist_id = $1 AND t.deleted_at IS NULL
+ ORDER BY pt.position ASC
+ `;
+ const { rows } = await db.query(query, [playlistId]);
+ return rows;
};
\ No newline at end of file
diff --git a/src/routes/playlists.routes.js b/src/routes/playlists.routes.js
index d095f08..2a1e3b5 100644
--- a/src/routes/playlists.routes.js
+++ b/src/routes/playlists.routes.js
@@ -10,6 +10,7 @@ const { authenticate, optionalAuthenticate } = require('../middleware/auth');
const asyncHandler = require('../utils/async-handler');
const { uploadImage } = require('../middleware/multer');
+//POST /playlists
router.post(
'/',
authenticate,
@@ -17,6 +18,10 @@ router.post(
asyncHandler(controller.createPlaylist)
);
+//GET /playlists
router.get('/', optionalAuthenticate, asyncHandler(controller.listPlaylists));
+// GET /playlists/:playlist_id (Detailed View)
+router.get('/:playlist_id', optionalAuthenticate, asyncHandler(controller.getPlaylist));
+
module.exports = router;
diff --git a/src/services/playlists.service.js b/src/services/playlists.service.js
index b4005e4..8d777af 100644
--- a/src/services/playlists.service.js
+++ b/src/services/playlists.service.js
@@ -156,4 +156,54 @@ exports.listPlaylists = async ({
total: parseInt(total)
},
};
+};
+
+// ============================================================
+// ENDPOINT 2 — GET /playlists/{playlist_id}
+// ============================================================
+exports.getPlaylist = async ({ playlistId, userId, secretToken, includeTracks }) => {
+ // 1. Fetch playlist metadata
+ const playlist = await playlistModel.findPlaylistById(playlistId);
+
+ if (!playlist) {
+ throw new AppError('Playlist not found.', 404, 'PLAYLIST_NOT_FOUND');
+ }
+
+ // 2. Privacy Logic (Owner vs. Public vs. Secret Link)
+ if (!playlist.is_public) {
+ const isOwner = userId && playlist.owner_user_id === userId;
+ const hasValidToken = secretToken && secretToken === playlist.secret_token;
+
+ if (!isOwner && !hasValidToken) {
+ throw new AppError('This playlist is private.', 403, 'PRIVATE_PLAYLIST_ACCESS_DENIED');
+ }
+ }
+
+ const formatted = formatPlaylist(playlist);
+ let tracks = [];
+
+ // 3. Conditional Track Loading & Smart Cover Logic
+ if (includeTracks) {
+ // Fetch full list if requested
+ tracks = await playlistModel.findPlaylistTracks(playlistId);
+
+ // Fallback cover logic using the fetched tracks
+ if (!formatted.cover_image && tracks.length > 0) {
+ formatted.cover_image = tracks[0].cover_image || null;
+ }
+ } else if (!formatted.cover_image) {
+ // Tracks NOT requested, but we still need the cover image fallback
+ // We call a lightweight model function that only gets the FIRST track art
+ const topTrack = await playlistModel.getTopTrackArt(playlistId);
+ formatted.cover_image = topTrack?.cover_image || null;
+ }
+
+ // 4. Final Response Construction
+ const response = { ...formatted };
+
+ if (includeTracks) {
+ response.tracks = tracks;
+ }
+
+ return response;
};
\ No newline at end of file
From 988035adf6769628b3c99fa8f1916be0fb0ecadb Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Fri, 3 Apr 2026 04:24:47 +0200
Subject: [PATCH 146/535] feat(playlists): add endpoint PATCH/
playlists/:playlist_id
-implement playlist patch endpoint with file upload and tag replacement
-add slug generation, normalization, uniqueness, and patch support
-persist slug on create and include slug in playlist responses
-allow release_date null reset and enforce real non-past dates
-add migrations for playlist_tags and playlists.slug
---
.../20260402000001-add-playlist-tags.js | 32 +++
.../20260402000002-add-playlist-slug.js | 26 ++
openapi.yaml | 83 +++++-
src/controllers/playlists.controller.js | 63 +++++
src/models/playlist.model.js | 160 +++++++++++-
src/routes/playlists.routes.js | 10 +-
src/services/playlists.service.js | 246 +++++++++++++++++-
7 files changed, 606 insertions(+), 14 deletions(-)
create mode 100644 migrations/20260402000001-add-playlist-tags.js
create mode 100644 migrations/20260402000002-add-playlist-slug.js
diff --git a/migrations/20260402000001-add-playlist-tags.js b/migrations/20260402000001-add-playlist-tags.js
new file mode 100644
index 0000000..d9d03c3
--- /dev/null
+++ b/migrations/20260402000001-add-playlist-tags.js
@@ -0,0 +1,32 @@
+'use strict';
+
+exports.setup = function(options, seedLink) {};
+
+exports.up = async function(db) {
+ await db.runSql(`
+ CREATE TABLE "playlist_tags" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ "playlist_id" uuid NOT NULL REFERENCES "playlists" ("id") ON DELETE CASCADE,
+ "tag_id" uuid NOT NULL REFERENCES "tags" ("id") ON DELETE CASCADE,
+ "created_at" timestamptz NOT NULL DEFAULT now()
+ );
+ `);
+
+ await db.runSql(`
+ CREATE UNIQUE INDEX playlist_tags_playlist_id_tag_id_idx
+ ON "playlist_tags" ("playlist_id", "tag_id");
+ `);
+
+ await db.runSql(`
+ CREATE INDEX playlist_tags_tag_id_idx
+ ON "playlist_tags" ("tag_id");
+ `);
+};
+
+exports.down = async function(db) {
+ await db.runSql(`DROP INDEX IF EXISTS playlist_tags_tag_id_idx;`);
+ await db.runSql(`DROP INDEX IF EXISTS playlist_tags_playlist_id_tag_id_idx;`);
+ await db.runSql(`DROP TABLE IF EXISTS "playlist_tags" CASCADE;`);
+};
+
+exports._meta = { version: 1 };
\ No newline at end of file
diff --git a/migrations/20260402000002-add-playlist-slug.js b/migrations/20260402000002-add-playlist-slug.js
new file mode 100644
index 0000000..1495689
--- /dev/null
+++ b/migrations/20260402000002-add-playlist-slug.js
@@ -0,0 +1,26 @@
+'use strict';
+
+exports.setup = function(options, seedLink) {};
+
+exports.up = async function(db) {
+ await db.runSql(`
+ ALTER TABLE "playlists"
+ ADD COLUMN IF NOT EXISTS "slug" varchar(255);
+ `);
+
+ await db.runSql(`
+ CREATE UNIQUE INDEX IF NOT EXISTS playlists_slug_idx
+ ON "playlists" ("slug")
+ WHERE deleted_at IS NULL;
+ `);
+};
+
+exports.down = async function(db) {
+ await db.runSql(`DROP INDEX IF EXISTS playlists_slug_idx;`);
+ await db.runSql(`
+ ALTER TABLE "playlists"
+ DROP COLUMN IF EXISTS "slug";
+ `);
+};
+
+exports._meta = { version: 1 };
\ No newline at end of file
diff --git a/openapi.yaml b/openapi.yaml
index a58278b..8e0bd3b 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -2472,6 +2472,7 @@ paths:
playlist_id: 8d5a8f6c-7b4a-4c7a-9c25-9a9f1e3a12aa
owner_user_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
name: 'Gym Mix'
+ slug: 'gym-mix'
description: 'High energy tracks'
is_public: true
cover_image: 'https://cdn.rythmify.com/playlists/8d5a8f6c-7b4a-4c7a-9c25-9a9f1e3a12aa/cover.jpg'
@@ -2656,6 +2657,7 @@ paths:
playlist_id: 8d5a8f6c-7b4a-4c7a-9c25-9a9f1e3a12aa
owner_user_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
name: 'Gym Mix'
+ slug: 'gym-mix'
description: 'High energy tracks'
is_public: true
cover_image: 'https://cdn.rythmify.com/playlists/8d5a8f6c-7b4a-4c7a-9c25-9a9f1e3a12aa/cover.jpg'
@@ -2712,10 +2714,20 @@ paths:
- `name`
- `description`
- `is_public`
+ - `cover_image`
+ - `subtype`
+ - `slug`
+ - `release_date`
+ - `genre_id`
+ - `tags`
Notes:
- Only the playlist owner can update a playlist.
- - If `is_public=false`, the playlist becomes secret/private.
+ - If `is_public=false`, the playlist becomes secret/private and gets a secret token if missing.
+ - If `is_public=true`, any existing secret token is cleared.
+ - For `subtype` values `album`, `ep`, `single`, or `compilation`, `release_date` is required (either already stored or provided in this request).
+ - Send `genre_id` as `null` (or empty form value) to clear the existing genre.
+ - When `tags` is provided, existing playlist tags are replaced.
operationId: updatePlaylist
parameters:
- name: playlist_id
@@ -2729,7 +2741,7 @@ paths:
requestBody:
required: true
content:
- application/json:
+ multipart/form-data:
schema:
$ref: '#/components/schemas/UpdatePlaylistRequest'
@@ -2745,11 +2757,23 @@ paths:
playlist_id: 8d5a8f6c-7b4a-4c7a-9c25-9a9f1e3a12aa
owner_user_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
name: 'Updated Gym Mix'
+ slug: 'updated-gym-mix'
description: 'Updated high energy tracks'
+ cover_image: 'https://cdn.rythmify.com/playlists/8d5a8f6c-7b4a-4c7a-9c25-9a9f1e3a12aa/cover.jpg'
+ subtype: 'album'
is_public: false
+ release_date: '2026-03-06'
+ genre_id: 6c6d6f9f-2e4a-4c0e-84e6-1738a29edb8d
+ tags:
+ - id: 1a2b3c4d-5e6f-47a8-90ab-cdef12345678
+ name: workout
+ - id: 2b3c4d5e-6f70-48a9-90bc-def123456789
+ name: edm
track_count: 12
like_count: 340
+ repost_count: 10
created_at: '2026-03-06T10:00:00Z'
+ updated_at: '2026-03-06T11:00:00Z'
message: Playlist updated successfully.
'400':
@@ -8725,6 +8749,10 @@ components:
name:
type: string
example: My Gym Set
+ slug:
+ type: string
+ description: URL-friendly unique slug generated from playlist name; used as the playlist permalink.
+ example: my-gym-set
description:
type: string
nullable: true
@@ -8751,6 +8779,11 @@ components:
format: uuid
nullable: true
example: 6c6d6f9f-2e4a-4c0e-84e6-1738a29edb8d
+ tags:
+ type: array
+ description: Associated playlist tags.
+ items:
+ $ref: '#/components/schemas/Tag'
created_at:
type: string
format: date-time
@@ -8771,7 +8804,7 @@ components:
type: integer
description: Derived value (not stored).
example: 20
- required: [playlist_id, owner_user_id, name, is_public, created_at]
+ required: [playlist_id, owner_user_id, name, slug, is_public, created_at]
PlaylistTrackItem:
type: object
@@ -8859,7 +8892,11 @@ components:
UpdatePlaylistRequest:
type: object
- description: At least one field must be provided.
+ description: |
+ At least one field must be provided.
+
+ Use `multipart/form-data` for this endpoint.
+ Send `cover_image` as a file upload when replacing the playlist cover.
properties:
name:
type: string
@@ -8880,6 +8917,44 @@ components:
- true: public playlist
- false: secret/private playlist
example: false
+ cover_image:
+ type: string
+ format: binary
+ description: Optional cover image file.
+ subtype:
+ type: string
+ description: Playlist subtype.
+ enum: [playlist, album, ep, single, compilation]
+ example: album
+ slug:
+ type: string
+ minLength: 1
+ maxLength: 255
+ description: |
+ Optional custom slug (permalink). It will be normalized and made unique by the server.
+ example: updated-gym-mix
+ release_date:
+ type: string
+ format: date
+ nullable: true
+ description: |
+ Required when effective subtype is one of: `album`, `ep`, `single`, `compilation`.
+ example: 2026-03-06
+ genre_id:
+ type: string
+ format: uuid
+ nullable: true
+ description: Optional genre ID for classification. Set to `null` to remove genre.
+ example: 6c6d6f9f-2e4a-4c0e-84e6-1738a29edb8d
+ tags:
+ type: array
+ description: |
+ Optional tag names to replace existing playlist tags.
+ Example: send as repeated multipart fields or as an array depending on client support.
+ items:
+ type: string
+ minLength: 1
+ example: [workout, edm]
additionalProperties: false
AddTrackToPlaylistRequest:
diff --git a/src/controllers/playlists.controller.js b/src/controllers/playlists.controller.js
index 91ede8e..4ca244c 100644
--- a/src/controllers/playlists.controller.js
+++ b/src/controllers/playlists.controller.js
@@ -84,4 +84,67 @@ exports.getPlaylist = async (req, res) => {
});
return success(res, data, 'Playlist details fetched successfully.');
+};
+
+// ============================================================
+// ENDPOINT 4 — PATCH /playlists/:playlist_id
+// ============================================================
+exports.updatePlaylist = async (req, res) => {
+ const userId = req.user?.sub;
+ if (!userId) return error(res, 'UNAUTHORIZED', 'Authentication required.', 401);
+
+ const { playlist_id } = req.params;
+
+ // Sanitize: convert empty strings to undefined so DB never sees ""
+ const sanitize = (v) => (v === '' || v === undefined ? undefined : v);
+ const sanitizeBool = (v) => {
+ if (v === '' || v === undefined) return undefined;
+ return v === true || v === 'true';
+ };
+ const sanitizeArray = (v) => {
+ if (v === undefined) return undefined;
+ if (Array.isArray(v)) return v.filter(Boolean);
+ if (v === '') return undefined;
+ return [v];
+ };
+
+ const hasReleaseDateField = Object.prototype.hasOwnProperty.call(req.body, 'release_date');
+ const hasGenreIdField = Object.prototype.hasOwnProperty.call(req.body, 'genre_id');
+ const sanitizeReleaseDate = (v) => {
+ if (v === undefined) return undefined;
+ if (v === '' || v === null) return null;
+ return String(v).trim();
+ };
+ const sanitizeGenreId = (v) => {
+ if (v === undefined) return undefined;
+ if (v === '' || v === null) return null;
+ return String(v).trim();
+ };
+
+ const name = sanitize(req.body.name);
+ const description = sanitize(req.body.description);
+ const isPublic = sanitizeBool(req.body.is_public);
+ const releaseDate = sanitizeReleaseDate(req.body.release_date);
+ const genreId = sanitizeGenreId(req.body.genre_id);
+ const subtype = sanitize(req.body.subtype);
+ const slug = req.body.slug !== undefined ? String(req.body.slug).trim() : undefined;
+ const tags = sanitizeArray(req.body.tags);
+
+ const data = await service.updatePlaylist({
+ playlistId: playlist_id,
+ userId,
+ name,
+ description,
+ isPublic,
+ coverImageFile: req.file || null,
+ releaseDate,
+ releaseDateProvided: hasReleaseDateField,
+ genreIdProvided: hasGenreIdField,
+ genreId,
+ subtype,
+ slug,
+ tags,
+ });
+
+ return success(res, data.playlist, 'Playlist updated successfully.');
};
\ No newline at end of file
diff --git a/src/models/playlist.model.js b/src/models/playlist.model.js
index 50a8024..f573db7 100644
--- a/src/models/playlist.model.js
+++ b/src/models/playlist.model.js
@@ -12,6 +12,7 @@ const BASE_SELECT = `
p.cover_image,
p.type,
p.subtype,
+ p.slug,
p.is_public,
p.secret_token,
p.release_date,
@@ -58,13 +59,13 @@ exports.getTopTrackArt = async (playlistId) => {
// ------------------------------------------------------------
// Endpoint 1 — Create
// ------------------------------------------------------------
-exports.create = async ({ userId, name, isPublic, secretToken, subtype }) => {
+exports.create = async ({ userId, name, isPublic, secretToken, subtype, slug }) => {
const { rows } = await db.query(
`INSERT INTO playlists
- (user_id, name, is_public, secret_token, subtype, type)
- VALUES ($1, $2, $3, $4, $5, 'regular')
+ (user_id, name, is_public, secret_token, subtype, slug, type)
+ VALUES ($1, $2, $3, $4, $5, $6, 'regular')
RETURNING *`,
- [userId, name, isPublic, secretToken, subtype]
+ [userId, name, isPublic, secretToken, subtype, slug]
);
return rows[0];
};
@@ -233,15 +234,47 @@ exports.countLikedPlaylists = async ({ userId, q, subtype, isAlbumView }) => {
};
// ============================================================
-// ENDPOINT 2 — GET /playlists/{playlist_id}
+// ENDPOINT 3 — GET /playlists/{playlist_id}
// ============================================================
/**
* Finds a single playlist by ID using the standardized BASE_SELECT.
*/
exports.findPlaylistById = async (playlistId) => {
- // Using the BASE_SELECT you defined earlier
- const query = `${BASE_SELECT} WHERE p.id = $1 AND p.deleted_at IS NULL`;
+ const query = `
+ ${BASE_SELECT}
+ WHERE p.id = $1 AND p.deleted_at IS NULL
+ `;
const { rows } = await db.query(query, [playlistId]);
+ if (!rows[0]) return null;
+
+ // Fetch tags separately
+ const tagsResult = await db.query(
+ `SELECT t.id, t.name
+ FROM playlist_tags pt
+ JOIN tags t ON t.id = pt.tag_id
+ WHERE pt.playlist_id = $1
+ ORDER BY t.name ASC`,
+ [playlistId]
+ );
+
+ return { ...rows[0], tags: tagsResult.rows };
+};
+
+exports.findBySlug = async (slug, excludeId = null) => {
+ if (excludeId) {
+ const { rows } = await db.query(
+ `SELECT id FROM playlists
+ WHERE slug = $1 AND id != $2 AND deleted_at IS NULL`,
+ [slug, excludeId]
+ );
+ return rows[0] || null;
+ }
+
+ const { rows } = await db.query(
+ `SELECT id FROM playlists
+ WHERE slug = $1 AND deleted_at IS NULL`,
+ [slug]
+ );
return rows[0] || null;
};
@@ -266,4 +299,115 @@ exports.findPlaylistTracks = async (playlistId) => {
`;
const { rows } = await db.query(query, [playlistId]);
return rows;
-};
\ No newline at end of file
+};
+
+// ============================================================
+// ENDPOINT 4 — PATCH /playlists/:playlist_id
+// Also used internally by ENDPOINT 1 after cover image upload
+// ============================================================
+exports.updatePlaylist = async (playlistId, {
+ name, description, isPublic, secretToken, subtype,
+ coverImage, releaseDate, genreId, slug,
+}) => {
+ const fields = [];
+ const params = [];
+
+ if (name !== undefined) { params.push(name); fields.push(`name = $${params.length}`); }
+ if (description !== undefined) { params.push(description); fields.push(`description = $${params.length}`); }
+ if (isPublic !== undefined) { params.push(isPublic); fields.push(`is_public = $${params.length}`); }
+ if (secretToken !== undefined) { params.push(secretToken); fields.push(`secret_token = $${params.length}`); }
+ if (subtype !== undefined) { params.push(subtype); fields.push(`subtype = $${params.length}`); }
+ if (coverImage !== undefined) { params.push(coverImage); fields.push(`cover_image = $${params.length}`); }
+ if (releaseDate !== undefined) { params.push(releaseDate); fields.push(`release_date = $${params.length}`); }
+ if (genreId !== undefined) { params.push(genreId); fields.push(`genre_id = $${params.length}`); }
+ if (slug !== undefined) { params.push(slug); fields.push(`slug = $${params.length}`); }
+
+ if (fields.length === 0) return null;
+
+ params.push(playlistId);
+ const { rows } = await db.query(
+ `UPDATE playlists
+ SET ${fields.join(', ')}
+ WHERE id = $${params.length} AND deleted_at IS NULL
+ RETURNING *`,
+ params
+ );
+ return rows[0] || null;
+};
+
+
+exports.getPlaylistTags = async (playlistId) => {
+ const { rows } = await db.query(
+ `SELECT t.id, t.name
+ FROM playlist_tags pt
+ JOIN tags t ON t.id = pt.tag_id
+ WHERE pt.playlist_id = $1
+ ORDER BY t.name ASC`,
+ [playlistId]
+ );
+ return rows;
+};
+
+exports.replacePlaylistTags = async (playlistId, tagNames) => {
+ // Delete all existing tags for this playlist
+ await db.query(
+ `DELETE FROM playlist_tags WHERE playlist_id = $1`,
+ [playlistId]
+ );
+
+ if (!tagNames || tagNames.length === 0) return [];
+
+ // Normalize tag names
+ const normalized = [...new Set(
+ tagNames.map(n => String(n).trim().toLowerCase()).filter(Boolean)
+ )];
+
+ // Find or create each tag
+ const tagIds = [];
+ for (const name of normalized) {
+ // Try to find existing tag
+ let result = await db.query(
+ `SELECT id FROM tags WHERE LOWER(name) = $1 LIMIT 1`,
+ [name]
+ );
+
+ // Create if not found
+ if (!result.rows[0]) {
+ result = await db.query(
+ `INSERT INTO tags (name) VALUES ($1)
+ ON CONFLICT DO NOTHING
+ RETURNING id`,
+ [name]
+ );
+ if (!result.rows[0]) {
+ result = await db.query(
+ `SELECT id FROM tags WHERE LOWER(name) = $1 LIMIT 1`,
+ [name]
+ );
+ }
+ }
+
+ if (result.rows[0]) tagIds.push(result.rows[0].id);
+ }
+
+ // Insert new playlist_tags
+ for (const tagId of tagIds) {
+ await db.query(
+ `INSERT INTO playlist_tags (playlist_id, tag_id)
+ VALUES ($1, $2)
+ ON CONFLICT DO NOTHING`,
+ [playlistId, tagId]
+ );
+ }
+
+ // Return final tag list
+ const { rows } = await db.query(
+ `SELECT t.id, t.name
+ FROM playlist_tags pt
+ JOIN tags t ON t.id = pt.tag_id
+ WHERE pt.playlist_id = $1
+ ORDER BY t.name ASC`,
+ [playlistId]
+ );
+ return rows;
+};
diff --git a/src/routes/playlists.routes.js b/src/routes/playlists.routes.js
index 2a1e3b5..fa884ac 100644
--- a/src/routes/playlists.routes.js
+++ b/src/routes/playlists.routes.js
@@ -24,4 +24,12 @@ router.get('/', optionalAuthenticate, asyncHandler(controller.listPlaylists));
// GET /playlists/:playlist_id (Detailed View)
router.get('/:playlist_id', optionalAuthenticate, asyncHandler(controller.getPlaylist));
-module.exports = router;
+// PATCH /playlists/:playlist_id
+router.patch(
+ '/:playlist_id',
+ authenticate,
+ uploadImage.single('cover_image'),
+ asyncHandler(controller.updatePlaylist)
+);
+
+module.exports = router;
\ No newline at end of file
diff --git a/src/services/playlists.service.js b/src/services/playlists.service.js
index 8d777af..0d1978f 100644
--- a/src/services/playlists.service.js
+++ b/src/services/playlists.service.js
@@ -47,6 +47,7 @@ const formatPlaylist = (p) => ({
description: p.description,
cover_image: p.cover_image,
subtype: p.subtype,
+ slug: p.slug,
is_public: p.is_public,
release_date: p.release_date,
genre_id: p.genre_id,
@@ -66,6 +67,31 @@ const uploadCoverImage = async (file, playlistId) => {
return result.url;
};
+const generateSlug = (name) => {
+ const slug = name
+ .toLowerCase()
+ .trim()
+ .replace(/[^a-z0-9\s-]/g, '') // remove special chars
+ .replace(/\s+/g, '-') // spaces to hyphens
+ .replace(/-+/g, '-') // collapse multiple hyphens
+ .substring(0, 100); // max 100 chars
+
+ // Fallback so slug is never empty after normalization.
+ return slug || 'playlist';
+};
+
+const makeUniqueSlug = async (baseSlug, excludeId = null) => {
+ let slug = baseSlug;
+ let counter = 1;
+
+ while (true) {
+ const existing = await playlistModel.findBySlug(slug, excludeId);
+ if (!existing) return slug;
+ slug = `${baseSlug}-${counter}`;
+ counter++;
+ }
+};
+
// ============================================================
// ENDPOINT 1 — POST /playlists
// ============================================================
@@ -73,13 +99,17 @@ exports.createPlaylist = async ({ userId, name, isPublic }) => {
// 1. Logic: Private playlists get a secret sharing token automatically
const secretToken = isPublic === false ? generateSecretToken() : null;
+ const baseSlug = generateSlug(name);
+ const slug = await makeUniqueSlug(baseSlug);
+
// 2. Insert minimal record
const playlist = await playlistModel.create({
userId,
name,
isPublic,
secretToken,
- subtype: 'playlist' // Defaulting to 'playlist' at creation
+ subtype: 'playlist', // Defaulting to 'playlist' at creation
+ slug,
});
return { playlist: formatPlaylist(playlist) };
@@ -206,4 +236,218 @@ exports.getPlaylist = async ({ playlistId, userId, secretToken, includeTracks })
}
return response;
+};
+
+// ============================================================
+// ENDPOINT 4 — PATCH /playlists/:playlist_id
+// ============================================================
+// ============================================================
+// ENDPOINT 4 — PATCH /playlists/:playlist_id
+// ============================================================
+exports.updatePlaylist = async ({
+ playlistId, userId, name, description, isPublic,
+ coverImageFile, releaseDate, releaseDateProvided, genreId, genreIdProvided, subtype, slug, tags,
+}) => {
+ // ── Fetch ─────────────────────────────────────────────────
+ const playlist = await playlistModel.findPlaylistById(playlistId);
+ if (!playlist) {
+ throw new AppError('Playlist not found.', 404, 'PLAYLIST_NOT_FOUND');
+ }
+
+ // ── Ownership ─────────────────────────────────────────────
+ if (playlist.owner_user_id !== userId) {
+ throw new AppError(
+ 'You are not allowed to modify this playlist.',
+ 403,
+ 'PLAYLIST_FORBIDDEN'
+ );
+ }
+
+ // ── At least one field required ───────────────────────────
+ const allUndefined = [
+ name, description, isPublic, coverImageFile,
+ subtype, slug, tags,
+ ].every((v) => v === undefined || v === null)
+ && !releaseDateProvided
+ && !genreIdProvided;
+
+ if (allUndefined) {
+ throw new AppError(
+ 'At least one field must be provided.',
+ 422,
+ 'BUSINESS_RULE_VIOLATION'
+ );
+ }
+
+ // ── Name validation ───────────────────────────────────────
+ if (name !== undefined && name.trim().length === 0) {
+ throw new AppError(
+ 'Playlist name cannot be empty.',
+ 400,
+ 'VALIDATION_FAILED'
+ );
+ }
+
+ // ── Subtype validation ────────────────────────────────────
+ if (subtype !== undefined && !VALID_SUBTYPES.includes(subtype)) {
+ throw new AppError(
+ `Invalid subtype. Must be one of: ${VALID_SUBTYPES.join(', ')}.`,
+ 400,
+ 'VALIDATION_FAILED'
+ );
+ }
+
+ // ── Release date required for non-playlist subtypes ───────
+ const REQUIRES_RELEASE_DATE = ['album', 'ep', 'single', 'compilation'];
+ const effectiveSubtype = subtype || playlist.subtype;
+ const effectiveReleaseDate = releaseDateProvided ? releaseDate : playlist.release_date;
+
+ if (
+ REQUIRES_RELEASE_DATE.includes(effectiveSubtype) &&
+ !effectiveReleaseDate
+ ) {
+ throw new AppError(
+ `Release date is required when subtype is ${effectiveSubtype}.`,
+ 400,
+ 'VALIDATION_FAILED'
+ );
+ }
+
+ // ── Release date format validation ────────────────────────
+ if (releaseDateProvided && releaseDate !== null) {
+ const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
+ if (!dateRegex.test(releaseDate)) {
+ throw new AppError(
+ 'Invalid release_date format. Use YYYY-MM-DD.',
+ 400,
+ 'VALIDATION_FAILED'
+ );
+ }
+
+ // Ensure the provided date is a real calendar date (e.g. reject 2026-02-31).
+ const [year, month, day] = releaseDate.split('-').map(Number);
+ const parsed = new Date(Date.UTC(year, month - 1, day));
+ const isRealDate =
+ parsed.getUTCFullYear() === year &&
+ parsed.getUTCMonth() === month - 1 &&
+ parsed.getUTCDate() === day;
+
+ if (!isRealDate) {
+ throw new AppError(
+ 'Invalid release_date value.',
+ 400,
+ 'VALIDATION_FAILED'
+ );
+ }
+
+ const today = new Date();
+ const todayUtc = `${today.getUTCFullYear()}-${String(today.getUTCMonth() + 1).padStart(2, '0')}-${String(today.getUTCDate()).padStart(2, '0')}`;
+ if (releaseDate < todayUtc) {
+ throw new AppError(
+ 'release_date cannot be in the past.',
+ 400,
+ 'VALIDATION_FAILED'
+ );
+ }
+ }
+
+ // ── Genre ID validation ───────────────────────────────────
+ if (genreIdProvided && genreId !== null) {
+ const db = require('../config/db');
+ const genreCheck = await db.query(
+ `SELECT id FROM genres WHERE id = $1`,
+ [genreId]
+ );
+ if (!genreCheck.rows[0]) {
+ throw new AppError(
+ 'Genre not found.',
+ 404,
+ 'RESOURCE_NOT_FOUND'
+ );
+ }
+ }
+
+ // ── Tags validation ───────────────────────────────────────
+ if (tags !== undefined && !Array.isArray(tags)) {
+ throw new AppError(
+ 'Tags must be an array of strings.',
+ 400,
+ 'VALIDATION_FAILED'
+ );
+ }
+
+ if (tags !== undefined && tags.length > 10) {
+ throw new AppError(
+ 'Maximum of 10 tags allowed per playlist.',
+ 400,
+ 'VALIDATION_FAILED'
+ );
+ }
+
+ // ── Secret token logic ────────────────────────────────────
+ let secretToken;
+ if (isPublic === false && !playlist.secret_token) {
+ secretToken = generateSecretToken();
+ } else if (isPublic === true) {
+ secretToken = null;
+ }
+
+ // ── Slug logic (explicit slug takes priority, else derive from name) ──
+ let resolvedSlug;
+ if (slug !== undefined) {
+ if (slug.length === 0) {
+ throw new AppError(
+ 'Slug cannot be empty.',
+ 400,
+ 'VALIDATION_FAILED'
+ );
+ }
+
+ const normalizedSlug = generateSlug(slug);
+ resolvedSlug = await makeUniqueSlug(normalizedSlug, playlistId);
+ } else if (name !== undefined) {
+ const baseSlug = generateSlug(name);
+ resolvedSlug = await makeUniqueSlug(baseSlug, playlistId);
+ }
+
+ // ── Cover image upload ────────────────────────────────────
+ let coverImageUrl;
+ if (coverImageFile) {
+ if (playlist.cover_image) {
+ await storageService.deleteAllVersionsByUrl(playlist.cover_image);
+ }
+ const ext = coverImageFile.originalname.split('.').pop();
+ const key = `playlists/${playlistId}/cover.${ext}`;
+ const result = await storageService.uploadImage(coverImageFile, key);
+ coverImageUrl = result.url;
+ }
+
+ // ── Update playlist fields ────────────────────────────────
+ const updatePayload = {
+ name,
+ description,
+ isPublic,
+ secretToken,
+ subtype,
+ coverImage: coverImageUrl,
+ releaseDate,
+ genreId: genreIdProvided ? genreId : undefined,
+ slug: resolvedSlug,
+ };
+
+ const hasDbFieldUpdate = Object.values(updatePayload).some((v) => v !== undefined);
+ const updated = hasDbFieldUpdate
+ ? await playlistModel.updatePlaylist(playlistId, updatePayload)
+ : playlist;
+
+ // ── Replace tags if provided ──────────────────────────────
+ let updatedTags = playlist.tags || [];
+ if (tags !== undefined) {
+ updatedTags = await playlistModel.replacePlaylistTags(playlistId, tags);
+ }
+
+ const formatted = formatPlaylist(updated || playlist);
+ formatted.tags = updatedTags;
+
+ return { playlist: formatted };
};
\ No newline at end of file
From 8d8d23f8b545c816468051672058f119cb778a39 Mon Sep 17 00:00:00 2001
From: Beshoy205
Date: Fri, 3 Apr 2026 13:59:39 +0200
Subject: [PATCH 147/535] feat(followers): implement social graph endpoints
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- POST /users/:user_id/follow — follow a user (idempotent)
- DELETE /users/:user_id/follow — unfollow a user (idempotent)
Notes:
- Transaction support for data consistency
- Edge case handling (self-follow, block relationships)
- Optimized queries with LEFT JOIN
---
src/controllers/followers.controller.js | 54 ++++-
src/models/follow.model.js | 273 ++++++++++++++++++------
src/routes/followers.routes.js | 2 +
src/services/followers.service.js | 65 +++++-
4 files changed, 326 insertions(+), 68 deletions(-)
diff --git a/src/controllers/followers.controller.js b/src/controllers/followers.controller.js
index 3e66d21..76aa587 100644
--- a/src/controllers/followers.controller.js
+++ b/src/controllers/followers.controller.js
@@ -46,7 +46,7 @@ exports.getFollowers = async (req, res) => {
};
exports.getMyFollowing = async (req, res) => {
- const userId = req.user.id;
+ const userId = req.user.sub;
const searchQuery = req.query.q;
const limit = parseInt(req.query.limit) || 10;
const offset = parseInt(req.query.offset) || 0;
@@ -82,7 +82,7 @@ exports.getMyFollowing = async (req, res) => {
};
exports.getSuggestedUsersToFollow = async (req, res) => {
- const userId = req.user.id;
+ const userId = req.user.sub;
const limit = parseInt(req.query.limit) || 10;
const offset = parseInt(req.query.offset) || 0;
const result = await followersService.getSuggestedUsersToFollow(userId, limit, offset);
@@ -101,9 +101,55 @@ exports.getSuggestedUsersToFollow = async (req, res) => {
};
exports.getFollowStatus = async (req, res) => {
- const userId = req.user.id;
+ const userId = req.user.sub;
const targetUserId = req.params.user_id;
const data = await followersService.getFollowStatus(userId, targetUserId);
- return success(res, data, 'Follow status returned successfully.', 200);
+ // Wrap in data property per OpenAPI spec
+ return success(res, { ...data }, 'Follow status returned successfully.', 200);
};
+/**
+ * Follow a user
+ * POST /users/{user_id}/follow
+ *
+ * Returns:
+ * - 201 Created: if new follow
+ * - 200 OK: if already following (idempotent)
+ */
+exports.followUser = async (req, res) => {
+ const followerId = req.user.sub;
+ const userId = req.params.user_id;
+
+ const result = await followersService.followUser(followerId, userId);
+
+ // Idempotent: return 200 if already following, 201 if new follow
+ const statusCode = result.alreadyFollowing ? 200 : 201;
+ const message = result.alreadyFollowing
+ ? 'You are already following this user.'
+ : 'You are now following this user.';
+
+ const responseData = {
+ follower_id: result.follower_id,
+ followed_id: result.followed_id,
+ ...(result.created_at && { created_at: result.created_at })
+ };
+
+ return success(res, responseData, message, statusCode);
+};
+
+/**
+ * Unfollow a user
+ * DELETE /users/{user_id}/follow
+ *
+ * Returns:
+ * - 204 No Content: always (idempotent)
+ */
+exports.unfollowUser = async (req, res) => {
+ const followerId = req.user.sub;
+ const userId = req.params.user_id;
+
+ await followersService.unfollowUser(followerId, userId);
+
+ // 204 No Content - idempotent, no response body
+ return res.status(204).send();
+};
diff --git a/src/models/follow.model.js b/src/models/follow.model.js
index a329a30..6dec809 100644
--- a/src/models/follow.model.js
+++ b/src/models/follow.model.js
@@ -1,132 +1,285 @@
+// ============================================================
+// models/follow.model.js
+// Owner: Beshoy Maher (BE-3)
+// All database queries for follow functionality
+// Uses transactions for data consistency
+// ============================================================
+
const db = require('../config/db');
+const AppError = require('../utils/app-error');
+
+// ===== GET QUERIES =====
+/**
+ * Get paginated list of users that a user is following
+ * Optimized: Uses JOIN instead of NOT IN subquery
+ */
exports.getFollowing = async (userId, limit, offset) => {
const query = `
SELECT u.id, u.display_name, u.username, u.profile_picture
- FROM followers f
+ FROM follows f
JOIN users u ON f.following_id = u.id
- WHERE f.follower_id = $1
+ WHERE f.follower_id = $1 AND u.deleted_at IS NULL
ORDER BY u.display_name ASC
LIMIT $2 OFFSET $3
- `;
+ `;
const { rows } = await db.query(query, [userId, limit, offset]);
- // Get total count
const countQuery = `
- SELECT COUNT(*) as total FROM followers WHERE follower_id = $1
- `;
+ SELECT COUNT(*) as total FROM follows f
+ JOIN users u ON f.following_id = u.id
+ WHERE f.follower_id = $1 AND u.deleted_at IS NULL
+ `;
const { rows: countRows } = await db.query(countQuery, [userId]);
const total = parseInt(countRows[0].total);
return { items: rows, total, limit, offset };
};
+/**
+ * Get paginated list of users who follow a given user
+ */
exports.getFollowers = async (userId, limit, offset) => {
const query = `
SELECT u.id, u.display_name, u.username, u.profile_picture
- FROM followers f
+ FROM follows f
JOIN users u ON f.follower_id = u.id
- WHERE f.following_id = $1
+ WHERE f.following_id = $1 AND u.deleted_at IS NULL
ORDER BY u.display_name ASC
LIMIT $2 OFFSET $3
- `;
+ `;
const { rows } = await db.query(query, [userId, limit, offset]);
- // Get total count
const countQuery = `
- SELECT COUNT(*) as total FROM followers WHERE following_id = $1
- `;
+ SELECT COUNT(*) as total FROM follows f
+ JOIN users u ON f.follower_id = u.id
+ WHERE f.following_id = $1 AND u.deleted_at IS NULL
+ `;
const { rows: countRows } = await db.query(countQuery, [userId]);
const total = parseInt(countRows[0].total);
return { items: rows, total, limit, offset };
};
+/**
+ * Search within a user's following list
+ */
exports.searchMyFollowing = async (userId, query, limit, offset) => {
const searchQuery = `
SELECT u.id, u.display_name, u.username, u.profile_picture
- FROM followers f
+ FROM follows f
JOIN users u ON f.following_id = u.id
- WHERE f.follower_id = $1 AND (u.display_name ILIKE $2 OR u.username ILIKE $2)
+ WHERE f.follower_id = $1
+ AND u.deleted_at IS NULL
+ AND (u.display_name ILIKE $2 OR u.username ILIKE $2)
ORDER BY u.display_name ASC
LIMIT $3 OFFSET $4
- `;
+ `;
const { rows } = await db.query(searchQuery, [userId, `%${query}%`, limit, offset]);
- // Get total count for search results
const countQuery = `
- SELECT COUNT(*) as total FROM followers f
+ SELECT COUNT(*) as total FROM follows f
JOIN users u ON f.following_id = u.id
- WHERE f.follower_id = $1 AND (u.display_name ILIKE $2 OR u.username ILIKE $2)
- `;
+ WHERE f.follower_id = $1
+ AND u.deleted_at IS NULL
+ AND (u.display_name ILIKE $2 OR u.username ILIKE $2)
+ `;
const { rows: countRows } = await db.query(countQuery, [userId, `%${query}%`]);
const total = parseInt(countRows[0].total);
return { items: rows, total, query };
};
+/**
+ * Get suggested users to follow
+ * Optimized: Uses LEFT JOIN instead of NOT IN subquery
+ * Excludes: self, already following, blocked users, deleted users
+ */
exports.getSuggestedUsers = async (userId, limit, offset) => {
const query = `
- SELECT DISTINCT u.id, u.display_name, u.username, u.profile_picture, u.followers_count
+ SELECT u.id, u.display_name, u.username, u.profile_picture, u.followers_count
FROM users u
+ LEFT JOIN follows f ON u.id = f.following_id AND f.follower_id = $1
+ LEFT JOIN blocks b ON (
+ (b.blocker_id = $1 AND b.blocked_id = u.id) OR
+ (b.blocker_id = u.id AND b.blocked_id = $1)
+ )
WHERE u.id != $1
- AND u.id NOT IN (
- SELECT following_id FROM followers WHERE follower_id = $1
- )
+ AND f.id IS NULL
+ AND b.id IS NULL
AND u.deleted_at IS NULL
ORDER BY u.followers_count DESC
LIMIT $2 OFFSET $3
- `;
+ `;
const { rows } = await db.query(query, [userId, limit, offset]);
- // Get total count
const countQuery = `
- SELECT COUNT(DISTINCT u.id) as total FROM users u
+ SELECT COUNT(*) as total FROM users u
+ LEFT JOIN follows f ON u.id = f.following_id AND f.follower_id = $1
+ LEFT JOIN blocks b ON (
+ (b.blocker_id = $1 AND b.blocked_id = u.id) OR
+ (b.blocker_id = u.id AND b.blocked_id = u.id)
+ )
WHERE u.id != $1
- AND u.id NOT IN (
- SELECT following_id FROM followers WHERE follower_id = $1
- )
+ AND f.id IS NULL
+ AND b.id IS NULL
AND u.deleted_at IS NULL
- `;
+ `;
const { rows: countRows } = await db.query(countQuery, [userId]);
const total = parseInt(countRows[0].total);
return { items: rows, total, limit, offset };
};
+/**
+ * Check follow/block status between two users
+ */
exports.getFollowStatus = async (userId, targetUserId) => {
- const followingQuery = `
- SELECT 1 FROM followers
- WHERE follower_id = $1 AND following_id = $2
+ const query = `
+ SELECT
+ (SELECT COUNT(*) > 0 FROM follows WHERE follower_id = $1 AND following_id = $2) as is_following,
+ (SELECT COUNT(*) > 0 FROM follows WHERE follower_id = $2 AND following_id = $1) as is_followed_by,
+ (SELECT COUNT(*) > 0 FROM blocks WHERE blocker_id = $1 AND blocked_id = $2) as is_blocking,
+ (SELECT COUNT(*) > 0 FROM blocks WHERE blocker_id = $2 AND blocked_id = $1) as is_blocked_by
`;
- const { rows: followingRows } = await db.query(followingQuery, [userId, targetUserId]);
- const isFollowing = followingRows.length > 0;
+ const { rows } = await db.query(query, [userId, targetUserId]);
+ return rows[0];
+};
- const followedByQuery = `
- SELECT 1 FROM followers
- WHERE follower_id = $1 AND following_id = $2
- `;
- const { rows: followedByRows } = await db.query(followedByQuery, [targetUserId, userId]);
- const isFollowedBy = followedByRows.length > 0;
+// ===== FOLLOW/UNFOLLOW OPERATIONS WITH TRANSACTIONS =====
- const blockingQuery = `
- SELECT 1 FROM blocks
- WHERE blocker_id = $1 AND blocked_id = $2
- `;
- const { rows: blockingRows } = await db.query(blockingQuery, [userId, targetUserId]);
- const isBlocking = blockingRows.length > 0;
+/**
+ * Follow a user
+ * Handles: Self-follow prevention, blocking checks, duplicates
+ * Uses transaction to ensure followers_count consistency
+ * Returns:
+ * - { follower_id, followed_id, created_at, alreadyFollowing: true } if already following
+ * - { follower_id, followed_id, created_at, alreadyFollowing: false } if new follow
+ */
+exports.followUser = async (followerId, userId) => {
+ const client = await db.connect();
+ try {
+ await client.query('BEGIN');
- const blockedByQuery = `
- SELECT 1 FROM blocks
- WHERE blocker_id = $1 AND blocked_id = $2
- `;
- const { rows: blockedByRows } = await db.query(blockedByQuery, [targetUserId, userId]);
- const isBlockedBy = blockedByRows.length > 0;
-
- return {
- is_following: isFollowing,
- is_followed_by: isFollowedBy,
- is_blocking: isBlocking,
- is_blocked_by: isBlockedBy
- };
+ // Edge case: prevent self-follow
+ if (followerId === userId) {
+ throw new AppError('You cannot follow yourself.', 400, 'FOLLOW_SELF');
+ }
+
+ // Edge case: check if blocked
+ const blockQuery = `
+ SELECT 1 FROM blocks
+ WHERE (blocker_id = $1 AND blocked_id = $2)
+ OR (blocker_id = $2 AND blocked_id = $1)
+ LIMIT 1
+ `;
+ const { rows: blockRows } = await client.query(blockQuery, [followerId, userId]);
+ if (blockRows.length > 0) {
+ throw new AppError('You cannot follow this user.', 403, 'PERMISSION_DENIED');
+ }
+
+ // Check if already following
+ const existingQuery = `
+ SELECT 1 FROM follows
+ WHERE follower_id = $1 AND following_id = $2
+ LIMIT 1
+ `;
+ const { rows: existingRows } = await client.query(existingQuery, [followerId, userId]);
+ if (existingRows.length > 0) {
+ // Idempotent: return 200 with alreadyFollowing flag
+ await client.query('COMMIT');
+ return {
+ follower_id: followerId,
+ followed_id: userId,
+ alreadyFollowing: true
+ };
+ }
+
+ // Insert follow relationship
+ const now = new Date().toISOString();
+ await client.query(
+ `INSERT INTO follows (follower_id, following_id) VALUES ($1, $2)`,
+ [followerId, userId]
+ );
+
+ // Update follower's following_count
+ await client.query(
+ `UPDATE users SET following_count = following_count + 1 WHERE id = $1`,
+ [followerId]
+ );
+
+ // Update followed user's followers_count
+ await client.query(
+ `UPDATE users SET followers_count = followers_count + 1 WHERE id = $1`,
+ [userId]
+ );
+
+ await client.query('COMMIT');
+ return {
+ follower_id: followerId,
+ followed_id: userId,
+ created_at: now,
+ alreadyFollowing: false
+ };
+ } catch (error) {
+ await client.query('ROLLBACK');
+ throw error;
+ } finally {
+ client.release();
+ }
+};
+
+/**
+ * Unfollow a user
+ * Idempotent: returns success even if not following
+ * Uses transaction to ensure followers_count consistency
+ */
+exports.unfollowUser = async (followerId, userId) => {
+ const client = await db.connect();
+ try {
+ await client.query('BEGIN');
+
+ // Edge case: prevent self-unfollow
+ if (followerId === userId) {
+ throw new AppError('You cannot unfollow yourself.', 400, 'FOLLOW_SELF');
+ }
+
+ // Check if currently following
+ const existingQuery = `
+ SELECT 1 FROM follows
+ WHERE follower_id = $1 AND following_id = $2
+ LIMIT 1
+ `;
+ const { rows: existingRows } = await client.query(existingQuery, [followerId, userId]);
+
+ // If following, delete and update counts
+ if (existingRows.length > 0) {
+ // Delete follow relationship
+ await client.query(
+ `DELETE FROM follows WHERE follower_id = $1 AND following_id = $2`,
+ [followerId, userId]
+ );
+
+ // Update follower's following_count
+ await client.query(
+ `UPDATE users SET following_count = following_count - 1 WHERE id = $1`,
+ [followerId]
+ );
+
+ // Update followed user's followers_count
+ await client.query(
+ `UPDATE users SET followers_count = followers_count - 1 WHERE id = $1`,
+ [userId]
+ );
+ }
+
+ // Idempotent: return success whether was following or not
+ await client.query('COMMIT');
+ return null; // 204 No Content
+ } catch (error) {
+ await client.query('ROLLBACK');
+ throw error;
+ } finally {
+ client.release();
+ }
};
diff --git a/src/routes/followers.routes.js b/src/routes/followers.routes.js
index df1b4ce..52ac6e0 100644
--- a/src/routes/followers.routes.js
+++ b/src/routes/followers.routes.js
@@ -13,6 +13,8 @@ const asyncHandler = require('../utils/async-handler');
router.get('/me/following', authenticate, asyncHandler(controller.getMyFollowing));
router.get('/suggested', authenticate, asyncHandler(controller.getSuggestedUsersToFollow));
router.get('/:user_id/follow-status', authenticate, asyncHandler(controller.getFollowStatus));
+router.post('/:user_id/follow', authenticate, asyncHandler(controller.followUser));
+router.delete('/:user_id/follow', authenticate, asyncHandler(controller.unfollowUser));
router.get('/:user_id/following', authenticate, asyncHandler(controller.getFollowing));
router.get('/:user_id/followers', authenticate, asyncHandler(controller.getFollowers));
diff --git a/src/services/followers.service.js b/src/services/followers.service.js
index 449f766..78c0287 100644
--- a/src/services/followers.service.js
+++ b/src/services/followers.service.js
@@ -2,13 +2,18 @@
// services/followers.service.js
// Owner : Beshoy Maher (BE-3)
// All business logic, rules & cross-module orchestration
-// No direct SQL here — delegate to models/
+// No direct SQL here — delegate to models
// ============================================================
const followModel = require('../models/follow.model');
const userModel = require('../models/user.model');
const AppError = require('../utils/app-error');
+// ===== GET OPERATIONS =====
+
+/**
+ * Get users that a specific user is following
+ */
exports.getFollowing = async (userId, limit, offset) => {
const user = await userModel.findById(userId);
if (!user) {
@@ -17,22 +22,37 @@ exports.getFollowing = async (userId, limit, offset) => {
return await followModel.getFollowing(userId, limit, offset);
};
+/**
+ * Get users who follow a specific user
+ */
exports.getFollowers = async (userId, limit, offset) => {
const user = await userModel.findById(userId);
- if (!user) {
- throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
- }
+ if (!user) {
+ throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
+ }
return await followModel.getFollowers(userId, limit, offset);
};
+/**
+ * Search within authenticated user's following list
+ */
exports.searchMyFollowing = async (userId, query, limit, offset) => {
const user = await userModel.findById(userId);
if (!user) {
throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
}
+
+ // Validate query length
+ if (query.trim().length === 0) {
+ throw new AppError('Search query cannot be empty', 400, 'VALIDATION_FAILED');
+ }
+
return await followModel.searchMyFollowing(userId, query, limit, offset);
};
+/**
+ * Get suggested users to follow
+ */
exports.getSuggestedUsersToFollow = async (userId, limit, offset) => {
const user = await userModel.findById(userId);
if (!user) {
@@ -41,15 +61,52 @@ exports.getSuggestedUsersToFollow = async (userId, limit, offset) => {
return await followModel.getSuggestedUsers(userId, limit, offset);
};
+/**
+ * Get follow/block status between two users
+ */
exports.getFollowStatus = async (userId, targetUserId) => {
const user = await userModel.findById(userId);
if (!user) {
throw new AppError('User not found', 404, 'RESOURCE_NOT_FOUND');
}
+
const targetUser = await userModel.findById(targetUserId);
if (!targetUser) {
throw new AppError('Target user not found', 404, 'RESOURCE_NOT_FOUND');
}
+
return await followModel.getFollowStatus(userId, targetUserId);
};
+// ===== FOLLOW/UNFOLLOW OPERATIONS =====
+
+/**
+ * Follow a user
+ * Validation: target user exists, not self-follow, not blocked, not already following
+ */
+exports.followUser = async (followerId, userId) => {
+ // Validate target user exists
+ const targetUser = await userModel.findById(userId);
+ if (!targetUser) {
+ throw new AppError('Target user not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+
+ // Delegate to model which handles edge cases and transactions
+ return await followModel.followUser(followerId, userId);
+};
+
+/**
+ * Unfollow a user
+ * Validation: target user exists, currently following
+ */
+exports.unfollowUser = async (followerId, userId) => {
+ // Validate target user exists
+ const targetUser = await userModel.findById(userId);
+ if (!targetUser) {
+ throw new AppError('Target user not found', 404, 'RESOURCE_NOT_FOUND');
+ }
+
+ // Delegate to model which handles transactions
+ return await followModel.unfollowUser(followerId, userId);
+};
+
From ea9179b00416cee7213a1d7b7ed2bfd77d60171b Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Fri, 3 Apr 2026 20:10:57 +0200
Subject: [PATCH 148/535] refactor(GET/ /playlists/{playlist_id}:),
feat(DELETE/ /playlists/{playlist_id}:)
-Returned secret token(only for playlist owner) in Get/playlists/:playlist_id for frontend/cross teams
-Created Endpoint 5: DELETE /playlists/:playlist_id to allow users to remove playlists.
-implemented ownership check to ensure only the playlist creator can perform the deletion.
-Added cleanup logic to handle associated records in the 'playlist_tracks' junction table.
---
openapi.yaml | 9 ++++-
src/controllers/playlists.controller.js | 48 +++++++++++++++++++++++++
src/models/playlist.model.js | 13 +++++++
src/routes/playlists.routes.js | 7 ++++
src/services/playlists.service.js | 41 ++++++++++++++++++++-
5 files changed, 116 insertions(+), 2 deletions(-)
diff --git a/openapi.yaml b/openapi.yaml
index 8e0bd3b..8d81e0a 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -2617,7 +2617,9 @@ paths:
1) the requester is the owner (via Bearer token), OR
2) `secret_token` is provided.
- Note: `secret_token` is never returned in the response.
+ Response note:
+ - `secret_token` is returned only when the requester is the playlist owner.
+ - For non-owners, `secret_token` is omitted from the response.
operationId: getPlaylistById
security: [] # public endpoint (Authorization header may still be sent optionally)
@@ -8828,6 +8830,11 @@ components:
- $ref: '#/components/schemas/Playlist'
- type: object
properties:
+ secret_token:
+ type: string
+ nullable: true
+ description: Returned only for the playlist owner; omitted for non-owners.
+ example: 9f3e0a1b2c3d4e5f60718293a4b5c6d7e8f90123456789ab
tracks:
type: array
description: Ordered tracks in the playlist (by position).
diff --git a/src/controllers/playlists.controller.js b/src/controllers/playlists.controller.js
index 4ca244c..33c4663 100644
--- a/src/controllers/playlists.controller.js
+++ b/src/controllers/playlists.controller.js
@@ -1,5 +1,6 @@
const service = require('../services/playlists.service');
const { success, error } = require('../utils/api-response');
+const { validate: isUuid } = require('uuid');
/**
* Helper to extract the authenticated user ID from the request.
@@ -14,6 +15,29 @@ const getAuthenticatedUserId = (req, res) => {
return userId;
};
+/**
+ * Validate required params/body fields and send a consistent error response.
+ */
+const validateRequiredFields = (res, fields) => {
+ for (const { value, name } of fields) {
+ if (value === undefined || value === null || String(value).trim().length === 0) {
+ error(res, 'VALIDATION_FAILED', `${name} is required.`, 400);
+ return false;
+ }
+ }
+ return true;
+};
+
+const validateUuidFields = (res, fields) => {
+ for (const { value, name } of fields) {
+ if (!isUuid(String(value).trim())) {
+ error(res, 'VALIDATION_FAILED', `${name} must be a valid UUID.`, 400);
+ return false;
+ }
+ }
+ return true;
+};
+
// ============================================================
// ENDPOINT 1 — POST /playlists
// ============================================================
@@ -76,6 +100,9 @@ exports.getPlaylist = async (req, res) => {
const { playlist_id } = req.params;
const { secret_token, include_tracks } = req.query; // For sharing private playlists via links
+ if (!validateRequiredFields(res, [{ value: playlist_id, name: 'Playlist id' }])) return;
+ if (!validateUuidFields(res, [{ value: playlist_id, name: 'Playlist id' }])) return;
+
const data = await service.getPlaylist({
playlistId: playlist_id,
userId,
@@ -94,6 +121,8 @@ exports.updatePlaylist = async (req, res) => {
if (!userId) return error(res, 'UNAUTHORIZED', 'Authentication required.', 401);
const { playlist_id } = req.params;
+ if (!validateRequiredFields(res, [{ value: playlist_id, name: 'Playlist id' }])) return;
+ if (!validateUuidFields(res, [{ value: playlist_id, name: 'Playlist id' }])) return;
// Sanitize: convert empty strings to undefined so DB never sees ""
const sanitize = (v) => (v === '' || v === undefined ? undefined : v);
@@ -147,4 +176,23 @@ exports.updatePlaylist = async (req, res) => {
});
return success(res, data.playlist, 'Playlist updated successfully.');
+};
+
+// ============================================================
+// ENDPOINT 5 — DELETE /playlists/:playlist_id
+// ============================================================
+exports.deletePlaylist = async (req, res) => {
+ const userId = req.user?.sub;
+ if (!userId) return error(res, 'UNAUTHORIZED', 'Authentication required.', 401);
+
+ const { playlist_id } = req.params;
+ if (!validateRequiredFields(res, [{ value: playlist_id, name: 'Playlist id' }])) return;
+ if (!validateUuidFields(res, [{ value: playlist_id, name: 'Playlist id' }])) return;
+
+ await service.deletePlaylist({
+ playlistId: playlist_id,
+ userId,
+ });
+
+ return success(res, { success: true }, 'Playlist deleted successfully.');
};
\ No newline at end of file
diff --git a/src/models/playlist.model.js b/src/models/playlist.model.js
index f573db7..17c61d7 100644
--- a/src/models/playlist.model.js
+++ b/src/models/playlist.model.js
@@ -411,3 +411,16 @@ exports.replacePlaylistTags = async (playlistId, tagNames) => {
);
return rows;
};
+
+// ============================================================
+// ENDPOINT 5 — DELETE /playlists/:playlist_id
+// ============================================================
+exports.hardDelete = async (playlistId) => {
+ const { rows } = await db.query(
+ `DELETE FROM playlists
+ WHERE id = $1 AND deleted_at IS NULL
+ RETURNING id`,
+ [playlistId]
+ );
+ return rows[0] || null;
+};
diff --git a/src/routes/playlists.routes.js b/src/routes/playlists.routes.js
index fa884ac..5a2ec27 100644
--- a/src/routes/playlists.routes.js
+++ b/src/routes/playlists.routes.js
@@ -32,4 +32,11 @@ router.patch(
asyncHandler(controller.updatePlaylist)
);
+// DELETE /playlists/:playlist_id
+router.delete(
+ '/:playlist_id',
+ authenticate,
+ asyncHandler(controller.deletePlaylist)
+);
+
module.exports = router;
\ No newline at end of file
diff --git a/src/services/playlists.service.js b/src/services/playlists.service.js
index 0d1978f..ef8d54a 100644
--- a/src/services/playlists.service.js
+++ b/src/services/playlists.service.js
@@ -210,6 +210,7 @@ exports.getPlaylist = async ({ playlistId, userId, secretToken, includeTracks })
}
const formatted = formatPlaylist(playlist);
+ const isOwner = userId && playlist.owner_user_id === userId;
let tracks = [];
// 3. Conditional Track Loading & Smart Cover Logic
@@ -230,6 +231,10 @@ exports.getPlaylist = async ({ playlistId, userId, secretToken, includeTracks })
// 4. Final Response Construction
const response = { ...formatted };
+
+ if (isOwner) {
+ response.secret_token = playlist.secret_token || null;
+ }
if (includeTracks) {
response.tracks = tracks;
@@ -440,7 +445,7 @@ exports.updatePlaylist = async ({
? await playlistModel.updatePlaylist(playlistId, updatePayload)
: playlist;
- // ── Replace tags if provided ──────────────────────────────
+ // ── Replace tags
let updatedTags = playlist.tags || [];
if (tags !== undefined) {
updatedTags = await playlistModel.replacePlaylistTags(playlistId, tags);
@@ -450,4 +455,38 @@ exports.updatePlaylist = async ({
formatted.tags = updatedTags;
return { playlist: formatted };
+};
+
+// ============================================================
+// ENDPOINT 5 — DELETE /playlists/:playlist_id
+// ============================================================
+exports.deletePlaylist = async ({ playlistId, userId }) => {
+ // 1. Fetch playlist
+ const playlist = await playlistModel.findPlaylistById(playlistId);
+ if (!playlist) {
+ throw new AppError('Playlist not found.', 404, 'PLAYLIST_NOT_FOUND');
+ }
+
+ // 2. Only owner can delete
+ if (playlist.owner_user_id !== userId) {
+ throw new AppError(
+ 'You are not allowed to delete this playlist.',
+ 403,
+ 'PLAYLIST_FORBIDDEN'
+ );
+ }
+
+ // 3. Delete cover image from blob if exists
+ if (playlist.cover_image) {
+ await storageService.deleteAllVersionsByUrl(playlist.cover_image);
+ }
+
+ // 4. Hard delete — cascade removes playlist_tracks, playlist_likes,
+ // playlist_reposts, and playlist_tags automatically via ON DELETE CASCADE
+ const deleted = await playlistModel.hardDelete(playlistId);
+ if (!deleted) {
+ throw new AppError('Playlist not found.', 404, 'PLAYLIST_NOT_FOUND');
+ }
+
+ return true;
};
\ No newline at end of file
From d37e5f0b16980c1d4425c3a484424739600bcca3 Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Fri, 3 Apr 2026 20:59:58 +0200
Subject: [PATCH 149/535] feat(POST/ playlists/{playlist_id}/tracks)
- Created Endpoint 6: POST /playlists/:playlist_id/tracks.
- Added ownership validation to ensure only the creator can modify the tracklist.
- Implemented logic to automatically calculate the next 'position' for the new track (max + 1).
- Integrated an atomic increment for 'track_count' in the playlists table.
- Added validation to ensure the track_id exists and is not already in the playlist (preventing duplicates).
- Optimized "Smart Cover" fallback: The first added track now immediately provides the playlist's default art.
---
src/controllers/playlists.controller.js | 35 ++++++++
src/models/playlist.model.js | 54 ++++++++++++
src/routes/playlists.routes.js | 7 ++
src/services/playlists.service.js | 107 +++++++++++++++++++++++-
4 files changed, 202 insertions(+), 1 deletion(-)
diff --git a/src/controllers/playlists.controller.js b/src/controllers/playlists.controller.js
index 33c4663..42d60e6 100644
--- a/src/controllers/playlists.controller.js
+++ b/src/controllers/playlists.controller.js
@@ -195,4 +195,39 @@ exports.deletePlaylist = async (req, res) => {
});
return success(res, { success: true }, 'Playlist deleted successfully.');
+};
+
+// ============================================================
+// ENDPOINT 6 — POST /playlists/:playlist_id/tracks
+// ============================================================
+exports.addTrack = async (req, res) => {
+ const userId = req.user?.sub;
+ if (!userId) return error(res, 'UNAUTHORIZED', 'Authentication required.', 401);
+
+ const { playlist_id } = req.params;
+ if (!validateRequiredFields(res, [{ value: playlist_id, name: 'Playlist id' }])) return;
+ if (!validateUuidFields(res, [{ value: playlist_id, name: 'Playlist id' }])) return;
+
+ const { track_id, position } = req.body;
+
+ if (!validateRequiredFields(res, [{ value: track_id, name: 'track_id' }])) return;
+ if (!validateUuidFields(res, [{ value: track_id, name: 'track_id' }])) return;
+
+ // Parse position if provided
+ let parsedPosition;
+ if (position !== undefined && position !== null) {
+ parsedPosition = parseInt(position);
+ if (isNaN(parsedPosition) || parsedPosition < 1) {
+ return error(res, 'VALIDATION_FAILED', 'position must be a positive integer.', 400);
+ }
+ }
+
+ const data = await service.addTrack({
+ playlistId: playlist_id,
+ userId,
+ trackId: track_id,
+ position: parsedPosition,
+ });
+
+ return success(res, data.playlist, 'Track added to playlist successfully.', 201);
};
\ No newline at end of file
diff --git a/src/models/playlist.model.js b/src/models/playlist.model.js
index 17c61d7..ce8141d 100644
--- a/src/models/playlist.model.js
+++ b/src/models/playlist.model.js
@@ -424,3 +424,57 @@ exports.hardDelete = async (playlistId) => {
);
return rows[0] || null;
};
+
+
+// ============================================================
+// ENDPOINT 6 — POST /playlists/:playlist_id/tracks
+// ============================================================
+
+/**
+ * Check if a track already exists in this playlist.
+ */
+exports.findPlaylistTrack = async (playlistId, trackId) => {
+ const { rows } = await db.query(
+ `SELECT 1 FROM playlist_tracks
+ WHERE playlist_id = $1 AND track_id = $2`,
+ [playlistId, trackId]
+ );
+ return rows.length > 0;
+};
+
+/**
+ * Get the max current position in the playlist.
+ * Returns 0 if playlist is empty.
+ */
+exports.getMaxPosition = async (playlistId) => {
+ const { rows } = await db.query(
+ `SELECT COALESCE(MAX(position), 0) AS max_pos
+ FROM playlist_tracks
+ WHERE playlist_id = $1`,
+ [playlistId]
+ );
+ return rows[0].max_pos;
+};
+
+/**
+ * Shift existing tracks down to make room at a given position.
+ */
+exports.shiftPositionsDown = async (playlistId, fromPosition) => {
+ await db.query(
+ `UPDATE playlist_tracks
+ SET position = position + 1
+ WHERE playlist_id = $1 AND position >= $2`,
+ [playlistId, fromPosition]
+ );
+};
+
+/**
+ * Insert a track at a specific position.
+ */
+exports.insertTrackAtPosition = async (playlistId, trackId, position) => {
+ await db.query(
+ `INSERT INTO playlist_tracks (playlist_id, track_id, position)
+ VALUES ($1, $2, $3)`,
+ [playlistId, trackId, position]
+ );
+};
\ No newline at end of file
diff --git a/src/routes/playlists.routes.js b/src/routes/playlists.routes.js
index 5a2ec27..a247157 100644
--- a/src/routes/playlists.routes.js
+++ b/src/routes/playlists.routes.js
@@ -39,4 +39,11 @@ router.delete(
asyncHandler(controller.deletePlaylist)
);
+// POST /playlists/:playlist_id/tracks — Add track to playlist
+router.post(
+ '/:playlist_id/tracks',
+ authenticate,
+ asyncHandler(controller.addTrack)
+);
+
module.exports = router;
\ No newline at end of file
diff --git a/src/services/playlists.service.js b/src/services/playlists.service.js
index ef8d54a..b1cb82b 100644
--- a/src/services/playlists.service.js
+++ b/src/services/playlists.service.js
@@ -178,8 +178,22 @@ exports.listPlaylists = async ({
]);
}
+ const formattedItems = await Promise.all(
+ items.map(async (playlist) => {
+ const formatted = formatPlaylist(playlist);
+
+ // Smart fallback: if playlist has no custom cover, use first track cover.
+ if (!formatted.cover_image) {
+ const topTrack = await playlistModel.getTopTrackArt(formatted.playlist_id);
+ formatted.cover_image = topTrack?.cover_image || null;
+ }
+
+ return formatted;
+ })
+ );
+
return {
- items: items.map(p => formatPlaylist(p)),
+ items: formattedItems,
meta: {
limit: safeLimit,
offset: safeOffset,
@@ -489,4 +503,95 @@ exports.deletePlaylist = async ({ playlistId, userId }) => {
}
return true;
+};
+
+// ============================================================
+// ENDPOINT 6 — POST /playlists/:playlist_id/tracks
+// ============================================================
+
+/**
+ * Adds a track to a playlist.
+ * - Validates ownership
+ * - Validates track exists and is not deleted/hidden
+ * - Prevents duplicate tracks in the same playlist
+ * - Supports optional position insertion (shifts existing tracks down)
+ * - Defaults to appending at the end if no position is given
+ */
+exports.addTrack = async ({ playlistId, userId, trackId, position }) => {
+ // 1. Fetch playlist
+ const playlist = await playlistModel.findPlaylistById(playlistId);
+ if (!playlist) {
+ throw new AppError('Playlist not found.', 404, 'PLAYLIST_NOT_FOUND');
+ }
+
+ // 2. Owner check
+ checkOwner(playlist, userId);
+
+ // 3. Validate track exists and is accessible
+ // [DEPENDS: tracks module — Saja] — confirm function name is findTrackByIdWithDetails
+ const trackModel = require('../models/track.model');
+ const track = await trackModel.findTrackByIdWithDetails(trackId);
+ if (!track) {
+ throw new AppError('Track not found.', 404, 'TRACK_NOT_FOUND');
+ }
+ if (track.is_hidden) {
+ throw new AppError('Track not found.', 404, 'TRACK_NOT_FOUND');
+ }
+ if (!track.is_public) {
+ throw new AppError(
+ 'Only public tracks can be added to playlists.',
+ 422,
+ 'PLAYLIST_TRACK_MUST_BE_PUBLIC'
+ );
+ }
+
+ // 4. Check for duplicate
+ const alreadyExists = await playlistModel.findPlaylistTrack(playlistId, trackId);
+ if (alreadyExists) {
+ throw new AppError(
+ 'Track already exists in this playlist.',
+ 409,
+ 'PLAYLIST_TRACK_ALREADY_EXISTS'
+ );
+ }
+
+ // 5. Determine insertion position
+ const maxPos = await playlistModel.getMaxPosition(playlistId);
+ const nextPos = maxPos + 1;
+
+ let insertAt;
+ if (position === undefined || position === null) {
+ // No position given — append to end
+ insertAt = nextPos;
+ } else {
+ if (position < 1 || position > nextPos) {
+ throw new AppError(
+ `Position must be between 1 and ${nextPos}.`,
+ 422,
+ 'PLAYLIST_POSITION_INVALID'
+ );
+ }
+ // Shift existing tracks down to make room
+ await playlistModel.shiftPositionsDown(playlistId, position);
+ insertAt = position;
+ }
+
+ // 6. Insert the track
+ await playlistModel.insertTrackAtPosition(playlistId, trackId, insertAt);
+
+ // 7. Return updated playlist with all tracks
+ const tracks = await playlistModel.findPlaylistTracks(playlistId);
+ const formatted = formatPlaylist(playlist);
+
+ if (!formatted.cover_image && tracks.length > 0) {
+ formatted.cover_image = tracks[0].cover_image || null;
+ }
+
+ return {
+ playlist: {
+ ...formatted,
+ track_count: tracks.length,
+ tracks,
+ },
+ };
};
\ No newline at end of file
From 3ca88c4c21ccda950fae28741f5969558cb4446b Mon Sep 17 00:00:00 2001
From: Omar Hamza
Date: Fri, 3 Apr 2026 21:18:26 +0200
Subject: [PATCH 150/535] feat(Discovery): implement recommendation system and
genre discovery APIs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Implemented full Home Recommendation System (/home) as an aggregator endpoint
- Includes:
- trending_by_genre (with initial tab)
- mixed_for_you (playlist-based recommendations)
- more_of_what_you_like (track recommendations)
- hot_for_you (single featured track)
- discover_with_stations (artist-based stations)
- artists_to_watch (emerging artists)
- albums_for_you (album recommendations)
- made_for_you (daily/weekly curated playlists)
- Designed system to return lightweight preview data:
- playlists include preview_track (first track)
- no full track lists returned in /home
- supports instant playback from home
- Implemented lazy loading strategy:
- full playlist tracks via /playlists/{id}
- related tracks via /tracks/{id}/related
- genre tabs via /home/trending-by-genre/{genre_id}
- Added Genre Discovery system:
- /genres/{genre_id}/page as aggregator endpoint
- returns playlists, albums, artists, and tracks (preview)
- supports tab-based lazy loading:
- /genres/{id}/tracks
- /genres/{id}/playlists
- /genres/{id}/albums
- /genres/{id}/artists
- Introduced "Introducing" section:
- featured playlist per genre
- includes preview_track + tracks_preview (2–3 tracks)
- reuses existing Playlist schema (no new entity)
- Added metadata improvements:
- playlist_count and album_count in genre response
- Implemented stations:
- dynamic artist-based radio (/home/stations/{artist_id}/tracks)
- combines artist tracks + same-genre tracks
- Ensured consistency across all endpoints:
- standardized response structure
- empty arrays instead of null
- pagination-ready design
- Optimized performance:
- aggregator endpoints use parallel queries (Promise.all)
- avoids over-fetching data
- designed for caching support
This commit establishes the full recommendation and discovery foundation,
with clear separation between initial load (/home, /genres/page)
and interaction-based endpoints (lazy loading).
---
openapi.yaml | 1737 ++++++++++++++++++++++++++++++++++++++++----------
1 file changed, 1417 insertions(+), 320 deletions(-)
diff --git a/openapi.yaml b/openapi.yaml
index 7d6dee0..2df400b 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -57,6 +57,12 @@ tags:
description: Activity feed from followed artists
- name: Home & Discovery
description: Personalized home page, stations, mixes, and trending content
+ - name: Genre Discovery
+ description: Genre page aggregator and paginated sub-endpoints
+ - name: Track Discovery
+ description: Track-level discovery - related tracks and contextual navigation
+ - name: Follow Discovery
+ description: Suggested users and artists to follow
- name: Messages
description: Direct messaging between users
- name: Notifications
@@ -3233,34 +3239,32 @@ paths:
$ref: '#/components/responses/RateLimited'
# ==========================================================
- # Module 7.5 — Home & Discovery
+ # Module 7.5 — Home & Discovery + Genre Discovery
# ==========================================================
/home:
get:
tags: [Home & Discovery]
- summary: Get home page
+ summary: Get full home page
operationId: getHome
description: |
- Returns all home page sections in one call.
+ Returns all home page sections in one response.
+ All sub-queries run in parallel on the server.
- - trending_by_genre: Global. Computed from listening_history grouped by genre
- last 7 days. First tab pre-loaded; rest lazy-load via /home/trending-by-genre/{genre_id}.
+ Sections returned:
+ - `hot_for_you` - one featured track of the day
+ - `trending_by_genre` - genre tab bar + first tab pre-loaded
+ - `more_of_what_you_like` - tracks from liked genres and followed artists
+ - `mixed_for_you` - up to 6 auto-generated genre mixes
+ - `made_for_you` - Daily Drops + Weekly Wave summaries
+ - `artists_to_watch` - emerging artists by play velocity
+ - `discover_with_stations` - artist-based radio stations
- - mixed_for_you: Auth required. Top genres/artists from listening_history last
- 30 days → unplayed tracks grouped into up to 6 mixes.
+ Cross-section deduplication is applied server-side to track lists.
+ Priority order: hot_for_you -> trending -> more_of_what_you_like -> mixed_for_you.
- - more_of_what_you_like: Auth required. Built from track_likes + follows.
- Falls back to global trending if no history.
-
- - hot_for_you: Top trending unplayed track in user's preferred genre.
- Global #1 trending if unauthenticated. Cached until midnight UTC.
-
- - discover_with_stations: Seeded from follows. Falls back to most-followed
- artists globally.
-
- - artists_to_watch: Global. is_featured=true playlists with genre tag,
- refreshed weekly by cron.
+ Empty sections return `[]`, never `null`.
+ Unauthenticated users receive global fallbacks for personalised sections.
security: []
responses:
'200':
@@ -3269,317 +3273,576 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/HomeResponse'
+ '429':
+ $ref: '#/components/responses/RateLimited'
+ '500':
+ $ref: '#/components/responses/InternalError'
/home/trending-by-genre/{genre_id}:
get:
tags: [Home & Discovery]
- summary: Get trending tracks for a genre tab
+ summary: Lazy-load trending tracks for a genre tab
operationId: getTrendingByGenre
description: |
- Lazy-loads a genre tab in "Trending by Genre".
- Query: SELECT track_id, COUNT(*) FROM listening_history
- JOIN tracks ON tracks.id = listening_history.track_id
- WHERE tracks.genre_id = $1
- AND played_at >= NOW() - INTERVAL '7 days'
- GROUP BY track_id ORDER BY count DESC LIMIT 20
+ Called when the user taps a genre tab on the home page.
+ The first tab is pre-loaded in `GET /home`. All others lazy-load here.
+
+ Order: recency-weighted play count (last 7 days).
+ Fallback: if fewer than 5 results, pads with newest tracks in genre.
security: []
parameters:
- name: genre_id
in: path
required: true
- schema:
- type: string
- format: uuid
+ schema: { type: string, format: uuid }
- name: limit
in: query
- schema:
- type: integer
- default: 20
- maximum: 50
+ schema: { type: integer, default: 20, minimum: 1, maximum: 50 }
+ - name: offset
+ in: query
+ schema: { type: integer, default: 0, minimum: 0 }
responses:
'200':
- description: Trending tracks for this genre.
+ description: Trending tracks for this genre tab.
content:
application/json:
schema:
- $ref: '#/components/schemas/TrendingByGenreSection'
+ $ref: '#/components/schemas/TrendingByGenreTabResponse'
'404':
$ref: '#/components/responses/NotFound'
+ '429':
+ $ref: '#/components/responses/RateLimited'
- /home/stations:
+ /home/hot-for-you:
get:
tags: [Home & Discovery]
- summary: List artist stations
- operationId: listStations
+ summary: Get the featured track of the day
+ operationId: getHotForYou
description: |
- "Discover with Stations". Authenticated: stations for followed artists,
- ordered by follower_count desc. Unauthenticated: top 10 most-followed artists globally.
+ Returns exactly one track. Cached per user until midnight UTC.
+ The initial data is already embedded in `GET /home`.
+ Call this endpoint only for a standalone refresh.
+
+ Reason field values:
+ - `"Trending in {genre_name}"` - unplayed track found in user's top genre
+ - `"global trending"` - fell back to global top (authenticated, no genre match)
+ - `null` - unauthenticated user
security: []
+ responses:
+ '200':
+ description: Hot track returned.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HotForYouResponse'
+ '429':
+ $ref: '#/components/responses/RateLimited'
+
+ /home/more-of-what-you-like:
+ get:
+ tags: [Home & Discovery]
+ summary: Get tracks based on liked genres and followed artists
+ operationId: getMoreOfWhatYouLike
+ description: |
+ Paginated version of the more_of_what_you_like section.
+ Call when the user taps "See all". Initial data comes from `GET /home`.
+ security:
+ - bearerAuth: []
parameters:
- name: limit
in: query
- schema:
- type: integer
- default: 10
- maximum: 20
+ schema: { type: integer, default: 20, minimum: 1, maximum: 50 }
- name: offset
in: query
- schema:
- type: integer
- default: 0
+ schema: { type: integer, default: 0, minimum: 0 }
responses:
'200':
- description: Station list.
+ description: Tracks returned.
content:
application/json:
schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/Station'
+ $ref: '#/components/schemas/MoreOfWhatYouLikeResponse'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/RateLimited'
- /home/stations/{artist_id}/tracks:
+ /home/mixed-for-you:
get:
tags: [Home & Discovery]
- summary: Get tracks for an artist station
- operationId: getStationTracks
+ summary: Get personalized genre mixes
+ operationId: getMixedForYou
description: |
- Track selection:
- 1. Seed artist's own top tracks by play_count.
- 2. Collaborators via track_artists.
- 3. Genre/tag neighbors via track_tags.
- 4. Co-play signals from listening_history session windows.
- 5. Exclude tracks played by user in last 7 days.
- 6. Weighted random shuffle.
- security: []
+ Paginated list of auto-generated mixes. Up to 6 mixes total.
+ Initial data comes from `GET /home`. Call this only for refresh or pagination.
+
+ Fallback: no history -> returns 3 global trending mixes, source = "trending_fallback".
+ security:
+ - bearerAuth: []
parameters:
- - name: artist_id
- in: path
- required: true
- schema:
- type: string
- format: uuid
- name: limit
in: query
- schema:
- type: integer
- default: 50
- maximum: 100
+ schema: { type: integer, default: 6, minimum: 1, maximum: 6 }
- name: offset
in: query
- schema:
- type: integer
- default: 0
+ schema: { type: integer, default: 0, minimum: 0 }
responses:
'200':
- description: Station tracks.
+ description: Mixes returned.
content:
application/json:
schema:
- type: object
- properties:
- data:
- type: object
- properties:
- station:
- $ref: '#/components/schemas/Station'
- tracks:
- type: array
- items:
- $ref: '#/components/schemas/FeedTrack'
- '404':
- $ref: '#/components/responses/NotFound'
+ $ref: '#/components/schemas/MixedForYouResponse'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/RateLimited'
- /home/mixes/{mix_id}/tracks:
+ /home/made-for-you:
get:
tags: [Home & Discovery]
- summary: Get tracks in a personal mix
- operationId: getMixTracks
+ summary: Get daily and weekly curated mix summaries
+ operationId: getMadeForYou
description: |
- Returns tracks for a "Mixed for You" or "More of What You Like" playlist.
- These are auto_generated playlists in the playlists table, regenerated every 24h.
+ Returns Daily Drops and Weekly Wave playlist summaries.
+ These are generated by cron - not per request.
+ Use `GET /playlists/{id}?include_tracks=true` to load the actual track list.
+ Initial data comes from `GET /home`. Call this only for standalone refresh.
security:
- bearerAuth: []
+ responses:
+ '200':
+ description: Mix summaries returned.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/MadeForYouResponse'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/RateLimited'
+
+ /home/stations:
+ get:
+ tags: [Home & Discovery]
+ summary: List artist radio stations
+ operationId: listStations
+ description: |
+ Authenticated: stations for followed artists, ordered by follower_count DESC.
+ Unauthenticated: top 10 most-followed artists globally.
+ Initial data comes from `GET /home` (discover_with_stations). Call this for pagination.
+ security: []
+ parameters:
+ - name: limit
+ in: query
+ schema: { type: integer, default: 10, minimum: 1, maximum: 20 }
+ - name: offset
+ in: query
+ schema: { type: integer, default: 0, minimum: 0 }
+ responses:
+ '200':
+ description: Station list returned.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/StationListResponse'
+ '429':
+ $ref: '#/components/responses/RateLimited'
+
+ /home/stations/{artist_id}/tracks:
+ get:
+ tags: [Home & Discovery]
+ summary: Get tracks for an artist station
+ operationId: getStationTracks
+ description: |
+ Returns artist's own top tracks plus same-genre tracks from other artists.
+ Authenticated users have tracks played in the last 7 days excluded.
+ security: []
parameters:
- - name: mix_id
+ - name: artist_id
in: path
required: true
- schema:
- type: string
- format: uuid
- description: PersonalMix.id
+ schema: { type: string, format: uuid }
- name: limit
in: query
- schema:
- type: integer
- default: 25
- maximum: 50
+ schema: { type: integer, default: 50, minimum: 1, maximum: 100 }
- name: offset
in: query
- schema:
- type: integer
- default: 0
+ schema: { type: integer, default: 0, minimum: 0 }
responses:
'200':
- description: Mix tracks.
+ description: Station tracks returned.
content:
application/json:
schema:
- type: object
- properties:
- data:
- type: object
- properties:
- mix:
- $ref: '#/components/schemas/PersonalMix'
- tracks:
- type: array
- items:
- $ref: '#/components/schemas/FeedTrack'
- '401':
- $ref: '#/components/responses/Unauthorized'
+ $ref: '#/components/schemas/StationTracksResponse'
'404':
$ref: '#/components/responses/NotFound'
+ '429':
+ $ref: '#/components/responses/RateLimited'
- /home/hot-for-you:
+ /home/albums-for-you:
get:
tags: [Home & Discovery]
- summary: Get hot track of the day
- operationId: getHotForYou
+ summary: Get album recommendations
+ operationId: getAlbumsForYou
description: |
- Personalized (authenticated):
- 1. Get user's top genre from listening_history last 30 days.
- 2. Pick highest trend_score track in that genre not yet played by user.
- 3. Falls back through genre #2, #3 until unplayed track found.
- 4. Hard fallback: global #1 trending track.
- Unauthenticated: global #1 trending track.
- Cached per user until midnight UTC (valid_until).
+ Authenticated: albums from followed artists, ordered by like_count DESC.
+ Unauthenticated: globally popular albums, ordered by like_count DESC.
security: []
+ parameters:
+ - name: limit
+ in: query
+ schema: { type: integer, default: 10, minimum: 1, maximum: 20 }
+ - name: offset
+ in: query
+ schema: { type: integer, default: 0, minimum: 0 }
responses:
'200':
- description: Hot track returned.
+ description: Albums returned.
content:
application/json:
schema:
- type: object
- properties:
- data:
- $ref: '#/components/schemas/HotForYou'
+ $ref: '#/components/schemas/AlbumsForYouResponse'
+ '429':
+ $ref: '#/components/responses/RateLimited'
/home/artists-to-watch:
get:
tags: [Home & Discovery]
- summary: Get artists to watch out for
+ summary: Get emerging artists by play velocity
operationId: getArtistsToWatch
description: |
- "Artists to Watch Out For". Buzzing playlists refreshed weekly by cron:
- 1. Artists who uploaded in last 90 days, not yet verified.
- 2. Ranked by play_count velocity (plays per day since upload).
- 3. Grouped by genre into one featured playlist per genre.
- is_new = true if refreshed in last 7 days.
+ Unverified artists with uploads in the last 90 days,
+ ordered by plays-per-day (integer, no ML).
+ Initial data comes from `GET /home`. Call this for pagination.
security: []
parameters:
- name: limit
in: query
- schema:
- type: integer
- default: 10
- maximum: 20
+ schema: { type: integer, default: 10, minimum: 1, maximum: 20 }
+ - name: offset
+ in: query
+ schema: { type: integer, default: 0, minimum: 0 }
responses:
'200':
- description: Buzzing playlists.
+ description: Emerging artists returned.
content:
application/json:
schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/BuzzingPlaylist'
-
- # ==========================================================
- # Module 8 — Feed, Search & Discovery
- # ==========================================================
+ $ref: '#/components/schemas/ArtistsToWatchResponse'
+ '429':
+ $ref: '#/components/responses/RateLimited'
- /feed:
+ /genres/{genre_id}/page:
get:
- tags: [Feed]
- summary: Get activity feed
+ tags: [Genre Discovery]
+ summary: Get full genre page
+ operationId: getGenrePage
description: |
- Returns a chronological feed of new tracks from artists the authenticated user follows.
+ Returns all data needed to render a genre screen in one request.
+ All sub-queries run in parallel on the server.
- Notes:
- - Only public (`is_public: true`) and non-hidden (`is_hidden: false`) tracks are shown.
- - Admin-hidden tracks are excluded from all feeds.
+ Sections returned:
+ - `genre` - header info (name, cover image, counts)
+ - `playlists` - up to 12 tagged or inferred playlists
+ - `albums` - up to 12 newest albums
+ - `artists` - up to 10 top artists by follower count
+ - `tracks` - up to 20 latest public tracks (sort: newest)
- **Rate limit:** 60 requests per minute
- security:
- - bearerAuth: []
+ Empty sections return `[]`, never `null`.
+ security: []
parameters:
- - name: page
+ - name: genre_id
+ in: path
+ required: true
+ schema: { type: string, format: uuid }
+ - name: tracks_limit
in: query
- schema:
- type: integer
- default: 1
- minimum: 1
+ schema: { type: integer, default: 20, minimum: 1, maximum: 50 }
+ - name: artists_limit
+ in: query
+ schema: { type: integer, default: 10, minimum: 1, maximum: 20 }
+ - name: playlists_limit
+ in: query
+ schema: { type: integer, default: 12, minimum: 1, maximum: 20 }
+ - name: albums_limit
+ in: query
+ schema: { type: integer, default: 12, minimum: 1, maximum: 20 }
+ responses:
+ '200':
+ description: Genre page data returned.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/GenrePageResponse'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '429':
+ $ref: '#/components/responses/RateLimited'
+ '500':
+ $ref: '#/components/responses/InternalError'
+
+ /genres/{genre_id}/tracks:
+ get:
+ tags: [Genre Discovery]
+ summary: List tracks in a genre
+ operationId: getGenreTracks
+ description: |
+ Paginated tracks for the given genre.
+ - `sort=newest` (default) - ordered by created_at DESC, play_count DESC
+ - `sort=popular` - ordered by play_count DESC, created_at DESC
+ security: []
+ parameters:
+ - name: genre_id
+ in: path
+ required: true
+ schema: { type: string, format: uuid }
- name: limit
in: query
- schema:
- type: integer
- default: 20
- minimum: 1
- maximum: 100
+ schema: { type: integer, default: 20, minimum: 1, maximum: 50 }
+ - name: offset
+ in: query
+ schema: { type: integer, default: 0, minimum: 0 }
- name: sort
in: query
schema:
type: string
enum: [newest, popular]
default: newest
- description: Sort order for feed items
- - name: genre
- in: query
- schema:
- type: string
- description: Filter feed by genre (e.g. electronic, lo-fi, hip-hop)
-
responses:
'200':
- description: Feed retrieved successfully
+ description: Tracks returned.
content:
application/json:
schema:
- type: object
- properties:
- data:
- type: array
- items:
- $ref: '#/components/schemas/FeedTrack'
- pagination:
- $ref: '#/components/schemas/Pagination'
-
- '401':
- $ref: '#/components/responses/Unauthorized'
+ $ref: '#/components/schemas/GenreTracksResponse'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '429':
+ $ref: '#/components/responses/RateLimited'
- /feed/trending:
+ /genres/{genre_id}/albums:
get:
- tags: [Feed]
- summary: Get trending tracks
+ tags: [Genre Discovery]
+ summary: List albums in a genre
+ operationId: getGenreAlbums
description: |
- Returns tracks ranked by recent play count and engagement velocity.
- Each result includes a `rank` and `trend_score`.
-
- Notes:
- - Only public (`is_public: true`) and non-hidden (`is_hidden: false`) tracks are included.
-
- **Rate limit:** 60 requests per minute
+ Paginated albums for the given genre.
+ Order: release_date DESC, like_count DESC.
security: []
parameters:
- - name: genre
- in: query
- schema:
+ - name: genre_id
+ in: path
+ required: true
+ schema: { type: string, format: uuid }
+ - name: limit
+ in: query
+ schema: { type: integer, default: 12, minimum: 1, maximum: 20 }
+ - name: offset
+ in: query
+ schema: { type: integer, default: 0, minimum: 0 }
+ responses:
+ '200':
+ description: Albums returned.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/GenreAlbumsResponse'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '429':
+ $ref: '#/components/responses/RateLimited'
+
+ /genres/{genre_id}/playlists:
+ get:
+ tags: [Genre Discovery]
+ summary: List playlists in a genre
+ operationId: getGenrePlaylists
+ description: |
+ Paginated playlists for the given genre.
+ Sources (merged, deduplicated):
+ - `tagged` - playlists with genre_id set directly
+ - `inferred` - playlists where >= 50% of tracks are in this genre
+
+ Order: like_count DESC. Excludes auto-generated and curated types.
+ The `source` field on each item indicates how it was matched.
+ security: []
+ parameters:
+ - name: genre_id
+ in: path
+ required: true
+ schema: { type: string, format: uuid }
+ - name: limit
+ in: query
+ schema: { type: integer, default: 12, minimum: 1, maximum: 20 }
+ - name: offset
+ in: query
+ schema: { type: integer, default: 0, minimum: 0 }
+ responses:
+ '200':
+ description: Playlists returned.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/GenrePlaylistsResponse'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '429':
+ $ref: '#/components/responses/RateLimited'
+
+ /genres/{genre_id}/artists:
+ get:
+ tags: [Genre Discovery]
+ summary: List top artists in a genre
+ operationId: getGenreArtists
+ description: |
+ Paginated top artists for the given genre.
+ Condition: artist must have at least 1 public track in genre.
+ Order: follower_count DESC.
+ security: []
+ parameters:
+ - name: genre_id
+ in: path
+ required: true
+ schema: { type: string, format: uuid }
+ - name: limit
+ in: query
+ schema: { type: integer, default: 10, minimum: 1, maximum: 20 }
+ - name: offset
+ in: query
+ schema: { type: integer, default: 0, minimum: 0 }
+ responses:
+ '200':
+ description: Artists returned.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/GenreArtistsResponse'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '429':
+ $ref: '#/components/responses/RateLimited'
+
+ /tracks/{track_id}/related:
+ get:
+ tags: [Track Discovery]
+ summary: Get tracks related to a specific track
+ operationId: getRelatedTracks
+ description: |
+ Returns tracks related to the given track, based on:
+ 1. Same artist - other public tracks by the same artist
+ 2. Same genre - trending tracks in the same genre by other artists
+
+ The reference track is included in the response for context.
+ Results are deduplicated (the reference track is never in the related list).
+ No complex recommendation logic. No ML.
+ security: []
+ parameters:
+ - name: track_id
+ in: path
+ required: true
+ schema: { type: string, format: uuid }
+ - name: limit
+ in: query
+ schema: { type: integer, default: 20, minimum: 1, maximum: 50 }
+ - name: offset
+ in: query
+ schema: { type: integer, default: 0, minimum: 0 }
+ responses:
+ '200':
+ description: Related tracks returned.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RelatedTracksResponse'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '429':
+ $ref: '#/components/responses/RateLimited'
+ '500':
+ $ref: '#/components/responses/InternalError'
+
+ # ==========================================================
+ # Module 8 — Feed, Search & Discovery
+ # ==========================================================
+
+ /feed:
+ get:
+ tags: [Feed]
+ summary: Get activity feed
+ description: |
+ Returns a chronological feed of new tracks from artists the authenticated user follows.
+
+ Notes:
+ - Only public (`is_public: true`) and non-hidden (`is_hidden: false`) tracks are shown.
+ - Admin-hidden tracks are excluded from all feeds.
+
+ **Rate limit:** 60 requests per minute
+ security:
+ - bearerAuth: []
+ parameters:
+ - name: page
+ in: query
+ schema:
+ type: integer
+ default: 1
+ minimum: 1
+ - name: limit
+ in: query
+ schema:
+ type: integer
+ default: 20
+ minimum: 1
+ maximum: 100
+ - name: sort
+ in: query
+ schema:
+ type: string
+ enum: [newest, popular]
+ default: newest
+ description: Sort order for feed items
+ - name: genre
+ in: query
+ schema:
+ type: string
+ description: Filter feed by genre (e.g. electronic, lo-fi, hip-hop)
+
+ responses:
+ '200':
+ description: Feed retrieved successfully
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ data:
+ type: array
+ items:
+ $ref: '#/components/schemas/FeedTrack'
+ pagination:
+ $ref: '#/components/schemas/Pagination'
+
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+
+ /feed/trending:
+ get:
+ tags: [Feed]
+ summary: Get trending tracks
+ description: |
+ Returns tracks ranked by recent play count and engagement velocity.
+ Each result includes a `rank` and `trend_score`.
+
+ Notes:
+ - Only public (`is_public: true`) and non-hidden (`is_hidden: false`) tracks are included.
+
+ **Rate limit:** 60 requests per minute
+ security: []
+ parameters:
+ - name: genre
+ in: query
+ schema:
type: string
description: Filter by genre
- name: period
@@ -5718,24 +5981,63 @@ paths:
/users/suggested:
get:
- tags:
- - Follow
+ tags: [Follow Discovery]
summary: Get suggested users to follow
operationId: getSuggestedUsers
- description: Rule-based user suggestions (friends of friends, shared genres, popular
- users).
+ description: |
+ Two sources merged and deduplicated:
+ - `mutual` - friends of friends, ordered by mutual follow count
+ - `popular` - users not yet followed, ordered by follower count
+ security:
+ - bearerAuth: []
parameters:
- - $ref: '#/components/parameters/Limit'
- - $ref: '#/components/parameters/Offset'
+ - name: limit
+ in: query
+ schema: { type: integer, default: 20, minimum: 1, maximum: 100 }
+ - name: offset
+ in: query
+ schema: { type: integer, default: 0, minimum: 0 }
responses:
'200':
- description: Paginated suggested users list.
+ description: Suggested users returned.
content:
application/json:
schema:
- $ref: '#/components/schemas/UserListResponse'
+ $ref: '#/components/schemas/SuggestedUsersResponse'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/RateLimited'
+
+ /users/suggested/artists:
+ get:
+ tags: [Follow Discovery]
+ summary: Get suggested artists to follow
+ operationId: getSuggestedArtists
+ description: |
+ Artists in the user's top genres not yet followed.
+ Order: follower_count DESC.
+ Fallback: top artists globally if no genre history.
+ security:
+ - bearerAuth: []
+ parameters:
+ - name: limit
+ in: query
+ schema: { type: integer, default: 10, minimum: 1, maximum: 20 }
+ - name: offset
+ in: query
+ schema: { type: integer, default: 0, minimum: 0 }
+ responses:
+ '200':
+ description: Suggested artists returned.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SuggestedArtistsResponse'
'401':
$ref: '#/components/responses/Unauthorized'
+ '429':
+ $ref: '#/components/responses/RateLimited'
/users/{user_id}/block:
post:
@@ -8697,6 +8999,10 @@ components:
type: integer
description: Derived value (not stored).
example: 340
+ preview_track:
+ nullable: true
+ description: First track of the playlist used for instant play
+ $ref: '#/components/schemas/Track'
required: [playlist_id, owner_user_id, name, is_public, created_at]
PlaylistTrackItem:
@@ -8948,160 +9254,479 @@ components:
example: 0.94
# ----------------------------------------------------------
- # Home & Discovery Schemas
+ # Discovery & Genre Schemas
# ----------------------------------------------------------
- Station:
+ DiscoveryTrack:
type: object
description: |
- Artist-based radio station. Tracks computed on-the-fly from track_artists,
- track_tags, genres, and listening_history co-play signals. No dedicated DB table.
+ Lightweight track object used across all discovery endpoints.
+ artist_name is denormalised to avoid per-item user lookups.
+ required: [id, title, user_id, play_count]
properties:
id:
type: string
format: uuid
- description: Deterministic UUID derived from seed_artist_id.
- example: st-a1b2c3d4-e5f6-7890-abcd-ef1234567890
- name:
+ example: e5f6a7b8-c9d0-1234-efab-567890abcdef
+ title:
type: string
- example: Based on SZA
- seed_artist:
- $ref: '#/components/schemas/UserSummary'
+ example: Summer Vibes
cover_image:
type: string
format: uri
nullable: true
- example: https://cdn.rythmify.com/stations/sta1b2c3.jpg
- track_count:
+ example: https://cdn.rythmify.com/covers/track1.jpg
+ duration:
type: integer
- example: 50
- required: [id, name, seed_artist]
+ nullable: true
+ description: Duration in seconds.
+ example: 213
+ genre_name:
+ type: string
+ nullable: true
+ example: Electronic
+ play_count:
+ type: integer
+ example: 4200
+ like_count:
+ type: integer
+ example: 312
+ user_id:
+ type: string
+ format: uuid
+ example: a1b2c3d4-e5f6-7890-abcd-ef1234567890
+ artist_name:
+ type: string
+ nullable: true
+ description: Denormalised display_name of the track owner.
+ example: DJ Karim
+ stream_url:
+ type: string
+ format: uri
+ nullable: true
+ example: https://cdn.rythmify.com/tracks/e5f6a7b8/stream.mp3
+ created_at:
+ type: string
+ format: date-time
+ example: '2026-03-01T10:00:00Z'
- PersonalMix:
+ DiscoveryMix:
type: object
- description: |
- Auto-generated playlist. Two flavors:
- - listening_history: "Mixed for {display_name}" — built from top genres/artists
- in listening_history last 30 days.
- - taste_profile: "More of What You Like" — built from track_likes + follows.
- Stored in playlists table with type = 'auto_generated'. Regenerated every 24h.
+ required: [id, label, flavor, track_count, generated_at]
properties:
id:
type: string
format: uuid
- description: Playlist ID in the playlists table.
+ example: aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
label:
type: string
- description: Display label on the card.
example: MIX 1
flavor:
type: string
enum: [listening_history, taste_profile]
example: listening_history
+ genre_name:
+ type: string
+ nullable: true
+ example: Electronic
cover_image:
type: string
format: uri
nullable: true
- example: https://cdn.rythmify.com/mixes/mix1-a1b2.jpg
+ example: https://cdn.rythmify.com/mixes/mix1.jpg
track_count:
type: integer
example: 25
generated_at:
type: string
format: date-time
- description: When this mix was last regenerated.
- example: '2026-03-12T00:00:00Z'
- required: [id, label, flavor, track_count, generated_at]
+ example: '2026-04-03T00:00:00Z'
+ preview_track:
+ allOf:
+ - $ref: '#/components/schemas/DiscoveryTrack'
+ nullable: true
+ description: |
+ The first track of this mix, included for instant playback from the Home screen.
+ null if the mix has no tracks. Never contains the full track list.
- BuzzingPlaylist:
+ DiscoveryStation:
type: object
- description: |
- "Artists to Watch Out For" card. Backed by playlists with is_featured=true
- and a genre tag. Artists qualify if: uploaded in last 90 days, play_count
- velocity above threshold, not yet verified.
+ required: [id, name, artist_id, artist_name]
properties:
id:
type: string
format: uuid
- title:
- type: string
- example: Buzzing Hip Hop & Rap
- genre:
+ example: a1b2c3d4-e5f6-7890-abcd-ef1234567890
+ name:
type: string
- example: Hip Hop & Rap
- genre_id:
+ example: Based on DJ Karim
+ artist_id:
type: string
format: uuid
- nullable: true
+ example: a1b2c3d4-e5f6-7890-abcd-ef1234567890
+ artist_name:
+ type: string
+ example: DJ Karim
cover_image:
type: string
format: uri
nullable: true
- is_new:
- type: boolean
- description: True if refreshed in the last 7 days.
- example: true
+ example: https://cdn.rythmify.com/avatars/djkarim.jpg
track_count:
type: integer
- example: 20
- required: [id, title, genre, is_new, track_count]
+ example: 50
+ follower_count:
+ type: integer
+ example: 14200
- HotForYou:
+ DiscoveryAlbum:
type: object
- description: |
- Single featured track per user per day. Authenticated: highest trend_score
- track in user's top genre (from listening_history) not yet played.
- Unauthenticated: global #1 trending track. Refreshes daily at midnight UTC.
+ description: Album object used in home album recommendations.
+ required: [id, name, owner_id, owner_name, track_count, like_count]
properties:
- track:
- $ref: '#/components/schemas/FeedTrack'
- reason:
- type: string
- description: Human-readable reason shown under the card.
- example: Trending in Electronic · Matches your taste
- valid_until:
+ id:
+ type: string
+ format: uuid
+ example: 8d5a8f6c-7b4a-4c7a-9c25-9a9f1e3a12aa
+ name:
+ type: string
+ example: Midnight Sessions
+ cover_image:
+ type: string
+ format: uri
+ nullable: true
+ example: https://cdn.rythmify.com/covers/album1.jpg
+ owner_id:
+ type: string
+ format: uuid
+ example: a1b2c3d4-e5f6-7890-abcd-ef1234567890
+ owner_name:
+ type: string
+ example: DJ Karim
+ track_count:
+ type: integer
+ example: 12
+ like_count:
+ type: integer
+ example: 430
+ created_at:
type: string
format: date-time
- description: When this recommendation expires (next midnight UTC).
- example: '2026-03-13T00:00:00Z'
- required: [track, valid_until]
+ example: '2026-02-15T10:00:00Z'
- TrendingByGenreSection:
+ CuratedMixSummary:
type: object
- description: Trending tracks for one genre tab in the "Trending by Genre" section.
+ required: [id, label, description, track_count, refreshes_at]
properties:
- genre_id:
+ id:
type: string
format: uuid
+ description: Playlist ID - use GET /playlists/{id} to load tracks.
+ example: cccccccc-dddd-eeee-ffff-000000000001
+ label:
+ type: string
+ example: Daily Drops
+ description:
+ type: string
+ example: Your top picks for today
+ track_count:
+ type: integer
+ example: 30
+ refreshes_at:
+ type: string
+ format: date-time
+ description: When this mix will next be regenerated.
+ example: '2026-04-04T00:00:00Z'
+ preview_track:
+ allOf:
+ - $ref: '#/components/schemas/DiscoveryTrack'
nullable: true
- genre_name:
+ description: |
+ The first track of this mix, included for instant playback from the Home screen.
+ null if the mix has no tracks. Never contains the full track list.
+
+ EmergingArtist:
+ type: object
+ required: [id, display_name, play_velocity]
+ properties:
+ id:
type: string
+ format: uuid
+ example: b2c3d4e5-f6a7-8901-bcde-f12345678901
+ display_name:
+ type: string
+ example: Nour El Sound
+ profile_picture:
+ type: string
+ format: uri
+ nullable: true
+ example: https://cdn.rythmify.com/avatars/nour.jpg
+ top_genre:
+ type: string
+ nullable: true
example: Indie
- tracks:
- type: array
- description: Top trending tracks in this genre, ordered by trend_score desc.
- items:
- $ref: '#/components/schemas/TrendingTrack'
- required: [genre_name, tracks]
+ play_velocity:
+ type: integer
+ description: Average plays per day on tracks uploaded in the last 90 days.
+ example: 142
+ track_count:
+ type: integer
+ example: 3
+
+ SuggestedUser:
+ type: object
+ required: [id, display_name, is_verified, follower_count, suggestion_source]
+ properties:
+ id:
+ type: string
+ format: uuid
+ example: b2c3d4e5-f6a7-8901-bcde-f12345678901
+ display_name:
+ type: string
+ example: Ahmed Sami
+ username:
+ type: string
+ nullable: true
+ example: ahmed_sami
+ profile_picture:
+ type: string
+ format: uri
+ nullable: true
+ example: https://cdn.rythmify.com/avatars/ahmed.jpg
+ is_verified:
+ type: boolean
+ example: false
+ follower_count:
+ type: integer
+ example: 3200
+ mutual_count:
+ type: integer
+ nullable: true
+ description: Number of mutual follows. Present only when suggestion_source = "mutual".
+ example: 4
+ suggestion_source:
+ type: string
+ enum: [mutual, popular]
+ example: mutual
+
+ SuggestedArtist:
+ type: object
+ required: [id, display_name, is_verified, follower_count]
+ properties:
+ id:
+ type: string
+ format: uuid
+ example: a1b2c3d4-e5f6-7890-abcd-ef1234567890
+ display_name:
+ type: string
+ example: DJ Karim
+ username:
+ type: string
+ nullable: true
+ example: dj_karim
+ profile_picture:
+ type: string
+ format: uri
+ nullable: true
+ example: https://cdn.rythmify.com/avatars/djkarim.jpg
+ is_verified:
+ type: boolean
+ example: false
+ follower_count:
+ type: integer
+ example: 14200
+ top_genre:
+ type: string
+ nullable: true
+ example: Electronic
+
+ GenreDetail:
+ type: object
+ required: [id, name, track_count, artist_count, playlist_count, album_count]
+ properties:
+ id:
+ type: string
+ format: uuid
+ example: 11111111-2222-3333-4444-555555555555
+ name:
+ type: string
+ example: Electronic
+ cover_image:
+ type: string
+ format: uri
+ nullable: true
+ description: Hero image for the genre page header.
+ example: https://cdn.rythmify.com/genres/electronic.jpg
+ track_count:
+ type: integer
+ description: Total public tracks in this genre.
+ example: 18420
+ artist_count:
+ type: integer
+ description: Total artists with at least one public track in this genre.
+ example: 340
+ playlist_count:
+ type: integer
+ description: Total playlists in this genre.
+ example: 128
+ album_count:
+ type: integer
+ description: Total albums in this genre.
+ example: 64
+
+ GenreArtist:
+ type: object
+ required: [id, display_name, is_verified, follower_count, track_count_in_genre]
+ properties:
+ id:
+ type: string
+ format: uuid
+ example: a1b2c3d4-e5f6-7890-abcd-ef1234567890
+ display_name:
+ type: string
+ example: DJ Karim
+ username:
+ type: string
+ nullable: true
+ example: dj_karim
+ profile_picture:
+ type: string
+ format: uri
+ nullable: true
+ example: https://cdn.rythmify.com/avatars/djkarim.jpg
+ is_verified:
+ type: boolean
+ example: false
+ follower_count:
+ type: integer
+ description: Platform-wide follower count.
+ example: 14200
+ track_count_in_genre:
+ type: integer
+ description: Public tracks this artist has in the genre.
+ example: 18
+
+ GenrePlaylist:
+ type: object
+ required: [id, name, owner_id, owner_name, track_count, like_count, source]
+ properties:
+ id:
+ type: string
+ format: uuid
+ example: 8d5a8f6c-7b4a-4c7a-9c25-9a9f1e3a12aa
+ name:
+ type: string
+ example: Late Night Electronic
+ cover_image:
+ type: string
+ format: uri
+ nullable: true
+ example: https://cdn.rythmify.com/covers/playlist1.jpg
+ owner_id:
+ type: string
+ format: uuid
+ example: a1b2c3d4-e5f6-7890-abcd-ef1234567890
+ owner_name:
+ type: string
+ description: Denormalised display_name of the playlist owner.
+ example: DJ Karim
+ track_count:
+ type: integer
+ example: 32
+ like_count:
+ type: integer
+ example: 1240
+ source:
+ type: string
+ enum: [tagged, inferred]
+ description: |
+ tagged - playlist has genre_id set directly.
+ inferred - >= 50% of its tracks belong to this genre.
+ example: tagged
+ created_at:
+ type: string
+ format: date-time
+ example: '2026-01-15T10:00:00Z'
+
+ GenreAlbum:
+ type: object
+ description: Album object used in genre page context (with release_date).
+ required: [id, name, owner_id, owner_name, track_count, like_count]
+ properties:
+ id:
+ type: string
+ format: uuid
+ example: cccccccc-dddd-eeee-ffff-000000000001
+ name:
+ type: string
+ example: Midnight Sessions
+ cover_image:
+ type: string
+ format: uri
+ nullable: true
+ example: https://cdn.rythmify.com/covers/album1.jpg
+ owner_id:
+ type: string
+ format: uuid
+ example: a1b2c3d4-e5f6-7890-abcd-ef1234567890
+ owner_name:
+ type: string
+ description: Denormalised display_name of the album owner.
+ example: DJ Karim
+ track_count:
+ type: integer
+ example: 12
+ like_count:
+ type: integer
+ example: 430
+ release_date:
+ type: string
+ format: date
+ nullable: true
+ description: Album release date. Primary sort key.
+ example: '2026-03-01'
HomeResponse:
type: object
- description: |
- Full home page payload. All sections in one request to minimize round-trips.
- Personalized sections fall back to popularity-based results for unauthenticated users.
+ required: [data, message]
properties:
data:
type: object
+ required:
+ - hot_for_you
+ - trending_by_genre
+ - more_of_what_you_like
+ - mixed_for_you
+ - made_for_you
+ - artists_to_watch
+ - discover_with_stations
properties:
+ hot_for_you:
+ type: object
+ required: [track, valid_until]
+ properties:
+ track:
+ $ref: '#/components/schemas/DiscoveryTrack'
+ reason:
+ type: string
+ nullable: true
+ description: |
+ "Trending in {genre_name}", "global trending", or null (unauthenticated).
+ example: Trending in Electronic
+ valid_until:
+ type: string
+ format: date-time
+ example: '2026-04-04T00:00:00Z'
trending_by_genre:
type: object
- description: '"Trending by Genre" section.'
+ required: [genres, initial_tab]
properties:
genres:
type: array
- description: Ordered genre tabs. First is selected by default.
+ description: All genre tabs for rendering the tab bar.
items:
type: object
+ required: [genre_name]
properties:
genre_id:
type: string
@@ -9109,46 +9734,518 @@ components:
nullable: true
genre_name:
type: string
- example: Indie
- required: [genre_name]
+ example: Electronic
initial_tab:
- $ref: '#/components/schemas/TrendingByGenreSection'
- description: |
- Pre-loaded data for the first tab. Remaining tabs lazy-load via
- GET /home/trending-by-genre/{genre_id}.
- required: [genres, initial_tab]
+ type: object
+ required: [genre_name, tracks]
+ properties:
+ genre_id:
+ type: string
+ format: uuid
+ nullable: true
+ genre_name:
+ type: string
+ example: Electronic
+ tracks:
+ type: array
+ items:
+ $ref: '#/components/schemas/DiscoveryTrack'
+ more_of_what_you_like:
+ type: object
+ required: [tracks, source]
+ properties:
+ tracks:
+ type: array
+ items:
+ $ref: '#/components/schemas/DiscoveryTrack'
+ source:
+ type: string
+ enum: [personalized, trending_fallback]
+ example: personalized
mixed_for_you:
type: array
- description: '"Mixed for {display_name}" — flavor: listening_history.'
maxItems: 6
items:
- $ref: '#/components/schemas/PersonalMix'
- more_of_what_you_like:
+ $ref: '#/components/schemas/DiscoveryMix'
+ made_for_you:
+ type: object
+ required: [daily_mix, weekly_mix]
+ properties:
+ daily_mix:
+ $ref: '#/components/schemas/CuratedMixSummary'
+ weekly_mix:
+ $ref: '#/components/schemas/CuratedMixSummary'
+ artists_to_watch:
type: array
- description: '"More of What You Like" — flavor: taste_profile.'
- maxItems: 6
+ maxItems: 10
items:
- $ref: '#/components/schemas/PersonalMix'
- hot_for_you:
- $ref: '#/components/schemas/HotForYou'
+ $ref: '#/components/schemas/EmergingArtist'
discover_with_stations:
type: array
maxItems: 10
items:
- $ref: '#/components/schemas/Station'
- artists_to_watch:
+ $ref: '#/components/schemas/DiscoveryStation'
+ message:
+ type: string
+ example: Home page data fetched successfully.
+
+ TrendingByGenreTabResponse:
+ type: object
+ required: [data, message]
+ properties:
+ data:
+ type: object
+ required: [genre_name, tracks]
+ properties:
+ genre_id:
+ type: string
+ format: uuid
+ nullable: true
+ genre_name:
+ type: string
+ example: Electronic
+ tracks:
type: array
- maxItems: 10
items:
- $ref: '#/components/schemas/BuzzingPlaylist'
- required:
- - trending_by_genre
- - mixed_for_you
- - more_of_what_you_like
- - hot_for_you
- - discover_with_stations
- - artists_to_watch
- required: [data]
+ $ref: '#/components/schemas/DiscoveryTrack'
+ message:
+ type: string
+ example: Trending tracks fetched successfully.
+
+ HotForYouResponse:
+ type: object
+ required: [data, message]
+ properties:
+ data:
+ type: object
+ required: [track, valid_until]
+ properties:
+ track:
+ $ref: '#/components/schemas/DiscoveryTrack'
+ reason:
+ type: string
+ nullable: true
+ example: Trending in Electronic
+ valid_until:
+ type: string
+ format: date-time
+ example: '2026-04-04T00:00:00Z'
+ message:
+ type: string
+ example: Hot track fetched successfully.
+
+ MoreOfWhatYouLikeResponse:
+ type: object
+ required: [data, message]
+ properties:
+ data:
+ type: object
+ required: [tracks, source, meta]
+ properties:
+ tracks:
+ type: array
+ items:
+ $ref: '#/components/schemas/DiscoveryTrack'
+ source:
+ type: string
+ enum: [personalized, trending_fallback]
+ example: personalized
+ meta:
+ $ref: '#/components/schemas/ListMeta'
+ message:
+ type: string
+ example: Recommendations fetched successfully.
+
+ MixedForYouResponse:
+ type: object
+ required: [data, message]
+ properties:
+ data:
+ type: object
+ required: [items, source]
+ properties:
+ items:
+ type: array
+ maxItems: 6
+ items:
+ $ref: '#/components/schemas/DiscoveryMix'
+ source:
+ type: string
+ enum: [personalized, trending_fallback]
+ example: personalized
+ message:
+ type: string
+ example: Personalized mixes fetched successfully.
+
+ MadeForYouResponse:
+ type: object
+ required: [data, message]
+ properties:
+ data:
+ type: object
+ required: [daily_mix, weekly_mix]
+ properties:
+ daily_mix:
+ $ref: '#/components/schemas/CuratedMixSummary'
+ weekly_mix:
+ $ref: '#/components/schemas/CuratedMixSummary'
+ message:
+ type: string
+ example: Made for you playlists fetched successfully.
+
+ StationListResponse:
+ type: object
+ required: [data, message]
+ properties:
+ data:
+ type: object
+ required: [items, meta]
+ properties:
+ items:
+ type: array
+ items:
+ $ref: '#/components/schemas/DiscoveryStation'
+ meta:
+ $ref: '#/components/schemas/ListMeta'
+ message:
+ type: string
+ example: Stations fetched successfully.
+
+ StationTracksResponse:
+ type: object
+ required: [data, message]
+ properties:
+ data:
+ type: object
+ required: [station, tracks, meta]
+ properties:
+ station:
+ $ref: '#/components/schemas/DiscoveryStation'
+ tracks:
+ type: array
+ items:
+ $ref: '#/components/schemas/DiscoveryTrack'
+ meta:
+ $ref: '#/components/schemas/ListMeta'
+ message:
+ type: string
+ example: Station tracks fetched successfully.
+
+ AlbumsForYouResponse:
+ type: object
+ required: [data, message]
+ properties:
+ data:
+ type: object
+ required: [items, source, meta]
+ properties:
+ items:
+ type: array
+ items:
+ $ref: '#/components/schemas/DiscoveryAlbum'
+ source:
+ type: string
+ enum: [followed_artists, popular]
+ example: followed_artists
+ meta:
+ $ref: '#/components/schemas/ListMeta'
+ message:
+ type: string
+ example: Albums fetched successfully.
+
+ ArtistsToWatchResponse:
+ type: object
+ required: [data, message]
+ properties:
+ data:
+ type: object
+ required: [items, meta]
+ properties:
+ items:
+ type: array
+ items:
+ $ref: '#/components/schemas/EmergingArtist'
+ meta:
+ $ref: '#/components/schemas/ListMeta'
+ message:
+ type: string
+ example: Emerging artists fetched successfully.
+
+ SuggestedUsersResponse:
+ type: object
+ required: [data, message]
+ properties:
+ data:
+ type: object
+ required: [items, meta]
+ properties:
+ items:
+ type: array
+ items:
+ $ref: '#/components/schemas/SuggestedUser'
+ meta:
+ $ref: '#/components/schemas/ListMeta'
+ message:
+ type: string
+ example: Suggested users fetched successfully.
+
+ SuggestedArtistsResponse:
+ type: object
+ required: [data, message]
+ properties:
+ data:
+ type: object
+ required: [items, meta]
+ properties:
+ items:
+ type: array
+ items:
+ $ref: '#/components/schemas/SuggestedArtist'
+ meta:
+ $ref: '#/components/schemas/ListMeta'
+ message:
+ type: string
+ example: Suggested artists fetched successfully.
+
+ RelatedTracksResponse:
+ type: object
+ required: [data, message]
+ properties:
+ data:
+ type: object
+ required: [reference_track, tracks, meta]
+ properties:
+ reference_track:
+ $ref: '#/components/schemas/DiscoveryTrack'
+ description: The track the related list is based on.
+ tracks:
+ type: array
+ description: |
+ Related tracks. Same artist first (up to half the limit),
+ then same-genre tracks by other artists. Reference track never included.
+ items:
+ $ref: '#/components/schemas/DiscoveryTrack'
+ meta:
+ $ref: '#/components/schemas/ListMeta'
+ message:
+ type: string
+ example: Related tracks fetched successfully.
+
+ IntroducingSection:
+ type: object
+ nullable: true
+ description: Featured playlist for the genre with preview tracks
+ properties:
+ playlist:
+ allOf:
+ - $ref: '#/components/schemas/Playlist'
+ description: Featured playlist (same as normal playlist but highlighted)
+ tracks_preview:
+ type: array
+ maxItems: 3
+ description: First 2-3 tracks from the playlist (UI preview only)
+ items:
+ $ref: '#/components/schemas/Track'
+
+ GenrePageResponse:
+ type: object
+ required: [data, message]
+ properties:
+ data:
+ type: object
+ required: [genre, playlists, albums, artists, tracks]
+ properties:
+ genre:
+ $ref: '#/components/schemas/GenreDetail'
+ introducing:
+ $ref: '#/components/schemas/IntroducingSection'
+ playlists:
+ type: array
+ description: Up to playlists_limit playlists. Empty array if none.
+ items:
+ $ref: '#/components/schemas/GenrePlaylist'
+ albums:
+ type: array
+ description: Up to albums_limit albums, newest first. Empty array if none.
+ items:
+ $ref: '#/components/schemas/GenreAlbum'
+ artists:
+ type: array
+ description: Up to artists_limit top artists. Empty array if none.
+ items:
+ $ref: '#/components/schemas/GenreArtist'
+ tracks:
+ type: array
+ description: Up to tracks_limit latest public tracks. Empty array if none.
+ items:
+ $ref: '#/components/schemas/DiscoveryTrack'
+ message:
+ type: string
+ example: Genre page data fetched successfully.
+ example:
+ data:
+ genre:
+ id: 11111111-2222-3333-4444-555555555555
+ name: Electronic
+ cover_image: https://cdn.rythmify.com/genres/electronic.jpg
+ track_count: 18420
+ artist_count: 340
+ playlist_count: 128
+ album_count: 64
+ introducing:
+ playlist:
+ playlist_id: 8d5a8f6c-7b4a-4c7a-9c25-9a9f1e3a12aa
+ owner_user_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
+ name: Late Night Electronic
+ description: Selected editorial mix for the genre.
+ is_public: true
+ created_at: '2026-01-15T10:00:00Z'
+ track_count: 32
+ like_count: 1240
+ cover_image: https://cdn.rythmify.com/covers/playlist1.jpg
+ preview_track:
+ id: e5f6a7b8-c9d0-1234-efab-567890abcdef
+ title: Midnight Run
+ status: ready
+ is_public: true
+ cover_image: https://cdn.rythmify.com/covers/track1.jpg
+ duration: 213
+ genre_name: Electronic
+ play_count: 18200
+ like_count: 1140
+ comment_count: 64
+ repost_count: 21
+ user_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
+ audio_url: https://cdn.rythmify.com/tracks/e5f6a7b8/original.mp3
+ artist_name: DJ Karim
+ stream_url: https://cdn.rythmify.com/tracks/e5f6a7b8/stream.mp3
+ created_at: '2026-03-01T10:00:00Z'
+ tracks_preview:
+ - id: e5f6a7b8-c9d0-1234-efab-567890abcdef
+ title: Midnight Run
+ status: ready
+ is_public: true
+ cover_image: https://cdn.rythmify.com/covers/track1.jpg
+ duration: 213
+ genre_name: Electronic
+ play_count: 18200
+ like_count: 1140
+ comment_count: 64
+ repost_count: 21
+ user_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
+ audio_url: https://cdn.rythmify.com/tracks/e5f6a7b8/original.mp3
+ artist_name: DJ Karim
+ stream_url: https://cdn.rythmify.com/tracks/e5f6a7b8/stream.mp3
+ created_at: '2026-03-01T10:00:00Z'
+ - id: f1a2b3c4-d5e6-7890-abcd-111111111111
+ title: Solar Drift
+ status: ready
+ is_public: true
+ cover_image: https://cdn.rythmify.com/covers/track2.jpg
+ duration: 201
+ genre_name: Electronic
+ play_count: 9100
+ like_count: 780
+ comment_count: 25
+ repost_count: 9
+ user_id: b2c3d4e5-f6a7-8901-bcde-f12345678901
+ audio_url: https://cdn.rythmify.com/tracks/f1a2b3c4/original.mp3
+ artist_name: Nour El Sound
+ stream_url: https://cdn.rythmify.com/tracks/f1a2b3c4/stream.mp3
+ created_at: '2026-03-02T10:00:00Z'
+ - id: a9b8c7d6-e5f4-3210-fedc-222222222222
+ title: Echo Lane
+ status: ready
+ is_public: true
+ cover_image: https://cdn.rythmify.com/covers/track3.jpg
+ duration: 198
+ genre_name: Electronic
+ play_count: 8400
+ like_count: 610
+ comment_count: 19
+ repost_count: 7
+ user_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
+ audio_url: https://cdn.rythmify.com/tracks/a9b8c7d6/original.mp3
+ artist_name: DJ Karim
+ stream_url: https://cdn.rythmify.com/tracks/a9b8c7d6/stream.mp3
+ created_at: '2026-03-03T10:00:00Z'
+ playlists: []
+ albums: []
+ artists: []
+ tracks: []
+ message: Genre page data fetched successfully.
+
+ GenreTracksResponse:
+ type: object
+ required: [data, message]
+ properties:
+ data:
+ type: object
+ required: [tracks, meta]
+ properties:
+ tracks:
+ type: array
+ items:
+ $ref: '#/components/schemas/DiscoveryTrack'
+ meta:
+ $ref: '#/components/schemas/ListMeta'
+ message:
+ type: string
+ example: Tracks fetched successfully.
+
+ GenreAlbumsResponse:
+ type: object
+ required: [data, message]
+ properties:
+ data:
+ type: object
+ required: [albums, meta]
+ properties:
+ albums:
+ type: array
+ items:
+ $ref: '#/components/schemas/GenreAlbum'
+ meta:
+ $ref: '#/components/schemas/ListMeta'
+ message:
+ type: string
+ example: Albums fetched successfully.
+
+ GenrePlaylistsResponse:
+ type: object
+ required: [data, message]
+ properties:
+ data:
+ type: object
+ required: [playlists, meta]
+ properties:
+ playlists:
+ type: array
+ items:
+ $ref: '#/components/schemas/GenrePlaylist'
+ meta:
+ $ref: '#/components/schemas/ListMeta'
+ message:
+ type: string
+ example: Playlists fetched successfully.
+
+ GenreArtistsResponse:
+ type: object
+ required: [data, message]
+ properties:
+ data:
+ type: object
+ required: [artists, meta]
+ properties:
+ artists:
+ type: array
+ items:
+ $ref: '#/components/schemas/GenreArtist'
+ meta:
+ $ref: '#/components/schemas/ListMeta'
+ message:
+ type: string
+ example: Artists fetched successfully.
# ----------------------------------------------------------
From e607beafedc30cf460c93d47c68c36a148b5fa06 Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Sat, 4 Apr 2026 01:30:28 +0200
Subject: [PATCH 151/535] feat(tracks):GET/playlists/{playlist_id}/tracks
- Aligned PATCH behavior with GET to ensure consistent cover_image fallback (returns first track art if custom cover is null).
- Implemented explicit cover removal flow via 'remove_cover_image' flag or empty form values in the update controller.
- Refined service logic to delete stored assets and nullify database entries during cover removal while maintaining track-based fallbacks.
- Standardized secret token persistence: tokens are now preserved during updates and remain hidden from non-owner responses.
- Updated OpenAPI documentation:
- Added paginated contract and response schemas for GET playlist tracks.
- Documented cover fallback behavior and token preservation rules for PATCH requests.
- Added 'remove_cover_image' to the UpdatePlaylistRequest schema.
- Improved API predictability for frontend integration and addressed documentation drift.
---
openapi.yaml | 188 +++++++++++++++++++++++-
src/controllers/playlists.controller.js | 24 +++
src/models/playlist.model.js | 41 ++++++
src/routes/playlists.routes.js | 7 +
src/services/playlists.service.js | 116 +++++++++++----
5 files changed, 342 insertions(+), 34 deletions(-)
diff --git a/openapi.yaml b/openapi.yaml
index 8d81e0a..58fecf5 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -2620,6 +2620,7 @@ paths:
Response note:
- `secret_token` is returned only when the requester is the playlist owner.
- For non-owners, `secret_token` is omitted from the response.
+ - For private playlists accessed via query token, the token is used for authorization only and is not returned for non-owners.
operationId: getPlaylistById
security: [] # public endpoint (Authorization header may still be sent optionally)
@@ -2642,7 +2643,7 @@ paths:
- name: include_tracks
in: query
required: false
- description: If false, returns only playlist metadata (without tracks array).
+ description: If false, returns only playlist metadata (without tracks array). If true, `tracks` is included.
schema:
type: boolean
default: true
@@ -2726,10 +2727,12 @@ paths:
Notes:
- Only the playlist owner can update a playlist.
- If `is_public=false`, the playlist becomes secret/private and gets a secret token if missing.
- - If `is_public=true`, any existing secret token is cleared.
+ - If `is_public=true`, any existing secret token is preserved (not cleared).
- For `subtype` values `album`, `ep`, `single`, or `compilation`, `release_date` is required (either already stored or provided in this request).
- Send `genre_id` as `null` (or empty form value) to clear the existing genre.
- When `tags` is provided, existing playlist tags are replaced.
+ - If no custom playlist cover exists after update, response `cover_image` falls back to the first track cover (if available).
+ - To remove an uploaded cover, send `remove_cover_image=true` (or `cover_image` as an empty form value).
operationId: updatePlaylist
parameters:
- name: playlist_id
@@ -2880,6 +2883,114 @@ paths:
$ref: '#/components/responses/RateLimited'
/playlists/{playlist_id}/tracks:
+ get:
+ tags: [Playlists]
+ summary: Get playlist tracks (paginated)
+ description: |
+ Returns paginated tracks for a playlist.
+
+ Access rules:
+ - Public playlists: accessible to anyone.
+ - Private playlists: accessible if either:
+ 1) the requester is the owner (via Bearer token), OR
+ 2) a valid `secret_token` query parameter is provided.
+
+ Pagination behavior:
+ - `page` is 1-based and clamped to a minimum of 1.
+ - `limit` defaults to 20 and is clamped to a maximum of 100.
+ operationId: getPlaylistTracks
+ security: []
+
+ parameters:
+ - name: playlist_id
+ in: path
+ required: true
+ description: Playlist ID.
+ schema:
+ type: string
+ format: uuid
+
+ - name: secret_token
+ in: query
+ required: false
+ description: Required only to access private playlists when not authenticated as the owner.
+ schema:
+ type: string
+
+ - name: page
+ in: query
+ required: false
+ description: Page number (1-based).
+ schema:
+ type: integer
+ minimum: 1
+ default: 1
+
+ - name: limit
+ in: query
+ required: false
+ description: Number of tracks per page (server-clamped to max 100).
+ schema:
+ type: integer
+ minimum: 1
+ maximum: 100
+ default: 20
+
+ responses:
+ '200':
+ description: Playlist tracks returned successfully.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PlaylistTracksGetResponse'
+ example:
+ data:
+ playlist_id: 8d5a8f6c-7b4a-4c7a-9c25-9a9f1e3a12aa
+ tracks:
+ - track_id: e5f6a7b8-c9d0-1234-efab-567890abcdef
+ position: 1
+ added_at: '2026-03-06T10:01:00Z'
+ title: Lift Me Up
+ duration: 214
+ cover_image: 'https://cdn.rythmify.com/tracks/e5f6a7b8/cover.jpg'
+ is_public: true
+ deleted_at: null
+ artist_name: DJ Nova
+ artist_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890
+ pagination:
+ page: 1
+ per_page: 20
+ total_items: 84
+ total_pages: 5
+ has_next: true
+ has_prev: false
+ message: Playlist tracks fetched successfully.
+
+ '403':
+ description: Access denied for private playlist.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ example:
+ error:
+ code: PLAYLIST_ACCESS_DENIED
+ message: You do not have access to this playlist.
+
+ '404':
+ description: Playlist not found.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ example:
+ error:
+ code: PLAYLIST_NOT_FOUND
+ message: Playlist not found.
+
+ '429':
+ $ref: '#/components/responses/RateLimited'
+
post:
tags: [Playlists]
summary: Add track to playlist
@@ -8825,6 +8936,49 @@ components:
example: 2026-03-06T10:01:00Z
required: [track_id, position, added_at]
+ PlaylistTrackListItem:
+ type: object
+ properties:
+ track_id:
+ type: string
+ format: uuid
+ example: e5f6a7b8-c9d0-1234-efab-567890abcdef
+ position:
+ type: integer
+ minimum: 1
+ example: 1
+ added_at:
+ type: string
+ format: date-time
+ example: 2026-03-06T10:01:00Z
+ title:
+ type: string
+ example: Lift Me Up
+ duration:
+ type: integer
+ nullable: true
+ example: 214
+ cover_image:
+ type: string
+ nullable: true
+ example: https://cdn.rythmify.com/tracks/e5f6a7b8/cover.jpg
+ is_public:
+ type: boolean
+ example: true
+ deleted_at:
+ type: string
+ format: date-time
+ nullable: true
+ example: null
+ artist_name:
+ type: string
+ example: DJ Nova
+ artist_id:
+ type: string
+ format: uuid
+ example: a1b2c3d4-e5f6-7890-abcd-ef1234567890
+ required: [track_id, position, added_at, title, is_public, artist_name, artist_id]
+
PlaylistDetails:
allOf:
- $ref: '#/components/schemas/Playlist'
@@ -8851,6 +9005,28 @@ components:
type: string
example: Playlist fetched successfully.
+ PlaylistTracksGetResponse:
+ type: object
+ required: [data]
+ properties:
+ data:
+ type: object
+ properties:
+ playlist_id:
+ type: string
+ format: uuid
+ example: 8d5a8f6c-7b4a-4c7a-9c25-9a9f1e3a12aa
+ tracks:
+ type: array
+ items:
+ $ref: '#/components/schemas/PlaylistTrackListItem'
+ pagination:
+ $ref: '#/components/schemas/Pagination'
+ required: [playlist_id, tracks, pagination]
+ message:
+ type: string
+ example: Playlist tracks fetched successfully.
+
PlaylistResponse:
type: object
required: [data]
@@ -8927,7 +9103,13 @@ components:
cover_image:
type: string
format: binary
- description: Optional cover image file.
+ description: Optional cover image file to upload/replace the playlist cover.
+ remove_cover_image:
+ type: boolean
+ description: |
+ Optional flag to remove the current uploaded cover image.
+ When true, `cover_image` is cleared and response may fall back to first track cover.
+ example: true
subtype:
type: string
description: Playlist subtype.
diff --git a/src/controllers/playlists.controller.js b/src/controllers/playlists.controller.js
index 42d60e6..f29c8c8 100644
--- a/src/controllers/playlists.controller.js
+++ b/src/controllers/playlists.controller.js
@@ -158,6 +158,10 @@ exports.updatePlaylist = async (req, res) => {
const subtype = sanitize(req.body.subtype);
const slug = req.body.slug !== undefined ? String(req.body.slug).trim() : undefined;
const tags = sanitizeArray(req.body.tags);
+ const clearCoverImage =
+ req.body.remove_cover_image === true
+ || req.body.remove_cover_image === 'true'
+ || req.body.cover_image === '';
const data = await service.updatePlaylist({
playlistId: playlist_id,
@@ -166,6 +170,7 @@ exports.updatePlaylist = async (req, res) => {
description,
isPublic,
coverImageFile: req.file || null,
+ clearCoverImage,
releaseDate,
releaseDateProvided: hasReleaseDateField,
genreIdProvided: hasGenreIdField,
@@ -230,4 +235,23 @@ exports.addTrack = async (req, res) => {
});
return success(res, data.playlist, 'Track added to playlist successfully.', 201);
+};
+
+// ============================================================
+// ENDPOINT 7 — GET /playlists/:playlist_id/tracks
+// ============================================================
+exports.getPlaylistTracks = async (req, res) => {
+ const userId = req.user?.sub ?? null;
+ const { playlist_id } = req.params;
+ const { secret_token, page, limit } = req.query;
+
+ const data = await service.getPlaylistTracks({
+ playlistId: playlist_id,
+ userId,
+ secretToken: secret_token,
+ page,
+ limit,
+ });
+
+ return success(res, data, 'Playlist tracks fetched successfully.');
};
\ No newline at end of file
diff --git a/src/models/playlist.model.js b/src/models/playlist.model.js
index ce8141d..a6dae43 100644
--- a/src/models/playlist.model.js
+++ b/src/models/playlist.model.js
@@ -477,4 +477,45 @@ exports.insertTrackAtPosition = async (playlistId, trackId, position) => {
VALUES ($1, $2, $3)`,
[playlistId, trackId, position]
);
+};
+
+// ============================================================
+// ENDPOINT 7 — GET /playlists/:playlist_id/tracks
+// ============================================================
+exports.findPlaylistTracksPaginated = async (playlistId, { limit, offset }) => {
+ const { rows } = await db.query(
+ `SELECT
+ pt.track_id,
+ pt.position,
+ pt.added_at,
+ t.title,
+ t.duration,
+ t.cover_image,
+ t.is_public,
+ t.deleted_at,
+ u.display_name AS artist_name,
+ u.id AS artist_id
+ FROM playlist_tracks pt
+ JOIN tracks t ON pt.track_id = t.id
+ JOIN users u ON t.user_id = u.id
+ WHERE pt.playlist_id = $1
+ AND t.deleted_at IS NULL
+ ORDER BY pt.position ASC
+ LIMIT $2 OFFSET $3`,
+ [playlistId, limit, offset]
+ );
+
+ const countResult = await db.query(
+ `SELECT COUNT(*)::int AS total
+ FROM playlist_tracks pt
+ JOIN tracks t ON pt.track_id = t.id
+ WHERE pt.playlist_id = $1
+ AND t.deleted_at IS NULL`,
+ [playlistId]
+ );
+
+ return {
+ rows,
+ total: countResult.rows[0].total,
+ };
};
\ No newline at end of file
diff --git a/src/routes/playlists.routes.js b/src/routes/playlists.routes.js
index a247157..dea3171 100644
--- a/src/routes/playlists.routes.js
+++ b/src/routes/playlists.routes.js
@@ -32,6 +32,13 @@ router.patch(
asyncHandler(controller.updatePlaylist)
);
+// GET /playlists/:playlist_id/tracks
+router.get(
+ '/:playlist_id/tracks',
+ optionalAuthenticate,
+ asyncHandler(controller.getPlaylistTracks)
+);
+
// DELETE /playlists/:playlist_id
router.delete(
'/:playlist_id',
diff --git a/src/services/playlists.service.js b/src/services/playlists.service.js
index b1cb82b..759753e 100644
--- a/src/services/playlists.service.js
+++ b/src/services/playlists.service.js
@@ -97,7 +97,7 @@ const makeUniqueSlug = async (baseSlug, excludeId = null) => {
// ============================================================
exports.createPlaylist = async ({ userId, name, isPublic }) => {
// 1. Logic: Private playlists get a secret sharing token automatically
- const secretToken = isPublic === false ? generateSecretToken() : null;
+ const secretToken = generateSecretToken();
const baseSlug = generateSlug(name);
const slug = await makeUniqueSlug(baseSlug);
@@ -112,7 +112,11 @@ exports.createPlaylist = async ({ userId, name, isPublic }) => {
slug,
});
- return { playlist: formatPlaylist(playlist) };
+ const formatted = formatPlaylist(playlist);
+ if (!playlist.is_public) {
+ formatted.secret_token = playlist.secret_token;
+ }
+ return { playlist: formatted };
};
// ============================================================
@@ -182,7 +186,6 @@ exports.listPlaylists = async ({
items.map(async (playlist) => {
const formatted = formatPlaylist(playlist);
- // Smart fallback: if playlist has no custom cover, use first track cover.
if (!formatted.cover_image) {
const topTrack = await playlistModel.getTopTrackArt(formatted.playlist_id);
formatted.cover_image = topTrack?.cover_image || null;
@@ -257,15 +260,12 @@ exports.getPlaylist = async ({ playlistId, userId, secretToken, includeTracks })
return response;
};
-// ============================================================
-// ENDPOINT 4 — PATCH /playlists/:playlist_id
-// ============================================================
// ============================================================
// ENDPOINT 4 — PATCH /playlists/:playlist_id
// ============================================================
exports.updatePlaylist = async ({
playlistId, userId, name, description, isPublic,
- coverImageFile, releaseDate, releaseDateProvided, genreId, genreIdProvided, subtype, slug, tags,
+ coverImageFile, clearCoverImage, releaseDate, releaseDateProvided, genreId, genreIdProvided, subtype, slug, tags,
}) => {
// ── Fetch ─────────────────────────────────────────────────
const playlist = await playlistModel.findPlaylistById(playlistId);
@@ -287,6 +287,7 @@ exports.updatePlaylist = async ({
name, description, isPublic, coverImageFile,
subtype, slug, tags,
].every((v) => v === undefined || v === null)
+ && !clearCoverImage
&& !releaseDateProvided
&& !genreIdProvided;
@@ -358,16 +359,6 @@ exports.updatePlaylist = async ({
'VALIDATION_FAILED'
);
}
-
- const today = new Date();
- const todayUtc = `${today.getUTCFullYear()}-${String(today.getUTCMonth() + 1).padStart(2, '0')}-${String(today.getUTCDate()).padStart(2, '0')}`;
- if (releaseDate < todayUtc) {
- throw new AppError(
- 'release_date cannot be in the past.',
- 400,
- 'VALIDATION_FAILED'
- );
- }
}
// ── Genre ID validation ───────────────────────────────────
@@ -404,12 +395,14 @@ exports.updatePlaylist = async ({
}
// ── Secret token logic ────────────────────────────────────
+ // Token is generated once and never cleared — old share links always work
let secretToken;
- if (isPublic === false && !playlist.secret_token) {
+ if (!playlist.secret_token) {
+ // No token exists yet — generate one regardless of current privacy setting
+ // so it's ready whenever the playlist is made private
secretToken = generateSecretToken();
- } else if (isPublic === true) {
- secretToken = null;
}
+// Never set to null — token persists even when playlist is public
// ── Slug logic (explicit slug takes priority, else derive from name) ──
let resolvedSlug;
@@ -424,14 +417,16 @@ exports.updatePlaylist = async ({
const normalizedSlug = generateSlug(slug);
resolvedSlug = await makeUniqueSlug(normalizedSlug, playlistId);
- } else if (name !== undefined) {
- const baseSlug = generateSlug(name);
- resolvedSlug = await makeUniqueSlug(baseSlug, playlistId);
}
// ── Cover image upload ────────────────────────────────────
let coverImageUrl;
- if (coverImageFile) {
+ if (clearCoverImage) {
+ if (playlist.cover_image) {
+ await storageService.deleteAllVersionsByUrl(playlist.cover_image);
+ }
+ coverImageUrl = null;
+ } else if (coverImageFile) {
if (playlist.cover_image) {
await storageService.deleteAllVersionsByUrl(playlist.cover_image);
}
@@ -465,8 +460,20 @@ exports.updatePlaylist = async ({
updatedTags = await playlistModel.replacePlaylistTags(playlistId, tags);
}
- const formatted = formatPlaylist(updated || playlist);
- formatted.tags = updatedTags;
+ const finalPlaylist = updated || playlist;
+ const formatted = formatPlaylist(finalPlaylist);
+ formatted.tags = updatedTags;
+
+ // Keep update response consistent with GET details cover fallback behavior.
+ if (!formatted.cover_image) {
+ const topTrack = await playlistModel.getTopTrackArt(playlistId);
+ formatted.cover_image = topTrack?.cover_image || null;
+ }
+
+ // Return secret_token to owner if playlist is private
+ if (!finalPlaylist.is_public) {
+ formatted.secret_token = finalPlaylist.secret_token || playlist.secret_token;
+ }
return { playlist: formatted };
};
@@ -582,11 +589,6 @@ exports.addTrack = async ({ playlistId, userId, trackId, position }) => {
// 7. Return updated playlist with all tracks
const tracks = await playlistModel.findPlaylistTracks(playlistId);
const formatted = formatPlaylist(playlist);
-
- if (!formatted.cover_image && tracks.length > 0) {
- formatted.cover_image = tracks[0].cover_image || null;
- }
-
return {
playlist: {
...formatted,
@@ -594,4 +596,56 @@ exports.addTrack = async ({ playlistId, userId, trackId, position }) => {
tracks,
},
};
+};
+
+// ============================================================
+// ENDPOINT 7 — GET /playlists/:playlist_id/tracks
+// ============================================================
+exports.getPlaylistTracks = async ({
+ playlistId, userId, secretToken, page, limit,
+}) => {
+ // 1. Fetch playlist to verify it exists and check access
+ const playlist = await playlistModel.findPlaylistById(playlistId);
+ if (!playlist) {
+ throw new AppError('Playlist not found.', 404, 'PLAYLIST_NOT_FOUND');
+ }
+
+ // 2. Privacy check
+ if (!playlist.is_public) {
+ const isOwner = userId && playlist.owner_user_id === userId;
+ const hasToken = secretToken && secretToken === playlist.secret_token;
+ if (!isOwner && !hasToken) {
+ throw new AppError(
+ 'You do not have access to this playlist.',
+ 403,
+ 'PLAYLIST_ACCESS_DENIED'
+ );
+ }
+ }
+
+ // 3. Pagination
+ const parsedLimit = Math.min(parseInt(limit) || 20, 100);
+ const parsedPage = Math.max(parseInt(page) || 1, 1);
+ const offset = (parsedPage - 1) * parsedLimit;
+
+ // 4. Fetch paginated tracks
+ const { rows, total } = await playlistModel.findPlaylistTracksPaginated(
+ playlistId,
+ { limit: parsedLimit, offset }
+ );
+
+ const totalPages = Math.ceil(total / parsedLimit);
+
+ return {
+ playlist_id: playlistId,
+ tracks: rows,
+ pagination: {
+ page: parsedPage,
+ per_page: parsedLimit,
+ total_items: total,
+ total_pages: totalPages,
+ has_next: parsedPage < totalPages,
+ has_prev: parsedPage > 1,
+ },
+ };
};
\ No newline at end of file
From 9229135ab817e21fa897435030a1c3221d5b71f9 Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Sat, 4 Apr 2026 02:04:53 +0200
Subject: [PATCH 152/535]
feat(playlists):PATCH/playlists/{playlist_id}/tracks/reorder
- Created Endpoint 8: PATCH /playlists/:playlist_id/reorder to support drag-and-drop sorting.
- Implemented bulk update logic to reassign 'position' values for multiple track IDs in a single transaction.
- Added ownership validation to ensure only the playlist creator can modify the playback order.
- Integrated a data integrity check to verify all provided track IDs belong to the target playlist before updating
---
src/controllers/playlists.controller.js | 19 +++++++
src/models/playlist.model.js | 58 +++++++++++++++++++
src/routes/playlists.routes.js | 7 +++
src/services/playlists.service.js | 76 +++++++++++++++++++++++++
4 files changed, 160 insertions(+)
diff --git a/src/controllers/playlists.controller.js b/src/controllers/playlists.controller.js
index f29c8c8..ce0c6b3 100644
--- a/src/controllers/playlists.controller.js
+++ b/src/controllers/playlists.controller.js
@@ -254,4 +254,23 @@ exports.getPlaylistTracks = async (req, res) => {
});
return success(res, data, 'Playlist tracks fetched successfully.');
+};
+
+// ============================================================
+// ENDPOINT 8 — PATCH /playlists/:playlist_id/tracks/reorder
+// ============================================================
+exports.reorderPlaylistTracks = async (req, res) => {
+ const userId = req.user?.sub;
+ if (!userId) return error(res, 'UNAUTHORIZED', 'Authentication required.', 401);
+
+ const { playlist_id } = req.params;
+ const { items } = req.body;
+
+ const data = await service.reorderPlaylistTracks({
+ playlistId: playlist_id,
+ userId,
+ items,
+ });
+
+ return success(res, data, 'Playlist tracks reordered successfully.');
};
\ No newline at end of file
diff --git a/src/models/playlist.model.js b/src/models/playlist.model.js
index a6dae43..6b7bdd0 100644
--- a/src/models/playlist.model.js
+++ b/src/models/playlist.model.js
@@ -518,4 +518,62 @@ exports.findPlaylistTracksPaginated = async (playlistId, { limit, offset }) => {
rows,
total: countResult.rows[0].total,
};
+};
+
+// ============================================================
+// ENDPOINT 8 — PATCH /playlists/:playlist_id/tracks/reorder
+// ============================================================
+exports.getAllTracksInPlaylist = async (playlistId) => {
+ const { rows } = await db.query(
+ `SELECT track_id FROM playlist_tracks WHERE playlist_id = $1`,
+ [playlistId]
+ );
+ return rows.map(r => r.track_id);
+};
+
+exports.reorderTracks = async (playlistId, items) => {
+ const client = await db.connect();
+ const TEMP_POSITION_OFFSET = 1000000;
+
+ try {
+ await client.query('BEGIN');
+
+ // Lock current playlist tracks to prevent concurrent writes during reorder.
+ await client.query(
+ `SELECT track_id
+ FROM playlist_tracks
+ WHERE playlist_id = $1
+ FOR UPDATE`,
+ [playlistId]
+ );
+
+ // Move all current positions out of the way first so final assignments
+ // cannot collide with the unique (playlist_id, position) index.
+ await client.query(
+ `UPDATE playlist_tracks
+ SET position = position + $1
+ WHERE playlist_id = $2`,
+ [TEMP_POSITION_OFFSET, playlistId]
+ );
+
+ for (const item of items) {
+ const result = await client.query(
+ `UPDATE playlist_tracks
+ SET position = $1
+ WHERE playlist_id = $2 AND track_id = $3`,
+ [item.position, playlistId, item.track_id]
+ );
+
+ if (result.rowCount !== 1) {
+ throw new Error('PLAYLIST_REORDER_TRACK_NOT_FOUND');
+ }
+ }
+
+ await client.query('COMMIT');
+ } catch (err) {
+ await client.query('ROLLBACK');
+ throw err;
+ } finally {
+ client.release();
+ }
};
\ No newline at end of file
diff --git a/src/routes/playlists.routes.js b/src/routes/playlists.routes.js
index dea3171..214af99 100644
--- a/src/routes/playlists.routes.js
+++ b/src/routes/playlists.routes.js
@@ -39,6 +39,13 @@ router.get(
asyncHandler(controller.getPlaylistTracks)
);
+//PATCH /playlists/:playlist_id/tracks/reorder
+router.patch(
+ '/:playlist_id/tracks/reorder',
+ authenticate,
+ asyncHandler(controller.reorderPlaylistTracks)
+);
+
// DELETE /playlists/:playlist_id
router.delete(
'/:playlist_id',
diff --git a/src/services/playlists.service.js b/src/services/playlists.service.js
index 759753e..2cf7774 100644
--- a/src/services/playlists.service.js
+++ b/src/services/playlists.service.js
@@ -648,4 +648,80 @@ exports.getPlaylistTracks = async ({
has_prev: parsedPage > 1,
},
};
+};
+
+// ============================================================
+// ENDPOINT 8 — PATCH /playlists/:playlist_id/tracks/reorder
+// ============================================================
+exports.reorderPlaylistTracks = async ({ playlistId, userId, items }) => {
+ // 1. Fetch playlist
+ const playlist = await playlistModel.findPlaylistById(playlistId);
+ if (!playlist) {
+ throw new AppError('Playlist not found.', 404, 'PLAYLIST_NOT_FOUND');
+ }
+
+ // 2. Only owner can reorder
+ if (playlist.owner_user_id !== userId) {
+ throw new AppError(
+ 'You are not allowed to modify this playlist.',
+ 403,
+ 'PLAYLIST_FORBIDDEN'
+ );
+ }
+
+ // 3. items must be an array
+ if (!Array.isArray(items) || items.length === 0) {
+ throw new AppError(
+ 'items must be a non-empty array.',
+ 400,
+ 'VALIDATION_FAILED'
+ );
+ }
+
+ // 4. Must provide full list
+ const existingIds = await playlistModel.getAllTracksInPlaylist(playlistId);
+ if (items.length !== existingIds.length) {
+ throw new AppError(
+ 'Reorder requires the full list of playlist tracks.',
+ 422,
+ 'PLAYLIST_REORDER_REQUIRES_FULL_LIST'
+ );
+ }
+
+ // 5. All track_ids must exist in playlist
+ const incomingIds = items.map(i => i.track_id);
+ const missing = incomingIds.filter(id => !existingIds.includes(id));
+ if (missing.length > 0) {
+ throw new AppError(
+ 'One or more tracks are not in this playlist.',
+ 404,
+ 'PLAYLIST_TRACK_NOT_FOUND'
+ );
+ }
+
+ // 6. Positions must be 1..N with no gaps and no duplicates
+ const positions = items.map(i => i.position).sort((a, b) => a - b);
+ const isValid = positions.every((p, i) => p === i + 1);
+ if (!isValid) {
+ throw new AppError(
+ 'Positions must start at 1 with no gaps and no duplicates.',
+ 422,
+ 'PLAYLIST_POSITIONS_INVALID'
+ );
+ }
+
+ // 7. Perform reorder in a transaction
+ await playlistModel.reorderTracks(playlistId, items);
+
+ // 8. Return updated track list
+ const { rows, total } = await playlistModel.findPlaylistTracksPaginated(
+ playlistId,
+ { limit: 100, offset: 0 }
+ );
+
+ return {
+ playlist_id: playlistId,
+ tracks: rows,
+ total,
+ };
};
\ No newline at end of file
From 6fd717854ceaf94c88c4d8644b9eb83442fc8133 Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Sat, 4 Apr 2026 02:40:19 +0200
Subject: [PATCH 153/535]
feat(playlists):DELETE/playlists/{playlist_id}/tracks/{track_id}
-Added Endpoint 7: DELETE /playlists/:playlist_id/tracks/:track_id to allow selective track removal from a specific playlist
-Implemented strict authorization checks to ensure only the playlist creator can modify the tracklist, preventing unauthorized deletions.
-Logic specifically targets and removes the association in the playlist_tracks table without affecting the master record in the tracks table
-Integrated an atomic decrement for the track_count column in the playlists table to maintain accurate data synchronization across the UI
-Triggered a fallback check to ensure the playlist cover_image remains valid if the track at Position 1 is the one being removed
---
src/controllers/playlists.controller.js | 28 +++++++++
src/models/playlist.model.js | 76 +++++++++++++++++++++++--
src/routes/playlists.routes.js | 7 +++
src/services/playlists.service.js | 42 ++++++++++++++
4 files changed, 149 insertions(+), 4 deletions(-)
diff --git a/src/controllers/playlists.controller.js b/src/controllers/playlists.controller.js
index ce0c6b3..d01b8e1 100644
--- a/src/controllers/playlists.controller.js
+++ b/src/controllers/playlists.controller.js
@@ -273,4 +273,32 @@ exports.reorderPlaylistTracks = async (req, res) => {
});
return success(res, data, 'Playlist tracks reordered successfully.');
+};
+
+// ============================================================
+// ENDPOINT 7 — DELETE /playlists/:playlist_id/tracks/:track_id
+// ============================================================
+exports.removeTrack = async (req, res) => {
+ const userId = req.user?.sub;
+ if (!userId) return error(res, 'UNAUTHORIZED', 'Authentication required.', 401);
+
+ const { playlist_id, track_id } = req.params;
+
+ if (!validateRequiredFields(res, [
+ { value: playlist_id, name: 'Playlist id' },
+ { value: track_id, name: 'Track id' },
+ ])) return;
+
+ if (!validateUuidFields(res, [
+ { value: playlist_id, name: 'Playlist id' },
+ { value: track_id, name: 'Track id' },
+ ])) return;
+
+ const data = await service.removeTrack({
+ playlistId: playlist_id,
+ userId,
+ trackId: track_id,
+ });
+
+ return success(res, data.playlist, 'Track removed from playlist successfully.');
};
\ No newline at end of file
diff --git a/src/models/playlist.model.js b/src/models/playlist.model.js
index 6b7bdd0..cfd4732 100644
--- a/src/models/playlist.model.js
+++ b/src/models/playlist.model.js
@@ -460,11 +460,23 @@ exports.getMaxPosition = async (playlistId) => {
* Shift existing tracks down to make room at a given position.
*/
exports.shiftPositionsDown = async (playlistId, fromPosition) => {
+ const TEMP_POSITION_OFFSET = 1000000;
+ const threshold = fromPosition + TEMP_POSITION_OFFSET;
+
+ // Phase 1: move affected rows out of range
+ await db.query(
+ `UPDATE playlist_tracks
+ SET position = position + $1
+ WHERE playlist_id = $2 AND position >= $3`,
+ [TEMP_POSITION_OFFSET, playlistId, fromPosition]
+ );
+
+ // Phase 2: bring rows back shifted by +1
await db.query(
`UPDATE playlist_tracks
- SET position = position + 1
- WHERE playlist_id = $1 AND position >= $2`,
- [playlistId, fromPosition]
+ SET position = position - $1 + 1
+ WHERE playlist_id = $2 AND position >= $3`,
+ [TEMP_POSITION_OFFSET, playlistId, threshold]
);
};
@@ -576,4 +588,60 @@ exports.reorderTracks = async (playlistId, items) => {
} finally {
client.release();
}
-};
\ No newline at end of file
+};
+
+// ============================================================
+// ENDPOINT 7 — DELETE /playlists/:playlist_id/tracks/:track_id
+// ============================================================
+
+/**
+ * Remove a specific track from a playlist.
+ * Returns true if removed, false if it wasn't there.
+ */
+exports.removeTrackFromPlaylist = async (playlistId, trackId) => {
+ const client = await db.connect();
+ const TEMP_POSITION_OFFSET = 1000000;
+ try {
+ await client.query('BEGIN');
+
+ const deleted = await client.query(
+ `DELETE FROM playlist_tracks
+ WHERE playlist_id = $1 AND track_id = $2
+ RETURNING position`,
+ [playlistId, trackId]
+ );
+
+ if (deleted.rowCount === 0) {
+ await client.query('COMMIT');
+ return false;
+ }
+
+ const removedPosition = deleted.rows[0].position;
+ const threshold = removedPosition + TEMP_POSITION_OFFSET;
+
+ // Phase 1: move affected rows out of range
+ await client.query(
+ `UPDATE playlist_tracks
+ SET position = position + $1
+ WHERE playlist_id = $2 AND position > $3`,
+ [TEMP_POSITION_OFFSET, playlistId, removedPosition]
+ );
+
+ // Phase 2: bring rows back shifted by -1
+ await client.query(
+ `UPDATE playlist_tracks
+ SET position = position - $1 - 1
+ WHERE playlist_id = $2 AND position > $3`,
+ [TEMP_POSITION_OFFSET, playlistId, threshold]
+ );
+
+ await client.query('COMMIT');
+ return true;
+ } catch (err) {
+ await client.query('ROLLBACK');
+ throw err;
+ } finally {
+ client.release();
+ }
+};
+
diff --git a/src/routes/playlists.routes.js b/src/routes/playlists.routes.js
index 214af99..1d9c227 100644
--- a/src/routes/playlists.routes.js
+++ b/src/routes/playlists.routes.js
@@ -60,4 +60,11 @@ router.post(
asyncHandler(controller.addTrack)
);
+// DELETE /playlists/:playlist_id/tracks/:track_id — Remove track
+router.delete(
+ '/:playlist_id/tracks/:track_id',
+ authenticate,
+ asyncHandler(controller.removeTrack)
+);
+
module.exports = router;
\ No newline at end of file
diff --git a/src/services/playlists.service.js b/src/services/playlists.service.js
index 2cf7774..4b70359 100644
--- a/src/services/playlists.service.js
+++ b/src/services/playlists.service.js
@@ -724,4 +724,46 @@ exports.reorderPlaylistTracks = async ({ playlistId, userId, items }) => {
tracks: rows,
total,
};
+};
+
+// ============================================================
+// ENDPOINT 7 — DELETE /playlists/:playlist_id/tracks/:track_id
+// ============================================================
+
+/**
+ * Removes a track from a playlist and re-normalizes positions.
+ * - Owner only
+ * - Returns 404 if track is not in the playlist
+ * - Re-normalizes positions after removal (1..N, no gaps)
+ */
+exports.removeTrack = async ({ playlistId, userId, trackId }) => {
+ // 1. Fetch playlist
+ const playlist = await playlistModel.findPlaylistById(playlistId);
+ if (!playlist) {
+ throw new AppError('Playlist not found.', 404, 'PLAYLIST_NOT_FOUND');
+ }
+
+ // 2. Owner check
+ checkOwner(playlist, userId);
+
+ // 3. Remove the track — returns false if it wasn't in the playlist
+ const removed = await playlistModel.removeTrackFromPlaylist(playlistId, trackId);
+ if (!removed) {
+ throw new AppError(
+ 'Track not found in this playlist.',
+ 404,
+ 'PLAYLIST_TRACK_NOT_FOUND'
+ );
+ }
+
+ // 4. Return updated playlist with all remaining tracks
+ const tracks = await playlistModel.findPlaylistTracks(playlistId);
+ const formatted = formatPlaylist(playlist);
+ return {
+ playlist: {
+ ...formatted,
+ track_count: tracks.length,
+ tracks,
+ },
+ };
};
\ No newline at end of file
From ee3b3d2002b95a0fd29d0982b7195e9a2cdd151f Mon Sep 17 00:00:00 2001
From: alyaa_m
Date: Sat, 4 Apr 2026 03:00:52 +0200
Subject: [PATCH 154/535] feat(playlists):POST/playlists/{playlist_id}/embed
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Endpoint Implementation: Created GET /playlists/:id/embed to generate portable player integrations for external sites.
Access Control: Integrated checkAccess utility to strictly enforce privacy rules, supporting is_public checks, owner authentication, and secret_token validation for private sharing.
Param Sanitization: Implemented strict clamping and defaults for width (200–1200px), height (100–600px), and theme (light/dark) to ensure iframe stability.
Iframe Generation: Logic dynamically builds a standard HTML