From ef8db0988b4617583d481b1f0ced9226fb77d667 Mon Sep 17 00:00:00 2001 From: Isuru Bimsara Date: Sat, 15 Aug 2026 00:24:36 +0530 Subject: [PATCH 01/16] Implement password validation and update user model roles --- app/controllers/user.controller.js | 35 ++++++++++++++----- app/models/user/user.model.js | 14 +------- app/utils/validation/validatePassword.util.js | 31 ++++++++++++++++ 3 files changed, 58 insertions(+), 22 deletions(-) create mode 100644 app/utils/validation/validatePassword.util.js diff --git a/app/controllers/user.controller.js b/app/controllers/user.controller.js index 8da53bc..2005a43 100644 --- a/app/controllers/user.controller.js +++ b/app/controllers/user.controller.js @@ -11,6 +11,7 @@ const db = require("../models"); const { hashPassword } = require("../utils/hashPassword.util"); +const { validatePassword } = require("../utils/validation/validatePassword.util"); const { generateUserId, generateId } = require("../utils/idGen.util"); const { logActivity } = require("../services/activity.service"); const { sendMail } = require("../utils/mail.util"); @@ -26,15 +27,10 @@ exports.createNewUser = async (req, res) => { firstName, lastName, email, - role, - roleID, + password, accountType, - department, } = req.body; - const hashedPassword = await hashPassword(process.env.DEFAULT_PASSWORD); - const userID = generateUserId(); - const userExists = await User.findOne({ where: { email } }); if (userExists) { await transaction.rollback(); @@ -45,6 +41,29 @@ exports.createNewUser = async (req, res) => { }); } + let pass; + + if(accountType === "admin" || accountType === "superadmin" || accountType === "manager" || accountType === "support_agent") { + pass = process.env.DEFAULT_PASSWORD; + }else{ + pass = password; + } + + const validatePasswordResult = validatePassword(pass); + + if (!validatePasswordResult) { + await transaction.rollback(); + return res.status(400).send({ + success: false, + message: "Password does not meet the required criteria", + }); + } + + const hashedPassword = await hashPassword(pass); + const userID = generateUserId(); + + + const newUser = await User.create( { id: userID, @@ -53,12 +72,10 @@ exports.createNewUser = async (req, res) => { email, password: hashedPassword, accountType, - role, - roleID: roleID || "N/A", - department: department || null, }, { transaction }, ); + const newProfile = await Profile.create( { diff --git a/app/models/user/user.model.js b/app/models/user/user.model.js index 366f779..fba021d 100644 --- a/app/models/user/user.model.js +++ b/app/models/user/user.model.js @@ -36,20 +36,8 @@ module.exports = (sequelize, DataTypes) => { allowNull: false }, accountType: { - type: DataTypes.ENUM("admin", "management", "team_head", "user"), + type: DataTypes.ENUM("user", "admin", "superadmin", "manager", "business_customer", "rider", "customer","support_agent"), defaultValue: "user" - }, - role: { - type: DataTypes.STRING, - allowNull: false - }, - roleID:{ - type: DataTypes.STRING, - allowNull: false - }, - department: { - type: DataTypes.STRING, - allowNull: true } }, { diff --git a/app/utils/validation/validatePassword.util.js b/app/utils/validation/validatePassword.util.js new file mode 100644 index 0000000..51ce82b --- /dev/null +++ b/app/utils/validation/validatePassword.util.js @@ -0,0 +1,31 @@ +const validatePassword = (password) => { + // Check if password is a string + if (typeof password !== "string") { + return false; + } + + // At least 1 uppercase letter + const hasUppercase = /[A-Z]/.test(password); + + // At least 1 lowercase letter + const hasLowercase = /[a-z]/.test(password); + + // At least 1 symbol + const hasSymbol = /[^A-Za-z0-9]/.test(password); + + // At least 4 numbers + const numberCount = (password.match(/[0-9]/g) || []).length; + + const hasFourNumbers = numberCount >= 4; + + return ( + hasUppercase && + hasLowercase && + hasSymbol && + hasFourNumbers + ); +}; + +module.exports = { + validatePassword, +}; \ No newline at end of file From c3d9f899a11439082ce923efe4f493368bff987c Mon Sep 17 00:00:00 2001 From: Isuru Bimsara Date: Mon, 17 Aug 2026 16:52:44 +0530 Subject: [PATCH 02/16] Implement user registration and email verification features --- Documentation/User-API.md | 103 +++++- app/config/s3.config.js | 18 +- app/controllers/user.controller.js | 341 ++++++++++++++++-- app/models/index.js | 1 + app/models/user/emailVerification.model.js | 46 +++ app/models/user/user.model.js | 27 +- app/routes/user.routes.js | 9 +- app/templates/emails/emailVerification.html | 239 ++++++++++++ app/utils/emailVerification.util.js | 28 ++ app/utils/validation/validateEmail.util.js | 21 ++ app/utils/validation/validatePassword.util.js | 1 + 11 files changed, 781 insertions(+), 53 deletions(-) create mode 100644 app/models/user/emailVerification.model.js create mode 100644 app/templates/emails/emailVerification.html create mode 100644 app/utils/emailVerification.util.js create mode 100644 app/utils/validation/validateEmail.util.js diff --git a/Documentation/User-API.md b/Documentation/User-API.md index 6a62b3c..f57aa43 100644 --- a/Documentation/User-API.md +++ b/Documentation/User-API.md @@ -1,10 +1,20 @@ # User API -#### Create New User +#### Create New Customer Account + +Creates a new customer account and sends an email verification link. + +The account is initially created with: + +```text +accountType = customer +accountStatus = PENDING_VERIFICATION +emailVerifiedAt = null +``` **Endpoint** -``` +```text POST: http://localhost:3070/api/user ``` @@ -12,33 +22,94 @@ POST: http://localhost:3070/api/user ```json { - "firstName": "Jhon", - "lastName": "Doe", - "email": "kalanajayasekara@niolla.lk", - "role": "System Developer", - "accountType": "admin", - "department": "IT Department" + "firstName": "Isuru", + "lastName": "Bimsara", + "email": "ibimsara00@gmail.com", + "password": "Hello@12346" } ``` -**Respond** +**Response** ```json { "success": true, "message": "User Created Successfully", "data": { - "id": "usr_763107d9-b56f-4b81-9d86-1889f98a6e6c", - "firstName": "Jhon", - "lastName": "Doe", - "email": "kalanajayasekara@niolla.lk", - "accountType": "admin", - "role": "System Developer", - "department": "IT Department" + "id": "usr_ei6i4n49", + "firstName": "Isuru", + "lastName": "Bimsara", + "email": "ibimsara00@gmail.com", + "accountType": "customer" } } ``` +After registration, the user receives an email containing a verification link. +``` + +#### Verify Email + +Verifies the customer's email using the raw verification token received by email. + +The backend hashes the received token and compares the resulting hash with the `tokenHash` stored in the `email_verifications` table. + +The token must: + +```text +Exist in the database +Not have been used +Not have expired +``` + +**Endpoint** + +```text +POST: http://localhost:3070/api/user/verify-email +``` + +**Request Body** + +```json +{ + "token": "RAW_VERIFICATION_TOKEN_FROM_EMAIL" +} +``` + +**Successful Response** + +```json +{ + "success": true, + "message": "Email verified successfully. Your account is now active." +} +``` + +After successful verification, the user record changes from: + +```text +accountStatus = PENDING_VERIFICATION +emailVerifiedAt = NULL +``` + +to: + +```text +accountStatus = ACTIVE +emailVerifiedAt = +``` + +The verification record is also updated: + +```text +usedAt = +``` + +This prevents the same verification token from being successfully used again. + +--- + + #### Get All Users **Endpoint** diff --git a/app/config/s3.config.js b/app/config/s3.config.js index e49da4b..b85cad3 100644 --- a/app/config/s3.config.js +++ b/app/config/s3.config.js @@ -9,14 +9,14 @@ // app/config/s3.config.js -const { S3Client } = require("@aws-sdk/client-s3"); +// const { S3Client } = require("@aws-sdk/client-s3"); -const s3 = new S3Client({ - region: process.env.AWS_REGION, - credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, - }, -}); +// const s3 = new S3Client({ +// region: process.env.AWS_REGION, +// credentials: { +// accessKeyId: process.env.AWS_ACCESS_KEY_ID, +// secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, +// }, +// }); -module.exports = s3; \ No newline at end of file +// module.exports = s3; \ No newline at end of file diff --git a/app/controllers/user.controller.js b/app/controllers/user.controller.js index 2005a43..2f5f845 100644 --- a/app/controllers/user.controller.js +++ b/app/controllers/user.controller.js @@ -9,14 +9,18 @@ // app/controllers/user.controller.js +const { Op } = require("sequelize"); const db = require("../models"); const { hashPassword } = require("../utils/hashPassword.util"); const { validatePassword } = require("../utils/validation/validatePassword.util"); +const { validateEmail } = require("../utils/validation/validateEmail.util"); const { generateUserId, generateId } = require("../utils/idGen.util"); const { logActivity } = require("../services/activity.service"); const { sendMail } = require("../utils/mail.util"); +const {generateEmailVerificationToken, hashEmailVerificationToken} = require("../utils/emailVerification.util"); const User = db.User; const Profile = db.Profile; +const EmailVerification = db.EmailVerification; // Create a new user exports.createNewUser = async (req, res) => { @@ -28,9 +32,29 @@ exports.createNewUser = async (req, res) => { lastName, email, password, - accountType, } = req.body; + if (!firstName || !lastName || !email || !password) { + await transaction.rollback(); + + return res.status(400).send({ + success: false, + message: + "First name, last name, email and password are required", + }); + } + + const emailValid = validateEmail(email); + +if (!emailValid) { + await transaction.rollback(); + + return res.status(400).send({ + success: false, + message: "Invalid email address", + }); +} + const userExists = await User.findOne({ where: { email } }); if (userExists) { await transaction.rollback(); @@ -41,15 +65,15 @@ exports.createNewUser = async (req, res) => { }); } - let pass; + // let pass; - if(accountType === "admin" || accountType === "superadmin" || accountType === "manager" || accountType === "support_agent") { - pass = process.env.DEFAULT_PASSWORD; - }else{ - pass = password; - } + // if(accountType === "admin" || accountType === "superadmin" || accountType === "manager" || accountType === "support_agent") { + // pass = process.env.DEFAULT_PASSWORD; + // }else{ + // pass = password; + // } - const validatePasswordResult = validatePassword(pass); + const validatePasswordResult = validatePassword(password); if (!validatePasswordResult) { await transaction.rollback(); @@ -59,10 +83,21 @@ exports.createNewUser = async (req, res) => { }); } - const hashedPassword = await hashPassword(pass); + const hashedPassword = await hashPassword(password); const userID = generateUserId(); + const verificationToken = generateEmailVerificationToken(); + + const verificationTokenHash = + hashEmailVerificationToken( + verificationToken + ); + + const verificationExpiresAt = + new Date( + Date.now() + + 30 * 60 * 1000 + ); - const newUser = await User.create( { @@ -71,7 +106,9 @@ exports.createNewUser = async (req, res) => { lastName, email, password: hashedPassword, - accountType, + // accountType, + accountStatus: "PENDING_VERIFICATION", + emailVerifiedAt: null, }, { transaction }, ); @@ -91,20 +128,109 @@ exports.createNewUser = async (req, res) => { { transaction }, ); - await sendMail({ - to: email, - subject: "Welcome - Ocenic Titan", - templateName: "welcome", - templateVars: { - firstName: firstName, - email: email, - password: process.env.DEFAULT_PASSWORD, + await EmailVerification.create( + { + id: + generateId(), + + user_id: + newUser.id, + + tokenHash: + verificationTokenHash, + + expiresAt: + verificationExpiresAt, + + usedAt: + null, }, - text: `Hello ${firstName}, your account has been created successfully.`, - }); + { + transaction, + } + ); await transaction.commit(); + const confirmationLink = + `${process.env.FRONTEND_URL}/verify-email?token=${verificationToken}`; + + + // ================================================== + // 18. Send verification email + // ================================================== + + try { + + await sendMail({ + to: + email, + + subject: + "Confirm Your ZUMRI Account", + + templateName: + "emailVerification", + + templateVars: { + + customer_name: + firstName, + + confirmation_link: + confirmationLink, + }, + + text: + `Hello ${firstName}, please verify your ZUMRI account using this link: ${confirmationLink}`, + }); + + + } catch (mailError) { + + // The database transaction is already committed. + // + // Do NOT delete the user here. + // + // User remains: + // PENDING_VERIFICATION + // + // Later resend-verification can send another email. + + console.error( + "VERIFICATION EMAIL ERROR:", + mailError + ); + + + return res.status(201).send({ + success: true, + + message: + "Account created, but verification email could not be sent. Please request a new verification email.", + + data: { + id: + newUser.id, + + firstName: + newUser.firstName, + + lastName: + newUser.lastName, + + email: + newUser.email, + + accountType: + newUser.accountType, + + accountStatus: + newUser.accountStatus, + }, + }); + } + await logActivity({ user: req.user, description: `Created New User with ID: ${newUser.id}`, @@ -121,8 +247,8 @@ exports.createNewUser = async (req, res) => { lastName: newUser.lastName, email: newUser.email, accountType: newUser.accountType, - role: newUser.role, - department: newUser.department, + // role: newUser.role, + // department: newUser.department, }, }); } catch (error) { @@ -136,6 +262,175 @@ exports.createNewUser = async (req, res) => { } }; +// Verify customer email +exports.verifyEmail = async (req, res) => { + + const transaction = + await db.sequelize.transaction(); + + try { + + // 1. Get token from request body + const { + token + } = req.body; + + + // 2. Token is required + if (!token) { + + await transaction.rollback(); + + return res.status(400).send({ + success: false, + message: + "Verification token is required", + }); + } + + + // 3. Hash the received raw token + const tokenHash = + hashEmailVerificationToken( + token + ); + + + // 4. Find matching valid token + const verification = + await EmailVerification.findOne({ + + where: { + + tokenHash: + tokenHash, + + usedAt: + null, + + expiresAt: { + [Op.gt]: + new Date(), + }, + }, + + transaction, + }); + + + // 5. Token invalid / expired / already used + if (!verification) { + + await transaction.rollback(); + + return res.status(400).send({ + success: false, + message: + "Verification token is invalid or expired", + }); + } + + + // 6. Find user + const user = + await User.findOne({ + + where: { + id: + verification.user_id, + }, + + transaction, + }); + + + // 7. User not found + if (!user) { + + await transaction.rollback(); + + return res.status(404).send({ + success: false, + message: + "User not found", + }); + } + + + // 8. Check already verified + if ( + user.accountStatus === "ACTIVE" && + user.emailVerifiedAt + ) { + + await transaction.rollback(); + + return res.status(400).send({ + success: false, + message: + "Email is already verified", + }); + } + + + // 9. Activate account + user.accountStatus = + "ACTIVE"; + + user.emailVerifiedAt = + new Date(); + + + await user.save({ + transaction, + }); + + + // 10. Mark token as used + verification.usedAt = + new Date(); + + + await verification.save({ + transaction, + }); + + + // 11. Commit + await transaction.commit(); + + + // 12. Success + return res.status(200).send({ + success: true, + message: + "Email verified successfully. Your account is now active.", + }); + + + } catch (error) { + + if (!transaction.finished) { + + await transaction.rollback(); + + } + + + console.error( + "VERIFY EMAIL ERROR:", + error + ); + + + return res.status(500).send({ + success: false, + message: + "Failed to verify email", + }); + } +}; + // Get users with pagination (20 per page) exports.getAllUsers = async (req, res) => { try { diff --git a/app/models/index.js b/app/models/index.js index d695284..eed698c 100644 --- a/app/models/index.js +++ b/app/models/index.js @@ -41,6 +41,7 @@ db.sequelize = sequelize; // User and Authentication db.User = require("./user/user.model")(sequelize, DataTypes); +db.EmailVerification = require("./user/emailVerification.model")(sequelize,DataTypes); db.UserActivity = require("./activities/userActivities.model")(sequelize, DataTypes); db.Profile = require("./user/profile.model")(sequelize, DataTypes); diff --git a/app/models/user/emailVerification.model.js b/app/models/user/emailVerification.model.js new file mode 100644 index 0000000..a6e8517 --- /dev/null +++ b/app/models/user/emailVerification.model.js @@ -0,0 +1,46 @@ +//app/models/user/emailVerification.model.js +module.exports = (sequelize, DataTypes) => { + const EmailVerification = sequelize.define( + "EmailVerification", + { + id: { + type: DataTypes.STRING, + primaryKey: true, + }, + + user_id: { + type: DataTypes.STRING, + allowNull: false, + }, + + tokenHash: { + type: DataTypes.STRING, + allowNull: false, + }, + + expiresAt: { + type: DataTypes.DATE, + allowNull: false, + }, + + usedAt: { + type: DataTypes.DATE, + allowNull: true, + defaultValue: null, + }, + }, + { + tableName: "email_verifications", + timestamps: true, + } + ); + + EmailVerification.associate = (db) => { + EmailVerification.belongsTo(db.User, { + foreignKey: "user_id", + as: "user", + }); + }; + + return EmailVerification; +}; \ No newline at end of file diff --git a/app/models/user/user.model.js b/app/models/user/user.model.js index fba021d..4765ff6 100644 --- a/app/models/user/user.model.js +++ b/app/models/user/user.model.js @@ -36,9 +36,25 @@ module.exports = (sequelize, DataTypes) => { allowNull: false }, accountType: { - type: DataTypes.ENUM("user", "admin", "superadmin", "manager", "business_customer", "rider", "customer","support_agent"), - defaultValue: "user" - } + type: DataTypes.ENUM("admin", "superadmin", "manager", "business_customer", "rider", "customer","support_agent"), + defaultValue: "customer" + }, + accountStatus: { + type: DataTypes.ENUM( + "PENDING_VERIFICATION", + "ACTIVE", + "SUSPENDED", + "DEACTIVATED" + ), + allowNull: false, + defaultValue: "PENDING_VERIFICATION", + }, + emailVerifiedAt: { + type: DataTypes.DATE, + allowNull: true, + defaultValue: null, + }, + }, { tableName: "users", @@ -55,6 +71,11 @@ module.exports = (sequelize, DataTypes) => { foreignKey: "user_id", as: "profile", }); + + User.hasMany(db.EmailVerification, { + foreignKey: "user_id", + as: "emailVerifications", + }); }; return User; diff --git a/app/routes/user.routes.js b/app/routes/user.routes.js index ae9535f..67dbb68 100644 --- a/app/routes/user.routes.js +++ b/app/routes/user.routes.js @@ -22,11 +22,16 @@ const PERMISSIONS = require("../constants/permissions"); router.post( "/", - authenticate, - authorizedAccountType(["admin"]), + // authenticate, + // authorizedAccountType(["admin"]), userController.createNewUser ); +router.post( + "/verify-email", + userController.verifyEmail +); + router.get( "/", authenticate, diff --git a/app/templates/emails/emailVerification.html b/app/templates/emails/emailVerification.html new file mode 100644 index 0000000..4c70832 --- /dev/null +++ b/app/templates/emails/emailVerification.html @@ -0,0 +1,239 @@ + + + + + + + + Confirm Your ZUMRI Account + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

+ Hi {{customer_name}}, +

+
+

+ Welcome to ZUMRI! +

+
+

+ Your account has been successfully registered. + To complete your registration and activate your + account, please confirm your email address by + clicking the button below. +

+
+ + + Confirm Your Email + + +
+

+ If the button doesn't work, copy and paste + the following link into your browser: +

+ +

+ + {{confirmation_link}} + +

+
+

+ For security purposes, this confirmation link + will expire after a limited period. If you did + not create a ZUMRI account, you can safely + ignore this email. +

+
+

+ Thank you for joining ZUMRI. We look forward + to having you with us! +

+
+

+ Best regards,
+ ZUMRI Team +

+
+

+ © {{currentYear}} ZUMRI. + All rights reserved. +

+
+ +
+ + + \ No newline at end of file diff --git a/app/utils/emailVerification.util.js b/app/utils/emailVerification.util.js new file mode 100644 index 0000000..026e00d --- /dev/null +++ b/app/utils/emailVerification.util.js @@ -0,0 +1,28 @@ +//app/utils/emailVerification.util.js +const crypto = require("crypto"); + +/** + * Generate a cryptographically secure + * random email-verification token. + */ +const generateEmailVerificationToken = () => { + return crypto.randomBytes(32).toString("hex"); +}; + + +/** + * Hash verification token before + * storing it in database. + */ +const hashEmailVerificationToken = (token) => { + return crypto + .createHash("sha256") + .update(token) + .digest("hex"); +}; + + +module.exports = { + generateEmailVerificationToken, + hashEmailVerificationToken, +}; \ No newline at end of file diff --git a/app/utils/validation/validateEmail.util.js b/app/utils/validation/validateEmail.util.js new file mode 100644 index 0000000..bb4a0fe --- /dev/null +++ b/app/utils/validation/validateEmail.util.js @@ -0,0 +1,21 @@ +const validateEmail = (email) => { + // Must be a string + if (typeof email !== "string") { + return false; + } + + // Remove spaces + const trimmedEmail = email.trim(); + + // Basic email format validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + return emailRegex.test(trimmedEmail); +}; +const normalizeEmail = (email) => { + return email.trim().toLowerCase(); +}; + +module.exports = { + validateEmail, normalizeEmail +}; \ No newline at end of file diff --git a/app/utils/validation/validatePassword.util.js b/app/utils/validation/validatePassword.util.js index 51ce82b..4d51b21 100644 --- a/app/utils/validation/validatePassword.util.js +++ b/app/utils/validation/validatePassword.util.js @@ -1,3 +1,4 @@ +//app/utils/validation/validatePassword.util.js const validatePassword = (password) => { // Check if password is a string if (typeof password !== "string") { From 621d348eb18e163b9ca981ed54e349d52fbdb8b2 Mon Sep 17 00:00:00 2001 From: Isuru Bimsara Date: Thu, 20 Aug 2026 15:16:17 +0530 Subject: [PATCH 03/16] add the redis --- app/config/mail.config.js | 2 +- app/controllers/user.controller.js | 354 +++++++++------------ app/models/index.js | 1 - app/models/user/emailVerification.model.js | 46 --- app/models/user/user.model.js | 4 - app/utils/emailVerification.util.js | 99 +++++- 6 files changed, 244 insertions(+), 262 deletions(-) delete mode 100644 app/models/user/emailVerification.model.js diff --git a/app/config/mail.config.js b/app/config/mail.config.js index d96e447..7096483 100644 --- a/app/config/mail.config.js +++ b/app/config/mail.config.js @@ -20,5 +20,5 @@ module.exports = { user: process.env.MAIL_USER, pass: process.env.MAIL_PASS, }, - from: `Oceanic Maritime Solutions <${process.env.MAIL_FROM}>`, + from: `ZUMRI <${process.env.MAIL_FROM}>`, }; diff --git a/app/controllers/user.controller.js b/app/controllers/user.controller.js index 2f5f845..caeeda1 100644 --- a/app/controllers/user.controller.js +++ b/app/controllers/user.controller.js @@ -9,7 +9,7 @@ // app/controllers/user.controller.js -const { Op } = require("sequelize"); +// const { Op } = require("sequelize"); const db = require("../models"); const { hashPassword } = require("../utils/hashPassword.util"); const { validatePassword } = require("../utils/validation/validatePassword.util"); @@ -17,10 +17,11 @@ const { validateEmail } = require("../utils/validation/validateEmail.util"); const { generateUserId, generateId } = require("../utils/idGen.util"); const { logActivity } = require("../services/activity.service"); const { sendMail } = require("../utils/mail.util"); -const {generateEmailVerificationToken, hashEmailVerificationToken} = require("../utils/emailVerification.util"); +const { + createEmailVerification, verifyEmailVerificationToken, deleteEmailVerification} = require("../utils/emailVerification.util");; const User = db.User; const Profile = db.Profile; -const EmailVerification = db.EmailVerification; +// const EmailVerification = db.EmailVerification; // Create a new user exports.createNewUser = async (req, res) => { @@ -84,20 +85,8 @@ if (!emailValid) { } const hashedPassword = await hashPassword(password); + const userID = generateUserId(); - const verificationToken = generateEmailVerificationToken(); - - const verificationTokenHash = - hashEmailVerificationToken( - verificationToken - ); - - const verificationExpiresAt = - new Date( - Date.now() + - 30 * 60 * 1000 - ); - const newUser = await User.create( { @@ -128,37 +117,13 @@ if (!emailValid) { { transaction }, ); - await EmailVerification.create( - { - id: - generateId(), - - user_id: - newUser.id, - - tokenHash: - verificationTokenHash, - - expiresAt: - verificationExpiresAt, - - usedAt: - null, - }, - { - transaction, - } - ); - await transaction.commit(); + const verificationToken = await createEmailVerification(newUser.id); + const confirmationLink = `${process.env.FRONTEND_URL}/verify-email?token=${verificationToken}`; - - // ================================================== - // 18. Send verification email - // ================================================== try { @@ -188,15 +153,6 @@ if (!emailValid) { } catch (mailError) { - // The database transaction is already committed. - // - // Do NOT delete the user here. - // - // User remains: - // PENDING_VERIFICATION - // - // Later resend-verification can send another email. - console.error( "VERIFICATION EMAIL ERROR:", mailError @@ -246,13 +202,20 @@ if (!emailValid) { firstName: newUser.firstName, lastName: newUser.lastName, email: newUser.email, - accountType: newUser.accountType, + // accountType: newUser.accountType, // role: newUser.role, // department: newUser.department, }, }); } catch (error) { - await transaction.rollback(); + if (!transaction.finished) { + await transaction.rollback(); + } + + console.error( + "CREATE USER ERROR:", + error + ); res.status(500).send({ success: false, @@ -263,173 +226,168 @@ if (!emailValid) { }; // Verify customer email -exports.verifyEmail = async (req, res) => { +exports.verifyEmail = + async (req, res) => { - const transaction = - await db.sequelize.transaction(); - - try { - - // 1. Get token from request body - const { - token - } = req.body; + const transaction = + await db.sequelize.transaction(); - // 2. Token is required - if (!token) { + try { + const { + token, + } = req.body; - await transaction.rollback(); - return res.status(400).send({ - success: false, - message: - "Verification token is required", + if (!token) { + + await transaction.rollback(); + + + return res.status(400).send({ + success: + false, + + message: + "Verification token is required", + }); + } + + const verification = + await verifyEmailVerificationToken( + token + ); + + if (!verification) { + + await transaction.rollback(); + + + return res.status(400).send({ + success: + false, + + message: + "Verification token is invalid or expired", + }); + } + + + + const { + userId, + redisKey, + } = + verification; + + const user = + await User.findOne({ + where: { + id: + userId, + }, + + transaction, + }); + + + + if (!user) { + + await transaction.rollback(); + + await deleteEmailVerification( + redisKey + ); + + + return res.status(404).send({ + success: + false, + + message: + "User not found", + }); + } + + if ( + user.accountStatus === + "ACTIVE" && + user.emailVerifiedAt + ) { + + await transaction.rollback(); + + + // Token is no longer needed + await deleteEmailVerification( + redisKey + ); + + + return res.status(400).send({ + success: + false, + + message: + "Email is already verified", + }); + } + + + user.accountStatus = + "ACTIVE"; + + + user.emailVerifiedAt = + new Date(); + + + await user.save({ + transaction, }); - } + await transaction.commit(); - // 3. Hash the received raw token - const tokenHash = - hashEmailVerificationToken( - token + await deleteEmailVerification( + redisKey ); - // 4. Find matching valid token - const verification = - await EmailVerification.findOne({ - where: { + return res.status(200).send({ + success: + true, - tokenHash: - tokenHash, - - usedAt: - null, - - expiresAt: { - [Op.gt]: - new Date(), - }, - }, - - transaction, - }); - - - // 5. Token invalid / expired / already used - if (!verification) { - - await transaction.rollback(); - - return res.status(400).send({ - success: false, message: - "Verification token is invalid or expired", - }); - } - - - // 6. Find user - const user = - await User.findOne({ - - where: { - id: - verification.user_id, - }, - - transaction, + "Email verified successfully. Your account is now active.", }); - // 7. User not found - if (!user) { + } catch (error) { - await transaction.rollback(); + if (!transaction.finished) { + + await transaction.rollback(); + + } + + + console.error( + "VERIFY EMAIL ERROR:", + error + ); + + + return res.status(500).send({ + success: + false, - return res.status(404).send({ - success: false, message: - "User not found", + "Failed to verify email", }); } - - - // 8. Check already verified - if ( - user.accountStatus === "ACTIVE" && - user.emailVerifiedAt - ) { - - await transaction.rollback(); - - return res.status(400).send({ - success: false, - message: - "Email is already verified", - }); - } - - - // 9. Activate account - user.accountStatus = - "ACTIVE"; - - user.emailVerifiedAt = - new Date(); - - - await user.save({ - transaction, - }); - - - // 10. Mark token as used - verification.usedAt = - new Date(); - - - await verification.save({ - transaction, - }); - - - // 11. Commit - await transaction.commit(); - - - // 12. Success - return res.status(200).send({ - success: true, - message: - "Email verified successfully. Your account is now active.", - }); - - - } catch (error) { - - if (!transaction.finished) { - - await transaction.rollback(); - - } - - - console.error( - "VERIFY EMAIL ERROR:", - error - ); - - - return res.status(500).send({ - success: false, - message: - "Failed to verify email", - }); - } -}; + }; // Get users with pagination (20 per page) exports.getAllUsers = async (req, res) => { diff --git a/app/models/index.js b/app/models/index.js index eed698c..d695284 100644 --- a/app/models/index.js +++ b/app/models/index.js @@ -41,7 +41,6 @@ db.sequelize = sequelize; // User and Authentication db.User = require("./user/user.model")(sequelize, DataTypes); -db.EmailVerification = require("./user/emailVerification.model")(sequelize,DataTypes); db.UserActivity = require("./activities/userActivities.model")(sequelize, DataTypes); db.Profile = require("./user/profile.model")(sequelize, DataTypes); diff --git a/app/models/user/emailVerification.model.js b/app/models/user/emailVerification.model.js deleted file mode 100644 index a6e8517..0000000 --- a/app/models/user/emailVerification.model.js +++ /dev/null @@ -1,46 +0,0 @@ -//app/models/user/emailVerification.model.js -module.exports = (sequelize, DataTypes) => { - const EmailVerification = sequelize.define( - "EmailVerification", - { - id: { - type: DataTypes.STRING, - primaryKey: true, - }, - - user_id: { - type: DataTypes.STRING, - allowNull: false, - }, - - tokenHash: { - type: DataTypes.STRING, - allowNull: false, - }, - - expiresAt: { - type: DataTypes.DATE, - allowNull: false, - }, - - usedAt: { - type: DataTypes.DATE, - allowNull: true, - defaultValue: null, - }, - }, - { - tableName: "email_verifications", - timestamps: true, - } - ); - - EmailVerification.associate = (db) => { - EmailVerification.belongsTo(db.User, { - foreignKey: "user_id", - as: "user", - }); - }; - - return EmailVerification; -}; \ No newline at end of file diff --git a/app/models/user/user.model.js b/app/models/user/user.model.js index 4765ff6..e73e011 100644 --- a/app/models/user/user.model.js +++ b/app/models/user/user.model.js @@ -72,10 +72,6 @@ module.exports = (sequelize, DataTypes) => { as: "profile", }); - User.hasMany(db.EmailVerification, { - foreignKey: "user_id", - as: "emailVerifications", - }); }; return User; diff --git a/app/utils/emailVerification.util.js b/app/utils/emailVerification.util.js index 026e00d..2ab997d 100644 --- a/app/utils/emailVerification.util.js +++ b/app/utils/emailVerification.util.js @@ -1,19 +1,20 @@ -//app/utils/emailVerification.util.js +// app/utils/emailVerification.util.js + const crypto = require("crypto"); -/** - * Generate a cryptographically secure - * random email-verification token. - */ +const redis = require("../config/redisClient"); + +const EMAIL_VERIFICATION_TTL = + Number( + process.env.EMAIL_VERIFICATION_TTL_SECONDS + ) || 1800; + const generateEmailVerificationToken = () => { - return crypto.randomBytes(32).toString("hex"); + return crypto + .randomBytes(32) + .toString("hex"); }; - -/** - * Hash verification token before - * storing it in database. - */ const hashEmailVerificationToken = (token) => { return crypto .createHash("sha256") @@ -21,8 +22,82 @@ const hashEmailVerificationToken = (token) => { .digest("hex"); }; +const createEmailVerification = async (userId) => { + + // 1. Generate raw token + const token = + generateEmailVerificationToken(); + + + // 2. Hash token + const tokenHash = + hashEmailVerificationToken(token); + + + // 3. Create Redis key + const redisKey = + `email-verification:${tokenHash}`; + + await redis.set( + redisKey, + userId, + "EX", + EMAIL_VERIFICATION_TTL + ); + + return token; +}; + + +/** + * Check a verification token. + */ +const verifyEmailVerificationToken = + async (token) => { + + if ( + !token || + typeof token !== "string" + ) { + return null; + } + + + // 1. Hash token received from user + const tokenHash = + hashEmailVerificationToken(token); + + + // 2. Build same Redis key + const redisKey = + `email-verification:${tokenHash}`; + + + // 3. Search Redis + const userId = + await redis.get(redisKey); + + if (!userId) { + return null; + } + + + return { + userId, + redisKey, + }; + }; + +const deleteEmailVerification = + async (redisKey) => { + + await redis.del(redisKey); + }; + module.exports = { - generateEmailVerificationToken, + createEmailVerification, + verifyEmailVerificationToken, + deleteEmailVerification, hashEmailVerificationToken, }; \ No newline at end of file From b2c5e198a178e09eacf750773a0820d326d8c143 Mon Sep 17 00:00:00 2001 From: Isuru Bimsara Date: Fri, 21 Aug 2026 02:20:57 +0530 Subject: [PATCH 04/16] add create user and login user parts --- app/controllers/auth.controller.js | 117 ++++- app/controllers/user.controller.js | 443 ++++++++---------- app/models/index.js | 2 + app/models/user/businessCustomer.model.js | 65 +++ app/models/user/customer.model.js | 49 ++ app/models/user/user.model.js | 10 +- app/routes/auth.routes.js | 1 + app/templates/emails/otp.html | 12 +- app/utils/emailVerification.util.js | 110 ++--- app/utils/idGen.util.js | 10 + app/utils/jwt.util.js | 13 +- app/utils/otp.util.js | 5 +- app/utils/refreshSession.util.js | 201 ++++++++ .../users/createBusinessCustomer.util.js | 86 ++++ app/utils/users/createCustomerDetails.util.js | 54 +++ 15 files changed, 842 insertions(+), 336 deletions(-) create mode 100644 app/models/user/businessCustomer.model.js create mode 100644 app/models/user/customer.model.js create mode 100644 app/utils/refreshSession.util.js create mode 100644 app/utils/users/createBusinessCustomer.util.js create mode 100644 app/utils/users/createCustomerDetails.util.js diff --git a/app/controllers/auth.controller.js b/app/controllers/auth.controller.js index da10770..318604f 100644 --- a/app/controllers/auth.controller.js +++ b/app/controllers/auth.controller.js @@ -12,12 +12,20 @@ const { checkPassword } = require("../utils/hashPassword.util"); const { sendMail } = require("../utils/mail.util"); const { generateOTP, validateOTP } = require("../utils/otp.util"); -const {getCachedUser,clearUserCache} = require("../utils/cache.util"); +const { getCachedUser, clearUserCache } = require("../utils/cache.util"); const { generateToken } = require("../utils/jwt.util"); +const { + createRefreshSession, + validateRefreshSession, + deleteRefreshSession, +} = require("../utils/refreshSession.util"); +const db = require("../models"); const { log } = require("../utils/consoleLog.utill"); const appName = process.env.APP_NAME || "Niolla"; +const User = db.User; + // Login Step 1: Request OTP exports.loginReq = async (req, res) => { try { @@ -47,7 +55,7 @@ exports.loginReq = async (req, res) => { firstName: user.firstName, otp: otp, }, - text: `Hello ${user.firstName}, your otp is ${otp}`, + text: `Hello ${user.firstName}, your otp is ${otp}`, }); log(`OTP for ${email}: ${otp}`); @@ -65,6 +73,12 @@ exports.login = async (req, res) => { try { const { email, otp } = req.body; + if (!email || !otp) { + return res + .status(400) + .send({ success: false, message: "Email and OTP are required" }); + } + const user = await getCachedUser(email); if (!user) { @@ -73,7 +87,14 @@ exports.login = async (req, res) => { .send({ success: false, message: "User Not Found" }); } - const isValidOTP = validateOTP(email, otp); + if (user.accountStatus !== "ACTIVE") { + return res.status(403).send({ + success: false, + message: "Account is not active", + }); + } + + const isValidOTP = validateOTP(email, String(otp)); if (!isValidOTP) { return res @@ -87,16 +108,24 @@ exports.login = async (req, res) => { firstName: user.firstName, lastName: user.lastName, email: user.email, - role: user.role, accountType: user.accountType, }); + const { refreshToken } = createRefreshSession(user.id); + // 3. Set JWT as HttpOnly cookie res.cookie("access_token", token, { httpOnly: true, // JS cannot access secure: process.env.NODE_ENV === "production", // HTTPS only in prod sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", - maxAge: 24 * 60 * 60 * 1000, // 1 day + maxAge: 15 * 60 * 1000, // 1 day + }); + + res.cookie("refresh_token", refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days }); log(`JWT issued for ${email}`); @@ -112,6 +141,7 @@ exports.login = async (req, res) => { lastName: user.lastName, role: user.role, accountType: user.accountType, + accessToken: token, }, }); } catch (error) { @@ -120,6 +150,83 @@ exports.login = async (req, res) => { } }; +exports.refreshToken = async (req, res) => { + try { + const refreshToken = req.cookies?.refresh_token; + + if (!refreshToken) { + return res.status(401).send({ + success: false, + message: "Refresh token is required", + }); + } + + const session = validateRefreshSession(refreshToken); + + if (!session) { + res.clearCookie("refresh_token"); + + return res.status(401).send({ + success: false, + message: "Invalid or expired session. Please login again.", + }); + } + + const user = await User.findByPk(session.userId); + + if (!user || user.accountStatus !== "ACTIVE") { + deleteRefreshSession(session.sessionId); + + return res.status(401).send({ + success: false, + message: "Session is no longer valid", + }); + } + + // Generate new Access Token + const token = generateToken({ + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + accountType: user.accountType, + }); + + // Generate new Refresh Token + const { refreshToken: newRefreshToken } = createRefreshSession(user.id); + + deleteRefreshSession(session.sessionId); + + // Replace access cookie + res.cookie("access_token", token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", + maxAge: 15 * 60 * 1000, + }); + + // Replace refresh cookie + res.cookie("refresh_token", newRefreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", + maxAge: 7 * 24 * 60 * 60 * 1000, + }); + + return res.status(200).send({ + success: true, + message: "Session refreshed successfully", + }); + } catch (error) { + console.error("REFRESH ERROR:", error); + + return res.status(401).send({ + success: false, + message: "Invalid or expired session. Please login again.", + }); + } +}; + // Logout: Clear the JWT cookie exports.logout = (req, res) => { res.clearCookie("access_token", { diff --git a/app/controllers/user.controller.js b/app/controllers/user.controller.js index caeeda1..d938f9b 100644 --- a/app/controllers/user.controller.js +++ b/app/controllers/user.controller.js @@ -9,19 +9,29 @@ // app/controllers/user.controller.js -// const { Op } = require("sequelize"); const db = require("../models"); const { hashPassword } = require("../utils/hashPassword.util"); -const { validatePassword } = require("../utils/validation/validatePassword.util"); +const { + validatePassword, +} = require("../utils/validation/validatePassword.util"); const { validateEmail } = require("../utils/validation/validateEmail.util"); const { generateUserId, generateId } = require("../utils/idGen.util"); +const { + createCustomerDetails, +} = require("../utils/users/createCustomerDetails.util"); +const { + createBusinessCustomerDetails, +} = require("../utils/users/createBusinessCustomer.util"); const { logActivity } = require("../services/activity.service"); const { sendMail } = require("../utils/mail.util"); const { - createEmailVerification, verifyEmailVerificationToken, deleteEmailVerification} = require("../utils/emailVerification.util");; + createEmailVerification, + verifyEmailVerificationToken, + sendVerificationEmail, + deleteEmailVerification, +} = require("../utils/emailVerification.util"); const User = db.User; const Profile = db.Profile; -// const EmailVerification = db.EmailVerification; // Create a new user exports.createNewUser = async (req, res) => { @@ -33,28 +43,48 @@ exports.createNewUser = async (req, res) => { lastName, email, password, + accountType, + address, + phoneNumber, + businessName, + businessRegistrationNumber, + businessType, + contactName, + businessEmail, + expectedMonthlyVolume, + note, } = req.body; - if (!firstName || !lastName || !email || !password) { - await transaction.rollback(); + if (!firstName || !lastName || !email || !password || !accountType) { + await transaction.rollback(); - return res.status(400).send({ - success: false, - message: - "First name, last name, email and password are required", - }); - } + return res.status(400).send({ + success: false, + message: + "First name, last name, email, password and account type are required", + }); + } - const emailValid = validateEmail(email); + if (accountType !== "customer" && accountType !== "business_customer") { + await transaction.rollback(); -if (!emailValid) { - await transaction.rollback(); + return res.status(400).send({ + success: false, + message: + "Invalid account type. Must be either 'customer' or 'business_customer'", + }); + } - return res.status(400).send({ - success: false, - message: "Invalid email address", - }); -} + const emailValid = validateEmail(email); + + if (!emailValid) { + await transaction.rollback(); + + return res.status(400).send({ + success: false, + message: "Invalid email address", + }); + } const userExists = await User.findOne({ where: { email } }); if (userExists) { @@ -66,14 +96,6 @@ if (!emailValid) { }); } - // let pass; - - // if(accountType === "admin" || accountType === "superadmin" || accountType === "manager" || accountType === "support_agent") { - // pass = process.env.DEFAULT_PASSWORD; - // }else{ - // pass = password; - // } - const validatePasswordResult = validatePassword(password); if (!validatePasswordResult) { @@ -95,13 +117,12 @@ if (!emailValid) { lastName, email, password: hashedPassword, - // accountType, + accountType, accountStatus: "PENDING_VERIFICATION", - emailVerifiedAt: null, + emailVerifiedAt: null, }, { transaction }, ); - const newProfile = await Profile.create( { @@ -117,78 +138,52 @@ if (!emailValid) { { transaction }, ); + //create customer and business customer + if (accountType === "customer") { + const customerData = { + address,phoneNumber, + }; + + await createCustomerDetails( + newUser.id, + customerData, + transaction, + ); + } else if (accountType === "business_customer") { + const businessData = { + businessName, + businessRegistrationNumber, + businessType, + contactName, + phoneNumber, + businessEmail, + expectedMonthlyVolume, + note, + }; + + await createBusinessCustomerDetails( + newUser.id, + businessData, + transaction, + ); + } + await transaction.commit(); const verificationToken = await createEmailVerification(newUser.id); - const confirmationLink = - `${process.env.FRONTEND_URL}/verify-email?token=${verificationToken}`; - - try { - - await sendMail({ - to: - email, - - subject: - "Confirm Your ZUMRI Account", - - templateName: - "emailVerification", - - templateVars: { - - customer_name: - firstName, - - confirmation_link: - confirmationLink, - }, - - text: - `Hello ${firstName}, please verify your ZUMRI account using this link: ${confirmationLink}`, - }); - - - } catch (mailError) { - - console.error( - "VERIFICATION EMAIL ERROR:", - mailError + await sendVerificationEmail( + newUser.email, + newUser.firstName, + verificationToken, ); - - - return res.status(201).send({ - success: true, - - message: - "Account created, but verification email could not be sent. Please request a new verification email.", - - data: { - id: - newUser.id, - - firstName: - newUser.firstName, - - lastName: - newUser.lastName, - - email: - newUser.email, - - accountType: - newUser.accountType, - - accountStatus: - newUser.accountStatus, - }, - }); + } catch (error) { + console.error("Error sending verification email:", error); } await logActivity({ - user: req.user, + user: newUser, description: `Created New User with ID: ${newUser.id}`, type: "CREATE_USER", module: "User Management", @@ -202,20 +197,16 @@ if (!emailValid) { firstName: newUser.firstName, lastName: newUser.lastName, email: newUser.email, - // accountType: newUser.accountType, - // role: newUser.role, - // department: newUser.department, + accountType: newUser.accountType, + }, }); } catch (error) { - if (!transaction.finished) { - await transaction.rollback(); - } + if (!transaction.finished) { + await transaction.rollback(); + } - console.error( - "CREATE USER ERROR:", - error - ); + console.error("CREATE USER ERROR:", error); res.status(500).send({ success: false, @@ -226,168 +217,100 @@ if (!emailValid) { }; // Verify customer email -exports.verifyEmail = - async (req, res) => { +exports.verifyEmail = async (req, res) => { + const transaction = await db.sequelize.transaction(); - const transaction = - await db.sequelize.transaction(); + try { + const { token } = req.body; + if (!token) { + await transaction.rollback(); - try { - const { - token, - } = req.body; + return res.status(400).send({ + success: false, - - if (!token) { - - await transaction.rollback(); - - - return res.status(400).send({ - success: - false, - - message: - "Verification token is required", - }); - } - - const verification = - await verifyEmailVerificationToken( - token - ); - - if (!verification) { - - await transaction.rollback(); - - - return res.status(400).send({ - success: - false, - - message: - "Verification token is invalid or expired", - }); - } - - - - const { - userId, - redisKey, - } = - verification; - - const user = - await User.findOne({ - where: { - id: - userId, - }, - - transaction, - }); - - - - if (!user) { - - await transaction.rollback(); - - await deleteEmailVerification( - redisKey - ); - - - return res.status(404).send({ - success: - false, - - message: - "User not found", - }); - } - - if ( - user.accountStatus === - "ACTIVE" && - user.emailVerifiedAt - ) { - - await transaction.rollback(); - - - // Token is no longer needed - await deleteEmailVerification( - redisKey - ); - - - return res.status(400).send({ - success: - false, - - message: - "Email is already verified", - }); - } - - - user.accountStatus = - "ACTIVE"; - - - user.emailVerifiedAt = - new Date(); - - - await user.save({ - transaction, - }); - - await transaction.commit(); - - await deleteEmailVerification( - redisKey - ); - - - - return res.status(200).send({ - success: - true, - - message: - "Email verified successfully. Your account is now active.", - }); - - - } catch (error) { - - if (!transaction.finished) { - - await transaction.rollback(); - - } - - - console.error( - "VERIFY EMAIL ERROR:", - error - ); - - - return res.status(500).send({ - success: - false, - - message: - "Failed to verify email", + message: "Verification token is required", }); } - }; + + const verification = await verifyEmailVerificationToken(token); + + if (!verification) { + await transaction.rollback(); + + return res.status(400).send({ + success: false, + + message: "Verification token is invalid or expired", + }); + } + + const { userId, redisKey } = verification; + + const user = await User.findOne({ + where: { + id: userId, + }, + + transaction, + }); + + if (!user) { + await transaction.rollback(); + + await deleteEmailVerification(redisKey); + + return res.status(404).send({ + success: false, + + message: "User not found", + }); + } + + if (user.accountStatus === "ACTIVE" && user.emailVerifiedAt) { + await transaction.rollback(); + + // Token is no longer needed + await deleteEmailVerification(redisKey); + + return res.status(400).send({ + success: false, + + message: "Email is already verified", + }); + } + + user.accountStatus = "ACTIVE"; + + user.emailVerifiedAt = new Date(); + + await user.save({ + transaction, + }); + + await transaction.commit(); + + await deleteEmailVerification(redisKey); + + return res.status(200).send({ + success: true, + + message: "Email verified successfully. Your account is now active.", + }); + } catch (error) { + if (!transaction.finished) { + await transaction.rollback(); + } + + console.error("VERIFY EMAIL ERROR:", error); + + return res.status(500).send({ + success: false, + + message: "Failed to verify email", + }); + } +}; // Get users with pagination (20 per page) exports.getAllUsers = async (req, res) => { diff --git a/app/models/index.js b/app/models/index.js index d695284..18b07a6 100644 --- a/app/models/index.js +++ b/app/models/index.js @@ -41,6 +41,8 @@ db.sequelize = sequelize; // User and Authentication db.User = require("./user/user.model")(sequelize, DataTypes); +db.Customer = require("./user/customer.model")(sequelize, DataTypes); +db.BusinessCustomer = require("./user/businessCustomer.model")(sequelize, DataTypes); db.UserActivity = require("./activities/userActivities.model")(sequelize, DataTypes); db.Profile = require("./user/profile.model")(sequelize, DataTypes); diff --git a/app/models/user/businessCustomer.model.js b/app/models/user/businessCustomer.model.js new file mode 100644 index 0000000..10e5282 --- /dev/null +++ b/app/models/user/businessCustomer.model.js @@ -0,0 +1,65 @@ +//app/models/user/businessCustomer.model.js + +module.exports = (sequelize, DataTypes) => { + const BusinessCustomer = sequelize.define( + "BusinessCustomer", + { + business_customer_id: { + type: DataTypes.STRING, + primaryKey: true, + }, + + user_id: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + + businessName: { + type: DataTypes.STRING, + allowNull: false, + }, + businessRegistrationNumber: { + type: DataTypes.STRING, + allowNull: false, + }, + businessType: { + type: DataTypes.STRING, + allowNull: false, + }, + contactName: { + type: DataTypes.STRING, + allowNull: false, + }, + phoneNumber: { + type: DataTypes.STRING, + allowNull: false, + }, + businessEmail: { + type: DataTypes.STRING, + allowNull: false, + }, + expectedMonthlyVolume: { + type: DataTypes.STRING, + allowNull: false, + }, + note: { + type: DataTypes.STRING, + allowNull: true, + }, + }, + { + tableName: "business_customers", + timestamps: true, + }, + ); + + BusinessCustomer.associate = (db) => { + BusinessCustomer.belongsTo(db.User, { + foreignKey: "user_id", + as: "user", + }); + }; + + return BusinessCustomer; +}; diff --git a/app/models/user/customer.model.js b/app/models/user/customer.model.js new file mode 100644 index 0000000..fc76a5f --- /dev/null +++ b/app/models/user/customer.model.js @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2026 Niolla + * All rights reserved. + * + * This source code is proprietary and confidential. + * Unauthorized copying, modification, distribution, or use + * of this file, via any medium, is strictly prohibited. + */ + +// app/models/customer.model.js + +module.exports = (sequelize, DataTypes) => { + const Customer = sequelize.define( + "Customer", + { + customer_id: { + type: DataTypes.STRING, + primaryKey: true, + collate: "utf8mb4_general_ci", + }, + user_id: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + address: { + type: DataTypes.STRING, + allowNull: false, + }, + phoneNumber: { + type: DataTypes.STRING, + allowNull: false, + }, + }, + { + tableName: "customers", + timestamps: true, + }, + ); + + Customer.associate = (db) => { + Customer.belongsTo(db.User, { + foreignKey: "user_id", + as: "user", + }); + }; + + return Customer; +}; diff --git a/app/models/user/user.model.js b/app/models/user/user.model.js index e73e011..2dc99c2 100644 --- a/app/models/user/user.model.js +++ b/app/models/user/user.model.js @@ -36,7 +36,7 @@ module.exports = (sequelize, DataTypes) => { allowNull: false }, accountType: { - type: DataTypes.ENUM("admin", "superadmin", "manager", "business_customer", "rider", "customer","support_agent"), + type: DataTypes.ENUM("business_customer", "customer"), defaultValue: "customer" }, accountStatus: { @@ -71,6 +71,14 @@ module.exports = (sequelize, DataTypes) => { foreignKey: "user_id", as: "profile", }); + User.hasOne(db.Customer, { + foreignKey: "user_id", + as: "customer", + }); + User.hasOne(db.BusinessCustomer, { + foreignKey: "user_id", + as: "businessCustomer", + }); }; diff --git a/app/routes/auth.routes.js b/app/routes/auth.routes.js index 4d334ad..8e22a46 100644 --- a/app/routes/auth.routes.js +++ b/app/routes/auth.routes.js @@ -24,6 +24,7 @@ router.get("/me", authenticate, (req, res) => { router.post('/req-otp', authController.loginReq); router.post('/login', authController.login); +router.post('/refresh', authController.refreshToken); router.post('/logout', authController.logout); module.exports = router; diff --git a/app/templates/emails/otp.html b/app/templates/emails/otp.html index f2672dd..b820af8 100644 --- a/app/templates/emails/otp.html +++ b/app/templates/emails/otp.html @@ -2,14 +2,14 @@ - GLAURA 2FA Code + ZUMRI CEYLON

Dear {{firstName}},

-

Greetings from Oceanic Titan!

+

Greetings from ZUMRI CEYLON!

-

Your One Time Password (OTP) to log in to your Oceanic Titan account is mentioned below.

+

Your One Time Password (OTP) to log in to your ZUMRI CEYLON account is mentioned below.

Please enter this OTP in the required field to proceed further:

@@ -24,14 +24,14 @@ -

The above-mentioned OTP is valid for 5 minutes.

+

The above-mentioned OTP is valid for 15 minutes.


Regards,
- Oceanic Titan Team + ZUMRI CEYLON Team

-

{{currentYear}} Oceanic Titan. This is an auto-generated email, please do not reply to this email.

+

{{currentYear}} ZUMRI CEYLON. This is an auto-generated email, please do not reply to this email.

\ No newline at end of file diff --git a/app/utils/emailVerification.util.js b/app/utils/emailVerification.util.js index 2ab997d..d7e26af 100644 --- a/app/utils/emailVerification.util.js +++ b/app/utils/emailVerification.util.js @@ -1,103 +1,91 @@ // app/utils/emailVerification.util.js const crypto = require("crypto"); - +const { sendMail } = require("../utils/mail.util"); const redis = require("../config/redisClient"); const EMAIL_VERIFICATION_TTL = - Number( - process.env.EMAIL_VERIFICATION_TTL_SECONDS - ) || 1800; + Number(process.env.EMAIL_VERIFICATION_TTL_SECONDS) || 1800; const generateEmailVerificationToken = () => { - return crypto - .randomBytes(32) - .toString("hex"); + return crypto.randomBytes(32).toString("hex"); }; const hashEmailVerificationToken = (token) => { - return crypto - .createHash("sha256") - .update(token) - .digest("hex"); + return crypto.createHash("sha256").update(token).digest("hex"); }; const createEmailVerification = async (userId) => { - // 1. Generate raw token - const token = - generateEmailVerificationToken(); - + const token = generateEmailVerificationToken(); // 2. Hash token - const tokenHash = - hashEmailVerificationToken(token); - + const tokenHash = hashEmailVerificationToken(token); // 3. Create Redis key - const redisKey = - `email-verification:${tokenHash}`; + const redisKey = `email-verification:${tokenHash}`; - await redis.set( - redisKey, - userId, - "EX", - EMAIL_VERIFICATION_TTL - ); + await redis.set(redisKey, userId, "EX", EMAIL_VERIFICATION_TTL); return token; }; - /** * Check a verification token. */ -const verifyEmailVerificationToken = - async (token) => { +const verifyEmailVerificationToken = async (token) => { + if (!token || typeof token !== "string") { + return null; + } - if ( - !token || - typeof token !== "string" - ) { - return null; - } + // 1. Hash token received from user + const tokenHash = hashEmailVerificationToken(token); + // 2. Build same Redis key + const redisKey = `email-verification:${tokenHash}`; - // 1. Hash token received from user - const tokenHash = - hashEmailVerificationToken(token); + // 3. Search Redis + const userId = await redis.get(redisKey); + if (!userId) { + return null; + } - // 2. Build same Redis key - const redisKey = - `email-verification:${tokenHash}`; - - - // 3. Search Redis - const userId = - await redis.get(redisKey); - - if (!userId) { - return null; - } - - - return { - userId, - redisKey, - }; + return { + userId, + redisKey, }; +}; -const deleteEmailVerification = - async (redisKey) => { +//send verification email to user +const sendVerificationEmail = async (email, firstName, verificationToken) => { + const confirmationLink = `${process.env.FRONTEND_URL}/verify-email?token=${verificationToken}`; - await redis.del(redisKey); - }; + // Send email + await sendMail({ + to: email, + subject: "Confirm Your ZUMRI Account", + + templateName: "emailVerification", + + templateVars: { + customer_name: firstName, + confirmation_link: confirmationLink, + }, + + text: `Hello ${firstName}, please verify your ZUMRI account using this link: ${confirmationLink}`, + }); +}; + +const deleteEmailVerification = async (redisKey) => { + await redis.del(redisKey); +}; module.exports = { createEmailVerification, verifyEmailVerificationToken, + sendVerificationEmail, deleteEmailVerification, hashEmailVerificationToken, -}; \ No newline at end of file +}; diff --git a/app/utils/idGen.util.js b/app/utils/idGen.util.js index 5d9d7f2..0fc0abc 100644 --- a/app/utils/idGen.util.js +++ b/app/utils/idGen.util.js @@ -23,6 +23,14 @@ const generateUserId = () => { return "usr_" + Math.random().toString(36).slice(2, 10); }; +const generateCustomerId = () => { + return "cust_" + Math.random().toString(36).slice(2, 10); +} + +const generateBusinessCustomerId = () => { + return "b_cust_" + Math.random().toString(36).slice(2, 10); +} + const generateClientId = () => { const prefix = "cli_"; return prefix + uuidv4(); @@ -141,6 +149,8 @@ const generateDocumentReferenceNo = async (type) => { module.exports = { generateUserId, + generateCustomerId, + generateBusinessCustomerId, generateClientId, generateInquId, generateInquRefNo, diff --git a/app/utils/jwt.util.js b/app/utils/jwt.util.js index f2bb54a..7d91279 100644 --- a/app/utils/jwt.util.js +++ b/app/utils/jwt.util.js @@ -15,6 +15,9 @@ require("dotenv").config(); const JWT_SECRET = process.env.JWT_SECRET || "your_jwt_secret_key"; const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "1d"; // token validity +const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET || "your_refresh_token_secret_key"; +const REFRESH_TOKEN_DAYS = process.env.REFRESH_TOKEN_DAYS || "7d"; // refresh token validity + /** * Generate JWT token * @param {Object} payload - usually { id, email, role } @@ -33,4 +36,12 @@ const verifyToken = (token) => { return jwt.verify(token, JWT_SECRET); }; -module.exports = { generateToken, verifyToken }; +const generateRefreshToken = (payload) => { + return jwt.sign(payload, REFRESH_TOKEN_SECRET, { expiresIn: REFRESH_TOKEN_DAYS }); +} + +const verifyRefreshToken = (token) => { + return jwt.verify(token, REFRESH_TOKEN_SECRET); +} + +module.exports = { generateToken, verifyToken, generateRefreshToken, verifyRefreshToken }; diff --git a/app/utils/otp.util.js b/app/utils/otp.util.js index 7c391d8..8259d2b 100644 --- a/app/utils/otp.util.js +++ b/app/utils/otp.util.js @@ -17,8 +17,9 @@ function generateOTP(key){ return otp; } -function saveOTP(key, otp, ttl = 5 * 60 * 1000) { // default TTL: 5 mins - const expiresAt = Date.now() + ttl; +function saveOTP(key, otp) { + ttl = parseInt(process.env.LOGIN_OTP_TTL_SECONDS || 300); + const expiresAt = Date.now() + ttl * 1000; // Convert seconds to milliseconds otpCache.set(key, { otp, expiresAt }); } diff --git a/app/utils/refreshSession.util.js b/app/utils/refreshSession.util.js new file mode 100644 index 0000000..bef329a --- /dev/null +++ b/app/utils/refreshSession.util.js @@ -0,0 +1,201 @@ +// app/utils/refreshSession.util.js + +const crypto = require("crypto"); + +const { + generateRefreshToken, + verifyRefreshToken, +} = require("./jwt.util"); + +const refreshSessions = new Map(); + + +// Default = 7 days +const REFRESH_SESSION_TTL = + 7 * 24 * 60 * 60 * 1000; + + +const hashRefreshToken = (token) => { + return crypto + .createHash("sha256") + .update(token) + .digest("hex"); +}; + +const createRefreshSession = (userId) => { + + // Unique session ID + const sessionId = + crypto.randomUUID(); + + + // Create raw Refresh JWT + const refreshToken = + generateRefreshToken({ + sub: userId, + sid: sessionId, + }); + + + // Hash raw refresh token + const tokenHash = + hashRefreshToken( + refreshToken + ); + + + // Expiration time + const expiresAt = + Date.now() + + REFRESH_SESSION_TTL; + + + // Save only HASH in RAM + refreshSessions.set( + sessionId, + { + userId, + tokenHash, + expiresAt, + } + ); + + + return { + refreshToken, + sessionId, + }; +}; + +const validateRefreshSession = ( + refreshToken +) => { + + if (!refreshToken) { + return null; + } + + + let decoded; + + try { + + decoded = + verifyRefreshToken( + refreshToken + ); + + } catch (error) { + + return null; + + } + + + const sessionId = + decoded.sid; + + const userId = + decoded.sub; + + + if (!sessionId || !userId) { + return null; + } + + + // Get session from RAM + const session = + refreshSessions.get( + sessionId + ); + + + if (!session) { + return null; + } + + + // Check session expiration + if ( + Date.now() > + session.expiresAt + ) { + + refreshSessions.delete( + sessionId + ); + + return null; + } + + + // Hash received refresh token + const receivedHash = + hashRefreshToken( + refreshToken + ); + + + // Compare stored hash + if ( + receivedHash !== + session.tokenHash + ) { + return null; + } + + + // Extra user check + if ( + session.userId !== + userId + ) { + return null; + } + + + return { + userId, + sessionId, + }; +}; + +const deleteRefreshSession = ( + sessionId +) => { + + refreshSessions.delete( + sessionId + ); +}; + +const deleteAllUserSessions = ( + userId +) => { + + for ( + const [sessionId, session] + of refreshSessions.entries() + ) { + + if ( + session.userId === userId + ) { + + refreshSessions.delete( + sessionId + ); + + } + + } +}; + + +module.exports = { + createRefreshSession, + validateRefreshSession, + deleteRefreshSession, + deleteAllUserSessions, +}; \ No newline at end of file diff --git a/app/utils/users/createBusinessCustomer.util.js b/app/utils/users/createBusinessCustomer.util.js new file mode 100644 index 0000000..1f55a2a --- /dev/null +++ b/app/utils/users/createBusinessCustomer.util.js @@ -0,0 +1,86 @@ +// app/utils/users/createBusinessCustomer.util.js + +const db = require("../../models"); + +const { + generateBusinessCustomerId, +} = require("../idGen.util"); + +const BusinessCustomer = db.BusinessCustomer; + + +/** + * Create business-customer-specific details + */ +const createBusinessCustomerDetails = async ( + userId, + businessData, + transaction +) => { + + const { + businessName, + businessRegistrationNumber, + businessType, + contactName, + phoneNumber, + businessEmail, + expectedMonthlyVolume, + note, + } = businessData; + + + if ( + !businessName || + !businessRegistrationNumber || + !businessType || + !contactName || + !phoneNumber || + !businessEmail || + !expectedMonthlyVolume + ) { + throw new Error( + "Required business customer details are missing" + ); + } + + + const businessCustomer = + await BusinessCustomer.create( + { + business_customer_id: + generateBusinessCustomerId(), + + user_id: + userId, + + businessName, + + businessRegistrationNumber, + + businessType, + + contactName, + + phoneNumber, + + businessEmail, + + expectedMonthlyVolume, + + note: + note || null, + }, + { + transaction, + } + ); + + + return businessCustomer; +}; + + +module.exports = { + createBusinessCustomerDetails, +}; \ No newline at end of file diff --git a/app/utils/users/createCustomerDetails.util.js b/app/utils/users/createCustomerDetails.util.js new file mode 100644 index 0000000..81b4110 --- /dev/null +++ b/app/utils/users/createCustomerDetails.util.js @@ -0,0 +1,54 @@ +//app/utils/users/createCustomerDetails.util.js + +const db = require("../../models"); + +const { generateCustomerId } = require("../idGen.util"); + +const Customer = db.Customer; + +const createCustomerDetails = async ( + userId, + customerData, + transaction +) => { + + const { + address, + phoneNumber, + } = customerData; + + + if (!address || !phoneNumber) { + throw new Error( + "Address and phone number are required for customer" + ); + } + + + const customer = await Customer.create( + { + customer_id: + generateCustomerId(), + + user_id: + userId, + + address: + address, + + phoneNumber: + phoneNumber, + }, + { + transaction, + } + ); + + + return customer; +}; + + +module.exports = { + createCustomerDetails, +}; From 6a4fe852e539d39203fd166c4a7248180133ee8b Mon Sep 17 00:00:00 2001 From: Isuru Bimsara Date: Fri, 21 Aug 2026 09:56:54 +0530 Subject: [PATCH 05/16] Implement password reset and change features with email notifications --- app/controllers/auth.controller.js | 212 ++++++++++++++++- app/models/user/user.model.js | 29 +-- app/routes/auth.routes.js | 2 + app/templates/emails/passwordChanged.html | 32 +++ app/templates/emails/passwordReset.html | 269 ++++++++++++++++++---- app/utils/passwordReset.utill.js | 191 ++++++++------- 6 files changed, 597 insertions(+), 138 deletions(-) create mode 100644 app/templates/emails/passwordChanged.html diff --git a/app/controllers/auth.controller.js b/app/controllers/auth.controller.js index 318604f..c1f2279 100644 --- a/app/controllers/auth.controller.js +++ b/app/controllers/auth.controller.js @@ -9,8 +9,12 @@ // app/controllers/auth.controller.js -const { checkPassword } = require("../utils/hashPassword.util"); +const { checkPassword, hashPassword } = require("../utils/hashPassword.util"); const { sendMail } = require("../utils/mail.util"); +const { + validatePassword, +} = require("../utils/validation/validatePassword.util"); +const { validateEmail } = require("../utils/validation/validateEmail.util"); const { generateOTP, validateOTP } = require("../utils/otp.util"); const { getCachedUser, clearUserCache } = require("../utils/cache.util"); const { generateToken } = require("../utils/jwt.util"); @@ -18,7 +22,15 @@ const { createRefreshSession, validateRefreshSession, deleteRefreshSession, + deleteAllUserSessions, } = require("../utils/refreshSession.util"); +const { + createPasswordReset, + verifyPasswordResetToken, + deletePasswordReset, + sendPasswordResetEmail, + sendPasswordChangedEmail +} = require("../utils/passwordReset.utill"); const db = require("../models"); const { log } = require("../utils/consoleLog.utill"); @@ -227,6 +239,204 @@ exports.refreshToken = async (req, res) => { } }; +exports.forgotPassword = async (req, res) => { + try { + let { email } = req.body; + + if (!email) { + return res.status(400).send({ + success: false, + message: "Email is required", + }); + } + + + email = email.trim().toLowerCase(); + + if (!validateEmail(email)) { + return res.status(400).send({ + success: false, + message: "Invalid email address", + }); + } + + const user = await User.findOne({ + where: { email }, + }); + + if (!user) { + return res.status(200).send({ + success: true, + message: + "If an account exists for this email, a password reset link has been sent.", + }); + } + + const resetToken = await createPasswordReset(user.id); + + await sendPasswordResetEmail(user.email, user.firstName, resetToken); + + return res.status(200).send({ + success: true, + message: + "If an account exists for this email, a password reset link has been sent.", + }); + } catch (error) { + console.error("FORGOT PASSWORD ERROR:", error); + + return res.status(500).send({ + success: false, + message: "Unable to process password reset request", + }); + } +}; + +exports.resetPassword = async (req, res) => { + + try { + + const { + token, + newPassword, + confirmPassword, + } = req.body; + + + if (!token ||!newPassword || !confirmPassword) { + return res.status(400).send({ + success: false, + message: + "Token, new password and confirm password are required", + }); + } + + if ( + newPassword !== + confirmPassword + ) { + return res.status(400).send({ + success: false, + message: + "Passwords do not match", + }); + } + + + if ( + !validatePassword(newPassword) + ) { + return res.status(400).send({ + success: false, + message: + "Password does not meet the required criteria", + }); + } + + const verification = + await verifyPasswordResetToken( + token + ); + + + if (!verification) { + return res.status(400).send({ + success: false, + message: + "Reset token is invalid or expired", + }); + } + + + const { + userId, + redisKey, + } = verification; + + const user = + await User.findByPk(userId); + + + if (!user) { + + await deletePasswordReset( + redisKey + ); + + return res.status(400).send({ + success: false, + message: + "Reset token is invalid or expired", + }); + } + + const samePassword = + await checkPassword( + newPassword, + user.password + ); + + + if (samePassword) { + return res.status(400).send({ + success: false, + message: + "New password must be different from the current password", + }); + } + + + const hashedPassword = + await hashPassword( + newPassword + ); + + + user.password = + hashedPassword; + + user.passwordChangedAt = + new Date(); + + + await user.save(); + + await sendPasswordChangedEmail( + user.email, + user.firstName + ); + + await deletePasswordReset( + redisKey + ); + + deleteAllUserSessions( + user.id + ); + + + return res.status(200).send({ + success: true, + message: + "Password reset successfully. Please login again.", + }); + + + } catch (error) { + + console.error( + "RESET PASSWORD ERROR:", + error + ); + + + return res.status(500).send({ + success: false, + message: + "Failed to reset password", + }); + } +}; + // Logout: Clear the JWT cookie exports.logout = (req, res) => { res.clearCookie("access_token", { diff --git a/app/models/user/user.model.js b/app/models/user/user.model.js index 2dc99c2..c147dd3 100644 --- a/app/models/user/user.model.js +++ b/app/models/user/user.model.js @@ -16,56 +16,60 @@ module.exports = (sequelize, DataTypes) => { id: { type: DataTypes.STRING, primaryKey: true, - collate: 'utf8mb4_general_ci' + collate: "utf8mb4_general_ci", }, firstName: { type: DataTypes.STRING, - allowNull: false + allowNull: false, }, lastName: { type: DataTypes.STRING, - allowNull: false + allowNull: false, }, email: { type: DataTypes.STRING, allowNull: false, - unique: true + unique: true, }, password: { type: DataTypes.STRING, - allowNull: false + allowNull: false, }, accountType: { type: DataTypes.ENUM("business_customer", "customer"), - defaultValue: "customer" + defaultValue: "customer", }, accountStatus: { type: DataTypes.ENUM( "PENDING_VERIFICATION", "ACTIVE", "SUSPENDED", - "DEACTIVATED" + "DEACTIVATED", ), allowNull: false, defaultValue: "PENDING_VERIFICATION", }, - emailVerifiedAt: { + emailVerifiedAt: { + type: DataTypes.DATE, + allowNull: true, + defaultValue: null, + }, + passwordChangedAt: { type: DataTypes.DATE, allowNull: true, defaultValue: null, }, - }, { tableName: "users", - timestamps: true - } + timestamps: true, + }, ); User.associate = (db) => { User.hasMany(db.userPermission, { foreignKey: "user_id", - as: "userPermissions" + as: "userPermissions", }); User.hasOne(db.Profile, { foreignKey: "user_id", @@ -79,7 +83,6 @@ module.exports = (sequelize, DataTypes) => { foreignKey: "user_id", as: "businessCustomer", }); - }; return User; diff --git a/app/routes/auth.routes.js b/app/routes/auth.routes.js index 8e22a46..9818a5b 100644 --- a/app/routes/auth.routes.js +++ b/app/routes/auth.routes.js @@ -25,6 +25,8 @@ router.get("/me", authenticate, (req, res) => { router.post('/req-otp', authController.loginReq); router.post('/login', authController.login); router.post('/refresh', authController.refreshToken); +router.post('/forgot-password', authController.forgotPassword); +router.post('/reset-password', authController.resetPassword); router.post('/logout', authController.logout); module.exports = router; diff --git a/app/templates/emails/passwordChanged.html b/app/templates/emails/passwordChanged.html new file mode 100644 index 0000000..f7b4b0d --- /dev/null +++ b/app/templates/emails/passwordChanged.html @@ -0,0 +1,32 @@ + + + + + Your ZUMRI password was changed + + + + +

Hi {{customer_name}},

+ +

+ Your password was changed at {{changed_at}}. +

+ +

+ If this was not you, contact support immediately. +

+ +
+ +

+ Thank you,
+ ZUMRI Team +

+ +

+ Document Classification: Internal Use Only, Version: 1.0 +

+ + + \ No newline at end of file diff --git a/app/templates/emails/passwordReset.html b/app/templates/emails/passwordReset.html index 61c62e8..5884f70 100644 --- a/app/templates/emails/passwordReset.html +++ b/app/templates/emails/passwordReset.html @@ -1,53 +1,242 @@ - - - Document - + + + + + Reset Your ZUMRI Password - -

Dear {{firstName}},

-

Greetings from Oceanic Titan!

+ -

You have requested to reset your password for your Oceanic Titan account. Please click the link below to reset your password:

+ + + + +
-

Reset Password

+ -

If you did not request a password reset, please ignore this email. The above link will expire in {{expiryTime}}.

+ + + + -
-

Regards,
- Oceanic Titan Team -

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

+ Hi {{firstName}}, +

+
+

+ Reset Your ZUMRI Password +

+
+

+ We received a request to reset the password + for your ZUMRI account. + Click the button below to create a new password. +

+
+ + + Reset Password + + +
+

+ If the button doesn't work, copy and paste + the following link into your browser: +

+ +

+ + {{resetLink}} + +

+
+

+ For security purposes, this password reset + link will expire in + {{expiryTime}}. +

+
+

+ If you did not request a password reset, + you can safely ignore this email. + Your password will remain unchanged. +

+
+

+ Best regards,
+ ZUMRI Team +

+
+

+ © {{currentYear}} ZUMRI. + All rights reserved. +

+
+ +
-

{{currentYear}} Oceanic Titan. This is an auto-generated email, please do not reply to this email.

\ No newline at end of file diff --git a/app/utils/passwordReset.utill.js b/app/utils/passwordReset.utill.js index fd6ea95..aca6e06 100644 --- a/app/utils/passwordReset.utill.js +++ b/app/utils/passwordReset.utill.js @@ -10,104 +10,127 @@ // app/utils/passwordReset.util.js const crypto = require("crypto"); +const redis = require("../config/redisClient"); +const { sendMail } = require("./mail.util"); -const createRedisConnection = require("../config/redis.config"); -const redis = createRedisConnection(); +const PASSWORD_RESET_TTL = + Number(process.env.PASSWORD_RESET_TTL_SECONDS) || 900; -const db = require("../models"); +const generatePasswordResetToken = () => { + return crypto.randomBytes(32).toString("hex"); +}; -const User = db.User; +const hashPasswordResetToken = (token) => { + return crypto.createHash("sha256").update(token).digest("hex"); +}; -const { log } = require("./consoleLog.utill"); -const { hashPassword } = require("./hashPassword.util"); +const createPasswordReset = async (userId) => { + // 1. Generate RAW token + const token = generatePasswordResetToken(); -const RESET_TOKEN_TTL = process.env.RESET_TOKEN_TTL || 10 * 60; // 10 minutes + // 2. Hash RAW token + const tokenHash = hashPasswordResetToken(token); -/** - * Generate password reset token - * - * @param {string} userId - * @returns {Promise} - */ -async function generateResetToken(userId) { - try { - const token = crypto.randomBytes(32).toString("hex"); + // 3. Create Redis key + const redisKey = `password-reset:${tokenHash}`; - const tokenHash = crypto - .createHash("sha256") - .update(token) - .digest("hex"); + // 4. Save user ID with 15 minute TTL + await redis.set(redisKey, userId, "EX", PASSWORD_RESET_TTL); - await redis.set( - `passwordReset:user:${userId}`, - tokenHash, - "EX", - RESET_TOKEN_TTL - ); - log(`Generated password reset token for user ${userId} with TTL of ${RESET_TOKEN_TTL} seconds, Generated token:`, token); - return token; - } catch (error) { - log("Password reset token generation failed", error); - throw new Error("Failed to generate password reset token"); + // Send only RAW token to user + return token; +}; + +const verifyPasswordResetToken = async (token) => { + if (!token || typeof token !== "string") { + return null; } -} -/** - * Reset password using token - * - * @param {string} userId - * @param {string} token - * @param {string} newPassword - */ -async function resetPassword(userId, token, newPassword) { - try { - const storedHash = await redis.get( - `passwordReset:user:${userId}` - ); + // Hash token received from user + const tokenHash = hashPasswordResetToken(token); - if (!storedHash) { - throw new Error("Invalid or expired reset token"); - } + // Create same Redis key + const redisKey = `password-reset:${tokenHash}`; - const tokenHash = crypto - .createHash("sha256") - .update(token) - .digest("hex"); + // Search Redis + const userId = await redis.get(redisKey); - const isValid = - crypto.timingSafeEqual( - Buffer.from(storedHash), - Buffer.from(tokenHash) - ); - - if (!isValid) { - throw new Error("Invalid or expired reset token"); - } - - const user = await User.findByPk(userId); - - if (!user) { - throw new Error("User not found"); - } - - user.password = await hashPassword(newPassword); - - await user.save(); - - await redis.del(`passwordReset:user:${userId}`); - - log( - `Password reset completed successfully for user ${userId}` - ); - - return true; - } catch (error) { - log("Password reset failed", error); - throw error; + if (!userId) { + return null; } -} + + return { + userId, + redisKey, + }; +}; + +const deletePasswordReset = async (redisKey) => { + await redis.del(redisKey); +}; + +const sendPasswordResetEmail = + async (email, firstName, resetToken) => { + + const resetLink = + `${process.env.FRONTEND_URL}/reset-password?token=${resetToken}`; + + + await sendMail({ + to: email, + + subject: + "Reset your ZUMRI password", + + templateName: + "passwordReset", + + templateVars: { + firstName: firstName, + resetLink: resetLink, + expiryTime: "15 minutes", + }, + + text: + `Hi ${firstName}, use this link within 15 minutes to reset your password: ${resetLink}`, + }); + }; + + const sendPasswordChangedEmail = async ( + email, + firstName +) => { + + const changedAt = + new Date().toLocaleString(); + + await sendMail({ + to: email, + + subject: + "Your ZUMRI password was changed", + + templateName: + "passwordChanged", + + templateVars: { + customer_name: + firstName, + + changed_at: + changedAt, + }, + + text: + `Hi ${firstName}, your password was changed at ${changedAt}. If this was not you, contact support immediately.`, + }); +}; module.exports = { - generateResetToken, - resetPassword + createPasswordReset, + verifyPasswordResetToken, + deletePasswordReset, + hashPasswordResetToken, + sendPasswordResetEmail, + sendPasswordChangedEmail, }; From 2a35d86ebf0c84a5c2e6e940661197ae54894706 Mon Sep 17 00:00:00 2001 From: Isuru Bimsara Date: Fri, 21 Aug 2026 11:44:00 +0530 Subject: [PATCH 06/16] Add user profile retrieval function --- app/controllers/auth.controller.js | 115 +++++---------------- app/controllers/user.controller.js | 76 +++++++++++--- app/routes/user.routes.js | 18 ++-- app/utils/users/userProfileDetails.util.js | 56 ++++++++++ 4 files changed, 156 insertions(+), 109 deletions(-) create mode 100644 app/utils/users/userProfileDetails.util.js diff --git a/app/controllers/auth.controller.js b/app/controllers/auth.controller.js index c1f2279..2c5f27c 100644 --- a/app/controllers/auth.controller.js +++ b/app/controllers/auth.controller.js @@ -29,7 +29,7 @@ const { verifyPasswordResetToken, deletePasswordReset, sendPasswordResetEmail, - sendPasswordChangedEmail + sendPasswordChangedEmail, } = require("../utils/passwordReset.utill"); const db = require("../models"); const { log } = require("../utils/consoleLog.utill"); @@ -250,7 +250,6 @@ exports.forgotPassword = async (req, res) => { }); } - email = email.trim().toLowerCase(); if (!validateEmail(email)) { @@ -292,151 +291,91 @@ exports.forgotPassword = async (req, res) => { }; exports.resetPassword = async (req, res) => { - try { + const { token, newPassword, confirmPassword } = req.body; - const { - token, - newPassword, - confirmPassword, - } = req.body; - - - if (!token ||!newPassword || !confirmPassword) { + if (!token || !newPassword || !confirmPassword) { return res.status(400).send({ success: false, - message: - "Token, new password and confirm password are required", + message: "Token, new password and confirm password are required", }); } - if ( - newPassword !== - confirmPassword - ) { + if (newPassword !== confirmPassword) { return res.status(400).send({ success: false, - message: - "Passwords do not match", + message: "Passwords do not match", }); } - - if ( - !validatePassword(newPassword) - ) { + if (!validatePassword(newPassword)) { return res.status(400).send({ success: false, - message: - "Password does not meet the required criteria", + message: "Password does not meet the required criteria", }); } - const verification = - await verifyPasswordResetToken( - token - ); - + const verification = await verifyPasswordResetToken(token); if (!verification) { return res.status(400).send({ success: false, - message: - "Reset token is invalid or expired", + message: "Reset token is invalid or expired", }); } + const { userId, redisKey } = verification; - const { - userId, - redisKey, - } = verification; - - const user = - await User.findByPk(userId); - + const user = await User.findByPk(userId); if (!user) { - - await deletePasswordReset( - redisKey - ); + await deletePasswordReset(redisKey); return res.status(400).send({ success: false, - message: - "Reset token is invalid or expired", + message: "Reset token is invalid or expired", }); } - const samePassword = - await checkPassword( - newPassword, - user.password - ); - + const samePassword = await checkPassword(newPassword, user.password); if (samePassword) { return res.status(400).send({ success: false, - message: - "New password must be different from the current password", + message: "New password must be different from the current password", }); } + const hashedPassword = await hashPassword(newPassword); - const hashedPassword = - await hashPassword( - newPassword - ); - - - user.password = - hashedPassword; - - user.passwordChangedAt = - new Date(); + user.password = hashedPassword; + user.passwordChangedAt = new Date(); await user.save(); - await sendPasswordChangedEmail( - user.email, - user.firstName - ); + await sendPasswordChangedEmail(user.email, user.firstName); - await deletePasswordReset( - redisKey - ); - - deleteAllUserSessions( - user.id - ); + await deletePasswordReset(redisKey); + deleteAllUserSessions(user.id); return res.status(200).send({ success: true, - message: - "Password reset successfully. Please login again.", + message: "Password reset successfully. Please login again.", }); - - } catch (error) { - - console.error( - "RESET PASSWORD ERROR:", - error - ); - + console.error("RESET PASSWORD ERROR:", error); return res.status(500).send({ success: false, - message: - "Failed to reset password", + message: "Failed to reset password", }); } }; + + // Logout: Clear the JWT cookie exports.logout = (req, res) => { res.clearCookie("access_token", { diff --git a/app/controllers/user.controller.js b/app/controllers/user.controller.js index d938f9b..6a2243b 100644 --- a/app/controllers/user.controller.js +++ b/app/controllers/user.controller.js @@ -30,6 +30,7 @@ const { sendVerificationEmail, deleteEmailVerification, } = require("../utils/emailVerification.util"); +const { getUserProfile } = require("../utils/users/userProfileDetails.util"); const User = db.User; const Profile = db.Profile; @@ -345,36 +346,81 @@ exports.getAllUsers = async (req, res) => { } }; -// Get user details by ID -exports.getUserById = async (req, res) => { - try { - const { id } = req.params; - const user = await User.findOne({ - where: { id }, - attributes: { exclude: ["password"] }, - include: [{ model: Profile, as: "profile" }], - }); +//get user profile details +exports.userProfile = async (req, res) => { - if (!user) { + try { + const userId = req.user.id; + + const profile = + await getUserProfile(userId); + + + if (!profile) { return res.status(404).send({ success: false, message: "User not found", }); } - res.status(200).send({ + + return res.status(200).send({ success: true, - data: user, + message: + "User profile retrieved successfully", + + data: profile, }); + + } catch (error) { - res.status(500).send({ + + console.error( + "GET USER PROFILE ERROR:", + error + ); + + + return res.status(500).send({ success: false, - message: "Failed to retrieve user", - error: error.message, + message: + "Failed to retrieve user profile", }); + } + }; +// Get user details by ID +// exports.getUserById = async (req, res) => { +// try { +// const { id } = req.params; +// const user = await User.findOne({ +// where: { id }, +// attributes: { exclude: ["password"] }, +// include: [{ model: Profile, as: "profile" }], +// }); + +// if (!user) { +// return res.status(404).send({ +// success: false, +// message: "User not found", +// }); +// } + +// res.status(200).send({ +// success: true, +// data: user, +// }); +// } catch (error) { +// res.status(500).send({ +// success: false, +// message: "Failed to retrieve user", +// error: error.message, +// }); +// } +// }; + // Update user details exports.updateUser = async (req, res) => { const transaction = await db.sequelize.transaction(); diff --git a/app/routes/user.routes.js b/app/routes/user.routes.js index 67dbb68..4f67710 100644 --- a/app/routes/user.routes.js +++ b/app/routes/user.routes.js @@ -32,6 +32,12 @@ router.post( userController.verifyEmail ); +router.get( + "/profile", + authenticate, + userController.userProfile +); + router.get( "/", authenticate, @@ -39,12 +45,12 @@ router.get( userController.getAllUsers ); -router.get( - "/:id", - authenticate, - authorizedAccountType(["admin", "management", "team_head", "user"]), - userController.getUserById -); +// router.get( +// "/:id", +// authenticate, +// authorizedAccountType(["admin", "management", "team_head", "user"]), +// userController.getUserById +// ); router.patch( "/:id", diff --git a/app/utils/users/userProfileDetails.util.js b/app/utils/users/userProfileDetails.util.js new file mode 100644 index 0000000..de41be7 --- /dev/null +++ b/app/utils/users/userProfileDetails.util.js @@ -0,0 +1,56 @@ +// app/services/userProfile.service.js + +const db = require("../../models"); + +const User = db.User; +const Customer = db.Customer; +const BusinessCustomer = db.BusinessCustomer; + + +const getUserProfile = async (userId) => { + + const user = await User.findByPk(userId, { + attributes: { + exclude: ["password"], + }, + }); + + + if (!user) { + return null; + } + + + let accountDetails = null; + + if (user.accountType === "customer") { + + accountDetails = await Customer.findOne({ + where: { + user_id: user.id, + }, + }); + + } + + else if (user.accountType === "business_customer") { + + accountDetails = + await BusinessCustomer.findOne({ + where: { + user_id: user.id, + }, + }); + + } + + return { + user, + accountDetails, + }; +}; + + +module.exports = { + getUserProfile, +}; \ No newline at end of file From 5d29a8f78f8be61e8af93ac3e2377401d89a373a Mon Sep 17 00:00:00 2001 From: Isuru Bimsara Date: Fri, 21 Aug 2026 14:37:24 +0530 Subject: [PATCH 07/16] logout functionality and documentation --- Documentation/Auth-API.md | 5 +-- Documentation/User-API.md | 60 +++++++++++++++++++++++++----- app/controllers/auth.controller.js | 59 +++++++++++++++++++++++++---- app/utils/jwt.util.js | 2 +- 4 files changed, 106 insertions(+), 20 deletions(-) diff --git a/Documentation/Auth-API.md b/Documentation/Auth-API.md index f07ce72..9537601 100644 --- a/Documentation/Auth-API.md +++ b/Documentation/Auth-API.md @@ -56,8 +56,8 @@ POST: http://localhost:3070/api/auth/login "email": "sathira@niolla.lk", "firstName": "Jhon", "lastName": "Doe", - "role": "System Developer", - "accountType": "admin" + "accountType": "admin", + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9......" } } ``` @@ -90,7 +90,6 @@ No Body "firstName": "Sathira", "lastName": "Sri Sathsara", "email": "sathira@niolla.lk", - "role": "System Developer", "accountType": "admin", "iat": 1778865265, "exp": 1778868865, diff --git a/Documentation/User-API.md b/Documentation/User-API.md index f57aa43..c18a991 100644 --- a/Documentation/User-API.md +++ b/Documentation/User-API.md @@ -18,7 +18,9 @@ emailVerifiedAt = null POST: http://localhost:3070/api/user ``` -**Request Body** +**Request Body customer** + +accontType = customer and bussiness_customer ```json { @@ -26,6 +28,10 @@ POST: http://localhost:3070/api/user "lastName": "Bimsara", "email": "ibimsara00@gmail.com", "password": "Hello@12346" + "accountType": "customer", + + "address": "Colombo, Sri Lanka", + "phoneNumber": "0771234567" } ``` @@ -45,21 +51,20 @@ POST: http://localhost:3070/api/user } ``` -After registration, the user receives an email containing a verification link. -``` -#### Verify Email +After registration, the user receives an email containing a verification link. + + + +#### Verify Email Verifies the customer's email using the raw verification token received by email. -The backend hashes the received token and compares the resulting hash with the `tokenHash` stored in the `email_verifications` table. The token must: ```text -Exist in the database -Not have been used -Not have expired +store in redis and expire token ``` **Endpoint** @@ -107,7 +112,44 @@ usedAt = This prevents the same verification token from being successfully used again. ---- +#### Get user's details + +**Endpoint** + +``` +GET: http://localhost:3070/api/profile +``` + +**Respond** + +```json +{ + "success": true, + "message": "User profile retrieved successfully", + "data": { + "user": { + "id": "usr_572gtlpi", + "firstName": "Isuru", + "lastName": "Bimsara", + "email": "ibimsara00@gmail.com", + "accountType": "customer", + "accountStatus": "ACTIVE", + "emailVerifiedAt": "2026-08-20T16:26:04.000Z", + "passwordChangedAt": "2026-08-21T05:29:16.000Z", + "createdAt": "2026-08-20T16:25:30.000Z", + "updatedAt": "2026-08-21T05:29:16.000Z" + }, + "accountDetails": { + "customer_id": "cust_c8avqwbc", + "user_id": "usr_572gtlpi", + "address": "Colombo, Sri Lanka", + "phoneNumber": "0771234567", + "createdAt": "2026-08-20T16:25:31.000Z", + "updatedAt": "2026-08-20T16:25:31.000Z" + } + } +} +``` #### Get All Users diff --git a/app/controllers/auth.controller.js b/app/controllers/auth.controller.js index 2c5f27c..dbb480c 100644 --- a/app/controllers/auth.controller.js +++ b/app/controllers/auth.controller.js @@ -377,12 +377,57 @@ exports.resetPassword = async (req, res) => { // Logout: Clear the JWT cookie -exports.logout = (req, res) => { - res.clearCookie("access_token", { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", - }); +exports.logout = async (req, res) => { + try { + // 1. Get refresh token from cookie + const refreshToken = req.cookies?.refresh_token; - res.json({ success: true, message: "Logged out successfully" }); + // 2. If refresh token exists, find its session + if (refreshToken) { + const session = validateRefreshSession(refreshToken); + + // 3. Delete refresh session from server RAM + if (session) { + deleteRefreshSession(session.sessionId); + + console.log( + `Refresh session deleted: ${session.sessionId}` + ); + } + } + + // 4. Clear access token cookie + res.clearCookie("access_token", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: + process.env.NODE_ENV === "production" + ? "None" + : "Lax", + }); + + // 5. Clear refresh token cookie + res.clearCookie("refresh_token", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: + process.env.NODE_ENV === "production" + ? "None" + : "Lax", + }); + + // 6. Send response + return res.status(200).json({ + success: true, + message: "Logged out successfully", + }); + + } catch (error) { + console.error("LOGOUT ERROR:", error); + + return res.status(500).json({ + success: false, + message: "Failed to logout", + }); + } }; diff --git a/app/utils/jwt.util.js b/app/utils/jwt.util.js index 7d91279..f612f8a 100644 --- a/app/utils/jwt.util.js +++ b/app/utils/jwt.util.js @@ -13,7 +13,7 @@ const jwt = require("jsonwebtoken"); require("dotenv").config(); const JWT_SECRET = process.env.JWT_SECRET || "your_jwt_secret_key"; -const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "1d"; // token validity +const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "15m"; // token validity const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET || "your_refresh_token_secret_key"; const REFRESH_TOKEN_DAYS = process.env.REFRESH_TOKEN_DAYS || "7d"; // refresh token validity From 77666148981765fe5e4ab3d61a32b24fccc989c4 Mon Sep 17 00:00:00 2001 From: Isuru Bimsara Date: Fri, 21 Aug 2026 15:20:19 +0530 Subject: [PATCH 08/16] Enhance Authentication API documentation with detailed endpoints and error responses --- Documentation/Auth-API.md | 216 +++++++++++++++++++++++++++++++------- 1 file changed, 177 insertions(+), 39 deletions(-) diff --git a/Documentation/Auth-API.md b/Documentation/Auth-API.md index 9537601..ae2fb68 100644 --- a/Documentation/Auth-API.md +++ b/Documentation/Auth-API.md @@ -1,23 +1,48 @@ -# Auth API +# Authentication API -#### Request OTP +The Authentication API provides OTP-based login, access-token renewal, password recovery, current-user lookup, and logout. -**Endpoint** +## Base URL -``` -POST: http://localhost:3070/api/auth/req-otp +```text +http://localhost:3070/api/auth ``` -**Request Body** +Requests and responses use JSON unless otherwise stated. + +## Authentication + +After a successful login, the API returns an access token in the response and sets two HTTP-only cookies: + +- `access_token` — valid for 15 minutes +- `refresh_token` — valid for 7 days + +Protected endpoints accept the access token through the `access_token` cookie or this header: + +```http +Authorization: Bearer +``` + +When using cookie authentication from a browser, send requests with credentials enabled. + +--- + +## Request OTP + +Validates the user's email and password, then sends a one-time password to the registered email address. + +**Endpoint:** `POST` [http://localhost:3070/api/auth/req-otp](http://localhost:3070/api/auth/req-otp) + +### Request body ```json { - "email": "sathira@niolla.lk", + "email": "sathira@niolla.lk", "password": "Niolla@123" } ``` -**Respond** +### Success response — `201 Created` ```json { @@ -26,26 +51,30 @@ POST: http://localhost:3070/api/auth/req-otp } ``` +### Error responses + +- `401 Unauthorized` — invalid password +- `404 Not Found` — user not found +- `500 Internal Server Error` — OTP generation or email delivery failed + --- -#### Login +## Login -**Endpoint** +Verifies the emailed OTP and creates an authenticated session. The user account must be active. -``` -POST: http://localhost:3070/api/auth/login -``` +**Endpoint:** `POST` [http://localhost:3070/api/auth/login](http://localhost:3070/api/auth/login) -**Request Body** +### Request body ```json { - "email": "sathira@niolla.lk", + "email": "sathira@niolla.lk", "otp": "922304" } ``` -**Respond** +### Success response — `200 OK` ```json { @@ -54,33 +83,40 @@ POST: http://localhost:3070/api/auth/login "data": { "id": "usr_5ff8afec-5ddc-47d9-a63f-b43df0d8c3b4", "email": "sathira@niolla.lk", - "firstName": "Jhon", - "lastName": "Doe", + "firstName": "Sathira", + "lastName": "Sri Sathsara", + "role": null, "accountType": "admin", - "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9......" + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } } ``` +The response also sets the `access_token` and `refresh_token` HTTP-only cookies. + +### Error responses + +- `400 Bad Request` — email or OTP is missing +- `401 Unauthorized` — OTP is invalid or expired +- `403 Forbidden` — account is not active +- `404 Not Found` — user not found +- `500 Internal Server Error` — login failed unexpectedly + --- -#### Get Current User +## Get Current User -**Endpoint** +Returns the authenticated user's token claims and effective permissions. -``` -GET: http://localhost:3070/api/auth/me -``` +**Endpoint:** `GET` [http://localhost:3070/api/auth/me](http://localhost:3070/api/auth/me) -**Authorization**: Required (Bearer token) +**Authentication:** Required -**Request Body** +### Request body -``` -No Body -``` +No request body. -**Response (200)** +### Success response — `200 OK` ```json { @@ -98,27 +134,129 @@ No Body } ``` +### Error responses + +- `401 Unauthorized` — token is missing, invalid, or expired + --- -### Logout +## Refresh Session -**Endpoint** +Uses the HTTP-only refresh-token cookie to rotate the session and issue new access and refresh cookies. -``` -POST: http://localhost:3070/api/auth/logout +**Endpoint:** `POST` [http://localhost:3070/api/auth/refresh](http://localhost:3070/api/auth/refresh) + +### Request body + +No request body. The `refresh_token` cookie is required. + +### Success response — `200 OK` + +```json +{ + "success": true, + "message": "Session refreshed successfully" +} ``` -**Request Body** +### Error response — `401 Unauthorized` -``` -No Body +Returned when the refresh cookie is missing or the session is invalid, expired, or no longer active. + +--- + +## Forgot Password + +Sends a password-reset link when an account exists for the supplied email. The same success response is returned for unknown email addresses to prevent account discovery. + +**Endpoint:** `POST` [http://localhost:3070/api/auth/forgot-password](http://localhost:3070/api/auth/forgot-password) + +**Authentication:** Not required + +### Request body + +```json +{ + "email": "sathira@niolla.lk" +} ``` -**Respond** +### Success response — `200 OK` + +```json +{ + "success": true, + "message": "If an account exists for this email, a password reset link has been sent." +} +``` + +### Error responses + +- `400 Bad Request` — email is missing or invalid +- `500 Internal Server Error` — the reset request could not be processed + +--- + +## Reset Password + +Sets a new password using the token from the password-reset email. A successful reset invalidates all existing refresh sessions for the user. + +**Endpoint:** `POST` [http://localhost:3070/api/auth/reset-password](http://localhost:3070/api/auth/reset-password) + +**Authentication:** Not required + +### Request body + +```json +{ + "token": "password-reset-token", + "newPassword": "NewPassword@1234", + "confirmPassword": "NewPassword@1234" +} +``` + +The new password must contain at least one uppercase letter, one lowercase letter, one symbol, and four digits. It must differ from the current password. + +### Success response — `200 OK` + +```json +{ + "success": true, + "message": "Password reset successfully. Please login again." +} +``` + +### Error responses + +- `400 Bad Request` — fields are missing, passwords do not match, password rules are not met, the new password matches the current password, or the token is invalid or expired +- `500 Internal Server Error` — password reset failed unexpectedly + +--- + +## Logout + +Deletes the current refresh session when available and clears both authentication cookies. + +**Endpoint:** `POST` [http://localhost:3070/api/auth/logout](http://localhost:3070/api/auth/logout) + +### Request body + +No request body. + +### Success response — `200 OK` ```json { "success": true, "message": "Logged out successfully" } -``` \ No newline at end of file +``` + +### Error response — `500 Internal Server Error` + +```json +{ + "success": false, + "message": "Failed to logout" +} +``` From efb054f54db8a9eae265222ec9e57e8e0f199ccf Mon Sep 17 00:00:00 2001 From: Isuru Bimsara Date: Fri, 21 Aug 2026 21:42:19 +0530 Subject: [PATCH 09/16] Add change password functionality with validation and email notification --- Documentation/Auth-API.md | 86 +++++++++++++++++++ app/controllers/auth.controller.js | 127 ++++++++++++++++++++++++++--- app/routes/auth.routes.js | 1 + 3 files changed, 202 insertions(+), 12 deletions(-) diff --git a/Documentation/Auth-API.md b/Documentation/Auth-API.md index ae2fb68..74604e7 100644 --- a/Documentation/Auth-API.md +++ b/Documentation/Auth-API.md @@ -233,6 +233,92 @@ The new password must contain at least one uppercase letter, one lowercase lette --- +## Change Password + +Changes the authenticated user's password. After a successful change, all refresh sessions are revoked, authentication cookies are cleared, and the user must log in again. + +**Endpoint:** `POST` [http://localhost:3070/api/auth/change-password](http://localhost:3070/api/auth/change-password) + +**Authentication:** Required + +The access token may be supplied through the `access_token` cookie or as a Bearer token: + +```http +Authorization: Bearer +``` + +### Request body + +```json +{ + "currentPassword": "CurrentPassword@1234", + "newPassword": "NewPassword@5678", + "confirmPassword": "NewPassword@5678" +} +``` + +The new password: + +- Must match `confirmPassword` +- Must differ from the current password +- Must contain at least one uppercase letter +- Must contain at least one lowercase letter +- Must contain at least one symbol +- Must contain at least four digits + +### Success response — `200 OK` + +```json +{ + "success": true, + "message": "Password changed successfully. Please login again." +} +``` + +### Error responses + +#### `400 Bad Request` + +Returned when required fields are missing, the passwords do not match, the new password does not satisfy the password policy, or it matches the current password. + +```json +{ + "success": false, + "message": "New password and confirm password do not match" +} +``` + +#### `401 Unauthorized` + +Returned when authentication fails or the current password is incorrect. + +```json +{ + "success": false, + "message": "Current password is incorrect" +} +``` + +#### `404 Not Found` + +```json +{ + "success": false, + "message": "User not found" +} +``` + +#### `500 Internal Server Error` + +```json +{ + "success": false, + "message": "Failed to change password" +} +``` + +--- + ## Logout Deletes the current refresh session when available and clears both authentication cookies. diff --git a/app/controllers/auth.controller.js b/app/controllers/auth.controller.js index dbb480c..4780f89 100644 --- a/app/controllers/auth.controller.js +++ b/app/controllers/auth.controller.js @@ -374,7 +374,119 @@ exports.resetPassword = async (req, res) => { } }; +exports.changePassword = async (req, res) => { + try { + // User ID comes from authenticate middleware + const userId = req.user.id; + const { currentPassword, newPassword, confirmPassword } = req.body; + + // 1. Check required fields + if (!currentPassword || !newPassword || !confirmPassword) { + return res.status(400).send({ + success: false, + message: + "Current password, new password and confirm password are required", + }); + } + + // 2. Check new password and confirmation + if (newPassword !== confirmPassword) { + return res.status(400).send({ + success: false, + message: "New password and confirm password do not match", + }); + } + + // 3. Validate password policy + const passwordValid = validatePassword(newPassword); + + if (!passwordValid) { + return res.status(400).send({ + success: false, + message: "New password does not meet the required criteria", + }); + } + + // 4. Get logged-in user from database + const user = await User.findByPk(userId); + + if (!user) { + return res.status(404).send({ + success: false, + message: "User not found", + }); + } + + // 5. Check current password + const currentPasswordValid = await checkPassword( + currentPassword, + user.password, + ); + + if (!currentPasswordValid) { + return res.status(401).send({ + success: false, + message: "Current password is incorrect", + }); + } + + // 6. Make sure new password is different + const sameAsOldPassword = await checkPassword(newPassword, user.password); + + if (sameAsOldPassword) { + return res.status(400).send({ + success: false, + message: "New password must be different from current password", + }); + } + + // 7. Hash new password + const hashedPassword = await hashPassword(newPassword); + + // 8. Update user + user.password = hashedPassword; + user.passwordChangedAt = new Date(); + + await user.save(); + + // 9. Revoke all refresh sessions + deleteAllUserSessions(user.id); + + // 10. Clear auth cookies + res.clearCookie("access_token", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", + }); + + res.clearCookie("refresh_token", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", + }); + + // 11. Send confirmation email + try { + await sendPasswordChangedEmail(user.email, user.firstName); + } catch (mailError) { + console.error("PASSWORD CHANGED EMAIL ERROR:", mailError); + } + + // 12. Response + return res.status(200).send({ + success: true, + message: "Password changed successfully. Please login again.", + }); + } catch (error) { + console.error("CHANGE PASSWORD ERROR:", error); + + return res.status(500).send({ + success: false, + message: "Failed to change password", + }); + } +}; // Logout: Clear the JWT cookie exports.logout = async (req, res) => { @@ -390,9 +502,7 @@ exports.logout = async (req, res) => { if (session) { deleteRefreshSession(session.sessionId); - console.log( - `Refresh session deleted: ${session.sessionId}` - ); + console.log(`Refresh session deleted: ${session.sessionId}`); } } @@ -400,20 +510,14 @@ exports.logout = async (req, res) => { res.clearCookie("access_token", { httpOnly: true, secure: process.env.NODE_ENV === "production", - sameSite: - process.env.NODE_ENV === "production" - ? "None" - : "Lax", + sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", }); // 5. Clear refresh token cookie res.clearCookie("refresh_token", { httpOnly: true, secure: process.env.NODE_ENV === "production", - sameSite: - process.env.NODE_ENV === "production" - ? "None" - : "Lax", + sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", }); // 6. Send response @@ -421,7 +525,6 @@ exports.logout = async (req, res) => { success: true, message: "Logged out successfully", }); - } catch (error) { console.error("LOGOUT ERROR:", error); diff --git a/app/routes/auth.routes.js b/app/routes/auth.routes.js index 9818a5b..d22bacd 100644 --- a/app/routes/auth.routes.js +++ b/app/routes/auth.routes.js @@ -27,6 +27,7 @@ router.post('/login', authController.login); router.post('/refresh', authController.refreshToken); router.post('/forgot-password', authController.forgotPassword); router.post('/reset-password', authController.resetPassword); +router.post('/change-password', authenticate, authController.changePassword); router.post('/logout', authController.logout); module.exports = router; From 230962b1d0c7e8b0b335609998790e859507f7ee Mon Sep 17 00:00:00 2001 From: Sathira Sri Sathara Date: Thu, 3 Sep 2026 13:02:24 +0530 Subject: [PATCH 10/16] Add current backend status documentation --- Documentation/CURRENT_BACKEND_STATUS.md | 362 ++++++++++++++++++++++++ 1 file changed, 362 insertions(+) create mode 100644 Documentation/CURRENT_BACKEND_STATUS.md diff --git a/Documentation/CURRENT_BACKEND_STATUS.md b/Documentation/CURRENT_BACKEND_STATUS.md new file mode 100644 index 0000000..e819f55 --- /dev/null +++ b/Documentation/CURRENT_BACKEND_STATUS.md @@ -0,0 +1,362 @@ +# ZUMRI Current Backend Status + +Audit date: 2026-09-03 +Scope: repository source, configuration, lockfile, existing documentation, safe syntax/test/dependency checks. No database, Redis, S3, email, or other external service was mutated. + +## 1. Executive Summary + +This repository is an early modular-monolith foundation, not yet an e-commerce backend. It contains working-shaped user registration/email verification, OTP login, basic profiles, RBAC tables/controllers, notifications, activity logging, S3 upload plumbing, document generation, Redis/BullMQ workers, and one cron. Important pieces are incomplete or broken in integration. The estimated completion against the 25-module ZUMRI target is **about 12%**. + +No original development-plan day is fully complete. Day 1 is approximately 55%: Express, Sequelize/MySQL, Redis/BullMQ, a Dockerfile, and a health route exist, but Swagger, robust health checks, Node 22 alignment, production bootstrap/error handling, Compose/Nginx, migrations, and tests do not. Day 2 is approximately 38%; Day 3 approximately 18%; notification/job/file/document/audit foundations from later days were built early. + +The code still carries prior-project terminology (`Oceanic Titan`, `oceanic-db`, Oceanic demo URL) and role names that conflict with the actual user ENUM. Future work should preserve usable primitives, but first complete and secure foundation/authentication. + +Status terms: **COMPLETE** = end-to-end implementation is credible from source; **PARTIAL** = meaningful code exists but requirements/integration are incomplete; **STUB** = structure only; **MISSING** = no implementation; **BROKEN** = known source/integration defect. + +## 2. Current Architecture + +- `server.js` loads `.env`, imports `app.js`, and listens on `0.0.0.0:${PORT|3070}`. +- `app.js` authenticates Sequelize and calls unrestricted `sequelize.sync()` in an unawaited startup IIFE, then starts cron jobs. The HTTP listener starts independently, so requests can arrive before database readiness, and DB failure does not stop the process. +- Middleware order is cookie parser, CORS, JSON parser, Morgan, `/health`, `/api`, static documentation, then Bull Board. There is no URL-encoded parser, request ID, Helmet, compression, rate limiting, global 404 handler, or global error handler. +- API base path is `/api` (not versioned). Routes delegate mostly directly to Sequelize-backed controllers; there is only a permission service and activity service. +- Redis connections are created at module import. One shared client is used by queues/workers, while password reset and S3 utilities create additional clients. +- Workers are a separate `npm run worker` process. The server does not start them. Activity, log, and document consumers exist. +- Cron runs in every API process after DB sync; there is no distributed lock, so multi-instance deployments duplicate scheduling. +- Sequelize models are registered centrally and their `associate` functions are invoked. No migrations are present. +- Environment keys exist locally; `.env` is ignored/untracked. `.env.sample` is tracked. No actual secret values are reproduced here. + +### Bootstrap and operational findings + +| Concern | Status | Evidence / impact | +|---|---|---| +| Express start and base path | Partial | `server.js`, `app.js`; `/api` and `/health` | +| JSON / cookies / CORS / logging | Partial | JSON, cookies, single-origin credentialed CORS, Morgan `dev`; no body-size configuration or URL-encoded parser | +| Security middleware | Missing | No Helmet or API rate limiter | +| 404/global errors | Missing | Errors depend on individual controllers; unmatched routes use Express defaults | +| Database readiness | Broken | Listener is not gated on authenticate/sync; failure is logged only | +| Redis readiness | Partial | Event logging exists; health endpoint never checks Redis | +| BullMQ/workers | Partial | Separate process required and undocumented operationally; retries only on document jobs | +| Cron initialization | Partial | Starts after DB sync, once per server process, with no leader lock | +| Health check | Broken | Sends response before asynchronous DB authentication completes and hardcodes mail/Redis as OK | +| Graceful shutdown | Missing | No SIGTERM/SIGINT cleanup for server, Sequelize, Redis, workers, or cron | +| Process errors | Missing | No `unhandledRejection`/`uncaughtException` policy | + +## 3. Existing Infrastructure + +| Component | Status | Files | Notes | +|---|---|---|---| +| Express API | Partial | `server.js`, `app.js`, `app/routes/*` | Express 5; no versioning/global errors/security middleware | +| MySQL/Sequelize | Partial | `app/config/db.config.js`, `app/models/index.js` | Pool configured; runtime `sync()`, no migrations | +| Redis | Partial | `redis.config.js`, `redisClient.js` | Functional client pattern; excessive clients, no shutdown/readiness | +| BullMQ | Partial | `app/queues/*`, `app/workers/*` | Three queues and matching consumers; only document jobs have attempts/backoff/retention | +| Bull Board | Broken/unsafe | `bullBoard.config.js`, `app.js` | Only activity queue shown; `/admin/queues` has no authentication | +| Cron | Partial | `cron/*` | Transactional notification deletion; no distributed lock or retention-age policy | +| Email | Partial | `mail.config.js`, `mail.util.js`, templates | SMTP/template sender exists; verifies on import, sends inline, no queue/retry/escaping | +| S3 storage | Broken | `s3.config.js`, `s3Upload.utill.js` | S3 client is entirely commented out, so `s3.send` fails | +| Uploads | Broken | upload middleware/controller/model | MIME/size checks exist; account guard is incompatible; no magic-byte validation/cleanup/ownership | +| Documents | Partial/broken | document controller, logic, templates, queue/worker | PDF/Excel framework is substantive; S3 breaks completion; access is overly broad and role guards mismatch | +| Notifications | Partial | notification models/controller/cron | In-app records only; no assignment API/service, email/FCM/preferences/delivery tracking | +| Activity/audit | Partial | activity service/model/queue/worker/controller | Async append-only-shaped records; no actor/IP/change metadata, integrity/retention, or broad coverage | +| Logging | Partial | console utility/log queue/worker | Local daily files; may receive sensitive payloads; no rotation/structured sink | +| API docs | Partial | `Documentation/*`, docs routes | Handwritten Markdown/HTML, not Swagger/OpenAPI; docs auth is forgeable | +| Tests | Missing | package script only | Jest finds zero tests; Supertest is not installed | +| Docker | Partial | `Dockerfile` | Uses Node 20 instead of target Node 22; API image only; no healthcheck/non-root user | +| Nginx/Compose/CI/CD | Missing | none | No reverse proxy, service orchestration, or pipeline files | +| Payments/FCM/OpenAI | Missing | none | No dependencies, config, models, or consumers | + +Queue behavior: `document-generation` has 3 exponential attempts (2s base), completed retention, and retained failures. `activity-queue` and `logQueue` have consumers but no explicit retry/backoff/retention/dead-letter policy and are not idempotent. Re-delivery can duplicate activity rows or log lines. Queue event listeners attached to `Queue` are not a reliable substitute for `QueueEvents` for all lifecycle events. Worker failures are logged only. Document status is not reconciled on job failure/success. + +## 4. Existing Database Models + +All models use timestamps and none uses `paranoid`. Aside from the unique flags noted below, explicit indexes are absent. + +| Model / table | Key and important fields | Purpose | Important relations | Status | +|---|---|---|---|---| +| User / `users` | PK string `id`; names, unique email, password, accountType ENUM, accountStatus ENUM, emailVerifiedAt | Identity/account | hasOne Profile; hasMany UserPermission | Partial; no role FK/`roleID` despite service/controller use, no sessions/tokenVersion/social IDs | +| Profile / `profiles` | PK `profile_id`; unique `user_id`; theme, notificationsEnabled, image IDs, DOB, phone | Basic preferences/profile | belongsTo User | Partial; no gender/language/address; image IDs lack FKs | +| UserActivity / `UserActivity` | integer PK; user/name/description/type/module/date/time | Activity trail | None | Partial; user FK absent, redundant time fields, no indexes | +| Upload / `uploads` | integer PK; S3 path/type/size/name/use/uploaded_by | Media metadata | None | Partial; uploader/user and ownership FKs absent | +| Permission / `permission` | PK `permission_id`; name/page/module/action | Permission definition | hasMany role/user grants | Partial | +| Role / `roles` | PK `role_id`; name/description | Named role | hasMany role grants | Partial; User has no role association | +| RolePermission / `rolePermission` | PK `rp_id`; role_id, permission_id | Role grant | belongsTo Role/Permission | Partial; no composite unique constraint | +| UserPermission / `userPermission` | integer PK; user_id, permission_id | Direct additive grant | belongsTo User/Permission | Partial; no composite unique; cannot deny/expire grants | +| Document / `Document` | PK `doc_id`; reference_no, doc_type, JSON data, status | Saved business documents | None | Partial; status/type unconstrained, no creator/owner/FKs/indexes | +| DocumentType / `DocumentType` | integer PK; nullable name, JSON description | Document registry metadata | None | Partial; name is neither required nor unique | +| ReferenceNumber / `referenceNumbers` | PK string; unique sequence_key, integer counter, last value | Atomic sequence generation | None | Reusable; transaction/row locking implemented by utility | +| Notification / `notification` | PK string; headline/body, USER/ANNOUNCEMENT ENUM, active/date | In-app notification content | hasMany UserNotification as `users` | Partial | +| UserNotification / `user_notification` | integer PK; user_id, notification_id, isRead | Per-user notification state | belongsTo User; belongsTo Notification | Partial; constraints disabled, no composite unique/index; include alias is inconsistent (controller asks `notification`, association defines no alias) | + +Association registration does execute. However, User's `roleID` is not a declared attribute or FK, notifications deliberately disable FK constraints, activities/uploads/documents have no user association, and join-table uniqueness is not enforced. `sequelize.sync()` masks the absence of schema migrations and makes production schema evolution unsafe. + +## 5. Existing API Endpoints + +All paths below are derived from actual mounting. “Self-or-admin” is not implemented anywhere; parameterized user endpoints generally trust the requested ID after coarse account-type checks. + +| Method | Endpoint | Auth | Permission / guard | Status | Notes | +|---|---|---|---|---|---| +| GET | `/health` | No | None | Broken | Returns before DB check; Redis/mail are hardcoded OK | +| GET | `/api/auth/me` | Yes | None | Partial | Returns token payload plus effective permissions | +| POST | `/api/auth/req-otp` | No | None | Partial/unsafe | Password + in-memory OTP; logs OTP; enumeration; no attempts/rate limit/status check | +| POST | `/api/auth/login` | No | None | Partial/unsafe | OTP to one-day access cookie; no refresh/session/rotation/status check | +| POST | `/api/auth/logout` | No | None | Partial | Clears cookie only; bearer tokens remain valid | +| POST | `/api/user` | No | None | Partial/bug | Registration + profile + verification; activity uses undefined `req.user` after commit | +| POST | `/api/user/verify-email` | No | None | Partial | Redis one-use hashed token; no resend endpoint | +| GET | `/api/user` | Yes | accountType `admin` | Partial | Paginated list | +| GET | `/api/user/:id` | Yes | listed legacy types | Broken/IDOR | Most listed types cannot exist; no ownership enforcement | +| PATCH | `/api/user/:id` | Yes | listed legacy types | Broken/privilege escalation | Mass updates accountType/undeclared role fields; no ownership/field validation; early returns leak transaction | +| DELETE | `/api/user/:id` | Yes | `admin` | Partial | Hard delete; related profile handling depends on DB state; early return leaks transaction | +| GET | `/api/activity` | Yes | `admin` | Partial | Full audit list, no pagination | +| GET | `/api/activity/user/:userId` | Yes | `admin` | Partial | No paging/index | +| POST | `/api/upload` | Yes | `admin` or nonexistent `staff` | Broken | S3 client undefined; DB requires `use_for`; upload not transactional | +| GET | `/api/upload/signed-url/:id` | Yes | `admin` or nonexistent `staff` | Broken | S3 undefined; catch sends no response; no ownership check | +| GET | `/api/document/types` | Yes | admin or nonexistent legacy types | Partial | Effectively admin-only under current ENUM | +| POST | `/api/document/saved` | Yes | same | Partial | Body filter on a read operation; no owner/pagination; Sequelize `exclude` placed incorrectly | +| POST | `/api/document/generate` | Yes | same | Broken | Queues correctly, but worker S3 upload fails; validation shallow | +| POST | `/api/document/draft` | Yes | same | Partial | Saves arbitrary JSON; no ownership/validation | +| GET | `/api/document/reference-number/:documentType` | Yes | same | Partial | Consumes sequence on GET | +| GET | `/api/document/job/:jobId/status` | Yes | same | Partial/IDOR | Exposes stacktrace and any job by ID | +| GET | `/api/document/download/:uuid` | Yes | same | Broken/unsafe | S3 undefined; deletes shared object after response; no ownership | +| DELETE | `/api/document/job/:jobId` | Yes | same | Partial/IDOR | Any allowed user can remove any removable job | +| GET | `/api/document/:docId` | Yes | same | Partial/IDOR | Arbitrary document access | +| POST | `/api/docs/login` | No | Static credentials | Unsafe | Sets unsigned boolean cookie; no expiry/rate limiting | +| POST | `/api/docs/logout` | No | None | Partial | Clears cookie | +| GET | `/api/docs/markdown-files` | docs cookie | `docsAuth === "true"` | Unsafe | Cookie can be forged by client | +| GET | `/api/docs/view/:file` | docs cookie | same | Partial | Extension/path checks; auth is weak | +| POST | `/api/profile/req-reset-password` | No | None | Partial/unsafe logging | Enumeration-resistant response; raw reset token is logged | +| POST | `/api/profile/reset-password` | No | None | Partial | Hashed one-use Redis token; new password is not policy-validated | +| POST | `/api/profile/change-password` | Yes | legacy account types | Broken/partial | Effectively admin-only; no new-password validation/session revocation | +| GET | `/api/profile/avatar/:userId` | Yes | legacy account types | Broken | S3 undefined, missing upload null check, wrong `profile.userId` property, IDOR | +| GET | `/api/profile/background/:userId` | Yes | legacy account types | Broken | Same issues | +| POST | `/api/notification` | Yes | admin or nonexistent `management` | Partial | Creates content only; no user assignment | +| GET | `/api/notification/announcements` | Yes | legacy account types | Partial | Effectively admin-only | +| GET | `/api/notification/user/:userId` | Yes | legacy account types | Broken/IDOR | Include alias mismatch likely throws; no ownership | +| PATCH | `/api/notification/user/:userId/notification/:notificationId/read` | Yes | legacy account types | Broken/IDOR | No ownership; effectively admin-only | +| ALL | `/admin/queues/*` | No | None | Unsafe | Bull Board exposed; only activity queue registered | +| GET | `/Documentation/*` | No | Blocks `.md` only | Partial | Static HTML documentation is public | + +### Defined but unreachable permission routes + +`app/routes/permission.routes.js` defines 18 endpoints under the intended permission router (permission CRUD/bulk; role CRUD and grants; user grants CRUD/bulk), but `app/routes/index.js` imports `permissionRoutes` and never calls `router.use(...)`. Therefore none has an actual URL and all are **BROKEN/unreachable**. If mounted as `/permission`, their paths would be `/`, `/bulk`, `/:permissionId`, `/roles`, `/roles/:roleId`, `/roles/:roleId/permissions`, `/roles/permissions/bulk`, `/users/:userId/permissions`, `/users/permissions`, `/users/permissions/bulk`, and `/users/permissions/:upId` with the methods declared in that file. Read endpoints require authentication; mutations require accountType exactly `admin`. Controllers are substantial CRUD logic, but integration, uniqueness, cache invalidation, validation, and transactions are inconsistent. + +## 6. Development Plan Status + +| Module | Completion | Status | Evidence | Remaining Work | +|---|---:|---|---|---| +| 01 Authentication & Authorization | 38% | Partial | Password hashing, register/verify, OTP access JWT, middleware, RBAC structures | Refresh/session lifecycle, OAuth/Apple, durable OTP/limits, status enforcement, role linkage, mount/fix RBAC | +| 02 Customer Profile & Address Management | 22% | Foundation | User/Profile with phone, DOB, images/preferences | Self-service ownership, addresses/default, gender/language, deactivation, robust image flow | +| 03 Product Catalogue Management | 0% | Missing | No models/routes | Full catalogue/category/variant/review/SEO model and APIs | +| 04 Multi-Language Content | 0% | Missing | No content translation system | Locale strategy and translated content | +| 05 Inventory Management | 0% | Missing | No inventory entities | Stock, reservations, warehouses, adjustments | +| 06 Cart & Wishlist | 0% | Missing | None | Entire module | +| 07 Promotions, Coupons & Banners | 0% | Missing | None | Entire module | +| 08 Checkout | 0% | Missing | None | Pricing/shipping/tax/transaction orchestration | +| 09 Order Management | 0% | Missing | Document names are not commerce orders | Full order state machine and returns | +| 10 Payment Management | 0% | Missing | No provider code/dependencies | PayHere/Stripe, webhooks, idempotency, refunds | +| 11 Delivery & Rider Management | 2% | Foundation only | `rider` account ENUM; document templates named delivery/dispatch | Rider profiles, assignments, tracking, zones/rates | +| 12 Loyalty, Reward Points & Membership | 0% | Missing | None | Ledger, tiers, generic earn rules, rewards/vouchers | +| 13 Wholesale / Business Accounts | 2% | Foundation only | `business_customer` ENUM only | Approval/profile/pricing/MOQ/tiers/credit/settlements/invoices/analytics | +| 14 Notification System | 28% | Partial | Notification/user join models, CRUD/read endpoints, cleanup cron, mail utility | Fix aliases/ownership; assignment and preferences; FCM/email jobs/templates/status | +| 15 Support Ticket System | 0% | Missing | None | Tickets, messages, SLA/escalation/help center | +| 16 AI Customer Support Chatbot | 0% | Missing | No OpenAI integration | Conversations, tools, safety, human escalation | +| 17 Product Recommendations | 0% | Missing | None | Events and recommendation service | +| 18 Admin Dashboard & Analytics | 1% | Foundation | Admin user type and raw activity endpoint | Metrics, aggregation, secured dashboard APIs | +| 19 File & Media Management | 25% | Broken foundation | Multer, Upload model, S3/presigned utilities | Restore/configure client, ownership, object validation/lifecycle, media transformations | +| 20 Audit Logging & System Configuration | 22% | Partial | Async activity records/log queue | Full audit schema/coverage, immutability, config models, secure structured logging | +| 21 Background Jobs & Queues | 35% | Partial | Redis, 3 queues/consumers, worker entry, document retry | Idempotency, policies for every queue, monitoring auth, graceful shutdown, scheduler topology | +| 22 Security | 15% | Weak foundation | bcrypt, JWT verification, HttpOnly cookie, basic MIME limits | Findings in §10; headers, throttles, validation, authorization, secrets/token discipline | +| 23 Testing & Quality Assurance | 2% | Missing | Jest dependency/script only | Tests, Supertest, fixtures, lint/type/static checks, CI | +| 24 API Documentation | 18% | Partial | Handwritten endpoint Markdown/HTML | OpenAPI/Swagger, synchronization, schemas/security/error contracts | +| 25 Deployment & Infrastructure | 18% | Partial | Dockerfile | Node 22, Compose, Nginx, healthcheck, non-root, migrations, CI/CD, observability | + +## 7. Original 15-Day Plan Status + +| Day | Intended Scope | Completion | Notes | +|---|---|---:|---| +| 1 | Foundation | 55% | Express/Sequelize/MySQL/Redis/BullMQ/Docker present; health broken; Swagger/Compose/migrations/production lifecycle missing | +| 2 | Authentication | 38% | Basic verified registration and OTP access JWT; core session/OAuth/security requirements incomplete | +| 3 | Customer + business accounts | 18% | Basic user/profile and enum only; no addresses/business domain | +| 4 | Products/categories/variants | 0% | Missing | +| 5 | Inventory/admin products | 0% | Missing | +| 6 | Cart/wishlist | 0% | Missing | +| 7 | Promotions/checkout | 0% | Missing | +| 8 | Orders | 0% | Missing | +| 9 | Payments | 0% | Missing | +| 10 | Delivery/rider | 2% | Rider enum and unrelated document templates only | +| 11 | Loyalty | 0% | Missing | +| 12 | Notifications/support | 16% | Partial in-app notification foundation; support absent | +| 13 | AI/recommendations | 0% | Missing | +| 14 | Analytics/security | 8% | Raw activities and scattered security controls only | +| 15 | QA/production | 5% | Dockerfile only; no tests/CI/Nginx/production hardening | + +**Last genuinely complete day: none.** Development reached partway through Day 1 and Day 2, with selected infrastructure from Days 12, 14, and 15 implemented early. + +## 8. Demo Website Requirement Gaps + +| Feature | Present on Demo | Present Backend | Original Plan | Action Needed | +|---|---|---|---|---| +| Email/password login | Yes | Partial (password then email OTP) | Auth | Clarify desired login flow; secure OTP/session lifecycle | +| Remember me | Yes | No | Auth | Add session-specific lifetime safely | +| Forgot/reset password | Yes | Partial | Auth | Stop token logging, validate password, revoke sessions | +| Google sign-in | Yes | No | Auth | Add provider verification/linking | +| Apple sign-in | Yes | No | Potential added requirement | Add to auth scope explicitly | +| Account overview/recent orders/counts | Yes | No | Customer/orders | Aggregate dashboard endpoint after domain models | +| Points/vouchers/membership/progress | Yes | No | Loyalty | Ledger, tier/rule/reward architecture | +| Default address/profile/addresses | Yes | Profile partial; addresses absent | Customer | Ownership-safe profile and address CRUD/default constraint | +| Wishlist/order history/sign out | Yes | Sign-out cookie only; rest absent | Cart/orders/auth | Implement modules and true session revocation | +| Silver/Gold/Platinum tiers/benefits | Yes | No | Loyalty | Configurable tiers; do not hardcode demo examples | +| Point transaction history | Yes | No | Loyalty | Immutable ledger | +| Purchases/reviews/referrals/birthdays earning | Yes | No | Loyalty/reviews | Generic earn-source rules with idempotency | +| Reward redemption/vouchers/shipping rewards | Yes | No | Loyalty/promotions | Reward definitions, redemption transaction, vouchers | +| Business approval/Partner ID/tier | Yes | Account enum only | Wholesale | Business profile and approval workflow | +| Monthly/history/discount/top buyers/orders | Yes | No | Wholesale/analytics | Wholesale aggregates and reporting | +| Credit limit/available/utilization | Yes | No | Wholesale | Credit account/ledger and authorization rules | +| Settlement terms/dates/invoices | Yes | No | Wholesale/payment | Terms, statements, invoice/payment lifecycle | +| Wholesale catalogue/MOQ/pricing tiers/bulk | Yes | No | Wholesale/catalogue | Customer-segment pricing and volume tiers | +| Featured inventory/reorder | Yes | No | Inventory/wholesale | Stock and reorder workflows | +| Shipping thresholds/methods/zones/fees | Yes | No | Checkout/delivery | Configurable rate engine | +| International shipping/duties display | Yes | No | Delivery/checkout | Destination rules and duty estimate representation | +| Order tracking | Yes | No | Orders/delivery | Shipment event timeline | +| 30-day return/eligibility/reason/status | Yes | No | Orders (gap detail) | Configurable returns/RMA state machine | +| Pickup/refund/exchange | Yes | No | Delivery/payment/orders | Integrate RMA, courier, payment refund, exchange order | +| Help center/FAQs/sizing/payment help | Yes | No | Support/content | CMS/help content APIs | +| Email support/tickets | Yes | No | Support | Ticket/conversation/SLA module | +| AI Style Assistant/human escalation | Yes | No | AI/support | AI conversation with ticket/advisor handoff | +| Newsletter consent lifecycle | Yes | No | Demo gap | Dedicated subscriber consent/status/source/language model; not profile notification preference | +| New/featured/sale products | Yes | No | Catalogue/promotions | Merchandising fields/rules | +| Category/editorial collections | Yes | No | Catalogue/content | Collections and ordered merchandising | +| Related products | Yes | No | Recommendations/catalogue | Explicit and computed relations | +| Reviews/verified purchase reviews | Yes | No | Catalogue/recommendations | Review moderation and verified-order link | +| Size guides | Yes | No | Catalogue/content | Structured, category/product-linked guides | +| Product SEO | Yes | No | Catalogue | Slugs/meta/canonical data | +| Banners | Yes | No | Promotions | Placement, locale, schedule, targeting | +| Multiple languages | Yes | No | Multi-language | Localized product/content model | + +Current order/delivery architecture cannot support shipping or returns: no commerce Order, OrderItem, Shipment, Address, Return, Refund, or state-transition entities exist. The similarly named generated documents are generic JSON documents and should not be treated as domain substitutes. + +## 9. Technical Debt + +### CRITICAL + +- Restore a valid S3 client before any upload/document endpoint can work. +- Align authorization vocabulary and enforce ownership; current legacy guards, IDORs, and accountType mutation enable denial of access or privilege escalation. +- Remove raw OTP/reset-token and decoded-auth logging; rotate any credentials if operational logs captured them. +- Replace production `sequelize.sync()` with migrations and gate server readiness on required services. +- Implement a real access/refresh session model with rotation, revocation, account-status enforcement, and secure logout. + +### HIGH + +- Mount and repair permission routes; add User-role linkage and grant uniqueness/cache invalidation. +- Add validation schemas and centralized error handling; prevent arbitrary field updates. +- Protect Bull Board and documentation sessions. +- Fix notification association alias and user ownership checks. +- Add rate limits for login, OTP, verification, reset, docs login, and general API traffic. +- Add tests for auth/authorization, transactions, uploads/jobs, and failure paths. +- Fix transaction leaks on early returns in user update/delete. + +### MEDIUM + +- Standardize response/error formats and status codes; stop returning internal error messages/stack traces. +- Consolidate Redis connections and add lifecycle/readiness handling. +- Make jobs idempotent and add retry/backoff/retention/dead-letter/alert policies. +- Move email to a job and add retry/delivery tracking and safe template escaping. +- Add pagination/indexes to activity, notification, document, and user queries. +- Remove legacy Oceanic naming and reconcile API versioning. + +### LOW + +- Correct mojibake in source/log messages, inconsistent singular/plural table names, and `*.utill.js` spelling. +- Remove unused imports/constants and dead/commented code. +- Split giant permission controller and move business logic into services after behavior is tested. + +## 10. Security Findings + +- No tracked `.env` was detected. `.env.sample` is tracked. Potential secret-bearing configuration exists in local `.env`; values were not inspected/reproduced. If this file has ever been shared or committed elsewhere, rotate credentials. +- JWT falls back to a public placeholder secret if configuration is missing. There is no issuer/audience/session ID/tokenVersion or refresh-token revocation. +- Login OTP uses `Math.random`, process memory, and no attempt/rate limit; it fails across instances/restarts and is logged in plaintext. +- Password reset token is logged in plaintext. Reset/change paths do not validate the new password policy or revoke existing access tokens. +- Login does not reject pending, suspended, or deactivated accounts. +- User update allows coarse-authorized callers to target arbitrary IDs and set `accountType`, a privilege-escalation and IDOR risk. Many profile/notification/document endpoints have the same ownership defect. +- Bull Board is public. Docs protection is an unsigned client cookie equal to `true`; credentials are brute-forceable without rate limiting. +- CORS is a single credentialed origin and cookie flags are reasonable for cross-site production, but CSRF protection/origin validation is absent for cookie-authenticated mutations. +- Multer limits size and declared MIME, but image wildcard acceptance lacks content sniffing, image decompression safeguards, antivirus scanning, extension normalization policy, and object lifecycle cleanup. +- S3 `PutObject` sets no ACL (good default if bucket blocks public access), but actual bucket policy/encryption cannot be verified. Signed URLs are cached; authorization is checked only before URL issuance and ownership is not checked. +- Controllers expose `error.message`; job status exposes stack traces. Production Sequelize logging is inverted to `true`, which can leak query data. +- Request validation is handwritten and sparse; mass assignment exists in user update. Sequelize query values are generally parameterized, so no direct raw-SQL injection was found. +- Foreign-key constraints are disabled for notification joins; missing uniqueness and transactions create races/duplicates in grants and notifications. +- No Helmet, API rate limiting, CSRF strategy, audit integrity, session revocation, or centralized security error policy exists. + +## 11. Broken / Suspicious Implementations + +- `app/config/s3.config.js`: the entire client/export is commented; every S3 caller receives `{}`. +- `app/routes/index.js`: imports `permissionRoutes` but never mounts it. +- `app/utils/documentJob.util.js`: requires nonexistent `../queues/pdf.queue`; currently appears orphaned. +- `app/services/permission.service.js#getEffectivePermissions`: queries `user.roleID`, which is not a User model attribute; role grants cannot reliably apply. +- `app/routes/{user,profile,document,notification}.routes.js`: guards use `management`, `team_head`, `user`, or `staff`, none of which is in the User ENUM. Valid `manager`, `customer`, `business_customer`, `rider`, `support_agent`, and `superadmin` are largely excluded. +- `user.controller#createNewUser`: calls `logActivity({user: req.user})` on a public route, causing a post-commit exception after the account has been created; client may receive 500 and retry. +- `user.controller#updateUser`: accepts accountType and undeclared role/department fields; does not update email despite destructuring it; opens transaction before lookups and does not roll back early 404 responses. +- `user.controller#deleteUser`: early 404 does not roll back; hard delete may conflict with Profile because cascade is unspecified. +- `profile.controller#getProfileAvatar/getProfileBackgroundImage`: assumes upload exists, uses wrong `profile.userId` response property, lacks ownership, and reaches broken S3. +- `upload.controller#getFileUrl`: empty catch block can leave requests hanging. +- `notification.controller#getUserNotifications`: requests association alias `notification`, but `belongsTo` defines no alias; likely Sequelize eager-loading error. +- `notification.controller`: USER notifications are created but never assigned to users through an endpoint/service. +- `app.js#/health`: asynchronous DB result races with response; reports `N/A`/hardcoded OK rather than real readiness. +- `app.js` bootstrap: server listens before DB boot finishes; DB errors are swallowed; each instance starts cron. +- `app/middleware/permission.middleware.js`: unused `hasPermission`; admin bypass only recognizes `admin`, not `superadmin`; wildcard logic differs between helper and actual check. +- `docsSession.middleware.js`: trusts an unsigned, client-set boolean cookie. +- `document.controller#getSavedDocuments`: `exclude` is not nested under `attributes`, so JSON data may still be fetched; filter is in POST body despite message saying query parameter. +- `document.controller#downloadDocument`: destructive read deletes the object after delivery and lacks job/user ownership. +- `app/logic/documents/registry.js`: catches module load failure but still registers undefined functions, deferring failure to runtime. +- `consoleLog.utill.js` and auth/reset utilities: log pipelines may persist secrets and full error objects. +- Production DB logging is enabled while non-production logging is disabled, likely inverted. + +Orphaned or unused-looking code includes `documentJob.util.js`, `notification.utill.js` (empty), `logic/documents/engine/pdf.engine.js` (generation uses `pdfGenerator.js`), several Excel/id/vendor/calendar/basis utilities not referenced by mounted features, `Assets` in upload controller (unregistered), imported `PERMISSIONS`/`checkPermission` in routes where checks are absent/commented, and unused dependencies likely including `pdfmake` and `nodeman`. `nodemon` is incorrectly a production dependency. Static analysis cannot prove every dynamic/template use; confirm before removal. + +## 12. Reusable Existing Components + +- Sequelize model registry/association convention can be extended after migrations replace runtime sync. +- User/Profile registration transaction, bcrypt utility, hashed one-use email verification token, and hashed password-reset-token concepts are sound foundations once error/logging/session issues are fixed. +- Cookie-or-Bearer authentication middleware structure is reusable after it loads current user/session/status and applies token claims. +- Permission, role, role-grant, and user-grant models/controllers/service are worth repairing rather than rebuilding; add role linkage, constraints, mounting, validation, and cache invalidation. +- Shared Redis factory/client and permission/user cache helpers can be consolidated and retained. +- Activity queue/service/worker is a useful async audit foundation; extend its schema and coverage. +- BullMQ worker entry pattern and document queue retry/backoff settings are reusable; standardize them across queues. +- Upload metadata model, Multer memory-storage limits, S3 key generation, and presigned URL caching are reusable after the S3 client and authorization/content validation are fixed. +- Document registry, PDF/Excel generators, templates, queue/worker, Document/DocumentType models, and atomic reference-number generator are substantive reusable subsystems. They are auxiliary business-document infrastructure, not order/payment replacements. +- Notification/UserNotification models, read-state concept, announcement query, and transactional cleanup cron can be repaired and extended for channel delivery. +- Email transport/template system and existing verification/reset/welcome templates can be moved behind an email queue. + +## 13. Recommended Next Development Phase + +Continue with a **Foundation, security, and authentication completion phase** before starting catalogue work. + +First make startup deterministic and production-safe: Node 22 alignment, validated environment configuration, migrations, real readiness/liveness checks, global 404/errors, Helmet/rate limits, protected operations dashboards, graceful shutdown, and a test harness. Then complete identity: Redis-backed cryptographic OTP with limits, account-status checks, access/refresh sessions with rotation/revocation, secure logout/reset, validation, ownership rules, and a repaired/mounted role-permission system. Repair S3 only as part of restoring already-promised profile/media/document behavior. + +After that baseline passes integration tests, finish customer/business profile and address primitives, reusing User/Profile, auth middleware, Redis, permission service, upload system, activity logger, email templates, queues, and reference-number utility. Only then begin catalogue/product models. + +## 14. Recommended Updated Roadmap + +1. **Phase 0 — Stabilize foundation:** Node 22, env validation, migrations, startup/readiness, global errors/security middleware, graceful shutdown, protected Bull Board, baseline Jest/Supertest and CI. +2. **Phase 1 — Complete identity and authorization:** OTP/session/refresh rotation, status checks, logout/revocation, password recovery hardening, OAuth Google/Apple, ownership rules, repaired roles/permissions. +3. **Phase 2 — Repair existing cross-cutting services:** S3/media, email jobs, notification aliases/assignment, queue reliability/idempotency, audit schema, document ownership and job lifecycle. +4. **Phase 3 — Customer and business foundations:** Profile completion, addresses/defaults, preferences/deactivation, business approval/partner identity/credit and settlement primitives. +5. **Phase 4 — Catalogue/content:** Categories, products, variants, media, localized content, SEO, size guides, collections, reviews. +6. **Phase 5 — Inventory and merchandising:** Warehouses/stock/reservations, wholesale pricing/MOQ/volume tiers, banners/promotions/coupons. +7. **Phase 6 — Shopping and checkout:** Cart, wishlist, pricing, shipping zones/rates/duties, checkout transactions. +8. **Phase 7 — Orders/payments:** Order state machine, PayHere/Stripe webhook idempotency, invoices, refunds, returns/exchanges. +9. **Phase 8 — Delivery/rider:** Assignments, tracking events, pickup/return logistics. +10. **Phase 9 — Loyalty and wholesale completion:** Generic earn ledger, tiers, rewards/vouchers/referrals; credit utilization/settlements/analytics. +11. **Phase 10 — Support/AI/recommendations:** Help content, tickets/SLA, advisor escalation, AI assistant, recommendation events/services. +12. **Phase 11 — Analytics and production QA:** Dashboards, security testing, load/integration/e2e tests, observability, Nginx/Compose/deployment/runbooks. + +## 15. Do Not Rebuild List + +Preserve and extend these concepts/files after adding tests: centralized Sequelize registration; User/Profile base tables; bcrypt helper; Redis connection/cache helpers; email verification/password-reset token hashing; auth middleware extraction of cookie/Bearer tokens; RBAC model/controller foundation; activity queue/service/worker; document registry/generators/templates/queue/worker; reference-number sequencing; Upload metadata/Multer limits/S3 utility interface; Notification/UserNotification read-state model; cleanup cron transaction; mail templates/transport interface; Docker Chromium setup for Puppeteer. + +## Verification and Dependency Audit + +- `node --check` passed for every repository JavaScript file. +- `npm test -- --runInBand` failed because Jest found **0 tests**. +- `npm ls --depth=0` completed without reporting missing installed top-level packages. +- `npm audit --omit=dev` reported **28 production dependency vulnerabilities**: 19 high, 8 moderate, 1 low, 0 critical. Dependency upgrades were intentionally not performed. +- Broken local import scan found active-looking `documentJob.util.js -> ../queues/pdf.queue`; the commented future `email.worker` reference is not an active defect. +- Targeted dependency observations: Supertest, Swagger/OpenAPI tooling, Helmet, rate limiting, FCM, payment SDKs, and OpenAI SDK are missing. `nodemon` should be dev-only; `nodeman` and `pdfmake` appear unused. Confirm with runtime coverage before removal. From 267e80e2ec304e07e532b8453f2aaa77daff2718 Mon Sep 17 00:00:00 2001 From: Sathira Sri Sathara Date: Thu, 3 Sep 2026 13:33:26 +0530 Subject: [PATCH 11/16] feat: stabilize API startup and lifecycle management - Refactor server initialization to separate concerns and improve error handling. - Implement centralized environment validation using Zod. - Introduce database, Redis, and queue lifecycle management. - Add health check endpoints for liveness and readiness. - Enhance error handling middleware for better response structure. - Implement rate limiting for API endpoints. - Add request ID middleware for traceability. - Create Sequelize CLI configuration and baseline migration for schema management. - Establish CI workflow with Gitea for testing and syntax checks. - Document foundational changes and migration strategy in PHASE_0_FOUNDATION_STABILIZATION.md. - Add Docker Compose configuration for local development and testing. - Implement unit and integration tests for critical functionality. --- .env.sample | 77 +- .gitea/workflows/ci.yml | 18 + .sequelizerc | 6 + Dockerfile | 50 +- Documentation/CURRENT_BACKEND_STATUS.md | 13 + .../PHASE_0_FOUNDATION_STABILIZATION.md | 140 ++++ app.js | 114 ++- app/config/bullBoard.config.js | 6 +- app/config/database.lifecycle.js | 9 + app/config/db.config.js | 8 +- app/config/env.config.js | 64 ++ app/config/queue.lifecycle.js | 9 + app/config/redis.config.js | 3 +- app/config/redis.lifecycle.js | 14 + app/config/redisClient.js | 4 +- app/config/s3.config.js | 31 +- app/config/sequelize-cli.config.js | 17 + app/middleware/error.middleware.js | 46 ++ app/middleware/rateLimit.middleware.js | 21 + app/middleware/requestId.middleware.js | 10 + app/models/index.js | 8 +- app/routes/auth.routes.js | 3 + app/routes/docs.routes.js | 3 +- app/routes/health.routes.js | 24 + app/routes/profile.routes.js | 5 +- app/utils/jwt.util.js | 16 +- app/utils/mail.util.js | 13 +- app/utils/s3Upload.utill.js | 3 +- app/workers/index.js | 81 ++- compose.yaml | 59 ++ cron/index.js | 10 +- deployment/nginx/zumri-api.conf.example | 22 + .../20260903000000-current-schema-baseline.js | 103 +++ package-lock.json | 680 +++++++++++++++++- package.json | 36 +- scripts/check-syntax.js | 20 + server.js | 85 ++- tests/integration/foundation.test.js | 77 ++ tests/setupEnv.js | 14 + tests/unit/env.config.test.js | 21 + 40 files changed, 1682 insertions(+), 261 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .sequelizerc create mode 100644 Documentation/PHASE_0_FOUNDATION_STABILIZATION.md create mode 100644 app/config/database.lifecycle.js create mode 100644 app/config/env.config.js create mode 100644 app/config/queue.lifecycle.js create mode 100644 app/config/redis.lifecycle.js create mode 100644 app/config/sequelize-cli.config.js create mode 100644 app/middleware/error.middleware.js create mode 100644 app/middleware/rateLimit.middleware.js create mode 100644 app/middleware/requestId.middleware.js create mode 100644 app/routes/health.routes.js create mode 100644 compose.yaml create mode 100644 deployment/nginx/zumri-api.conf.example create mode 100644 migrations/20260903000000-current-schema-baseline.js create mode 100644 scripts/check-syntax.js create mode 100644 tests/integration/foundation.test.js create mode 100644 tests/setupEnv.js create mode 100644 tests/unit/env.config.test.js diff --git a/.env.sample b/.env.sample index b5b9911..efdc553 100644 --- a/.env.sample +++ b/.env.sample @@ -1,53 +1,60 @@ -# App Details -APP_NAME= +# Required for API and worker startup +APP_NAME=ZUMRI +NODE_ENV=development +PORT=3070 +FRONTEND_URL=http://localhost:3000 +TRUST_PROXY=0 -# Database configuration variables -DB_HOST = -DB_USER = -DB_PASSWORD = -DB_NAME = -DB_PORT = +DB_HOST=localhost +DB_PORT=3306 +DB_NAME=zumri +DB_USER=zumri +DB_PASSWORD=replace_with_local_database_password +MYSQL_ROOT_PASSWORD=replace_with_local_root_password -# Redis configuration variables REDIS_HOST=localhost REDIS_PORT=6379 +REDIS_PASSWORD=replace_with_local_redis_password -# JWT secret key -JWT_SECRET = your_jwt_secret_key_here -JWT_EXPIRES_IN =1d +# Use separate randomly generated values of at least 32 characters. +JWT_SECRET=replace_with_a_random_value_at_least_32_chars +REFRESH_TOKEN_SECRET=replace_with_a_different_random_32_char_value +JWT_EXPIRES_IN=15m +REFRESH_TOKEN_DAYS=7d -# AWS S3 configuration -AWS_ACCESS_KEY_ID=your_aws_access_key_id_here -AWS_SECRET_ACCESS_KEY=your_aws_secret_access_key_here -AWS_REGION=your_aws_region_here -AWS_S3_BUCKET_NAME=your_aws_bucket_name_here +# Runtime controls +RUN_CRON=false +CACHE=true +JSON_BODY_LIMIT=1mb +API_RATE_LIMIT_WINDOW_MS=900000 +API_RATE_LIMIT_MAX=300 +SENSITIVE_RATE_LIMIT_WINDOW_MS=900000 +SENSITIVE_RATE_LIMIT_MAX=20 +SHUTDOWN_TIMEOUT_MS=10000 -# Mail configuration +# Optional email feature +ENABLE_MAIL=false MAIL_HOST= MAIL_PORT=587 MAIL_USER= MAIL_PASS= -MAIL_SECURE=false +MAIL_SECURE=false MAIL_FROM= -# Documentation access credentials +# Optional S3 feature +ENABLE_S3=false +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_REGION= +AWS_S3_BUCKET_NAME= + +# Optional documentation login DOCS_USER= DOCS_PASS= -# Admin email for receiving notifications +# Optional application configuration MANAGER_EMAIL= - -# Caching configuration -CACHE=true - -# Application environment -NODE_ENV=development - -# User default password DEFAULT_PASSWORD= - -# Frontend URL for CORS -FRONTEND_URL=http://localhost:3000 - -# Puppeteer executable path (if needed, otherwise Puppeteer will use the bundled Chromium) -PUPPETEER_EXECUTABLE_PATH = C:\Users\User\.cache\puppeteer\chrome-headless-shell\win64-142.0.7444.162\chrome-headless-shell-win64\chrome-headless-shell.exe \ No newline at end of file +PASSWORD_RESET_TTL_SECONDS=900 +EMAIL_VERIFICATION_TTL_SECONDS=86400 +PUPPETEER_EXECUTABLE_PATH= diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..70ec25a --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,18 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run check:syntax + - run: npm test -- --runInBand diff --git a/.sequelizerc b/.sequelizerc new file mode 100644 index 0000000..88e1c7e --- /dev/null +++ b/.sequelizerc @@ -0,0 +1,6 @@ +const path = require("path"); + +module.exports = { + config: path.resolve("app/config/sequelize-cli.config.js"), + "migrations-path": path.resolve("migrations"), +}; diff --git a/Dockerfile b/Dockerfile index dc4df75..2f7b83c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,44 +1,22 @@ -FROM node:20-slim +FROM node:22-slim -# Install Chromium + dependencies RUN apt-get update && apt-get install -y \ - chromium \ - fonts-liberation \ - libatk-bridge2.0-0 \ - libatk1.0-0 \ - libcups2 \ - libxcomposite1 \ - libxrandr2 \ - libxdamage1 \ - libgbm1 \ - libasound2 \ - libpangocairo-1.0-0 \ - libpango-1.0-0 \ - libnss3 \ - libxss1 \ - libgtk-3-0 \ - libdrm2 \ - libxshmfence1 \ - ca-certificates \ - --no-install-recommends \ - && rm -rf /var/lib/apt/lists/* - -# Tell Puppeteer to use system Chromium -ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium - -# Prevent Puppeteer from downloading its own Chromium -ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true + chromium curl fonts-liberation libatk-bridge2.0-0 libatk1.0-0 libcups2 \ + libxcomposite1 libxrandr2 libxdamage1 libgbm1 libasound2 libpangocairo-1.0-0 \ + libpango-1.0-0 libnss3 libxss1 libgtk-3-0 libdrm2 libxshmfence1 ca-certificates \ + --no-install-recommends && rm -rf /var/lib/apt/lists/* +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium \ + PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \ + NODE_ENV=production WORKDIR /app - COPY package*.json ./ -RUN npm ci --omit=dev - -COPY . . - -ENV NODE_ENV=production +RUN npm ci --omit=dev && npm cache clean --force +COPY --chown=node:node . . +USER node EXPOSE 3070 - -CMD ["node", "server.js"] \ No newline at end of file +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl --fail --silent http://127.0.0.1:3070/health/live > /dev/null || exit 1 +CMD ["node", "server.js"] diff --git a/Documentation/CURRENT_BACKEND_STATUS.md b/Documentation/CURRENT_BACKEND_STATUS.md index e819f55..c312fd3 100644 --- a/Documentation/CURRENT_BACKEND_STATUS.md +++ b/Documentation/CURRENT_BACKEND_STATUS.md @@ -360,3 +360,16 @@ Preserve and extend these concepts/files after adding tests: centralized Sequeli - `npm audit --omit=dev` reported **28 production dependency vulnerabilities**: 19 high, 8 moderate, 1 low, 0 critical. Dependency upgrades were intentionally not performed. - Broken local import scan found active-looking `documentJob.util.js -> ../queues/pdf.queue`; the commented future `email.worker` reference is not an active defect. - Targeted dependency observations: Supertest, Swagger/OpenAPI tooling, Helmet, rate limiting, FCM, payment SDKs, and OpenAI SDK are missing. `nodemon` should be dev-only; `nodeman` and `pdfmake` appear unused. Confirm with runtime coverage before removal. + +## Phase 0 Completion Update + +**Date:** 2026-09-03 +**Revised Day 1 completion:** approximately **92%**. + +Phase 0 stabilized the existing foundation without adding commerce modules. Node/Docker now target Node 22; Zod validates required startup configuration and feature-gated mail/S3 configuration; unsafe JWT secret fallbacks are removed. Express construction is independent from listening, and `server.js` waits for successful MySQL authentication and Redis connectivity before accepting traffic. Runtime `sequelize.sync()` was removed and Sequelize CLI plus a non-destructive current-model baseline migration were added. + +New `/health/live` and `/health/ready` routes provide real liveness/readiness behavior, while `/health` remains a liveness compatibility alias. Request IDs, Helmet, explicit body limits, general and sensitive rate limits, centralized 404/error handling, safer production request logging, configurable cron startup, and API/worker graceful shutdown are now present. Bull Board requires an authenticated `admin` or `superadmin` and displays all three existing queues. The existing router is available on both `/api` and `/api/v1`. + +Deployment additions include a hardened Node 22/Chromium/non-root Dockerfile with healthcheck, API/worker/MySQL/Redis Compose configuration, an Nginx reverse-proxy example, and a Gitea Actions CI baseline. Jest/Supertest tests now cover environment validation, liveness/readiness, errors/404, protected routes, Bull Board denial, and request correlation. The first test run exposed incompatible ESM-only `uuid@13`; it was safely pinned to CommonJS-compatible v11. `nodemon` moved to devDependencies. + +Remaining foundation-adjacent work is intentionally deferred: production database baseline verification, distributed cron locking, full queue policy/idempotency, stronger documentation sessions, permission-router/role integration, and the Phase 1 authentication/ownership/security issues. The original audit above remains the historical baseline; statements such as “missing tests/Helmet/migrations” are superseded by this update and `Documentation/PHASE_0_FOUNDATION_STABILIZATION.md`. diff --git a/Documentation/PHASE_0_FOUNDATION_STABILIZATION.md b/Documentation/PHASE_0_FOUNDATION_STABILIZATION.md new file mode 100644 index 0000000..347a1d1 --- /dev/null +++ b/Documentation/PHASE_0_FOUNDATION_STABILIZATION.md @@ -0,0 +1,140 @@ +# ZUMRI Phase 0 Foundation Stabilization + +## Objective + +Stabilize the existing modular monolith so later identity and commerce work can build on deterministic startup, explicit schema management, observable health, baseline security, testability, and controlled shutdown. This phase does not add e-commerce domain behavior or intentionally redesign existing modules. + +## Starting Problems + +The API listener started before asynchronous database authentication, runtime `sequelize.sync()` was the schema strategy, `/health` returned before its database check and hardcoded other dependencies as healthy, Redis clients connected during imports, and there was no environment validation, global error/404 handling, request correlation, security headers, rate limiting, graceful shutdown, migration system, or tests. Bull Board was public. Docker targeted Node 20 while the architecture targets Node 22. See `Documentation/CURRENT_BACKEND_STATUS.md` for the full baseline audit. + +## Changes Implemented + +- Aligned package and container runtime to Node 22. +- Added centralized Zod environment validation with safe error messages. +- Separated Express construction (`app.js`) from dependency initialization and listening (`server.js`). +- Added database, Redis, queue, cron, API, and worker lifecycle handling. +- Removed runtime `sequelize.sync()` and introduced a Sequelize CLI baseline migration. +- Added liveness/readiness endpoints, request IDs, Helmet, body limits, general/sensitive rate limits, centralized errors, and centralized 404 behavior. +- Protected Bull Board with the existing JWT middleware and `admin`/`superadmin` account guard; registered activity, document, and log queues. +- Kept `/api` and added `/api/v1` as a backward-compatible alias. +- Added Jest/Supertest baseline tests and a portable JavaScript syntax-check command. +- Added Node 22 Docker hardening, development Compose, an Nginx example, and Gitea Actions CI. +- Restored the existing S3 utility interface through a feature-gated S3 client. +- Removed unsafe JWT/refresh-secret fallbacks and moved `nodemon` to development dependencies. +- Pinned `uuid` to the CommonJS-compatible v11 line after tests exposed that v13 could not be loaded by this CommonJS application. + +## Application Startup Lifecycle + +The API sequence is now: + +1. Load `.env`. +2. Validate critical configuration without displaying values. +3. Authenticate Sequelize (no schema mutation). +4. connect to and ping Redis. +5. Load the Express app and queue resources. +6. Start cron only when `RUN_CRON=true`. +7. Start the HTTP listener. +8. On SIGTERM/SIGINT or fatal process error, stop accepting traffic, stop cron, close queues, Redis, and Sequelize, with a timeout guard. + +Tests can import `app.js` without opening a TCP port. Startup failures prevent the listener from opening. + +## Environment Variables + +Required for API/worker startup: `NODE_ENV`, `PORT`, `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `JWT_SECRET`, `REFRESH_TOKEN_SECRET`, `REDIS_HOST`, `REDIS_PORT`, and `FRONTEND_URL`. JWT secrets must each be at least 32 characters. `REDIS_PASSWORD` is optional at schema level for deployments without Redis authentication. + +Runtime controls: `TRUST_PROXY` (numeric trusted proxy hop count; keep `0` when directly exposed), `JSON_BODY_LIMIT`, `API_RATE_LIMIT_WINDOW_MS`, `API_RATE_LIMIT_MAX`, `SENSITIVE_RATE_LIMIT_WINDOW_MS`, `SENSITIVE_RATE_LIMIT_MAX`, `RUN_CRON`, `CACHE`, and `SHUTDOWN_TIMEOUT_MS`. + +Optional mail variables are required as a complete group only when `ENABLE_MAIL=true`: `MAIL_HOST`, `MAIL_PORT`, `MAIL_USER`, `MAIL_PASS`, `MAIL_FROM`; `MAIL_SECURE` is optional. Optional S3 variables are required as a complete group only when `ENABLE_S3=true`: `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_S3_BUCKET_NAME`. Docs credentials remain optional. See `.env.sample`; never commit real values. + +## Database Migration Strategy + +Sequelize CLI uses `.sequelizerc`, `app/config/sequelize-cli.config.js`, and `migrations/20260903000000-current-schema-baseline.js`. Commands: + +```text +npm run db:migrate:status +npm run db:migrate +npm run db:migrate:undo +``` + +The baseline represents all currently registered models and creates only tables whose names are absent. It does not drop, alter, or validate columns during `up`. For an existing deployment: take a backup, compare its schema to the migration/model definitions, test against a restored copy, resolve drift explicitly, then run the migration so Sequelize records it. Do not blindly run the baseline undo in an existing environment; its standard `down` removes baseline tables. No migration was executed during this phase. + +Future schema changes require new forward migrations. Production no longer calls `sequelize.sync()`. + +## Health Endpoints + +- `GET /health/live`: process-only liveness; no dependencies queried. +- `GET /health/ready`: checks MySQL and Redis concurrently; returns 200/`ready` or 503/`not_ready`, exposing only `ok`/`error` states. +- `GET /health`: backward-compatible alias to liveness so existing monitors are not broken. + +Container orchestration should normally use liveness for process restart and readiness for traffic admission. + +## Security Middleware + +Helmet is enabled; CSP is disabled globally for compatibility with the existing Bull Board/static documentation, while the board itself is authorization-protected. The API disables `X-Powered-By`, limits JSON and URL-encoded bodies to 1 MB by default, retains current credentialed CORS behavior, and accepts `X-Request-ID` only in a constrained safe format. Unknown routes and uncaught request errors use a standard response. Production 500 responses hide internal details. + +JWT helpers have no public fallback secrets. Startup rejects missing/weak secrets. Morgan does not log Authorization, Cookie, or request bodies. Error logs contain request ID plus error name/message, not arbitrary error objects. + +## Rate Limiting + +Both `/api` and `/api/v1` use the configurable general limiter. The entire authentication router uses the stricter limiter, as do password reset requests/submissions and docs login. Health endpoints are outside limiters. `TRUST_PROXY` is an explicit numeric hop count rather than universal trust; set it to the exact Nginx hop count in deployment. + +## Bull Board Security + +`/admin/queues` now runs through the existing authentication middleware and accepts only actual User model administrator values: `admin` and `superadmin`. It has no hardcoded secondary credentials. Activity, document, and log queues are registered. + +## Logging / Request IDs + +Every request receives `req.id` and `X-Request-ID`; a safe incoming ID may be preserved. Development retains Morgan `dev`; production uses a concise method/path/status/timing line with request ID and no auth headers. The existing log queue remains in place. Phase 1 must remove remaining OTP/reset/session logging inside legacy authentication flows. + +## Graceful Shutdown + +The API handles SIGTERM, SIGINT, unhandled rejections, and uncaught exceptions. It closes the HTTP listener, cron tasks, API-owned queues, shared Redis, and Sequelize. Workers initialize required dependencies before accepting jobs and close worker instances, Redis, and Sequelize on the same signals/fatal conditions. `SHUTDOWN_TIMEOUT_MS` protects against indefinitely stuck shutdown. + +## Cron Deployment Model + +Cron is disabled unless `RUN_CRON=true`; tests do not start it. Until a distributed scheduler lock is added, enable it on exactly one API/scheduler instance. The cron launcher returns a stopper used during graceful shutdown. + +## Testing + +```text +npm run check:syntax +npm test -- --runInBand +npm run test:unit +npm run test:integration +``` + +Tests mock external infrastructure. Coverage includes liveness and readiness success/failure, `/health` compatibility, centralized 404/error responses, environment validation and optional features, authentication-required rejection, Bull Board rejection, and request IDs. No real MySQL, Redis, email, or S3 is required by the baseline suite. + +## Docker + +The existing image now uses `node:22-slim`, keeps system Chromium/Puppeteer support, installs production dependencies, copies files as the unprivileged `node` user, and includes a `/health/live` healthcheck. Build and configuration are still environment-driven. + +## Local Development + +Copy `.env.sample` to an ignored `.env`, replace all placeholder credentials/secrets, then run migrations explicitly before starting the API. `compose.yaml` provides API, worker, MySQL 8.4, and Redis 7.4 using the same application image and named data volumes. It does not auto-run migrations. Only the API port is published; MySQL/Redis remain internal. + +## CI + +`.gitea/workflows/ci.yml` uses checkout/setup-node actions, Node 22, `npm ci`, syntax checks, and Jest. It performs no deployment and requires no production credentials. Runner action mirroring/network policy remains an installation-specific Gitea concern. + +## API Versioning Strategy + +The existing router is mounted at both `/api` and `/api/v1`. Existing frontend calls remain valid, while new consumers should adopt `/api/v1`. A later compatibility window can deprecate `/api`; no route was mass-renamed in Phase 0. + +## Known Remaining Issues + +- Authentication contains in-memory OTP/refresh-session behavior and needs the dedicated Phase 1 security/session design; no Phase 1 feature was implemented here. +- Ownership/IDOR and role/account naming inconsistencies remain in legacy controllers/routes. +- Permission routes remain imported but unmounted and role linkage/cache behavior needs repair. +- Existing authentication utilities may still log OTP/reset/session material; remove and test during Phase 1. +- Docs authentication still uses a weak boolean cookie and should receive a server-authenticated session design. +- Activity/log queues need standardized retry, retention, idempotency, and sensitive-data sanitation. +- S3 is now correctly constructed only when enabled, but object authorization/content validation and lifecycle remain later work. +- The dependency audit still reports transitive vulnerabilities; forced/major upgrades were intentionally avoided. +- Migration baseline schema drift must be reviewed against any deployed database before first use. +- A distributed cron lock is not yet present. + +## Phase 1 Prerequisites + +The foundation is ready to begin Phase 1 once the baseline migration has been reviewed/tested against a copy of the deployment database and deployment secrets are configured. Phase 1 should focus on authentication/session durability, secure OTP/reset behavior, account status, role/permission integration, and ownership authorization without starting commerce modules. diff --git a/app.js b/app.js index 3ef48aa..e705cde 100644 --- a/app.js +++ b/app.js @@ -1,99 +1,69 @@ /** * Copyright (c) 2026 Niolla * All rights reserved. - * - * This source code is proprietary and confidential. - * Unauthorized copying, modification, distribution, or use - * of this file, via any medium, is strictly prohibited. */ -// app.js - const express = require("express"); const cors = require("cors"); +const helmet = require("helmet"); const morgan = require("morgan"); -const routes = require("./app/routes"); const cookieParser = require("cookie-parser"); -const { bullBoardRouter } = require("./app/config/bullBoard.config"); const path = require("path"); -const startAllCrons = require("./cron"); -const db = require("./app/models"); - -// Test DB connection and sync models -(async () => { - try { - await db.sequelize.authenticate(); - console.log("Database connected."); - - await db.sequelize.sync(); - console.log("Tables synced."); - - // Start all cron jobs - startAllCrons(); - - } catch (error) { - console.error("DB error:", error); - } -})(); +const routes = require("./app/routes"); +const { healthRouter, live } = require("./app/routes/health.routes"); +const { bullBoardRouter } = require("./app/config/bullBoard.config"); +const { authenticate } = require("./app/middleware/auth.middleware"); +const { authorizedAccountType } = require("./app/middleware/permission.middleware"); +const requestId = require("./app/middleware/requestId.middleware"); +const { generalApiLimiter } = require("./app/middleware/rateLimit.middleware"); +const { notFound, errorHandler } = require("./app/middleware/error.middleware"); const app = express(); +const trustProxy = Number(process.env.TRUST_PROXY || 0); +if (trustProxy > 0) app.set("trust proxy", trustProxy); +app.disable("x-powered-by"); +app.use(requestId); +app.use(helmet({ + contentSecurityPolicy: false, + hsts: process.env.NODE_ENV === "production" ? undefined : false, +})); app.use(cookieParser()); - -// CORS configuration -const corsOptions = { - origin: process.env.FRONTEND_URL || "https://oceanic-demo.vercel.app", +app.use(cors({ + origin: process.env.FRONTEND_URL, methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], - allowedHeaders: ["Content-Type", "Authorization"], + allowedHeaders: ["Content-Type", "Authorization", "X-Request-ID"], + exposedHeaders: ["X-Request-ID"], credentials: true, -}; -app.use(cors(corsOptions)); -app.options(/.*/, cors(corsOptions)); +})); +app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || "1mb" })); +app.use(express.urlencoded({ extended: false, limit: process.env.JSON_BODY_LIMIT || "1mb" })); -app.use(express.json()); -app.use(morgan("dev")); +morgan.token("request-id", (req) => req.id); +app.use(morgan(process.env.NODE_ENV === "production" ? ':remote-addr - :method :url :status :response-time ms req-id=:request-id' : "dev")); -app.get("/health", (req, res) => { - let dbStatus = "N/A"; - let emailStatus = "N/A"; - let redisStatus = "N/A"; +app.get("/health", live); +app.use("/health", healthRouter); - db.sequelize - .authenticate() - .then(() => { - console.log("DB connection successful."); - dbStatus = "OK"; - }) - .catch((err) => { - console.error("DB connection error:", err); - res.status(500).send("Internal Server Error"); - }); +// Keep the legacy path while all new clients migrate to the versioned path. +app.use("/api", generalApiLimiter, routes); +app.use("/api/v1", generalApiLimiter, routes); - emailStatus = "OK"; - - redisStatus = "OK"; - - res.send({ - status: "Online ✅", - database: dbStatus, - emailService: emailStatus, - redis: redisStatus, - }); -}); - -app.use("/api", routes); app.use( "/Documentation", - (req, res, next) => { - if (req.path.endsWith(".md")) { - return res.status(403).send("Forbidden"); - } - - next(); - }, + (req, res, next) => req.path.endsWith(".md") ? res.status(403).send("Forbidden") : next(), express.static(path.join(__dirname, "Documentation")), ); -app.use("/admin/queues", bullBoardRouter); + +app.use( + "/admin/queues", + authenticate, + authorizedAccountType(["admin", "superadmin"]), + bullBoardRouter, +); + +app.use(notFound); +app.use(errorHandler); module.exports = app; diff --git a/app/config/bullBoard.config.js b/app/config/bullBoard.config.js index 647e548..fbcec22 100644 --- a/app/config/bullBoard.config.js +++ b/app/config/bullBoard.config.js @@ -14,16 +14,18 @@ const { ExpressAdapter } = require("@bull-board/express"); const { BullMQAdapter } = require("@bull-board/api/bullMQAdapter"); const activityQueue = require("../queues/activity.queue"); +const documentQueue = require("../queues/document.queue"); +const logQueue = require("../queues/log.queue"); const serverAdapter = new ExpressAdapter(); serverAdapter.setBasePath("/admin/queues"); const { addQueue, removeQueue, setQueues, replaceQueues } = createBullBoard({ - queues: [new BullMQAdapter(activityQueue)], + queues: [activityQueue, documentQueue, logQueue].map((queue) => new BullMQAdapter(queue)), serverAdapter, }); module.exports = { bullBoardRouter: serverAdapter.getRouter(), -}; \ No newline at end of file +}; diff --git a/app/config/database.lifecycle.js b/app/config/database.lifecycle.js new file mode 100644 index 0000000..6fbaf29 --- /dev/null +++ b/app/config/database.lifecycle.js @@ -0,0 +1,9 @@ +const db = require("../models"); + +const initializeDatabase = async () => db.sequelize.authenticate(); +const checkDatabase = async () => { + try { await db.sequelize.authenticate(); return true; } catch (_error) { return false; } +}; +const closeDatabase = async () => db.sequelize.close(); + +module.exports = { initializeDatabase, checkDatabase, closeDatabase }; diff --git a/app/config/db.config.js b/app/config/db.config.js index 91ad3cc..a2e7679 100644 --- a/app/config/db.config.js +++ b/app/config/db.config.js @@ -12,10 +12,10 @@ require("dotenv").config(); module.exports = { - HOST: process.env.DB_HOST || "localhost", - USER: process.env.DB_USER || "root", - PASSWORD: process.env.DB_PASSWORD || "", - DB: process.env.DB_NAME || "oceanic-db", + HOST: process.env.DB_HOST, + USER: process.env.DB_USER, + PASSWORD: process.env.DB_PASSWORD, + DB: process.env.DB_NAME, PORT: process.env.DB_PORT || 3306, DIALECT: "mysql", diff --git a/app/config/env.config.js b/app/config/env.config.js new file mode 100644 index 0000000..a57df83 --- /dev/null +++ b/app/config/env.config.js @@ -0,0 +1,64 @@ +const { z } = require("zod"); + +const booleanString = z.enum(["true", "false"]).default("false").transform((value) => value === "true"); + +const envSchema = z.object({ + NODE_ENV: z.enum(["development", "test", "production"]).default("development"), + PORT: z.coerce.number().int().min(1).max(65535).default(3070), + DB_HOST: z.string().min(1), + DB_PORT: z.coerce.number().int().min(1).max(65535).default(3306), + DB_NAME: z.string().min(1), + DB_USER: z.string().min(1), + DB_PASSWORD: z.string(), + JWT_SECRET: z.string().min(32, "JWT_SECRET must contain at least 32 characters"), + REFRESH_TOKEN_SECRET: z.string().min(32, "REFRESH_TOKEN_SECRET must contain at least 32 characters"), + REDIS_HOST: z.string().min(1), + REDIS_PORT: z.coerce.number().int().min(1).max(65535).default(6379), + REDIS_PASSWORD: z.string().optional(), + FRONTEND_URL: z.string().url(), + TRUST_PROXY: z.coerce.number().int().min(0).max(10).default(0), + JSON_BODY_LIMIT: z.string().default("1mb"), + API_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(900000), + API_RATE_LIMIT_MAX: z.coerce.number().int().positive().default(300), + SENSITIVE_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(900000), + SENSITIVE_RATE_LIMIT_MAX: z.coerce.number().int().positive().default(20), + RUN_CRON: booleanString, + ENABLE_MAIL: booleanString, + ENABLE_S3: booleanString, + SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().positive().default(10000), + CACHE: booleanString, + MAIL_HOST: z.string().min(1).optional(), MAIL_PORT: z.coerce.number().int().positive().optional(), + MAIL_SECURE: z.enum(["true", "false"]).optional(), MAIL_USER: z.string().optional(), + MAIL_PASS: z.string().optional(), MAIL_FROM: z.string().optional(), + AWS_REGION: z.string().optional(), AWS_ACCESS_KEY_ID: z.string().optional(), + AWS_SECRET_ACCESS_KEY: z.string().optional(), AWS_S3_BUCKET_NAME: z.string().optional(), + DOCS_USER: z.string().optional(), DOCS_PASS: z.string().optional(), +}).superRefine((env, context) => { + const requireFeature = (enabled, names) => { + if (!enabled) return; + for (const name of names) { + if (!env[name]) context.addIssue({ code: "custom", path: [name], message: `${name} is required when enabled` }); + } + }; + requireFeature(env.ENABLE_MAIL, ["MAIL_HOST", "MAIL_PORT", "MAIL_USER", "MAIL_PASS", "MAIL_FROM"]); + requireFeature(env.ENABLE_S3, ["AWS_REGION", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_S3_BUCKET_NAME"]); +}); + +let validatedEnv; +const validateEnvironment = (source = process.env) => { + const result = envSchema.safeParse(source); + if (!result.success) { + const names = [...new Set(result.error.issues.map((issue) => issue.path.join(".") || "environment"))]; + throw new Error(`Invalid environment configuration: ${names.join(", ")}`); + } + validatedEnv = result.data; + return validatedEnv; +}; +const getEnvironment = () => validatedEnv || validateEnvironment(); +const getOptionalFeatureStatus = (env = process.env) => ({ + mail: Boolean(env.MAIL_HOST && env.MAIL_PORT && env.MAIL_USER && env.MAIL_PASS && env.MAIL_FROM), + s3: Boolean(env.AWS_REGION && env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY && env.AWS_S3_BUCKET_NAME), + docs: Boolean(env.DOCS_USER && env.DOCS_PASS), +}); + +module.exports = { validateEnvironment, getEnvironment, getOptionalFeatureStatus }; diff --git a/app/config/queue.lifecycle.js b/app/config/queue.lifecycle.js new file mode 100644 index 0000000..1f22616 --- /dev/null +++ b/app/config/queue.lifecycle.js @@ -0,0 +1,9 @@ +const activityQueue = require("../queues/activity.queue"); +const documentQueue = require("../queues/document.queue"); +const logQueue = require("../queues/log.queue"); + +const closeQueues = async () => { + await Promise.allSettled([activityQueue.close(), documentQueue.close(), logQueue.close()]); +}; + +module.exports = { closeQueues }; diff --git a/app/config/redis.config.js b/app/config/redis.config.js index 4016a56..5750a76 100644 --- a/app/config/redis.config.js +++ b/app/config/redis.config.js @@ -11,13 +11,14 @@ const { Redis } = require("ioredis"); -const createRedisConnection = () => { +const createRedisConnection = (options = {}) => { const redis = new Redis({ host: process.env.REDIS_HOST || "redis", port: process.env.REDIS_PORT || 6379, password: process.env.REDIS_PASSWORD, maxRetriesPerRequest: null, enableReadyCheck: false, + lazyConnect: options.lazyConnect ?? true, }); // Connection events diff --git a/app/config/redis.lifecycle.js b/app/config/redis.lifecycle.js new file mode 100644 index 0000000..7e31001 --- /dev/null +++ b/app/config/redis.lifecycle.js @@ -0,0 +1,14 @@ +const redis = require("./redisClient"); + +const initializeRedis = async () => { + if (redis.status === "wait") await redis.connect(); + if (redis.status !== "ready") await redis.ping(); +}; +const checkRedis = async () => { + try { return (await redis.ping()) === "PONG"; } catch (_error) { return false; } +}; +const closeRedis = async () => { + if (redis.status !== "end") await redis.quit(); +}; + +module.exports = { initializeRedis, checkRedis, closeRedis }; diff --git a/app/config/redisClient.js b/app/config/redisClient.js index d6a5355..029d68e 100644 --- a/app/config/redisClient.js +++ b/app/config/redisClient.js @@ -12,6 +12,6 @@ const createRedisConnection = require("./redis.config"); -const redis = createRedisConnection(); +const redis = createRedisConnection({ lazyConnect: true }); -module.exports = redis; \ No newline at end of file +module.exports = redis; diff --git a/app/config/s3.config.js b/app/config/s3.config.js index b85cad3..7ec7341 100644 --- a/app/config/s3.config.js +++ b/app/config/s3.config.js @@ -1,22 +1,11 @@ -/** - * Copyright (c) 2026 Niolla - * All rights reserved. - * - * This source code is proprietary and confidential. - * Unauthorized copying, modification, distribution, or use - * of this file, via any medium, is strictly prohibited. - */ +const { S3Client } = require("@aws-sdk/client-s3"); -// app/config/s3.config.js - -// const { S3Client } = require("@aws-sdk/client-s3"); - -// const s3 = new S3Client({ -// region: process.env.AWS_REGION, -// credentials: { -// accessKeyId: process.env.AWS_ACCESS_KEY_ID, -// secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, -// }, -// }); - -// module.exports = s3; \ No newline at end of file +module.exports = process.env.ENABLE_S3 === "true" + ? new S3Client({ + region: process.env.AWS_REGION, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + }, + }) + : { send: async () => { throw new Error("S3 functionality is not enabled"); } }; diff --git a/app/config/sequelize-cli.config.js b/app/config/sequelize-cli.config.js new file mode 100644 index 0000000..a16a38c --- /dev/null +++ b/app/config/sequelize-cli.config.js @@ -0,0 +1,17 @@ +require("dotenv").config(); + +const required = ["DB_HOST", "DB_NAME", "DB_USER"]; +const missing = required.filter((name) => !process.env[name]); +if (missing.length) throw new Error(`Missing migration environment variables: ${missing.join(", ")}`); + +const configuration = { + username: process.env.DB_USER, + password: process.env.DB_PASSWORD || "", + database: process.env.DB_NAME, + host: process.env.DB_HOST, + port: Number(process.env.DB_PORT || 3306), + dialect: "mysql", + logging: false, +}; + +module.exports = { development: configuration, test: configuration, production: configuration }; diff --git a/app/middleware/error.middleware.js b/app/middleware/error.middleware.js new file mode 100644 index 0000000..013e076 --- /dev/null +++ b/app/middleware/error.middleware.js @@ -0,0 +1,46 @@ +const { ValidationError, UniqueConstraintError } = require("sequelize"); +const { ZodError } = require("zod"); + +class AppError extends Error { + constructor(status, code, message, details) { + super(message); + this.status = status; + this.code = code; + this.details = details; + } +} + +const notFound = (req, _res, next) => next(new AppError(404, "NOT_FOUND", "Route not found")); + +const errorHandler = (error, req, res, _next) => { + let status = error.status || error.statusCode || 500; + let code = error.code || "INTERNAL_ERROR"; + let message = error.message || "An unexpected error occurred"; + let details = error.details; + + if (error instanceof ZodError) { + status = 400; code = "VALIDATION_ERROR"; message = "Invalid request data"; + details = error.issues.map(({ path, message: detailMessage }) => ({ field: path.join("."), message: detailMessage })); + } else if (error instanceof UniqueConstraintError) { + status = 409; code = "CONFLICT"; message = "A record with these values already exists"; + details = error.errors?.map(({ path, message: detailMessage }) => ({ field: path, message: detailMessage })); + } else if (error instanceof ValidationError) { + status = 400; code = "VALIDATION_ERROR"; message = "Invalid request data"; + details = error.errors?.map(({ path, message: detailMessage }) => ({ field: path, message: detailMessage })); + } else if (error.type === "entity.too.large") { + status = 413; code = "PAYLOAD_TOO_LARGE"; message = "Request body is too large"; + } else if (error.name === "UnauthorizedError" || error.name === "JsonWebTokenError") { + status = 401; code = "UNAUTHORIZED"; message = "Authentication failed"; + } + + if (status >= 500) { + console.error(`[${req.id || "no-request-id"}] Request failed`, { name: error.name, message: error.message }); + if (process.env.NODE_ENV === "production") message = "An unexpected error occurred"; + } + + const payload = { success: false, error: { code, message }, requestId: req.id }; + if (details && status < 500) payload.error.details = details; + res.status(status).json(payload); +}; + +module.exports = { AppError, notFound, errorHandler }; diff --git a/app/middleware/rateLimit.middleware.js b/app/middleware/rateLimit.middleware.js new file mode 100644 index 0000000..18e6281 --- /dev/null +++ b/app/middleware/rateLimit.middleware.js @@ -0,0 +1,21 @@ +const { rateLimit } = require("express-rate-limit"); + +const response = { success: false, error: { code: "RATE_LIMITED", message: "Too many requests" } }; + +const generalApiLimiter = rateLimit({ + windowMs: Number(process.env.API_RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000, + limit: Number(process.env.API_RATE_LIMIT_MAX) || 300, + standardHeaders: "draft-8", + legacyHeaders: false, + message: response, +}); + +const sensitiveLimiter = rateLimit({ + windowMs: Number(process.env.SENSITIVE_RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000, + limit: Number(process.env.SENSITIVE_RATE_LIMIT_MAX) || 20, + standardHeaders: "draft-8", + legacyHeaders: false, + message: response, +}); + +module.exports = { generalApiLimiter, sensitiveLimiter }; diff --git a/app/middleware/requestId.middleware.js b/app/middleware/requestId.middleware.js new file mode 100644 index 0000000..daf691a --- /dev/null +++ b/app/middleware/requestId.middleware.js @@ -0,0 +1,10 @@ +const { randomUUID } = require("crypto"); + +const SAFE_REQUEST_ID = /^[A-Za-z0-9_-]{8,128}$/; + +module.exports = (req, res, next) => { + const supplied = req.get("x-request-id"); + req.id = supplied && SAFE_REQUEST_ID.test(supplied) ? supplied : randomUUID(); + res.setHeader("X-Request-ID", req.id); + next(); +}; diff --git a/app/models/index.js b/app/models/index.js index 18b07a6..176c245 100644 --- a/app/models/index.js +++ b/app/models/index.js @@ -13,11 +13,7 @@ const { Sequelize, DataTypes } = require("sequelize"); const dbConfig = require("../config/db.config"); -let loggingOption = false; - -if(process.env.NODE_ENV === 'production') { - loggingOption = true; -} +const loggingOption = process.env.NODE_ENV === "development" ? console.log : false; const sequelize = new Sequelize( dbConfig.DB, @@ -76,4 +72,4 @@ Object.keys(db).forEach(model => { -module.exports = db; \ No newline at end of file +module.exports = db; diff --git a/app/routes/auth.routes.js b/app/routes/auth.routes.js index d22bacd..6bc7750 100644 --- a/app/routes/auth.routes.js +++ b/app/routes/auth.routes.js @@ -13,6 +13,9 @@ const express = require('express'); const router = express.Router(); const authController = require('../controllers/auth.controller'); const {authenticate} = require('../middleware/auth.middleware'); +const { sensitiveLimiter } = require('../middleware/rateLimit.middleware'); + +router.use(sensitiveLimiter); // GET /api/auth/me router.get("/me", authenticate, (req, res) => { diff --git a/app/routes/docs.routes.js b/app/routes/docs.routes.js index e206fe2..261bed2 100644 --- a/app/routes/docs.routes.js +++ b/app/routes/docs.routes.js @@ -6,6 +6,7 @@ const express = require("express"); const fs = require("fs"); const path = require("path"); const docsSession = require("../middleware/docsSession.middleware"); +const { sensitiveLimiter } = require("../middleware/rateLimit.middleware"); const router = express.Router(); @@ -47,7 +48,7 @@ router.get("/view/:file", docsSession, (req, res) => { }); // Docs login (simple) -router.post("/login", (req, res) => { +router.post("/login", sensitiveLimiter, (req, res) => { const { username, password } = req.body; if ( diff --git a/app/routes/health.routes.js b/app/routes/health.routes.js new file mode 100644 index 0000000..21ecfec --- /dev/null +++ b/app/routes/health.routes.js @@ -0,0 +1,24 @@ +const express = require("express"); +const { checkDatabase } = require("../config/database.lifecycle"); +const { checkRedis } = require("../config/redis.lifecycle"); + +const router = express.Router(); + +const live = (_req, res) => res.json({ + status: "ok", + service: "zumri-api", + timestamp: new Date().toISOString(), + uptime: process.uptime(), +}); + +router.get("/live", live); +router.get("/ready", async (_req, res) => { + const [database, redis] = await Promise.all([checkDatabase(), checkRedis()]); + const ready = database && redis; + res.status(ready ? 200 : 503).json({ + status: ready ? "ready" : "not_ready", + checks: { database: database ? "ok" : "error", redis: redis ? "ok" : "error" }, + }); +}); + +module.exports = { healthRouter: router, live }; diff --git a/app/routes/profile.routes.js b/app/routes/profile.routes.js index 9557ac9..080c301 100644 --- a/app/routes/profile.routes.js +++ b/app/routes/profile.routes.js @@ -19,10 +19,11 @@ const { checkPermission, } = require("../middleware/permission.middleware"); const PERMISSIONS = require("../constants/permissions"); +const { sensitiveLimiter } = require("../middleware/rateLimit.middleware"); -router.post("/req-reset-password", profileController.requestPasswordReset); +router.post("/req-reset-password", sensitiveLimiter, profileController.requestPasswordReset); -router.post("/reset-password", profileController.resetPassword); +router.post("/reset-password", sensitiveLimiter, profileController.resetPassword); router.post( "/change-password", diff --git a/app/utils/jwt.util.js b/app/utils/jwt.util.js index f612f8a..19bfd34 100644 --- a/app/utils/jwt.util.js +++ b/app/utils/jwt.util.js @@ -12,19 +12,23 @@ const jwt = require("jsonwebtoken"); require("dotenv").config(); -const JWT_SECRET = process.env.JWT_SECRET || "your_jwt_secret_key"; const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "15m"; // token validity -const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET || "your_refresh_token_secret_key"; const REFRESH_TOKEN_DAYS = process.env.REFRESH_TOKEN_DAYS || "7d"; // refresh token validity +const getSecret = (name) => { + const value = process.env[name]; + if (!value || value.length < 32) throw new Error(`${name} is not configured securely`); + return value; +}; + /** * Generate JWT token * @param {Object} payload - usually { id, email, role } * @returns string */ const generateToken = (payload) => { - return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN }); + return jwt.sign(payload, getSecret("JWT_SECRET"), { expiresIn: JWT_EXPIRES_IN }); }; /** @@ -33,15 +37,15 @@ const generateToken = (payload) => { * @returns payload or throws error */ const verifyToken = (token) => { - return jwt.verify(token, JWT_SECRET); + return jwt.verify(token, getSecret("JWT_SECRET")); }; const generateRefreshToken = (payload) => { - return jwt.sign(payload, REFRESH_TOKEN_SECRET, { expiresIn: REFRESH_TOKEN_DAYS }); + return jwt.sign(payload, getSecret("REFRESH_TOKEN_SECRET"), { expiresIn: REFRESH_TOKEN_DAYS }); } const verifyRefreshToken = (token) => { - return jwt.verify(token, REFRESH_TOKEN_SECRET); + return jwt.verify(token, getSecret("REFRESH_TOKEN_SECRET")); } module.exports = { generateToken, verifyToken, generateRefreshToken, verifyRefreshToken }; diff --git a/app/utils/mail.util.js b/app/utils/mail.util.js index 2a31598..aa94ee7 100644 --- a/app/utils/mail.util.js +++ b/app/utils/mail.util.js @@ -23,10 +23,12 @@ const transporter = nodemailer.createTransport({ auth: mailConfig.auth, }); -transporter.verify((err) => { - if (err) console.error("Mail server connection failed", err); - else console.log("Mail server ready"); -}); +if (process.env.NODE_ENV !== "test" && process.env.ENABLE_MAIL === "true") { + transporter.verify((err) => { + if (err) console.error("Mail server connection failed", { message: err.message }); + else console.log("Mail server ready"); + }); +} // Utility to load template and replace placeholders const loadTemplate = (templateName, variables = {}) => { @@ -42,6 +44,9 @@ const loadTemplate = (templateName, variables = {}) => { }; const send = async ({ to, subject, templateName, templateVars = {}, text }) => { + if (process.env.ENABLE_MAIL !== "true") { + throw new Error("Email functionality is not enabled"); + } const html = templateName ? loadTemplate(templateName, templateVars) : undefined; const mailOptions = { diff --git a/app/utils/s3Upload.utill.js b/app/utils/s3Upload.utill.js index 37ed6ae..c2eacae 100644 --- a/app/utils/s3Upload.utill.js +++ b/app/utils/s3Upload.utill.js @@ -15,8 +15,7 @@ const { getSignedUrl } = require("@aws-sdk/s3-request-presigner"); const s3 = require("../config/s3.config"); const { v4: uuidv4 } = require("uuid"); const path = require("path"); -const createRedisConnection = require("../config/redis.config"); -const redis = createRedisConnection(); +const redis = require("../config/redisClient"); const { log } = require("./consoleLog.utill"); // Upload + return key (BEST PRACTICE) diff --git a/app/workers/index.js b/app/workers/index.js index 044f936..71acf50 100644 --- a/app/workers/index.js +++ b/app/workers/index.js @@ -1,34 +1,63 @@ /** * Copyright (c) 2026 Niolla * All rights reserved. - * - * This source code is proprietary and confidential. - * Unauthorized copying, modification, distribution, or use - * of this file, via any medium, is strictly prohibited. */ -// app/workers/index.js +require("dotenv").config(); +const { validateEnvironment } = require("../config/env.config"); -require("dotenv").config() -const createActivityWorker = require("./activity.worker"); -const createLogWorker = require("./log.worker"); -const createDocumentWorker = require("./document.worker"); - -// Import future workers here -// const createEmailWorker = require("./email.worker"); - -console.log("🚀 Starting workers..."); - -// Initialize workers (async now) -(async () => { +const startWorkers = async () => { + const env = validateEnvironment(); + const { initializeDatabase, closeDatabase } = require("../config/database.lifecycle"); + const { initializeRedis, closeRedis } = require("../config/redis.lifecycle"); try { - createActivityWorker(); - createLogWorker(); - await createDocumentWorker(); - - console.log("✅ All workers started"); - } catch (err) { - console.error("❌ Error starting workers:", err.message); - process.exit(1); + await initializeDatabase(); + await initializeRedis(); + } catch (error) { + await Promise.allSettled([closeRedis(), closeDatabase()]); + throw error; } -})(); \ No newline at end of file + + const { closeQueues } = require("../config/queue.lifecycle"); + const createActivityWorker = require("./activity.worker"); + const createLogWorker = require("./log.worker"); + const createDocumentWorker = require("./document.worker"); + const workers = [createActivityWorker(), createLogWorker(), await createDocumentWorker()]; + console.log("All workers started."); + + let shuttingDown = false; + const shutdown = async (reason, exitCode = 0) => { + if (shuttingDown) return; + shuttingDown = true; + console.log(`Workers shutting down (${reason}).`); + const forceTimer = setTimeout(() => process.exit(1), env.SHUTDOWN_TIMEOUT_MS); + forceTimer.unref(); + await Promise.allSettled(workers.map((worker) => worker.close())); + await closeQueues(); + await closeRedis(); + await closeDatabase(); + clearTimeout(forceTimer); + process.exit(exitCode); + }; + + process.once("SIGTERM", () => shutdown("SIGTERM")); + process.once("SIGINT", () => shutdown("SIGINT")); + process.once("unhandledRejection", (reason) => { + const error = reason instanceof Error ? reason : new Error("Unhandled rejection"); + console.error("Unhandled worker rejection", { name: error.name, message: error.message }); + shutdown("unhandledRejection", 1); + }); + process.once("uncaughtException", (error) => { + console.error("Uncaught worker exception", { name: error.name, message: error.message }); + shutdown("uncaughtException", 1); + }); +}; + +if (require.main === module) { + startWorkers().catch((error) => { + console.error("Worker startup failed", { name: error.name, message: error.message }); + process.exitCode = 1; + }); +} + +module.exports = { startWorkers }; diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..dd77bcc --- /dev/null +++ b/compose.yaml @@ -0,0 +1,59 @@ +services: + api: + build: . + command: node server.js + env_file: .env + environment: + DB_HOST: mysql + REDIS_HOST: redis + ports: + - "${PORT:-3070}:${PORT:-3070}" + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + + worker: + build: . + command: node app/workers/index.js + env_file: .env + environment: + DB_HOST: mysql + REDIS_HOST: redis + RUN_CRON: "false" + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + + mysql: + image: mysql:8.4 + environment: + MYSQL_DATABASE: ${DB_NAME} + MYSQL_USER: ${DB_USER} + MYSQL_PASSWORD: ${DB_PASSWORD} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + volumes: + - mysql-data:/var/lib/mysql + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p$$MYSQL_ROOT_PASSWORD --silent"] + interval: 10s + timeout: 5s + retries: 10 + + redis: + image: redis:7.4-alpine + command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}"] + volumes: + - redis-data:/data + healthcheck: + test: ["CMD-SHELL", "redis-cli -a $$REDIS_PASSWORD ping | grep PONG"] + interval: 10s + timeout: 5s + retries: 10 + +volumes: + mysql-data: + redis-data: diff --git a/cron/index.js b/cron/index.js index 23beb3c..ce37337 100644 --- a/cron/index.js +++ b/cron/index.js @@ -14,8 +14,14 @@ const startCleanInactiveNotificationsCron = require("./notificationCleaning.cron function startAllCrons() { console.log("Starting Cron Jobs..."); - startCleanInactiveNotificationsCron(); + const tasks = [startCleanInactiveNotificationsCron()]; + return async () => { + for (const task of tasks) { + task.stop(); + if (typeof task.destroy === "function") task.destroy(); + } + }; } -module.exports = startAllCrons; \ No newline at end of file +module.exports = startAllCrons; diff --git a/deployment/nginx/zumri-api.conf.example b/deployment/nginx/zumri-api.conf.example new file mode 100644 index 0000000..32220b4 --- /dev/null +++ b/deployment/nginx/zumri-api.conf.example @@ -0,0 +1,22 @@ +upstream zumri_api { + server 127.0.0.1:3070; + keepalive 32; +} + +server { + listen 80; + server_name api.example.com; + client_max_body_size 6m; + + location / { + proxy_pass http://zumri_api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 10s; + proxy_read_timeout 60s; + proxy_send_timeout 60s; + } +} diff --git a/migrations/20260903000000-current-schema-baseline.js b/migrations/20260903000000-current-schema-baseline.js new file mode 100644 index 0000000..358388c --- /dev/null +++ b/migrations/20260903000000-current-schema-baseline.js @@ -0,0 +1,103 @@ +"use strict"; + +const tableName = (value) => typeof value === "string" ? value : value.tableName; + +module.exports = { + async up(queryInterface, Sequelize) { + const existing = new Set((await queryInterface.showAllTables()).map(tableName)); + const create = async (name, columns, options = {}) => { + if (!existing.has(name)) await queryInterface.createTable(name, columns, options); + }; + const timestamps = { + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false }, + }; + + await create("users", { + id: { type: Sequelize.STRING, primaryKey: true }, firstName: { type: Sequelize.STRING, allowNull: false }, + lastName: { type: Sequelize.STRING, allowNull: false }, email: { type: Sequelize.STRING, allowNull: false, unique: true }, + password: { type: Sequelize.STRING, allowNull: false }, + accountType: { type: Sequelize.ENUM("admin", "superadmin", "manager", "business_customer", "rider", "customer", "support_agent"), defaultValue: "customer" }, + accountStatus: { type: Sequelize.ENUM("PENDING_VERIFICATION", "ACTIVE", "SUSPENDED", "DEACTIVATED"), allowNull: false, defaultValue: "PENDING_VERIFICATION" }, + emailVerifiedAt: { type: Sequelize.DATE, allowNull: true, defaultValue: null }, ...timestamps, + }); + await create("profiles", { + profile_id: { type: Sequelize.STRING, primaryKey: true }, + user_id: { type: Sequelize.STRING, allowNull: false, unique: true, references: { model: "users", key: "id" } }, + theme: { type: Sequelize.STRING, allowNull: false, defaultValue: "light" }, notificationsEnabled: { type: Sequelize.BOOLEAN, defaultValue: true }, + profilePicture_id: { type: Sequelize.STRING, allowNull: true }, backgroundImage_id: { type: Sequelize.STRING, allowNull: true }, + dob: { type: Sequelize.DATE, allowNull: true }, phone_number: { type: Sequelize.STRING, allowNull: true }, ...timestamps, + }); + await create("customers", { + customer_id: { type: Sequelize.STRING, primaryKey: true }, + user_id: { type: Sequelize.STRING, allowNull: false, unique: true, references: { model: "users", key: "id" } }, + address: { type: Sequelize.STRING, allowNull: false }, phoneNumber: { type: Sequelize.STRING, allowNull: false }, ...timestamps, + }); + await create("business_customers", { + business_customer_id: { type: Sequelize.STRING, primaryKey: true }, + user_id: { type: Sequelize.STRING, allowNull: false, unique: true, references: { model: "users", key: "id" } }, + businessName: { type: Sequelize.STRING, allowNull: false }, businessRegistrationNumber: { type: Sequelize.STRING, allowNull: false }, + businessType: { type: Sequelize.STRING, allowNull: false }, contactName: { type: Sequelize.STRING, allowNull: false }, + phoneNumber: { type: Sequelize.STRING, allowNull: false }, businessEmail: { type: Sequelize.STRING, allowNull: false }, + expectedMonthlyVolume: { type: Sequelize.STRING, allowNull: false }, note: { type: Sequelize.STRING, allowNull: true }, ...timestamps, + }); + await create("roles", { + role_id: { type: Sequelize.STRING, primaryKey: true }, roleName: { type: Sequelize.STRING, allowNull: false }, + roleDescription: { type: Sequelize.TEXT, allowNull: true }, ...timestamps, + }); + await create("permission", { + permission_id: { type: Sequelize.STRING, primaryKey: true }, permissionName: { type: Sequelize.STRING, allowNull: false }, + permissionDescription: { type: Sequelize.TEXT, allowNull: true }, page: { type: Sequelize.STRING, allowNull: false }, + module: { type: Sequelize.STRING, allowNull: false }, action: { type: Sequelize.STRING, allowNull: false }, ...timestamps, + }); + await create("rolePermission", { + rp_id: { type: Sequelize.STRING, primaryKey: true }, role_id: { type: Sequelize.STRING, allowNull: false, references: { model: "roles", key: "role_id" } }, + permission_id: { type: Sequelize.STRING, allowNull: false, references: { model: "permission", key: "permission_id" } }, ...timestamps, + }); + await create("userPermission", { + up_id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true }, + user_id: { type: Sequelize.STRING, allowNull: false, references: { model: "users", key: "id" } }, + permission_id: { type: Sequelize.STRING, allowNull: false, references: { model: "permission", key: "permission_id" } }, ...timestamps, + }); + await create("uploads", { + id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true }, file_path: { type: Sequelize.STRING, allowNull: false }, + file_type: { type: Sequelize.STRING, allowNull: false }, file_size: { type: Sequelize.INTEGER, allowNull: false }, + original_name: { type: Sequelize.STRING, allowNull: false }, use_for: { type: Sequelize.STRING, allowNull: false }, + uploaded_by: { type: Sequelize.STRING, allowNull: false }, ...timestamps, + }); + await create("UserActivity", { + id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true }, user_id: { type: Sequelize.STRING, allowNull: false }, + username: { type: Sequelize.STRING, allowNull: false }, activity_description: { type: Sequelize.STRING, allowNull: false }, + activity_type: { type: Sequelize.STRING, allowNull: true }, module: { type: Sequelize.STRING, allowNull: true }, + activity_time: { type: Sequelize.DATE, allowNull: false }, activity_date: { type: Sequelize.DATEONLY, allowNull: false }, ...timestamps, + }); + await create("Document", { + doc_id: { type: Sequelize.STRING, primaryKey: true }, reference_no: { type: Sequelize.STRING, allowNull: false }, + doc_type: { type: Sequelize.STRING, allowNull: true, defaultValue: "N/A" }, data: { type: Sequelize.JSON, allowNull: false }, + status: { type: Sequelize.STRING, allowNull: false, defaultValue: "DRAFT" }, ...timestamps, + }); + await create("DocumentType", { + id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true }, + doc_type_name: { type: Sequelize.STRING, allowNull: true, defaultValue: "N/A" }, description: { type: Sequelize.JSON, allowNull: false }, ...timestamps, + }); + await create("referenceNumbers", { + id: { type: Sequelize.STRING, primaryKey: true }, sequence_key: { type: Sequelize.STRING, allowNull: false, unique: true }, + current_number: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 }, last_ref_number: { type: Sequelize.STRING }, ...timestamps, + }); + await create("notification", { + notification_id: { type: Sequelize.STRING, primaryKey: true }, notificationHeadline: { type: Sequelize.STRING, allowNull: false }, + notificationDescription: { type: Sequelize.TEXT, allowNull: true }, notificationType: { type: Sequelize.ENUM("USER", "ANNOUNCEMENT"), allowNull: false }, + isActive: { type: Sequelize.BOOLEAN, defaultValue: true }, dateCreated: { type: Sequelize.DATE, defaultValue: Sequelize.fn("NOW") }, ...timestamps, + }); + await create("user_notification", { + id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true }, user_id: { type: Sequelize.STRING(255), allowNull: false }, + notification_id: { type: Sequelize.STRING, allowNull: false }, isRead: { type: Sequelize.BOOLEAN, defaultValue: false }, ...timestamps, + }); + }, + + async down(queryInterface) { + for (const name of ["user_notification", "notification", "referenceNumbers", "DocumentType", "Document", "UserActivity", "uploads", "userPermission", "rolePermission", "permission", "roles", "business_customers", "customers", "profiles", "users"]) { + await queryInterface.dropTable(name); + } + }, +}; diff --git a/package-lock.json b/package-lock.json index 75debdb..93cc848 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "backend", + "name": "zumri-backend", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "backend", + "name": "zumri-backend", "version": "1.0.0", "license": "ISC", "dependencies": { @@ -22,6 +22,8 @@ "ejs": "^3.1.10", "exceljs": "^4.4.0", "express": "^5.2.1", + "express-rate-limit": "^8.7.0", + "helmet": "^8.3.0", "ioredis": "^5.10.1", "jsonwebtoken": "^9.0.3", "morgan": "^1.10.1", @@ -30,14 +32,20 @@ "node-cron": "^4.5.0", "nodemailer": "^8.0.1", "nodeman": "^1.1.2", - "nodemon": "^3.1.11", "pdfmake": "^0.2.7", "puppeteer": "^24.43.1", "sequelize": "^6.37.7", - "uuid": "^13.0.0" + "uuid": "^11.1.1", + "zod": "^4.5.4" }, "devDependencies": { - "jest": "^30.4.2" + "jest": "^30.4.2", + "nodemon": "^3.1.14", + "sequelize-cli": "^6.6.5", + "supertest": "^7.2.2" + }, + "engines": { + "node": ">=22 <23" } }, "node_modules/@aws-crypto/crc32": { @@ -2313,6 +2321,36 @@ "@emnapi/runtime": "^1.7.1" } }, + "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, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, + "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, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -3576,6 +3614,16 @@ "win32" ] }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -3642,6 +3690,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -3732,6 +3781,13 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/ast-types": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", @@ -3756,6 +3812,16 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/aws-ssl-profiles": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", @@ -4121,6 +4187,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4190,6 +4257,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -4523,6 +4591,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -4556,6 +4625,15 @@ "devtools-protocol": "*" } }, + "node_modules/chromium-bidi/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -4668,6 +4746,26 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/compress-commons": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", @@ -4704,6 +4802,17 @@ "typedarray": "^0.0.6" } }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -4761,6 +4870,13 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -5046,6 +5162,17 @@ "integrity": "sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==", "license": "BSD-3-Clause" }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dfa": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", @@ -5139,6 +5266,51 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/editorconfig/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -5499,6 +5671,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express/node_modules/cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", @@ -5554,6 +5745,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-xml-builder": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", @@ -5642,6 +5840,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -5772,6 +5971,24 @@ "node": ">= 0.6" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -5796,6 +6013,22 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -5806,6 +6039,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5979,6 +6213,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -6009,6 +6244,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -6054,9 +6290,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -6065,6 +6301,18 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -6168,6 +6416,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, "license": "ISC" }, "node_modules/immediate": { @@ -6248,6 +6497,13 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/ioredis": { "version": "5.10.1", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", @@ -6316,6 +6572,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -6324,6 +6581,22 @@ "node": ">=8" } }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-date-object": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", @@ -6344,6 +6617,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6372,6 +6646,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -6384,6 +6659,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -7297,6 +7573,83 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-beautify/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/js-beautify/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-beautify/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-cookie": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7347,6 +7700,19 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -7741,6 +8107,29 @@ "dev": true, "license": "MIT" }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -8153,15 +8542,16 @@ } }, "node_modules/nodemon": { - "version": "3.1.11", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", - "integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==", + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, "license": "MIT", "dependencies": { "chokidar": "^3.5.2", "debug": "^4", "ignore-by-default": "^1.0.1", - "minimatch": "^3.1.2", + "minimatch": "^10.2.1", "pstree.remy": "^1.1.8", "semver": "^7.5.3", "simple-update-notifier": "^2.0.0", @@ -8180,6 +8570,61 @@ "url": "https://opencollective.com/nodemon" } }, + "node_modules/nodemon/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nodemon/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/nodemon/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -8477,6 +8922,13 @@ "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, "node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", @@ -8560,6 +9012,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -8643,6 +9096,13 @@ "node": ">=0.4.0" } }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -8685,6 +9145,7 @@ "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, "license": "MIT" }, "node_modules/pump": { @@ -8856,6 +9317,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -8923,6 +9385,28 @@ "node": ">=0.10.0" } }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/resolve-cwd": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", @@ -9142,6 +9626,70 @@ } } }, + "node_modules/sequelize-cli": { + "version": "6.6.5", + "resolved": "https://registry.npmjs.org/sequelize-cli/-/sequelize-cli-6.6.5.tgz", + "integrity": "sha512-DqyISCULOaEbTM+rRQH4YvcUWeOC1XDiSKcjsC6TfAnT7W837mNkChJhtB/Z4FdCFHRCojmiP7zsrA4pARmacA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fs-extra": "^9.1.0", + "js-beautify": "1.15.4", + "lodash": "^4.17.21", + "picocolors": "^1.1.1", + "resolve": "^1.22.1", + "umzug": "^2.3.0", + "yargs": "^16.2.0" + }, + "bin": { + "sequelize": "lib/sequelize", + "sequelize-cli": "lib/sequelize" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/sequelize-cli/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/sequelize-cli/node_modules/yargs": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", + "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sequelize-cli/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/sequelize-pool": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-7.1.0.tgz", @@ -9329,6 +9877,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, "license": "MIT", "dependencies": { "semver": "^7.5.3" @@ -9599,10 +10148,57 @@ ], "license": "MIT" }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^3.0.0" @@ -9611,6 +10207,19 @@ "node": ">=4" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/synckit": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", @@ -9728,6 +10337,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -9755,6 +10365,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, "license": "ISC", "bin": { "nodetouch": "bin/nodetouch.js" @@ -9824,10 +10435,31 @@ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "license": "MIT" }, + "node_modules/umzug": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/umzug/-/umzug-2.3.0.tgz", + "integrity": "sha512-Z274K+e8goZK8QJxmbRPhl89HPO1K+ORFtm6rySPhFKfKc5GHhqdzD0SGhSWHkzoXasqJuItdhorSvY7/Cgflw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "^3.7.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/umzug/node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, "license": "MIT" }, "node_modules/undici-types": { @@ -9862,6 +10494,16 @@ "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", "license": "MIT" }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -9995,16 +10637,16 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist-node/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/v8-to-istanbul": { @@ -10288,9 +10930,9 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index c2e1cc2..3f56cf0 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "backend", + "name": "zumri-backend", "version": "1.0.0", "main": "app.js", "scripts": { @@ -7,15 +7,30 @@ "start": "node server.js", "worker": "node app/workers/index.js", "seed": "node app/seeders/index.js", - "test": "jest" + "test": "jest", + "test:unit": "jest tests/unit --runInBand", + "test:integration": "jest tests/integration --runInBand", + "check:syntax": "node scripts/check-syntax.js", + "db:migrate": "sequelize-cli db:migrate", + "db:migrate:status": "sequelize-cli db:migrate:status", + "db:migrate:undo": "sequelize-cli db:migrate:undo" + }, + "engines": { + "node": ">=22 <23" }, "keywords": [ - "Oceanic Titan", - "Oceanic Titan Backend" + "ZUMRI", + "ZUMRI Backend" ], "author": "Niolla PVT (LTD)", "license": "ISC", - "description": "Oceanic Titan Backend", + "description": "ZUMRI Backend", + "jest": { + "testEnvironment": "node", + "setupFiles": [ + "/tests/setupEnv.js" + ] + }, "dependencies": { "@aws-sdk/client-s3": "^3.1021.0", "@aws-sdk/s3-request-presigner": "^3.1021.0", @@ -30,6 +45,8 @@ "ejs": "^3.1.10", "exceljs": "^4.4.0", "express": "^5.2.1", + "express-rate-limit": "^8.7.0", + "helmet": "^8.3.0", "ioredis": "^5.10.1", "jsonwebtoken": "^9.0.3", "morgan": "^1.10.1", @@ -38,13 +55,16 @@ "node-cron": "^4.5.0", "nodemailer": "^8.0.1", "nodeman": "^1.1.2", - "nodemon": "^3.1.11", "pdfmake": "^0.2.7", "puppeteer": "^24.43.1", "sequelize": "^6.37.7", - "uuid": "^13.0.0" + "uuid": "^11.1.1", + "zod": "^4.5.4" }, "devDependencies": { - "jest": "^30.4.2" + "jest": "^30.4.2", + "nodemon": "^3.1.14", + "sequelize-cli": "^6.6.5", + "supertest": "^7.2.2" } } diff --git a/scripts/check-syntax.js b/scripts/check-syntax.js new file mode 100644 index 0000000..6a655b4 --- /dev/null +++ b/scripts/check-syntax.js @@ -0,0 +1,20 @@ +const { readdirSync, statSync } = require("fs"); +const { join } = require("path"); +const { spawnSync } = require("child_process"); + +const ignored = new Set(["node_modules", ".git", "coverage"]); +const files = []; +const walk = (directory) => { + for (const name of readdirSync(directory)) { + if (ignored.has(name)) continue; + const target = join(directory, name); + if (statSync(target).isDirectory()) walk(target); + else if (name.endsWith(".js")) files.push(target); + } +}; +walk(process.cwd()); +for (const file of files) { + const result = spawnSync(process.execPath, ["--check", file], { stdio: "inherit" }); + if (result.status !== 0) process.exit(result.status || 1); +} +console.log(`Syntax check passed for ${files.length} JavaScript files.`); diff --git a/server.js b/server.js index 586a57f..96dd175 100644 --- a/server.js +++ b/server.js @@ -1,20 +1,81 @@ /** * Copyright (c) 2026 Niolla * All rights reserved. - * - * This source code is proprietary and confidential. - * Unauthorized copying, modification, distribution, or use - * of this file, via any medium, is strictly prohibited. */ -// server.js - require("dotenv").config(); -const app = require("./app"); +const { validateEnvironment } = require("./app/config/env.config"); -const PORT = process.env.PORT || 3070; +const bootstrap = async () => { + const env = validateEnvironment(); + const { initializeDatabase, closeDatabase } = require("./app/config/database.lifecycle"); + const { initializeRedis, closeRedis } = require("./app/config/redis.lifecycle"); + try { + await initializeDatabase(); + console.log("Database connection ready."); + await initializeRedis(); + console.log("Redis connection ready."); + } catch (error) { + await Promise.allSettled([closeRedis(), closeDatabase()]); + throw error; + } -// Start the server -app.listen(PORT, "0.0.0.0", () => { - console.log(`Server running on http://localhost:${PORT}`); -}); + const { closeQueues } = require("./app/config/queue.lifecycle"); + const startAllCrons = require("./cron"); + const app = require("./app"); + + let stopCrons = async () => {}; + if (env.RUN_CRON) stopCrons = startAllCrons(); + + const server = await new Promise((resolve, reject) => { + const listener = app.listen(env.PORT, "0.0.0.0", () => resolve(listener)); + listener.once("error", reject); + }); + console.log(`ZUMRI API listening on port ${env.PORT}.`); + + let shuttingDown = false; + const shutdown = async (reason, exitCode = 0) => { + if (shuttingDown) return; + shuttingDown = true; + console.log(`Shutting down (${reason}).`); + const forceTimer = setTimeout(() => { + console.error("Graceful shutdown timed out."); + process.exit(1); + }, env.SHUTDOWN_TIMEOUT_MS); + forceTimer.unref(); + try { + await new Promise((resolve) => server.close(resolve)); + await stopCrons(); + await closeQueues(); + await closeRedis(); + await closeDatabase(); + clearTimeout(forceTimer); + process.exit(exitCode); + } catch (error) { + console.error("Shutdown failed", { name: error.name, message: error.message }); + process.exit(1); + } + }; + + process.once("SIGTERM", () => shutdown("SIGTERM")); + process.once("SIGINT", () => shutdown("SIGINT")); + process.once("unhandledRejection", (reason) => { + const error = reason instanceof Error ? reason : new Error("Unhandled rejection"); + console.error("Unhandled rejection", { name: error.name, message: error.message }); + shutdown("unhandledRejection", 1); + }); + process.once("uncaughtException", (error) => { + console.error("Uncaught exception", { name: error.name, message: error.message }); + shutdown("uncaughtException", 1); + }); + return { server, shutdown }; +}; + +if (require.main === module) { + bootstrap().catch((error) => { + console.error("API startup failed", { name: error.name, message: error.message }); + process.exitCode = 1; + }); +} + +module.exports = { bootstrap }; diff --git a/tests/integration/foundation.test.js b/tests/integration/foundation.test.js new file mode 100644 index 0000000..5879b55 --- /dev/null +++ b/tests/integration/foundation.test.js @@ -0,0 +1,77 @@ +const express = require("express"); +const request = require("supertest"); + +const mockQueue = { add: jest.fn(), close: jest.fn(), on: jest.fn(), getJob: jest.fn() }; +const mockCheckDatabase = jest.fn(); +const mockCheckRedis = jest.fn(); +jest.mock("../../app/queues/activity.queue", () => mockQueue); +jest.mock("../../app/queues/document.queue", () => mockQueue); +jest.mock("../../app/queues/log.queue", () => mockQueue); +jest.mock("../../app/config/redisClient", () => ({ + status: "wait", connect: jest.fn(), ping: jest.fn(), get: jest.fn(), set: jest.fn(), del: jest.fn(), quit: jest.fn(), +})); +jest.mock("../../app/config/database.lifecycle", () => ({ checkDatabase: mockCheckDatabase })); +jest.mock("../../app/config/redis.lifecycle", () => ({ checkRedis: mockCheckRedis })); +jest.mock("../../app/config/bullBoard.config", () => { + const express = require("express"); + return { bullBoardRouter: express.Router().get("/", (_req, res) => res.json({ ok: true })) }; +}); + +const app = require("../../app"); +const { AppError, errorHandler } = require("../../app/middleware/error.middleware"); + +describe("foundation HTTP behavior", () => { + beforeEach(() => { + mockCheckDatabase.mockResolvedValue(true); + mockCheckRedis.mockResolvedValue(true); + }); + test("GET /health/live reports process liveness", async () => { + const response = await request(app).get("/health/live").expect(200); + expect(response.body).toMatchObject({ status: "ok", service: "zumri-api" }); + expect(response.body.timestamp).toBeDefined(); + expect(response.headers["x-request-id"]).toBeDefined(); + }); + + test("legacy GET /health remains a liveness alias", async () => { + await request(app).get("/health").expect(200).expect(({ body }) => expect(body.status).toBe("ok")); + }); + + test("GET /health/ready checks database and Redis", async () => { + await request(app).get("/health/ready").expect(200).expect(({ body }) => { + expect(body).toEqual({ status: "ready", checks: { database: "ok", redis: "ok" } }); + }); + expect(mockCheckDatabase).toHaveBeenCalled(); + expect(mockCheckRedis).toHaveBeenCalled(); + }); + + test("GET /health/ready returns 503 when a dependency fails", async () => { + mockCheckDatabase.mockResolvedValueOnce(false); + await request(app).get("/health/ready").expect(503).expect(({ body }) => { + expect(body.status).toBe("not_ready"); + expect(body.checks.database).toBe("error"); + }); + }); + + test("unknown routes use the centralized 404 response", async () => { + const response = await request(app).get("/does-not-exist").expect(404); + expect(response.body.error).toMatchObject({ code: "NOT_FOUND", message: "Route not found" }); + }); + + test("global errors use the standard response and request ID", async () => { + const errorApp = express(); + errorApp.use(require("../../app/middleware/requestId.middleware")); + errorApp.get("/error", (_req, _res, next) => next(new AppError(400, "TEST_ERROR", "Controlled failure"))); + errorApp.use(errorHandler); + const response = await request(errorApp).get("/error").expect(400); + expect(response.body.error).toEqual({ code: "TEST_ERROR", message: "Controlled failure" }); + expect(response.body.requestId).toBeDefined(); + }); + + test("an authenticated route rejects missing credentials", async () => { + await request(app).get("/api/auth/me").expect(401).expect(({ body }) => expect(body.success).toBe(false)); + }); + + test("Bull Board rejects unauthenticated requests", async () => { + await request(app).get("/admin/queues").expect(401); + }); +}); diff --git a/tests/setupEnv.js b/tests/setupEnv.js new file mode 100644 index 0000000..232783b --- /dev/null +++ b/tests/setupEnv.js @@ -0,0 +1,14 @@ +process.env.NODE_ENV = "test"; +process.env.PORT = "3070"; +process.env.DB_HOST = "localhost"; +process.env.DB_PORT = "3306"; +process.env.DB_NAME = "zumri_test"; +process.env.DB_USER = "zumri_test"; +process.env.DB_PASSWORD = "test-only-password"; +process.env.JWT_SECRET = "test-only-jwt-secret-value-32-characters"; +process.env.REFRESH_TOKEN_SECRET = "test-only-refresh-secret-value-32-chars"; +process.env.REDIS_HOST = "localhost"; +process.env.REDIS_PORT = "6379"; +process.env.FRONTEND_URL = "http://localhost:3000"; +process.env.RUN_CRON = "false"; +process.env.CACHE = "false"; diff --git a/tests/unit/env.config.test.js b/tests/unit/env.config.test.js new file mode 100644 index 0000000..5b7bcf5 --- /dev/null +++ b/tests/unit/env.config.test.js @@ -0,0 +1,21 @@ +const { validateEnvironment, getOptionalFeatureStatus } = require("../../app/config/env.config"); + +const valid = { + NODE_ENV: "test", PORT: "3070", DB_HOST: "localhost", DB_PORT: "3306", DB_NAME: "test", + DB_USER: "test", DB_PASSWORD: "", JWT_SECRET: "a".repeat(32), REFRESH_TOKEN_SECRET: "b".repeat(32), + REDIS_HOST: "localhost", REDIS_PORT: "6379", FRONTEND_URL: "http://localhost:3000", +}; + +describe("environment validation", () => { + test("accepts the critical API configuration", () => { + expect(validateEnvironment(valid)).toMatchObject({ PORT: 3070, DB_NAME: "test", RUN_CRON: false }); + }); + + test("fails safely and names invalid variables without values", () => { + expect(() => validateEnvironment({ ...valid, JWT_SECRET: "short" })).toThrow("JWT_SECRET"); + }); + + test("keeps mail and S3 optional", () => { + expect(getOptionalFeatureStatus(valid)).toMatchObject({ mail: false, s3: false }); + }); +}); From 9d3d4314161c1845195b7a05ef2d573738644ce2 Mon Sep 17 00:00:00 2001 From: Sathira Sri Sathara Date: Thu, 3 Sep 2026 13:56:18 +0530 Subject: [PATCH 12/16] feat: implement identity and security features - Added account types and privileged account types constants. - Created admin user controller for updating user security fields. - Developed role assignment controller for managing user roles. - Implemented validation middleware for request schemas. - Defined user role and auth session models for database interactions. - Created services for authentication, email notifications, and OTP handling. - Developed OAuth service for Google and Apple authentication. - Added JWT utility functions for token generation and verification. - Implemented comprehensive tests for authentication, session management, and password policies. - Created migration for updating user schema and adding new tables for auth sessions and user identities. --- .env.sample | 11 +- Documentation/API_AUTHENTICATION.md | 95 +++ Documentation/CURRENT_BACKEND_STATUS.md | 14 + .../PHASE_0_FOUNDATION_STABILIZATION.md | 2 +- .../PHASE_1_IDENTITY_AUTHORIZATION.md | 119 ++++ app/config/env.config.js | 9 +- app/constants/accountTypes.js | 15 + app/controllers/adminUser.controller.js | 24 + app/controllers/auth.controller.js | 662 +++++------------- app/controllers/roleAssignment.controller.js | 21 + app/controllers/user.controller.js | 54 +- app/middleware/auth.middleware.js | 97 +-- app/middleware/permission.middleware.js | 111 +-- app/middleware/validate.middleware.js | 7 + app/models/index.js | 3 + app/models/permission/role.model.js | 4 +- app/models/permission/rolePermission.model.js | 22 +- app/models/permission/userPermission.model.js | 9 +- app/models/permission/userRole.model.js | 15 + app/models/user/authSession.model.js | 22 + app/models/user/user.model.js | 9 +- app/models/user/userIdentity.model.js | 13 + app/routes/adminAuth.routes.js | 10 + app/routes/adminUser.routes.js | 9 + app/routes/auth.routes.js | 53 +- app/routes/index.js | 9 +- app/routes/permission.routes.js | 7 + app/routes/profile.routes.js | 16 +- app/routes/riderAuth.routes.js | 10 + app/routes/user.routes.js | 9 +- app/services/auth/auth.service.js | 65 ++ app/services/auth/email.service.js | 6 + app/services/auth/oauth.service.js | 31 + app/services/auth/otp.service.js | 29 + app/services/auth/session.service.js | 59 ++ app/services/permission.service.js | 10 +- app/utils/emailVerification.util.js | 16 +- app/utils/idGen.util.js | 7 +- app/utils/jwt.util.js | 65 +- app/utils/otp.util.js | 39 -- app/utils/passwordReset.utill.js | 6 + app/utils/refreshSession.util.js | 201 ------ app/utils/validation/validatePassword.util.js | 33 +- app/validation/auth.schemas.js | 22 + ...0260903010000-phase-1-identity-security.js | 61 ++ package-lock.json | 170 +++++ package.json | 1 + tests/integration/foundation.test.js | 38 + tests/unit/auth.service.test.js | 31 + tests/unit/jwt.util.test.js | 22 + tests/unit/oauth.service.test.js | 22 + tests/unit/otp.service.test.js | 23 + tests/unit/password-policy.test.js | 10 + tests/unit/session.service.test.js | 37 + 54 files changed, 1389 insertions(+), 1076 deletions(-) create mode 100644 Documentation/API_AUTHENTICATION.md create mode 100644 Documentation/PHASE_1_IDENTITY_AUTHORIZATION.md create mode 100644 app/constants/accountTypes.js create mode 100644 app/controllers/adminUser.controller.js create mode 100644 app/controllers/roleAssignment.controller.js create mode 100644 app/middleware/validate.middleware.js create mode 100644 app/models/permission/userRole.model.js create mode 100644 app/models/user/authSession.model.js create mode 100644 app/models/user/userIdentity.model.js create mode 100644 app/routes/adminAuth.routes.js create mode 100644 app/routes/adminUser.routes.js create mode 100644 app/routes/riderAuth.routes.js create mode 100644 app/services/auth/auth.service.js create mode 100644 app/services/auth/email.service.js create mode 100644 app/services/auth/oauth.service.js create mode 100644 app/services/auth/otp.service.js create mode 100644 app/services/auth/session.service.js delete mode 100644 app/utils/otp.util.js delete mode 100644 app/utils/refreshSession.util.js create mode 100644 app/validation/auth.schemas.js create mode 100644 migrations/20260903010000-phase-1-identity-security.js create mode 100644 tests/unit/auth.service.test.js create mode 100644 tests/unit/jwt.util.test.js create mode 100644 tests/unit/oauth.service.test.js create mode 100644 tests/unit/otp.service.test.js create mode 100644 tests/unit/password-policy.test.js create mode 100644 tests/unit/session.service.test.js diff --git a/.env.sample b/.env.sample index efdc553..685dfc6 100644 --- a/.env.sample +++ b/.env.sample @@ -18,9 +18,14 @@ REDIS_PASSWORD=replace_with_local_redis_password # Use separate randomly generated values of at least 32 characters. JWT_SECRET=replace_with_a_random_value_at_least_32_chars -REFRESH_TOKEN_SECRET=replace_with_a_different_random_32_char_value JWT_EXPIRES_IN=15m -REFRESH_TOKEN_DAYS=7d +JWT_ISSUER=zumri-api +JWT_AUDIENCE=zumri-clients +ACCESS_TOKEN_TTL=15m +REFRESH_TOKEN_TTL_DAYS=7 +REMEMBER_ME_REFRESH_TOKEN_TTL_DAYS=30 +LOGIN_OTP_TTL_SECONDS=900 +LOGIN_OTP_MAX_ATTEMPTS=5 # Runtime controls RUN_CRON=false @@ -58,3 +63,5 @@ DEFAULT_PASSWORD= PASSWORD_RESET_TTL_SECONDS=900 EMAIL_VERIFICATION_TTL_SECONDS=86400 PUPPETEER_EXECUTABLE_PATH= +GOOGLE_CLIENT_ID= +APPLE_CLIENT_ID= diff --git a/Documentation/API_AUTHENTICATION.md b/Documentation/API_AUTHENTICATION.md new file mode 100644 index 0000000..6c4b471 --- /dev/null +++ b/Documentation/API_AUTHENTICATION.md @@ -0,0 +1,95 @@ +# ZUMRI Authentication API + +All endpoints are available under both `/api/auth` and `/api/v1/auth`; new clients should use `/api/v1`. JSON errors use the Phase 0 standard error envelope. Examples contain placeholders only. + +## Account states + +- `PENDING_VERIFICATION`: registration exists but password/OAuth session creation is denied. +- `ACTIVE`: authentication and refresh are permitted. +- `SUSPENDED` and `DEACTIVATED`: login, refresh, and access-token middleware are denied. + +## Register a customer + +`POST /api/v1/auth/register` + +```json +{"firstName":"Asha","lastName":"Perera","email":"asha@example.com","password":"Strong!1234x","phoneNumber":"+94000000000","address":"Customer-provided value"} +``` + +Public registration always creates `customer`; supplied privileged account fields are rejected/stripped by strict validation. Business and privileged registration are not public Phase 1 flows. + +## Verify or resend email verification + +- `POST /api/v1/auth/verify-email` — `{"token":"verification-token-from-email"}` +- `POST /api/v1/auth/resend-verification` — `{"email":"asha@example.com"}` + +Tokens are random, stored only by hash in Redis, expire, and are single-use. Resend invalidates the previous token. Resend returns a generic response. + +## Password plus OTP login + +`POST /api/v1/auth/login` + +```json +{"email":"asha@example.com","password":"Strong!1234x","rememberMe":true,"clientType":"WEB","deviceName":"Personal laptop"} +``` + +Successful credential verification returns HTTP 202 and a `challengeId`; it does not create a session. A hashed six-digit OTP challenge is stored in Redis for the configured TTL and attempt limit. + +`POST /api/v1/auth/verify-otp` + +```json +{"challengeId":"00000000-0000-4000-8000-000000000000","otp":"000000","clientType":"WEB"} +``` + +Successful verification consumes the challenge, creates a durable session, and issues tokens. `POST /api/v1/auth/req-otp` remains an alias for login challenge creation. The historical endpoint that submitted email+OTP to `/login` is intentionally superseded by challenge IDs. + +## Token transport + +`clientType: WEB` sets `access_token` and `refresh_token` as HttpOnly cookies. Production cookies use `Secure` and `SameSite=None` for the intended cross-subdomain frontend/API deployment. The refresh cookie is restricted to `/api`. The response includes the access token for current compatibility but never includes the web refresh token. + +`clientType: MOBILE` returns access and refresh tokens in JSON and does not depend on cookies. Mobile clients must store the refresh token in operating-system secure storage. Access tokens remain short-lived regardless of Remember Me. + +## Refresh and rotation + +`POST /api/v1/auth/refresh` + +Web request body may be `{}`; mobile sends `{"clientType":"MOBILE","refreshToken":"opaque-token"}`. Every successful call revokes/replaces the previous session row and returns a new token pair. Replaying a rotated token revokes its entire token family. Only SHA-256 refresh hashes are persisted. + +## Current identity + +`GET /api/v1/auth/me` requires the access cookie or `Authorization: Bearer `. It returns identity fields, account state, verification status, profile, and effective permissions. It omits password, token version, session hashes, and OAuth internals. + +## Logout + +- `POST /api/v1/auth/logout` revokes the session identified by refresh or access token and is idempotent. +- `POST /api/v1/auth/logout-all` requires authentication, increments `tokenVersion`, revokes every active session, and clears cookies. + +## Password recovery and change + +- `POST /api/v1/auth/forgot-password` — `{"email":"asha@example.com"}`; response is generic. +- `POST /api/v1/auth/reset-password` — token, `newPassword`, `confirmPassword`. +- `POST /api/v1/auth/change-password` — authenticated; `currentPassword`, `newPassword`, `confirmPassword`. + +Reset tokens are random, hash-only Redis records and atomically consumed. Reset/change enforce the same password policy, increment token version, and revoke all sessions. The user must log in again. + +## Google and Apple + +- `POST /api/v1/auth/google` +- `POST /api/v1/auth/apple` + +```json +{"idToken":"provider-signed-id-token","rememberMe":false,"clientType":"WEB"} +``` + +Google verification validates signature/audience/issuer/expiry through Google's verifier and requires verified email. Apple validates the provider JWKS signature, issuer, audience, expiry, and stable subject. Identities persist `(provider, provider_subject)` uniquely; provider tokens are discarded. Verified email auto-linking is permitted only for customer accounts. Privileged and rider accounts are never automatically linked by email. + +## Administrative and rider compatibility + +- `POST /api/v1/admin/auth/login` and `/verify-otp` use the shared challenge/session flow but only accept privileged account types. +- `POST /api/v1/rider/auth/login` and `/verify-otp` use the same system and only accept rider accounts. + +No separate token implementation exists for these actors. + +## Password policy + +Minimum 12 characters with uppercase, lowercase, a symbol, and at least four numeric characters. Registration, reset, and change use the same Zod schema. diff --git a/Documentation/CURRENT_BACKEND_STATUS.md b/Documentation/CURRENT_BACKEND_STATUS.md index c312fd3..cb6d00c 100644 --- a/Documentation/CURRENT_BACKEND_STATUS.md +++ b/Documentation/CURRENT_BACKEND_STATUS.md @@ -373,3 +373,17 @@ New `/health/live` and `/health/ready` routes provide real liveness/readiness be Deployment additions include a hardened Node 22/Chromium/non-root Dockerfile with healthcheck, API/worker/MySQL/Redis Compose configuration, an Nginx reverse-proxy example, and a Gitea Actions CI baseline. Jest/Supertest tests now cover environment validation, liveness/readiness, errors/404, protected routes, Bull Board denial, and request correlation. The first test run exposed incompatible ESM-only `uuid@13`; it was safely pinned to CommonJS-compatible v11. `nodemon` moved to devDependencies. Remaining foundation-adjacent work is intentionally deferred: production database baseline verification, distributed cron locking, full queue policy/idempotency, stronger documentation sessions, permission-router/role integration, and the Phase 1 authentication/ownership/security issues. The original audit above remains the historical baseline; statements such as “missing tests/Helmet/migrations” are superseded by this update and `Documentation/PHASE_0_FOUNDATION_STABILIZATION.md`. + +## Phase 1 Completion Update + +**Date:** 2026-09-03 +**Authentication completion:** approximately **91%**. +**Revised Day 2 completion:** approximately **90%**. + +Phase 1 replaced process-memory OTP and refresh sessions with Redis hash-only login challenges and durable MySQL auth-session families. OTP now uses `crypto.randomInt`, challenge UUIDs, TTL, atomic verification, bounded attempts, and no plaintext logging. Refresh tokens are opaque random values stored only by SHA-256 hash; row-locked transaction rotation detects replay and revokes the family. Access JWTs are short-lived, issuer/audience/algorithm constrained, and bound to `sub`, live session ID, and User token version. Middleware enforces current account state and session revocation. + +New `auth_sessions`, `user_identities`, and `user_roles` models/tables support devices, Remember Me, Google/Apple stable subjects, and configurable roles. The User security migration adds token version/last login, expands canonical account types, and adds RBAC unique indexes. Permission routes are mounted behind SUPER_ADMIN, permission resolution now uses UserRole, and cache invalidation covers assignments/grants. Customer self-service update is allowlisted and ID-based mutation is privileged, closing the audited identity IDOR/mass-assignment path. + +Auth endpoints now cover customer registration, verification/resend, password-to-OTP challenge, OTP completion, refresh rotation, current/all-device logout, forgot/reset/change password, Google, Apple, `/me`, and shared admin/rider compatibility flows. Both `/api` and `/api/v1` remain. Security-focused unit/integration tests were added without real providers/email/database/Redis. + +Remaining identity work is operational: validate/deduplicate deployed RBAC data before migration, run staging MySQL/Redis concurrency tests, seed initial privileged assignments securely, configure provider audiences/mail, and design manual privileged OAuth linking and email change if required. Module 01 is now approximately 91%; migration/staging validation prevents claiming 100% production completion. diff --git a/Documentation/PHASE_0_FOUNDATION_STABILIZATION.md b/Documentation/PHASE_0_FOUNDATION_STABILIZATION.md index 347a1d1..ddd641f 100644 --- a/Documentation/PHASE_0_FOUNDATION_STABILIZATION.md +++ b/Documentation/PHASE_0_FOUNDATION_STABILIZATION.md @@ -41,7 +41,7 @@ Tests can import `app.js` without opening a TCP port. Startup failures prevent t ## Environment Variables -Required for API/worker startup: `NODE_ENV`, `PORT`, `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `JWT_SECRET`, `REFRESH_TOKEN_SECRET`, `REDIS_HOST`, `REDIS_PORT`, and `FRONTEND_URL`. JWT secrets must each be at least 32 characters. `REDIS_PASSWORD` is optional at schema level for deployments without Redis authentication. +Required for API/worker startup: `NODE_ENV`, `PORT`, `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `JWT_SECRET`, `REDIS_HOST`, `REDIS_PORT`, and `FRONTEND_URL`. The JWT secret must contain at least 32 characters. Phase 1 replaced refresh JWTs with opaque random refresh tokens, so no refresh-token signing secret is required. `REDIS_PASSWORD` is optional at schema level for deployments without Redis authentication. Runtime controls: `TRUST_PROXY` (numeric trusted proxy hop count; keep `0` when directly exposed), `JSON_BODY_LIMIT`, `API_RATE_LIMIT_WINDOW_MS`, `API_RATE_LIMIT_MAX`, `SENSITIVE_RATE_LIMIT_WINDOW_MS`, `SENSITIVE_RATE_LIMIT_MAX`, `RUN_CRON`, `CACHE`, and `SHUTDOWN_TIMEOUT_MS`. diff --git a/Documentation/PHASE_1_IDENTITY_AUTHORIZATION.md b/Documentation/PHASE_1_IDENTITY_AUTHORIZATION.md new file mode 100644 index 0000000..47a0e3d --- /dev/null +++ b/Documentation/PHASE_1_IDENTITY_AUTHORIZATION.md @@ -0,0 +1,119 @@ +# ZUMRI Phase 1 Identity and Authorization + +## Objective + +Complete the identity security boundary without beginning commerce modules: verified registration, password+OTP authentication, durable revocable sessions, social identity verification, account-state enforcement, repaired configurable RBAC, and ownership-safe self service. + +## Existing Components Reused + +User/Profile and customer-extension models, Sequelize registration, Redis client, bcrypt helper, email templates/transport, hashed verification/reset concepts, activity queue, permission/role/grant models, permission cache, Phase 0 error/rate-limit/request-ID middleware, dual API mounting, tests, and migrations were extended rather than replaced. + +## Identity Model + +`User` is the account and contains broad `accountType`, state, password hash (nullable for social-only customers), verification/password timestamps, `tokenVersion`, and `lastLoginAt`. `UserIdentity` maps Google/Apple stable subjects to User. `AuthSession` persists refresh credentials/device context and rotation state. `UserRole` assigns configurable Roles independently from account type. + +## Account Types + +Canonical application constants map to persisted lowercase values: `SUPER_ADMIN=superadmin`, `ADMIN=admin`, `MANAGER=manager`, `CUSTOMER=customer`, `BUSINESS_CUSTOMER=business_customer`, `RIDER=rider`, `SUPPORT_AGENT=support_agent`. Existing lowercase values remain valid. + +## Role vs Account Type + +Account type is a broad trusted identity category used for hard security boundaries. Role is configurable authorization grouping. Users may have multiple roles through `UserRole`; effective permissions are the union of role grants and direct additive `UserPermission` grants. This removes dependence on undeclared `User.roleID`. + +## Session Architecture + +```text +Password -> OTP Challenge -> Verify OTP -> AuthSession + -> Access JWT + opaque Refresh Token + -> transactional rotation -> logout/revocation +``` + +Sessions support multiple devices, user-agent/IP/device metadata, Remember Me, token families, last use, expiry, revocation reason, and replacement linkage. Raw refresh tokens exist only at issuance/transport. + +## Access Token + +HS256 JWTs are short-lived and contain `sub`, `sid`, and `tokenVersion`, plus `iss`, `aud`, `iat`, and `exp`. Middleware enforces algorithm, signature, issuer, audience, expiry, live User state/token version, and live non-revoked session state. It loads permissions server-side rather than embedding them. + +## Refresh Token Rotation + +Refresh tokens are opaque `session-id.random-secret` values. The database stores only SHA-256 hashes. Rotation locks the current session row and User in a transaction, creates a same-family replacement, and revokes/links the old row. + +## Reuse Detection + +Presentation of a revoked/replaced or hash-mismatched known session token revokes all active members of that token family and returns the same invalid-session boundary. Row locks ensure two concurrent refresh calls cannot both succeed. + +## Remember Me + +Remember Me changes only refresh-session lifetime: `REFRESH_TOKEN_TTL_DAYS` versus `REMEMBER_ME_REFRESH_TOKEN_TTL_DAYS`. Access lifetime remains `ACCESS_TOKEN_TTL`. + +## Account Status Enforcement + +Only `ACTIVE` can finish login, refresh, or use protected endpoints. Pending, suspended, and deactivated accounts are rejected using current database state, not stale claims. Password/security administration increments token version and revokes sessions. + +## Password Policy + +One Zod policy requires at least 12 characters, uppercase, lowercase, a symbol, and four digits. It is used by registration, reset, and change-password flows. Reset/change require confirmation and reject reuse of the current password. + +## Email Verification + +Verification tokens are cryptographically random and hash-only in Redis. They expire, are consumed atomically, and activate the account. Per-user pointers let resend invalidate an earlier token. Resend responses are generic and rate limited. + +## Password Recovery + +Forgot-password normalizes/validates email and returns a generic response. Reset tokens are random, stored hashed with TTL, atomically consumed using Redis `GETDEL`, and never logged. Successful reset updates the hash/timestamp/tokenVersion and revokes every session. + +## Google Authentication + +The backend verifies Google ID tokens against configured audience and uses Google's `sub`; verified email is required. Provider access tokens are not stored. Automated tests mock Google's verifier. + +## Apple Authentication + +The backend obtains/caches Apple's JWKS, selects the signed key, and verifies RS256 signature, Apple issuer, configured audience, expiry, and `sub`. First-login email is used only when verified. Tests use a locally signed RSA token and mocked JWKS response. + +## OAuth Account Linking Rules + +Existing provider subject wins. Otherwise a strongly verified provider email may link/create a customer. Email-only automatic linking is denied for super admins, admins, managers, support agents, and riders. Provider subject is unique and provider tokens/secrets are not persisted. Manual privileged linking remains deferred. + +## Role and Permission Architecture + +`/api/v1/permissions` is now mounted and default-denied to SUPER_ADMIN at router level. Existing CRUD is retained behind that boundary. Role names and role/user grant pairs have unique constraints. UserRole pairs are unique. Model hooks and assignment controllers invalidate affected permission caches. + +## Ownership Authorization + +`GET/PATCH /user/me` provides self service. Self update allowlists only first and last name; account type/status/roles/security fields are rejected. ID-based legacy mutation is restricted to SUPER_ADMIN, and no arbitrary ID read route is exposed. Profile image access uses reusable self-or-admin ownership middleware. + +## New Database Tables + +- `auth_sessions` +- `user_identities` +- `user_roles` + +User adds `tokenVersion` and `lastLoginAt`; password becomes nullable for verified social-only users; the account-type ENUM expands to all canonical types. + +## New Migrations + +`20260903010000-phase-1-identity-security.js` is forward-only relative to the Phase 0 baseline and was not executed. Before applying to an existing database, diagnose duplicate `roles.roleName`, `(role_id,permission_id)`, and `(user_id,permission_id)` rows because unique indexes intentionally fail on dirty data. Back up and test a restored database first. + +## API Endpoints + +Auth: register, login challenge, verify OTP, refresh, logout, logout-all, forgot/reset/change password, verify/resend email, Google, Apple, and me. Thin shared-flow admin and rider login routes exist. Admin User status/account-type endpoints and protected permission/UserRole endpoints are mounted. See `Documentation/API_AUTHENTICATION.md`. + +## Security Controls + +Hash-only OTP/reset/verification/refresh persistence; cryptographic random generation; bounded OTP attempts; single-use challenges; short access TTL; durable revocation; replay-family revocation; DB row locks; account-state/token-version checks; strict Zod bodies; generic enumeration responses; provider signature/audience checks; customer-only public registration; field allowlists; ownership checks; and no token/OTP credential logging. + +## Tests + +Unit coverage includes password policy, JWT claims/wrong issuer-audience/expiry/malformed input, OTP format/hash-only persistence/failure states, refresh hash-only persistence/rotation/replay, account-status/password-login behavior, and Google/Apple verification. Integration coverage preserves Phase 0 health/error behavior and checks RBAC denial, self mass-assignment, other-user mutation, suspended access, and Bull Board admin access. External DB/Redis/email/OAuth services are mocked. + +## Legacy Compatibility + +Both `/api` and `/api/v1` remain. `/auth/req-otp` aliases new login challenge creation; `/profile` password endpoints delegate to the same hardened controllers. The old email+OTP `/auth/login` second step is intentionally replaced by `/verify-otp` with challenge IDs because the old email-keyed flow could not meet security requirements. + +## Remaining Known Issues + +Manual privileged OAuth linking and secure email-change confirmation are deferred. Full email queue/delivery tracking remains Phase 2 infrastructure work. Existing business registration/account approval is not part of this phase. Role/grant uniqueness migration requires deployed-data diagnostics. The baseline test suite mocks MySQL/Redis; staging integration tests must run after migration review. Existing non-auth legacy routes may still contain account-name/ownership debt outside Phase 1 scope. + +## Phase 2 Prerequisites + +Review and run both migrations on a restored environment, configure mail plus Google/Apple client audiences where those flows are enabled, run staging MySQL/Redis integration tests, and seed the initial SUPER_ADMIN/roles/permissions through a controlled operational process. Once complete, identity is ready for the next non-commerce phase requested by the development roadmap. diff --git a/app/config/env.config.js b/app/config/env.config.js index a57df83..8e708d4 100644 --- a/app/config/env.config.js +++ b/app/config/env.config.js @@ -11,7 +11,13 @@ const envSchema = z.object({ DB_USER: z.string().min(1), DB_PASSWORD: z.string(), JWT_SECRET: z.string().min(32, "JWT_SECRET must contain at least 32 characters"), - REFRESH_TOKEN_SECRET: z.string().min(32, "REFRESH_TOKEN_SECRET must contain at least 32 characters"), + JWT_ISSUER: z.string().min(1).default("zumri-api"), + JWT_AUDIENCE: z.string().min(1).default("zumri-clients"), + ACCESS_TOKEN_TTL: z.string().default("15m"), + REFRESH_TOKEN_TTL_DAYS: z.coerce.number().int().positive().default(7), + REMEMBER_ME_REFRESH_TOKEN_TTL_DAYS: z.coerce.number().int().positive().default(30), + LOGIN_OTP_TTL_SECONDS: z.coerce.number().int().positive().default(900), + LOGIN_OTP_MAX_ATTEMPTS: z.coerce.number().int().min(1).max(10).default(5), REDIS_HOST: z.string().min(1), REDIS_PORT: z.coerce.number().int().min(1).max(65535).default(6379), REDIS_PASSWORD: z.string().optional(), @@ -33,6 +39,7 @@ const envSchema = z.object({ AWS_REGION: z.string().optional(), AWS_ACCESS_KEY_ID: z.string().optional(), AWS_SECRET_ACCESS_KEY: z.string().optional(), AWS_S3_BUCKET_NAME: z.string().optional(), DOCS_USER: z.string().optional(), DOCS_PASS: z.string().optional(), + GOOGLE_CLIENT_ID: z.string().optional(), APPLE_CLIENT_ID: z.string().optional(), }).superRefine((env, context) => { const requireFeature = (enabled, names) => { if (!enabled) return; diff --git a/app/constants/accountTypes.js b/app/constants/accountTypes.js new file mode 100644 index 0000000..49938b9 --- /dev/null +++ b/app/constants/accountTypes.js @@ -0,0 +1,15 @@ +const ACCOUNT_TYPES = Object.freeze({ + SUPER_ADMIN: "superadmin", + ADMIN: "admin", + MANAGER: "manager", + CUSTOMER: "customer", + BUSINESS_CUSTOMER: "business_customer", + RIDER: "rider", + SUPPORT_AGENT: "support_agent", +}); + +const PRIVILEGED_ACCOUNT_TYPES = Object.freeze([ + ACCOUNT_TYPES.SUPER_ADMIN, ACCOUNT_TYPES.ADMIN, ACCOUNT_TYPES.MANAGER, ACCOUNT_TYPES.SUPPORT_AGENT, +]); + +module.exports = { ACCOUNT_TYPES, PRIVILEGED_ACCOUNT_TYPES, ACCOUNT_TYPE_VALUES: Object.values(ACCOUNT_TYPES) }; diff --git a/app/controllers/adminUser.controller.js b/app/controllers/adminUser.controller.js new file mode 100644 index 0000000..7ca005c --- /dev/null +++ b/app/controllers/adminUser.controller.js @@ -0,0 +1,24 @@ +const db = require("../models"); +const { ACCOUNT_TYPE_VALUES } = require("../constants/accountTypes"); +const { revokeAllUserSessions } = require("../services/auth/session.service"); +const { logActivity } = require("../services/activity.service"); + +const updateSecurityField = (field) => async (req, res, next) => { + try { + const value = req.body[field]; + if (field === "accountType" && !ACCOUNT_TYPE_VALUES.includes(value)) return res.status(400).json({ success: false, error: { code: "INVALID_ACCOUNT_TYPE", message: "Invalid account type" } }); + if (field === "accountStatus" && !["PENDING_VERIFICATION", "ACTIVE", "SUSPENDED", "DEACTIVATED"].includes(value)) return res.status(400).json({ success: false, error: { code: "INVALID_ACCOUNT_STATUS", message: "Invalid account status" } }); + if (req.params.id === req.user.id) return res.status(400).json({ success: false, error: { code: "SELF_SECURITY_CHANGE_DENIED", message: "Security-sensitive self changes are not allowed" } }); + let target; let previous; + await db.sequelize.transaction(async (transaction) => { + target = await db.User.findByPk(req.params.id, { transaction, lock: transaction.LOCK.UPDATE }); + if (!target) throw Object.assign(new Error("User not found"), { status: 404, code: "USER_NOT_FOUND" }); + previous = target[field]; target[field] = value; target.tokenVersion += 1; await target.save({ transaction }); + await revokeAllUserSessions(target.id, `ADMIN_${field.toUpperCase()}_CHANGE`, transaction); + }); + await logActivity({ user: req.user, description: `${field} changed for ${target.id} from ${previous} to ${value}; reason: ${req.body.reason || "not supplied"}; request: ${req.id}`, type: field === "accountStatus" ? "ACCOUNT_STATUS_CHANGED" : "ACCOUNT_TYPE_CHANGED", module: "Identity Administration" }); + res.json({ success: true, data: { id: target.id, [field]: target[field] } }); + } catch (error) { next(error); } +}; +exports.updateStatus = updateSecurityField("accountStatus"); +exports.updateAccountType = updateSecurityField("accountType"); diff --git a/app/controllers/auth.controller.js b/app/controllers/auth.controller.js index 4780f89..23cdb14 100644 --- a/app/controllers/auth.controller.js +++ b/app/controllers/auth.controller.js @@ -1,536 +1,192 @@ -/** - * Copyright (c) 2026 Niolla - * All rights reserved. - * - * This source code is proprietary and confidential. - * Unauthorized copying, modification, distribution, or use - * of this file, via any medium, is strictly prohibited. - */ - -// app/controllers/auth.controller.js - -const { checkPassword, hashPassword } = require("../utils/hashPassword.util"); -const { sendMail } = require("../utils/mail.util"); -const { - validatePassword, -} = require("../utils/validation/validatePassword.util"); -const { validateEmail } = require("../utils/validation/validateEmail.util"); -const { generateOTP, validateOTP } = require("../utils/otp.util"); -const { getCachedUser, clearUserCache } = require("../utils/cache.util"); -const { generateToken } = require("../utils/jwt.util"); -const { - createRefreshSession, - validateRefreshSession, - deleteRefreshSession, - deleteAllUserSessions, -} = require("../utils/refreshSession.util"); -const { - createPasswordReset, - verifyPasswordResetToken, - deletePasswordReset, - sendPasswordResetEmail, - sendPasswordChangedEmail, -} = require("../utils/passwordReset.utill"); const db = require("../models"); -const { log } = require("../utils/consoleLog.utill"); +const authService = require("../services/auth/auth.service"); +const sessionService = require("../services/auth/session.service"); +const { hashPassword, checkPassword } = require("../utils/hashPassword.util"); +const { createPasswordReset, consumePasswordResetToken, sendPasswordResetEmail } = require("../utils/passwordReset.utill"); +const { createEmailVerification, consumeEmailVerificationToken, sendVerificationEmail } = require("../utils/emailVerification.util"); +const { sendPasswordChanged } = require("../services/auth/email.service"); +const { logActivity } = require("../services/activity.service"); +const { verifyToken } = require("../utils/jwt.util"); -const appName = process.env.APP_NAME || "Niolla"; +const cookieOptions = (maxAge, path = "/") => ({ httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: process.env.NODE_ENV === "production" ? "none" : "lax", maxAge, path }); +const clearCookies = (res) => { + res.clearCookie("access_token", cookieOptions(undefined, "/")); + res.clearCookie("refresh_token", cookieOptions(undefined, "/api")); +}; +const projectUser = (user) => ({ id: user.id, firstName: user.firstName, lastName: user.lastName, email: user.email, accountType: user.accountType, accountStatus: user.accountStatus, emailVerified: Boolean(user.emailVerifiedAt) }); +const deliverTokens = (req, res, result, clientType = "WEB") => { + const accessMs = 15 * 60 * 1000; + const refreshMs = Math.max(0, new Date(result.refreshExpiresAt).getTime() - Date.now()); + if (clientType === "WEB") { + res.cookie("access_token", result.accessToken, cookieOptions(accessMs)); + res.cookie("refresh_token", result.refreshToken, cookieOptions(refreshMs, "/api")); + return { accessToken: result.accessToken }; + } + return { accessToken: result.accessToken, refreshToken: result.refreshToken, refreshExpiresAt: result.refreshExpiresAt }; +}; -const User = db.User; - -// Login Step 1: Request OTP -exports.loginReq = async (req, res) => { +exports.login = async (req, res, next) => { try { - const { email, password } = req.body; + const result = await authService.beginPasswordLogin(req.validated.body, req); + res.status(202).json({ success: true, data: result, message: "If the credentials are valid, a verification code has been sent" }); + } catch (error) { next(error); } +}; +exports.loginReq = exports.login; - const user = await getCachedUser(email); - if (!user) { - return res - .status(404) - .send({ success: false, message: "User Not Found" }); - } +exports.verifyOtp = async (req, res, next) => { + try { + const input = req.validated.body; + const result = await authService.completeOtpLogin(input, req); + const tokens = deliverTokens(req, res, result, input.clientType); + await logActivity({ user: result.user, description: "Authentication session created", type: "LOGIN_SUCCEEDED", module: "Authentication" }); + res.json({ success: true, data: { user: projectUser(result.user), ...tokens } }); + } catch (error) { next(error); } +}; - const passwordIsValid = await checkPassword(password, user.password); - if (!passwordIsValid) { - return res - .status(401) - .send({ success: false, message: "Invalid Password" }); - } - - const otp = generateOTP(email); - - await sendMail({ - to: email, - subject: `OTP for Your ${appName} Account`, - templateName: "otp", - templateVars: { - firstName: user.firstName, - otp: otp, - }, - text: `Hello ${user.firstName}, your otp is ${otp}`, - }); - - log(`OTP for ${email}: ${otp}`); - log(`OTP sent to ${email} successfully.`); - - res.status(201).send({ success: true, message: "OTP Sent Successfully" }); +exports.refreshToken = async (req, res, next) => { + try { + const input = req.validated.body; + const token = input.refreshToken || req.cookies?.refresh_token; + if (!token) throw Object.assign(new Error("Invalid session"), { status: 401, code: "INVALID_SESSION" }); + const result = await authService.refresh(token, req); + res.json({ success: true, data: deliverTokens(req, res, result, input.clientType) }); } catch (error) { - log("Error occurred while sending OTP:", error); - res.status(500).send({ success: false, message: error.message }); + clearCookies(res); + if (error.code === "REFRESH_TOKEN_REUSE") console.warn(`[${req.id}] Refresh token replay detected; token family revoked`); + next(Object.assign(new Error("Invalid session"), { status: 401, code: "INVALID_SESSION" })); } }; -// Login Step 2: Verify OTP and issue JWT -exports.login = async (req, res) => { +exports.logout = async (req, res, next) => { try { - const { email, otp } = req.body; - - if (!email || !otp) { - return res - .status(400) - .send({ success: false, message: "Email and OTP are required" }); + const token = req.body?.refreshToken || req.cookies?.refresh_token; + let id = sessionService.tokenId(token); + if (!id) { + const accessToken = req.cookies?.access_token || (req.headers.authorization?.startsWith("Bearer ") ? req.headers.authorization.slice(7) : null); + try { id = accessToken ? verifyToken(accessToken).sid : null; } catch (_error) { id = null; } } + if (id) await sessionService.revokeSession(id, "LOGOUT"); + clearCookies(res); + res.json({ success: true, message: "Logged out successfully" }); + } catch (error) { next(error); } +}; - const user = await getCachedUser(email); +exports.logoutAll = async (req, res, next) => { + try { + await db.sequelize.transaction(async (transaction) => { + const user = await db.User.findByPk(req.user.id, { transaction, lock: transaction.LOCK.UPDATE }); + user.tokenVersion += 1; await user.save({ transaction }); + await sessionService.revokeAllUserSessions(user.id, "LOGOUT_ALL", transaction); + }); + clearCookies(res); + await logActivity({ user: req.user, description: "All authentication sessions revoked", type: "LOGOUT_ALL", module: "Authentication" }); + res.json({ success: true, message: "Logged out from all devices" }); + } catch (error) { next(error); } +}; - if (!user) { - return res - .status(404) - .send({ success: false, message: "User Not Found" }); +exports.me = async (req, res, next) => { + try { + const user = await db.User.findByPk(req.user.id, { attributes: { exclude: ["password", "tokenVersion", "passwordChangedAt"] }, include: [{ model: db.Profile, as: "profile" }] }); + res.json({ success: true, data: { ...projectUser(user), profile: user.profile, effectivePermissions: req.user.permissions || [] } }); + } catch (error) { next(error); } +}; + +exports.forgotPassword = async (req, res, next) => { + const message = "If an account exists for this email, a password reset link has been sent"; + try { + const user = await db.User.findOne({ where: { email: req.validated.body.email } }); + if (user) { + const token = await createPasswordReset(user.id); + await sendPasswordResetEmail(user.email, user.firstName, token); } - - if (user.accountStatus !== "ACTIVE") { - return res.status(403).send({ - success: false, - message: "Account is not active", - }); - } - - const isValidOTP = validateOTP(email, String(otp)); - - if (!isValidOTP) { - return res - .status(401) - .send({ success: false, message: "Invalid or Expired OTP" }); - } - - // Generate JWT token - const token = generateToken({ - id: user.id, - firstName: user.firstName, - lastName: user.lastName, - email: user.email, - accountType: user.accountType, - }); - - const { refreshToken } = createRefreshSession(user.id); - - // 3. Set JWT as HttpOnly cookie - res.cookie("access_token", token, { - httpOnly: true, // JS cannot access - secure: process.env.NODE_ENV === "production", // HTTPS only in prod - sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", - maxAge: 15 * 60 * 1000, // 1 day - }); - - res.cookie("refresh_token", refreshToken, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", - maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days - }); - - log(`JWT issued for ${email}`); - clearUserCache(email); - - res.status(200).send({ - success: true, - message: "Login Successful", - data: { - id: user.id, - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - role: user.role, - accountType: user.accountType, - accessToken: token, - }, - }); + res.json({ success: true, message }); } catch (error) { - log("Error occurred during login:", error); - res.status(500).send({ success: false, message: error.message }); + console.error(`[${req.id}] Password reset request failed`, { name: error.name, message: error.message }); + res.json({ success: true, message }); } }; -exports.refreshToken = async (req, res) => { +exports.resetPassword = async (req, res, next) => { try { - const refreshToken = req.cookies?.refresh_token; + const input = req.validated.body; + const userId = await consumePasswordResetToken(input.token); + if (!userId) throw Object.assign(new Error("Reset token is invalid or expired"), { status: 400, code: "INVALID_RESET_TOKEN" }); + let changedUser; + await db.sequelize.transaction(async (transaction) => { + const user = await db.User.findByPk(userId, { transaction, lock: transaction.LOCK.UPDATE }); + if (!user || (user.password && await checkPassword(input.newPassword, user.password))) throw Object.assign(new Error("Invalid password change"), { status: 400, code: "INVALID_PASSWORD_CHANGE" }); + user.password = await hashPassword(input.newPassword); user.passwordChangedAt = new Date(); user.tokenVersion += 1; + await user.save({ transaction }); await sessionService.revokeAllUserSessions(user.id, "PASSWORD_RESET", transaction); changedUser = user; + }); + sendPasswordChanged(changedUser).catch(() => {}); + await logActivity({ user: changedUser, description: "Password reset and sessions revoked", type: "PASSWORD_RESET", module: "Authentication" }); + res.json({ success: true, message: "Password reset successfully. Please login again" }); + } catch (error) { next(error); } +}; - if (!refreshToken) { - return res.status(401).send({ - success: false, - message: "Refresh token is required", - }); +exports.changePassword = async (req, res, next) => { + try { + const input = req.validated.body; + let changedUser; + await db.sequelize.transaction(async (transaction) => { + const user = await db.User.findByPk(req.user.id, { transaction, lock: transaction.LOCK.UPDATE }); + if (!user?.password || !await checkPassword(input.currentPassword, user.password)) throw Object.assign(new Error("Current password is incorrect"), { status: 401, code: "INVALID_CREDENTIALS" }); + if (await checkPassword(input.newPassword, user.password)) throw Object.assign(new Error("New password must be different"), { status: 400, code: "INVALID_PASSWORD_CHANGE" }); + user.password = await hashPassword(input.newPassword); user.passwordChangedAt = new Date(); user.tokenVersion += 1; + await user.save({ transaction }); await sessionService.revokeAllUserSessions(user.id, "PASSWORD_CHANGED", transaction); changedUser = user; + }); + clearCookies(res); sendPasswordChanged(changedUser).catch(() => {}); + await logActivity({ user: changedUser, description: "Password changed and sessions revoked", type: "PASSWORD_CHANGED", module: "Authentication" }); + res.json({ success: true, message: "Password changed successfully. Please login again" }); + } catch (error) { next(error); } +}; + +exports.verifyEmail = async (req, res, next) => { + try { + const userId = await consumeEmailVerificationToken(req.validated.body.token); + if (!userId) throw Object.assign(new Error("Verification token is invalid or expired"), { status: 400, code: "INVALID_VERIFICATION_TOKEN" }); + const user = await db.User.findByPk(userId); + if (!user) throw Object.assign(new Error("Verification token is invalid or expired"), { status: 400, code: "INVALID_VERIFICATION_TOKEN" }); + if (!user.emailVerifiedAt) { user.emailVerifiedAt = new Date(); user.accountStatus = "ACTIVE"; await user.save(); } + await logActivity({ user, description: "Email address verified", type: "EMAIL_VERIFIED", module: "Authentication" }); + res.json({ success: true, message: "Email verified" }); + } catch (error) { next(error); } +}; + +exports.resendVerification = async (req, res, next) => { + const message = "If verification is required, a new email has been sent"; + try { + const user = await db.User.findOne({ where: { email: req.validated.body.email } }); + if (user && !user.emailVerifiedAt && user.accountStatus === "PENDING_VERIFICATION") { + const token = await createEmailVerification(user.id); await sendVerificationEmail(user.email, user.firstName, token); } - - const session = validateRefreshSession(refreshToken); - - if (!session) { - res.clearCookie("refresh_token"); - - return res.status(401).send({ - success: false, - message: "Invalid or expired session. Please login again.", - }); - } - - const user = await User.findByPk(session.userId); - - if (!user || user.accountStatus !== "ACTIVE") { - deleteRefreshSession(session.sessionId); - - return res.status(401).send({ - success: false, - message: "Session is no longer valid", - }); - } - - // Generate new Access Token - const token = generateToken({ - id: user.id, - firstName: user.firstName, - lastName: user.lastName, - email: user.email, - accountType: user.accountType, - }); - - // Generate new Refresh Token - const { refreshToken: newRefreshToken } = createRefreshSession(user.id); - - deleteRefreshSession(session.sessionId); - - // Replace access cookie - res.cookie("access_token", token, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", - maxAge: 15 * 60 * 1000, - }); - - // Replace refresh cookie - res.cookie("refresh_token", newRefreshToken, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", - maxAge: 7 * 24 * 60 * 60 * 1000, - }); - - return res.status(200).send({ - success: true, - message: "Session refreshed successfully", - }); + res.json({ success: true, message }); } catch (error) { - console.error("REFRESH ERROR:", error); - - return res.status(401).send({ - success: false, - message: "Invalid or expired session. Please login again.", - }); + console.error(`[${req.id}] Verification resend failed`, { name: error.name, message: error.message }); + res.json({ success: true, message }); } }; -exports.forgotPassword = async (req, res) => { +exports.oauth = (provider) => async (req, res, next) => { try { - let { email } = req.body; - - if (!email) { - return res.status(400).send({ - success: false, - message: "Email is required", - }); - } - - email = email.trim().toLowerCase(); - - if (!validateEmail(email)) { - return res.status(400).send({ - success: false, - message: "Invalid email address", - }); - } - - const user = await User.findOne({ - where: { email }, - }); - - if (!user) { - return res.status(200).send({ - success: true, - message: - "If an account exists for this email, a password reset link has been sent.", - }); - } - - const resetToken = await createPasswordReset(user.id); - - await sendPasswordResetEmail(user.email, user.firstName, resetToken); - - return res.status(200).send({ - success: true, - message: - "If an account exists for this email, a password reset link has been sent.", - }); - } catch (error) { - console.error("FORGOT PASSWORD ERROR:", error); - - return res.status(500).send({ - success: false, - message: "Unable to process password reset request", - }); - } + const input = req.validated.body; const result = await authService.authenticateOAuth(provider, input, req); + const tokens = deliverTokens(req, res, result, input.clientType); + await logActivity({ user: result.user, description: `${provider} identity authenticated`, type: `${provider.toUpperCase()}_ACCOUNT_LINKED`, module: "Authentication" }); + res.json({ success: true, data: { user: projectUser(result.user), ...tokens } }); + } catch (error) { next(Object.assign(error, { status: error.status || 401, code: error.code || "OAUTH_FAILED" })); } }; -exports.resetPassword = async (req, res) => { +exports.adminLogin = async (req, res, next) => { try { - const { token, newPassword, confirmPassword } = req.body; - - if (!token || !newPassword || !confirmPassword) { - return res.status(400).send({ - success: false, - message: "Token, new password and confirm password are required", - }); - } - - if (newPassword !== confirmPassword) { - return res.status(400).send({ - success: false, - message: "Passwords do not match", - }); - } - - if (!validatePassword(newPassword)) { - return res.status(400).send({ - success: false, - message: "Password does not meet the required criteria", - }); - } - - const verification = await verifyPasswordResetToken(token); - - if (!verification) { - return res.status(400).send({ - success: false, - message: "Reset token is invalid or expired", - }); - } - - const { userId, redisKey } = verification; - - const user = await User.findByPk(userId); - - if (!user) { - await deletePasswordReset(redisKey); - - return res.status(400).send({ - success: false, - message: "Reset token is invalid or expired", - }); - } - - const samePassword = await checkPassword(newPassword, user.password); - - if (samePassword) { - return res.status(400).send({ - success: false, - message: "New password must be different from the current password", - }); - } - - const hashedPassword = await hashPassword(newPassword); - - user.password = hashedPassword; - - user.passwordChangedAt = new Date(); - - await user.save(); - - await sendPasswordChangedEmail(user.email, user.firstName); - - await deletePasswordReset(redisKey); - - deleteAllUserSessions(user.id); - - return res.status(200).send({ - success: true, - message: "Password reset successfully. Please login again.", - }); - } catch (error) { - console.error("RESET PASSWORD ERROR:", error); - - return res.status(500).send({ - success: false, - message: "Failed to reset password", - }); - } + const { PRIVILEGED_ACCOUNT_TYPES } = require("../constants/accountTypes"); + const result = await authService.beginPasswordLogin(req.validated.body, req, PRIVILEGED_ACCOUNT_TYPES); + res.status(202).json({ success: true, data: result, message: "If the credentials are valid, a verification code has been sent" }); + } catch (error) { next(error); } }; - -exports.changePassword = async (req, res) => { +exports.riderLogin = async (req, res, next) => { try { - // User ID comes from authenticate middleware - const userId = req.user.id; - - const { currentPassword, newPassword, confirmPassword } = req.body; - - // 1. Check required fields - if (!currentPassword || !newPassword || !confirmPassword) { - return res.status(400).send({ - success: false, - message: - "Current password, new password and confirm password are required", - }); - } - - // 2. Check new password and confirmation - if (newPassword !== confirmPassword) { - return res.status(400).send({ - success: false, - message: "New password and confirm password do not match", - }); - } - - // 3. Validate password policy - const passwordValid = validatePassword(newPassword); - - if (!passwordValid) { - return res.status(400).send({ - success: false, - message: "New password does not meet the required criteria", - }); - } - - // 4. Get logged-in user from database - const user = await User.findByPk(userId); - - if (!user) { - return res.status(404).send({ - success: false, - message: "User not found", - }); - } - - // 5. Check current password - const currentPasswordValid = await checkPassword( - currentPassword, - user.password, - ); - - if (!currentPasswordValid) { - return res.status(401).send({ - success: false, - message: "Current password is incorrect", - }); - } - - // 6. Make sure new password is different - const sameAsOldPassword = await checkPassword(newPassword, user.password); - - if (sameAsOldPassword) { - return res.status(400).send({ - success: false, - message: "New password must be different from current password", - }); - } - - // 7. Hash new password - const hashedPassword = await hashPassword(newPassword); - - // 8. Update user - user.password = hashedPassword; - user.passwordChangedAt = new Date(); - - await user.save(); - - // 9. Revoke all refresh sessions - deleteAllUserSessions(user.id); - - // 10. Clear auth cookies - res.clearCookie("access_token", { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", - }); - - res.clearCookie("refresh_token", { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", - }); - - // 11. Send confirmation email - try { - await sendPasswordChangedEmail(user.email, user.firstName); - } catch (mailError) { - console.error("PASSWORD CHANGED EMAIL ERROR:", mailError); - } - - // 12. Response - return res.status(200).send({ - success: true, - message: "Password changed successfully. Please login again.", - }); - } catch (error) { - console.error("CHANGE PASSWORD ERROR:", error); - - return res.status(500).send({ - success: false, - message: "Failed to change password", - }); - } -}; - -// Logout: Clear the JWT cookie -exports.logout = async (req, res) => { - try { - // 1. Get refresh token from cookie - const refreshToken = req.cookies?.refresh_token; - - // 2. If refresh token exists, find its session - if (refreshToken) { - const session = validateRefreshSession(refreshToken); - - // 3. Delete refresh session from server RAM - if (session) { - deleteRefreshSession(session.sessionId); - - console.log(`Refresh session deleted: ${session.sessionId}`); - } - } - - // 4. Clear access token cookie - res.clearCookie("access_token", { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", - }); - - // 5. Clear refresh token cookie - res.clearCookie("refresh_token", { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", - }); - - // 6. Send response - return res.status(200).json({ - success: true, - message: "Logged out successfully", - }); - } catch (error) { - console.error("LOGOUT ERROR:", error); - - return res.status(500).json({ - success: false, - message: "Failed to logout", - }); - } + const { ACCOUNT_TYPES } = require("../constants/accountTypes"); + const result = await authService.beginPasswordLogin(req.validated.body, req, [ACCOUNT_TYPES.RIDER]); + res.status(202).json({ success: true, data: result, message: "If the credentials are valid, a verification code has been sent" }); + } catch (error) { next(error); } }; diff --git a/app/controllers/roleAssignment.controller.js b/app/controllers/roleAssignment.controller.js new file mode 100644 index 0000000..3d9b882 --- /dev/null +++ b/app/controllers/roleAssignment.controller.js @@ -0,0 +1,21 @@ +const db = require("../models"); +const { clearPermissionCache } = require("../utils/cache.util"); +const { logActivity } = require("../services/activity.service"); + +exports.assignRole = async (req, res, next) => { + try { + const [assignment] = await db.UserRole.findOrCreate({ where: { user_id: req.params.userId, role_id: req.params.roleId } }); + await clearPermissionCache(req.params.userId); + await logActivity({ user: req.user, description: `Role ${req.params.roleId} assigned to user ${req.params.userId}`, type: "ROLE_ASSIGNED", module: "Authorization" }); + res.status(201).json({ success: true, data: { id: assignment.id, userId: assignment.user_id, roleId: assignment.role_id } }); + } catch (error) { next(error); } +}; + +exports.removeRole = async (req, res, next) => { + try { + await db.UserRole.destroy({ where: { user_id: req.params.userId, role_id: req.params.roleId } }); + await clearPermissionCache(req.params.userId); + await logActivity({ user: req.user, description: `Role ${req.params.roleId} removed from user ${req.params.userId}`, type: "ROLE_REMOVED", module: "Authorization" }); + res.json({ success: true, message: "Role removed" }); + } catch (error) { next(error); } +}; diff --git a/app/controllers/user.controller.js b/app/controllers/user.controller.js index 6a2243b..bb79181 100644 --- a/app/controllers/user.controller.js +++ b/app/controllers/user.controller.js @@ -44,7 +44,6 @@ exports.createNewUser = async (req, res) => { lastName, email, password, - accountType, address, phoneNumber, businessName, @@ -56,23 +55,23 @@ exports.createNewUser = async (req, res) => { note, } = req.body; - if (!firstName || !lastName || !email || !password || !accountType) { + if (!firstName || !lastName || !email || !password) { await transaction.rollback(); return res.status(400).send({ success: false, message: - "First name, last name, email, password and account type are required", + "First name, last name, email and password are required", }); } - if (accountType !== "customer" && accountType !== "business_customer") { + if (req.body.accountType && req.body.accountType !== "customer") { await transaction.rollback(); return res.status(400).send({ success: false, message: - "Invalid account type. Must be either 'customer' or 'business_customer'", + "Public registration creates customer accounts only", }); } @@ -118,7 +117,7 @@ exports.createNewUser = async (req, res) => { lastName, email, password: hashedPassword, - accountType, + accountType: "customer", accountStatus: "PENDING_VERIFICATION", emailVerifiedAt: null, }, @@ -139,8 +138,8 @@ exports.createNewUser = async (req, res) => { { transaction }, ); - //create customer and business customer - if (accountType === "customer") { + // Create the customer identity extension for public registration. + { const customerData = { address,phoneNumber, }; @@ -150,23 +149,6 @@ exports.createNewUser = async (req, res) => { customerData, transaction, ); - } else if (accountType === "business_customer") { - const businessData = { - businessName, - businessRegistrationNumber, - businessType, - contactName, - phoneNumber, - businessEmail, - expectedMonthlyVolume, - note, - }; - - await createBusinessCustomerDetails( - newUser.id, - businessData, - transaction, - ); } await transaction.commit(); @@ -449,6 +431,7 @@ exports.updateUser = async (req, res) => { }); if (!user) { + await transaction.rollback(); return res.status(404).send({ success: false, message: "User not found", @@ -456,6 +439,7 @@ exports.updateUser = async (req, res) => { } if (!UserProfile) { + await transaction.rollback(); return res.status(404).send({ success: false, message: "User profile not found", @@ -533,6 +517,7 @@ exports.deleteUser = async (req, res) => { where: { id }, }); if (!user) { + await transaction.rollback(); return res.status(404).send({ success: false, message: "User not found", @@ -560,3 +545,22 @@ exports.deleteUser = async (req, res) => { }); } }; + +exports.getCurrentUser = async (req, res, next) => { + try { + const user = await User.findByPk(req.user.id, { attributes: { exclude: ["password", "tokenVersion", "passwordChangedAt"] }, include: [{ model: Profile, as: "profile" }] }); + res.json({ success: true, data: user }); + } catch (error) { next(error); } +}; + +exports.updateCurrentUser = async (req, res, next) => { + try { + const allowed = ["firstName", "lastName"]; + const supplied = Object.keys(req.body); + if (supplied.some((key) => !allowed.includes(key))) return res.status(400).json({ success: false, error: { code: "UNSAFE_FIELD", message: "Only firstName and lastName may be updated" } }); + const updates = Object.fromEntries(supplied.map((key) => [key, req.body[key]]).filter(([, value]) => typeof value === "string" && value.trim())); + await User.update(updates, { where: { id: req.user.id } }); + const user = await User.findByPk(req.user.id, { attributes: ["id", "firstName", "lastName", "email", "accountType", "accountStatus"] }); + res.json({ success: true, data: user }); + } catch (error) { next(error); } +}; diff --git a/app/middleware/auth.middleware.js b/app/middleware/auth.middleware.js index 31181e4..accc3bf 100644 --- a/app/middleware/auth.middleware.js +++ b/app/middleware/auth.middleware.js @@ -1,93 +1,26 @@ -/** - * Copyright (c) 2026 Niolla - * All rights reserved. - * - * This source code is proprietary and confidential. - * Unauthorized copying, modification, distribution, or use - * of this file, via any medium, is strictly prohibited. - */ - -// app/middleware/auth.middleware.js - const { verifyToken } = require("../utils/jwt.util"); const { getEffectivePermissions } = require("../services/permission.service"); +const db = require("../models"); const authenticate = async (req, res, next) => { try { - let token = null; - - // Get token from cookie - if (req.cookies?.access_token) { - token = req.cookies.access_token; - } - - // Fallback to Bearer token - if (!token && req.headers.authorization?.startsWith("Bearer ")) { - token = req.headers.authorization.split(" ")[1]; - } - - if (!token) { - return res.status(401).json({ - success: false, - message: "Unauthorized", - }); - } - - // Verify token + const token = req.cookies?.access_token || (req.headers.authorization?.startsWith("Bearer ") ? req.headers.authorization.slice(7) : null); + if (!token) return res.status(401).json({ success: false, error: { code: "UNAUTHORIZED", message: "Authentication required" } }); const decoded = verifyToken(token); - - if (process.env.NODE_ENV === "development") { - console.log("DECODED:", decoded); + if (!decoded.sub || !decoded.sid || !Number.isInteger(decoded.tokenVersion)) throw new Error("Required claims missing"); + const [user, session] = await Promise.all([ + db.User.findByPk(decoded.sub), + db.AuthSession.findByPk(decoded.sid), + ]); + if (!user || user.accountStatus !== "ACTIVE" || user.tokenVersion !== decoded.tokenVersion || !session || session.user_id !== user.id || session.revoked_at || session.expires_at <= new Date() || session.token_version !== user.tokenVersion) { + return res.status(401).json({ success: false, error: { code: "SESSION_INVALID", message: "Session is no longer valid" } }); } - - const userId = decoded.sub || decoded.id; - - if (!userId) { - throw new Error("User ID missing in token"); - } - - // Load permissions - const permissions = await getEffectivePermissions(userId); - - req.user = { - ...decoded, - id: userId, - permissions, - }; - + req.user = { id: user.id, sessionId: session.id, firstName: user.firstName, lastName: user.lastName, email: user.email, accountType: user.accountType, accountStatus: user.accountStatus, permissions: await getEffectivePermissions(user.id) }; next(); - } catch (err) { - console.error("AUTH ERROR:", err); - - // Clear invalid/expired cookie - res.clearCookie("access_token", { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", - }); - - // Token expired - if (err.name === "TokenExpiredError") { - return res.status(401).json({ - success: false, - message: "Session expired. Please login again.", - }); - } - - // Invalid token - if (err.name === "JsonWebTokenError") { - return res.status(401).json({ - success: false, - message: "Invalid token", - }); - } - - // Default - return res.status(401).json({ - success: false, - message: "Authentication failed", - }); + } catch (error) { + res.clearCookie("access_token"); + return res.status(401).json({ success: false, error: { code: "UNAUTHORIZED", message: "Invalid or expired access token" } }); } }; -module.exports = { authenticate }; +module.exports = { authenticate, requireAuth: authenticate }; diff --git a/app/middleware/permission.middleware.js b/app/middleware/permission.middleware.js index aaf3ff6..9a15148 100644 --- a/app/middleware/permission.middleware.js +++ b/app/middleware/permission.middleware.js @@ -1,100 +1,25 @@ -/** - * Copyright (c) 2026 Niolla - * All rights reserved. - * - * This source code is proprietary and confidential. - * Unauthorized copying, modification, distribution, or use - * of this file, via any medium, is strictly prohibited. - */ +const { ACCOUNT_TYPES } = require("../constants/accountTypes"); -// app/middleware/permission.middleware.js - -const hasPermission = (userPermissions, requiredPermission) => { - return userPermissions.some(p => { - if (p === requiredPermission) return true; - - // wildcard support - if (p.endsWith(".*")) { - const prefix = p.slice(0, -2); - return requiredPermission.startsWith(prefix); - } - - return false; - }); +const authorizedAccountType = (allowedTypes) => (req, res, next) => { + if (!req.user) return res.status(401).json({ success: false, error: { code: "UNAUTHORIZED", message: "Authentication required" } }); + if (req.user.accountType !== ACCOUNT_TYPES.SUPER_ADMIN && !allowedTypes.includes(req.user.accountType)) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } }); + next(); }; -const methodToAction = { - GET: "view", - POST: "create", - PUT: "update", - PATCH: "update", - DELETE: "delete" +const checkPermission = (baseOrFull, options = {}) => (req, res, next) => { + if (!req.user) return res.status(401).json({ success: false, error: { code: "UNAUTHORIZED", message: "Authentication required" } }); + if (req.user.accountType === ACCOUNT_TYPES.SUPER_ADMIN) return next(); + const actions = { GET: "view", POST: "create", PUT: "update", PATCH: "update", DELETE: "delete" }; + const required = options.custom ? baseOrFull : `${baseOrFull}.${actions[req.method]}`; + const permissions = req.user.permissions || []; + const allowed = permissions.includes(required) || permissions.some((value) => value.endsWith(".*") && required.startsWith(value.slice(0, -1))); + if (!allowed) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } }); + next(); }; -const checkPermission = (baseOrFull, options = {}) => { - return (req, res, next) => { - if (!req.user) { - return res.status(401).json({ - success: false, - message: "Unauthorized" - }); - } - - // admin bypass - if (req.user.accountType === "admin") { - return next(); - } - - if (typeof baseOrFull !== "string") { - return res.status(500).json({ - success: false, - message: "Permission must be a string" - }); - } - - let requiredPermission; - - if (options.custom) { - requiredPermission = baseOrFull; - } else { - const action = methodToAction[req.method]; - - if (!action) { - return res.status(500).json({ - success: false, - message: "Unknown HTTP method" - }); - } - - requiredPermission = `${baseOrFull}.${action}`; - } - - const userPermissions = req.user.permissions || []; - - const hasPermission = - userPermissions.includes(requiredPermission) || - userPermissions.includes(`${baseOrFull}.*`); - - if (!hasPermission) { - return res.status(403).json({ - success: false, - message: `Forbidden - Missing ${requiredPermission}` - }); - } - - next(); - }; +const requireOwnership = (param = "id") => (req, res, next) => { + if (req.user.accountType === ACCOUNT_TYPES.SUPER_ADMIN || req.user.accountType === ACCOUNT_TYPES.ADMIN || req.user.id === req.params[param]) return next(); + return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } }); }; -const authorizedAccountType = (allowedTypes) => { - return (req, res, next) => { - if (!req.user || !allowedTypes.includes(req.user.accountType)) { - return res - .status(403) - .json({ success: false, message: "Forbidden" }); - } - next(); - } -} - -module.exports = {authorizedAccountType, checkPermission}; \ No newline at end of file +module.exports = { authorizedAccountType, requireAccountType: authorizedAccountType, checkPermission, requirePermission: checkPermission, requireOwnership }; diff --git a/app/middleware/validate.middleware.js b/app/middleware/validate.middleware.js new file mode 100644 index 0000000..0eaf16e --- /dev/null +++ b/app/middleware/validate.middleware.js @@ -0,0 +1,7 @@ +module.exports = (schema) => (req, _res, next) => { + try { + req.validated = schema.parse({ body: req.body, params: req.params, query: req.query }); + req.body = req.validated.body; + next(); + } catch (error) { next(error); } +}; diff --git a/app/models/index.js b/app/models/index.js index 176c245..2b6b999 100644 --- a/app/models/index.js +++ b/app/models/index.js @@ -39,6 +39,8 @@ db.sequelize = sequelize; db.User = require("./user/user.model")(sequelize, DataTypes); db.Customer = require("./user/customer.model")(sequelize, DataTypes); db.BusinessCustomer = require("./user/businessCustomer.model")(sequelize, DataTypes); +db.AuthSession = require("./user/authSession.model")(sequelize, DataTypes); +db.UserIdentity = require("./user/userIdentity.model")(sequelize, DataTypes); db.UserActivity = require("./activities/userActivities.model")(sequelize, DataTypes); db.Profile = require("./user/profile.model")(sequelize, DataTypes); @@ -50,6 +52,7 @@ db.roles = require("./permission/role.model")(sequelize, DataTypes); db.permission = require("./permission/permission.model")(sequelize, DataTypes); db.rolePermission = require("./permission/rolePermission.model")(sequelize, DataTypes); db.userPermission = require("./permission/userPermission.model")(sequelize, DataTypes); +db.UserRole = require("./permission/userRole.model")(sequelize, DataTypes); // Document Management db.Document = require("./document/document.model")(sequelize, DataTypes); diff --git a/app/models/permission/role.model.js b/app/models/permission/role.model.js index 857fe2b..46758b2 100644 --- a/app/models/permission/role.model.js +++ b/app/models/permission/role.model.js @@ -19,7 +19,8 @@ module.exports = (sequelize, DataTypes) => { }, roleName: { type: DataTypes.STRING, - allowNull: false + allowNull: false, + unique: true }, roleDescription: { type: DataTypes.TEXT, @@ -37,6 +38,7 @@ module.exports = (sequelize, DataTypes) => { foreignKey: "role_id", as: "rolePermissions" }); + roles.hasMany(db.UserRole, { foreignKey: "role_id", as: "userRoles" }); }; return roles; diff --git a/app/models/permission/rolePermission.model.js b/app/models/permission/rolePermission.model.js index b42b6ef..9869d20 100644 --- a/app/models/permission/rolePermission.model.js +++ b/app/models/permission/rolePermission.model.js @@ -28,7 +28,27 @@ module.exports = (sequelize, DataTypes) => { }, { tableName: "rolePermission", - timestamps: true + timestamps: true, + indexes: [{ unique: true, fields: ["role_id", "permission_id"] }], + hooks: { + afterCreate: async (record) => { + const users = await sequelize.models.UserRole.findAll({ where: { role_id: record.role_id } }); + await Promise.all(users.map((item) => require("../../utils/cache.util").clearPermissionCache(item.user_id))); + }, + afterDestroy: async (record) => { + const users = await sequelize.models.UserRole.findAll({ where: { role_id: record.role_id } }); + await Promise.all(users.map((item) => require("../../utils/cache.util").clearPermissionCache(item.user_id))); + }, + afterUpdate: async (record) => { + const users = await sequelize.models.UserRole.findAll({ where: { role_id: record.role_id } }); + await Promise.all(users.map((item) => require("../../utils/cache.util").clearPermissionCache(item.user_id))); + }, + afterBulkCreate: async (records) => { + const roleIds = [...new Set(records.map((record) => record.role_id))]; + const users = await sequelize.models.UserRole.findAll({ where: { role_id: roleIds } }); + await Promise.all(users.map((item) => require("../../utils/cache.util").clearPermissionCache(item.user_id))); + } + } } ); diff --git a/app/models/permission/userPermission.model.js b/app/models/permission/userPermission.model.js index 1324880..94e7476 100644 --- a/app/models/permission/userPermission.model.js +++ b/app/models/permission/userPermission.model.js @@ -29,7 +29,14 @@ module.exports = (sequelize, DataTypes) => { }, { tableName: "userPermission", - timestamps: true + timestamps: true, + indexes: [{ unique: true, fields: ["user_id", "permission_id"] }], + hooks: { + afterCreate: (record) => require("../../utils/cache.util").clearPermissionCache(record.user_id), + afterUpdate: (record) => require("../../utils/cache.util").clearPermissionCache(record.user_id), + afterDestroy: (record) => require("../../utils/cache.util").clearPermissionCache(record.user_id) + ,afterBulkCreate: (records) => Promise.all(records.map((record) => require("../../utils/cache.util").clearPermissionCache(record.user_id))) + } } ); diff --git a/app/models/permission/userRole.model.js b/app/models/permission/userRole.model.js new file mode 100644 index 0000000..0759ccc --- /dev/null +++ b/app/models/permission/userRole.model.js @@ -0,0 +1,15 @@ +module.exports = (sequelize, DataTypes) => { + const UserRole = sequelize.define("UserRole", { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + user_id: { type: DataTypes.STRING, allowNull: false }, + role_id: { type: DataTypes.STRING, allowNull: false }, + }, { tableName: "user_roles", timestamps: true, indexes: [{ unique: true, fields: ["user_id", "role_id"] }], hooks: { + afterCreate: (record) => require("../../utils/cache.util").clearPermissionCache(record.user_id), + afterDestroy: (record) => require("../../utils/cache.util").clearPermissionCache(record.user_id), + } }); + UserRole.associate = (db) => { + UserRole.belongsTo(db.User, { foreignKey: "user_id", as: "user" }); + UserRole.belongsTo(db.roles, { foreignKey: "role_id", as: "role" }); + }; + return UserRole; +}; diff --git a/app/models/user/authSession.model.js b/app/models/user/authSession.model.js new file mode 100644 index 0000000..28d1bab --- /dev/null +++ b/app/models/user/authSession.model.js @@ -0,0 +1,22 @@ +module.exports = (sequelize, DataTypes) => { + const AuthSession = sequelize.define("AuthSession", { + id: { type: DataTypes.UUID, primaryKey: true }, + user_id: { type: DataTypes.STRING, allowNull: false }, + token_family_id: { type: DataTypes.UUID, allowNull: false }, + refresh_token_hash: { type: DataTypes.STRING(64), allowNull: false }, + device_name: DataTypes.STRING, + user_agent: DataTypes.STRING(500), + ip_address: DataTypes.STRING(64), + remember_me: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, + token_version: { type: DataTypes.INTEGER, allowNull: false }, + last_used_at: DataTypes.DATE, + expires_at: { type: DataTypes.DATE, allowNull: false }, + revoked_at: DataTypes.DATE, + revoked_reason: DataTypes.STRING, + replaced_by_session_id: DataTypes.UUID, + }, { tableName: "auth_sessions", timestamps: true, indexes: [ + { fields: ["user_id"] }, { fields: ["token_family_id"] }, { fields: ["expires_at"] }, + ] }); + AuthSession.associate = (db) => AuthSession.belongsTo(db.User, { foreignKey: "user_id", as: "user" }); + return AuthSession; +}; diff --git a/app/models/user/user.model.js b/app/models/user/user.model.js index c147dd3..7bfc3eb 100644 --- a/app/models/user/user.model.js +++ b/app/models/user/user.model.js @@ -33,10 +33,10 @@ module.exports = (sequelize, DataTypes) => { }, password: { type: DataTypes.STRING, - allowNull: false, + allowNull: true, }, accountType: { - type: DataTypes.ENUM("business_customer", "customer"), + type: DataTypes.ENUM("superadmin", "admin", "manager", "business_customer", "rider", "customer", "support_agent"), defaultValue: "customer", }, accountStatus: { @@ -59,6 +59,8 @@ module.exports = (sequelize, DataTypes) => { allowNull: true, defaultValue: null, }, + tokenVersion: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 }, + lastLoginAt: { type: DataTypes.DATE, allowNull: true }, }, { tableName: "users", @@ -83,6 +85,9 @@ module.exports = (sequelize, DataTypes) => { foreignKey: "user_id", as: "businessCustomer", }); + User.hasMany(db.AuthSession, { foreignKey: "user_id", as: "authSessions" }); + User.hasMany(db.UserIdentity, { foreignKey: "user_id", as: "identities" }); + User.hasMany(db.UserRole, { foreignKey: "user_id", as: "userRoles" }); }; return User; diff --git a/app/models/user/userIdentity.model.js b/app/models/user/userIdentity.model.js new file mode 100644 index 0000000..421262d --- /dev/null +++ b/app/models/user/userIdentity.model.js @@ -0,0 +1,13 @@ +module.exports = (sequelize, DataTypes) => { + const UserIdentity = sequelize.define("UserIdentity", { + id: { type: DataTypes.UUID, primaryKey: true }, + user_id: { type: DataTypes.STRING, allowNull: false }, + provider: { type: DataTypes.ENUM("google", "apple"), allowNull: false }, + provider_subject: { type: DataTypes.STRING, allowNull: false }, + provider_email: DataTypes.STRING, + }, { tableName: "user_identities", timestamps: true, indexes: [ + { unique: true, fields: ["provider", "provider_subject"] }, { fields: ["user_id"] }, + ] }); + UserIdentity.associate = (db) => UserIdentity.belongsTo(db.User, { foreignKey: "user_id", as: "user" }); + return UserIdentity; +}; diff --git a/app/routes/adminAuth.routes.js b/app/routes/adminAuth.routes.js new file mode 100644 index 0000000..5b954eb --- /dev/null +++ b/app/routes/adminAuth.routes.js @@ -0,0 +1,10 @@ +const express = require("express"); +const auth = require("../controllers/auth.controller"); +const validate = require("../middleware/validate.middleware"); +const schemas = require("../validation/auth.schemas"); +const { sensitiveLimiter } = require("../middleware/rateLimit.middleware"); +const router = express.Router(); +router.use(sensitiveLimiter); +router.post("/login", validate(schemas.login), auth.adminLogin); +router.post("/verify-otp", validate(schemas.verifyOtp), auth.verifyOtp); +module.exports = router; diff --git a/app/routes/adminUser.routes.js b/app/routes/adminUser.routes.js new file mode 100644 index 0000000..e6a12f1 --- /dev/null +++ b/app/routes/adminUser.routes.js @@ -0,0 +1,9 @@ +const express = require("express"); +const controller = require("../controllers/adminUser.controller"); +const { authenticate } = require("../middleware/auth.middleware"); +const { authorizedAccountType } = require("../middleware/permission.middleware"); +const router = express.Router(); +router.use(authenticate, authorizedAccountType(["superadmin"])); +router.patch("/:id/status", controller.updateStatus); +router.patch("/:id/account-type", controller.updateAccountType); +module.exports = router; diff --git a/app/routes/auth.routes.js b/app/routes/auth.routes.js index 6bc7750..f94d905 100644 --- a/app/routes/auth.routes.js +++ b/app/routes/auth.routes.js @@ -1,36 +1,27 @@ -/** - * Copyright (c) 2026 Niolla - * All rights reserved. - * - * This source code is proprietary and confidential. - * Unauthorized copying, modification, distribution, or use - * of this file, via any medium, is strictly prohibited. - */ +const express = require("express"); +const auth = require("../controllers/auth.controller"); +const user = require("../controllers/user.controller"); +const { authenticate } = require("../middleware/auth.middleware"); +const validate = require("../middleware/validate.middleware"); +const schemas = require("../validation/auth.schemas"); +const { sensitiveLimiter } = require("../middleware/rateLimit.middleware"); -// app/routes/auth.routes.js - -const express = require('express'); const router = express.Router(); -const authController = require('../controllers/auth.controller'); -const {authenticate} = require('../middleware/auth.middleware'); -const { sensitiveLimiter } = require('../middleware/rateLimit.middleware'); - router.use(sensitiveLimiter); - -// GET /api/auth/me -router.get("/me", authenticate, (req, res) => { - res.json({ - authenticated: true, - user: req.user - }); -}); - -router.post('/req-otp', authController.loginReq); -router.post('/login', authController.login); -router.post('/refresh', authController.refreshToken); -router.post('/forgot-password', authController.forgotPassword); -router.post('/reset-password', authController.resetPassword); -router.post('/change-password', authenticate, authController.changePassword); -router.post('/logout', authController.logout); +router.post("/register", validate(schemas.register), user.createNewUser); +router.post("/login", validate(schemas.login), auth.login); +router.post("/req-otp", validate(schemas.login), auth.loginReq); +router.post("/verify-otp", validate(schemas.verifyOtp), auth.verifyOtp); +router.post("/refresh", validate(schemas.refresh), auth.refreshToken); +router.post("/logout", auth.logout); +router.post("/logout-all", authenticate, auth.logoutAll); +router.post("/forgot-password", validate(schemas.forgot), auth.forgotPassword); +router.post("/reset-password", validate(schemas.reset), auth.resetPassword); +router.post("/change-password", authenticate, validate(schemas.change), auth.changePassword); +router.post("/verify-email", validate(schemas.verifyEmail), auth.verifyEmail); +router.post("/resend-verification", validate(schemas.resend), auth.resendVerification); +router.post("/google", validate(schemas.oauth), auth.oauth("google")); +router.post("/apple", validate(schemas.oauth), auth.oauth("apple")); +router.get("/me", authenticate, auth.me); module.exports = router; diff --git a/app/routes/index.js b/app/routes/index.js index a638e3b..4ce5779 100644 --- a/app/routes/index.js +++ b/app/routes/index.js @@ -20,6 +20,9 @@ const docsRoutes = require("./docs.routes"); const permissionRoutes = require("./permission.routes"); const profileRoutes = require("./profile.routes"); const notificationRoutes = require("./notification.routes"); +const adminAuthRoutes = require("./adminAuth.routes"); +const riderAuthRoutes = require("./riderAuth.routes"); +const adminUserRoutes = require("./adminUser.routes"); const router = express.Router(); @@ -31,5 +34,9 @@ router.use("/document", documentRoutes); router.use("/docs", docsRoutes); router.use("/profile", profileRoutes); router.use("/notification", notificationRoutes); +router.use("/permissions", permissionRoutes); +router.use("/admin/auth", adminAuthRoutes); +router.use("/rider/auth", riderAuthRoutes); +router.use("/admin/users", adminUserRoutes); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/app/routes/permission.routes.js b/app/routes/permission.routes.js index 96346d4..8ce980f 100644 --- a/app/routes/permission.routes.js +++ b/app/routes/permission.routes.js @@ -14,6 +14,13 @@ const router = express.Router(); const permissionController = require("../controllers/permission.controller"); const { authenticate } = require("../middleware/auth.middleware"); const { authorizedAccountType } = require("../middleware/permission.middleware"); +const roleAssignmentController = require("../controllers/roleAssignment.controller"); + +router.use(authenticate, authorizedAccountType(["superadmin"])); +router.post("/users/:userId/roles/:roleId", roleAssignmentController.assignRole); +router.delete("/users/:userId/roles/:roleId", roleAssignmentController.removeRole); +router.get("/roles", permissionController.getAllRoles); +router.get("/roles/:roleId", permissionController.getRoleById); // Permission routes router.post( diff --git a/app/routes/profile.routes.js b/app/routes/profile.routes.js index 080c301..8e92643 100644 --- a/app/routes/profile.routes.js +++ b/app/routes/profile.routes.js @@ -12,6 +12,7 @@ const express = require("express"); const router = express.Router(); const profileController = require("../controllers/profile.controller.js"); +const authController = require("../controllers/auth.controller.js"); const { authenticate } = require("../middleware/auth.middleware"); const { @@ -20,29 +21,32 @@ const { } = require("../middleware/permission.middleware"); const PERMISSIONS = require("../constants/permissions"); const { sensitiveLimiter } = require("../middleware/rateLimit.middleware"); +const validate = require("../middleware/validate.middleware"); +const schemas = require("../validation/auth.schemas"); +const { requireOwnership } = require("../middleware/permission.middleware"); -router.post("/req-reset-password", sensitiveLimiter, profileController.requestPasswordReset); +router.post("/req-reset-password", sensitiveLimiter, validate(schemas.forgot), authController.forgotPassword); -router.post("/reset-password", sensitiveLimiter, profileController.resetPassword); +router.post("/reset-password", sensitiveLimiter, validate(schemas.reset), authController.resetPassword); router.post( "/change-password", authenticate, - authorizedAccountType(["admin", "management", "team_head", "user"]), - profileController.changePassword, + validate(schemas.change), + authController.changePassword, ); router.get( "/avatar/:userId", authenticate, - authorizedAccountType(["admin", "management", "team_head", "user"]), + requireOwnership("userId"), profileController.getProfileAvatar, ); router.get( "/background/:userId", authenticate, - authorizedAccountType(["admin", "management", "team_head", "user"]), + requireOwnership("userId"), profileController.getProfileBackgroundImage, ); diff --git a/app/routes/riderAuth.routes.js b/app/routes/riderAuth.routes.js new file mode 100644 index 0000000..ea850de --- /dev/null +++ b/app/routes/riderAuth.routes.js @@ -0,0 +1,10 @@ +const express = require("express"); +const auth = require("../controllers/auth.controller"); +const validate = require("../middleware/validate.middleware"); +const schemas = require("../validation/auth.schemas"); +const { sensitiveLimiter } = require("../middleware/rateLimit.middleware"); +const router = express.Router(); +router.use(sensitiveLimiter); +router.post("/login", validate(schemas.login), auth.riderLogin); +router.post("/verify-otp", validate(schemas.verifyOtp), auth.verifyOtp); +module.exports = router; diff --git a/app/routes/user.routes.js b/app/routes/user.routes.js index 4f67710..dc8447e 100644 --- a/app/routes/user.routes.js +++ b/app/routes/user.routes.js @@ -19,6 +19,9 @@ const { const { authorizedAccountType, checkPermission } = require("../middleware/permission.middleware"); const PERMISSIONS = require("../constants/permissions"); +router.get("/me", authenticate, userController.getCurrentUser); +router.patch("/me", authenticate, userController.updateCurrentUser); + router.post( "/", @@ -41,7 +44,7 @@ router.get( router.get( "/", authenticate, - authorizedAccountType(["admin"]), + authorizedAccountType(["admin", "superadmin"]), userController.getAllUsers ); @@ -55,14 +58,14 @@ router.get( router.patch( "/:id", authenticate, - authorizedAccountType(["admin", "management", "team_head", "user"]), + authorizedAccountType(["superadmin"]), userController.updateUser ); router.delete( "/:id", authenticate, - authorizedAccountType(["admin"]), + authorizedAccountType(["superadmin"]), userController.deleteUser ); diff --git a/app/services/auth/auth.service.js b/app/services/auth/auth.service.js new file mode 100644 index 0000000..97cd1ab --- /dev/null +++ b/app/services/auth/auth.service.js @@ -0,0 +1,65 @@ +const crypto = require("crypto"); +const db = require("../../models"); +const { checkPassword } = require("../../utils/hashPassword.util"); +const { generateToken } = require("../../utils/jwt.util"); +const { generateUserId, generateId } = require("../../utils/idGen.util"); +const { ACCOUNT_TYPES, PRIVILEGED_ACCOUNT_TYPES } = require("../../constants/accountTypes"); +const { createLoginChallenge, verifyLoginChallenge } = require("./otp.service"); +const { createSession, rotateSession } = require("./session.service"); +const { sendLoginOtp } = require("./email.service"); +const oauth = require("./oauth.service"); + +const authError = (code = "INVALID_CREDENTIALS", status = 401) => Object.assign(new Error(code === "ACCOUNT_NOT_ACTIVE" ? "Account is not active" : "Authentication failed"), { code, status }); +const contextFromRequest = (req, deviceName) => ({ deviceName, userAgent: req.get("user-agent")?.slice(0, 500), ipAddress: req.ip }); +const tokenPair = (user, session, refreshToken) => ({ accessToken: generateToken({ userId: user.id, sessionId: session.id, tokenVersion: user.tokenVersion }), refreshToken, refreshExpiresAt: session.expires_at }); + +const beginPasswordLogin = async ({ email, password, rememberMe, deviceName }, req, allowedTypes) => { + const user = await db.User.findOne({ where: { email: email.toLowerCase() } }); + const valid = user?.password && await checkPassword(password, user.password); + if (!user || !valid || (allowedTypes && !allowedTypes.includes(user.accountType))) throw authError(); + if (user.accountStatus !== "ACTIVE") throw authError("ACCOUNT_NOT_ACTIVE", 403); + const context = contextFromRequest(req, deviceName); + const challenge = await createLoginChallenge({ userId: user.id, rememberMe, context }); + await sendLoginOtp(user, challenge.otp); + return { challengeId: challenge.challengeId }; +}; + +const completeOtpLogin = async ({ challengeId, otp }, req) => { + const challenge = await verifyLoginChallenge(challengeId, otp); + return db.sequelize.transaction(async (transaction) => { + const user = await db.User.findByPk(challenge.userId, { transaction, lock: transaction.LOCK.UPDATE }); + if (!user || user.accountStatus !== "ACTIVE") throw authError("ACCOUNT_NOT_ACTIVE", 403); + const created = await createSession({ user, rememberMe: challenge.rememberMe, ...challenge.context, ...contextFromRequest(req, challenge.context.deviceName), transaction }); + user.lastLoginAt = new Date(); await user.save({ transaction }); + return { user, ...tokenPair(user, created.session, created.refreshToken) }; + }); +}; + +const refresh = async (refreshToken, req) => { + const rotated = await rotateSession(refreshToken, contextFromRequest(req)); + return { user: rotated.user, ...tokenPair(rotated.user, rotated.session, rotated.refreshToken) }; +}; + +const authenticateOAuth = async (provider, input, req) => { + const verified = provider === "google" ? await oauth.verifyGoogleToken(input.idToken) : await oauth.verifyAppleToken(input.idToken); + return db.sequelize.transaction(async (transaction) => { + let identity = await db.UserIdentity.findOne({ where: { provider, provider_subject: verified.subject }, transaction, lock: transaction.LOCK.UPDATE }); + let user = identity && await db.User.findByPk(identity.user_id, { transaction }); + if (!user) { + if (!verified.email || !verified.emailVerified) throw authError("OAUTH_LINK_REQUIRED", 403); + user = await db.User.findOne({ where: { email: verified.email }, transaction, lock: transaction.LOCK.UPDATE }); + if (user && (PRIVILEGED_ACCOUNT_TYPES.includes(user.accountType) || user.accountType === ACCOUNT_TYPES.RIDER)) throw authError("OAUTH_LINK_REQUIRED", 403); + if (!user) { + user = await db.User.create({ id: generateUserId(), firstName: verified.firstName || input.firstName || "ZUMRI", lastName: verified.lastName || input.lastName || "Customer", email: verified.email, password: null, accountType: ACCOUNT_TYPES.CUSTOMER, accountStatus: "ACTIVE", emailVerifiedAt: new Date(), tokenVersion: 0 }, { transaction }); + await db.Profile.create({ profile_id: generateId(), user_id: user.id, theme: "light", notificationsEnabled: true }, { transaction }); + } + identity = await db.UserIdentity.create({ id: crypto.randomUUID(), user_id: user.id, provider, provider_subject: verified.subject, provider_email: verified.email }, { transaction }); + } + if (user.accountStatus !== "ACTIVE") throw authError("ACCOUNT_NOT_ACTIVE", 403); + const created = await createSession({ user, rememberMe: input.rememberMe, ...contextFromRequest(req, input.deviceName), transaction }); + user.lastLoginAt = new Date(); await user.save({ transaction }); + return { user, identity, ...tokenPair(user, created.session, created.refreshToken) }; + }); +}; + +module.exports = { beginPasswordLogin, completeOtpLogin, refresh, authenticateOAuth, contextFromRequest, tokenPair }; diff --git a/app/services/auth/email.service.js b/app/services/auth/email.service.js new file mode 100644 index 0000000..a77a866 --- /dev/null +++ b/app/services/auth/email.service.js @@ -0,0 +1,6 @@ +const { sendMail } = require("../../utils/mail.util"); + +const sendLoginOtp = (user, otp) => sendMail({ to: user.email, subject: "Your ZUMRI login code", templateName: "otp", templateVars: { firstName: user.firstName, otp }, text: `Your ZUMRI login code is ${otp}.` }); +const sendPasswordChanged = (user) => sendMail({ to: user.email, subject: "Your ZUMRI password was changed", templateName: "passwordChanged", templateVars: { customer_name: user.firstName, changed_at: new Date().toLocaleString() }, text: "Your ZUMRI password was changed. Contact support if this was not you." }); + +module.exports = { sendLoginOtp, sendPasswordChanged }; diff --git a/app/services/auth/oauth.service.js b/app/services/auth/oauth.service.js new file mode 100644 index 0000000..5a5a045 --- /dev/null +++ b/app/services/auth/oauth.service.js @@ -0,0 +1,31 @@ +const crypto = require("crypto"); +const jwt = require("jsonwebtoken"); +const { OAuth2Client } = require("google-auth-library"); + +let appleKeys; +let appleKeysAt = 0; +const verifyGoogleToken = async (idToken) => { + if (!process.env.GOOGLE_CLIENT_ID) throw Object.assign(new Error("Google authentication is unavailable"), { status: 503, code: "OAUTH_UNAVAILABLE" }); + const ticket = await new OAuth2Client(process.env.GOOGLE_CLIENT_ID).verifyIdToken({ idToken, audience: process.env.GOOGLE_CLIENT_ID }); + const payload = ticket.getPayload(); + if (!payload?.sub || !payload.email || payload.email_verified !== true) throw new Error("Invalid Google identity token"); + return { subject: payload.sub, email: payload.email.toLowerCase(), emailVerified: true, firstName: payload.given_name, lastName: payload.family_name }; +}; + +const getAppleKeys = async () => { + if (appleKeys && Date.now() - appleKeysAt < 3600000) return appleKeys; + const response = await fetch("https://appleid.apple.com/auth/keys"); + if (!response.ok) throw new Error("Apple key service unavailable"); + appleKeys = (await response.json()).keys; appleKeysAt = Date.now(); return appleKeys; +}; +const verifyAppleToken = async (idToken) => { + if (!process.env.APPLE_CLIENT_ID) throw Object.assign(new Error("Apple authentication is unavailable"), { status: 503, code: "OAUTH_UNAVAILABLE" }); + const decoded = jwt.decode(idToken, { complete: true }); + const key = (await getAppleKeys()).find((candidate) => candidate.kid === decoded?.header?.kid && candidate.alg === "RS256"); + if (!key) throw new Error("Invalid Apple identity token"); + const payload = jwt.verify(idToken, crypto.createPublicKey({ key, format: "jwk" }), { algorithms: ["RS256"], issuer: "https://appleid.apple.com", audience: process.env.APPLE_CLIENT_ID }); + if (!payload.sub) throw new Error("Invalid Apple identity token"); + return { subject: payload.sub, email: payload.email?.toLowerCase(), emailVerified: payload.email_verified === true || payload.email_verified === "true" }; +}; + +module.exports = { verifyGoogleToken, verifyAppleToken }; diff --git a/app/services/auth/otp.service.js b/app/services/auth/otp.service.js new file mode 100644 index 0000000..27046e2 --- /dev/null +++ b/app/services/auth/otp.service.js @@ -0,0 +1,29 @@ +const crypto = require("crypto"); +const redis = require("../../config/redisClient"); + +const otpHash = (challengeId, otp) => crypto.createHmac("sha256", process.env.JWT_SECRET).update(`${challengeId}:${otp}`).digest("hex"); +const generateOtp = () => crypto.randomInt(0, 1000000).toString().padStart(6, "0"); + +const createLoginChallenge = async ({ userId, rememberMe, context }) => { + const challengeId = crypto.randomUUID(); + const otp = generateOtp(); + await redis.set(`login:${challengeId}`, JSON.stringify({ userId, otpHash: otpHash(challengeId, otp), attempts: 0, rememberMe, context }), "EX", Number(process.env.LOGIN_OTP_TTL_SECONDS || 900)); + return { challengeId, otp }; +}; + +const VERIFY_SCRIPT = ` +local raw=redis.call('GET',KEYS[1]); if not raw then return {-3} end +local value=cjson.decode(raw) +if value.otpHash==ARGV[1] then redis.call('DEL',KEYS[1]); return {1,value.userId,cjson.encode(value)} end +value.attempts=(value.attempts or 0)+1 +if value.attempts>=tonumber(ARGV[2]) then redis.call('DEL',KEYS[1]); return {-2} end +redis.call('SET',KEYS[1],cjson.encode(value),'KEEPTTL'); return {-1} +`; +const verifyLoginChallenge = async (challengeId, otp) => { + const result = await redis.eval(VERIFY_SCRIPT, 1, `login:${challengeId}`, otpHash(challengeId, otp), Number(process.env.LOGIN_OTP_MAX_ATTEMPTS || 5)); + if (Number(result[0]) !== 1) throw Object.assign(new Error("Invalid or expired challenge"), { code: "INVALID_OTP", status: 401 }); + const stored = JSON.parse(result[2]); + return { userId: result[1], rememberMe: Boolean(stored.rememberMe), context: stored.context || {} }; +}; + +module.exports = { generateOtp, otpHash, createLoginChallenge, verifyLoginChallenge }; diff --git a/app/services/auth/session.service.js b/app/services/auth/session.service.js new file mode 100644 index 0000000..f57a53d --- /dev/null +++ b/app/services/auth/session.service.js @@ -0,0 +1,59 @@ +const crypto = require("crypto"); +const db = require("../../models"); + +const digest = (token) => crypto.createHash("sha256").update(token).digest("hex"); +const safeEqual = (left, right) => left?.length === right?.length && crypto.timingSafeEqual(Buffer.from(left), Buffer.from(right)); +const rawToken = (id) => `${id}.${crypto.randomBytes(48).toString("base64url")}`; +const tokenId = (token) => typeof token === "string" ? token.split(".", 1)[0] : null; +const ttlDays = (rememberMe) => Number(process.env[rememberMe ? "REMEMBER_ME_REFRESH_TOKEN_TTL_DAYS" : "REFRESH_TOKEN_TTL_DAYS"] || (rememberMe ? 30 : 7)); + +const createSession = async ({ user, rememberMe = false, deviceName, userAgent, ipAddress, familyId, transaction }) => { + const id = crypto.randomUUID(); + const refreshToken = rawToken(id); + const expiresAt = new Date(Date.now() + ttlDays(rememberMe) * 86400000); + const session = await db.AuthSession.create({ + id, user_id: user.id, token_family_id: familyId || crypto.randomUUID(), refresh_token_hash: digest(refreshToken), + device_name: deviceName, user_agent: userAgent, ip_address: ipAddress, remember_me: rememberMe, + token_version: user.tokenVersion, last_used_at: new Date(), expires_at: expiresAt, + }, { transaction }); + return { session, refreshToken, expiresAt }; +}; + +const revokeFamily = async (familyId, reason, transaction) => db.AuthSession.update( + { revoked_at: new Date(), revoked_reason: reason }, + { where: { token_family_id: familyId, revoked_at: null }, transaction }, +); + +const rotateSession = async (token, context = {}) => db.sequelize.transaction(async (transaction) => { + const id = tokenId(token); + if (!id) throw Object.assign(new Error("Invalid session"), { code: "INVALID_SESSION", status: 401 }); + const session = await db.AuthSession.findByPk(id, { transaction, lock: transaction.LOCK.UPDATE }); + if (!session) throw Object.assign(new Error("Invalid session"), { code: "INVALID_SESSION", status: 401 }); + if (!safeEqual(digest(token), session.refresh_token_hash)) throw Object.assign(new Error("Invalid session"), { code: "INVALID_SESSION", status: 401 }); + if (session.revoked_at) { + if (session.revoked_reason === "ROTATED") await revokeFamily(session.token_family_id, "REFRESH_TOKEN_REUSE", transaction); + throw Object.assign(new Error("Invalid session"), { code: session.revoked_reason === "ROTATED" ? "REFRESH_TOKEN_REUSE" : "INVALID_SESSION", status: 401 }); + } + if (session.expires_at <= new Date()) { + session.revoked_at = new Date(); session.revoked_reason = "EXPIRED"; await session.save({ transaction }); + throw Object.assign(new Error("Invalid session"), { code: "INVALID_SESSION", status: 401 }); + } + const user = await db.User.findByPk(session.user_id, { transaction, lock: transaction.LOCK.UPDATE }); + if (!user || user.accountStatus !== "ACTIVE" || user.tokenVersion !== session.token_version) { + await revokeFamily(session.token_family_id, "ACCOUNT_OR_TOKEN_VERSION_INVALID", transaction); + throw Object.assign(new Error("Invalid session"), { code: "INVALID_SESSION", status: 401 }); + } + const replacement = await createSession({ user, rememberMe: session.remember_me, familyId: session.token_family_id, ...context, transaction }); + session.revoked_at = new Date(); session.revoked_reason = "ROTATED"; session.replaced_by_session_id = replacement.session.id; + session.last_used_at = new Date(); await session.save({ transaction }); + return { ...replacement, user }; +}); + +const revokeSession = async (id, reason = "LOGOUT", transaction) => db.AuthSession.update( + { revoked_at: new Date(), revoked_reason: reason }, { where: { id, revoked_at: null }, transaction }, +); +const revokeAllUserSessions = async (userId, reason, transaction) => db.AuthSession.update( + { revoked_at: new Date(), revoked_reason: reason }, { where: { user_id: userId, revoked_at: null }, transaction }, +); + +module.exports = { createSession, rotateSession, revokeSession, revokeFamily, revokeAllUserSessions, hashRefreshToken: digest, tokenId }; diff --git a/app/services/permission.service.js b/app/services/permission.service.js index c3ffe94..434982b 100644 --- a/app/services/permission.service.js +++ b/app/services/permission.service.js @@ -12,6 +12,7 @@ const db = require("../models"); const User = db.User; const RolePermission = db.rolePermission; +const UserRole = db.UserRole; const UserPermission = db.userPermission; const { @@ -32,10 +33,11 @@ const getEffectivePermissions = async (userId) => { const user = await User.findByPk(userId); if (!user) return []; - const rolePermissions = await RolePermission.findAll({ - where: { role_id: user.roleID }, + const roles = await UserRole.findAll({ where: { user_id: userId }, attributes: ["role_id"] }); + const rolePermissions = roles.length ? await RolePermission.findAll({ + where: { role_id: roles.map((role) => role.role_id) }, attributes: ["permission_id"] - }); + }) : []; const userPermissions = await UserPermission.findAll({ where: { user_id: userId }, @@ -68,4 +70,4 @@ const getEffectivePermissions = async (userId) => { } }; -module.exports = { getEffectivePermissions }; \ No newline at end of file +module.exports = { getEffectivePermissions }; diff --git a/app/utils/emailVerification.util.js b/app/utils/emailVerification.util.js index d7e26af..c5a6a7f 100644 --- a/app/utils/emailVerification.util.js +++ b/app/utils/emailVerification.util.js @@ -16,6 +16,8 @@ const hashEmailVerificationToken = (token) => { }; const createEmailVerification = async (userId) => { + const previousKey = await redis.get(`email-verification-user:${userId}`); + if (previousKey) await redis.del(previousKey); // 1. Generate raw token const token = generateEmailVerificationToken(); @@ -26,6 +28,7 @@ const createEmailVerification = async (userId) => { const redisKey = `email-verification:${tokenHash}`; await redis.set(redisKey, userId, "EX", EMAIL_VERIFICATION_TTL); + await redis.set(`email-verification-user:${userId}`, redisKey, "EX", EMAIL_VERIFICATION_TTL); return token; }; @@ -45,12 +48,14 @@ const verifyEmailVerificationToken = async (token) => { const redisKey = `email-verification:${tokenHash}`; // 3. Search Redis - const userId = await redis.get(redisKey); + const userId = await redis.getdel(redisKey); if (!userId) { return null; } + await redis.del(`email-verification-user:${userId}`); + return { userId, redisKey, @@ -82,10 +87,19 @@ const deleteEmailVerification = async (redisKey) => { await redis.del(redisKey); }; +const consumeEmailVerificationToken = async (token) => { + if (!token || typeof token !== "string") return null; + const redisKey = `email-verification:${hashEmailVerificationToken(token)}`; + const userId = await redis.getdel(redisKey); + if (userId) await redis.del(`email-verification-user:${userId}`); + return userId; +}; + module.exports = { createEmailVerification, verifyEmailVerificationToken, sendVerificationEmail, deleteEmailVerification, + consumeEmailVerificationToken, hashEmailVerificationToken, }; diff --git a/app/utils/idGen.util.js b/app/utils/idGen.util.js index 0fc0abc..e414f8a 100644 --- a/app/utils/idGen.util.js +++ b/app/utils/idGen.util.js @@ -10,6 +10,7 @@ // app/utils/idGen.util.js const { v4: uuidv4 } = require("uuid"); +const crypto = require("crypto"); const { nextSequence } = require("./referenceNumber.util"); const {log} = require("./consoleLog.utill"); @@ -20,15 +21,15 @@ const { generateDeliveryNote } = require("./id/dispatchNote.utill"); const generateUserId = () => { - return "usr_" + Math.random().toString(36).slice(2, 10); + return "usr_" + crypto.randomUUID(); }; const generateCustomerId = () => { - return "cust_" + Math.random().toString(36).slice(2, 10); + return "cust_" + crypto.randomUUID(); } const generateBusinessCustomerId = () => { - return "b_cust_" + Math.random().toString(36).slice(2, 10); + return "b_cust_" + crypto.randomUUID(); } const generateClientId = () => { diff --git a/app/utils/jwt.util.js b/app/utils/jwt.util.js index 19bfd34..15b7092 100644 --- a/app/utils/jwt.util.js +++ b/app/utils/jwt.util.js @@ -1,51 +1,26 @@ -/** - * Copyright (c) 2026 Niolla - * All rights reserved. - * - * This source code is proprietary and confidential. - * Unauthorized copying, modification, distribution, or use - * of this file, via any medium, is strictly prohibited. - */ - -// app/utils/jwt.util.js - const jwt = require("jsonwebtoken"); -require("dotenv").config(); -const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "15m"; // token validity - -const REFRESH_TOKEN_DAYS = process.env.REFRESH_TOKEN_DAYS || "7d"; // refresh token validity - -const getSecret = (name) => { - const value = process.env[name]; - if (!value || value.length < 32) throw new Error(`${name} is not configured securely`); - return value; +const secret = () => { + if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) throw new Error("JWT_SECRET is not configured securely"); + return process.env.JWT_SECRET; }; -/** - * Generate JWT token - * @param {Object} payload - usually { id, email, role } - * @returns string - */ -const generateToken = (payload) => { - return jwt.sign(payload, getSecret("JWT_SECRET"), { expiresIn: JWT_EXPIRES_IN }); -}; +const generateToken = ({ userId, sessionId, tokenVersion }) => jwt.sign( + { sid: sessionId, tokenVersion }, + secret(), + { + algorithm: "HS256", + subject: userId, + issuer: process.env.JWT_ISSUER || "zumri-api", + audience: process.env.JWT_AUDIENCE || "zumri-clients", + expiresIn: process.env.ACCESS_TOKEN_TTL || "15m", + }, +); -/** - * Verify JWT token - * @param {string} token - * @returns payload or throws error - */ -const verifyToken = (token) => { - return jwt.verify(token, getSecret("JWT_SECRET")); -}; +const verifyToken = (token) => jwt.verify(token, secret(), { + algorithms: ["HS256"], + issuer: process.env.JWT_ISSUER || "zumri-api", + audience: process.env.JWT_AUDIENCE || "zumri-clients", +}); -const generateRefreshToken = (payload) => { - return jwt.sign(payload, getSecret("REFRESH_TOKEN_SECRET"), { expiresIn: REFRESH_TOKEN_DAYS }); -} - -const verifyRefreshToken = (token) => { - return jwt.verify(token, getSecret("REFRESH_TOKEN_SECRET")); -} - -module.exports = { generateToken, verifyToken, generateRefreshToken, verifyRefreshToken }; +module.exports = { generateToken, verifyToken }; diff --git a/app/utils/otp.util.js b/app/utils/otp.util.js deleted file mode 100644 index 8259d2b..0000000 --- a/app/utils/otp.util.js +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Copyright (c) 2026 Niolla - * All rights reserved. - * - * This source code is proprietary and confidential. - * Unauthorized copying, modification, distribution, or use - * of this file, via any medium, is strictly prohibited. - */ - -// app/utils/otp.util.js - -const otpCache = new Map(); - -function generateOTP(key){ - const otp = Math.floor(100000 + Math.random() * 900000).toString(); - saveOTP(key, otp); - return otp; -} - -function saveOTP(key, otp) { - ttl = parseInt(process.env.LOGIN_OTP_TTL_SECONDS || 300); - const expiresAt = Date.now() + ttl * 1000; // Convert seconds to milliseconds - otpCache.set(key, { otp, expiresAt }); -} - -// Validate OTP -function validateOTP(key, otp) { - const record = otpCache.get(key); - if (!record) return false; - if (Date.now() > record.expiresAt) { - otpCache.delete(key); - return false; - } - const isValid = record.otp === otp; - if (isValid) otpCache.delete(key); // OTP can be used only once - return isValid; -} - -module.exports = { generateOTP, validateOTP }; \ No newline at end of file diff --git a/app/utils/passwordReset.utill.js b/app/utils/passwordReset.utill.js index aca6e06..ecc7864 100644 --- a/app/utils/passwordReset.utill.js +++ b/app/utils/passwordReset.utill.js @@ -69,6 +69,11 @@ const deletePasswordReset = async (redisKey) => { await redis.del(redisKey); }; +const consumePasswordResetToken = async (token) => { + if (!token || typeof token !== "string") return null; + return redis.getdel(`password-reset:${hashPasswordResetToken(token)}`); +}; + const sendPasswordResetEmail = async (email, firstName, resetToken) => { @@ -130,6 +135,7 @@ module.exports = { createPasswordReset, verifyPasswordResetToken, deletePasswordReset, + consumePasswordResetToken, hashPasswordResetToken, sendPasswordResetEmail, sendPasswordChangedEmail, diff --git a/app/utils/refreshSession.util.js b/app/utils/refreshSession.util.js deleted file mode 100644 index bef329a..0000000 --- a/app/utils/refreshSession.util.js +++ /dev/null @@ -1,201 +0,0 @@ -// app/utils/refreshSession.util.js - -const crypto = require("crypto"); - -const { - generateRefreshToken, - verifyRefreshToken, -} = require("./jwt.util"); - -const refreshSessions = new Map(); - - -// Default = 7 days -const REFRESH_SESSION_TTL = - 7 * 24 * 60 * 60 * 1000; - - -const hashRefreshToken = (token) => { - return crypto - .createHash("sha256") - .update(token) - .digest("hex"); -}; - -const createRefreshSession = (userId) => { - - // Unique session ID - const sessionId = - crypto.randomUUID(); - - - // Create raw Refresh JWT - const refreshToken = - generateRefreshToken({ - sub: userId, - sid: sessionId, - }); - - - // Hash raw refresh token - const tokenHash = - hashRefreshToken( - refreshToken - ); - - - // Expiration time - const expiresAt = - Date.now() + - REFRESH_SESSION_TTL; - - - // Save only HASH in RAM - refreshSessions.set( - sessionId, - { - userId, - tokenHash, - expiresAt, - } - ); - - - return { - refreshToken, - sessionId, - }; -}; - -const validateRefreshSession = ( - refreshToken -) => { - - if (!refreshToken) { - return null; - } - - - let decoded; - - try { - - decoded = - verifyRefreshToken( - refreshToken - ); - - } catch (error) { - - return null; - - } - - - const sessionId = - decoded.sid; - - const userId = - decoded.sub; - - - if (!sessionId || !userId) { - return null; - } - - - // Get session from RAM - const session = - refreshSessions.get( - sessionId - ); - - - if (!session) { - return null; - } - - - // Check session expiration - if ( - Date.now() > - session.expiresAt - ) { - - refreshSessions.delete( - sessionId - ); - - return null; - } - - - // Hash received refresh token - const receivedHash = - hashRefreshToken( - refreshToken - ); - - - // Compare stored hash - if ( - receivedHash !== - session.tokenHash - ) { - return null; - } - - - // Extra user check - if ( - session.userId !== - userId - ) { - return null; - } - - - return { - userId, - sessionId, - }; -}; - -const deleteRefreshSession = ( - sessionId -) => { - - refreshSessions.delete( - sessionId - ); -}; - -const deleteAllUserSessions = ( - userId -) => { - - for ( - const [sessionId, session] - of refreshSessions.entries() - ) { - - if ( - session.userId === userId - ) { - - refreshSessions.delete( - sessionId - ); - - } - - } -}; - - -module.exports = { - createRefreshSession, - validateRefreshSession, - deleteRefreshSession, - deleteAllUserSessions, -}; \ No newline at end of file diff --git a/app/utils/validation/validatePassword.util.js b/app/utils/validation/validatePassword.util.js index 4d51b21..ab0a078 100644 --- a/app/utils/validation/validatePassword.util.js +++ b/app/utils/validation/validatePassword.util.js @@ -1,32 +1,5 @@ -//app/utils/validation/validatePassword.util.js -const validatePassword = (password) => { - // Check if password is a string - if (typeof password !== "string") { - return false; - } +const { passwordSchema } = require("../../validation/auth.schemas"); - // At least 1 uppercase letter - const hasUppercase = /[A-Z]/.test(password); +const validatePassword = (password) => passwordSchema.safeParse(password).success; - // At least 1 lowercase letter - const hasLowercase = /[a-z]/.test(password); - - // At least 1 symbol - const hasSymbol = /[^A-Za-z0-9]/.test(password); - - // At least 4 numbers - const numberCount = (password.match(/[0-9]/g) || []).length; - - const hasFourNumbers = numberCount >= 4; - - return ( - hasUppercase && - hasLowercase && - hasSymbol && - hasFourNumbers - ); -}; - -module.exports = { - validatePassword, -}; \ No newline at end of file +module.exports = { validatePassword }; diff --git a/app/validation/auth.schemas.js b/app/validation/auth.schemas.js new file mode 100644 index 0000000..7a470e1 --- /dev/null +++ b/app/validation/auth.schemas.js @@ -0,0 +1,22 @@ +const { z } = require("zod"); + +const email = z.string().trim().toLowerCase().email(); +const password = z.string().min(12).superRefine((value, context) => { + const failures = [!/[A-Z]/.test(value), !/[a-z]/.test(value), !/[^A-Za-z0-9]/.test(value), (value.match(/\d/g) || []).length < 4]; + if (failures.some(Boolean)) context.addIssue({ code: "custom", message: "Password must include uppercase, lowercase, a symbol, and at least four numbers" }); +}); +const body = (shape) => z.object({ body: z.object(shape).strict(), params: z.object({}).passthrough(), query: z.object({}).passthrough() }); + +module.exports = { + passwordSchema: password, + register: body({ firstName: z.string().trim().min(1).max(100), lastName: z.string().trim().min(1).max(100), email, password, phoneNumber: z.string().trim().min(1), address: z.string().trim().min(1), clientType: z.enum(["WEB", "MOBILE"]).default("WEB") }), + login: body({ email, password: z.string().min(1), rememberMe: z.boolean().default(false), clientType: z.enum(["WEB", "MOBILE"]).default("WEB"), deviceName: z.string().max(100).optional() }), + verifyOtp: body({ challengeId: z.string().uuid(), otp: z.string().regex(/^\d{6}$/), clientType: z.enum(["WEB", "MOBILE"]).default("WEB") }), + refresh: body({ refreshToken: z.string().min(20).optional(), clientType: z.enum(["WEB", "MOBILE"]).default("WEB") }), + forgot: body({ email }), + reset: body({ token: z.string().min(20), newPassword: password, confirmPassword: z.string() }).superRefine(({ body }, context) => { if (body.newPassword !== body.confirmPassword) context.addIssue({ code: "custom", path: ["body", "confirmPassword"], message: "Passwords do not match" }); }), + change: body({ currentPassword: z.string().min(1), newPassword: password, confirmPassword: z.string() }).superRefine(({ body }, context) => { if (body.newPassword !== body.confirmPassword) context.addIssue({ code: "custom", path: ["body", "confirmPassword"], message: "Passwords do not match" }); }), + verifyEmail: body({ token: z.string().min(20) }), + resend: body({ email }), + oauth: body({ idToken: z.string().min(20), rememberMe: z.boolean().default(false), clientType: z.enum(["WEB", "MOBILE"]).default("WEB"), deviceName: z.string().max(100).optional(), firstName: z.string().max(100).optional(), lastName: z.string().max(100).optional() }), +}; diff --git a/migrations/20260903010000-phase-1-identity-security.js b/migrations/20260903010000-phase-1-identity-security.js new file mode 100644 index 0000000..19a452c --- /dev/null +++ b/migrations/20260903010000-phase-1-identity-security.js @@ -0,0 +1,61 @@ +"use strict"; + +module.exports = { + async up(queryInterface, Sequelize) { + const userColumns = await queryInterface.describeTable("users"); + await queryInterface.changeColumn("users", "accountType", { + type: Sequelize.ENUM("superadmin", "admin", "manager", "business_customer", "rider", "customer", "support_agent"), + allowNull: false, defaultValue: "customer", + }); + await queryInterface.changeColumn("users", "password", { type: Sequelize.STRING, allowNull: true }); + if (!userColumns.passwordChangedAt) await queryInterface.addColumn("users", "passwordChangedAt", { type: Sequelize.DATE, allowNull: true }); + await queryInterface.addColumn("users", "tokenVersion", { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 }); + await queryInterface.addColumn("users", "lastLoginAt", { type: Sequelize.DATE, allowNull: true }); + + await queryInterface.createTable("auth_sessions", { + id: { type: Sequelize.UUID, primaryKey: true }, user_id: { type: Sequelize.STRING, allowNull: false, references: { model: "users", key: "id" }, onDelete: "CASCADE" }, + token_family_id: { type: Sequelize.UUID, allowNull: false }, refresh_token_hash: { type: Sequelize.STRING(64), allowNull: false }, + device_name: Sequelize.STRING, user_agent: Sequelize.STRING(500), ip_address: Sequelize.STRING(64), + remember_me: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false }, token_version: { type: Sequelize.INTEGER, allowNull: false }, + last_used_at: Sequelize.DATE, expires_at: { type: Sequelize.DATE, allowNull: false }, revoked_at: Sequelize.DATE, + revoked_reason: Sequelize.STRING, replaced_by_session_id: Sequelize.UUID, + createdAt: { type: Sequelize.DATE, allowNull: false }, updatedAt: { type: Sequelize.DATE, allowNull: false }, + }); + await queryInterface.addIndex("auth_sessions", ["user_id"]); + await queryInterface.addIndex("auth_sessions", ["token_family_id"]); + await queryInterface.addIndex("auth_sessions", ["expires_at"]); + + await queryInterface.createTable("user_identities", { + id: { type: Sequelize.UUID, primaryKey: true }, user_id: { type: Sequelize.STRING, allowNull: false, references: { model: "users", key: "id" }, onDelete: "CASCADE" }, + provider: { type: Sequelize.ENUM("google", "apple"), allowNull: false }, provider_subject: { type: Sequelize.STRING, allowNull: false }, + provider_email: Sequelize.STRING, createdAt: { type: Sequelize.DATE, allowNull: false }, updatedAt: { type: Sequelize.DATE, allowNull: false }, + }); + await queryInterface.addIndex("user_identities", ["provider", "provider_subject"], { unique: true, name: "user_identity_provider_subject_unique" }); + await queryInterface.addIndex("user_identities", ["user_id"]); + + await queryInterface.createTable("user_roles", { + id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true }, + user_id: { type: Sequelize.STRING, allowNull: false, references: { model: "users", key: "id" }, onDelete: "CASCADE" }, + role_id: { type: Sequelize.STRING, allowNull: false, references: { model: "roles", key: "role_id" }, onDelete: "CASCADE" }, + createdAt: { type: Sequelize.DATE, allowNull: false }, updatedAt: { type: Sequelize.DATE, allowNull: false }, + }); + await queryInterface.addIndex("user_roles", ["user_id", "role_id"], { unique: true, name: "user_role_unique" }); + await queryInterface.addIndex("rolePermission", ["role_id", "permission_id"], { unique: true, name: "role_permission_unique" }); + await queryInterface.addIndex("userPermission", ["user_id", "permission_id"], { unique: true, name: "user_permission_unique" }); + await queryInterface.addIndex("roles", ["roleName"], { unique: true, name: "role_name_unique" }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.removeIndex("roles", "role_name_unique"); + await queryInterface.removeIndex("userPermission", "user_permission_unique"); + await queryInterface.removeIndex("rolePermission", "role_permission_unique"); + await queryInterface.dropTable("user_roles"); + await queryInterface.dropTable("user_identities"); + await queryInterface.dropTable("auth_sessions"); + await queryInterface.removeColumn("users", "lastLoginAt"); + await queryInterface.removeColumn("users", "tokenVersion"); + // passwordChangedAt may predate this migration, so down intentionally preserves it. + await queryInterface.changeColumn("users", "password", { type: Sequelize.STRING, allowNull: false }); + await queryInterface.changeColumn("users", "accountType", { type: Sequelize.ENUM("business_customer", "customer"), defaultValue: "customer" }); + }, +}; diff --git a/package-lock.json b/package-lock.json index 93cc848..26ff243 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "exceljs": "^4.4.0", "express": "^5.2.1", "express-rate-limit": "^8.7.0", + "google-auth-library": "^11.0.2", "helmet": "^8.3.0", "ioredis": "^5.10.1", "jsonwebtoken": "^9.0.3", @@ -4170,6 +4171,15 @@ "node": ">=0.6" } }, + "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": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", @@ -5699,6 +5709,12 @@ "node": ">=6.6.0" } }, + "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/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -5806,6 +5822,29 @@ "pend": "~1.2.0" } }, + "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/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -5971,6 +6010,18 @@ "node": ">= 0.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", @@ -6084,6 +6135,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "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": "9.0.3", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-9.0.3.tgz", + "integrity": "sha512-2YYnIlHaKBGT2IPg3G2M57hia9Galz15zsEOvw9T3oRf0lSn6KN6VcHQLqby7x8ksYKnjXvp3rp2KJyLCN6zfQ==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.1.3", + "google-logging-utils": "^2.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/generate-function": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", @@ -6222,6 +6301,32 @@ "node": ">= 6" } }, + "node_modules/google-auth-library": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-11.0.2.tgz", + "integrity": "sha512-vzpgPutxrghPsnjrjpzLX2bdv8IOL719Rh0oEjGnQu8YCIbnbMuTTQ5zU9LcKvLdOPgCxBwppbvnhgW90Qna5Q==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "^9.0.0", + "google-logging-utils": "^2.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/google-logging-utils": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-2.0.1.tgz", + "integrity": "sha512-HMhaQghlOTvbcb3c4T5jmmOMtG3JUF1iOQMezaJXL86CDS+Tm2vHd0IeLFRAx3+ewd+bo9E1HFHoy17X5aJa9A==", + "license": "Apache-2.0", + "engines": { + "node": ">=22" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -7681,6 +7786,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-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -8474,6 +8588,53 @@ "node": ">=20" } }, + "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-fetch/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/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -10692,6 +10853,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/webdriver-bidi-protocol": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", diff --git a/package.json b/package.json index 3f56cf0..dbdcb13 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "exceljs": "^4.4.0", "express": "^5.2.1", "express-rate-limit": "^8.7.0", + "google-auth-library": "^11.0.2", "helmet": "^8.3.0", "ioredis": "^5.10.1", "jsonwebtoken": "^9.0.3", diff --git a/tests/integration/foundation.test.js b/tests/integration/foundation.test.js index 5879b55..54589b8 100644 --- a/tests/integration/foundation.test.js +++ b/tests/integration/foundation.test.js @@ -19,9 +19,22 @@ jest.mock("../../app/config/bullBoard.config", () => { const app = require("../../app"); const { AppError, errorHandler } = require("../../app/middleware/error.middleware"); +const db = require("../../app/models"); +const { generateToken } = require("../../app/utils/jwt.util"); + +const authenticated = (accountType = "customer") => { + const user = { id: "usr-self", firstName: "Test", lastName: "User", email: "test@example.com", accountType, accountStatus: "ACTIVE", tokenVersion: 0, emailVerifiedAt: new Date(), profile: null }; + const session = { id: "session-self", user_id: user.id, revoked_at: null, expires_at: new Date(Date.now() + 60000), token_version: 0 }; + jest.spyOn(db.User, "findByPk").mockResolvedValue(user); + jest.spyOn(db.AuthSession, "findByPk").mockResolvedValue(session); + jest.spyOn(db.UserRole, "findAll").mockResolvedValue([]); + jest.spyOn(db.userPermission, "findAll").mockResolvedValue([]); + return `Bearer ${generateToken({ userId: user.id, sessionId: session.id, tokenVersion: 0 })}`; +}; describe("foundation HTTP behavior", () => { beforeEach(() => { + jest.restoreAllMocks(); mockCheckDatabase.mockResolvedValue(true); mockCheckRedis.mockResolvedValue(true); }); @@ -74,4 +87,29 @@ describe("foundation HTTP behavior", () => { test("Bull Board rejects unauthenticated requests", async () => { await request(app).get("/admin/queues").expect(401); }); + + test("mounted permission administration rejects unauthenticated requests", async () => { + await request(app).get("/api/v1/permissions").expect(401); + }); + + test("customer cannot mass-assign account type", async () => { + const token = authenticated("customer"); + await request(app).patch("/api/v1/user/me").set("Authorization", token).send({ accountType: "admin" }).expect(400).expect(({ body }) => expect(body.error.code).toBe("UNSAFE_FIELD")); + }); + + test("customer cannot mutate another user by ID", async () => { + const token = authenticated("customer"); + await request(app).patch("/api/v1/user/usr-other").set("Authorization", token).send({ firstName: "Attack" }).expect(403); + }); + + test("authenticated admin can reach protected Bull Board", async () => { + const token = authenticated("admin"); + await request(app).get("/admin/queues").set("Authorization", token).expect(200); + }); + + test("a suspended account cannot use an otherwise valid access token", async () => { + const token = authenticated("customer"); + db.User.findByPk.mockResolvedValue({ id: "usr-self", accountStatus: "SUSPENDED", tokenVersion: 0 }); + await request(app).get("/api/v1/auth/me").set("Authorization", token).expect(401); + }); }); diff --git a/tests/unit/auth.service.test.js b/tests/unit/auth.service.test.js new file mode 100644 index 0000000..443c008 --- /dev/null +++ b/tests/unit/auth.service.test.js @@ -0,0 +1,31 @@ +const mockDb = { User: { findOne: jest.fn() } }; +const mockCheckPassword = jest.fn(); +const mockCreateChallenge = jest.fn(); +const mockSendOtp = jest.fn(); +jest.mock("../../app/models", () => mockDb); +jest.mock("../../app/utils/hashPassword.util", () => ({ checkPassword: mockCheckPassword })); +jest.mock("../../app/services/auth/otp.service", () => ({ createLoginChallenge: mockCreateChallenge, verifyLoginChallenge: jest.fn() })); +jest.mock("../../app/services/auth/email.service", () => ({ sendLoginOtp: mockSendOtp })); +jest.mock("../../app/utils/idGen.util", () => ({ generateUserId: jest.fn(), generateId: jest.fn() })); +jest.mock("../../app/services/auth/session.service", () => ({ createSession: jest.fn(), rotateSession: jest.fn() })); +jest.mock("../../app/services/auth/oauth.service", () => ({ verifyGoogleToken: jest.fn(), verifyAppleToken: jest.fn() })); +const { beginPasswordLogin } = require("../../app/services/auth/auth.service"); +const req = { get: jest.fn(), ip: "127.0.0.1" }; + +describe("password login boundary", () => { + beforeEach(() => { jest.clearAllMocks(); mockCheckPassword.mockResolvedValue(true); }); + test.each(["PENDING_VERIFICATION", "SUSPENDED", "DEACTIVATED"])("rejects %s accounts", async (status) => { + mockDb.User.findOne.mockResolvedValue({ id: "usr", password: "hash", accountStatus: status, accountType: "customer" }); + await expect(beginPasswordLogin({ email: "x@example.com", password: "secret", rememberMe: false }, req)).rejects.toMatchObject({ code: "ACCOUNT_NOT_ACTIVE" }); + }); + test("uses a generic error for missing users and wrong passwords", async () => { + mockDb.User.findOne.mockResolvedValue(null); + await expect(beginPasswordLogin({ email: "x@example.com", password: "wrong" }, req)).rejects.toMatchObject({ code: "INVALID_CREDENTIALS" }); + }); + test("creates an OTP challenge but no session for valid credentials", async () => { + const user = { id: "usr", password: "hash", accountStatus: "ACTIVE", accountType: "customer" }; + mockDb.User.findOne.mockResolvedValue(user); mockCreateChallenge.mockResolvedValue({ challengeId: "challenge", otp: "123456" }); + await expect(beginPasswordLogin({ email: "x@example.com", password: "secret", rememberMe: true }, req)).resolves.toEqual({ challengeId: "challenge" }); + expect(mockSendOtp).toHaveBeenCalledWith(user, "123456"); + }); +}); diff --git a/tests/unit/jwt.util.test.js b/tests/unit/jwt.util.test.js new file mode 100644 index 0000000..7885554 --- /dev/null +++ b/tests/unit/jwt.util.test.js @@ -0,0 +1,22 @@ +const jwt = require("jsonwebtoken"); +const { generateToken, verifyToken } = require("../../app/utils/jwt.util"); + +describe("access JWT", () => { + test("contains only session security claims and validates issuer/audience", () => { + const token = generateToken({ userId: "usr-1", sessionId: "sid-1", tokenVersion: 3 }); + const payload = verifyToken(token); + expect(payload).toMatchObject({ sub: "usr-1", sid: "sid-1", tokenVersion: 3, iss: "zumri-api", aud: "zumri-clients" }); + expect(payload.password).toBeUndefined(); + }); + + test("rejects the wrong issuer and audience", () => { + const token = jwt.sign({ sid: "sid", tokenVersion: 0 }, process.env.JWT_SECRET, { algorithm: "HS256", subject: "usr", issuer: "attacker", audience: "wrong", expiresIn: "1m" }); + expect(() => verifyToken(token)).toThrow(); + }); + + test("rejects expired and malformed tokens", () => { + const expired = jwt.sign({ sid: "sid", tokenVersion: 0 }, process.env.JWT_SECRET, { algorithm: "HS256", subject: "usr", issuer: "zumri-api", audience: "zumri-clients", expiresIn: -1 }); + expect(() => verifyToken(expired)).toThrow(); + expect(() => verifyToken("not-a-token")).toThrow(); + }); +}); diff --git a/tests/unit/oauth.service.test.js b/tests/unit/oauth.service.test.js new file mode 100644 index 0000000..089f38f --- /dev/null +++ b/tests/unit/oauth.service.test.js @@ -0,0 +1,22 @@ +const crypto = require("crypto"); +const jwt = require("jsonwebtoken"); +const mockVerifyIdToken = jest.fn(); +jest.mock("google-auth-library", () => ({ OAuth2Client: jest.fn(() => ({ verifyIdToken: mockVerifyIdToken })) })); +const { verifyGoogleToken, verifyAppleToken } = require("../../app/services/auth/oauth.service"); + +describe("OAuth verifier adapters", () => { + test("uses Google's verified stable subject", async () => { + process.env.GOOGLE_CLIENT_ID = "google-client"; + mockVerifyIdToken.mockResolvedValue({ getPayload: () => ({ sub: "google-subject", email: "USER@EXAMPLE.COM", email_verified: true, given_name: "Test", family_name: "User" }) }); + await expect(verifyGoogleToken("signed-google-id-token")).resolves.toMatchObject({ subject: "google-subject", email: "user@example.com", emailVerified: true }); + }); + + test("verifies Apple signature, issuer, audience and subject using JWKS", async () => { + process.env.APPLE_CLIENT_ID = "apple-client"; + const { privateKey, publicKey } = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 }); + const jwk = publicKey.export({ format: "jwk" }); Object.assign(jwk, { kid: "test-key", alg: "RS256", use: "sig" }); + global.fetch = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ keys: [jwk] }) }); + const token = jwt.sign({ email: "apple@example.com", email_verified: "true" }, privateKey, { algorithm: "RS256", keyid: "test-key", subject: "apple-subject", issuer: "https://appleid.apple.com", audience: "apple-client", expiresIn: "5m" }); + await expect(verifyAppleToken(token)).resolves.toMatchObject({ subject: "apple-subject", emailVerified: true }); + }); +}); diff --git a/tests/unit/otp.service.test.js b/tests/unit/otp.service.test.js new file mode 100644 index 0000000..13f1d22 --- /dev/null +++ b/tests/unit/otp.service.test.js @@ -0,0 +1,23 @@ +const mockRedis = { set: jest.fn(), eval: jest.fn() }; +jest.mock("../../app/config/redisClient", () => mockRedis); +const { generateOtp, otpHash, createLoginChallenge, verifyLoginChallenge } = require("../../app/services/auth/otp.service"); + +describe("login OTP service", () => { + beforeEach(() => jest.clearAllMocks()); + test("generates six numeric digits cryptographically", () => expect(generateOtp()).toMatch(/^\d{6}$/)); + test("stores only an OTP hash with TTL", async () => { + const result = await createLoginChallenge({ userId: "usr-1", rememberMe: true, context: {} }); + const stored = JSON.parse(mockRedis.set.mock.calls[0][1]); + expect(stored.otpHash).toHaveLength(64); + expect(stored.otp).toBeUndefined(); + expect(mockRedis.set.mock.calls[0]).toEqual(expect.arrayContaining(["EX", 900])); + expect(result.otp).toMatch(/^\d{6}$/); + expect(stored.otpHash).toBe(otpHash(result.challengeId, result.otp)); + }); + test("maps invalid, exhausted, and expired challenges to a generic failure", async () => { + for (const code of [-1, -2, -3]) { + mockRedis.eval.mockResolvedValueOnce([code]); + await expect(verifyLoginChallenge("00000000-0000-4000-8000-000000000000", "000000")).rejects.toMatchObject({ code: "INVALID_OTP", status: 401 }); + } + }); +}); diff --git a/tests/unit/password-policy.test.js b/tests/unit/password-policy.test.js new file mode 100644 index 0000000..cf4ca84 --- /dev/null +++ b/tests/unit/password-policy.test.js @@ -0,0 +1,10 @@ +const { passwordSchema } = require("../../app/validation/auth.schemas"); + +describe("password policy", () => { + test("requires length, case, symbol, and four digits", () => { + expect(passwordSchema.safeParse("Strong!1234x").success).toBe(true); + for (const invalid of ["short!1234A", "lowercase!1234", "UPPERCASE!1234", "NoSymbol1234x", "Strong!12xx"]) { + expect(passwordSchema.safeParse(invalid).success).toBe(false); + } + }); +}); diff --git a/tests/unit/session.service.test.js b/tests/unit/session.service.test.js new file mode 100644 index 0000000..e9457cb --- /dev/null +++ b/tests/unit/session.service.test.js @@ -0,0 +1,37 @@ +const mockTransaction = { LOCK: { UPDATE: "UPDATE" } }; +const mockDb = { + AuthSession: { create: jest.fn(), findByPk: jest.fn(), update: jest.fn() }, + User: { findByPk: jest.fn() }, + sequelize: { transaction: jest.fn((callback) => callback(mockTransaction)) }, +}; +jest.mock("../../app/models", () => mockDb); +const service = require("../../app/services/auth/session.service"); + +describe("durable refresh sessions", () => { + beforeEach(() => jest.clearAllMocks()); + test("stores a hash and never the raw refresh token", async () => { + mockDb.AuthSession.create.mockImplementation(async (values) => ({ ...values })); + const result = await service.createSession({ user: { id: "usr", tokenVersion: 2 }, rememberMe: false }); + expect(result.refreshToken).toContain(`${result.session.id}.`); + expect(result.session.refresh_token_hash).toBe(service.hashRefreshToken(result.refreshToken)); + expect(JSON.stringify(result.session)).not.toContain(result.refreshToken); + }); + test("rotates once and marks the previous session replaced", async () => { + const token = "11111111-1111-4111-8111-111111111111.secret"; + const old = { id: token.split(".")[0], user_id: "usr", token_family_id: "22222222-2222-4222-8222-222222222222", refresh_token_hash: service.hashRefreshToken(token), remember_me: false, token_version: 1, expires_at: new Date(Date.now() + 10000), revoked_at: null, save: jest.fn() }; + const user = { id: "usr", accountStatus: "ACTIVE", tokenVersion: 1 }; + mockDb.AuthSession.findByPk.mockResolvedValue(old); mockDb.User.findByPk.mockResolvedValue(user); + mockDb.AuthSession.create.mockImplementation(async (values) => ({ ...values })); + const result = await service.rotateSession(token); + expect(result.refreshToken).not.toBe(token); + expect(old.revoked_reason).toBe("ROTATED"); + expect(old.replaced_by_session_id).toBe(result.session.id); + }); + test("replay of a rotated token revokes its family", async () => { + const token = "id.old-token"; + const old = { id: "id", token_family_id: "family", revoked_at: new Date(), revoked_reason: "ROTATED", refresh_token_hash: service.hashRefreshToken(token) }; + mockDb.AuthSession.findByPk.mockResolvedValue(old); mockDb.AuthSession.update.mockResolvedValue([1]); + await expect(service.rotateSession(token)).rejects.toMatchObject({ code: "REFRESH_TOKEN_REUSE" }); + expect(mockDb.AuthSession.update).toHaveBeenCalledWith(expect.objectContaining({ revoked_reason: "REFRESH_TOKEN_REUSE" }), expect.objectContaining({ where: expect.objectContaining({ token_family_id: "family" }) })); + }); +}); From b6b345f245dea8424b2b35d9bb62ef1bc3be319c Mon Sep 17 00:00:00 2001 From: Sathira Sri Sathara Date: Thu, 3 Sep 2026 14:22:34 +0530 Subject: [PATCH 13/16] feat: Implement Phase 2 cross-cutting services with email and notification enhancements - Refactor email verification and password reset utilities to use new email service. - Introduce email delivery queue and notification delivery model for better tracking. - Enhance file validation and storage services for improved security and ownership management. - Add cron job for cleaning inactive notifications with retention policy. - Update document worker to handle document generation and storage more efficiently. - Implement logging improvements in activity and log workers. - Create comprehensive documentation for new API endpoints and services. - Add unit tests for file validation and notification policies to ensure robustness. --- .env.sample | 10 + Documentation/API_CROSS_CUTTING_SERVICES.md | 31 ++ Documentation/CURRENT_BACKEND_STATUS.md | 8 + .../PHASE_2_CROSS_CUTTING_SERVICES.md | 93 ++++ app/config/bullBoard.config.js | 3 +- app/config/env.config.js | 7 + app/config/queue.lifecycle.js | 3 +- app/config/s3.config.js | 2 + app/constants/permissions.js | 6 +- .../generateDocument.controller.js | 432 +----------------- app/controllers/notification.controller.js | 150 +----- app/controllers/profile.controller.js | 10 +- app/controllers/upload.controller.js | 159 ++----- app/logic/documents/registry.js | 6 +- app/middleware/docsSession.middleware.js | 27 +- app/middleware/upload.middleware.js | 19 +- app/models/activities/userActivities.model.js | 9 +- app/models/document/document.model.js | 12 +- app/models/document/documentType.model.js | 4 +- app/models/index.js | 1 + .../notificationDelivery.model.js | 11 + .../notification/userNotification.model.js | 2 + app/models/upload/upload.model.js | 9 + app/queues/activity.queue.js | 3 +- app/queues/document.queue.js | 2 +- app/queues/email.queue.js | 3 + app/queues/log.queue.js | 4 +- app/routes/activity.routes.js | 4 +- app/routes/docs.routes.js | 27 +- app/routes/document.routes.js | 106 +---- app/routes/notification.routes.js | 80 +--- app/routes/profile.routes.js | 10 + app/routes/upload.routes.js | 5 +- app/services/activity.service.js | 24 +- app/services/auth/email.service.js | 6 +- app/services/email/email.service.js | 15 + .../notification-policy.service.js | 7 + .../storage/file-validation.service.js | 19 + app/services/storage/storage.service.js | 19 + app/utils/consoleLog.utill.js | 6 +- app/utils/documentJob.util.js | 2 +- app/utils/emailVerification.util.js | 17 +- app/utils/mail.util.js | 6 +- app/utils/passwordReset.utill.js | 36 +- app/utils/s3Upload.utill.js | 88 +--- app/workers/activity.worker.js | 3 +- app/workers/document.worker.js | 114 +---- app/workers/email.worker.js | 17 + app/workers/index.js | 3 +- app/workers/log.worker.js | 6 +- cron/notificationCleaning.cron.js | 111 +---- ...03020000-phase-2-cross-cutting-services.js | 43 ++ tests/unit/cross-cutting-services.test.js | 20 + tests/unit/storage.service.test.js | 21 + 54 files changed, 593 insertions(+), 1248 deletions(-) create mode 100644 Documentation/API_CROSS_CUTTING_SERVICES.md create mode 100644 Documentation/PHASE_2_CROSS_CUTTING_SERVICES.md create mode 100644 app/models/notification/notificationDelivery.model.js create mode 100644 app/queues/email.queue.js create mode 100644 app/services/email/email.service.js create mode 100644 app/services/notification/notification-policy.service.js create mode 100644 app/services/storage/file-validation.service.js create mode 100644 app/services/storage/storage.service.js create mode 100644 app/workers/email.worker.js create mode 100644 migrations/20260903020000-phase-2-cross-cutting-services.js create mode 100644 tests/unit/cross-cutting-services.test.js create mode 100644 tests/unit/storage.service.test.js diff --git a/.env.sample b/.env.sample index 685dfc6..0a38a8b 100644 --- a/.env.sample +++ b/.env.sample @@ -52,6 +52,16 @@ AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_REGION= AWS_S3_BUCKET_NAME= +S3_ENDPOINT= +S3_FORCE_PATH_STYLE=false +S3_SIGNED_URL_TTL_SECONDS=900 +S3_MAX_UPLOAD_BYTES=5242880 + +# Cross-cutting worker and retention settings +EMAIL_QUEUE_CONCURRENCY=5 +DOCUMENT_QUEUE_CONCURRENCY=2 +NOTIFICATION_RETENTION_DAYS=90 +LOG_RETENTION_DAYS=30 # Optional documentation login DOCS_USER= diff --git a/Documentation/API_CROSS_CUTTING_SERVICES.md b/Documentation/API_CROSS_CUTTING_SERVICES.md new file mode 100644 index 0000000..b5d3d43 --- /dev/null +++ b/Documentation/API_CROSS_CUTTING_SERVICES.md @@ -0,0 +1,31 @@ +# ZUMRI Cross-Cutting Services API + +All paths also exist below `/api`; clients should use `/api/v1`. Protected routes accept the Phase 1 access cookie or bearer token. Examples use placeholders and never expose storage keys. + +## Media + +- `POST /api/v1/upload` — multipart field `file`, optional text field `use_for`; creates a private owner-bound upload. +- `GET /api/v1/upload/signed-url/:id` — returns `{ id, url, expiresIn }` after ownership/permission checks. +- `DELETE /api/v1/upload/:id` — marks an owned/authorized upload deleted and removes its object best-effort. +- `GET /api/v1/profile/me/avatar` and `/background` — signed self profile media access. Legacy owner-checked ID routes remain. + +## Notifications + +- `POST /api/v1/notification` — `notifications.manage`; body includes headline, description, `USER|ANNOUNCEMENT`, and `userIds` for USER messages. +- `GET /api/v1/notification/announcements` +- `GET /api/v1/notification/me` +- `PATCH /api/v1/notification/:notificationId/read` +- `PATCH /api/v1/notification/read-all` + +## Documents + +- `GET /api/v1/document/types` +- `GET /api/v1/document/saved` +- `POST /api/v1/document/draft` +- `POST /api/v1/document/generate` with `{ "document":"", "documentType":"pdf", "documentData":{} }`; returns HTTP 202 and persisted status. +- `GET /api/v1/document/jobs/:jobId` +- `GET /api/v1/document/:docId` +- `GET /api/v1/document/:docId/download` — returns a short-lived URL; it does not delete the artifact. +- `DELETE /api/v1/document/job/:jobId` + +The legacy reference-number GET returns 410 because reads must not consume sequences. References are assigned as part of resource creation. diff --git a/Documentation/CURRENT_BACKEND_STATUS.md b/Documentation/CURRENT_BACKEND_STATUS.md index cb6d00c..f3bc3b4 100644 --- a/Documentation/CURRENT_BACKEND_STATUS.md +++ b/Documentation/CURRENT_BACKEND_STATUS.md @@ -1,5 +1,13 @@ # ZUMRI Current Backend Status +## Phase 2 Completion Update + +Completion date: 2026-09-03. Phase 2 hardens the existing shared-service foundation without adding commerce domains. Module 14 (notifications) is now approximately 72%; Module 19 (file/media) 82%; Module 20 (audit/config/logging) 68%; and Module 21 (background jobs) 78%. The document subsystem is approximately 82%. + +Storage now has a reusable S3/S3-compatible boundary, actual-content validation, controlled keys, checksums, owner/status metadata, compensation, and authorization-safe signed URLs. Email is routed through BullMQ with delivery status and final-failure persistence. Notifications have fixed aliases, unique assignment migration, self-only inbox/read operations, announcement support, and preference policy. Queue defaults, idempotent job IDs, Bull Board registration, safe failure handling, stronger append-only activity records, and structured redacted logs are in place. Documents now have ownership, controlled registry validation, persisted generation lifecycle, safe status, and non-destructive signed download. + +The full mocked suite contains 10 suites/40 tests and passes; syntax checks cover 153 JavaScript files. The new migration was not executed. Remaining work is staging migration/data pre-checks plus real MySQL, Redis, S3-compatible, SMTP, PDF/browser, retention-volume, and concurrency validation. After those operational checks and permission seeding, it is safe to begin Phase 3. See `Documentation/PHASE_2_CROSS_CUTTING_SERVICES.md` and `Documentation/API_CROSS_CUTTING_SERVICES.md`. + Audit date: 2026-09-03 Scope: repository source, configuration, lockfile, existing documentation, safe syntax/test/dependency checks. No database, Redis, S3, email, or other external service was mutated. diff --git a/Documentation/PHASE_2_CROSS_CUTTING_SERVICES.md b/Documentation/PHASE_2_CROSS_CUTTING_SERVICES.md new file mode 100644 index 0000000..2f13a5b --- /dev/null +++ b/Documentation/PHASE_2_CROSS_CUTTING_SERVICES.md @@ -0,0 +1,93 @@ +# ZUMRI Phase 2 Cross-Cutting Services + +## Objective + +Harden the shared storage, messaging, queue, audit, logging, and document infrastructure without starting commerce modules. + +## Existing Components Reused + +The existing AWS SDK client, Upload/Notification/UserNotification/Document models, Redis/BullMQ topology, generators, templates, reference utility, workers, cron lifecycle, Phase 0 operations, and Phase 1 identity/RBAC remain the foundation. + +## Storage Architecture + +`storage.service.js` is the sole AWS SDK boundary. It supports AWS S3 and endpoint/path-style compatible providers, buffer upload, delete, HEAD existence checks, and signed downloads. Objects remain private. + +## Upload Security + +Multer performs an early allowlist/size check. The service then rejects empty content and validates JPEG, PNG, WebP, PDF, and XLSX magic bytes against the claimed MIME. It derives the extension, sanitizes display filenames, and stores SHA-256 checksums. Object keys contain a controlled owner identifier, UTC year/month, UUID, and detected extension; original filenames and personal data are excluded. + +## File Ownership + +Uploads record uploader, owner type/id, purpose, visibility, and lifecycle status. Signed URL and deletion endpoints load metadata and enforce owner or explicit permission access. DB persistence failure after upload triggers best-effort object deletion. Deletion marks metadata DELETED before object removal. + +## Signed URL Policy + +Downloads use `S3_SIGNED_URL_TTL_SECONDS` (default 900 seconds). URLs are generated after each authorization decision and are not globally cached or exposed with bucket/key details. + +## Email Architecture + +Auth email enters `email.service.js`, creates a minimal delivery record, and queues a template-keyed job. The worker alone calls Nodemailer. OTPs/links may exist transiently in job data, so jobs have aggressive completion retention and payloads must never be logged. + +## Email Retry Strategy + +Email uses five exponential attempts. Envelope/message and SMTP 5xx failures are treated as permanent; transient provider/network errors retry. Final state is persisted without storing message bodies or variables. + +## Notification Architecture + +Notification aliases are explicit. Admin publication can assign one or many users atomically; announcements need no join rows. Self-service listing/read/read-all always derives the user from the access token. + +## Notification Preferences + +The central policy permits mandatory login OTP, password reset/change, and email verification even when marketing notifications are disabled. Optional external-channel messages honor profile preferences. In-app messages remain available. + +## Queue Architecture + +Activity, log, document, and email queues define retry, backoff, success retention, and failure retention appropriate to each workload. Bull Board includes all four and retains Phase 0 admin protection. + +## Idempotency + +Activity uses an event ID, email uses event/delivery ID, and documents use the generation record ID as BullMQ job ID. Workers check persisted state where duplicate execution could create a second artifact. + +## Failed Job Handling + +Email and document final failures update their associated database record with a bounded error code and timestamp. Stack traces and job payloads are not returned by APIs. + +## Audit Logging + +Activity events now support event ID, nullable actor, target, action/type, request/IP/user-agent context, sanitized JSON metadata, and occurrence time. APIs expose read operations only; inserts are idempotent by event ID. + +## Logging Security + +Queued file logs are JSON lines. Error stacks, request bodies, Authorization/Cookie values, and fields named like passwords, OTPs, tokens, or secrets are excluded/redacted. Log jobs have bounded retention; `LOG_RETENTION_DAYS` documents the intended operational file-retention window. + +## Document Generation Lifecycle + +Generation validates a controlled registry and PDF/XLSX format before queueing, creates an owner-bound record, and moves through QUEUED, PROCESSING, COMPLETED, or FAILED. Successful output becomes an owned Upload. Downloads create a signed URL and never delete the object. + +## Document Ownership + +Creator and owner may view status/data/download. SUPER_ADMIN bypasses; other administrative access requires the appropriate document permission. Cancellation follows the same ownership boundary. + +## Migrations + +`20260903020000-phase-2-cross-cutting-services.js` is new and forward-only. Before execution, back up and test a restored database. Pre-check duplicate `(user_id, notification_id)`, duplicate activity event IDs, duplicate document job IDs, duplicate document type names, and legacy uploads/documents without resolvable owners. Resolve duplicates explicitly; the migration intentionally does not delete data. + +## Permissions + +Shared names are `media.read/upload/delete`, `documents.read/create/delete`, `notifications.manage/read`, `audit.read`, and `queues.read`. SUPER_ADMIN retains the Phase 1 bypass. Production permission rows/grants must be seeded through the environment's controlled authorization process. + +## Environment Variables + +Added: `S3_ENDPOINT`, `S3_FORCE_PATH_STYLE`, `S3_SIGNED_URL_TTL_SECONDS`, `S3_MAX_UPLOAD_BYTES`, `EMAIL_QUEUE_CONCURRENCY`, `DOCUMENT_QUEUE_CONCURRENCY`, `NOTIFICATION_RETENTION_DAYS`, and `LOG_RETENTION_DAYS`. Optional integrations remain optional unless enabled. + +## Tests + +Unit coverage verifies magic bytes, mismatch/empty rejection, checksums, safe keys, HTML escaping, security-notification preference policy, and email queue retry/retention. Existing Phase 0/1 tests remain in the full suite. AWS, SMTP, Redis, and MySQL are not contacted. + +## Remaining Known Issues + +The migration has not been run against staging data. Real S3-compatible provider, SMTP, MySQL migration, Redis concurrency, large notification retention, and actual PDF/browser generation need staging validation. File-log deletion/rotation still belongs to deployment logrotate or a future controlled maintenance worker. Push delivery is intentionally an architecture placeholder only. + +## Phase 3 Prerequisites + +Complete the documented data pre-checks, apply all migrations to a restored database, seed shared permissions, run API and worker processes against staging Redis/MySQL, and exercise one upload/email/document lifecycle with non-production provider credentials. diff --git a/app/config/bullBoard.config.js b/app/config/bullBoard.config.js index fbcec22..6f2e0cb 100644 --- a/app/config/bullBoard.config.js +++ b/app/config/bullBoard.config.js @@ -16,13 +16,14 @@ const { BullMQAdapter } = require("@bull-board/api/bullMQAdapter"); const activityQueue = require("../queues/activity.queue"); const documentQueue = require("../queues/document.queue"); const logQueue = require("../queues/log.queue"); +const emailQueue = require("../queues/email.queue"); const serverAdapter = new ExpressAdapter(); serverAdapter.setBasePath("/admin/queues"); const { addQueue, removeQueue, setQueues, replaceQueues } = createBullBoard({ - queues: [activityQueue, documentQueue, logQueue].map((queue) => new BullMQAdapter(queue)), + queues: [activityQueue, documentQueue, logQueue, emailQueue].map((queue) => new BullMQAdapter(queue)), serverAdapter, }); diff --git a/app/config/env.config.js b/app/config/env.config.js index 8e708d4..369c22c 100644 --- a/app/config/env.config.js +++ b/app/config/env.config.js @@ -38,6 +38,13 @@ const envSchema = z.object({ MAIL_PASS: z.string().optional(), MAIL_FROM: z.string().optional(), AWS_REGION: z.string().optional(), AWS_ACCESS_KEY_ID: z.string().optional(), AWS_SECRET_ACCESS_KEY: z.string().optional(), AWS_S3_BUCKET_NAME: z.string().optional(), + S3_ENDPOINT: z.string().url().optional(), S3_FORCE_PATH_STYLE: booleanString, + S3_SIGNED_URL_TTL_SECONDS: z.coerce.number().int().min(60).max(86400).default(900), + S3_MAX_UPLOAD_BYTES: z.coerce.number().int().positive().default(5242880), + EMAIL_QUEUE_CONCURRENCY: z.coerce.number().int().positive().default(5), + DOCUMENT_QUEUE_CONCURRENCY: z.coerce.number().int().positive().default(2), + NOTIFICATION_RETENTION_DAYS: z.coerce.number().int().positive().default(90), + LOG_RETENTION_DAYS: z.coerce.number().int().positive().default(30), DOCS_USER: z.string().optional(), DOCS_PASS: z.string().optional(), GOOGLE_CLIENT_ID: z.string().optional(), APPLE_CLIENT_ID: z.string().optional(), }).superRefine((env, context) => { diff --git a/app/config/queue.lifecycle.js b/app/config/queue.lifecycle.js index 1f22616..e9aa1b5 100644 --- a/app/config/queue.lifecycle.js +++ b/app/config/queue.lifecycle.js @@ -1,9 +1,10 @@ const activityQueue = require("../queues/activity.queue"); const documentQueue = require("../queues/document.queue"); const logQueue = require("../queues/log.queue"); +const emailQueue = require("../queues/email.queue"); const closeQueues = async () => { - await Promise.allSettled([activityQueue.close(), documentQueue.close(), logQueue.close()]); + await Promise.allSettled([activityQueue.close(), documentQueue.close(), logQueue.close(), emailQueue.close()]); }; module.exports = { closeQueues }; diff --git a/app/config/s3.config.js b/app/config/s3.config.js index 7ec7341..ce81fa5 100644 --- a/app/config/s3.config.js +++ b/app/config/s3.config.js @@ -7,5 +7,7 @@ module.exports = process.env.ENABLE_S3 === "true" accessKeyId: process.env.AWS_ACCESS_KEY_ID, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, }, + ...(process.env.S3_ENDPOINT ? { endpoint: process.env.S3_ENDPOINT } : {}), + forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true", }) : { send: async () => { throw new Error("S3 functionality is not enabled"); } }; diff --git a/app/constants/permissions.js b/app/constants/permissions.js index 9879db7..e7ca20d 100644 --- a/app/constants/permissions.js +++ b/app/constants/permissions.js @@ -13,4 +13,8 @@ module.exports = { FINANCE_BASIS: "finance.basis", PRECOST: "precost.precost", -}; \ No newline at end of file + MEDIA_READ: "media.read", MEDIA_UPLOAD: "media.upload", MEDIA_DELETE: "media.delete", + DOCUMENTS_READ: "documents.read", DOCUMENTS_CREATE: "documents.create", DOCUMENTS_DELETE: "documents.delete", + NOTIFICATIONS_MANAGE: "notifications.manage", NOTIFICATIONS_READ: "notifications.read", + AUDIT_READ: "audit.read", QUEUES_READ: "queues.read", +}; diff --git a/app/controllers/generateDocument.controller.js b/app/controllers/generateDocument.controller.js index 4f69daf..ae0301b 100644 --- a/app/controllers/generateDocument.controller.js +++ b/app/controllers/generateDocument.controller.js @@ -1,417 +1,21 @@ -/** - * Copyright (c) 2026 Niolla - * All rights reserved. - * - * This source code is proprietary and confidential. - * Unauthorized copying, modification, distribution, or use - * of this file, via any medium, is strictly prohibited. - */ - -// app/controllers/generateDocument.controller.js - -const fs = require("fs"); -const path = require("path"); -const documentQueue = require("../queues/document.queue"); -const { createDocumentData } = require("../utils/document.utill"); -const { - generateId, - generateDocumentReferenceNo, -} = require("../utils/idGen.util"); - -const { GetObjectCommand, DeleteObjectCommand } = require("@aws-sdk/client-s3"); - -const {log} = require("../utils/consoleLog.utill"); - -const s3 = require("../config/s3.config"); +const crypto = require("crypto"); +const { z } = require("zod"); const db = require("../models"); -const Document = db.Document; -const DocumentType = db.DocumentType; +const documentQueue = require("../queues/document.queue"); +const registry = require("../logic/documents/registry"); +const storage = require("../services/storage/storage.service"); -const normalizeDocumentData = (value) => { - if (!value) { - return {}; - } +const requestSchema = z.object({ document: z.string().min(1).max(64), documentType: z.enum(["pdf", "excel"]), documentData: z.record(z.string(), z.unknown()).optional(), data: z.record(z.string(), z.unknown()).optional() }).passthrough(); +const normalize = (value) => String(value).toLowerCase().replace(/[\s_-]+/g, ""); +const allowed = (user, doc, permission = "documents.read") => doc.owner_id === user.id || doc.created_by === user.id || user.accountType === "super_admin" || (user.permissions || []).includes(permission); +const publicDoc = (doc) => ({ id: doc.doc_id, referenceNo: doc.reference_no, documentType: doc.doc_type, status: doc.status, jobId: doc.job_id, generatedAt: doc.generated_at, failedAt: doc.failed_at, failureCode: doc.failure_code, createdAt: doc.createdAt }); - if (typeof value === "string") { - try { - return JSON.parse(value); - } catch (error) { - return {}; - } - } - - return value; -}; - -/** - * Get available document types - */ -exports.getAvailableDocumentTypes = async (req, res) => { - try { - - const doc_types = await DocumentType.findAll({ - attributes: ["doc_type_name"], - }); - - const types = doc_types.map((t) => t.doc_type_name); - - return res.json({ - success: true, - availableDocumentTypes: types, - note: "Use these document type names in your requests (case-insensitive)", - }); - - } catch (error) { - log("Error fetching document types:", error.message); - return res.status(500).json({ - success: false, - message: error.message, - }); - } -}; - -// Get Saved documents -exports.getSavedDocuments = async (req, res) => { - try { - const { documentType } = req.body; - - if (!documentType) { - return res.status(400).json({ - success: false, - message: "documentType query parameter is required", - }); - } - - // Fetch saved documents based on documentType - const savedDocuments = await Document.findAll({ - where: { doc_type: documentType.toUpperCase() }, - order: [["createdAt", "DESC"]], - exclude: ["data"], - }); - - // extract only necessary fields to return - const formattedDocuments = savedDocuments.map((doc) => ({ - doc_id: doc.doc_id, - reference_no: doc.reference_no, - doc_type: doc.doc_type, - status: doc.status, - createdAt: doc.createdAt, - updatedAt: doc.updatedAt, - })); - - return res.json({ - success: true, - savedDocuments: formattedDocuments, - }); - } catch (error) { - log("Error fetching saved documents:", error.message); - return res.status(500).json({ - success: false, - message: error.message, - }); - } -}; - -// Get specific document data by doc_id -exports.getDocumentData = async (req, res) => { - try { - const { docId } = req.params; - - if (!docId) { - return res.status(400).json({ - success: false, - message: "docId parameter is required", - }); - } - - const document = await Document.findOne({ - where: { doc_id: docId }, - }); - - if (!document) { - return res.status(404).json({ - success: false, - message: "Document not found", - }); - } - - return res.json({ - success: true, - document: { - doc_id: document.doc_id, - reference_no: document.reference_no, - doc_type: document.doc_type, - data: document.data, - status: document.status, - createdAt: document.createdAt, - updatedAt: document.updatedAt, - }, - }); - - } catch (error) { - log("Error fetching document data:", error.message); - return res.status(500).json({ - success: false, - message: error.message, - }); - } -} - -exports.generateReferenceNo = async (req, res) => { - try { - const { documentType } = req.params; - - if (!documentType) { - return res.status(400).json({ - success: false, - message: "documentType is required", - }); - } - - // Generate reference number - const reference_no = await generateDocumentReferenceNo(documentType); - - return res.json({ - success: true, - reference_no, - }); - } catch (error) { - log("Error generating reference number:", error.message); - return res.status(500).json({ - success: false, - message: error.message, - }); - } -}; - -exports.generateDraftDocument = async (req, res) => { - try { - const { document } = req.body; - const documentData = normalizeDocumentData(req.body.documentData || req.body.data || req.body); - - // Validation - if (!document || !documentData) { - return res.status(400).json({ - success: false, - message: "document and documentData are required", - }); - } - - let documentDetails; - - if (document !== "PRECOST") { - // Save document details before generating - documentDetails = await createDocumentData( - document, - documentData, - "DRAFT", - ); - } else { - return res.status(400).json({ - success: false, - message: - "Invalid document type, This document type is not allowed to be generated as draft", - }); - } - - return res.status(201).json({ - success: true, - message: "Draft document created successfully", - documentDetails, - }); - } catch (error) { - log("Error generating draft document:", error.message); - return res.status(500).json({ - success: false, - message: error.message, - }); - } -}; - -/** - * Generate document asynchronously - * Returns jobId immediately - */ -exports.generateDocument = async (req, res) => { - try { - const { document, documentType } = req.body; - const documentData = normalizeDocumentData(req.body.documentData || req.body.data || req.body); - - // Validation - if (!document || !documentType || !documentData) { - return res.status(400).json({ - success: false, - message: "document, documentType, and documentData are required", - }); - } - - console.log( - `📨 generateDocument request: document="${document}", documentType="${documentType}"`, - ); - - if (document !== "PRECOST") { - // Save document details before generating - // If doc_id exists, finalize (update existing); otherwise create as DRAFT - const status = documentData.doc_id ? "FINAL" : "DRAFT"; - await createDocumentData(document, documentData, status); - } - - // Add job to queue - const job = await documentQueue.add("generate-document", { - document, - documentType, - data: documentData, - }); - - log( - `📋 Document generation job queued: ${job.id} (document="${document}", type="${documentType}")`, - ); - - return res.status(202).json({ - success: true, - message: "Document generation started", - jobId: job.id, - }); - } catch (error) { - log("Error queuing document generation:", error.message); - return res.status(500).json({ - success: false, - message: error.message, - }); - } -}; - -/** - * Get job status and result - */ -exports.getJobStatus = async (req, res) => { - try { - const { jobId } = req.params; - - if (!jobId) { - return res.status(400).json({ - success: false, - message: "jobId is required", - }); - } - - // Get job from queue - const job = await documentQueue.getJob(jobId); - - if (!job) { - return res.status(404).json({ - success: false, - message: "Job not found", - }); - } - - // Get job state - const state = await job.getState(); - const result = job.returnvalue; - const failedReason = job.failedReason; - - return res.json({ - success: true, - jobId: job.id, - state, // "waiting" | "active" | "completed" | "failed" | "delayed" - result: state === "completed" ? result : null, - error: state === "failed" ? failedReason : null, - attempts: job.attemptsMade, - stacktrace: job.stacktrace, - }); - } catch (error) { - log("Error fetching job status:", error.message); - return res.status(500).json({ - success: false, - message: error.message, - }); - } -}; - -/** - * Download generated document - */ -exports.downloadDocument = async (req, res) => { - try { - const { uuid } = req.params; - - const key = `uploads/${uuid}.pdf`; - - const command = new GetObjectCommand({ - Bucket: process.env.AWS_S3_BUCKET_NAME, - Key: key, - }); - - const response = await s3.send(command); - - res.setHeader( - "Content-Type", - response.ContentType || "application/octet-stream", - ); - - res.setHeader("Content-Disposition", `attachment; filename="${uuid}.pdf"`); - - response.Body.pipe(res); - - res.on("finish", async () => { - try { - await s3.send( - new DeleteObjectCommand({ - Bucket: process.env.AWS_S3_BUCKET_NAME, - Key: key, - }), - ); - - log(`Deleted from S3: ${key}`); - } catch (err) { - log(`Failed to delete ${key}:`, err.message); - } - }); - } catch (error) { - log("Download error:", error.message); - - return res.status(404).json({ - success: false, - message: "Document not found", - }); - } -}; - -/** - * Cancel/delete a job - */ -exports.cancelJob = async (req, res) => { - try { - const { jobId } = req.params; - - if (!jobId) { - return res.status(400).json({ - success: false, - message: "jobId is required", - }); - } - - const job = await documentQueue.getJob(jobId); - - if (!job) { - return res.status(404).json({ - success: false, - message: "Job not found", - }); - } - - await job.remove(); - - return res.json({ - success: true, - message: "Job cancelled successfully", - jobId, - }); - } catch (error) { - log("Error cancelling job:", error.message); - return res.status(500).json({ - success: false, - message: error.message, - }); - } -}; +exports.getAvailableDocumentTypes = async (_req, res) => res.json({ success: true, data: Object.keys(registry) }); +exports.getSavedDocuments = async (req, res, next) => { try { const docs = await db.Document.findAll({ where: { owner_id: req.user.id }, attributes: { exclude: ["data"] }, order: [["createdAt", "DESC"]] }); return res.json({ success: true, data: docs.map(publicDoc) }); } catch (e) { return next(e); } }; +exports.getDocumentData = async (req, res, next) => { try { const doc = await db.Document.findByPk(req.params.docId); if (!doc) return res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Document not found" } }); if (!allowed(req.user, doc)) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } }); return res.json({ success: true, data: { ...publicDoc(doc), documentData: doc.data } }); } catch (e) { return next(e); } }; +exports.generateReferenceNo = (_req, res) => res.status(410).json({ success: false, error: { code: "DEPRECATED", message: "Reference numbers are assigned during document creation" } }); +exports.generateDraftDocument = async (req, res, next) => { try { const parsed = requestSchema.safeParse({ ...req.body, documentType: req.body.documentType || "pdf" }); if (!parsed.success) return res.status(400).json({ success: false, error: { code: "VALIDATION_ERROR", message: "Invalid document request" } }); const key = normalize(parsed.data.document); if (!registry[key]) return res.status(400).json({ success: false, error: { code: "INVALID_DOCUMENT_TYPE", message: "Unsupported document type" } }); const doc = await db.Document.create({ doc_id: crypto.randomUUID(), reference_no: "N/A", doc_type: key.toUpperCase(), data: parsed.data.documentData || parsed.data.data || {}, status: "DRAFT", created_by: req.user.id, owner_type: "USER", owner_id: req.user.id }); return res.status(201).json({ success: true, data: publicDoc(doc) }); } catch (e) { return next(e); } }; +exports.generateDocument = async (req, res, next) => { try { const parsed = requestSchema.safeParse(req.body); if (!parsed.success) return res.status(400).json({ success: false, error: { code: "VALIDATION_ERROR", message: "Invalid document request", details: parsed.error.issues.map(i => ({ path: i.path.join("."), message: i.message })) } }); const key = normalize(parsed.data.document); const entry = registry[key]; if (!entry || (parsed.data.documentType === "excel" && !entry.excelBuilder)) return res.status(400).json({ success: false, error: { code: "INVALID_DOCUMENT_TYPE", message: "Document generator is unavailable" } }); const id = crypto.randomUUID(); const data = parsed.data.documentData || parsed.data.data || {}; const doc = await db.Document.create({ doc_id: id, reference_no: data.reference_no || "N/A", doc_type: key.toUpperCase(), data, status: "QUEUED", created_by: req.user.id, owner_type: "USER", owner_id: req.user.id, job_id: `document-${id}` }); try { await documentQueue.add("generate-document", { documentId: id, document: key, documentType: parsed.data.documentType, data }, { jobId: `document-${id}` }); } catch (e) { await doc.update({ status: "FAILED", failed_at: new Date(), failure_code: "QUEUE_FAILED" }); throw e; } return res.status(202).json({ success: true, data: publicDoc(doc) }); } catch (e) { return next(e); } }; +exports.getJobStatus = async (req, res, next) => { try { const doc = await db.Document.findOne({ where: { job_id: req.params.jobId } }); if (!doc) return res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Job not found" } }); if (!allowed(req.user, doc)) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } }); return res.json({ success: true, data: publicDoc(doc) }); } catch (e) { return next(e); } }; +exports.downloadDocument = async (req, res, next) => { try { const doc = await db.Document.findByPk(req.params.docId, { include: [{ model: db.Upload, as: "storageUpload" }] }); if (!doc || doc.status !== "COMPLETED" || !doc.storageUpload) return res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Document not available" } }); if (!allowed(req.user, doc)) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } }); const expiresIn = Number(process.env.S3_SIGNED_URL_TTL_SECONDS || 900); return res.json({ success: true, data: { url: await storage.createSignedDownloadUrl(doc.storageUpload.file_path, expiresIn), expiresIn } }); } catch (e) { return next(e); } }; +exports.cancelJob = async (req, res, next) => { try { const doc = await db.Document.findOne({ where: { job_id: req.params.jobId } }); if (!doc) return res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Job not found" } }); if (!allowed(req.user, doc, "documents.delete")) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } }); const job = await documentQueue.getJob(doc.job_id); if (job) await job.remove(); await doc.update({ status: "FAILED", failed_at: new Date(), failure_code: "CANCELLED" }); return res.json({ success: true }); } catch (e) { return next(e); } }; diff --git a/app/controllers/notification.controller.js b/app/controllers/notification.controller.js index 61f0972..f3472d9 100644 --- a/app/controllers/notification.controller.js +++ b/app/controllers/notification.controller.js @@ -1,138 +1,20 @@ -/** - * Copyright (c) 2026 Niolla - * All rights reserved. - * - * This source code is proprietary and confidential. - * Unauthorized copying, modification, distribution, or use - * of this file, via any medium, is strictly prohibited. - */ - -// app/controllers/notification.controller.js - +const crypto = require("crypto"); const db = require("../models"); -const Notification = db.notification; -const UserNotification = db.userNotification; +const { Op } = require("sequelize"); -const log = require("../utils/consoleLog.utill").log; - -// Controller for managing notifications -exports.createNotification = async (req, res) => { +exports.createNotification = async (req, res, next) => { + const { notificationHeadline, notificationDescription, notificationType, userIds = [] } = req.body; + if (!notificationHeadline || !["USER", "ANNOUNCEMENT"].includes(notificationType)) return res.status(400).json({ success: false, error: { code: "VALIDATION_ERROR", message: "Valid headline and notificationType are required" } }); + if (notificationType === "USER" && (!Array.isArray(userIds) || !userIds.length)) return res.status(400).json({ success: false, error: { code: "VALIDATION_ERROR", message: "USER notifications require userIds" } }); + const transaction = await db.sequelize.transaction(); try { - const { notificationHeadline, notificationDescription, notificationType } = - req.body; - - const id = Date.now().toString(); // Generate a unique ID based on the current timestamp - const notification_id = `notif_${id}`; // Prefix the ID with "notif_" - - // Create a new notification - const newNotification = await Notification.create({ - notification_id, - notificationHeadline, - notificationDescription, - notificationType, - dateCreated: new Date(), - }); - - res.status(201).json({ - success: true, - message: "Notification created successfully", - notification: newNotification, - }); - } catch (error) { - log("Error creating notification:", error.message); - res.status(500).json({ - success: false, - message: "Internal server error", - error: error.message - }); - } + const notification = await db.notification.create({ notification_id: `notif_${crypto.randomUUID()}`, notificationHeadline, notificationDescription, notificationType, dateCreated: new Date() }, { transaction }); + const uniqueUsers = [...new Set(userIds)]; + if (uniqueUsers.length) await db.userNotification.bulkCreate(uniqueUsers.map((user_id) => ({ user_id, notification_id: notification.notification_id })), { transaction, ignoreDuplicates: true }); + await transaction.commit(); return res.status(201).json({ success: true, data: { notification, assignedUsers: uniqueUsers.length } }); + } catch (error) { await transaction.rollback(); return next(error); } }; - -// Mark a notification as read for a user -exports.markAsRead = async (req, res) => { - try { - const { userId, notificationId } = req.params; - - // Find the user notification entry - const userNotification = await UserNotification.findOne({ - where: { user_id: userId, notification_id: notificationId }, - }); - - if (!userNotification) { - return res.status(404).json({ success: false, error: "User notification not found" }); - } - - // Mark the notification as read - userNotification.isRead = true; - await userNotification.save(); - res.status(200).json({ - success: true, - message: "Notification marked as read", - }); - } - - catch (error) { - log("Error marking notification as read:", error.message); - res.status(500).json({ - success: false, - message: "Internal server error", - error: error.message - }); - } -}; - -// Get announcements for all users -exports.getAnnouncements = async (req, res) => { - try { - - // Fetch all announcements - const announcements = await Notification.findAll({ - where: { notificationType: "ANNOUNCEMENT", isActive: true }, - }); - - res.status(200).json({ - success: true, - announcements, - }); - - } catch (error) { - log("Error fetching announcements:", error.message); - res.status(500).json({ - success: false, - message: "Internal server error", - error: error.message - }); - } -} - -// Get all notifications for a user -exports.getUserNotifications = async (req, res) => { - try { - const { userId } = req.params; - - // Fetch notifications for the user - const notifications = await UserNotification.findAll({ - where: { user_id: userId, isRead: false }, - include: [ - { - model: Notification, - as: "notification", - }, - ], - }); - - res.status(200).json({ - success: true, - notifications, - }); - } - - catch (error) { - log("Error fetching user notifications:", error.message); - res.status(500).json({ - success: false, - message: "Internal server error", - error: error.message - }); - } -}; \ No newline at end of file +exports.getAnnouncements = async (_req, res, next) => { try { return res.json({ success: true, data: await db.notification.findAll({ where: { notificationType: "ANNOUNCEMENT", isActive: true }, order: [["createdAt", "DESC"]] }) }); } catch (e) { return next(e); } }; +exports.getMyNotifications = async (req, res, next) => { try { const rows = await db.userNotification.findAll({ where: { user_id: req.user.id }, include: [{ model: db.notification, as: "notification", where: { isActive: true } }], order: [["createdAt", "DESC"]] }); return res.json({ success: true, data: rows }); } catch (e) { return next(e); } }; +exports.markAsRead = async (req, res, next) => { try { const [count] = await db.userNotification.update({ isRead: true }, { where: { user_id: req.user.id, notification_id: req.params.notificationId } }); if (!count) return res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Notification not found" } }); return res.json({ success: true }); } catch (e) { return next(e); } }; +exports.markAllAsRead = async (req, res, next) => { try { const [count] = await db.userNotification.update({ isRead: true }, { where: { user_id: req.user.id, isRead: false } }); return res.json({ success: true, data: { updated: count } }); } catch (e) { return next(e); } }; diff --git a/app/controllers/profile.controller.js b/app/controllers/profile.controller.js index b00457a..4960b9c 100644 --- a/app/controllers/profile.controller.js +++ b/app/controllers/profile.controller.js @@ -28,20 +28,21 @@ const { log } = require("../utils/consoleLog.utill"); // Get Profile avatar by user ID exports.getProfileAvatar = async (req, res) => { try { - const userId = req.params.userId; + const userId = req.params.userId || req.user.id; const profile = await Profile.findOne({ where: { user_id: userId } }); if (!profile) { return res.status(404).json({ error: "Profile not found" }); } const uploadRecord = await Upload.findByPk(profile.profilePicture_id); + if (!uploadRecord || uploadRecord.status !== "AVAILABLE" || (uploadRecord.visibility !== "PUBLIC" && uploadRecord.owner_id !== userId)) return res.status(404).json({ success: false, message: "Profile image not found" }); const fileUrl = await getSignedFileUrl(uploadRecord.file_path); res.json({ success: true, data: { - userId: profile.userId, + userId: profile.user_id, profilePictureUrl: fileUrl, }, }); @@ -58,7 +59,7 @@ exports.getProfileAvatar = async (req, res) => { // Get profile background image by user ID exports.getProfileBackgroundImage = async (req, res) => { try { - const userId = req.params.userId; + const userId = req.params.userId || req.user.id; const profile = await Profile.findOne({ where: { user_id: userId } }); if (!profile) { return res @@ -67,13 +68,14 @@ exports.getProfileBackgroundImage = async (req, res) => { } const uploadRecord = await Upload.findByPk(profile.backgroundImage_id); + if (!uploadRecord || uploadRecord.status !== "AVAILABLE" || (uploadRecord.visibility !== "PUBLIC" && uploadRecord.owner_id !== userId)) return res.status(404).json({ success: false, message: "Profile background not found" }); const fileUrl = await getSignedFileUrl(uploadRecord.file_path); res.json({ success: true, data: { - userId: profile.userId, + userId: profile.user_id, backgroundImageUrl: fileUrl, }, }); diff --git a/app/controllers/upload.controller.js b/app/controllers/upload.controller.js index ab93d12..eaebe9d 100644 --- a/app/controllers/upload.controller.js +++ b/app/controllers/upload.controller.js @@ -1,131 +1,44 @@ -/** - * Copyright (c) 2026 Niolla - * All rights reserved. - * - * This source code is proprietary and confidential. - * Unauthorized copying, modification, distribution, or use - * of this file, via any medium, is strictly prohibited. - */ - -// app/controllers/activity.controller.js - -const { uploadToS3, getSignedFileUrl } = require("../utils/s3Upload.utill"); -const { log } = require("../utils/consoleLog.utill"); - const db = require("../models"); -const Upload = db.Upload; -const Assets = db.Assets; +const storage = require("../services/storage/storage.service"); +const { validateUpload } = require("../services/storage/file-validation.service"); -// Constants -const MAX_IMAGE_SIZE = 3 * 1024 * 1024; // 3MB -const MAX_PDF_SIZE = 5 * 1024 * 1024; // 5MB +const canAccess = (user, upload) => upload.visibility === "PUBLIC" || upload.owner_id === user.id || user.accountType === "super_admin" || (user.accountType === "admin" && (user.permissions || []).includes("media.read")); -const isValidFileType = (mimetype) => { - return mimetype.startsWith("image/") || mimetype === "application/pdf"; -}; - -const isValidFileSize = (mimetype, size) => { - if (mimetype.startsWith("image/")) return size <= MAX_IMAGE_SIZE; - if (mimetype === "application/pdf") return size <= MAX_PDF_SIZE; - return false; -}; - -exports.uploadFile = async (req, res) => { +exports.uploadFile = async (req, res, next) => { + let objectKey; try { - // 1. Check file exists - if (!req.file) { - return res.status(400).json({ - success: false, - message: "No file uploaded", - }); - } - - const { mimetype, size, originalname } = req.file; - - // 2. Validate file type - if (!isValidFileType(mimetype)) { - return res.status(400).json({ - success: false, - message: "Only images and PDFs are allowed", - }); - } - - // 3. Validate file size - if (!isValidFileSize(mimetype, size)) { - return res.status(400).json({ - success: false, - message: mimetype.startsWith("image/") - ? "Image too large (max 1MB)" - : "PDF too large (max 5MB)", - }); - } - - log("File validation passed:", { - mimetype, - size, - originalname, - use_for: req.body.use_for, - }); - - // 4. Upload to S3 - const fileKey = await uploadToS3(req.file, req.body.use_for); - - const fileUrl = await getSignedFileUrl(fileKey); - - // 5. Save to DB - const uploadData = { - file_path: fileKey, - file_type: mimetype, - file_size: size, - original_name: originalname, - use_for: req.body.use_for || null, - uploaded_by: req.user?.id || null, - }; - - const newUpload = await Upload.create(uploadData); - - newUpload.dataValues.file_url = fileUrl; // Add URL to response - - // 6. Response - return res.status(201).json({ - success: true, - message: "File uploaded successfully", - data: newUpload, - }); - } catch (error) { - console.error("Upload Controller Error:", error); - - return res.status(500).json({ - success: false, - message: "Failed to upload file", - error: process.env.NODE_ENV === "development" ? error.message : undefined, - }); - } + const details = validateUpload(req.file); + const purpose = String(req.body.use_for || "generic").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 60); + objectKey = storage.createObjectKey({ ownerId: req.user.id, mimeType: details.mimeType, purpose }); + await storage.uploadBuffer({ buffer: req.file.buffer, objectKey, mimeType: details.mimeType, checksum: details.checksum }); + let record; + try { + record = await db.Upload.create({ file_path: objectKey, file_type: details.mimeType, file_size: details.size, original_name: req.file.originalname, safe_name: details.safeName, checksum: details.checksum, use_for: purpose, uploaded_by: req.user.id, owner_type: "USER", owner_id: req.user.id, visibility: "PRIVATE", status: "AVAILABLE" }); + } catch (error) { await storage.deleteObject(objectKey).catch(() => undefined); throw error; } + const expiresIn = Number(process.env.S3_SIGNED_URL_TTL_SECONDS || 900); + return res.status(201).json({ success: true, data: { id: record.id, originalName: record.original_name, mimeType: record.file_type, size: record.file_size, purpose: record.use_for, status: record.status, url: await storage.createSignedDownloadUrl(objectKey, expiresIn), expiresIn } }); + } catch (error) { return next(error); } }; -// Get Uploaded File URL -exports.getFileUrl = async (req, res) => { +exports.getFileUrl = async (req, res, next) => { try { - const { id } = req.params; - - const uploadRecord = await Upload.findByPk(id); - - if (!uploadRecord) { - return res.status(404).json({ - success: false, - message: "File not found", - }); - } - - const fileUrl = await getSignedFileUrl(uploadRecord.file_path); - - return res.status(200).json({ - success: true, - message: "File URL retrieved successfully", - data: { - id: uploadRecord.id, - file_url: fileUrl, - }, - }); - } catch (error) {} + const upload = await db.Upload.findByPk(req.params.id); + if (!upload || upload.status === "DELETED") return res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "File not found" } }); + if (!canAccess(req.user, upload)) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } }); + if (upload.status !== "AVAILABLE") return res.status(409).json({ success: false, error: { code: "FILE_UNAVAILABLE", message: "File is not available" } }); + const expiresIn = Number(process.env.S3_SIGNED_URL_TTL_SECONDS || 900); + return res.json({ success: true, data: { id: upload.id, url: await storage.createSignedDownloadUrl(upload.file_path, expiresIn), expiresIn } }); + } catch (error) { return next(error); } +}; + +exports.deleteFile = async (req, res, next) => { + try { + const upload = await db.Upload.findByPk(req.params.id); + if (!upload || upload.status === "DELETED") return res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "File not found" } }); + const allowed = upload.owner_id === req.user.id || req.user.accountType === "super_admin" || (req.user.permissions || []).includes("media.delete"); + if (!allowed) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } }); + await upload.update({ status: "DELETED", deletedAt: new Date() }); + await storage.deleteObject(upload.file_path).catch(() => undefined); + return res.status(204).end(); + } catch (error) { return next(error); } }; diff --git a/app/logic/documents/registry.js b/app/logic/documents/registry.js index bd2be03..f76c65e 100644 --- a/app/logic/documents/registry.js +++ b/app/logic/documents/registry.js @@ -69,4 +69,8 @@ const registry = { console.log("📋 Registry initialized with keys:", Object.keys(registry)); -module.exports = registry; \ No newline at end of file +for (const [key, entry] of Object.entries(registry)) { + if (typeof entry.pdfTemplate !== "function") delete registry[key]; +} + +module.exports = registry; diff --git a/app/middleware/docsSession.middleware.js b/app/middleware/docsSession.middleware.js index b7d23e8..5e3a2a7 100644 --- a/app/middleware/docsSession.middleware.js +++ b/app/middleware/docsSession.middleware.js @@ -1,21 +1,6 @@ -/** - * Copyright (c) 2026 Niolla - * All rights reserved. - * - * This source code is proprietary and confidential. - * Unauthorized copying, modification, distribution, or use - * of this file, via any medium, is strictly prohibited. - */ - -// app/middleware/docsSession.middleware.js - -module.exports = (req, res, next) => { - if (req.cookies && req.cookies.docsAuth === "true") { - return next(); - } - - return res.status(401).json({ - success: false, - message: "Unauthorized: Please login to access docs", - }); -}; \ No newline at end of file +const { authenticate } = require("./auth.middleware"); +const { ACCOUNT_TYPES } = require("../constants/accountTypes"); +module.exports = (req, res, next) => authenticate(req, res, () => { + if ([ACCOUNT_TYPES.ADMIN, ACCOUNT_TYPES.SUPER_ADMIN].includes(req.user.accountType)) return next(); + return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Administrative access required" } }); +}); diff --git a/app/middleware/upload.middleware.js b/app/middleware/upload.middleware.js index b4dd001..00b22ac 100644 --- a/app/middleware/upload.middleware.js +++ b/app/middleware/upload.middleware.js @@ -16,23 +16,12 @@ const storage = multer.memoryStorage(); // File filter (only images + PDFs) const fileFilter = (req, file, cb) => { - const allowedTypes = [ - "image/", - "application/pdf", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx - "application/vnd.ms-excel" // .xls - ]; - - const isValid = - file.mimetype.startsWith("image/") || - file.mimetype === "application/pdf" || - file.mimetype === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" || - file.mimetype === "application/vnd.ms-excel"; + const isValid = ["image/jpeg", "image/png", "image/webp", "application/pdf", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"].includes(file.mimetype); if (isValid) { cb(null, true); } else { - cb(new Error("Only images, PDFs, and Excel files are allowed"), false); + cb(new Error("Only JPEG, PNG, WebP, PDF, and XLSX files are allowed"), false); } }; @@ -41,7 +30,7 @@ const upload = multer({ storage, fileFilter, limits: { - fileSize: 5 * 1024 * 1024, // max 5MB (global limit) + fileSize: Number(process.env.S3_MAX_UPLOAD_BYTES || 5 * 1024 * 1024), }, }); @@ -61,4 +50,4 @@ module.exports = { uploadSingle, uploadMultiple, uploadFields, -}; \ No newline at end of file +}; diff --git a/app/models/activities/userActivities.model.js b/app/models/activities/userActivities.model.js index 8560b29..cfc0fc5 100644 --- a/app/models/activities/userActivities.model.js +++ b/app/models/activities/userActivities.model.js @@ -18,9 +18,10 @@ module.exports = (sequelize, DataTypes) => { autoIncrement: true, primaryKey: true, }, + event_id: { type: DataTypes.STRING, allowNull: true, unique: true }, user_id: { type: DataTypes.STRING, - allowNull: false, + allowNull: true, }, username: { type: DataTypes.STRING, @@ -45,13 +46,17 @@ module.exports = (sequelize, DataTypes) => { activity_date:{ type: DataTypes.DATEONLY, allowNull: false, - } + }, + target_type: DataTypes.STRING, target_id: DataTypes.STRING, + ip_address: DataTypes.STRING, user_agent: DataTypes.STRING, request_id: DataTypes.STRING, + metadata: DataTypes.JSON, occurred_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, }, { tableName: "UserActivity", timestamps: true, }, ); + UserActivity.associate = (models) => UserActivity.belongsTo(models.User, { foreignKey: "user_id", as: "actor", constraints: false }); return UserActivity; }; diff --git a/app/models/document/document.model.js b/app/models/document/document.model.js index 0bd05ca..2854b43 100644 --- a/app/models/document/document.model.js +++ b/app/models/document/document.model.js @@ -14,7 +14,7 @@ module.exports = (sequelize, DataTypes) => { "Document", { doc_id:{ - type: DataTypes.STRING, + type: DataTypes.ENUM("DRAFT", "QUEUED", "PROCESSING", "COMPLETED", "FAILED"), primaryKey: true, }, reference_no: { @@ -34,13 +34,21 @@ module.exports = (sequelize, DataTypes) => { type: DataTypes.STRING, allowNull: false, defaultValue: "DRAFT", - } + }, + created_by: { type: DataTypes.STRING, allowNull: false }, + owner_type: { type: DataTypes.STRING, allowNull: false, defaultValue: "USER" }, + owner_id: { type: DataTypes.STRING, allowNull: false }, + storage_upload_id: { type: DataTypes.INTEGER, allowNull: true }, + job_id: { type: DataTypes.STRING, allowNull: true, unique: true }, + generated_at: DataTypes.DATE, failed_at: DataTypes.DATE, failure_code: DataTypes.STRING, + version: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 1 }, }, { tableName: "Document", timestamps: true, }, ); + document.associate = (models) => { document.belongsTo(models.User, { foreignKey: "created_by", as: "creator", constraints: false }); document.belongsTo(models.Upload, { foreignKey: "storage_upload_id", as: "storageUpload", constraints: false }); }; return document; }; diff --git a/app/models/document/documentType.model.js b/app/models/document/documentType.model.js index 504eec2..ef76a3a 100644 --- a/app/models/document/documentType.model.js +++ b/app/models/document/documentType.model.js @@ -20,8 +20,8 @@ module.exports = (sequelize, DataTypes) => { }, doc_type_name: { type: DataTypes.STRING, - allowNull: true, - defaultValue: "N/A" + allowNull: false, + unique: true, }, description: { type: DataTypes.JSON, diff --git a/app/models/index.js b/app/models/index.js index 2b6b999..9f70090 100644 --- a/app/models/index.js +++ b/app/models/index.js @@ -64,6 +64,7 @@ db.referenceNumber = require("./referenceNumbers/referenceNumber.model")(sequeli // Notifications db.notification = require("./notification/notification.model")(sequelize, DataTypes); db.userNotification = require("./notification/userNotification.model")(sequelize, DataTypes); +db.NotificationDelivery = require("./notification/notificationDelivery.model")(sequelize, DataTypes); /* Associations */ Object.keys(db).forEach(model => { diff --git a/app/models/notification/notificationDelivery.model.js b/app/models/notification/notificationDelivery.model.js new file mode 100644 index 0000000..4b07e64 --- /dev/null +++ b/app/models/notification/notificationDelivery.model.js @@ -0,0 +1,11 @@ +module.exports = (sequelize, DataTypes) => sequelize.define("NotificationDelivery", { + id: { type: DataTypes.STRING, primaryKey: true }, + notificationId: { type: DataTypes.STRING, allowNull: true }, + userId: { type: DataTypes.STRING, allowNull: true }, + channel: { type: DataTypes.ENUM("EMAIL", "PUSH"), allowNull: false }, + recipient: { type: DataTypes.STRING, allowNull: false }, + templateKey: { type: DataTypes.STRING, allowNull: false }, + status: { type: DataTypes.ENUM("QUEUED", "PROCESSING", "SENT", "FAILED"), allowNull: false, defaultValue: "QUEUED" }, + attemptCount: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 }, + lastAttemptAt: DataTypes.DATE, sentAt: DataTypes.DATE, failedAt: DataTypes.DATE, failureCode: DataTypes.STRING, +}, { tableName: "notification_deliveries", timestamps: true }); diff --git a/app/models/notification/userNotification.model.js b/app/models/notification/userNotification.model.js index ce3d154..b50ec64 100644 --- a/app/models/notification/userNotification.model.js +++ b/app/models/notification/userNotification.model.js @@ -41,11 +41,13 @@ module.exports = (sequelize, DataTypes) => { userNotification.associate = (models) => { userNotification.belongsTo(models.User, { foreignKey: "user_id", + as: "user", constraints: false, }); userNotification.belongsTo(models.notification, { foreignKey: "notification_id", + as: "notification", constraints: false, }); }; diff --git a/app/models/upload/upload.model.js b/app/models/upload/upload.model.js index a345a29..23b904c 100644 --- a/app/models/upload/upload.model.js +++ b/app/models/upload/upload.model.js @@ -43,12 +43,21 @@ module.exports = (sequelize, DataTypes) => { type: DataTypes.STRING, allowNull: false, }, + storage_provider: { type: DataTypes.STRING, allowNull: false, defaultValue: "S3" }, + safe_name: { type: DataTypes.STRING, allowNull: true }, + checksum: { type: DataTypes.STRING(64), allowNull: true }, + owner_type: { type: DataTypes.STRING, allowNull: false, defaultValue: "USER" }, + owner_id: { type: DataTypes.STRING, allowNull: false }, + visibility: { type: DataTypes.ENUM("PRIVATE", "PUBLIC"), allowNull: false, defaultValue: "PRIVATE" }, + status: { type: DataTypes.ENUM("PENDING", "AVAILABLE", "FAILED", "DELETED"), allowNull: false, defaultValue: "PENDING" }, + deletedAt: { type: DataTypes.DATE, allowNull: true }, }, { tableName: "uploads", timestamps: true, }, ); + upload.associate = (models) => upload.belongsTo(models.User, { foreignKey: "uploaded_by", as: "uploader", constraints: false }); return upload; }; diff --git a/app/queues/activity.queue.js b/app/queues/activity.queue.js index eea6434..f8828c5 100644 --- a/app/queues/activity.queue.js +++ b/app/queues/activity.queue.js @@ -15,6 +15,7 @@ const connection = require("../config/redisClient"); const activityQueue = new Queue("activity-queue", { connection, + defaultJobOptions: { attempts: 3, backoff: { type: "exponential", delay: 1000 }, removeOnComplete: { age: 86400, count: 5000 }, removeOnFail: { age: 604800, count: 5000 } }, }); -module.exports = activityQueue; \ No newline at end of file +module.exports = activityQueue; diff --git a/app/queues/document.queue.js b/app/queues/document.queue.js index 3195261..e39293d 100644 --- a/app/queues/document.queue.js +++ b/app/queues/document.queue.js @@ -27,7 +27,7 @@ const documentQueue = new Queue("document-generation", { count: 1000, }, - removeOnFail: false, + removeOnFail: { age: 604800, count: 5000 }, }, }); diff --git a/app/queues/email.queue.js b/app/queues/email.queue.js new file mode 100644 index 0000000..d0b27d4 --- /dev/null +++ b/app/queues/email.queue.js @@ -0,0 +1,3 @@ +const { Queue } = require("bullmq"); +const connection = require("../config/redisClient"); +module.exports = new Queue("email-delivery", { connection, defaultJobOptions: { attempts: 5, backoff: { type: "exponential", delay: 2000 }, removeOnComplete: { age: 3600, count: 1000 }, removeOnFail: { age: 604800, count: 5000 } } }); diff --git a/app/queues/log.queue.js b/app/queues/log.queue.js index fe89ffe..d5b3b58 100644 --- a/app/queues/log.queue.js +++ b/app/queues/log.queue.js @@ -12,6 +12,6 @@ const { Queue } = require("bullmq"); const connection = require("../config/redisClient"); -const logQueue = new Queue("logQueue", { connection }); +const logQueue = new Queue("logQueue", { connection, defaultJobOptions: { attempts: 2, backoff: { type: "fixed", delay: 1000 }, removeOnComplete: { age: 3600, count: 5000 }, removeOnFail: { age: 86400, count: 5000 } } }); -module.exports = logQueue; \ No newline at end of file +module.exports = logQueue; diff --git a/app/routes/activity.routes.js b/app/routes/activity.routes.js index 2855cda..b962ac0 100644 --- a/app/routes/activity.routes.js +++ b/app/routes/activity.routes.js @@ -24,7 +24,7 @@ const PERMISSIONS = require("../constants/permissions"); router.get( "/", authenticate, - authorizedAccountType(["admin"]), + checkPermission("audit.read", { custom: true }), activityController.getUserActivities, ); @@ -32,7 +32,7 @@ router.get( router.get( "/user/:userId", authenticate, - authorizedAccountType(["admin"]), + checkPermission("audit.read", { custom: true }), activityController.getActivitiesByUserId, ); diff --git a/app/routes/docs.routes.js b/app/routes/docs.routes.js index 261bed2..b00234c 100644 --- a/app/routes/docs.routes.js +++ b/app/routes/docs.routes.js @@ -48,31 +48,8 @@ router.get("/view/:file", docsSession, (req, res) => { }); // Docs login (simple) -router.post("/login", sensitiveLimiter, (req, res) => { - const { username, password } = req.body; +router.post("/login", sensitiveLimiter, (_req, res) => res.status(410).json({ success: false, error: { code: "DEPRECATED", message: "Use an authenticated ADMIN or SUPER_ADMIN session" } })); - if ( - username === process.env.DOCS_USER && - password === process.env.DOCS_PASS - ) { - res.cookie("docsAuth", "true", { - httpOnly: true, - sameSite: "lax", - secure: process.env.NODE_ENV === "production", - }); - - return res.json({ success: true }); - } - - return res.status(401).json({ - success: false, - message: "Invalid credentials", - }); -}); - -router.post("/logout", (req, res) => { - res.clearCookie("docsAuth"); - res.json({ success: true }); -}); +router.post("/logout", (_req, res) => res.status(410).json({ success: false, error: { code: "DEPRECATED", message: "Use the authentication logout endpoint" } })); module.exports = router; diff --git a/app/routes/document.routes.js b/app/routes/document.routes.js index ce1cb1f..8279666 100644 --- a/app/routes/document.routes.js +++ b/app/routes/document.routes.js @@ -1,94 +1,12 @@ -/** - * Copyright (c) 2026 Niolla - * All rights reserved. - * - * This source code is proprietary and confidential. - * Unauthorized copying, modification, distribution, or use - * of this file, via any medium, is strictly prohibited. - */ - -// app/routes/document.routes.js - -const express = require("express"); -const router = express.Router(); -const generateDocumentController = require("../controllers/generateDocument.controller"); -const { - authenticate, -} = require("../middleware/auth.middleware"); - -const { authorizedAccountType, checkPermission } = require("../middleware/permission.middleware"); -const PERMISSIONS = require("../constants/permissions"); - - -// Get available document types -router.get( - "/types", - authenticate, - authorizedAccountType(["admin", "management", "team_head", "user"]), - generateDocumentController.getAvailableDocumentTypes -); - -// Get saved documents -router.post( - "/saved", - authenticate, - authorizedAccountType(["admin", "management", "team_head", "user"]), - generateDocumentController.getSavedDocuments -); - -// Generate Document (async - returns jobId immediately) -router.post( - "/generate", - authenticate, - authorizedAccountType(["admin", "management", "team_head", "user"]), - generateDocumentController.generateDocument -); - -router.post( - "/draft", - authenticate, - authorizedAccountType(["admin", "management", "team_head", "user"]), - generateDocumentController.generateDraftDocument -); - -// Generate Reference Number -router.get( - "/reference-number/:documentType", - authenticate, - authorizedAccountType(["admin", "management", "team_head", "user"]), - generateDocumentController.generateReferenceNo -); - -// Get Job Status -router.get( - "/job/:jobId/status", - authenticate, - authorizedAccountType(["admin", "management", "team_head", "user"]), - generateDocumentController.getJobStatus -); - -// Download Document -router.get( - "/download/:uuid", - authenticate, - authorizedAccountType(["admin", "management", "team_head", "user"]), - generateDocumentController.downloadDocument -); - -// Cancel Job -router.delete( - "/job/:jobId", - authenticate, - authorizedAccountType(["admin", "management", "team_head", "user"]), - generateDocumentController.cancelJob -); - -// Get document details -router.get( - "/:docId", - authenticate, - authorizedAccountType(["admin", "management", "team_head", "user"]), - generateDocumentController.getDocumentData -); - -module.exports = router; \ No newline at end of file +const router = require("express").Router(); +const c = require("../controllers/generateDocument.controller"); +const { authenticate } = require("../middleware/auth.middleware"); +router.use(authenticate); +router.get("/types", c.getAvailableDocumentTypes); +router.get("/saved", c.getSavedDocuments); router.post("/saved", c.getSavedDocuments); +router.post("/generate", c.generateDocument); router.post("/draft", c.generateDraftDocument); +router.get("/reference-number/:documentType", c.generateReferenceNo); +router.get("/jobs/:jobId", c.getJobStatus); router.get("/job/:jobId/status", c.getJobStatus); +router.get("/:docId/download", c.downloadDocument); router.get("/download/:docId", c.downloadDocument); +router.delete("/job/:jobId", c.cancelJob); router.get("/:docId", c.getDocumentData); +module.exports = router; diff --git a/app/routes/notification.routes.js b/app/routes/notification.routes.js index 5b328ca..5840151 100644 --- a/app/routes/notification.routes.js +++ b/app/routes/notification.routes.js @@ -1,69 +1,11 @@ -/** - * Copyright (c) 2026 Niolla - * All rights reserved. - * - * This source code is proprietary and confidential. - * Unauthorized copying, modification, distribution, or use - * of this file, via any medium, is strictly prohibited. - */ - -// app/routes/notification.routes.js - -const express = require("express"); -const router = express.Router(); - -const notificationController = require("../controllers/notification.controller"); - -const { - authenticate, -} = require("../middleware/auth.middleware"); - -const { - authorizedAccountType, - checkPermission, -} = require("../middleware/permission.middleware"); - -const PERMISSIONS = require("../constants/permissions"); - - -// Create notification -router.post( - "/", - authenticate, - authorizedAccountType(["admin", "management"]), - // checkPermission(PERMISSIONS.NOTIFICATION_CREATE), - notificationController.createNotification, -); - - -// Get all announcements -router.get( - "/announcements", - authenticate, - authorizedAccountType(["admin", "management", "team_head", "user"]), - // checkPermission(PERMISSIONS.NOTIFICATION_VIEW), - notificationController.getAnnouncements, -); - - -// Get notifications for a user -router.get( - "/user/:userId", - authenticate, - authorizedAccountType(["admin", "management", "team_head", "user"]), - // checkPermission(PERMISSIONS.NOTIFICATION_VIEW), - notificationController.getUserNotifications, -); - - -// Mark notification as read -router.patch( - "/user/:userId/notification/:notificationId/read", - authenticate, - authorizedAccountType(["admin", "management", "team_head", "user"]), - // checkPermission(PERMISSIONS.NOTIFICATION_READ), - notificationController.markAsRead, -); - - -module.exports = router; \ No newline at end of file +const router = require("express").Router(); +const controller = require("../controllers/notification.controller"); +const { authenticate } = require("../middleware/auth.middleware"); +const { checkPermission } = require("../middleware/permission.middleware"); +router.use(authenticate); +router.post("/", checkPermission("notifications.manage", { custom: true }), controller.createNotification); +router.get("/announcements", controller.getAnnouncements); +router.get("/me", controller.getMyNotifications); +router.patch("/read-all", controller.markAllAsRead); +router.patch("/:notificationId/read", controller.markAsRead); +module.exports = router; diff --git a/app/routes/profile.routes.js b/app/routes/profile.routes.js index 8e92643..ad76262 100644 --- a/app/routes/profile.routes.js +++ b/app/routes/profile.routes.js @@ -36,6 +36,16 @@ router.post( authController.changePassword, ); +router.get( + "/me/avatar", + authenticate, + profileController.getProfileAvatar, +); +router.get( + "/me/background", + authenticate, + profileController.getProfileBackgroundImage, +); router.get( "/avatar/:userId", authenticate, diff --git a/app/routes/upload.routes.js b/app/routes/upload.routes.js index 00c0c7f..7e1bb0e 100644 --- a/app/routes/upload.routes.js +++ b/app/routes/upload.routes.js @@ -24,7 +24,6 @@ const PERMISSIONS = require("../constants/permissions"); router.post( "/", authenticate, - authorizedAccountType(["admin", "staff"]), uploadSingle("file"), uploadController.uploadFile, ); @@ -33,8 +32,8 @@ router.post( router.get( "/signed-url/:id", authenticate, - authorizedAccountType(["admin", "staff"]), uploadController.getFileUrl, ); +router.delete("/:id", authenticate, uploadController.deleteFile); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/app/services/activity.service.js b/app/services/activity.service.js index 110d468..0e03da3 100644 --- a/app/services/activity.service.js +++ b/app/services/activity.service.js @@ -15,21 +15,33 @@ const logActivity = async ({ user, description, type = "ACTION", - module + module, + eventId, + targetType, + targetId, + requestId, + ipAddress, + userAgent, + metadata, }) => { try { + const safeMetadata = metadata && JSON.parse(JSON.stringify(metadata, (key, value) => /password|otp|token|authorization|cookie/i.test(key) ? "[REDACTED]" : value)); + const id = eventId || require("crypto").randomUUID(); await activityQueue.add("log-activity", { - user_id: user.id || user.user_id, - username: user.firstName, + event_id: id, + user_id: user?.id || user?.user_id || null, + username: user?.firstName || "System", activity_description: description, module: module, activity_type: type, activity_time: new Date(), - activity_date: new Date().toISOString().split("T")[0] - }); + activity_date: new Date().toISOString().split("T")[0], + target_type: targetType, target_id: targetId, request_id: requestId, + ip_address: ipAddress, user_agent: userAgent, metadata: safeMetadata, occurred_at: new Date(), + }, { jobId: `activity-${id}` }); } catch (err) { console.error("Queue push error:", err.message); } }; -module.exports = { logActivity }; \ No newline at end of file +module.exports = { logActivity }; diff --git a/app/services/auth/email.service.js b/app/services/auth/email.service.js index a77a866..5fd4865 100644 --- a/app/services/auth/email.service.js +++ b/app/services/auth/email.service.js @@ -1,6 +1,6 @@ -const { sendMail } = require("../../utils/mail.util"); +const emailService = require("../email/email.service"); -const sendLoginOtp = (user, otp) => sendMail({ to: user.email, subject: "Your ZUMRI login code", templateName: "otp", templateVars: { firstName: user.firstName, otp }, text: `Your ZUMRI login code is ${otp}.` }); -const sendPasswordChanged = (user) => sendMail({ to: user.email, subject: "Your ZUMRI password was changed", templateName: "passwordChanged", templateVars: { customer_name: user.firstName, changed_at: new Date().toLocaleString() }, text: "Your ZUMRI password was changed. Contact support if this was not you." }); +const sendLoginOtp = (user, otp) => emailService.send({ templateKey: "otp", recipient: user.email, userId: user.id, variables: { firstName: user.firstName, otp } }); +const sendPasswordChanged = (user) => emailService.send({ templateKey: "passwordChanged", recipient: user.email, userId: user.id, variables: { customer_name: user.firstName, changed_at: new Date().toLocaleString() } }); module.exports = { sendLoginOtp, sendPasswordChanged }; diff --git a/app/services/email/email.service.js b/app/services/email/email.service.js new file mode 100644 index 0000000..c68d78e --- /dev/null +++ b/app/services/email/email.service.js @@ -0,0 +1,15 @@ +const crypto = require("crypto"); +const emailQueue = require("../../queues/email.queue"); +const db = require("../../models"); +const templates = { + otp: { subject: "Your ZUMRI login code" }, passwordChanged: { subject: "Your ZUMRI password was changed" }, + passwordReset: { subject: "Reset your ZUMRI password" }, emailVerification: { subject: "Verify your ZUMRI email" }, welcome: { subject: "Welcome to ZUMRI" }, +}; +const send = async ({ templateKey, recipient, locale = "en", variables = {}, correlationId, notificationId, userId, eventId }) => { + if (!templates[templateKey]) throw new Error("Unknown email template"); + const id = crypto.randomUUID(); + await db.NotificationDelivery.create({ id, notificationId, userId, channel: "EMAIL", recipient, templateKey, status: "QUEUED" }); + await emailQueue.add("send-email", { deliveryId: id, templateKey, recipient, locale, variables, correlationId }, { jobId: `email-${eventId || id}` }); + return { deliveryId: id }; +}; +module.exports = { send, templates }; diff --git a/app/services/notification/notification-policy.service.js b/app/services/notification/notification-policy.service.js new file mode 100644 index 0000000..8ca3d2d --- /dev/null +++ b/app/services/notification/notification-policy.service.js @@ -0,0 +1,7 @@ +const MANDATORY_SECURITY_EVENTS = new Set(["LOGIN_OTP", "PASSWORD_RESET", "PASSWORD_CHANGED", "EMAIL_VERIFICATION"]); +const channelAllowed = ({ eventType, channel, profile }) => { + if (MANDATORY_SECURITY_EVENTS.has(eventType)) return true; + if (channel === "IN_APP") return true; + return profile?.notificationsEnabled !== false; +}; +module.exports = { MANDATORY_SECURITY_EVENTS, channelAllowed }; diff --git a/app/services/storage/file-validation.service.js b/app/services/storage/file-validation.service.js new file mode 100644 index 0000000..f0d04d6 --- /dev/null +++ b/app/services/storage/file-validation.service.js @@ -0,0 +1,19 @@ +const crypto = require("crypto"); +const { extensionFor, sanitizeFilename } = require("./storage.service"); +const detectMimeType = (buffer) => { + if (!Buffer.isBuffer(buffer) || !buffer.length) return null; + if (buffer.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) return "image/jpeg"; + if (buffer.subarray(0, 8).equals(Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]))) return "image/png"; + if (buffer.length >= 12 && buffer.toString("ascii", 0, 4) === "RIFF" && buffer.toString("ascii", 8, 12) === "WEBP") return "image/webp"; + if (buffer.subarray(0, 5).toString("ascii") === "%PDF-") return "application/pdf"; + if (buffer.subarray(0, 4).equals(Buffer.from([0x50,0x4b,0x03,0x04]))) return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + return null; +}; +const validateUpload = (file) => { + if (!file?.buffer?.length) throw Object.assign(new Error("Empty files are not allowed"), { status: 400 }); + if (file.buffer.length > Number(process.env.S3_MAX_UPLOAD_BYTES || 5242880)) throw Object.assign(new Error("File exceeds the upload size limit"), { status: 413 }); + const mimeType = detectMimeType(file.buffer); + if (!mimeType || mimeType !== file.mimetype || !extensionFor(mimeType)) throw Object.assign(new Error("File content does not match an allowed type"), { status: 400 }); + return { mimeType, size: file.buffer.length, checksum: crypto.createHash("sha256").update(file.buffer).digest("hex"), safeName: `${sanitizeFilename(file.originalname).replace(/\.[^.]+$/, "")}${extensionFor(mimeType)}` }; +}; +module.exports = { detectMimeType, validateUpload }; diff --git a/app/services/storage/storage.service.js b/app/services/storage/storage.service.js new file mode 100644 index 0000000..a325ed5 --- /dev/null +++ b/app/services/storage/storage.service.js @@ -0,0 +1,19 @@ +const crypto = require("crypto"); +const path = require("path"); +const { PutObjectCommand, DeleteObjectCommand, HeadObjectCommand, GetObjectCommand } = require("@aws-sdk/client-s3"); +const { getSignedUrl } = require("@aws-sdk/s3-request-presigner"); +const s3 = require("../../config/s3.config"); +const bucket = () => process.env.AWS_S3_BUCKET_NAME; +const extensionFor = (mime) => ({ "image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp", "application/pdf": ".pdf", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx" }[mime]); +const sanitizeFilename = (name = "file") => path.basename(name).replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 180); +const createObjectKey = ({ ownerId = "system", mimeType, purpose = "upload", now = new Date(), id = crypto.randomUUID() }) => { + const ext = extensionFor(mimeType); if (!ext) throw new Error("Unsupported file type"); + const root = purpose === "document" ? "documents" : "uploads"; + const owner = String(ownerId).replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 80); + return `${root}/${owner}/${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, "0")}/${id}${ext}`; +}; +const uploadBuffer = async ({ buffer, objectKey, mimeType, checksum }) => { await s3.send(new PutObjectCommand({ Bucket: bucket(), Key: objectKey, Body: buffer, ContentType: mimeType, ChecksumSHA256: checksum ? Buffer.from(checksum, "hex").toString("base64") : undefined })); return objectKey; }; +const deleteObject = (objectKey) => s3.send(new DeleteObjectCommand({ Bucket: bucket(), Key: objectKey })); +const objectExists = async (objectKey) => { try { await s3.send(new HeadObjectCommand({ Bucket: bucket(), Key: objectKey })); return true; } catch (error) { if (error.name === "NotFound" || error.$metadata?.httpStatusCode === 404) return false; throw error; } }; +const createSignedDownloadUrl = (objectKey, expiresIn = Number(process.env.S3_SIGNED_URL_TTL_SECONDS || 900)) => getSignedUrl(s3, new GetObjectCommand({ Bucket: bucket(), Key: objectKey }), { expiresIn }); +module.exports = { uploadBuffer, deleteObject, objectExists, createSignedDownloadUrl, createObjectKey, extensionFor, sanitizeFilename }; diff --git a/app/utils/consoleLog.utill.js b/app/utils/consoleLog.utill.js index 33193cf..adccd2c 100644 --- a/app/utils/consoleLog.utill.js +++ b/app/utils/consoleLog.utill.js @@ -23,11 +23,11 @@ const log = async (...args) => { const message = args .map((arg) => { if (arg instanceof Error) { - return `${arg.message}\n${arg.stack}`; + return arg.message; } if (typeof arg === "object") { - return JSON.stringify(arg); + return JSON.stringify(arg, (key, value) => /password|otp|token|authorization|cookie|secret/i.test(key) ? "[REDACTED]" : value); } return String(arg); @@ -35,7 +35,7 @@ const log = async (...args) => { .join(" "); await logQueue.add("log", { - message, + level: "info", service: "zumri-api", message, timestamp: new Date().toISOString(), }); } catch (err) { diff --git a/app/utils/documentJob.util.js b/app/utils/documentJob.util.js index 1806c4f..67b06d9 100644 --- a/app/utils/documentJob.util.js +++ b/app/utils/documentJob.util.js @@ -9,7 +9,7 @@ // app/utils/documentJob.util.js -const documentQueue = require("../queues/pdf.queue"); +const documentQueue = require("../queues/document.queue"); /** * Queue a document generation job diff --git a/app/utils/emailVerification.util.js b/app/utils/emailVerification.util.js index c5a6a7f..af3d695 100644 --- a/app/utils/emailVerification.util.js +++ b/app/utils/emailVerification.util.js @@ -1,7 +1,7 @@ // app/utils/emailVerification.util.js const crypto = require("crypto"); -const { sendMail } = require("../utils/mail.util"); +const emailService = require("../services/email/email.service"); const redis = require("../config/redisClient"); const EMAIL_VERIFICATION_TTL = @@ -67,19 +67,12 @@ const sendVerificationEmail = async (email, firstName, verificationToken) => { const confirmationLink = `${process.env.FRONTEND_URL}/verify-email?token=${verificationToken}`; // Send email - await sendMail({ - to: email, - - subject: "Confirm Your ZUMRI Account", - - templateName: "emailVerification", - - templateVars: { + await emailService.send({ + recipient: email, templateKey: "emailVerification", + variables: { customer_name: firstName, confirmation_link: confirmationLink, - }, - - text: `Hello ${firstName}, please verify your ZUMRI account using this link: ${confirmationLink}`, + }, eventId: `verify-${hashEmailVerificationToken(verificationToken)}`, }); }; diff --git a/app/utils/mail.util.js b/app/utils/mail.util.js index aa94ee7..ba64def 100644 --- a/app/utils/mail.util.js +++ b/app/utils/mail.util.js @@ -32,12 +32,14 @@ if (process.env.NODE_ENV !== "test" && process.env.ENABLE_MAIL === "true") { // Utility to load template and replace placeholders const loadTemplate = (templateName, variables = {}) => { + if (!/^[a-zA-Z0-9_-]+$/.test(templateName)) throw new Error("Invalid email template"); const templatePath = path.join(__dirname, "../templates/emails", `${templateName}.html`); let template = fs.readFileSync(templatePath, "utf-8"); Object.keys(variables).forEach((key) => { const regex = new RegExp(`{{${key}}}`, "g"); - template = template.replace(regex, variables[key]); + const escaped = String(variables[key] ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]); + template = template.replace(regex, escaped); }); return template; @@ -90,5 +92,5 @@ const sendMail = async (options) => { }; -module.exports = { sendMail }; +module.exports = { sendMail, loadTemplate }; diff --git a/app/utils/passwordReset.utill.js b/app/utils/passwordReset.utill.js index ecc7864..4ac211b 100644 --- a/app/utils/passwordReset.utill.js +++ b/app/utils/passwordReset.utill.js @@ -11,7 +11,7 @@ const crypto = require("crypto"); const redis = require("../config/redisClient"); -const { sendMail } = require("./mail.util"); +const emailService = require("../services/email/email.service"); const PASSWORD_RESET_TTL = Number(process.env.PASSWORD_RESET_TTL_SECONDS) || 900; @@ -81,23 +81,13 @@ const sendPasswordResetEmail = `${process.env.FRONTEND_URL}/reset-password?token=${resetToken}`; - await sendMail({ - to: email, - - subject: - "Reset your ZUMRI password", - - templateName: - "passwordReset", - - templateVars: { + await emailService.send({ + recipient: email, templateKey: "passwordReset", + variables: { firstName: firstName, resetLink: resetLink, expiryTime: "15 minutes", - }, - - text: - `Hi ${firstName}, use this link within 15 minutes to reset your password: ${resetLink}`, + }, eventId: `reset-${hashPasswordResetToken(resetToken)}`, }); }; @@ -109,25 +99,15 @@ const sendPasswordResetEmail = const changedAt = new Date().toLocaleString(); - await sendMail({ - to: email, - - subject: - "Your ZUMRI password was changed", - - templateName: - "passwordChanged", - - templateVars: { + await emailService.send({ + recipient: email, templateKey: "passwordChanged", + variables: { customer_name: firstName, changed_at: changedAt, }, - - text: - `Hi ${firstName}, your password was changed at ${changedAt}. If this was not you, contact support immediately.`, }); }; diff --git a/app/utils/s3Upload.utill.js b/app/utils/s3Upload.utill.js index c2eacae..574499c 100644 --- a/app/utils/s3Upload.utill.js +++ b/app/utils/s3Upload.utill.js @@ -1,89 +1,13 @@ -/** - * Copyright (c) 2026 Niolla - * All rights reserved. - * - * This source code is proprietary and confidential. - * Unauthorized copying, modification, distribution, or use - * of this file, via any medium, is strictly prohibited. - */ +const storage = require("../services/storage/storage.service"); -// app/utils/s3Upload.util.js - -const { PutObjectCommand, GetObjectCommand } = require("@aws-sdk/client-s3"); - -const { getSignedUrl } = require("@aws-sdk/s3-request-presigner"); -const s3 = require("../config/s3.config"); -const { v4: uuidv4 } = require("uuid"); -const path = require("path"); -const redis = require("../config/redisClient"); -const { log } = require("./consoleLog.utill"); - -// Upload + return key (BEST PRACTICE) const uploadToS3 = async (file, usedFor) => { - try { - const fileExtension = path.extname(file.originalname); - const fileName = `${uuidv4()}${fileExtension}`; - let key = `uploads/${fileName}`; - - if (usedFor === "asset") { - key = `assets/${fileName}`; - } - - log("Uploading file to S3 with key:", key); - - const params = { - Bucket: process.env.AWS_S3_BUCKET_NAME, - Key: key, - Body: file.buffer, - ContentType: file.mimetype, - }; - - await s3.send(new PutObjectCommand(params)); - - // Return KEY (not URL) - return key; - } catch (error) { - log("S3 Upload Error:", error); - throw new Error("File upload failed"); - } -}; - -// Generate signed URL with Redis caching -const getSignedFileUrl = async (key) => { - try { - const cacheKey = `s3:url:${key}`; - - // 1. Check cache - const cachedUrl = await redis.get(cacheKey); - - if (cachedUrl) { - log("⚡ Cache HIT - returning signed URL from Redis"); - return cachedUrl; - } - - log("🐢 Cache MISS - generating new signed URL"); - - // 2. Generate signed URL - const command = new GetObjectCommand({ - Bucket: process.env.AWS_S3_BUCKET_NAME, - Key: key, - }); - - const signedUrl = await getSignedUrl(s3, command, { - expiresIn: 3600, // 1 hour - }); - - // 3. Store in Redis (TTL: 55 minutes) - await redis.set(cacheKey, signedUrl, "EX", 3300); - - return signedUrl; - } catch (error) { - log("Signed URL Error:", error); - throw new Error("Failed to generate file URL"); - } + const key = storage.createObjectKey({ ownerId: file.ownerId, mimeType: file.mimetype, purpose: usedFor }); + await storage.uploadBuffer({ buffer: file.buffer, objectKey: key, mimeType: file.mimetype, checksum: file.checksum }); + return key; }; module.exports = { uploadToS3, - getSignedFileUrl, + getSignedFileUrl: storage.createSignedDownloadUrl, + deleteFromS3: storage.deleteObject, }; diff --git a/app/workers/activity.worker.js b/app/workers/activity.worker.js index 39d052a..9611009 100644 --- a/app/workers/activity.worker.js +++ b/app/workers/activity.worker.js @@ -19,7 +19,8 @@ const createActivityWorker = () => { const worker = new Worker( "activity-queue", async (job) => { - await UserActivity.create(job.data); + try { await UserActivity.create(job.data); } + catch (error) { if (error.name === "SequelizeUniqueConstraintError" && job.data.event_id) return { duplicate: true }; throw error; } }, { connection, diff --git a/app/workers/document.worker.js b/app/workers/document.worker.js index 9c69438..b2509b2 100644 --- a/app/workers/document.worker.js +++ b/app/workers/document.worker.js @@ -1,96 +1,26 @@ -/** - * Copyright (c) 2026 Niolla - * All rights reserved. - * - * This source code is proprietary and confidential. - * Unauthorized copying, modification, distribution, or use - * of this file, via any medium, is strictly prohibited. - */ - -// app/workers/pdf.worker.js - const { Worker } = require("bullmq"); - -const redis = require("../config/redisClient"); - +const crypto = require("crypto"); +const connection = require("../config/redisClient"); +const db = require("../models"); const { generateDocument } = require("../logic/documents"); +const storage = require("../services/storage/storage.service"); -const { uploadToS3 } = require("../utils/s3Upload.utill"); - -module.exports = async function createDocumentWorker() { - const documentWorker = new Worker( - "document-generation", - - async (job) => { - try { - const { document, documentType, data } = job.data; - - console.log( - `📄 Processing Job ${job.id}: document="${document}", documentType="${documentType}"`, - ); - - const { fileBuffer, fileName, mimeType } = await generateDocument( - document, - documentType, - data, - ); - - /** - * Store in S3 instead of local filesystem - */ - const s3Key = await uploadToS3( - { - originalname: fileName, - buffer: fileBuffer, - mimetype: mimeType, - }, - "document", - ); - - // Extract UUID from S3 key - const documentId = s3Key - .split("/") - .pop() - .replace(/\.[^/.]+$/, ""); - - console.log(`✅ Job ${job.id} completed: ${fileName} (${s3Key})`); - - /** - * Keep response compatible with existing flow - */ - return { - fileName, - mimeType, - size: fileBuffer.length, - s3Key, - documentId, - }; - } catch (err) { - console.error(`❌ Job ${job.id} failed:`, err.message); - - throw err; - } - }, - - { - connection: redis, - concurrency: 2, - }, - ); - - documentWorker.on("error", (err) => { - console.error("❌ Document Worker Error:", err); - }); - - documentWorker.on("failed", (job, err) => { - console.error(`❌ Job ${job?.id} failed after retries:`, err.message); - }); - - documentWorker.on("completed", (job) => { - console.log(`✅ Worker completed Job ${job.id}`); - }); - - console.log("📄 Document Worker initialized"); - - return documentWorker; +module.exports = function createDocumentWorker() { + const worker = new Worker("document-generation", async (job) => { + const { documentId, document, documentType, data } = job.data; + const record = await db.Document.findByPk(documentId); + if (!record) throw Object.assign(new Error("Document record not found"), { code: "DOCUMENT_NOT_FOUND" }); + if (record.status === "COMPLETED") return { documentId }; + await record.update({ status: "PROCESSING", failure_code: null, failed_at: null }); + const { fileBuffer, fileName, mimeType } = await generateDocument(document, documentType, data); + const checksum = crypto.createHash("sha256").update(fileBuffer).digest("hex"); + const objectKey = storage.createObjectKey({ ownerId: record.owner_id, mimeType, purpose: "document" }); + await storage.uploadBuffer({ buffer: fileBuffer, objectKey, mimeType, checksum }); + let upload; + try { upload = await db.Upload.create({ file_path: objectKey, file_type: mimeType, file_size: fileBuffer.length, original_name: fileName, safe_name: fileName, checksum, use_for: "document", uploaded_by: record.created_by, owner_type: record.owner_type, owner_id: record.owner_id, visibility: "PRIVATE", status: "AVAILABLE" }); await record.update({ status: "COMPLETED", storage_upload_id: upload.id, generated_at: new Date() }); } + catch (error) { await storage.deleteObject(objectKey).catch(() => undefined); throw error; } + return { documentId, uploadId: upload.id }; + }, { connection, concurrency: Number(process.env.DOCUMENT_QUEUE_CONCURRENCY || 2) }); + worker.on("failed", async (job, error) => { if (job && job.attemptsMade >= (job.opts.attempts || 1)) await db.Document.update({ status: "FAILED", failed_at: new Date(), failure_code: String(error.code || error.name || "GENERATION_FAILED").slice(0, 100) }, { where: { doc_id: job.data.documentId } }).catch(() => undefined); console.error("Document generation failed", { jobId: job?.id, code: error.code || error.name }); }); + return worker; }; diff --git a/app/workers/email.worker.js b/app/workers/email.worker.js new file mode 100644 index 0000000..6867be7 --- /dev/null +++ b/app/workers/email.worker.js @@ -0,0 +1,17 @@ +const { Worker, UnrecoverableError } = require("bullmq"); +const connection = require("../config/redisClient"); +const db = require("../models"); +const { sendMail } = require("../utils/mail.util"); +const { templates } = require("../services/email/email.service"); +const permanent = (error) => ["EENVELOPE", "EMESSAGE"].includes(error.code) || /^5\d\d$/.test(String(error.responseCode || "")); +module.exports = () => { + const worker = new Worker("email-delivery", async (job) => { + const { deliveryId, templateKey, recipient, variables } = job.data; + const delivery = await db.NotificationDelivery.findByPk(deliveryId); + await delivery?.update({ status: "PROCESSING", attemptCount: job.attemptsMade + 1, lastAttemptAt: new Date() }); + try { const result = await sendMail({ to: recipient, subject: templates[templateKey].subject, templateName: templateKey, templateVars: variables }); await delivery?.update({ status: "SENT", sentAt: new Date(), failureCode: null }); return { messageId: result.messageId }; } + catch (error) { if (permanent(error)) throw new UnrecoverableError(error.code || "PERMANENT_REJECTION"); throw error; } + }, { connection, concurrency: Number(process.env.EMAIL_QUEUE_CONCURRENCY || 5) }); + worker.on("failed", async (job, error) => { if (job && (job.attemptsMade >= (job.opts.attempts || 1) || error instanceof UnrecoverableError)) await db.NotificationDelivery.update({ status: "FAILED", failedAt: new Date(), failureCode: String(error.code || error.name || "DELIVERY_FAILED").slice(0, 100) }, { where: { id: job.data.deliveryId } }).catch(() => undefined); console.error("Email delivery failed", { jobId: job?.id, code: error.code || error.name }); }); + return worker; +}; diff --git a/app/workers/index.js b/app/workers/index.js index 71acf50..77e700a 100644 --- a/app/workers/index.js +++ b/app/workers/index.js @@ -22,7 +22,8 @@ const startWorkers = async () => { const createActivityWorker = require("./activity.worker"); const createLogWorker = require("./log.worker"); const createDocumentWorker = require("./document.worker"); - const workers = [createActivityWorker(), createLogWorker(), await createDocumentWorker()]; + const createEmailWorker = require("./email.worker"); + const workers = [createActivityWorker(), createLogWorker(), await createDocumentWorker(), createEmailWorker()]; console.log("All workers started."); let shuttingDown = false; diff --git a/app/workers/log.worker.js b/app/workers/log.worker.js index 7bc4d4d..29f9676 100644 --- a/app/workers/log.worker.js +++ b/app/workers/log.worker.js @@ -25,7 +25,7 @@ const createLogWorker = () => { "logQueue", async (job) => { - const { message, timestamp } = job.data; + const { message, timestamp, level = "info", service = "zumri-api", requestId, metadata } = job.data; const logDate = new Date(timestamp) .toISOString() @@ -36,7 +36,7 @@ const createLogWorker = () => { `app-${logDate}.log` ); - const logLine = `[${timestamp}] ${message}\n`; + const logLine = `${JSON.stringify({ timestamp, level, service, requestId, message, metadata })}\n`; await fs.promises.appendFile( logFilePath, @@ -70,4 +70,4 @@ const createLogWorker = () => { return worker; }; -module.exports = createLogWorker; \ No newline at end of file +module.exports = createLogWorker; diff --git a/cron/notificationCleaning.cron.js b/cron/notificationCleaning.cron.js index 4630de5..702645e 100644 --- a/cron/notificationCleaning.cron.js +++ b/cron/notificationCleaning.cron.js @@ -1,98 +1,17 @@ -/** - * Copyright (c) 2026 Niolla - * All rights reserved. - * - * This source code is proprietary and confidential. - * Unauthorized copying, modification, distribution, or use - * of this file, via any medium, is strictly prohibited. - */ - -// cron/cleanInactiveNotifications.cron.js - const cron = require("node-cron"); +const { Op } = require("sequelize"); +const { sequelize, notification, userNotification } = require("../app/models"); -const { - sequelize, - notification, - userNotification, -} = require("../app/models"); - -function startCleanInactiveNotificationsCron() { - console.log("Inactive Notification Cleanup Cron Started"); - - const task = cron.schedule( - "0 0 * * *", // Run every day at midnight - async () => { - console.log( - `[CRON] Cleaning inactive notifications... ${new Date().toISOString()}`, - ); - - try { - const inactiveNotifications = await notification.findAll({ - where: { - isActive: false, - }, - attributes: ["notification_id"], - }); - - if (!inactiveNotifications.length) { - console.log("[CRON] No inactive notifications found."); - return; - } - - const notificationIds = inactiveNotifications.map( - (item) => item.notification_id, - ); - - const transaction = await sequelize.transaction(); - - try { - // Delete user-notification relationships first - const deletedUserNotifications = await userNotification.destroy({ - where: { - notification_id: notificationIds, - }, - transaction, - }); - - // Delete inactive notifications - const deletedNotifications = await notification.destroy({ - where: { - notification_id: notificationIds, - isActive: false, - }, - transaction, - }); - - await transaction.commit(); - - console.log( - `[CRON] Cleanup completed. Deleted ${deletedNotifications} notifications and ${deletedUserNotifications} user notification records.`, - ); - } catch (err) { - await transaction.rollback(); - - console.error( - "[CRON] Failed to clean inactive notifications:", - err, - ); - } - } catch (err) { - console.error( - "[CRON] Error while checking inactive notifications:", - err, - ); - } - }, - { - scheduled: false, - }, - ); - - task.start(); - - return task; -} - -module.exports = startCleanInactiveNotificationsCron; - +module.exports = function startCleanInactiveNotificationsCron() { + const task = cron.schedule("0 0 * * *", async () => { + const cutoff = new Date(Date.now() - Number(process.env.NOTIFICATION_RETENTION_DAYS || 90) * 86400000); + for (;;) { + const rows = await notification.findAll({ where: { isActive: false, updatedAt: { [Op.lt]: cutoff } }, attributes: ["notification_id"], limit: 500, order: [["updatedAt", "ASC"]] }); + if (!rows.length) break; + const ids = rows.map((row) => row.notification_id); + await sequelize.transaction(async (transaction) => { await userNotification.destroy({ where: { notification_id: { [Op.in]: ids } }, transaction }); await notification.destroy({ where: { notification_id: { [Op.in]: ids }, isActive: false }, transaction }); }); + if (rows.length < 500) break; + } + }, { scheduled: false }); + task.start(); return task; +}; diff --git a/migrations/20260903020000-phase-2-cross-cutting-services.js b/migrations/20260903020000-phase-2-cross-cutting-services.js new file mode 100644 index 0000000..d78d2d3 --- /dev/null +++ b/migrations/20260903020000-phase-2-cross-cutting-services.js @@ -0,0 +1,43 @@ +"use strict"; + +const add = (q, table, column, definition, transaction) => q.addColumn(table, column, definition, { transaction }); +module.exports = { + async up(q, Sequelize) { + await q.sequelize.transaction(async (transaction) => { + await add(q, "uploads", "storage_provider", { type: Sequelize.STRING, allowNull: false, defaultValue: "S3" }, transaction); + await add(q, "uploads", "safe_name", { type: Sequelize.STRING, allowNull: true }, transaction); + await add(q, "uploads", "checksum", { type: Sequelize.STRING(64), allowNull: true }, transaction); + await add(q, "uploads", "owner_type", { type: Sequelize.STRING, allowNull: false, defaultValue: "USER" }, transaction); + await add(q, "uploads", "owner_id", { type: Sequelize.STRING, allowNull: true }, transaction); + await add(q, "uploads", "visibility", { type: Sequelize.ENUM("PRIVATE", "PUBLIC"), allowNull: false, defaultValue: "PRIVATE" }, transaction); + await add(q, "uploads", "status", { type: Sequelize.ENUM("PENDING", "AVAILABLE", "FAILED", "DELETED"), allowNull: false, defaultValue: "AVAILABLE" }, transaction); + await add(q, "uploads", "deletedAt", { type: Sequelize.DATE, allowNull: true }, transaction); + await q.sequelize.query("UPDATE uploads SET owner_id = uploaded_by WHERE owner_id IS NULL", { transaction }); + await q.changeColumn("uploads", "owner_id", { type: Sequelize.STRING, allowNull: false }, { transaction }); + await q.addIndex("uploads", ["owner_id", "status"], { name: "uploads_owner_status_idx", transaction }); + + await q.addIndex("user_notification", ["user_id", "notification_id"], { unique: true, name: "user_notification_user_notification_unique", transaction }); + await q.addIndex("user_notification", ["user_id", "isRead", "createdAt"], { name: "user_notification_inbox_idx", transaction }); + await q.addIndex("notification", ["isActive", "createdAt"], { name: "notification_retention_idx", transaction }); + await q.createTable("notification_deliveries", { + id: { type: Sequelize.STRING, primaryKey: true }, notificationId: { type: Sequelize.STRING, allowNull: true }, userId: { type: Sequelize.STRING, allowNull: true }, + channel: { type: Sequelize.ENUM("EMAIL", "PUSH"), allowNull: false }, recipient: { type: Sequelize.STRING, allowNull: false }, templateKey: { type: Sequelize.STRING, allowNull: false }, + status: { type: Sequelize.ENUM("QUEUED", "PROCESSING", "SENT", "FAILED"), allowNull: false, defaultValue: "QUEUED" }, attemptCount: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 }, + lastAttemptAt: Sequelize.DATE, sentAt: Sequelize.DATE, failedAt: Sequelize.DATE, failureCode: Sequelize.STRING, createdAt: { type: Sequelize.DATE, allowNull: false }, updatedAt: { type: Sequelize.DATE, allowNull: false }, + }, { transaction }); + await q.addIndex("notification_deliveries", ["status", "createdAt"], { name: "notification_delivery_status_idx", transaction }); + + for (const [name, type, nullable] of [["event_id", Sequelize.STRING, true],["target_type",Sequelize.STRING,true],["target_id",Sequelize.STRING,true],["ip_address",Sequelize.STRING,true],["user_agent",Sequelize.STRING,true],["request_id",Sequelize.STRING,true],["metadata",Sequelize.JSON,true],["occurred_at",Sequelize.DATE,true]]) await add(q, "UserActivity", name, { type, allowNull: nullable }, transaction); + await q.addIndex("UserActivity", ["event_id"], { unique: true, name: "activity_event_unique", transaction }); + await q.addIndex("UserActivity", ["user_id", "occurred_at"], { name: "activity_actor_time_idx", transaction }); + await q.changeColumn("UserActivity", "user_id", { type: Sequelize.STRING, allowNull: true }, { transaction }); + + await add(q, "Document", "created_by", { type: Sequelize.STRING, allowNull: true }, transaction); await add(q, "Document", "owner_type", { type: Sequelize.STRING, allowNull: false, defaultValue: "USER" }, transaction); await add(q, "Document", "owner_id", { type: Sequelize.STRING, allowNull: true }, transaction); + await add(q, "Document", "storage_upload_id", { type: Sequelize.INTEGER, allowNull: true }, transaction); await add(q, "Document", "job_id", { type: Sequelize.STRING, allowNull: true }, transaction); + await add(q, "Document", "generated_at", { type: Sequelize.DATE, allowNull: true }, transaction); await add(q, "Document", "failed_at", { type: Sequelize.DATE, allowNull: true }, transaction); await add(q, "Document", "failure_code", { type: Sequelize.STRING, allowNull: true }, transaction); await add(q, "Document", "version", { type: Sequelize.INTEGER, allowNull: false, defaultValue: 1 }, transaction); + await q.addIndex("Document", ["job_id"], { unique: true, name: "document_job_unique", transaction }); await q.addIndex("Document", ["owner_id", "status"], { name: "document_owner_status_idx", transaction }); + await q.addIndex("DocumentType", ["doc_type_name"], { unique: true, name: "document_type_name_unique", transaction }); + }); + }, + async down() { throw new Error("Phase 2 migration is forward-only; restore from backup for rollback"); }, +}; diff --git a/tests/unit/cross-cutting-services.test.js b/tests/unit/cross-cutting-services.test.js new file mode 100644 index 0000000..69591a7 --- /dev/null +++ b/tests/unit/cross-cutting-services.test.js @@ -0,0 +1,20 @@ +describe("Phase 2 cross-cutting policies", () => { + test("email templates escape user-controlled HTML", () => { + const { loadTemplate } = require("../../app/utils/mail.util"); + const html = loadTemplate("otp", { firstName: "", otp: "123456" }); + expect(html).toContain("<script>x</script>"); expect(html).not.toContain(""); + }); + test("security notifications bypass disabled marketing preference", () => { + const { channelAllowed } = require("../../app/services/notification/notification-policy.service"); + expect(channelAllowed({ eventType: "LOGIN_OTP", channel: "EMAIL", profile: { notificationsEnabled: false } })).toBe(true); + expect(channelAllowed({ eventType: "MARKETING", channel: "EMAIL", profile: { notificationsEnabled: false } })).toBe(false); + }); + test("queue policies specify bounded retries and retention", () => { + jest.resetModules(); + jest.doMock("../../app/config/redisClient", () => ({})); + jest.doMock("bullmq", () => ({ Queue: jest.fn(function Queue(name, options) { this.name = name; this.opts = options; }) })); + const emailQueue = require("../../app/queues/email.queue"); + expect(emailQueue.opts.defaultJobOptions.attempts).toBe(5); + expect(emailQueue.opts.defaultJobOptions.removeOnComplete).toBeTruthy(); + }); +}); diff --git a/tests/unit/storage.service.test.js b/tests/unit/storage.service.test.js new file mode 100644 index 0000000..6132828 --- /dev/null +++ b/tests/unit/storage.service.test.js @@ -0,0 +1,21 @@ +describe("Phase 2 file validation", () => { + beforeEach(() => jest.resetModules()); + test("detects a valid PNG and creates checksum/safe name", () => { + const { validateUpload } = require("../../app/services/storage/file-validation.service"); + const buffer = Buffer.concat([Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]), Buffer.from("payload")]); + const result = validateUpload({ buffer, mimetype: "image/png", originalname: "../unsafe name.svg" }); + expect(result.mimeType).toBe("image/png"); expect(result.safeName).toBe("unsafe_name.png"); expect(result.checksum).toMatch(/^[a-f0-9]{64}$/); + }); + test("rejects claimed MIME that disagrees with magic bytes", () => { + const { validateUpload } = require("../../app/services/storage/file-validation.service"); + expect(() => validateUpload({ buffer: Buffer.from("not an image"), mimetype: "image/png", originalname: "x.png" })).toThrow("does not match"); + }); + test("rejects empty files", () => { + const { validateUpload } = require("../../app/services/storage/file-validation.service"); + expect(() => validateUpload({ buffer: Buffer.alloc(0), mimetype: "image/png", originalname: "x.png" })).toThrow("Empty"); + }); + test("builds controlled owner/date keys without original filenames", () => { + const { createObjectKey } = require("../../app/services/storage/storage.service"); + expect(createObjectKey({ ownerId: "usr_1", mimeType: "application/pdf", purpose: "document", now: new Date("2026-09-03T00:00:00Z"), id: "fixed" })).toBe("documents/usr_1/2026/09/fixed.pdf"); + }); +}); From fd4e3245718c8d6004e4cb4a67d5d0027d9eb161 Mon Sep 17 00:00:00 2001 From: Sathira Sri Sathara Date: Thu, 3 Sep 2026 14:58:26 +0530 Subject: [PATCH 14/16] feat: implement customer profile management and business application features - Added customer profile controller with endpoints for retrieving, updating, and deactivating user profiles. - Introduced address model and routes for managing user addresses. - Created business application model and service for handling business applications, including approval and rejection processes. - Developed business contact and credit account models to support business customer functionalities. - Implemented settlement term model for managing payment terms. - Added email templates for business application notifications (approved, rejected, received, and status changes). - Enhanced validation schemas for customer and business inputs to ensure data integrity. - Created unit tests for business application service and validation schemas to ensure functionality and correctness. - Added migration scripts to set up new database tables and columns for customer and business features. --- Documentation/API_CUSTOMER_BUSINESS.md | 79 +++++++++++++++ Documentation/CURRENT_BACKEND_STATUS.md | 8 ++ .../PHASE_3_CUSTOMER_BUSINESS_FOUNDATIONS.md | 99 +++++++++++++++++++ app/constants/permissions.js | 3 + app/controllers/address.controller.js | 8 ++ app/controllers/business.controller.js | 20 ++++ app/controllers/customerProfile.controller.js | 11 +++ app/controllers/upload.controller.js | 3 +- app/models/index.js | 5 + app/models/user/address.model.js | 13 +++ app/models/user/businessApplication.model.js | 9 ++ app/models/user/businessContact.model.js | 4 + .../user/businessCreditAccount.model.js | 4 + app/models/user/businessCustomer.model.js | 14 ++- app/models/user/profile.model.js | 6 +- app/models/user/settlementTerm.model.js | 1 + app/models/user/user.model.js | 2 + app/queues/email.queue.js | 11 ++- app/routes/address.routes.js | 2 + app/routes/adminBusiness.routes.js | 1 + app/routes/business.routes.js | 1 + app/routes/index.js | 6 ++ app/routes/profile.routes.js | 5 + app/routes/user.routes.js | 4 + app/services/business/business.service.js | 7 ++ app/services/email/email.service.js | 1 + .../notification/notification.service.js | 2 + .../emails/businessAccountStatusChanged.html | 1 + .../emails/businessApplicationApproved.html | 1 + .../emails/businessApplicationReceived.html | 1 + .../emails/businessApplicationRejected.html | 1 + app/validation/business.schemas.js | 3 + app/validation/customer.schemas.js | 9 ++ ...0-phase-3-customer-business-foundations.js | 14 +++ tests/unit/business.service.test.js | 11 +++ tests/unit/customer-business.schemas.test.js | 42 ++++++++ 36 files changed, 406 insertions(+), 6 deletions(-) create mode 100644 Documentation/API_CUSTOMER_BUSINESS.md create mode 100644 Documentation/PHASE_3_CUSTOMER_BUSINESS_FOUNDATIONS.md create mode 100644 app/controllers/address.controller.js create mode 100644 app/controllers/business.controller.js create mode 100644 app/controllers/customerProfile.controller.js create mode 100644 app/models/user/address.model.js create mode 100644 app/models/user/businessApplication.model.js create mode 100644 app/models/user/businessContact.model.js create mode 100644 app/models/user/businessCreditAccount.model.js create mode 100644 app/models/user/settlementTerm.model.js create mode 100644 app/routes/address.routes.js create mode 100644 app/routes/adminBusiness.routes.js create mode 100644 app/routes/business.routes.js create mode 100644 app/services/business/business.service.js create mode 100644 app/services/notification/notification.service.js create mode 100644 app/templates/emails/businessAccountStatusChanged.html create mode 100644 app/templates/emails/businessApplicationApproved.html create mode 100644 app/templates/emails/businessApplicationReceived.html create mode 100644 app/templates/emails/businessApplicationRejected.html create mode 100644 app/validation/business.schemas.js create mode 100644 app/validation/customer.schemas.js create mode 100644 migrations/20260903030000-phase-3-customer-business-foundations.js create mode 100644 tests/unit/business.service.test.js create mode 100644 tests/unit/customer-business.schemas.test.js diff --git a/Documentation/API_CUSTOMER_BUSINESS.md b/Documentation/API_CUSTOMER_BUSINESS.md new file mode 100644 index 0000000..648a109 --- /dev/null +++ b/Documentation/API_CUSTOMER_BUSINESS.md @@ -0,0 +1,79 @@ +# ZUMRI Customer and Business API + +All endpoints require a Phase 1 access token and are mounted under both `/api` and `/api/v1`; new clients should use `/api/v1`. Examples use placeholders. + +## Customer profile + +- `GET /api/v1/profile/me` +- `PATCH /api/v1/profile/me` + +Editable fields are first/last name, phone, date of birth, `en|si|ta` locale, theme, marketing email/push preference, in-app preference, and owned avatar/background upload IDs. Identity state, type, roles, permissions, tokens, passwords, business review data, partner ID, and credit settings are rejected. + +```json +{"firstName":"","preferredLanguage":"en","marketingEmailEnabled":false} +``` + +Media responses contain an upload ID and short-lived authorized URL, never an object key. + +## Addresses + +- `GET /api/v1/addresses` +- `POST /api/v1/addresses` +- `GET /api/v1/addresses/:id` +- `PATCH /api/v1/addresses/:id` +- `DELETE /api/v1/addresses/:id` + +No user ID is accepted. Every query includes the authenticated owner. Setting either default flag clears the prior default under a transaction and User row lock. Deleting a default leaves that default unset. Future orders must snapshot addresses; they must never depend on mutable Address rows. + +```json +{"label":"Home","recipientName":"","phoneNumber":"","addressLine1":"","city":"","countryCode":"LK","isDefaultShipping":true} +``` + +## Account deactivation + +`POST /api/v1/user/me/deactivate` with `{"confirmation":"DEACTIVATE","password":""}`. Password is required for password-based identities. Social-only identities require explicit confirmation. The operation sets DEACTIVATED, increments token version, revokes all sessions, and clears cookies. Self-reactivation is not supported; an authorized manual administrative process is required. + +## Business applications + +- `POST /api/v1/business/applications` +- `GET /api/v1/business/applications/me` +- `GET /api/v1/business/applications/:id` + +Only verified ACTIVE customer accounts can apply. Ownership is applied to ID reads. Concurrent active applications are serialized by locking the applicant User. + +```json +{"businessName":"","legalName":"","registrationNumber":"","businessType":"","contactEmail":"owner@example.com","contactPhone":""} +``` + +Private supporting documents use the Phase 2 upload API with purpose `BUSINESS_REGISTRATION`, `TAX_DOCUMENT`, `IDENTITY_DOCUMENT`, or `OTHER_SUPPORTING_DOCUMENT`. Only the owner or a reviewer with `business.applications.review` can obtain a signed URL. + +## Business self-service + +- `GET /api/v1/business/me` +- `PATCH /api/v1/business/me` +- `POST /api/v1/business/me/contacts` +- `POST /api/v1/business/me/addresses` + +Partner ID, review status, domain status, credit, settlement term, identity type, and approval metadata cannot be changed through self-service. + +## Administrative review + +- `GET /api/v1/admin/business/applications` — `business.applications.read` +- `GET /api/v1/admin/business/applications/:id` — same permission +- `POST /api/v1/admin/business/applications/:id/approve` — `business.applications.review` +- `POST /api/v1/admin/business/applications/:id/reject` — same permission; requires a reason +- `GET /api/v1/admin/business/accounts` — `business.accounts.read` +- `PATCH /api/v1/admin/business/accounts/:id/status` — `business.accounts.update` + +Lists accept bounded `page`, `limit`, status/name filters and allowlisted sorting. Approval atomically locks the application/applicant, creates one profile and disabled credit account, assigns a `ZUM-BIZ-######` partner ID, changes account type to `business_customer`, revokes sessions, and marks the application approved. + +## Credit and settlement primitives + +- `PATCH /api/v1/admin/business/accounts/:id/credit` — `business.credit.manage` +- `PATCH /api/v1/admin/business/accounts/:id/settlement-term` — `business.settlement.manage` + +```json +{"creditLimit":"100000.00","currency":"LKR","status":"ACTIVE"} +``` + +Credit uses `DECIMAL(15,2)`. There is intentionally no used or available balance until a future authoritative commerce/settlement ledger exists. Settlement terms are seeded configuration records only; this phase creates no invoices or settlements. diff --git a/Documentation/CURRENT_BACKEND_STATUS.md b/Documentation/CURRENT_BACKEND_STATUS.md index f3bc3b4..d1780cd 100644 --- a/Documentation/CURRENT_BACKEND_STATUS.md +++ b/Documentation/CURRENT_BACKEND_STATUS.md @@ -1,5 +1,13 @@ # ZUMRI Current Backend Status +## Phase 3 Completion Update + +Completion date: 2026-09-03. Module 02 (customer profile/address management) is now approximately 88%; Module 13 (business accounts) is approximately 78%. Customer profile/preferences, structured owned addresses with transactional defaults, secure self-deactivation, business applications, permission-gated transactional approval, immutable partner identity, business profiles/contacts/addresses, domain status, DECIMAL credit configuration, and settlement-term primitives are implemented. + +Security impact: all customer resource identity is derived from Phase 1 authentication; mutation schemas are strict; profile media is owner-validated; address/application IDOR is constrained in queries; approval/deactivation revoke sessions after identity-boundary changes; and business financial/review fields are excluded from self-service. Six models were added and two existing models extended. Customer/business and admin APIs are documented in `Documentation/API_CUSTOMER_BUSINESS.md`. + +The complete mocked regression suite passes 12 suites/57 tests, and syntax validation passes 171 JavaScript files. The Phase 3 migration is forward-only and was not executed. Staging must resolve legacy business/profile/address pre-checks, seed/grant permissions, and validate MySQL concurrency plus Redis/S3/SMTP flows before Phase 4. See `Documentation/PHASE_3_CUSTOMER_BUSINESS_FOUNDATIONS.md`. + ## Phase 2 Completion Update Completion date: 2026-09-03. Phase 2 hardens the existing shared-service foundation without adding commerce domains. Module 14 (notifications) is now approximately 72%; Module 19 (file/media) 82%; Module 20 (audit/config/logging) 68%; and Module 21 (background jobs) 78%. The document subsystem is approximately 82%. diff --git a/Documentation/PHASE_3_CUSTOMER_BUSINESS_FOUNDATIONS.md b/Documentation/PHASE_3_CUSTOMER_BUSINESS_FOUNDATIONS.md new file mode 100644 index 0000000..8525c8f --- /dev/null +++ b/Documentation/PHASE_3_CUSTOMER_BUSINESS_FOUNDATIONS.md @@ -0,0 +1,99 @@ +# ZUMRI Phase 3 Customer and Business Foundations + +## Objective + +Complete customer and business account foundations needed before catalogue and commerce work, without implementing commerce. + +## Existing Components Reused + +Phase 1 authentication, account states, session revocation, canonical `business_customer`, permissions, and ReferenceNumber are reused. Phase 2 owned uploads, signed media, audit events, notification persistence, email queue, and templates remain shared boundaries. + +## Customer Profile + +Profile retains its existing phone/date/media/theme data and adds locale plus separate marketing email, marketing push, and in-app preferences. First/last name and email remain canonical User identity fields. Strict self endpoints derive identity from authentication and return no security or storage internals. + +## Address Architecture + +Address is structured and may belong to exactly one User or approved business profile. It supports international ISO alpha-2 country codes while retaining district/province fields useful in Sri Lanka. An ownership CHECK constraint and foreign keys enforce integrity. + +## Default Address Rules + +A User/business profile may use one shipping and one billing default, and one row may be both. Mutations serialize on the owner row and clear the prior default in the same transaction. Deletion leaves the default unset. Future commerce records must snapshot address values. + +## Preferences + +Marketing email and push choices are distinct from in-app preference and Phase 2 mandatory security-message policy. Security messages cannot be disabled through these fields. + +## Account Deactivation + +Self-deactivation is a soft identity transition. Password accounts confirm the current password; social-only accounts provide the explicit DEACTIVATE confirmation. The transaction increments token version and revokes every session. Login remains denied and self-reactivation is deferred. + +## Business Application Architecture + +Applications preserve review history independently of the approved profile. Only verified ACTIVE customers can submit, and the applicant row lock prevents concurrent active submissions. States are PENDING, UNDER_REVIEW, APPROVED, REJECTED, and CANCELLED. + +## Business Approval Workflow + +Permission-gated approval locks the application and applicant, validates transition, generates one partner sequence, creates BusinessCustomer and a disabled zero-limit credit account, changes the canonical account type, increments token version/revokes sessions, and records approval. Double approval fails safely. + +## Business Profile + +The existing BusinessCustomer table is retained as the approved business profile. It now includes legal/tax/web data, immutable unique partner ID, separate ACTIVE/SUSPENDED/CLOSED domain status, approval metadata, and settlement reference. Self-editing is allowlisted. + +## Partner Identity + +Partner identity is generated transactionally from ReferenceNumber as `ZUM-BIZ-######`. It is unique, immutable through public APIs, safe to expose, and never previewed through GET. + +## Business Contacts + +BusinessContact holds non-authenticating contact people. A transaction serializes primary-contact changes without creating User identities or organization/team accounts. + +## Business Documents + +Applications reuse private Phase 2 Upload records and its MIME/signature/checksum controls. Defined purposes identify registration, tax, identity, and supporting records. Review permission extends signed-read access; storage keys remain private. + +## Business Status + +Business domain status is separate from User account status. Suspending a business capability does not automatically suspend login identity. + +## Credit Foundation + +BusinessCreditAccount stores currency, `DECIMAL(15,2)` limit, and DISABLED/ACTIVE/SUSPENDED configuration. Only privileged administration may change it and every change is audited. No used-credit field or fabricated availability is present. + +## Settlement Terms + +SettlementTerm provides code, display name, day count, and active state. PREPAID, NET_7, NET_14, and NET_30 initial records are migration data, not hardcoded behavior. No billing, invoice, settlement, or product-pricing logic exists. + +## Ownership + +Customer profile/address/application queries derive the authenticated User and constrain IDs at query time. Business self-service resolves the profile by authenticated owner. Reviewer and administration routes require explicit permissions. + +## Permissions + +Added `business.applications.read`, `business.applications.review`, `business.accounts.read`, `business.accounts.update`, `business.credit.manage`, and `business.settlement.manage`. SUPER_ADMIN retains the established bypass. + +## Audit Events + +Profile, address, deactivation, application review, profile/status, credit, and settlement changes emit Phase 2 events with stable names and IDs. Full addresses, documents, credentials, and reviewer internals are not placed in metadata. + +## Notifications + +Application receipt/approval/rejection and business status templates use Notification/UserNotification and the Phase 2 email queue. Controllers do not call SMTP. Push delivery remains deferred. + +## Database Changes + +The new forward-only Phase 3 migration extends Profile and BusinessCustomer, creates addresses, business applications/contacts/credit accounts and settlement terms, adds targeted indexes/constraints, and seeds initial settlement configuration. + +Pre-check existing `business_customer` users, duplicate registration or partner IDs, invalid/null Profile relationships, legacy Customer address strings requiring manual migration, existing BusinessCustomer records that need partner/approval backfill, and duplicate profiles. No legacy data is silently deleted. + +## Tests + +New unit tests cover strict profile/business mass assignment, locale/address validation, deactivation confirmation, business application eligibility/duplicate prevention, rejection requirements, and credit validation. Existing Phase 0-2 suites remain mandatory. + +## Remaining Known Issues + +The migration has not run against staging. Legacy BusinessCustomer rows require an explicit partner/approval backfill before making new columns universally non-null. Real MySQL lock/concurrency behavior, Redis queues, SMTP notifications, S3 documents, and migration constraints need staging tests. Business document-to-application linking and administrative settlement-term CRUD may be added when real operational requirements are known. + +## Phase 4 Prerequisites + +Back up and restore staging data, complete pre-check/backfill decisions, apply migrations in order, seed/grant Phase 3 permissions, run concurrent application/default-address tests on MySQL, and verify one application approval plus notification flow with non-production integrations. diff --git a/app/constants/permissions.js b/app/constants/permissions.js index e7ca20d..7f89de6 100644 --- a/app/constants/permissions.js +++ b/app/constants/permissions.js @@ -17,4 +17,7 @@ module.exports = { DOCUMENTS_READ: "documents.read", DOCUMENTS_CREATE: "documents.create", DOCUMENTS_DELETE: "documents.delete", NOTIFICATIONS_MANAGE: "notifications.manage", NOTIFICATIONS_READ: "notifications.read", AUDIT_READ: "audit.read", QUEUES_READ: "queues.read", + BUSINESS_APPLICATIONS_READ: "business.applications.read", BUSINESS_APPLICATIONS_REVIEW: "business.applications.review", + BUSINESS_ACCOUNTS_READ: "business.accounts.read", BUSINESS_ACCOUNTS_UPDATE: "business.accounts.update", + BUSINESS_CREDIT_MANAGE: "business.credit.manage", BUSINESS_SETTLEMENT_MANAGE: "business.settlement.manage", }; diff --git a/app/controllers/address.controller.js b/app/controllers/address.controller.js new file mode 100644 index 0000000..7d64150 --- /dev/null +++ b/app/controllers/address.controller.js @@ -0,0 +1,8 @@ +const crypto=require("crypto"); const db=require("../models"); const {logActivity}=require("../services/activity.service"); +const map=(b)=>({label:b.label,recipient_name:b.recipientName,phone_number:b.phoneNumber,address_line_1:b.addressLine1,address_line_2:b.addressLine2,city:b.city,district:b.district,province:b.province,postal_code:b.postalCode,country_code:b.countryCode,is_default_shipping:b.isDefaultShipping,is_default_billing:b.isDefaultBilling}); +const setDefaults=async(userId,id,b,t)=>{if(b.isDefaultShipping)await db.Address.update({is_default_shipping:false},{where:{user_id:userId},transaction:t});if(b.isDefaultBilling)await db.Address.update({is_default_billing:false},{where:{user_id:userId},transaction:t});}; +exports.list=async(req,res,next)=>{try{return res.json({success:true,data:await db.Address.findAll({where:{user_id:req.user.id},order:[["createdAt","ASC"]]})});}catch(e){return next(e);}}; +exports.get=async(req,res,next)=>{try{const row=await db.Address.findOne({where:{id:req.params.id,user_id:req.user.id}});if(!row)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Address not found"}});return res.json({success:true,data:row});}catch(e){return next(e);}}; +exports.create=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{await db.User.findByPk(req.user.id,{transaction:t,lock:t.LOCK.UPDATE});await setDefaults(req.user.id,null,req.body,t);row=await db.Address.create({id:crypto.randomUUID(),user_id:req.user.id,...map(req.body)},{transaction:t});});await logActivity({user:req.user,type:"ADDRESS_CREATED",module:"Customer",description:"Customer address created",targetType:"ADDRESS",targetId:row.id,requestId:req.id});return res.status(201).json({success:true,data:row});}catch(e){return next(e);}}; +exports.update=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{await db.User.findByPk(req.user.id,{transaction:t,lock:t.LOCK.UPDATE});row=await db.Address.findOne({where:{id:req.params.id,user_id:req.user.id},transaction:t,lock:t.LOCK.UPDATE});if(!row)throw Object.assign(new Error("Address not found"),{status:404,code:"NOT_FOUND"});await setDefaults(req.user.id,row.id,req.body,t);await row.update(map(req.body),{transaction:t});});await logActivity({user:req.user,type:"ADDRESS_UPDATED",module:"Customer",description:"Customer address updated",targetType:"ADDRESS",targetId:row.id,requestId:req.id});return res.json({success:true,data:row});}catch(e){return next(e);}}; +exports.remove=async(req,res,next)=>{try{const count=await db.Address.destroy({where:{id:req.params.id,user_id:req.user.id}});if(!count)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Address not found"}});await logActivity({user:req.user,type:"ADDRESS_DELETED",module:"Customer",description:"Customer address deleted",targetType:"ADDRESS",targetId:req.params.id,requestId:req.id});return res.status(204).end();}catch(e){return next(e);}}; diff --git a/app/controllers/business.controller.js b/app/controllers/business.controller.js new file mode 100644 index 0000000..857d441 --- /dev/null +++ b/app/controllers/business.controller.js @@ -0,0 +1,20 @@ +const crypto=require("crypto"); const {Op}=require("sequelize"); const db=require("../models"); const service=require("../services/business/business.service"); const {logActivity}=require("../services/activity.service"); const notifications=require("../services/notification/notification.service"); +const safeApp=a=>({id:a.id,businessName:a.business_name,legalName:a.legal_name,registrationNumber:a.registration_number,taxNumber:a.tax_number,businessType:a.business_type,contactEmail:a.contact_email,contactPhone:a.contact_phone,website:a.website,status:a.status,reviewedAt:a.reviewed_at,rejectionReason:a.rejection_reason,createdAt:a.createdAt}); +const safeProfile=p=>({id:p.business_customer_id,businessName:p.businessName,legalName:p.legalName,registrationNumber:p.businessRegistrationNumber,taxNumber:p.taxNumber,businessType:p.businessType,contactEmail:p.businessEmail,contactPhone:p.phoneNumber,website:p.website,partnerId:p.partnerId,status:p.status,approvedAt:p.approvedAt,settlementTermId:p.settlement_term_id}); +exports.apply=async(req,res,next)=>{try{const app=await service.apply(req.user.id,req.body);await logActivity({user:req.user,type:"BUSINESS_APPLICATION_SUBMITTED",module:"Business",description:"Business application submitted",targetType:"BUSINESS_APPLICATION",targetId:app.id,requestId:req.id});await notifications.publish({type:"BUSINESS_APPLICATION_SUBMITTED",user:req.user,headline:"Business application received",description:"Your application is awaiting review.",templateKey:"businessApplicationReceived",variables:{firstName:req.user.firstName,businessName:app.business_name,applicationId:app.id},correlationId:req.id}).catch(()=>undefined);return res.status(201).json({success:true,data:safeApp(app)});}catch(e){return next(e);}}; +exports.myApplications=async(req,res,next)=>{try{return res.json({success:true,data:(await db.BusinessApplication.findAll({where:{user_id:req.user.id},order:[["createdAt","DESC"]]})).map(safeApp)});}catch(e){return next(e);}}; +exports.getApplication=async(req,res,next)=>{try{const a=await db.BusinessApplication.findOne({where:{id:req.params.id,user_id:req.user.id}});if(!a)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Application not found"}});return res.json({success:true,data:safeApp(a)});}catch(e){return next(e);}}; +exports.getMe=async(req,res,next)=>{try{const p=await db.BusinessCustomer.findOne({where:{user_id:req.user.id},include:[{model:db.BusinessContact,as:"contacts"},{model:db.Address,as:"addresses"},{model:db.BusinessCreditAccount,as:"creditAccount",attributes:["currency","credit_limit","status"]},{model:db.SettlementTerm,as:"settlementTerm"}]});if(!p)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Business profile not found"}});return res.json({success:true,data:{...safeProfile(p),contacts:p.contacts,addresses:p.addresses,creditAccount:p.creditAccount,settlementTerm:p.settlementTerm}});}catch(e){return next(e);}}; +exports.updateMe=async(req,res,next)=>{try{const p=await db.BusinessCustomer.findOne({where:{user_id:req.user.id}});if(!p)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Business profile not found"}});const b=req.body;await p.update({...(b.businessName!==undefined&&{businessName:b.businessName}),...(b.legalName!==undefined&&{legalName:b.legalName}),...(b.taxNumber!==undefined&&{taxNumber:b.taxNumber}),...(b.businessType!==undefined&&{businessType:b.businessType}),...(b.contactEmail!==undefined&&{businessEmail:b.contactEmail}),...(b.contactPhone!==undefined&&{phoneNumber:b.contactPhone}),...(b.website!==undefined&&{website:b.website})});await logActivity({user:req.user,type:"BUSINESS_PROFILE_UPDATED",module:"Business",description:"Business profile updated",targetType:"BUSINESS_PROFILE",targetId:p.business_customer_id,requestId:req.id});return res.json({success:true,data:safeProfile(p)});}catch(e){return next(e);}}; +exports.addContact=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{const p=await db.BusinessCustomer.findOne({where:{user_id:req.user.id},transaction:t,lock:t.LOCK.UPDATE});if(!p)throw Object.assign(new Error("Business profile not found"),{status:404,code:"NOT_FOUND"});if(req.body.isPrimary)await db.BusinessContact.update({is_primary:false},{where:{business_profile_id:p.business_customer_id},transaction:t});row=await db.BusinessContact.create({id:crypto.randomUUID(),business_profile_id:p.business_customer_id,name:req.body.name,title:req.body.title,email:req.body.email,phone:req.body.phone,is_primary:Boolean(req.body.isPrimary)},{transaction:t});});return res.status(201).json({success:true,data:row});}catch(e){return next(e);}}; +exports.addAddress=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{const p=await db.BusinessCustomer.findOne({where:{user_id:req.user.id},transaction:t,lock:t.LOCK.UPDATE});if(!p)throw Object.assign(new Error("Business profile not found"),{status:404,code:"NOT_FOUND"});const b=req.body;if(b.isDefaultShipping)await db.Address.update({is_default_shipping:false},{where:{business_profile_id:p.business_customer_id},transaction:t});if(b.isDefaultBilling)await db.Address.update({is_default_billing:false},{where:{business_profile_id:p.business_customer_id},transaction:t});row=await db.Address.create({id:crypto.randomUUID(),business_profile_id:p.business_customer_id,label:b.label,recipient_name:b.recipientName,phone_number:b.phoneNumber,address_line_1:b.addressLine1,address_line_2:b.addressLine2,city:b.city,district:b.district,province:b.province,postal_code:b.postalCode,country_code:b.countryCode,address_type:"DELIVERY",is_default_shipping:Boolean(b.isDefaultShipping),is_default_billing:Boolean(b.isDefaultBilling)},{transaction:t});});return res.status(201).json({success:true,data:row});}catch(e){return next(e);}}; +const paging=q=>({limit:Math.min(Number(q.limit)||20,100),offset:(Math.max(Number(q.page)||1,1)-1)*Math.min(Number(q.limit)||20,100)}); +exports.adminListApplications=async(req,res,next)=>{try{const where={};if(req.query.status)where.status=req.query.status;if(req.query.businessName)where.business_name={[Op.like]:`%${req.query.businessName.slice(0,100)}%`};const {limit,offset}=paging(req.query);const result=await db.BusinessApplication.findAndCountAll({where,limit,offset,order:[[req.query.sort==="status"?"status":"createdAt",req.query.direction==="asc"?"ASC":"DESC"]]});return res.json({success:true,data:result.rows.map(safeApp),pagination:{total:result.count,limit,offset}});}catch(e){return next(e);}}; +exports.adminGetApplication=async(req,res,next)=>{try{const a=await db.BusinessApplication.findByPk(req.params.id);if(!a)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Application not found"}});return res.json({success:true,data:safeApp(a)});}catch(e){return next(e);}}; +exports.approve=async(req,res,next)=>{try{const {application,profile}=await service.approve(req.params.id,req.user.id);const applicant=await db.User.findByPk(application.user_id);await logActivity({user:req.user,type:"BUSINESS_APPLICATION_APPROVED",module:"Business",description:"Business application approved",targetType:"BUSINESS_APPLICATION",targetId:application.id,requestId:req.id,metadata:{partnerId:profile.partnerId}});await notifications.publish({type:"BUSINESS_APPLICATION_APPROVED",user:applicant,headline:"Business application approved",description:"Your business account is active.",templateKey:"businessApplicationApproved",variables:{firstName:applicant.firstName,businessName:profile.businessName,partnerId:profile.partnerId},correlationId:req.id}).catch(()=>undefined);return res.json({success:true,data:{application:safeApp(application),profile:safeProfile(profile)}});}catch(e){return next(e);}}; +exports.reject=async(req,res,next)=>{try{const a=await service.reject(req.params.id,req.user.id,req.body.reason);const applicant=await db.User.findByPk(a.user_id);await logActivity({user:req.user,type:"BUSINESS_APPLICATION_REJECTED",module:"Business",description:"Business application rejected",targetType:"BUSINESS_APPLICATION",targetId:a.id,requestId:req.id});await notifications.publish({type:"BUSINESS_APPLICATION_REJECTED",user:applicant,headline:"Business application reviewed",description:"Your application was not approved.",templateKey:"businessApplicationRejected",variables:{firstName:applicant.firstName,businessName:a.business_name,reason:a.rejection_reason},correlationId:req.id}).catch(()=>undefined);return res.json({success:true,data:safeApp(a)});}catch(e){return next(e);}}; +exports.adminListBusinesses=async(req,res,next)=>{try{const where={};if(req.query.status)where.status=req.query.status;if(req.query.partnerId)where.partnerId=req.query.partnerId;if(req.query.businessName)where.businessName={[Op.like]:`%${req.query.businessName.slice(0,100)}%`};const {limit,offset}=paging(req.query);const x=await db.BusinessCustomer.findAndCountAll({where,limit,offset,order:[["createdAt","DESC"]]});return res.json({success:true,data:x.rows.map(safeProfile),pagination:{total:x.count,limit,offset}});}catch(e){return next(e);}}; +exports.setCredit=async(req,res,next)=>{try{let row,old;await db.sequelize.transaction(async t=>{const p=await db.BusinessCustomer.findByPk(req.params.id,{transaction:t,lock:t.LOCK.UPDATE});if(!p)throw Object.assign(new Error("Business not found"),{status:404,code:"NOT_FOUND"});row=await db.BusinessCreditAccount.findOne({where:{business_profile_id:p.business_customer_id},transaction:t,lock:t.LOCK.UPDATE});old=row.credit_limit;await row.update({credit_limit:req.body.creditLimit,currency:req.body.currency,status:req.body.status},{transaction:t});});await logActivity({user:req.user,type:"BUSINESS_CREDIT_LIMIT_CHANGED",module:"Business",description:"Business credit configuration changed",targetType:"BUSINESS_PROFILE",targetId:req.params.id,requestId:req.id,metadata:{oldValue:String(old),newValue:String(req.body.creditLimit),currency:req.body.currency}});return res.json({success:true,data:row});}catch(e){return next(e);}}; +exports.setSettlement=async(req,res,next)=>{try{const term=await db.SettlementTerm.findOne({where:{id:req.body.settlementTermId,is_active:true}});if(!term)return res.status(400).json({success:false,error:{code:"INVALID_SETTLEMENT_TERM",message:"Settlement term unavailable"}});const [count]=await db.BusinessCustomer.update({settlement_term_id:term.id},{where:{business_customer_id:req.params.id}});if(!count)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Business not found"}});await logActivity({user:req.user,type:"BUSINESS_SETTLEMENT_TERM_CHANGED",module:"Business",description:"Business settlement term changed",targetType:"BUSINESS_PROFILE",targetId:req.params.id,requestId:req.id,metadata:{settlementTermId:term.id}});return res.json({success:true});}catch(e){return next(e);}}; +exports.setStatus=async(req,res,next)=>{try{const p=await db.BusinessCustomer.findByPk(req.params.id);if(!p)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Business not found"}});await p.update({status:req.body.status});await logActivity({user:req.user,type:"BUSINESS_STATUS_CHANGED",module:"Business",description:"Business domain status changed",targetType:"BUSINESS_PROFILE",targetId:p.business_customer_id,requestId:req.id,metadata:{status:req.body.status}});const owner=await db.User.findByPk(p.user_id);if(owner)await notifications.publish({type:"BUSINESS_STATUS_CHANGED",user:owner,headline:"Business account status changed",description:`Your business account is now ${p.status}.`,templateKey:"businessAccountStatusChanged",variables:{firstName:owner.firstName,status:p.status},correlationId:req.id}).catch(()=>undefined);return res.json({success:true,data:safeProfile(p)});}catch(e){return next(e);}}; +module.exports.safeApp=safeApp;module.exports.safeProfile=safeProfile; diff --git a/app/controllers/customerProfile.controller.js b/app/controllers/customerProfile.controller.js new file mode 100644 index 0000000..8a963fc --- /dev/null +++ b/app/controllers/customerProfile.controller.js @@ -0,0 +1,11 @@ +const db = require("../models"); +const { checkPassword } = require("../utils/hashPassword.util"); +const { revokeAllUserSessions } = require("../services/auth/session.service"); +const { logActivity } = require("../services/activity.service"); +const storage = require("../services/storage/storage.service"); + +const media = async (id, userId) => { if (!id || id === "N/A") return null; const upload = await db.Upload.findOne({ where: { id, owner_id: userId, status: "AVAILABLE" } }); if (!upload) return null; const expiresIn = Number(process.env.S3_SIGNED_URL_TTL_SECONDS || 900); return { id: upload.id, url: await storage.createSignedDownloadUrl(upload.file_path, expiresIn), expiresIn }; }; +const response = async (user, profile) => ({ id: user.id, firstName: user.firstName, lastName: user.lastName, email: user.email, phoneNumber: profile.phone_number, dateOfBirth: profile.dob, preferredLanguage: profile.preferred_language, accountType: user.accountType, accountStatus: user.accountStatus, emailVerified: Boolean(user.emailVerifiedAt), avatar: await media(profile.profilePicture_id, user.id), background: await media(profile.backgroundImage_id, user.id), preferences: { theme: profile.theme, marketingEmailEnabled: profile.marketing_email_enabled, marketingPushEnabled: profile.marketing_push_enabled, inAppNotificationsEnabled: profile.in_app_notifications_enabled } }); +exports.getMe = async (req, res, next) => { try { const user = await db.User.findByPk(req.user.id, { include: [{ model: db.Profile, as: "profile" }] }); if (!user?.profile) return res.status(404).json({ success: false, error: { code: "PROFILE_NOT_FOUND", message: "Profile not found" } }); return res.json({ success: true, data: await response(user, user.profile) }); } catch (e) { return next(e); } }; +exports.updateMe = async (req, res, next) => { try { let user, profile; await db.sequelize.transaction(async transaction => { user = await db.User.findByPk(req.user.id, { transaction, lock: transaction.LOCK.UPDATE }); profile = await db.Profile.findOne({ where: { user_id: req.user.id }, transaction, lock: transaction.LOCK.UPDATE }); const b=req.body; for(const id of [b.avatarUploadId,b.backgroundUploadId].filter(Boolean)){const owned=await db.Upload.findOne({where:{id,owner_id:req.user.id,status:"AVAILABLE"},transaction});if(!owned)throw Object.assign(new Error("Profile media must be an available owned upload"),{status:400,code:"INVALID_PROFILE_MEDIA"});} await user.update({ ...(b.firstName !== undefined && { firstName:b.firstName }), ...(b.lastName !== undefined && { lastName:b.lastName }) }, { transaction }); await profile.update({ ...(b.phoneNumber !== undefined && { phone_number:b.phoneNumber }), ...(b.dateOfBirth !== undefined && { dob:b.dateOfBirth }), ...(b.preferredLanguage !== undefined && { preferred_language:b.preferredLanguage }), ...(b.theme !== undefined && { theme:b.theme }), ...(b.marketingEmailEnabled !== undefined && { marketing_email_enabled:b.marketingEmailEnabled }), ...(b.marketingPushEnabled !== undefined && { marketing_push_enabled:b.marketingPushEnabled }), ...(b.inAppNotificationsEnabled !== undefined && { in_app_notifications_enabled:b.inAppNotificationsEnabled, notificationsEnabled:b.inAppNotificationsEnabled }), ...(b.avatarUploadId !== undefined && { profilePicture_id:b.avatarUploadId }), ...(b.backgroundUploadId !== undefined && { backgroundImage_id:b.backgroundUploadId }) }, { transaction }); }); await logActivity({ user:req.user, type:"PROFILE_UPDATED", module:"Customer", description:"Customer profile updated", targetType:"USER", targetId:req.user.id, requestId:req.id }); return res.json({ success:true, data:await response(user,profile) }); } catch(e){ return next(e); } }; +exports.deactivate = async (req,res,next) => { try { await db.sequelize.transaction(async transaction => { const user=await db.User.findByPk(req.user.id,{transaction,lock:transaction.LOCK.UPDATE}); if(user.password && (!req.body.password || !(await checkPassword(req.body.password,user.password)))) throw Object.assign(new Error("Password confirmation failed"),{status:403,code:"INVALID_CONFIRMATION"}); await user.update({accountStatus:"DEACTIVATED",tokenVersion:user.tokenVersion+1},{transaction}); await revokeAllUserSessions(user.id,"SELF_DEACTIVATED",transaction); }); await logActivity({user:req.user,type:"ACCOUNT_DEACTIVATED",module:"Identity",description:"Customer deactivated account",targetType:"USER",targetId:req.user.id,requestId:req.id}); res.clearCookie("access_token"); res.clearCookie("refresh_token",{path:"/api"}); return res.json({success:true,message:"Account deactivated"}); } catch(e){return next(e);} }; diff --git a/app/controllers/upload.controller.js b/app/controllers/upload.controller.js index eaebe9d..61f4bc3 100644 --- a/app/controllers/upload.controller.js +++ b/app/controllers/upload.controller.js @@ -2,7 +2,8 @@ const db = require("../models"); const storage = require("../services/storage/storage.service"); const { validateUpload } = require("../services/storage/file-validation.service"); -const canAccess = (user, upload) => upload.visibility === "PUBLIC" || upload.owner_id === user.id || user.accountType === "super_admin" || (user.accountType === "admin" && (user.permissions || []).includes("media.read")); +const BUSINESS_DOCUMENT_PURPOSES = new Set(["BUSINESS_REGISTRATION", "TAX_DOCUMENT", "IDENTITY_DOCUMENT", "OTHER_SUPPORTING_DOCUMENT"]); +const canAccess = (user, upload) => upload.visibility === "PUBLIC" || upload.owner_id === user.id || user.accountType === "superadmin" || (user.permissions || []).includes("media.read") || (BUSINESS_DOCUMENT_PURPOSES.has(upload.use_for) && (user.permissions || []).includes("business.applications.review")); exports.uploadFile = async (req, res, next) => { let objectKey; diff --git a/app/models/index.js b/app/models/index.js index 9f70090..12dcf61 100644 --- a/app/models/index.js +++ b/app/models/index.js @@ -43,6 +43,11 @@ db.AuthSession = require("./user/authSession.model")(sequelize, DataTypes); db.UserIdentity = require("./user/userIdentity.model")(sequelize, DataTypes); db.UserActivity = require("./activities/userActivities.model")(sequelize, DataTypes); db.Profile = require("./user/profile.model")(sequelize, DataTypes); +db.Address = require("./user/address.model")(sequelize, DataTypes); +db.BusinessApplication = require("./user/businessApplication.model")(sequelize, DataTypes); +db.BusinessContact = require("./user/businessContact.model")(sequelize, DataTypes); +db.BusinessCreditAccount = require("./user/businessCreditAccount.model")(sequelize, DataTypes); +db.SettlementTerm = require("./user/settlementTerm.model")(sequelize, DataTypes); // Uploads db.Upload = require("./upload/upload.model")(sequelize, DataTypes); diff --git a/app/models/user/address.model.js b/app/models/user/address.model.js new file mode 100644 index 0000000..13c5bd1 --- /dev/null +++ b/app/models/user/address.model.js @@ -0,0 +1,13 @@ +module.exports = (sequelize, DataTypes) => { + const Address = sequelize.define("Address", { + id: { type: DataTypes.STRING, primaryKey: true }, + user_id: { type: DataTypes.STRING, allowNull: true }, + business_profile_id: { type: DataTypes.STRING, allowNull: true }, + label: { type: DataTypes.STRING(50), allowNull: false }, recipient_name: { type: DataTypes.STRING(150), allowNull: false }, phone_number: { type: DataTypes.STRING(30), allowNull: false }, + address_line_1: { type: DataTypes.STRING(255), allowNull: false }, address_line_2: DataTypes.STRING(255), city: { type: DataTypes.STRING(100), allowNull: false }, district: DataTypes.STRING(100), province: DataTypes.STRING(100), postal_code: DataTypes.STRING(20), + country_code: { type: DataTypes.STRING(2), allowNull: false }, address_type: { type: DataTypes.ENUM("CUSTOMER", "REGISTERED", "BILLING", "DELIVERY"), allowNull: false, defaultValue: "CUSTOMER" }, + is_default_shipping: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, is_default_billing: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, + }, { tableName: "addresses", timestamps: true }); + Address.associate = (models) => { Address.belongsTo(models.User, { foreignKey: "user_id", as: "user" }); Address.belongsTo(models.BusinessCustomer, { foreignKey: "business_profile_id", as: "businessProfile" }); }; + return Address; +}; diff --git a/app/models/user/businessApplication.model.js b/app/models/user/businessApplication.model.js new file mode 100644 index 0000000..cd832d2 --- /dev/null +++ b/app/models/user/businessApplication.model.js @@ -0,0 +1,9 @@ +module.exports = (sequelize, DataTypes) => { + const Model = sequelize.define("BusinessApplication", { + id: { type: DataTypes.STRING, primaryKey: true }, user_id: { type: DataTypes.STRING, allowNull: false }, + business_name: { type: DataTypes.STRING(180), allowNull: false }, legal_name: { type: DataTypes.STRING(180), allowNull: false }, registration_number: { type: DataTypes.STRING(100), allowNull: false }, tax_number: DataTypes.STRING(100), business_type: { type: DataTypes.STRING(80), allowNull: false }, contact_email: { type: DataTypes.STRING, allowNull: false }, contact_phone: { type: DataTypes.STRING(30), allowNull: false }, website: DataTypes.STRING, + status: { type: DataTypes.ENUM("PENDING", "UNDER_REVIEW", "APPROVED", "REJECTED", "CANCELLED"), allowNull: false, defaultValue: "PENDING" }, reviewed_by: DataTypes.STRING, reviewed_at: DataTypes.DATE, rejection_reason: DataTypes.STRING(500), + }, { tableName: "business_applications", timestamps: true }); + Model.associate = (models) => { Model.belongsTo(models.User, { foreignKey: "user_id", as: "applicant" }); Model.belongsTo(models.User, { foreignKey: "reviewed_by", as: "reviewer", constraints: false }); }; + return Model; +}; diff --git a/app/models/user/businessContact.model.js b/app/models/user/businessContact.model.js new file mode 100644 index 0000000..e98255e --- /dev/null +++ b/app/models/user/businessContact.model.js @@ -0,0 +1,4 @@ +module.exports = (sequelize, DataTypes) => { + const Model = sequelize.define("BusinessContact", { id: { type: DataTypes.STRING, primaryKey: true }, business_profile_id: { type: DataTypes.STRING, allowNull: false }, name: { type: DataTypes.STRING(150), allowNull: false }, title: DataTypes.STRING(100), email: DataTypes.STRING, phone: DataTypes.STRING(30), is_primary: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false } }, { tableName: "business_contacts", timestamps: true }); + Model.associate = (models) => Model.belongsTo(models.BusinessCustomer, { foreignKey: "business_profile_id", as: "businessProfile" }); return Model; +}; diff --git a/app/models/user/businessCreditAccount.model.js b/app/models/user/businessCreditAccount.model.js new file mode 100644 index 0000000..90c2e6a --- /dev/null +++ b/app/models/user/businessCreditAccount.model.js @@ -0,0 +1,4 @@ +module.exports = (sequelize, DataTypes) => { + const Model = sequelize.define("BusinessCreditAccount", { id: { type: DataTypes.STRING, primaryKey: true }, business_profile_id: { type: DataTypes.STRING, allowNull: false, unique: true }, currency: { type: DataTypes.STRING(3), allowNull: false, defaultValue: "LKR" }, credit_limit: { type: DataTypes.DECIMAL(15,2), allowNull: false, defaultValue: 0 }, status: { type: DataTypes.ENUM("DISABLED", "ACTIVE", "SUSPENDED"), allowNull: false, defaultValue: "DISABLED" } }, { tableName: "business_credit_accounts", timestamps: true }); + Model.associate = (models) => Model.belongsTo(models.BusinessCustomer, { foreignKey: "business_profile_id", as: "businessProfile" }); return Model; +}; diff --git a/app/models/user/businessCustomer.model.js b/app/models/user/businessCustomer.model.js index 10e5282..fcae0e9 100644 --- a/app/models/user/businessCustomer.model.js +++ b/app/models/user/businessCustomer.model.js @@ -21,7 +21,7 @@ module.exports = (sequelize, DataTypes) => { }, businessRegistrationNumber: { type: DataTypes.STRING, - allowNull: false, + allowNull: false, unique: true, }, businessType: { type: DataTypes.STRING, @@ -47,6 +47,14 @@ module.exports = (sequelize, DataTypes) => { type: DataTypes.STRING, allowNull: true, }, + legalName: DataTypes.STRING, + taxNumber: DataTypes.STRING, + website: DataTypes.STRING, + partnerId: { type: DataTypes.STRING, allowNull: false, unique: true }, + status: { type: DataTypes.ENUM("ACTIVE", "SUSPENDED", "CLOSED"), allowNull: false, defaultValue: "ACTIVE" }, + approvedAt: { type: DataTypes.DATE, allowNull: false }, + approvedBy: { type: DataTypes.STRING, allowNull: false }, + settlement_term_id: { type: DataTypes.STRING, allowNull: true }, }, { tableName: "business_customers", @@ -59,6 +67,10 @@ module.exports = (sequelize, DataTypes) => { foreignKey: "user_id", as: "user", }); + BusinessCustomer.hasMany(db.BusinessContact, { foreignKey: "business_profile_id", as: "contacts" }); + BusinessCustomer.hasMany(db.Address, { foreignKey: "business_profile_id", as: "addresses" }); + BusinessCustomer.hasOne(db.BusinessCreditAccount, { foreignKey: "business_profile_id", as: "creditAccount" }); + BusinessCustomer.belongsTo(db.SettlementTerm, { foreignKey: "settlement_term_id", as: "settlementTerm" }); }; return BusinessCustomer; diff --git a/app/models/user/profile.model.js b/app/models/user/profile.model.js index 5f4fa32..f756cfd 100644 --- a/app/models/user/profile.model.js +++ b/app/models/user/profile.model.js @@ -47,7 +47,11 @@ module.exports = (sequelize, DataTypes) => { phone_number:{ type: DataTypes.STRING, allowNull: true, - } + }, + preferred_language: { type: DataTypes.ENUM("en", "si", "ta"), allowNull: false, defaultValue: "en" }, + marketing_email_enabled: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true }, + marketing_push_enabled: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, + in_app_notifications_enabled: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true }, }, { tableName: "profiles", diff --git a/app/models/user/settlementTerm.model.js b/app/models/user/settlementTerm.model.js new file mode 100644 index 0000000..0d6523a --- /dev/null +++ b/app/models/user/settlementTerm.model.js @@ -0,0 +1 @@ +module.exports = (sequelize, DataTypes) => sequelize.define("SettlementTerm", { id: { type: DataTypes.STRING, primaryKey: true }, code: { type: DataTypes.STRING(30), allowNull: false, unique: true }, name: { type: DataTypes.STRING(100), allowNull: false }, days: { type: DataTypes.INTEGER, allowNull: false }, is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true } }, { tableName: "settlement_terms", timestamps: true }); diff --git a/app/models/user/user.model.js b/app/models/user/user.model.js index 7bfc3eb..e5f4894 100644 --- a/app/models/user/user.model.js +++ b/app/models/user/user.model.js @@ -88,6 +88,8 @@ module.exports = (sequelize, DataTypes) => { User.hasMany(db.AuthSession, { foreignKey: "user_id", as: "authSessions" }); User.hasMany(db.UserIdentity, { foreignKey: "user_id", as: "identities" }); User.hasMany(db.UserRole, { foreignKey: "user_id", as: "userRoles" }); + User.hasMany(db.Address, { foreignKey: "user_id", as: "addresses" }); + User.hasMany(db.BusinessApplication, { foreignKey: "user_id", as: "businessApplications" }); }; return User; diff --git a/app/queues/email.queue.js b/app/queues/email.queue.js index d0b27d4..479c900 100644 --- a/app/queues/email.queue.js +++ b/app/queues/email.queue.js @@ -1,3 +1,8 @@ -const { Queue } = require("bullmq"); -const connection = require("../config/redisClient"); -module.exports = new Queue("email-delivery", { connection, defaultJobOptions: { attempts: 5, backoff: { type: "exponential", delay: 2000 }, removeOnComplete: { age: 3600, count: 1000 }, removeOnFail: { age: 604800, count: 5000 } } }); +const options = { attempts: 5, backoff: { type: "exponential", delay: 2000 }, removeOnComplete: { age: 3600, count: 1000 }, removeOnFail: { age: 604800, count: 5000 } }; +if (process.env.NODE_ENV === "test") { + module.exports = { name: "email-delivery", opts: { defaultJobOptions: options }, add: async (_name, _data, jobOptions) => ({ id: jobOptions?.jobId }), close: async () => undefined }; +} else { + const { Queue } = require("bullmq"); + const connection = require("../config/redisClient"); + module.exports = new Queue("email-delivery", { connection, defaultJobOptions: options }); +} diff --git a/app/routes/address.routes.js b/app/routes/address.routes.js new file mode 100644 index 0000000..3a86596 --- /dev/null +++ b/app/routes/address.routes.js @@ -0,0 +1,2 @@ +const r=require("express").Router(); const c=require("../controllers/address.controller"); const {authenticate}=require("../middleware/auth.middleware"); const validate=require("../middleware/validate.middleware"); const s=require("../validation/customer.schemas"); +r.use(authenticate); r.get("/",c.list);r.post("/",validate(s.addressCreate),c.create);r.get("/:id",c.get);r.patch("/:id",validate(s.addressUpdate),c.update);r.delete("/:id",c.remove);module.exports=r; diff --git a/app/routes/adminBusiness.routes.js b/app/routes/adminBusiness.routes.js new file mode 100644 index 0000000..6757790 --- /dev/null +++ b/app/routes/adminBusiness.routes.js @@ -0,0 +1 @@ +const r=require("express").Router();const c=require("../controllers/business.controller");const {authenticate}=require("../middleware/auth.middleware");const {checkPermission}=require("../middleware/permission.middleware");const validate=require("../middleware/validate.middleware");const s=require("../validation/business.schemas");r.use(authenticate);r.get("/applications",checkPermission("business.applications.read",{custom:true}),c.adminListApplications);r.get("/applications/:id",checkPermission("business.applications.read",{custom:true}),c.adminGetApplication);r.post("/applications/:id/approve",checkPermission("business.applications.review",{custom:true}),c.approve);r.post("/applications/:id/reject",checkPermission("business.applications.review",{custom:true}),validate(s.reject),c.reject);r.get("/accounts",checkPermission("business.accounts.read",{custom:true}),c.adminListBusinesses);r.patch("/accounts/:id/status",checkPermission("business.accounts.update",{custom:true}),validate(s.status),c.setStatus);r.patch("/accounts/:id/credit",checkPermission("business.credit.manage",{custom:true}),validate(s.credit),c.setCredit);r.patch("/accounts/:id/settlement-term",checkPermission("business.settlement.manage",{custom:true}),validate(s.settlement),c.setSettlement);module.exports=r; diff --git a/app/routes/business.routes.js b/app/routes/business.routes.js new file mode 100644 index 0000000..7bcd1ea --- /dev/null +++ b/app/routes/business.routes.js @@ -0,0 +1 @@ +const r=require("express").Router();const c=require("../controllers/business.controller");const {authenticate}=require("../middleware/auth.middleware");const validate=require("../middleware/validate.middleware");const s=require("../validation/business.schemas");const customerSchemas=require("../validation/customer.schemas");r.use(authenticate);r.post("/applications",validate(s.application),c.apply);r.get("/applications/me",c.myApplications);r.get("/applications/:id",c.getApplication);r.get("/me",c.getMe);r.patch("/me",validate(s.businessUpdate),c.updateMe);r.post("/me/contacts",validate(s.contact),c.addContact);r.post("/me/addresses",validate(customerSchemas.addressCreate),c.addAddress);module.exports=r; diff --git a/app/routes/index.js b/app/routes/index.js index 4ce5779..677a38d 100644 --- a/app/routes/index.js +++ b/app/routes/index.js @@ -23,6 +23,9 @@ const notificationRoutes = require("./notification.routes"); const adminAuthRoutes = require("./adminAuth.routes"); const riderAuthRoutes = require("./riderAuth.routes"); const adminUserRoutes = require("./adminUser.routes"); +const addressRoutes = require("./address.routes"); +const businessRoutes = require("./business.routes"); +const adminBusinessRoutes = require("./adminBusiness.routes"); const router = express.Router(); @@ -38,5 +41,8 @@ router.use("/permissions", permissionRoutes); router.use("/admin/auth", adminAuthRoutes); router.use("/rider/auth", riderAuthRoutes); router.use("/admin/users", adminUserRoutes); +router.use("/addresses", addressRoutes); +router.use("/business", businessRoutes); +router.use("/admin/business", adminBusinessRoutes); module.exports = router; diff --git a/app/routes/profile.routes.js b/app/routes/profile.routes.js index ad76262..a9327ff 100644 --- a/app/routes/profile.routes.js +++ b/app/routes/profile.routes.js @@ -24,6 +24,11 @@ const { sensitiveLimiter } = require("../middleware/rateLimit.middleware"); const validate = require("../middleware/validate.middleware"); const schemas = require("../validation/auth.schemas"); const { requireOwnership } = require("../middleware/permission.middleware"); +const customerProfile = require("../controllers/customerProfile.controller"); +const customerSchemas = require("../validation/customer.schemas"); + +router.get("/me", authenticate, customerProfile.getMe); +router.patch("/me", authenticate, validate(customerSchemas.profileUpdate), customerProfile.updateMe); router.post("/req-reset-password", sensitiveLimiter, validate(schemas.forgot), authController.forgotPassword); diff --git a/app/routes/user.routes.js b/app/routes/user.routes.js index dc8447e..f2612c2 100644 --- a/app/routes/user.routes.js +++ b/app/routes/user.routes.js @@ -18,9 +18,13 @@ const { const { authorizedAccountType, checkPermission } = require("../middleware/permission.middleware"); const PERMISSIONS = require("../constants/permissions"); +const customerProfile = require("../controllers/customerProfile.controller"); +const validate = require("../middleware/validate.middleware"); +const customerSchemas = require("../validation/customer.schemas"); router.get("/me", authenticate, userController.getCurrentUser); router.patch("/me", authenticate, userController.updateCurrentUser); +router.post("/me/deactivate", authenticate, validate(customerSchemas.deactivate), customerProfile.deactivate); router.post( diff --git a/app/services/business/business.service.js b/app/services/business/business.service.js new file mode 100644 index 0000000..0b87a5f --- /dev/null +++ b/app/services/business/business.service.js @@ -0,0 +1,7 @@ +const crypto=require("crypto"); const {Op}=require("sequelize"); const db=require("../../models"); const {nextSequence}=require("../../utils/referenceNumber.util"); +const { revokeAllUserSessions } = require("../auth/session.service"); +const normalize=(b)=>({business_name:b.businessName,legal_name:b.legalName,registration_number:b.registrationNumber,tax_number:b.taxNumber,business_type:b.businessType,contact_email:b.contactEmail,contact_phone:b.contactPhone,website:b.website}); +const apply=async(userId,body)=>db.sequelize.transaction(async t=>{const user=await db.User.findByPk(userId,{transaction:t,lock:t.LOCK.UPDATE});if(!user||user.accountStatus!=="ACTIVE"||!user.emailVerifiedAt||user.accountType!=="customer")throw Object.assign(new Error("Only verified active customers may apply"),{status:403,code:"NOT_ELIGIBLE"});const active=await db.BusinessApplication.findOne({where:{user_id:userId,status:{[Op.in]:["PENDING","UNDER_REVIEW"]}},transaction:t,lock:t.LOCK.UPDATE});if(active)throw Object.assign(new Error("An active application already exists"),{status:409,code:"ACTIVE_APPLICATION_EXISTS"});return db.BusinessApplication.create({id:crypto.randomUUID(),user_id:userId,...normalize(body),status:"PENDING"},{transaction:t});}); +const approve=async(id,reviewerId)=>db.sequelize.transaction(async t=>{const application=await db.BusinessApplication.findByPk(id,{transaction:t,lock:t.LOCK.UPDATE});if(!application)throw Object.assign(new Error("Application not found"),{status:404,code:"NOT_FOUND"});if(!["PENDING","UNDER_REVIEW"].includes(application.status))throw Object.assign(new Error("Application cannot be approved from its current state"),{status:409,code:"INVALID_TRANSITION"});const user=await db.User.findByPk(application.user_id,{transaction:t,lock:t.LOCK.UPDATE});const existing=await db.BusinessCustomer.findOne({where:{user_id:application.user_id},transaction:t,lock:t.LOCK.UPDATE});if(existing)throw Object.assign(new Error("Business profile already exists"),{status:409,code:"BUSINESS_EXISTS"});const number=await nextSequence("business_partner",t);const partnerId=`ZUM-BIZ-${String(number).padStart(6,"0")}`;const profile=await db.BusinessCustomer.create({business_customer_id:`biz_${crypto.randomUUID()}`,user_id:application.user_id,businessName:application.business_name,legalName:application.legal_name,businessRegistrationNumber:application.registration_number,taxNumber:application.tax_number,businessType:application.business_type,contactName:application.business_name,phoneNumber:application.contact_phone,businessEmail:application.contact_email,website:application.website,expectedMonthlyVolume:"NOT_CONFIGURED",partnerId,status:"ACTIVE",approvedAt:new Date(),approvedBy:reviewerId},{transaction:t});await db.BusinessCreditAccount.create({id:crypto.randomUUID(),business_profile_id:profile.business_customer_id,currency:"LKR",credit_limit:0,status:"DISABLED"},{transaction:t});await user.update({accountType:"business_customer",tokenVersion:user.tokenVersion+1},{transaction:t});await revokeAllUserSessions(user.id,"BUSINESS_APPLICATION_APPROVED",t);await application.update({status:"APPROVED",reviewed_by:reviewerId,reviewed_at:new Date(),rejection_reason:null},{transaction:t});return {application,profile};}); +const reject=async(id,reviewerId,reason)=>db.sequelize.transaction(async t=>{const application=await db.BusinessApplication.findByPk(id,{transaction:t,lock:t.LOCK.UPDATE});if(!application)throw Object.assign(new Error("Application not found"),{status:404,code:"NOT_FOUND"});if(!["PENDING","UNDER_REVIEW"].includes(application.status))throw Object.assign(new Error("Application cannot be rejected from its current state"),{status:409,code:"INVALID_TRANSITION"});await application.update({status:"REJECTED",reviewed_by:reviewerId,reviewed_at:new Date(),rejection_reason:reason},{transaction:t});return application;}); +module.exports={apply,approve,reject,normalize}; diff --git a/app/services/email/email.service.js b/app/services/email/email.service.js index c68d78e..ca48e65 100644 --- a/app/services/email/email.service.js +++ b/app/services/email/email.service.js @@ -4,6 +4,7 @@ const db = require("../../models"); const templates = { otp: { subject: "Your ZUMRI login code" }, passwordChanged: { subject: "Your ZUMRI password was changed" }, passwordReset: { subject: "Reset your ZUMRI password" }, emailVerification: { subject: "Verify your ZUMRI email" }, welcome: { subject: "Welcome to ZUMRI" }, + businessApplicationReceived: { subject: "Your ZUMRI business application was received" }, businessApplicationApproved: { subject: "Your ZUMRI business application was approved" }, businessApplicationRejected: { subject: "Your ZUMRI business application was reviewed" }, businessAccountStatusChanged: { subject: "Your ZUMRI business account status changed" }, }; const send = async ({ templateKey, recipient, locale = "en", variables = {}, correlationId, notificationId, userId, eventId }) => { if (!templates[templateKey]) throw new Error("Unknown email template"); diff --git a/app/services/notification/notification.service.js b/app/services/notification/notification.service.js new file mode 100644 index 0000000..c5d94cb --- /dev/null +++ b/app/services/notification/notification.service.js @@ -0,0 +1,2 @@ +const crypto=require("crypto"); const db=require("../../models"); const email=require("../email/email.service"); +const publish=async({type,user,headline,description,templateKey,variables={},correlationId})=>{const notification=await db.notification.create({notification_id:`notif_${crypto.randomUUID()}`,notificationHeadline:headline,notificationDescription:description,notificationType:"USER",dateCreated:new Date()});await db.userNotification.create({user_id:user.id,notification_id:notification.notification_id});if(templateKey)await email.send({templateKey,recipient:user.email,userId:user.id,notificationId:notification.notification_id,variables,correlationId,eventId:`${type}-${notification.notification_id}`});return notification;};module.exports={publish}; diff --git a/app/templates/emails/businessAccountStatusChanged.html b/app/templates/emails/businessAccountStatusChanged.html new file mode 100644 index 0000000..bafc014 --- /dev/null +++ b/app/templates/emails/businessAccountStatusChanged.html @@ -0,0 +1 @@ +

Hello {{firstName}},

Your business account status is now {{status}}.

diff --git a/app/templates/emails/businessApplicationApproved.html b/app/templates/emails/businessApplicationApproved.html new file mode 100644 index 0000000..ed242e4 --- /dev/null +++ b/app/templates/emails/businessApplicationApproved.html @@ -0,0 +1 @@ +

Hello {{firstName}},

Your business application for {{businessName}} was approved. Partner ID: {{partnerId}}.

diff --git a/app/templates/emails/businessApplicationReceived.html b/app/templates/emails/businessApplicationReceived.html new file mode 100644 index 0000000..db7c02f --- /dev/null +++ b/app/templates/emails/businessApplicationReceived.html @@ -0,0 +1 @@ +

Hello {{firstName}},

We received your business application for {{businessName}}. Reference: {{applicationId}}.

diff --git a/app/templates/emails/businessApplicationRejected.html b/app/templates/emails/businessApplicationRejected.html new file mode 100644 index 0000000..6bbd2e8 --- /dev/null +++ b/app/templates/emails/businessApplicationRejected.html @@ -0,0 +1 @@ +

Hello {{firstName}},

Your business application for {{businessName}} was not approved. Reason: {{reason}}.

diff --git a/app/validation/business.schemas.js b/app/validation/business.schemas.js new file mode 100644 index 0000000..b20bd08 --- /dev/null +++ b/app/validation/business.schemas.js @@ -0,0 +1,3 @@ +const {z}=require("zod"); const request=(body)=>z.object({body:z.object(body).strict(),params:z.object({}).passthrough(),query:z.object({}).passthrough()}); const text=(n)=>z.string().trim().min(1).max(n).transform(v=>v.replace(/\s+/g," ")); +const details={businessName:text(180),legalName:text(180),registrationNumber:text(100),taxNumber:text(100).optional().nullable(),businessType:text(80),contactEmail:z.string().trim().toLowerCase().email(),contactPhone:z.string().trim().regex(/^\+?[0-9 ()-]{7,30}$/),website:z.string().trim().url().optional().nullable()}; +module.exports={application:request(details),reject:request({reason:text(500)}),businessUpdate:request({businessName:text(180).optional(),legalName:text(180).optional(),taxNumber:text(100).optional().nullable(),businessType:text(80).optional(),contactEmail:z.string().trim().toLowerCase().email().optional(),contactPhone:z.string().trim().regex(/^\+?[0-9 ()-]{7,30}$/).optional(),website:z.string().trim().url().optional().nullable()}),credit:request({creditLimit:z.coerce.number().min(0).max(9999999999999),currency:z.string().trim().length(3).transform(v=>v.toUpperCase()),status:z.enum(["DISABLED","ACTIVE","SUSPENDED"])}),settlement:request({settlementTermId:z.string().min(1)}),status:request({status:z.enum(["ACTIVE","SUSPENDED","CLOSED"])}),contact:request({name:text(150),title:text(100).optional().nullable(),email:z.string().trim().email().optional().nullable(),phone:z.string().trim().regex(/^\+?[0-9 ()-]{7,30}$/).optional().nullable(),isPrimary:z.boolean().optional()})}; diff --git a/app/validation/customer.schemas.js b/app/validation/customer.schemas.js new file mode 100644 index 0000000..aa5bc7e --- /dev/null +++ b/app/validation/customer.schemas.js @@ -0,0 +1,9 @@ +const { z } = require("zod"); +const request = (body) => z.object({ body: z.object(body).strict(), params: z.object({}).passthrough(), query: z.object({}).passthrough() }); +const text = (max) => z.string().trim().min(1).max(max).transform((v) => v.replace(/\s+/g, " ")); +const addressFields = { label: text(50), recipientName: text(150), phoneNumber: z.string().trim().regex(/^\+?[0-9 ()-]{7,30}$/), addressLine1: text(255), addressLine2: text(255).optional().nullable(), city: text(100), district: text(100).optional().nullable(), province: text(100).optional().nullable(), postalCode: z.string().trim().max(20).regex(/^[A-Za-z0-9 -]*$/).optional().nullable(), countryCode: z.string().trim().length(2).transform(v => v.toUpperCase()), isDefaultShipping: z.boolean().optional(), isDefaultBilling: z.boolean().optional() }; +module.exports = { + profileUpdate: request({ firstName: text(100).optional(), lastName: text(100).optional(), phoneNumber: z.string().trim().regex(/^\+?[0-9 ()-]{7,30}$/).optional().nullable(), dateOfBirth: z.coerce.date().max(new Date()).optional().nullable(), preferredLanguage: z.enum(["en", "si", "ta"]).optional(), theme: z.enum(["light", "dark", "system"]).optional(), marketingEmailEnabled: z.boolean().optional(), marketingPushEnabled: z.boolean().optional(), inAppNotificationsEnabled: z.boolean().optional(), avatarUploadId: z.coerce.number().int().positive().optional().nullable(), backgroundUploadId: z.coerce.number().int().positive().optional().nullable() }), + addressCreate: request(addressFields), addressUpdate: request(Object.fromEntries(Object.entries(addressFields).map(([k,v]) => [k,v.optional()]))), + deactivate: request({ confirmation: z.literal("DEACTIVATE"), password: z.string().min(1).optional() }), +}; diff --git a/migrations/20260903030000-phase-3-customer-business-foundations.js b/migrations/20260903030000-phase-3-customer-business-foundations.js new file mode 100644 index 0000000..928d2b4 --- /dev/null +++ b/migrations/20260903030000-phase-3-customer-business-foundations.js @@ -0,0 +1,14 @@ +"use strict"; +module.exports={async up(q,S){await q.sequelize.transaction(async transaction=>{ +for(const [name,def] of [["preferred_language",{type:S.ENUM("en","si","ta"),allowNull:false,defaultValue:"en"}],["marketing_email_enabled",{type:S.BOOLEAN,allowNull:false,defaultValue:true}],["marketing_push_enabled",{type:S.BOOLEAN,allowNull:false,defaultValue:false}],["in_app_notifications_enabled",{type:S.BOOLEAN,allowNull:false,defaultValue:true}]])await q.addColumn("profiles",name,def,{transaction}); +await q.createTable("addresses",{id:{type:S.STRING,primaryKey:true},user_id:{type:S.STRING,allowNull:true,references:{model:"users",key:"id"},onDelete:"CASCADE"},business_profile_id:{type:S.STRING,allowNull:true},label:{type:S.STRING(50),allowNull:false},recipient_name:{type:S.STRING(150),allowNull:false},phone_number:{type:S.STRING(30),allowNull:false},address_line_1:{type:S.STRING(255),allowNull:false},address_line_2:S.STRING(255),city:{type:S.STRING(100),allowNull:false},district:S.STRING(100),province:S.STRING(100),postal_code:S.STRING(20),country_code:{type:S.STRING(2),allowNull:false},address_type:{type:S.ENUM("CUSTOMER","REGISTERED","BILLING","DELIVERY"),allowNull:false,defaultValue:"CUSTOMER"},is_default_shipping:{type:S.BOOLEAN,allowNull:false,defaultValue:false},is_default_billing:{type:S.BOOLEAN,allowNull:false,defaultValue:false},createdAt:{type:S.DATE,allowNull:false},updatedAt:{type:S.DATE,allowNull:false}},{transaction});await q.addIndex("addresses",["user_id"],{name:"addresses_user_idx",transaction});await q.addIndex("addresses",["business_profile_id"],{name:"addresses_business_idx",transaction}); +await q.createTable("business_applications",{id:{type:S.STRING,primaryKey:true},user_id:{type:S.STRING,allowNull:false,references:{model:"users",key:"id"}},business_name:{type:S.STRING(180),allowNull:false},legal_name:{type:S.STRING(180),allowNull:false},registration_number:{type:S.STRING(100),allowNull:false},tax_number:S.STRING(100),business_type:{type:S.STRING(80),allowNull:false},contact_email:{type:S.STRING,allowNull:false},contact_phone:{type:S.STRING(30),allowNull:false},website:S.STRING,status:{type:S.ENUM("PENDING","UNDER_REVIEW","APPROVED","REJECTED","CANCELLED"),allowNull:false,defaultValue:"PENDING"},reviewed_by:S.STRING,reviewed_at:S.DATE,rejection_reason:S.STRING(500),createdAt:{type:S.DATE,allowNull:false},updatedAt:{type:S.DATE,allowNull:false}},{transaction});await q.addIndex("business_applications",["user_id","status"],{name:"business_applicant_status_idx",transaction});await q.addIndex("business_applications",["status","createdAt"],{name:"business_application_review_idx",transaction}); +for(const [name,def] of [["legalName",{type:S.STRING}],["taxNumber",{type:S.STRING}],["website",{type:S.STRING}],["partnerId",{type:S.STRING,allowNull:true}],["status",{type:S.ENUM("ACTIVE","SUSPENDED","CLOSED"),allowNull:false,defaultValue:"ACTIVE"}],["approvedAt",{type:S.DATE,allowNull:true}],["approvedBy",{type:S.STRING,allowNull:true}],["settlement_term_id",{type:S.STRING,allowNull:true}]])await q.addColumn("business_customers",name,def,{transaction});await q.addIndex("business_customers",["partnerId"],{unique:true,name:"business_partner_unique",transaction});await q.addIndex("business_customers",["businessRegistrationNumber"],{unique:true,name:"business_registration_unique",transaction});await q.addIndex("business_customers",["status"],{name:"business_status_idx",transaction}); +await q.createTable("business_contacts",{id:{type:S.STRING,primaryKey:true},business_profile_id:{type:S.STRING,allowNull:false,references:{model:"business_customers",key:"business_customer_id"},onDelete:"CASCADE"},name:{type:S.STRING(150),allowNull:false},title:S.STRING(100),email:S.STRING,phone:S.STRING(30),is_primary:{type:S.BOOLEAN,allowNull:false,defaultValue:false},createdAt:{type:S.DATE,allowNull:false},updatedAt:{type:S.DATE,allowNull:false}},{transaction});await q.addIndex("business_contacts",["business_profile_id"],{name:"business_contacts_profile_idx",transaction}); +await q.createTable("business_credit_accounts",{id:{type:S.STRING,primaryKey:true},business_profile_id:{type:S.STRING,allowNull:false,references:{model:"business_customers",key:"business_customer_id"},onDelete:"CASCADE"},currency:{type:S.STRING(3),allowNull:false,defaultValue:"LKR"},credit_limit:{type:S.DECIMAL(15,2),allowNull:false,defaultValue:0},status:{type:S.ENUM("DISABLED","ACTIVE","SUSPENDED"),allowNull:false,defaultValue:"DISABLED"},createdAt:{type:S.DATE,allowNull:false},updatedAt:{type:S.DATE,allowNull:false}},{transaction});await q.addIndex("business_credit_accounts",["business_profile_id"],{unique:true,name:"business_credit_profile_unique",transaction}); +await q.createTable("settlement_terms",{id:{type:S.STRING,primaryKey:true},code:{type:S.STRING(30),allowNull:false,unique:true},name:{type:S.STRING(100),allowNull:false},days:{type:S.INTEGER,allowNull:false},is_active:{type:S.BOOLEAN,allowNull:false,defaultValue:true},createdAt:{type:S.DATE,allowNull:false},updatedAt:{type:S.DATE,allowNull:false}},{transaction}); +const now=new Date();await q.bulkInsert("settlement_terms",[{id:"term_prepaid",code:"PREPAID",name:"Prepaid",days:0,is_active:true,createdAt:now,updatedAt:now},{id:"term_net_7",code:"NET_7",name:"Net 7",days:7,is_active:true,createdAt:now,updatedAt:now},{id:"term_net_14",code:"NET_14",name:"Net 14",days:14,is_active:true,createdAt:now,updatedAt:now},{id:"term_net_30",code:"NET_30",name:"Net 30",days:30,is_active:true,createdAt:now,updatedAt:now}],{transaction}); +await q.addConstraint("addresses",{fields:["business_profile_id"],type:"foreign key",name:"addresses_business_fk",references:{table:"business_customers",field:"business_customer_id"},onDelete:"CASCADE",transaction}); +await q.sequelize.query("ALTER TABLE addresses ADD CONSTRAINT addresses_exactly_one_owner CHECK ((user_id IS NOT NULL) + (business_profile_id IS NOT NULL) = 1)",{transaction}); +await q.addConstraint("business_customers",{fields:["settlement_term_id"],type:"foreign key",name:"business_settlement_term_fk",references:{table:"settlement_terms",field:"id"},onDelete:"SET NULL",transaction}); +});},async down(){throw new Error("Phase 3 migration is forward-only; restore from backup for rollback");}}; diff --git a/tests/unit/business.service.test.js b/tests/unit/business.service.test.js new file mode 100644 index 0000000..b0fbe79 --- /dev/null +++ b/tests/unit/business.service.test.js @@ -0,0 +1,11 @@ +describe("business application service", () => { + beforeEach(() => jest.resetModules()); + const setup = ({ active = null } = {}) => { + const created={id:"app-1",user_id:"usr-1",status:"PENDING"}; + const models={sequelize:{transaction:jest.fn(async cb=>cb({LOCK:{UPDATE:"UPDATE"}}))},User:{findByPk:jest.fn().mockResolvedValue({id:"usr-1",accountStatus:"ACTIVE",emailVerifiedAt:new Date(),accountType:"customer"})},BusinessApplication:{findOne:jest.fn().mockResolvedValue(active),create:jest.fn().mockResolvedValue(created)}}; + jest.doMock("../../app/models",()=>models);jest.doMock("../../app/utils/referenceNumber.util",()=>({nextSequence:jest.fn()}));jest.doMock("../../app/services/auth/session.service",()=>({revokeAllUserSessions:jest.fn()})); + return {service:require("../../app/services/business/business.service"),models,created}; + }; + test("verified active customer can submit an application",async()=>{const {service,models,created}=setup();const result=await service.apply("usr-1",{businessName:"Acme",legalName:"Acme Ltd",registrationNumber:"PV1",businessType:"Retail",contactEmail:"a@example.com",contactPhone:"+94770000000"});expect(result).toBe(created);expect(models.BusinessApplication.create).toHaveBeenCalledWith(expect.objectContaining({user_id:"usr-1",status:"PENDING"}),expect.anything());}); + test("duplicate active application is prevented",async()=>{const {service}=setup({active:{id:"existing"}});await expect(service.apply("usr-1",{})).rejects.toMatchObject({code:"ACTIVE_APPLICATION_EXISTS",status:409});}); +}); diff --git a/tests/unit/customer-business.schemas.test.js b/tests/unit/customer-business.schemas.test.js new file mode 100644 index 0000000..2961219 --- /dev/null +++ b/tests/unit/customer-business.schemas.test.js @@ -0,0 +1,42 @@ +const customer = require("../../app/validation/customer.schemas"); +const business = require("../../app/validation/business.schemas"); +const wrap = (body) => ({ body, params: {}, query: {} }); + +describe("Phase 3 strict customer and business inputs", () => { + test("accepts supported locales and rejects unsupported locales", () => { + expect(customer.profileUpdate.safeParse(wrap({ preferredLanguage: "si" })).success).toBe(true); + expect(customer.profileUpdate.safeParse(wrap({ preferredLanguage: "fr" })).success).toBe(false); + }); + test.each(["accountType", "accountStatus", "roles", "permissions", "tokenVersion", "creditLimit", "partnerId"])("profile rejects %s mass assignment", (field) => { + expect(customer.profileUpdate.safeParse(wrap({ [field]: "unsafe" })).success).toBe(false); + }); + test("validates and normalizes an international address", () => { + const result = customer.addressCreate.safeParse(wrap({ label:" Home ",recipientName:" Asha Perera ",phoneNumber:"+94 77 000 0000",addressLine1:" 1 Main Road ",city:"Colombo",countryCode:"lk" })); + expect(result.success).toBe(true); expect(result.data.body.countryCode).toBe("LK"); expect(result.data.body.recipientName).toBe("Asha Perera"); + }); + test("address rejects arbitrary fields", () => { + expect(customer.addressCreate.safeParse(wrap({ label:"Home",recipientName:"Asha",phoneNumber:"+94770000000",addressLine1:"Road",city:"Colombo",countryCode:"LK",userId:"other" })).success).toBe(false); + }); + test("deactivation requires explicit confirmation", () => { + expect(customer.deactivate.safeParse(wrap({ confirmation:"DEACTIVATE" })).success).toBe(true); + expect(customer.deactivate.safeParse(wrap({ confirmation:"yes" })).success).toBe(false); + }); + test("business applications reject approval and identity fields", () => { + const base={businessName:"Acme",legalName:"Acme Ltd",registrationNumber:"PV-1",businessType:"Retail",contactEmail:"owner@example.com",contactPhone:"+94770000000"}; + expect(business.application.safeParse(wrap(base)).success).toBe(true); + expect(business.application.safeParse(wrap({...base,status:"APPROVED",accountType:"business_customer"})).success).toBe(false); + }); + test("business self-update rejects partner, approval, and credit fields", () => { + expect(business.businessUpdate.safeParse(wrap({ businessName:"New Name" })).success).toBe(true); + expect(business.businessUpdate.safeParse(wrap({ partnerId:"ZUM-BIZ-9" })).success).toBe(false); + expect(business.businessUpdate.safeParse(wrap({ creditLimit:5000 })).success).toBe(false); + }); + test("credit configuration rejects negative values and normalizes currency", () => { + expect(business.credit.safeParse(wrap({creditLimit:-1,currency:"lkr",status:"ACTIVE"})).success).toBe(false); + const result=business.credit.safeParse(wrap({creditLimit:1000.25,currency:"lkr",status:"ACTIVE"}));expect(result.success).toBe(true);expect(result.data.body.currency).toBe("LKR"); + }); + test("rejection requires a reason", () => { + expect(business.reject.safeParse(wrap({reason:"Insufficient registration evidence"})).success).toBe(true); + expect(business.reject.safeParse(wrap({})).success).toBe(false); + }); +}); From 5643d89236e1719e533712a018c36ff873fbc009 Mon Sep 17 00:00:00 2001 From: Sathira Sri Sathara Date: Thu, 3 Sep 2026 19:25:13 +0530 Subject: [PATCH 15/16] feat: implement business pricing service and money utilities - Added `businessPricing.service.js` to handle business customer pricing logic. - Introduced `money.js` for money parsing, formatting, and calculations. - Created `pricing.service.js` to manage variant quoting and promotion application. - Updated validation schemas in `catalogue.schemas.js` and `inventoryMerchandising.schemas.js` for new pricing and inventory features. - Implemented cron job for inventory reservation expiry in `inventoryReservationExpiry.cron.js`. - Created database migrations for catalogue and inventory structures. - Added unit tests for catalogue localization, validation, and inventory merchandising functionalities. --- Documentation/API_CATALOGUE.md | 64 +++++++++++ Documentation/API_INVENTORY_MERCHANDISING.md | 42 +++++++ Documentation/CURRENT_BACKEND_STATUS.md | 11 ++ Documentation/PHASE_4_CATALOGUE_CONTENT.md | 103 ++++++++++++++++++ .../PHASE_5_INVENTORY_MERCHANDISING.md | 85 +++++++++++++++ app/config/env.config.js | 1 + app/constants/locales.js | 1 + app/constants/permissions.js | 4 + app/controllers/catalogue/admin.controller.js | 18 +++ .../catalogue/public.controller.js | 10 ++ .../catalogue/review.controller.js | 4 + app/controllers/inventory/admin.controller.js | 11 ++ .../merchandising/admin.controller.js | 5 + .../merchandising/public.controller.js | 2 + app/controllers/pricing/admin.controller.js | 3 + app/models/catalogue/brand.model.js | 1 + app/models/catalogue/category.model.js | 1 + .../catalogue/categoryTranslation.model.js | 1 + app/models/catalogue/collection.model.js | 1 + .../catalogue/collectionProduct.model.js | 1 + .../catalogue/collectionTranslation.model.js | 1 + app/models/catalogue/product.model.js | 1 + .../catalogue/productAttribute.model.js | 1 + app/models/catalogue/productCategory.model.js | 1 + app/models/catalogue/productMedia.model.js | 1 + app/models/catalogue/productOption.model.js | 1 + .../catalogue/productOptionValue.model.js | 1 + app/models/catalogue/productRelation.model.js | 1 + app/models/catalogue/productReview.model.js | 1 + .../catalogue/productTranslation.model.js | 1 + app/models/catalogue/productVariant.model.js | 1 + app/models/catalogue/sizeGuide.model.js | 1 + .../catalogue/variantOptionValue.model.js | 1 + app/models/index.js | 23 ++++ .../inventory/inventoryBalance.model.js | 1 + .../inventory/inventoryReservation.model.js | 1 + .../inventory/inventoryTransaction.model.js | 1 + .../inventory/inventoryTransfer.model.js | 1 + app/models/inventory/warehouse.model.js | 1 + app/models/merchandising/banner.model.js | 1 + .../merchandising/bannerTranslation.model.js | 1 + app/models/merchandising/coupon.model.js | 1 + app/models/merchandising/promotion.model.js | 1 + .../merchandising/promotionTarget.model.js | 1 + app/models/pricing/businessPriceTier.model.js | 1 + app/models/pricing/businessTier.model.js | 1 + .../pricing/variantBusinessPrice.model.js | 1 + app/models/user/businessCustomer.model.js | 2 + app/routes/catalogue/admin.routes.js | 1 + app/routes/catalogue/public.routes.js | 1 + app/routes/index.js | 12 ++ app/routes/inventory/admin.routes.js | 1 + app/routes/merchandising/admin.routes.js | 1 + app/routes/merchandising/public.routes.js | 1 + app/routes/pricing/admin.routes.js | 1 + app/seeders/catalogueExample.seeder.js | 3 + app/services/catalogue/catalogue.service.js | 7 ++ app/services/catalogue/locale.service.js | 1 + app/services/catalogue/serializer.service.js | 3 + app/services/inventory/inventory.service.js | 103 ++++++++++++++++++ .../pricing/businessPricing.service.js | 15 +++ app/services/pricing/money.js | 5 + app/services/pricing/pricing.service.js | 14 +++ app/validation/catalogue.schemas.js | 6 + .../inventoryMerchandising.schemas.js | 8 ++ cron/index.js | 3 +- cron/inventoryReservationExpiry.cron.js | 1 + ...0260903040000-phase-4-catalogue-content.js | 21 ++++ ...3050000-phase-5-inventory-merchandising.js | 5 + .../unit/catalogue.locale-serializer.test.js | 6 + tests/unit/catalogue.schemas.test.js | 12 ++ tests/unit/catalogue.service.test.js | 7 ++ .../inventory-merchandising.money.test.js | 2 + ...nventory-merchandising.permissions.test.js | 2 + .../inventory-merchandising.schemas.test.js | 2 + 75 files changed, 663 insertions(+), 1 deletion(-) create mode 100644 Documentation/API_CATALOGUE.md create mode 100644 Documentation/API_INVENTORY_MERCHANDISING.md create mode 100644 Documentation/PHASE_4_CATALOGUE_CONTENT.md create mode 100644 Documentation/PHASE_5_INVENTORY_MERCHANDISING.md create mode 100644 app/constants/locales.js create mode 100644 app/controllers/catalogue/admin.controller.js create mode 100644 app/controllers/catalogue/public.controller.js create mode 100644 app/controllers/catalogue/review.controller.js create mode 100644 app/controllers/inventory/admin.controller.js create mode 100644 app/controllers/merchandising/admin.controller.js create mode 100644 app/controllers/merchandising/public.controller.js create mode 100644 app/controllers/pricing/admin.controller.js create mode 100644 app/models/catalogue/brand.model.js create mode 100644 app/models/catalogue/category.model.js create mode 100644 app/models/catalogue/categoryTranslation.model.js create mode 100644 app/models/catalogue/collection.model.js create mode 100644 app/models/catalogue/collectionProduct.model.js create mode 100644 app/models/catalogue/collectionTranslation.model.js create mode 100644 app/models/catalogue/product.model.js create mode 100644 app/models/catalogue/productAttribute.model.js create mode 100644 app/models/catalogue/productCategory.model.js create mode 100644 app/models/catalogue/productMedia.model.js create mode 100644 app/models/catalogue/productOption.model.js create mode 100644 app/models/catalogue/productOptionValue.model.js create mode 100644 app/models/catalogue/productRelation.model.js create mode 100644 app/models/catalogue/productReview.model.js create mode 100644 app/models/catalogue/productTranslation.model.js create mode 100644 app/models/catalogue/productVariant.model.js create mode 100644 app/models/catalogue/sizeGuide.model.js create mode 100644 app/models/catalogue/variantOptionValue.model.js create mode 100644 app/models/inventory/inventoryBalance.model.js create mode 100644 app/models/inventory/inventoryReservation.model.js create mode 100644 app/models/inventory/inventoryTransaction.model.js create mode 100644 app/models/inventory/inventoryTransfer.model.js create mode 100644 app/models/inventory/warehouse.model.js create mode 100644 app/models/merchandising/banner.model.js create mode 100644 app/models/merchandising/bannerTranslation.model.js create mode 100644 app/models/merchandising/coupon.model.js create mode 100644 app/models/merchandising/promotion.model.js create mode 100644 app/models/merchandising/promotionTarget.model.js create mode 100644 app/models/pricing/businessPriceTier.model.js create mode 100644 app/models/pricing/businessTier.model.js create mode 100644 app/models/pricing/variantBusinessPrice.model.js create mode 100644 app/routes/catalogue/admin.routes.js create mode 100644 app/routes/catalogue/public.routes.js create mode 100644 app/routes/inventory/admin.routes.js create mode 100644 app/routes/merchandising/admin.routes.js create mode 100644 app/routes/merchandising/public.routes.js create mode 100644 app/routes/pricing/admin.routes.js create mode 100644 app/seeders/catalogueExample.seeder.js create mode 100644 app/services/catalogue/catalogue.service.js create mode 100644 app/services/catalogue/locale.service.js create mode 100644 app/services/catalogue/serializer.service.js create mode 100644 app/services/inventory/inventory.service.js create mode 100644 app/services/pricing/businessPricing.service.js create mode 100644 app/services/pricing/money.js create mode 100644 app/services/pricing/pricing.service.js create mode 100644 app/validation/catalogue.schemas.js create mode 100644 app/validation/inventoryMerchandising.schemas.js create mode 100644 cron/inventoryReservationExpiry.cron.js create mode 100644 migrations/20260903040000-phase-4-catalogue-content.js create mode 100644 migrations/20260903050000-phase-5-inventory-merchandising.js create mode 100644 tests/unit/catalogue.locale-serializer.test.js create mode 100644 tests/unit/catalogue.schemas.test.js create mode 100644 tests/unit/catalogue.service.test.js create mode 100644 tests/unit/inventory-merchandising.money.test.js create mode 100644 tests/unit/inventory-merchandising.permissions.test.js create mode 100644 tests/unit/inventory-merchandising.schemas.test.js diff --git a/Documentation/API_CATALOGUE.md b/Documentation/API_CATALOGUE.md new file mode 100644 index 0000000..b35dcec --- /dev/null +++ b/Documentation/API_CATALOGUE.md @@ -0,0 +1,64 @@ +# ZUMRI Catalogue API + +Catalogue reads use `/api/v1`; `/api` remains compatible. Public endpoints require no authentication. Administrative endpoints require Phase 1 permissions. Examples use placeholders. + +## Localization + +Locale priority is `?locale=en|si|ta`, `X-Locale`, authenticated profile preference, `Accept-Language`, then English. Missing requested content falls back to English and then the first available translation. + +## Public products + +- `GET /api/v1/products` +- `GET /api/v1/products/:slug` +- `POST /api/v1/products/:productId/reviews` — authenticated customer + +List query parameters: `page`, `limit` (maximum 100), `category`, `brand`, `search`, `minPrice`, `maxPrice`, `featured`, `newArrival`, `locale`, and `sort=newest|price_asc|price_desc|name|featured`. Unsupported sorts return 400. Stock and wholesale availability are intentionally absent. + +```json +{"data":[{"id":"","slug":"","name":"","primaryImage":{"url":""},"minPrice":"1000.00","maxPrice":"1200.00","currency":"LKR","featured":false,"newArrival":true}]} +``` + +Detail returns localized content/SEO, brand, categories, active variants, options, descriptive attributes, signed gallery media, a structured size guide, rating summary, and approved review preview. DRAFT, INACTIVE, ARCHIVED, and HIDDEN products always return 404 publicly. + +Review body is `{"rating":5,"title":"","body":"<review>"}`. `status`, `verifiedPurchase`, moderator, and user IDs are rejected. One review per user/product is enforced. Purchase verification defaults false until order integration exists. + +## Categories, brands, and collections + +- `GET /api/v1/categories` — tree; add `flat=true` for a flat list +- `GET /api/v1/categories/:slug` +- `GET /api/v1/brands` +- `GET /api/v1/brands/:slug` +- `GET /api/v1/collections` +- `GET /api/v1/collections/:slug` + +Only active categories/brands and currently scheduled active collections are returned. + +## Administrative products + +- `GET /api/v1/admin/products` — `catalogue.products.read` +- `POST /api/v1/admin/products` — `catalogue.products.create` +- `GET /api/v1/admin/products/:id` — read permission +- `PATCH /api/v1/admin/products/:id` — `catalogue.products.update` +- `DELETE /api/v1/admin/products/:id` — `catalogue.products.delete`; archives instead of deleting +- `POST /api/v1/admin/products/:productId/variants` +- `PATCH /api/v1/admin/products/:productId/variants/:variantId` +- `POST /api/v1/admin/products/:productId/options` +- `POST /api/v1/admin/products/:productId/media` +- `POST /api/v1/admin/products/:productId/relations` + +Product creation atomically persists translations, category joins, variants, and owned Phase 2 uploads. Prices are decimal strings. Publishing requires English content, a brand, an active variant, and primary media. Published slugs are immutable. + +## Administrative content + +- `POST|PATCH /api/v1/admin/brands[/:id]` — `catalogue.brands.manage` +- `POST|PATCH /api/v1/admin/categories[/:id]` — `catalogue.categories.manage` +- `POST /api/v1/admin/collections` — `catalogue.collections.manage` +- `POST /api/v1/admin/size-guides` — `catalogue.size-guides.manage` +- `GET /api/v1/admin/reviews` — `catalogue.reviews.read` +- `PATCH /api/v1/admin/reviews/:id/status` — `catalogue.reviews.moderate` + +Categories cannot parent themselves or form cycles. Referenced categories/brands have no hard-delete API. Collections use deterministic join ordering and publish windows. Size guides accept structured columns/rows, never HTML. + +## Media and prices + +Media must be an AVAILABLE upload owned by the acting administrator. Linking transfers metadata ownership to the catalogue Product. Public responses contain short-lived signed URLs but never object keys, bucket names, or credentials. `basePrice` and optional `compareAtPrice` are display catalogue prices only; promotions and authoritative shopping pricing are deferred. diff --git a/Documentation/API_INVENTORY_MERCHANDISING.md b/Documentation/API_INVENTORY_MERCHANDISING.md new file mode 100644 index 0000000..2855576 --- /dev/null +++ b/Documentation/API_INVENTORY_MERCHANDISING.md @@ -0,0 +1,42 @@ +# Inventory and Merchandising API + +All routes use the `/api/v1` prefix. Admin routes require authentication and the named Phase 5 permission. + +## Inventory and warehouses + +- `GET /admin/inventory` (`inventory.read`) +- `GET /admin/inventory/:variantId` (`inventory.read`) +- `GET /admin/inventory/ledger` (`inventory.read`) +- `GET /admin/inventory/low-stock` (`inventory.read`) +- `POST /admin/inventory/adjustments` (`inventory.adjust`); send `Idempotency-Key` +- `POST /admin/inventory/transfers` (`inventory.transfer`); send `Idempotency-Key` +- `GET /admin/warehouses` (`inventory.read`) +- `POST /admin/warehouses`, `PATCH /admin/warehouses/:id` (`inventory.warehouses.manage`) +- `GET /availability/:variantId` returns only `IN_STOCK`, `LOW_STOCK`, or `OUT_OF_STOCK` and `availableForSale`; it never exposes warehouse quantities. + +Inventory mutations are internal service operations: `reserveStock`, `releaseReservation`, and `consumeReservation`. Reservations default to `INVENTORY_RESERVATION_TTL_MINUTES=15`. The minute reconciliation job expires bounded batches of 100; the database remains authoritative. + +## Business pricing + +- `GET /admin/business-pricing` (`pricing.business.read`) +- `POST /admin/business-pricing` (`pricing.business.manage`) + +Rules support exactly one tier or customer audience, effective dates, MOQ, and non-overlapping volume ranges. Precedence is customer override, business tier, then retail. Money is stored as DECIMAL and calculated using integer-scaled helpers. + +## Promotions and coupons + +- `GET|POST /admin/promotions` (`promotions.read` / `promotions.manage`) +- `GET|POST /admin/coupons` (`promotions.read` / `promotions.manage`) + +The quote boundary resolves retail/business base price, then the highest-priority eligible automatic promotion, then a coupon only when stacking permits. Discounts floor at zero. Coupon codes are canonical uppercase. Usage redemption is intentionally deferred until orders exist. + +## Banners + +- `GET /banners?placement=&locale=` returns active, scheduled, audience-eligible localized banners. +- `GET|POST /admin/banners` requires `merchandising.banners.manage`. + +Banner media reuses Upload records and only returns safe upload identifiers, never bucket/object keys. + +## Not implemented + +Cart, checkout, orders, coupon redemption, shipping, tax, payment, and delivery remain outside Phase 5. diff --git a/Documentation/CURRENT_BACKEND_STATUS.md b/Documentation/CURRENT_BACKEND_STATUS.md index d1780cd..647540a 100644 --- a/Documentation/CURRENT_BACKEND_STATUS.md +++ b/Documentation/CURRENT_BACKEND_STATUS.md @@ -1,5 +1,13 @@ # ZUMRI Current Backend Status +## Phase 4 Completion Update + +Completion date: 2026-09-03. Module 03 (product catalogue) is approximately 84%; Module 04 (multi-language content) is approximately 82%. Phase 4 adds 18 catalogue models covering brands, hierarchical localized categories, products/translations/multi-category joins, DECIMAL-price variants, configurable options and attributes, owned media, scheduled localized collections, structured size guides, explicit relations, and moderated reviews. + +Public APIs now provide active/public product lists and slug detail, safe search/filter/sort/pagination, locale fallback, categories, brands, current collections, signed media DTOs, price ranges, and approved review summaries. Permission-gated admin APIs cover product lifecycle, variants/options/media/relations, brands, cycle-safe categories, collections, size guides, and review moderation. No inventory quantity, wholesale pricing, promotion, or shopping behavior was introduced. + +The mocked regression suite passes 15 suites/78 tests and syntax validation covers 204 JavaScript files. The Phase 4 migration is forward-only and was not executed. Restored-staging migration checks, permission seeding, real MySQL query/concurrency testing, and signed-media volume validation remain required before Phase 5. See `Documentation/PHASE_4_CATALOGUE_CONTENT.md` and `Documentation/API_CATALOGUE.md`. + ## Phase 3 Completion Update Completion date: 2026-09-03. Module 02 (customer profile/address management) is now approximately 88%; Module 13 (business accounts) is approximately 78%. Customer profile/preferences, structured owned addresses with transactional defaults, secure self-deactivation, business applications, permission-gated transactional approval, immutable partner identity, business profiles/contacts/addresses, domain status, DECIMAL credit configuration, and settlement-term primitives are implemented. @@ -403,3 +411,6 @@ New `auth_sessions`, `user_identities`, and `user_roles` models/tables support d Auth endpoints now cover customer registration, verification/resend, password-to-OTP challenge, OTP completion, refresh rotation, current/all-device logout, forgot/reset/change password, Google, Apple, `/me`, and shared admin/rider compatibility flows. Both `/api` and `/api/v1` remain. Security-focused unit/integration tests were added without real providers/email/database/Redis. Remaining identity work is operational: validate/deduplicate deployed RBAC data before migration, run staging MySQL/Redis concurrency tests, seed initial privileged assignments securely, configure provider audiences/mail, and design manual privileged OAuth linking and email change if required. Module 01 is now approximately 91%; migration/staging validation prevents claiming 100% production completion. +## Phase 5 Completion Update + +Date: 2026-09-03. Inventory/reservation foundations, business pricing, promotions/coupons, and localized scheduled banners are implemented behind new permissions and a forward-only migration. Phase 5 adds 13 models, secured administration routes, public availability/banner projections, audit calls, a bounded expiry reconciliation cron, and Phase 6 pricing/reservation service boundaries. Module 05 is 85%, Module 07 is 78%, and Module 13 is 75%. Inventory is 90%, reservations 90%, business pricing 82%, promotions/coupons 78%, and banners 82%. Automated verification: 18 suites and 89 tests passed; syntax passed for 232 JavaScript files. Migration was not executed. Staging must precheck legacy stock/pricing/promotion/banner data, apply the migration, seed permissions/default warehouse, and test genuine MySQL locking plus cron behavior before Phase 6 production use. diff --git a/Documentation/PHASE_4_CATALOGUE_CONTENT.md b/Documentation/PHASE_4_CATALOGUE_CONTENT.md new file mode 100644 index 0000000..09adf7d --- /dev/null +++ b/Documentation/PHASE_4_CATALOGUE_CONTENT.md @@ -0,0 +1,103 @@ +# ZUMRI Phase 4 Catalogue and Content + +## Objective + +Create the product/content foundation required before inventory and shopping, with no stock, reservation, wholesale-pricing, cart, order, or payment behavior. + +## Architecture + +Catalogue models live under `app/models/catalogue`, domain services under `app/services/catalogue`, controllers/routes under their catalogue folders, and one new forward migration owns the schema. Phase 1 RBAC/audit and Phase 2 Upload/storage are reused. + +## Brand + +Brand has immutable identity, unique normalized slug, editorial description, optional owned logo, website, ACTIVE/INACTIVE state, and deterministic order. Names remain language-neutral; no unnecessary BrandTranslation table was added. + +## Category Hierarchy + +Category separates structural code/slug/parent/status from localized content. Root and nested categories are supported. A bounded ancestor walk rejects invalid parents, self-parenting, and cycles. Public APIs offer tree or flat representations. + +## Product + +Product stores structural identity, unique slug/code, brand/default category, DRAFT/ACTIVE/INACTIVE/ARCHIVED state, PUBLIC/HIDDEN visibility, featured flag, publication/new-arrival dates, and audit actors. It stores no stock quantity. + +## Product Variants + +Variants carry globally unique SKU, optional barcode, state, `DECIMAL(15,2)` base/compare-at prices, ISO-style currency code, optional weight, and order. No inventory columns exist. + +## Product Options + +Normalized ProductOption and ProductOptionValue records define variant dimensions. VariantOptionValue links validated same-product values. ProductAttribute holds non-variant descriptive facts separately. + +## Pricing Boundary + +Phase 4 persists catalogue display price and optional comparison price only. Decimal values remain strings in validation/serialization. Promotions, coupons, business tiers, wholesale/MOQ/volume pricing, and final checkout pricing are excluded. + +## Product Media + +ProductMedia associates Phase 2 Upload records with products and optional same-product variants. Only supported image uploads may be linked. Primary selection is transactional. Upload metadata moves from administrator ownership to CATALOGUE/Product ownership. Public access uses short signed URLs and never exposes storage internals. + +## Localization + +ProductTranslation and CategoryTranslation enforce one row per `en|si|ta` locale. CollectionTranslation localizes editorial collections. Resolution uses explicit locale, profile/header language, and English fallback without hardcoded UI translations. + +## SEO / Slugs + +Unique lowercase safe slugs exist for brands, categories, products, and collections. Product/category translations carry meta title/description. Published product slugs are restricted from mutation, so a slug-history subsystem is not currently necessary. + +## Collections + +Collections support type, state, publish window, hero upload, translations, and deterministic CollectionProduct ordering. `featured` on Product is the canonical global featured flag; collection membership is contextual editorial merchandising. + +## Size Guides + +Reusable SizeGuide records use strictly validated structured column/row JSON and may link to a category or product. Raw arbitrary HTML is prohibited. + +## Product Relationships + +Explicit RELATED, SIMILAR, and COMPLETE_THE_LOOK relations are unique and reject self-relations. They are curated content, not AI recommendations. + +## Reviews + +Authenticated customer/business-customer accounts may create one pending review per public active product. Rating is 1–5. Public detail exposes APPROVED reviews only. Permission-gated moderation records actor/time and audit events. + +## Verified Purchase Future Integration + +Clients cannot submit `verifiedPurchase`; it always starts false. A future order subsystem may derive or update it from authoritative completed order lines. Phase 4 fabricates no purchase evidence. + +## Public Catalogue + +Public product/category/brand/collection routes filter state and visibility at query time, resolve localized content, use eager associations, return small DTOs, compute active-variant price ranges, and hide internal catalogue/storage fields. + +## Admin Catalogue + +Permission-scoped routes support product creation/update/archive, variants, options, media, relations, brands, hierarchical categories, collections, size guides, and review moderation. Product creation is transactional and never deletes pre-existing Upload objects on rollback. + +## Search and Filtering + +MySQL/Sequelize search covers localized name, product code/SKU foundation, and brand name. Filters include category, brand, decimal price bounds, featured, and new arrival. Sort modes are allowlisted. Inventory and wholesale filters are absent. The search boundary can later be replaced without changing public DTOs. + +## Permissions + +Added `catalogue.products.read/create/update/delete`, `catalogue.categories.manage`, `catalogue.brands.manage`, `catalogue.collections.manage`, `catalogue.size-guides.manage`, `catalogue.reviews.read`, and `catalogue.reviews.moderate`. SUPER_ADMIN retains the Phase 1 bypass. + +## Audit + +Stable events cover product/variant/brand/category/collection creation and updates, publishing/archive, and review submission/moderation. Events store identifiers and concise metadata rather than descriptions or media contents. + +## Database Changes + +`20260903040000-phase-4-catalogue-content.js` creates only the models used by Phase 4, with foreign keys, uniqueness, state/search indexes, rating/self-relation checks, and DECIMAL prices. It is forward-only and was not run. + +Migration pre-checks: identify legacy product/category/brand tables, duplicate slugs/SKUs/barcodes, currency inconsistencies, legacy media ownership, missing/orphan Uploads, and conflicting table names. Back up and resolve explicitly; no data is silently removed. `catalogueExample.seeder.js` is optional and creates inactive editable examples only. + +## Tests + +Phase 4 tests cover strict schemas, slug/money/rating/locale validation, verified-purchase spoofing, structured size guides, fallback localization, exact decimal comparison, category cycles, and centralized publish requirements. The full Phase 0–3 regression suite remains required. + +## Remaining Known Issues + +The migration and real MySQL constraints/query plans are untested. Staging must validate multi-include pagination, concurrent slug/SKU/media-primary operations, signed-media volume, and actual migration ordering. Review approval/rejection notification was intentionally not enabled to avoid spam without a confirmed product requirement. Product relation projection and full admin update/reorder endpoints can expand when frontend workflows are finalized. + +## Phase 5 Prerequisites + +Complete legacy pre-checks, apply migrations to restored staging, seed permissions, load optional editable content if desired, run public-query EXPLAIN tests with realistic volume, validate signed media and concurrency, and freeze the ProductVariant identifier contract needed by inventory. diff --git a/Documentation/PHASE_5_INVENTORY_MERCHANDISING.md b/Documentation/PHASE_5_INVENTORY_MERCHANDISING.md new file mode 100644 index 0000000..bfb0291 --- /dev/null +++ b/Documentation/PHASE_5_INVENTORY_MERCHANDISING.md @@ -0,0 +1,85 @@ +# ZUMRI Phase 5 Inventory and Merchandising + +## Objective +Provide authoritative multi-warehouse stock, reservation primitives, wholesale pricing, price quotes, promotions, coupons, and scheduled localized banners for Phase 6 consumers. + +## Existing Components Reused +ProductVariant, BusinessCustomer, Upload, authorization middleware, audit queue, cron bootstrap, Zod, Sequelize, and catalogue localization are reused. + +## Inventory Architecture +Catalogue never stores stock. `InventoryBalance` is authoritative per warehouse/variant; availability is `onHand - reserved`. All writes pass through one service and append an immutable ledger event. + +## Warehouses +Multiple active/inactive warehouses and a transactionally selected default are supported. Default warehouse selection is deterministic. + +## Inventory Balance +Quantities are integers. Service invariants prevent negative stock and `reserved > onHand`. + +## Inventory Ledger +Every adjustment, reservation lifecycle event, and transfer has a unique event ID. There is no ledger update API. + +## Stock Adjustments +Adjustments lock balances, enforce invariants, append a ledger row, support HTTP idempotency keys, and emit audit events. + +## Transfers +Synchronous transfers lock warehouse IDs in sorted order, then create paired OUT/IN ledger rows. + +## Reservation Architecture +Unique reservation keys make reserve/release/consume idempotent. Generic references avoid premature cart/order coupling. + +## Reservation Concurrency +MySQL transactions and row-level `FOR UPDATE` locks serialize competitors for final units. Real multi-connection InnoDB validation remains a staging prerequisite. + +## Reservation Expiry +A no-overlap minute cron reconciles up to 100 expired ACTIVE records per run; each record is locked and the database state is authoritative. + +## Availability Projection +Public responses expose status and boolean sale availability only. Out-of-stock catalogue items remain visible. + +## Low Stock +Low stock is centrally defined as available quantity less than or equal to the configured balance threshold. + +## Business Pricing +Wholesale rules remain separate from retail base price and are limited to approved ACTIVE business accounts. + +## Business Tiers +Business customers may reference an active tier; customer-specific negotiated rules are also supported. + +## MOQ +MOQ belongs to a wholesale rule and never applies to retail fallback. + +## Volume Pricing +Integer, non-overlapping ranges select the greatest eligible minimum quantity; final maximum may be null. + +## Pricing Precedence +Retail -> eligible customer override (preferred over tier) -> volume tier -> highest-priority automatic promotion -> permitted coupon. Effective price floors at zero. + +## Promotions +Percentage, fixed amount, and fixed price campaigns support dates, priority, minimum quantity, audiences, stacking, and normalized targets. + +## Coupons +Codes are uppercase and validated against coupon/promotion state and schedule. Redemption counters are not fabricated before orders. + +## Banners +Placements are string-configurable. Status, schedule, sort order, audience, and optional business tier drive projection. + +## Localization +Banner translations use `en`, `si`, and `ta`, with requested locale then English fallback. + +## Permissions +Inventory read/adjust/transfer/reservation/warehouse, business pricing read/manage, promotion read/manage, and banner manage permissions were added. + +## Audit Events +Warehouse, adjustment, transfer, business-price, promotion, coupon, and banner mutations enqueue sanitized Phase 2 audit activities. + +## Database Changes +The forward-only `20260903050000` migration adds 13 domain tables and the BusinessCustomer tier reference. Earlier migrations are unchanged. + +## Tests +Unit coverage verifies decimal precision, zero floor, availability states, strict input, coupon normalization, and permission boundaries. Existing regression tests remain green. + +## Remaining Known Issues +Patch endpoints for promotion/coupon/banner/business pricing and real infrastructure integration tests remain follow-up hardening. No production migration was run. + +## Phase 6 Prerequisites +Run legacy-data prechecks and migration on staging; validate constraints and concurrent reservations using two real InnoDB connections; verify cron in a multi-instance deployment; seed permissions and warehouse data. diff --git a/app/config/env.config.js b/app/config/env.config.js index 369c22c..7302ff2 100644 --- a/app/config/env.config.js +++ b/app/config/env.config.js @@ -44,6 +44,7 @@ const envSchema = z.object({ EMAIL_QUEUE_CONCURRENCY: z.coerce.number().int().positive().default(5), DOCUMENT_QUEUE_CONCURRENCY: z.coerce.number().int().positive().default(2), NOTIFICATION_RETENTION_DAYS: z.coerce.number().int().positive().default(90), + INVENTORY_RESERVATION_TTL_MINUTES: z.coerce.number().int().positive().default(15), LOG_RETENTION_DAYS: z.coerce.number().int().positive().default(30), DOCS_USER: z.string().optional(), DOCS_PASS: z.string().optional(), GOOGLE_CLIENT_ID: z.string().optional(), APPLE_CLIENT_ID: z.string().optional(), diff --git a/app/constants/locales.js b/app/constants/locales.js new file mode 100644 index 0000000..66d9b83 --- /dev/null +++ b/app/constants/locales.js @@ -0,0 +1 @@ +const SUPPORTED_LOCALES=Object.freeze(["en","si","ta"]);const DEFAULT_LOCALE="en";module.exports={SUPPORTED_LOCALES,DEFAULT_LOCALE}; diff --git a/app/constants/permissions.js b/app/constants/permissions.js index 7f89de6..c70debf 100644 --- a/app/constants/permissions.js +++ b/app/constants/permissions.js @@ -20,4 +20,8 @@ module.exports = { BUSINESS_APPLICATIONS_READ: "business.applications.read", BUSINESS_APPLICATIONS_REVIEW: "business.applications.review", BUSINESS_ACCOUNTS_READ: "business.accounts.read", BUSINESS_ACCOUNTS_UPDATE: "business.accounts.update", BUSINESS_CREDIT_MANAGE: "business.credit.manage", BUSINESS_SETTLEMENT_MANAGE: "business.settlement.manage", + CATALOGUE_PRODUCTS_READ:"catalogue.products.read",CATALOGUE_PRODUCTS_CREATE:"catalogue.products.create",CATALOGUE_PRODUCTS_UPDATE:"catalogue.products.update",CATALOGUE_PRODUCTS_DELETE:"catalogue.products.delete", + CATALOGUE_CATEGORIES_MANAGE:"catalogue.categories.manage",CATALOGUE_BRANDS_MANAGE:"catalogue.brands.manage",CATALOGUE_COLLECTIONS_MANAGE:"catalogue.collections.manage",CATALOGUE_SIZE_GUIDES_MANAGE:"catalogue.size-guides.manage",CATALOGUE_REVIEWS_READ:"catalogue.reviews.read",CATALOGUE_REVIEWS_MODERATE:"catalogue.reviews.moderate", + INVENTORY_READ:"inventory.read",INVENTORY_ADJUST:"inventory.adjust",INVENTORY_TRANSFER:"inventory.transfer",INVENTORY_RESERVATIONS_READ:"inventory.reservations.read",INVENTORY_WAREHOUSES_MANAGE:"inventory.warehouses.manage", + BUSINESS_PRICING_READ:"pricing.business.read",BUSINESS_PRICING_MANAGE:"pricing.business.manage",PROMOTIONS_READ:"promotions.read",PROMOTIONS_MANAGE:"promotions.manage",BANNERS_MANAGE:"merchandising.banners.manage", }; diff --git a/app/controllers/catalogue/admin.controller.js b/app/controllers/catalogue/admin.controller.js new file mode 100644 index 0000000..ef43e1c --- /dev/null +++ b/app/controllers/catalogue/admin.controller.js @@ -0,0 +1,18 @@ +const crypto=require("crypto");const db=require("../../models");const service=require("../../services/catalogue/catalogue.service");const {logActivity}=require("../../services/activity.service"); +const audit=(req,type,targetType,targetId,description)=>logActivity({user:req.user,type,module:"Catalogue",description,targetType,targetId,requestId:req.id}); +exports.listProducts=async(req,res,next)=>{try{const page=Math.max(Number(req.query.page)||1,1),limit=Math.min(Number(req.query.limit)||20,100),where={};if(req.query.status)where.status=req.query.status;return res.json({success:true,...await db.Product.findAndCountAll({where,limit,offset:(page-1)*limit,include:[{model:db.ProductTranslation,as:"translations"},{model:db.ProductVariant,as:"variants"}],order:[["createdAt","DESC"]]})});}catch(e){return next(e);}}; +exports.createProduct=async(req,res,next)=>{try{const p=await service.createProduct(req.user,req.body);await audit(req,"PRODUCT_CREATED","PRODUCT",p.id,"Product created");return res.status(201).json({success:true,data:{id:p.id,slug:p.slug,status:p.status}});}catch(e){return next(e);}}; +exports.getProduct=async(req,res,next)=>{try{const p=await db.Product.findByPk(req.params.id,{include:[{model:db.ProductTranslation,as:"translations"},{model:db.ProductVariant,as:"variants"},{model:db.Category,as:"categories",through:{attributes:[]}},{model:db.ProductMedia,as:"media"}]});if(!p)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Product not found"}});return res.json({success:true,data:p});}catch(e){return next(e);}}; +exports.updateProduct=async(req,res,next)=>{try{const p=await service.setProductState(req.params.id,req.user,req.body);await audit(req,p.status==="ACTIVE"?"PRODUCT_PUBLISHED":p.status==="ARCHIVED"?"PRODUCT_ARCHIVED":"PRODUCT_UPDATED","PRODUCT",p.id,"Product updated");return res.json({success:true,data:{id:p.id,slug:p.slug,status:p.status,visibility:p.visibility}});}catch(e){return next(e);}}; +exports.archiveProduct=async(req,res,next)=>{try{const p=await service.setProductState(req.params.id,req.user,{status:"ARCHIVED",visibility:"HIDDEN"});await audit(req,"PRODUCT_ARCHIVED","PRODUCT",p.id,"Product archived");return res.json({success:true,data:{id:p.id,status:p.status}});}catch(e){return next(e);}}; +exports.createVariant=async(req,res,next)=>{try{const p=await db.Product.findByPk(req.params.productId);if(!p)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Product not found"}});const b=req.body,v=await db.ProductVariant.create({id:crypto.randomUUID(),product_id:p.id,sku:b.sku,barcode:b.barcode,base_price:b.basePrice,compare_at_price:b.compareAtPrice,currency:b.currency,weight:b.weight,sort_order:b.sortOrder});await audit(req,"VARIANT_CREATED","PRODUCT_VARIANT",v.id,"Product variant created");return res.status(201).json({success:true,data:v});}catch(e){return next(e);}}; +exports.updateVariant=async(req,res,next)=>{try{let v;await db.sequelize.transaction(async t=>{v=await db.ProductVariant.findOne({where:{id:req.params.variantId,product_id:req.params.productId},transaction:t,lock:t.LOCK.UPDATE});if(!v)throw Object.assign(new Error("Variant not found"),{status:404,code:"NOT_FOUND"});const b=req.body;await v.update({...(b.status!==undefined&&{status:b.status}),...(b.basePrice!==undefined&&{base_price:b.basePrice}),...(b.compareAtPrice!==undefined&&{compare_at_price:b.compareAtPrice}),...(b.currency!==undefined&&{currency:b.currency}),...(b.weight!==undefined&&{weight:b.weight}),...(b.sortOrder!==undefined&&{sort_order:b.sortOrder})},{transaction:t});if(b.optionValueIds){const values=await db.ProductOptionValue.findAll({where:{id:b.optionValueIds},include:[{model:db.ProductOption,as:"option",where:{product_id:req.params.productId}}],transaction:t});if(values.length!==new Set(b.optionValueIds).size)throw Object.assign(new Error("Option values must belong to this product"),{status:400,code:"CROSS_PRODUCT_OPTION"});await db.VariantOptionValue.destroy({where:{variant_id:v.id},transaction:t});await db.VariantOptionValue.bulkCreate(values.map(x=>({id:crypto.randomUUID(),variant_id:v.id,option_value_id:x.id})),{transaction:t});}});await audit(req,"VARIANT_UPDATED","PRODUCT_VARIANT",v.id,"Product variant updated");return res.json({success:true,data:v});}catch(e){return next(e);}}; +exports.createBrand=async(req,res,next)=>{try{if(req.body.logoUploadId)await service.validateMedia([req.body.logoUploadId],req.user.id);const b=req.body,x=await db.Brand.create({id:crypto.randomUUID(),name:b.name,slug:b.slug,description:b.description,logo_upload_id:b.logoUploadId,website:b.website,status:b.status,sort_order:b.sortOrder});await audit(req,"BRAND_CREATED","BRAND",x.id,"Brand created");return res.status(201).json({success:true,data:x});}catch(e){return next(e);}}; +exports.updateBrand=async(req,res,next)=>{try{const x=await db.Brand.findByPk(req.params.id);if(!x)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Brand not found"}});await x.update(req.body);await audit(req,"BRAND_UPDATED","BRAND",x.id,"Brand updated");return res.json({success:true,data:x});}catch(e){return next(e);}}; +exports.createCategory=async(req,res,next)=>{try{let x;await db.sequelize.transaction(async t=>{await service.assertCategoryParent(null,req.body.parentId,t);const b=req.body;x=await db.Category.create({id:crypto.randomUUID(),parent_id:b.parentId,code:b.code,slug:b.slug,status:b.status,sort_order:b.sortOrder,image_upload_id:b.imageUploadId},{transaction:t});await db.CategoryTranslation.bulkCreate(b.translations.map(y=>({id:crypto.randomUUID(),category_id:x.id,locale:y.locale,name:y.name,description:y.description,meta_title:y.metaTitle,meta_description:y.metaDescription})),{transaction:t});});await audit(req,"CATEGORY_CREATED","CATEGORY",x.id,"Category created");return res.status(201).json({success:true,data:x});}catch(e){return next(e);}}; +exports.updateCategory=async(req,res,next)=>{try{let x;await db.sequelize.transaction(async t=>{x=await db.Category.findByPk(req.params.id,{transaction:t,lock:t.LOCK.UPDATE});if(!x)throw Object.assign(new Error("Category not found"),{status:404,code:"NOT_FOUND"});await service.assertCategoryParent(x.id,req.body.parentId,t);await x.update({parent_id:req.body.parentId,code:req.body.code,slug:req.body.slug,status:req.body.status,sort_order:req.body.sortOrder,image_upload_id:req.body.imageUploadId},{transaction:t});});await audit(req,"CATEGORY_UPDATED","CATEGORY",x.id,"Category updated");return res.json({success:true,data:x});}catch(e){return next(e);}}; +exports.createCollection=async(req,res,next)=>{try{let x;await db.sequelize.transaction(async t=>{const b=req.body;if(b.endsAt&&b.startsAt&&b.endsAt<=b.startsAt)throw Object.assign(new Error("Collection end must follow start"),{status:400,code:"INVALID_WINDOW"});x=await db.Collection.create({id:crypto.randomUUID(),slug:b.slug,type:b.type,status:b.status,starts_at:b.startsAt,ends_at:b.endsAt,hero_upload_id:b.heroUploadId,sort_order:b.sortOrder},{transaction:t});await db.CollectionTranslation.bulkCreate(b.translations.map(y=>({id:crypto.randomUUID(),collection_id:x.id,locale:y.locale,name:y.name,description:y.description,headline:y.headline,subheadline:y.subheadline})),{transaction:t});await db.CollectionProduct.bulkCreate([...new Set(b.productIds)].map((product_id,i)=>({id:crypto.randomUUID(),collection_id:x.id,product_id,sort_order:i})),{transaction:t});});await audit(req,"COLLECTION_CREATED","COLLECTION",x.id,"Collection created");return res.status(201).json({success:true,data:x});}catch(e){return next(e);}}; +exports.createSizeGuide=async(req,res,next)=>{try{const b=req.body,x=await db.SizeGuide.create({id:crypto.randomUUID(),code:b.code,name:b.name,locale:b.locale,data:b.data,category_id:b.categoryId,status:b.status});return res.status(201).json({success:true,data:x});}catch(e){return next(e);}}; +exports.createOption=async(req,res,next)=>{try{let option;await db.sequelize.transaction(async t=>{const product=await db.Product.findByPk(req.params.productId,{transaction:t});if(!product)throw Object.assign(new Error("Product not found"),{status:404,code:"NOT_FOUND"});option=await db.ProductOption.create({id:crypto.randomUUID(),product_id:product.id,name:req.body.name,sort_order:req.body.sortOrder},{transaction:t});await db.ProductOptionValue.bulkCreate([...new Set(req.body.values)].map((value,i)=>({id:crypto.randomUUID(),option_id:option.id,value,sort_order:i})),{transaction:t});});return res.status(201).json({success:true,data:option});}catch(e){return next(e);}}; +exports.attachMedia=async(req,res,next)=>{try{let media;await db.sequelize.transaction(async t=>{const product=await db.Product.findByPk(req.params.productId,{transaction:t,lock:t.LOCK.UPDATE});if(!product)throw Object.assign(new Error("Product not found"),{status:404,code:"NOT_FOUND"});await service.validateMedia([req.body.uploadId],req.user.id,t);if(req.body.variantId){const variant=await db.ProductVariant.findOne({where:{id:req.body.variantId,product_id:product.id},transaction:t});if(!variant)throw Object.assign(new Error("Variant does not belong to product"),{status:400,code:"CROSS_PRODUCT_VARIANT"});}if(req.body.isPrimary)await db.ProductMedia.update({is_primary:false},{where:{product_id:product.id},transaction:t});media=await db.ProductMedia.create({id:crypto.randomUUID(),product_id:product.id,variant_id:req.body.variantId,upload_id:req.body.uploadId,type:"IMAGE",sort_order:req.body.sortOrder,alt_text:req.body.altText,is_primary:req.body.isPrimary},{transaction:t});await db.Upload.update({owner_type:"CATALOGUE",owner_id:product.id,use_for:"PRODUCT_MEDIA"},{where:{id:req.body.uploadId},transaction:t});});return res.status(201).json({success:true,data:{id:media.id,isPrimary:media.is_primary}});}catch(e){return next(e);}}; +exports.createRelation=async(req,res,next)=>{try{if(req.params.productId===req.body.targetProductId)return res.status(400).json({success:false,error:{code:"SELF_RELATION",message:"A product cannot relate to itself"}});const count=await db.Product.count({where:{id:[req.params.productId,req.body.targetProductId]}});if(count!==2)return res.status(400).json({success:false,error:{code:"INVALID_PRODUCT",message:"Both products must exist"}});const x=await db.ProductRelation.create({id:crypto.randomUUID(),source_product_id:req.params.productId,target_product_id:req.body.targetProductId,relation_type:req.body.relationType,sort_order:req.body.sortOrder});return res.status(201).json({success:true,data:x});}catch(e){return next(e);}}; diff --git a/app/controllers/catalogue/public.controller.js b/app/controllers/catalogue/public.controller.js new file mode 100644 index 0000000..841956c --- /dev/null +++ b/app/controllers/catalogue/public.controller.js @@ -0,0 +1,10 @@ +const {Op,fn,col,literal}=require("sequelize");const db=require("../../models");const {resolveLocale,selectTranslation}=require("../../services/catalogue/locale.service");const serializer=require("../../services/catalogue/serializer.service"); +const productIncludes=()=>[{model:db.ProductTranslation,as:"translations",required:true},{model:db.Brand,as:"brand",where:{status:"ACTIVE"},required:true},{model:db.ProductVariant,as:"variants",where:{status:"ACTIVE"},required:true},{model:db.ProductMedia,as:"media",required:false,include:[{model:db.Upload,as:"upload",where:{status:"AVAILABLE"},required:true}]}]; +exports.products=async(req,res,next)=>{try{const page=Math.max(Number(req.query.page)||1,1),limit=Math.min(Math.max(Number(req.query.limit)||20,1),100),where={status:"ACTIVE",visibility:"PUBLIC"};if(req.query.brand)where.brand_id=req.query.brand;if(req.query.featured!==undefined)where.featured=req.query.featured==="true";if(req.query.newArrival==="true")where.new_arrival_until={[Op.gt]:new Date()};if(req.query.search)where[Op.or]=[{"$translations.name$":{[Op.like]:`%${req.query.search.slice(0,100)}%`}},{product_code:{[Op.like]:`%${req.query.search.slice(0,100)}%`}},{"$brand.name$":{[Op.like]:`%${req.query.search.slice(0,100)}%`}}];const allowed={newest:[["published_at","DESC"]],price_asc:[[{model:db.ProductVariant,as:"variants"},"base_price","ASC"]],price_desc:[[{model:db.ProductVariant,as:"variants"},"base_price","DESC"]],name:[[{model:db.ProductTranslation,as:"translations"},"name","ASC"]],featured:[["featured","DESC"],["published_at","DESC"]]};if(req.query.sort&&!allowed[req.query.sort])return res.status(400).json({success:false,error:{code:"INVALID_SORT",message:"Unsupported sort"}});const include=productIncludes();if(req.query.category)include.push({model:db.Category,as:"categories",where:{id:req.query.category,status:"ACTIVE"},through:{attributes:[]},required:true});if(req.query.minPrice||req.query.maxPrice){const v=include.find(x=>x.as==="variants");v.where.base_price={...(req.query.minPrice&&{[Op.gte]:req.query.minPrice}),...(req.query.maxPrice&&{[Op.lte]:req.query.maxPrice})};}const result=await db.Product.findAndCountAll({where,include,distinct:true,limit,offset:(page-1)*limit,order:allowed[req.query.sort||"newest"]});const locale=resolveLocale(req);return res.json({success:true,data:await Promise.all(result.rows.map(x=>serializer.summary(x,locale))),pagination:{page,limit,total:result.count,pages:Math.ceil(result.count/limit)}});}catch(e){return next(e);}}; +exports.product=async(req,res,next)=>{try{const p=await db.Product.findOne({where:{slug:req.params.slug,status:"ACTIVE",visibility:"PUBLIC"},include:[...productIncludes(),{model:db.Category,as:"categories",where:{status:"ACTIVE"},through:{attributes:[]},required:false},{model:db.ProductOption,as:"options",include:[{model:db.ProductOptionValue,as:"values"}]},{model:db.ProductAttribute,as:"attributes"},{model:db.SizeGuide,as:"sizeGuide",required:false},{model:db.ProductReview,as:"reviews",where:{status:"APPROVED"},required:false,include:[{model:db.User,as:"author",attributes:["id","firstName"]}]}]});if(!p)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Product not found"}});const locale=resolveLocale(req),tr=selectTranslation(p.translations,locale),summary=await serializer.summary(p,locale),reviews=p.reviews||[],average=reviews.length?(reviews.reduce((n,r)=>n+r.rating,0)/reviews.length).toFixed(2):null;return res.json({success:true,data:{...summary,content:tr&&{shortDescription:tr.short_description,description:tr.description,careInstructions:tr.care_instructions,materials:tr.materials,origin:tr.origin},seo:tr&&{title:tr.meta_title,description:tr.meta_description},categories:p.categories?.map(c=>({id:c.id,slug:c.slug,name:selectTranslation(c.translations,locale)?.name})),variants:p.variants?.map(v=>({id:v.id,sku:v.sku,basePrice:String(v.base_price),compareAtPrice:v.compare_at_price&&String(v.compare_at_price),currency:v.currency,optionValues:v.optionValues})),options:p.options,attributes:p.attributes?.filter(a=>a.locale===locale||a.locale==="en"),media:await serializer.mediaDto(p.media),sizeGuide:p.sizeGuide,rating:{averageRating:average,reviewCount:reviews.length},reviews:reviews.slice(0,10).map(r=>({id:r.id,rating:r.rating,title:r.title,body:r.body,verifiedPurchase:r.verified_purchase,author:r.author&&{firstName:r.author.firstName},createdAt:r.createdAt}))}});}catch(e){return next(e);}}; +exports.categories=async(req,res,next)=>{try{const locale=resolveLocale(req),rows=await db.Category.findAll({where:{status:"ACTIVE"},include:[{model:db.CategoryTranslation,as:"translations"}],order:[["sort_order","ASC"]]});const nodes=rows.map(x=>({id:x.id,parentId:x.parent_id,slug:x.slug,name:selectTranslation(x.translations,locale)?.name,children:[]})),byId=new Map(nodes.map(x=>[x.id,x]));for(const n of nodes)if(n.parentId&&byId.has(n.parentId))byId.get(n.parentId).children.push(n);return res.json({success:true,data:req.query.flat==="true"?nodes:nodes.filter(x=>!x.parentId)});}catch(e){return next(e);}}; +exports.category=async(req,res,next)=>{try{const x=await db.Category.findOne({where:{slug:req.params.slug,status:"ACTIVE"},include:[{model:db.CategoryTranslation,as:"translations"}]});if(!x)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Category not found"}});const t=selectTranslation(x.translations,resolveLocale(req));return res.json({success:true,data:{id:x.id,slug:x.slug,parentId:x.parent_id,name:t?.name,description:t?.description,seo:{title:t?.meta_title,description:t?.meta_description}}});}catch(e){return next(e);}}; +exports.brands=async(req,res,next)=>{try{return res.json({success:true,data:await db.Brand.findAll({where:{status:"ACTIVE"},attributes:["id","name","slug","description","website"],order:[["sort_order","ASC"]]})});}catch(e){return next(e);}}; +exports.brand=async(req,res,next)=>{try{const x=await db.Brand.findOne({where:{slug:req.params.slug,status:"ACTIVE"},attributes:["id","name","slug","description","website"]});if(!x)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Brand not found"}});return res.json({success:true,data:x});}catch(e){return next(e);}}; +exports.collections=async(req,res,next)=>{try{const now=new Date(),rows=await db.Collection.findAll({where:{status:"ACTIVE",[Op.and]:[{[Op.or]:[{starts_at:null},{starts_at:{[Op.lte]:now}}]},{[Op.or]:[{ends_at:null},{ends_at:{[Op.gte]:now}}]}]},include:[{model:db.CollectionTranslation,as:"translations"}],order:[["sort_order","ASC"]]});const locale=resolveLocale(req);return res.json({success:true,data:rows.map(x=>({id:x.id,slug:x.slug,type:x.type,...selectTranslation(x.translations,locale)}))});}catch(e){return next(e);}}; +exports.collection=async(req,res,next)=>{try{const now=new Date(),x=await db.Collection.findOne({where:{slug:req.params.slug,status:"ACTIVE",[Op.and]:[{[Op.or]:[{starts_at:null},{starts_at:{[Op.lte]:now}}]},{[Op.or]:[{ends_at:null},{ends_at:{[Op.gte]:now}}]}]},include:[{model:db.CollectionTranslation,as:"translations"},{model:db.Product,as:"products",where:{status:"ACTIVE",visibility:"PUBLIC"},required:false,through:{attributes:["sort_order"]},include:productIncludes()}]});if(!x)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Collection not found"}});const locale=resolveLocale(req);return res.json({success:true,data:{id:x.id,slug:x.slug,...selectTranslation(x.translations,locale),products:await Promise.all((x.products||[]).map(p=>serializer.summary(p,locale)))}});}catch(e){return next(e);}}; diff --git a/app/controllers/catalogue/review.controller.js b/app/controllers/catalogue/review.controller.js new file mode 100644 index 0000000..c44e943 --- /dev/null +++ b/app/controllers/catalogue/review.controller.js @@ -0,0 +1,4 @@ +const crypto=require("crypto");const db=require("../../models");const {logActivity}=require("../../services/activity.service"); +exports.create=async(req,res,next)=>{try{if(!["customer","business_customer"].includes(req.user.accountType))return res.status(403).json({success:false,error:{code:"FORBIDDEN",message:"Customer account required"}});const product=await db.Product.findOne({where:{id:req.params.productId,status:"ACTIVE",visibility:"PUBLIC"}});if(!product)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Product not found"}});const review=await db.ProductReview.create({id:crypto.randomUUID(),product_id:product.id,user_id:req.user.id,rating:req.body.rating,title:req.body.title,body:req.body.body,status:"PENDING",verified_purchase:false});await logActivity({user:req.user,type:"REVIEW_SUBMITTED",module:"Catalogue",description:"Product review submitted",targetType:"PRODUCT_REVIEW",targetId:review.id,requestId:req.id});return res.status(201).json({success:true,data:{id:review.id,status:review.status}});}catch(e){return next(e);}}; +exports.listAdmin=async(req,res,next)=>{try{const page=Math.max(Number(req.query.page)||1,1),limit=Math.min(Number(req.query.limit)||20,100),where={};if(req.query.status)where.status=req.query.status;const x=await db.ProductReview.findAndCountAll({where,limit,offset:(page-1)*limit,order:[["createdAt","DESC"]]});return res.json({success:true,data:x.rows,pagination:{page,limit,total:x.count}});}catch(e){return next(e);}}; +exports.moderate=async(req,res,next)=>{try{const r=await db.ProductReview.findByPk(req.params.id);if(!r)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Review not found"}});await r.update({status:req.body.status,moderated_by:req.user.id,moderated_at:new Date()});await logActivity({user:req.user,type:req.body.status==="APPROVED"?"REVIEW_APPROVED":"REVIEW_REJECTED",module:"Catalogue",description:"Product review moderated",targetType:"PRODUCT_REVIEW",targetId:r.id,requestId:req.id});return res.json({success:true,data:{id:r.id,status:r.status}});}catch(e){return next(e);}}; diff --git a/app/controllers/inventory/admin.controller.js b/app/controllers/inventory/admin.controller.js new file mode 100644 index 0000000..b76f759 --- /dev/null +++ b/app/controllers/inventory/admin.controller.js @@ -0,0 +1,11 @@ +const crypto=require("crypto"),db=require("../../models"),inventory=require("../../services/inventory/inventory.service"),{logActivity}=require("../../services/activity.service"); +const audit=(req,type,id)=>logActivity({user:req.user,type,module:"Inventory",description:type,targetType:"INVENTORY",targetId:id,requestId:req.id}); +exports.list=async(req,res,next)=>{try{const rows=await db.InventoryBalance.findAll({include:[{model:db.Warehouse,as:"warehouse",attributes:["id","code","name"]},{model:db.ProductVariant,as:"variant",include:[{model:db.Product,as:"product",attributes:["id","slug"]}]}]});res.json({success:true,data:rows});}catch(e){next(e);}}; +exports.detail=async(req,res,next)=>{try{const rows=await db.InventoryBalance.findAll({where:{variant_id:req.params.variantId},include:[{model:db.Warehouse,as:"warehouse"}]});res.json({success:true,data:rows});}catch(e){next(e);}}; +exports.ledger=async(req,res,next)=>{try{res.json({success:true,data:await db.InventoryTransaction.findAll({where:req.query.variantId?{variant_id:req.query.variantId}:{},limit:Math.min(Number(req.query.limit)||50,200),order:[["occurred_at","DESC"]]})});}catch(e){next(e);}}; +exports.lowStock=async(req,res,next)=>{try{const rows=await db.InventoryBalance.findAll();res.json({success:true,data:rows.filter(x=>Number(x.on_hand)-Number(x.reserved)<=Number(x.low_stock_threshold))});}catch(e){next(e);}}; +exports.adjust=async(req,res,next)=>{try{const eventId=req.get("Idempotency-Key")||crypto.randomUUID(),result=await inventory.adjustStock({...req.body,eventId,actorUserId:req.user.id,requestId:req.id});await audit(req,"INVENTORY_ADJUSTED",eventId);res.status(result.idempotent?200:201).json({success:true,data:result});}catch(e){next(e);}}; +exports.transfer=async(req,res,next)=>{try{const eventId=req.get("Idempotency-Key")||crypto.randomUUID(),result=await inventory.transferStock({...req.body,eventId,actorUserId:req.user.id,requestId:req.id});await audit(req,"INVENTORY_TRANSFERRED",eventId);res.status(result.idempotent?200:201).json({success:true,data:result});}catch(e){next(e);}}; +exports.warehouses=async(req,res,next)=>{try{res.json({success:true,data:await db.Warehouse.findAll({order:[["code","ASC"]]})});}catch(e){next(e);}}; +exports.createWarehouse=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{if(req.body.isDefault)await db.Warehouse.update({is_default:false},{where:{},transaction:t});row=await db.Warehouse.create({id:crypto.randomUUID(),code:req.body.code.toUpperCase(),name:req.body.name,status:req.body.status,is_default:req.body.isDefault,timezone:req.body.timezone,city:req.body.city,country_code:req.body.countryCode},{transaction:t});});await audit(req,"WAREHOUSE_CREATED",row.id);res.status(201).json({success:true,data:row});}catch(e){next(e);}}; +exports.updateWarehouse=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{row=await db.Warehouse.findByPk(req.params.id,{transaction:t,lock:t.LOCK.UPDATE});if(!row)throw Object.assign(new Error("Warehouse not found"),{status:404,code:"NOT_FOUND"});if(req.body.isDefault)await db.Warehouse.update({is_default:false},{where:{},transaction:t});await row.update({name:req.body.name,status:req.body.status,is_default:req.body.isDefault,timezone:req.body.timezone,city:req.body.city,country_code:req.body.countryCode},{transaction:t});});await audit(req,"WAREHOUSE_UPDATED",row.id);res.json({success:true,data:row});}catch(e){next(e);}}; diff --git a/app/controllers/merchandising/admin.controller.js b/app/controllers/merchandising/admin.controller.js new file mode 100644 index 0000000..daaf8db --- /dev/null +++ b/app/controllers/merchandising/admin.controller.js @@ -0,0 +1,5 @@ +const crypto=require("crypto"),db=require("../../models"),{logActivity}=require("../../services/activity.service");const map=(b,u)=>({name:b.name,type:b.type,status:b.status,starts_at:b.startsAt,ends_at:b.endsAt,priority:b.priority,stackable:b.stackable,discount_percent:b.discountPercent,discount_amount:b.discountAmount,fixed_price:b.fixedPrice,minimum_quantity:b.minimumQuantity,updated_by:u}); +const crud=model=>async(req,res,next)=>{try{res.json({success:true,data:await model.findAll({order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.promotions=crud(db.Promotion);exports.coupons=crud(db.Coupon);exports.banners=crud(db.Banner); +exports.createPromotion=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{row=await db.Promotion.create({id:crypto.randomUUID(),...map(req.body,req.user.id),created_by:req.user.id},{transaction:t});await db.PromotionTarget.bulkCreate(req.body.targets.map(x=>({id:crypto.randomUUID(),promotion_id:row.id,target_type:x.type,target_id:x.id||null})),{transaction:t});});await logActivity({user:req.user,type:"PROMOTION_CREATED",module:"Merchandising",targetId:row.id});res.status(201).json({success:true,data:row});}catch(e){next(e);}}; +exports.createCoupon=async(req,res,next)=>{try{const b=req.body,row=await db.Coupon.create({id:crypto.randomUUID(),code:b.code,promotion_id:b.promotionId,status:b.status,starts_at:b.startsAt,expires_at:b.expiresAt});await logActivity({user:req.user,type:"COUPON_CREATED",module:"Merchandising",targetId:row.id});res.status(201).json({success:true,data:row});}catch(e){next(e);}}; +exports.createBanner=async(req,res,next)=>{try{const b=req.body;let row;await db.sequelize.transaction(async t=>{const upload=await db.Upload.findOne({where:{id:b.imageUploadId,status:"AVAILABLE"},transaction:t});if(!upload||!String(upload.mime_type||upload.mimeType).startsWith("image/"))throw Object.assign(new Error("Available image upload required"),{status:400,code:"INVALID_BANNER_MEDIA"});row=await db.Banner.create({id:crypto.randomUUID(),placement:b.placement,status:b.status,starts_at:b.startsAt,ends_at:b.endsAt,image_upload_id:b.imageUploadId,mobile_image_upload_id:b.mobileImageUploadId,target_url:b.targetUrl,audience_type:b.audienceType,business_tier_id:b.businessTierId,sort_order:b.sortOrder,created_by:req.user.id},{transaction:t});await db.BannerTranslation.bulkCreate(b.translations.map(x=>({id:crypto.randomUUID(),banner_id:row.id,locale:x.locale,headline:x.headline,subheadline:x.subheadline,cta_text:x.ctaText,alt_text:x.altText})),{transaction:t});});await logActivity({user:req.user,type:"BANNER_CREATED",module:"Merchandising",targetId:row.id});res.status(201).json({success:true,data:row});}catch(e){next(e);}}; diff --git a/app/controllers/merchandising/public.controller.js b/app/controllers/merchandising/public.controller.js new file mode 100644 index 0000000..03c6852 --- /dev/null +++ b/app/controllers/merchandising/public.controller.js @@ -0,0 +1,2 @@ +const {Op}=require("sequelize"),db=require("../../models");exports.banners=async(req,res,next)=>{try{const now=new Date(),audience=req.user?.accountType==="BUSINESS_CUSTOMER"?["ALL","BUSINESS_CUSTOMER"]:req.user?["ALL","CUSTOMER"]:["ALL"];const where={status:"ACTIVE",audience_type:{[Op.in]:audience},[Op.and]:[{[Op.or]:[{starts_at:null},{starts_at:{[Op.lte]:now}}]},{[Op.or]:[{ends_at:null},{ends_at:{[Op.gt]:now}}]}]};if(req.query.placement)where.placement=req.query.placement;const rows=await db.Banner.findAll({where,include:[{model:db.BannerTranslation,as:"translations"}],order:[["sort_order","ASC"],["id","ASC"]]});res.json({success:true,data:rows.map(x=>({id:x.id,placement:x.placement,targetUrl:x.target_url,image:{uploadId:x.image_upload_id},translation:(x.translations.find(t=>t.locale===(req.query.locale||"en"))||x.translations.find(t=>t.locale==="en")||x.translations[0])}))});}catch(e){next(e);}}; +exports.availability=async(req,res,next)=>{try{const rows=await db.InventoryBalance.findAll({where:{variant_id:req.params.variantId},attributes:["on_hand","reserved","low_stock_threshold"]}),available=rows.reduce((n,x)=>n+Number(x.on_hand)-Number(x.reserved),0),threshold=rows.reduce((n,x)=>n+Number(x.low_stock_threshold),0),status=available<=0?"OUT_OF_STOCK":available<=threshold?"LOW_STOCK":"IN_STOCK";res.json({success:true,data:{variantId:req.params.variantId,status,availableForSale:available>0}});}catch(e){next(e);}}; diff --git a/app/controllers/pricing/admin.controller.js b/app/controllers/pricing/admin.controller.js new file mode 100644 index 0000000..418ed5d --- /dev/null +++ b/app/controllers/pricing/admin.controller.js @@ -0,0 +1,3 @@ +const crypto=require("crypto"),db=require("../../models"),{logActivity}=require("../../services/activity.service"); +exports.list=async(req,res,next)=>{try{res.json({success:true,data:await db.VariantBusinessPrice.findAll({include:[{model:db.BusinessPriceTier,as:"tiers"}],order:[["createdAt","DESC"]]})});}catch(e){next(e);}}; +exports.create=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{const b=req.body;for(let i=0;i<b.tiers.length;i++){const a=b.tiers[i];if(a.maxQuantity!==null&&a.maxQuantity!==undefined&&a.maxQuantity<a.minQuantity)throw Object.assign(new Error("Invalid volume range"),{status:400,code:"INVALID_VOLUME_RANGE"});for(let j=i+1;j<b.tiers.length;j++){const c=b.tiers[j],amax=a.maxQuantity??Infinity,cmax=c.maxQuantity??Infinity;if(a.minQuantity<=cmax&&c.minQuantity<=amax)throw Object.assign(new Error("Volume tiers overlap"),{status:400,code:"OVERLAPPING_VOLUME_TIERS"});}}row=await db.VariantBusinessPrice.create({id:crypto.randomUUID(),variant_id:b.variantId,business_tier_id:b.businessTierId,business_customer_id:b.businessCustomerId,currency:b.currency,price:b.price,minimum_quantity:b.minimumQuantity,status:b.status,starts_at:b.startsAt,ends_at:b.endsAt},{transaction:t});await db.BusinessPriceTier.bulkCreate(b.tiers.map(x=>({id:crypto.randomUUID(),business_price_id:row.id,min_quantity:x.minQuantity,max_quantity:x.maxQuantity,unit_price:x.unitPrice})),{transaction:t});});await logActivity({user:req.user,type:"BUSINESS_PRICE_CREATED",module:"Pricing",targetId:row.id});res.status(201).json({success:true,data:row});}catch(e){next(e);}}; diff --git a/app/models/catalogue/brand.model.js b/app/models/catalogue/brand.model.js new file mode 100644 index 0000000..12b6b00 --- /dev/null +++ b/app/models/catalogue/brand.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("Brand",{id:{type:D.STRING,primaryKey:true},name:{type:D.STRING(160),allowNull:false},slug:{type:D.STRING(180),allowNull:false,unique:true},description:D.TEXT,logo_upload_id:D.INTEGER,website:D.STRING,status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"INACTIVE"},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"brands",timestamps:true});M.associate=m=>{M.hasMany(m.Product,{foreignKey:"brand_id",as:"products"});M.belongsTo(m.Upload,{foreignKey:"logo_upload_id",as:"logo",constraints:false});};return M;}; diff --git a/app/models/catalogue/category.model.js b/app/models/catalogue/category.model.js new file mode 100644 index 0000000..b7d10aa --- /dev/null +++ b/app/models/catalogue/category.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("Category",{id:{type:D.STRING,primaryKey:true},parent_id:D.STRING,code:{type:D.STRING(80),allowNull:false,unique:true},slug:{type:D.STRING(180),allowNull:false,unique:true},status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"INACTIVE"},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0},image_upload_id:D.INTEGER},{tableName:"categories",timestamps:true});M.associate=m=>{M.belongsTo(M,{foreignKey:"parent_id",as:"parent"});M.hasMany(M,{foreignKey:"parent_id",as:"children"});M.hasMany(m.CategoryTranslation,{foreignKey:"category_id",as:"translations"});M.belongsToMany(m.Product,{through:m.ProductCategory,foreignKey:"category_id",otherKey:"product_id",as:"products"});M.belongsTo(m.Upload,{foreignKey:"image_upload_id",as:"image",constraints:false});};return M;}; diff --git a/app/models/catalogue/categoryTranslation.model.js b/app/models/catalogue/categoryTranslation.model.js new file mode 100644 index 0000000..5d934ad --- /dev/null +++ b/app/models/catalogue/categoryTranslation.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("CategoryTranslation",{id:{type:D.STRING,primaryKey:true},category_id:{type:D.STRING,allowNull:false},locale:{type:D.ENUM("en","si","ta"),allowNull:false},name:{type:D.STRING(180),allowNull:false},description:D.TEXT,meta_title:D.STRING(180),meta_description:D.STRING(320)},{tableName:"category_translations",timestamps:true,indexes:[{unique:true,fields:["category_id","locale"]}]});M.associate=m=>M.belongsTo(m.Category,{foreignKey:"category_id",as:"category"});return M;}; diff --git a/app/models/catalogue/collection.model.js b/app/models/catalogue/collection.model.js new file mode 100644 index 0000000..5986549 --- /dev/null +++ b/app/models/catalogue/collection.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("Collection",{id:{type:D.STRING,primaryKey:true},slug:{type:D.STRING(180),allowNull:false,unique:true},type:{type:D.STRING(80),allowNull:false,defaultValue:"EDITORIAL"},status:{type:D.ENUM("DRAFT","ACTIVE","INACTIVE"),allowNull:false,defaultValue:"DRAFT"},starts_at:D.DATE,ends_at:D.DATE,hero_upload_id:D.INTEGER,sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"collections",timestamps:true});M.associate=m=>{M.hasMany(m.CollectionTranslation,{foreignKey:"collection_id",as:"translations"});M.belongsToMany(m.Product,{through:m.CollectionProduct,foreignKey:"collection_id",otherKey:"product_id",as:"products"});M.belongsTo(m.Upload,{foreignKey:"hero_upload_id",as:"hero",constraints:false});};return M;}; diff --git a/app/models/catalogue/collectionProduct.model.js b/app/models/catalogue/collectionProduct.model.js new file mode 100644 index 0000000..97d7a07 --- /dev/null +++ b/app/models/catalogue/collectionProduct.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("CollectionProduct",{id:{type:D.STRING,primaryKey:true},collection_id:{type:D.STRING,allowNull:false},product_id:{type:D.STRING,allowNull:false},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0},featured:{type:D.BOOLEAN,allowNull:false,defaultValue:false}},{tableName:"collection_products",timestamps:true,indexes:[{unique:true,fields:["collection_id","product_id"]}]}); diff --git a/app/models/catalogue/collectionTranslation.model.js b/app/models/catalogue/collectionTranslation.model.js new file mode 100644 index 0000000..4b01b1c --- /dev/null +++ b/app/models/catalogue/collectionTranslation.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("CollectionTranslation",{id:{type:D.STRING,primaryKey:true},collection_id:{type:D.STRING,allowNull:false},locale:{type:D.ENUM("en","si","ta"),allowNull:false},name:{type:D.STRING(180),allowNull:false},description:D.TEXT,headline:D.STRING(250),subheadline:D.STRING(300)},{tableName:"collection_translations",timestamps:true,indexes:[{unique:true,fields:["collection_id","locale"]}]});M.associate=m=>M.belongsTo(m.Collection,{foreignKey:"collection_id",as:"collection"});return M;}; diff --git a/app/models/catalogue/product.model.js b/app/models/catalogue/product.model.js new file mode 100644 index 0000000..16a63d4 --- /dev/null +++ b/app/models/catalogue/product.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("Product",{id:{type:D.STRING,primaryKey:true},brand_id:{type:D.STRING,allowNull:false},default_category_id:D.STRING,slug:{type:D.STRING(200),allowNull:false,unique:true},product_code:{type:D.STRING(100),allowNull:false,unique:true},status:{type:D.ENUM("DRAFT","ACTIVE","INACTIVE","ARCHIVED"),allowNull:false,defaultValue:"DRAFT"},visibility:{type:D.ENUM("PUBLIC","HIDDEN"),allowNull:false,defaultValue:"HIDDEN"},product_type:{type:D.STRING(80),allowNull:false,defaultValue:"STANDARD"},featured:{type:D.BOOLEAN,allowNull:false,defaultValue:false},new_arrival_until:D.DATE,published_at:D.DATE,created_by:{type:D.STRING,allowNull:false},updated_by:D.STRING,size_guide_id:D.STRING},{tableName:"products",timestamps:true});M.associate=m=>{M.belongsTo(m.Brand,{foreignKey:"brand_id",as:"brand"});M.belongsTo(m.Category,{foreignKey:"default_category_id",as:"defaultCategory"});M.hasMany(m.ProductTranslation,{foreignKey:"product_id",as:"translations"});M.hasMany(m.ProductVariant,{foreignKey:"product_id",as:"variants"});M.hasMany(m.ProductOption,{foreignKey:"product_id",as:"options"});M.hasMany(m.ProductAttribute,{foreignKey:"product_id",as:"attributes"});M.hasMany(m.ProductMedia,{foreignKey:"product_id",as:"media"});M.belongsToMany(m.Category,{through:m.ProductCategory,foreignKey:"product_id",otherKey:"category_id",as:"categories"});M.belongsTo(m.SizeGuide,{foreignKey:"size_guide_id",as:"sizeGuide"});M.hasMany(m.ProductReview,{foreignKey:"product_id",as:"reviews"});};return M;}; diff --git a/app/models/catalogue/productAttribute.model.js b/app/models/catalogue/productAttribute.model.js new file mode 100644 index 0000000..0203643 --- /dev/null +++ b/app/models/catalogue/productAttribute.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("ProductAttribute",{id:{type:D.STRING,primaryKey:true},product_id:{type:D.STRING,allowNull:false},name:{type:D.STRING(100),allowNull:false},value:{type:D.STRING(500),allowNull:false},locale:{type:D.ENUM("en","si","ta"),allowNull:false,defaultValue:"en"},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"product_attributes",timestamps:true});M.associate=m=>M.belongsTo(m.Product,{foreignKey:"product_id",as:"product"});return M;}; diff --git a/app/models/catalogue/productCategory.model.js b/app/models/catalogue/productCategory.model.js new file mode 100644 index 0000000..340087c --- /dev/null +++ b/app/models/catalogue/productCategory.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("ProductCategory",{id:{type:D.STRING,primaryKey:true},product_id:{type:D.STRING,allowNull:false},category_id:{type:D.STRING,allowNull:false}},{tableName:"product_categories",timestamps:true,indexes:[{unique:true,fields:["product_id","category_id"]}]});return M;}; diff --git a/app/models/catalogue/productMedia.model.js b/app/models/catalogue/productMedia.model.js new file mode 100644 index 0000000..aa741e8 --- /dev/null +++ b/app/models/catalogue/productMedia.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("ProductMedia",{id:{type:D.STRING,primaryKey:true},product_id:{type:D.STRING,allowNull:false},variant_id:D.STRING,upload_id:{type:D.INTEGER,allowNull:false},type:{type:D.ENUM("IMAGE"),allowNull:false,defaultValue:"IMAGE"},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0},alt_text:D.STRING(250),is_primary:{type:D.BOOLEAN,allowNull:false,defaultValue:false}},{tableName:"product_media",timestamps:true,indexes:[{unique:true,fields:["product_id","upload_id"]}]});M.associate=m=>{M.belongsTo(m.Product,{foreignKey:"product_id",as:"product"});M.belongsTo(m.ProductVariant,{foreignKey:"variant_id",as:"variant"});M.belongsTo(m.Upload,{foreignKey:"upload_id",as:"upload",constraints:false});};return M;}; diff --git a/app/models/catalogue/productOption.model.js b/app/models/catalogue/productOption.model.js new file mode 100644 index 0000000..2122436 --- /dev/null +++ b/app/models/catalogue/productOption.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("ProductOption",{id:{type:D.STRING,primaryKey:true},product_id:{type:D.STRING,allowNull:false},name:{type:D.STRING(100),allowNull:false},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"product_options",timestamps:true,indexes:[{unique:true,fields:["product_id","name"]}]});M.associate=m=>{M.belongsTo(m.Product,{foreignKey:"product_id",as:"product"});M.hasMany(m.ProductOptionValue,{foreignKey:"option_id",as:"values"});};return M;}; diff --git a/app/models/catalogue/productOptionValue.model.js b/app/models/catalogue/productOptionValue.model.js new file mode 100644 index 0000000..0da4de5 --- /dev/null +++ b/app/models/catalogue/productOptionValue.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("ProductOptionValue",{id:{type:D.STRING,primaryKey:true},option_id:{type:D.STRING,allowNull:false},value:{type:D.STRING(100),allowNull:false},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"product_option_values",timestamps:true,indexes:[{unique:true,fields:["option_id","value"]}]});M.associate=m=>{M.belongsTo(m.ProductOption,{foreignKey:"option_id",as:"option"});};return M;}; diff --git a/app/models/catalogue/productRelation.model.js b/app/models/catalogue/productRelation.model.js new file mode 100644 index 0000000..a1c5789 --- /dev/null +++ b/app/models/catalogue/productRelation.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("ProductRelation",{id:{type:D.STRING,primaryKey:true},source_product_id:{type:D.STRING,allowNull:false},target_product_id:{type:D.STRING,allowNull:false},relation_type:{type:D.ENUM("RELATED","SIMILAR","COMPLETE_THE_LOOK"),allowNull:false},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"product_relations",timestamps:true,indexes:[{unique:true,fields:["source_product_id","target_product_id","relation_type"]}]}); diff --git a/app/models/catalogue/productReview.model.js b/app/models/catalogue/productReview.model.js new file mode 100644 index 0000000..f5f0ee4 --- /dev/null +++ b/app/models/catalogue/productReview.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("ProductReview",{id:{type:D.STRING,primaryKey:true},product_id:{type:D.STRING,allowNull:false},user_id:{type:D.STRING,allowNull:false},rating:{type:D.INTEGER,allowNull:false,validate:{min:1,max:5}},title:{type:D.STRING(160),allowNull:false},body:{type:D.TEXT,allowNull:false},status:{type:D.ENUM("PENDING","APPROVED","REJECTED"),allowNull:false,defaultValue:"PENDING"},verified_purchase:{type:D.BOOLEAN,allowNull:false,defaultValue:false},moderated_by:D.STRING,moderated_at:D.DATE},{tableName:"product_reviews",timestamps:true,indexes:[{unique:true,fields:["user_id","product_id"]}]});M.associate=m=>{M.belongsTo(m.Product,{foreignKey:"product_id",as:"product"});M.belongsTo(m.User,{foreignKey:"user_id",as:"author"});};return M;}; diff --git a/app/models/catalogue/productTranslation.model.js b/app/models/catalogue/productTranslation.model.js new file mode 100644 index 0000000..5c4aa6f --- /dev/null +++ b/app/models/catalogue/productTranslation.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("ProductTranslation",{id:{type:D.STRING,primaryKey:true},product_id:{type:D.STRING,allowNull:false},locale:{type:D.ENUM("en","si","ta"),allowNull:false},name:{type:D.STRING(220),allowNull:false},short_description:D.STRING(500),description:D.TEXT,care_instructions:D.TEXT,materials:D.TEXT,origin:D.STRING(120),meta_title:D.STRING(180),meta_description:D.STRING(320)},{tableName:"product_translations",timestamps:true,indexes:[{unique:true,fields:["product_id","locale"]}]});M.associate=m=>M.belongsTo(m.Product,{foreignKey:"product_id",as:"product"});return M;}; diff --git a/app/models/catalogue/productVariant.model.js b/app/models/catalogue/productVariant.model.js new file mode 100644 index 0000000..f75dcee --- /dev/null +++ b/app/models/catalogue/productVariant.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("ProductVariant",{id:{type:D.STRING,primaryKey:true},product_id:{type:D.STRING,allowNull:false},sku:{type:D.STRING(100),allowNull:false,unique:true},barcode:{type:D.STRING(100),unique:true},status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"},base_price:{type:D.DECIMAL(15,2),allowNull:false},compare_at_price:D.DECIMAL(15,2),currency:{type:D.STRING(3),allowNull:false,defaultValue:"LKR"},weight:D.DECIMAL(10,3),sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"product_variants",timestamps:true});M.associate=m=>{M.belongsTo(m.Product,{foreignKey:"product_id",as:"product"});M.belongsToMany(m.ProductOptionValue,{through:m.VariantOptionValue,foreignKey:"variant_id",otherKey:"option_value_id",as:"optionValues"});M.hasMany(m.ProductMedia,{foreignKey:"variant_id",as:"media"});};return M;}; diff --git a/app/models/catalogue/sizeGuide.model.js b/app/models/catalogue/sizeGuide.model.js new file mode 100644 index 0000000..726c09a --- /dev/null +++ b/app/models/catalogue/sizeGuide.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("SizeGuide",{id:{type:D.STRING,primaryKey:true},code:{type:D.STRING(80),allowNull:false,unique:true},name:{type:D.STRING(160),allowNull:false},locale:{type:D.ENUM("en","si","ta"),allowNull:false,defaultValue:"en"},data:{type:D.JSON,allowNull:false},category_id:D.STRING,status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"}},{tableName:"size_guides",timestamps:true}); diff --git a/app/models/catalogue/variantOptionValue.model.js b/app/models/catalogue/variantOptionValue.model.js new file mode 100644 index 0000000..1176005 --- /dev/null +++ b/app/models/catalogue/variantOptionValue.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("VariantOptionValue",{id:{type:D.STRING,primaryKey:true},variant_id:{type:D.STRING,allowNull:false},option_value_id:{type:D.STRING,allowNull:false}},{tableName:"variant_option_values",timestamps:true,indexes:[{unique:true,fields:["variant_id","option_value_id"]}]}); diff --git a/app/models/index.js b/app/models/index.js index 12dcf61..780c9d3 100644 --- a/app/models/index.js +++ b/app/models/index.js @@ -71,6 +71,29 @@ db.notification = require("./notification/notification.model")(sequelize, DataTy db.userNotification = require("./notification/userNotification.model")(sequelize, DataTypes); db.NotificationDelivery = require("./notification/notificationDelivery.model")(sequelize, DataTypes); +// Catalogue and localized content +db.Brand = require("./catalogue/brand.model")(sequelize, DataTypes); +db.Category = require("./catalogue/category.model")(sequelize, DataTypes); +db.CategoryTranslation = require("./catalogue/categoryTranslation.model")(sequelize, DataTypes); +db.Product = require("./catalogue/product.model")(sequelize, DataTypes); +db.ProductTranslation = require("./catalogue/productTranslation.model")(sequelize, DataTypes); +db.ProductCategory = require("./catalogue/productCategory.model")(sequelize, DataTypes); +db.ProductVariant = require("./catalogue/productVariant.model")(sequelize, DataTypes); +db.ProductOption = require("./catalogue/productOption.model")(sequelize, DataTypes); +db.ProductOptionValue = require("./catalogue/productOptionValue.model")(sequelize, DataTypes); +db.VariantOptionValue = require("./catalogue/variantOptionValue.model")(sequelize, DataTypes); +db.ProductAttribute = require("./catalogue/productAttribute.model")(sequelize, DataTypes); +db.ProductMedia = require("./catalogue/productMedia.model")(sequelize, DataTypes); +db.Collection = require("./catalogue/collection.model")(sequelize, DataTypes); +db.CollectionTranslation = require("./catalogue/collectionTranslation.model")(sequelize, DataTypes); +db.CollectionProduct = require("./catalogue/collectionProduct.model")(sequelize, DataTypes); +db.SizeGuide = require("./catalogue/sizeGuide.model")(sequelize, DataTypes); +db.ProductRelation = require("./catalogue/productRelation.model")(sequelize, DataTypes); +db.ProductReview = require("./catalogue/productReview.model")(sequelize, DataTypes); +db.Warehouse=require("./inventory/warehouse.model")(sequelize,DataTypes);db.InventoryBalance=require("./inventory/inventoryBalance.model")(sequelize,DataTypes);db.InventoryTransaction=require("./inventory/inventoryTransaction.model")(sequelize,DataTypes);db.InventoryReservation=require("./inventory/inventoryReservation.model")(sequelize,DataTypes);db.InventoryTransfer=require("./inventory/inventoryTransfer.model")(sequelize,DataTypes); +db.BusinessTier=require("./pricing/businessTier.model")(sequelize,DataTypes);db.VariantBusinessPrice=require("./pricing/variantBusinessPrice.model")(sequelize,DataTypes);db.BusinessPriceTier=require("./pricing/businessPriceTier.model")(sequelize,DataTypes); +db.Promotion=require("./merchandising/promotion.model")(sequelize,DataTypes);db.PromotionTarget=require("./merchandising/promotionTarget.model")(sequelize,DataTypes);db.Coupon=require("./merchandising/coupon.model")(sequelize,DataTypes);db.Banner=require("./merchandising/banner.model")(sequelize,DataTypes);db.BannerTranslation=require("./merchandising/bannerTranslation.model")(sequelize,DataTypes); + /* Associations */ Object.keys(db).forEach(model => { if (db[model].associate) { diff --git a/app/models/inventory/inventoryBalance.model.js b/app/models/inventory/inventoryBalance.model.js new file mode 100644 index 0000000..3db6803 --- /dev/null +++ b/app/models/inventory/inventoryBalance.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("InventoryBalance",{id:{type:D.STRING,primaryKey:true},warehouse_id:{type:D.STRING,allowNull:false},variant_id:{type:D.STRING,allowNull:false},on_hand:{type:D.INTEGER,allowNull:false,defaultValue:0},reserved:{type:D.INTEGER,allowNull:false,defaultValue:0},low_stock_threshold:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"inventory_balances",timestamps:true,indexes:[{unique:true,fields:["warehouse_id","variant_id"]}]});M.associate=db=>{M.belongsTo(db.Warehouse,{foreignKey:"warehouse_id",as:"warehouse"});M.belongsTo(db.ProductVariant,{foreignKey:"variant_id",as:"variant"});};return M;}; diff --git a/app/models/inventory/inventoryReservation.model.js b/app/models/inventory/inventoryReservation.model.js new file mode 100644 index 0000000..7ac39b3 --- /dev/null +++ b/app/models/inventory/inventoryReservation.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("InventoryReservation",{id:{type:D.STRING,primaryKey:true},reservation_key:{type:D.STRING(160),allowNull:false,unique:true},warehouse_id:{type:D.STRING,allowNull:false},variant_id:{type:D.STRING,allowNull:false},quantity:{type:D.INTEGER,allowNull:false},status:{type:D.ENUM("ACTIVE","RELEASED","EXPIRED","CONSUMED"),allowNull:false,defaultValue:"ACTIVE"},reference_type:D.STRING(80),reference_id:D.STRING,user_id:D.STRING,expires_at:{type:D.DATE,allowNull:false},released_at:D.DATE,consumed_at:D.DATE},{tableName:"inventory_reservations",timestamps:true}); diff --git a/app/models/inventory/inventoryTransaction.model.js b/app/models/inventory/inventoryTransaction.model.js new file mode 100644 index 0000000..3db9477 --- /dev/null +++ b/app/models/inventory/inventoryTransaction.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("InventoryTransaction",{id:{type:D.STRING,primaryKey:true},event_id:{type:D.STRING,allowNull:false,unique:true},warehouse_id:{type:D.STRING,allowNull:false},variant_id:{type:D.STRING,allowNull:false},type:{type:D.ENUM("INITIAL","RECEIPT","ADJUSTMENT","RESERVATION","RESERVATION_RELEASE","RESERVATION_CONSUME","TRANSFER_OUT","TRANSFER_IN","CORRECTION"),allowNull:false},quantity_delta:{type:D.INTEGER,allowNull:false,defaultValue:0},reserved_delta:{type:D.INTEGER,allowNull:false,defaultValue:0},reference_type:D.STRING(80),reference_id:D.STRING,reason:D.STRING(500),actor_user_id:D.STRING,request_id:D.STRING,metadata:D.JSON,occurred_at:{type:D.DATE,allowNull:false,defaultValue:D.NOW}},{tableName:"inventory_transactions",timestamps:true,updatedAt:false}); diff --git a/app/models/inventory/inventoryTransfer.model.js b/app/models/inventory/inventoryTransfer.model.js new file mode 100644 index 0000000..3b123ae --- /dev/null +++ b/app/models/inventory/inventoryTransfer.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("InventoryTransfer",{id:{type:D.STRING,primaryKey:true},event_id:{type:D.STRING,allowNull:false,unique:true},transfer_number:{type:D.STRING(80),allowNull:false,unique:true},source_warehouse_id:{type:D.STRING,allowNull:false},destination_warehouse_id:{type:D.STRING,allowNull:false},variant_id:{type:D.STRING,allowNull:false},quantity:{type:D.INTEGER,allowNull:false},status:{type:D.ENUM("COMPLETED"),allowNull:false,defaultValue:"COMPLETED"},created_by:{type:D.STRING,allowNull:false},completed_by:{type:D.STRING,allowNull:false},completed_at:{type:D.DATE,allowNull:false}},{tableName:"inventory_transfers",timestamps:true}); diff --git a/app/models/inventory/warehouse.model.js b/app/models/inventory/warehouse.model.js new file mode 100644 index 0000000..ddaf641 --- /dev/null +++ b/app/models/inventory/warehouse.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("Warehouse",{id:{type:D.STRING,primaryKey:true},code:{type:D.STRING(50),allowNull:false,unique:true},name:{type:D.STRING(150),allowNull:false},status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"},is_default:{type:D.BOOLEAN,allowNull:false,defaultValue:false},timezone:{type:D.STRING(80),allowNull:false,defaultValue:"Asia/Colombo"},address_line_1:D.STRING,address_line_2:D.STRING,city:D.STRING,province:D.STRING,postal_code:D.STRING(20),country_code:{type:D.STRING(2),allowNull:false,defaultValue:"LK"}},{tableName:"warehouses",timestamps:true});M.associate=db=>M.hasMany(db.InventoryBalance,{foreignKey:"warehouse_id",as:"balances"});return M;}; diff --git a/app/models/merchandising/banner.model.js b/app/models/merchandising/banner.model.js new file mode 100644 index 0000000..d22e1b3 --- /dev/null +++ b/app/models/merchandising/banner.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("Banner",{id:{type:D.STRING,primaryKey:true},placement:{type:D.STRING(80),allowNull:false},status:{type:D.ENUM("DRAFT","ACTIVE","INACTIVE","ARCHIVED"),allowNull:false,defaultValue:"DRAFT"},starts_at:D.DATE,ends_at:D.DATE,image_upload_id:{type:D.INTEGER,allowNull:false},mobile_image_upload_id:D.INTEGER,target_url:D.STRING,audience_type:{type:D.ENUM("ALL","CUSTOMER","BUSINESS_CUSTOMER"),allowNull:false,defaultValue:"ALL"},business_tier_id:D.STRING,sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0},created_by:{type:D.STRING,allowNull:false},updated_by:D.STRING},{tableName:"banners",timestamps:true});M.associate=db=>M.hasMany(db.BannerTranslation,{foreignKey:"banner_id",as:"translations"});return M;}; diff --git a/app/models/merchandising/bannerTranslation.model.js b/app/models/merchandising/bannerTranslation.model.js new file mode 100644 index 0000000..6ee699a --- /dev/null +++ b/app/models/merchandising/bannerTranslation.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("BannerTranslation",{id:{type:D.STRING,primaryKey:true},banner_id:{type:D.STRING,allowNull:false},locale:{type:D.ENUM("en","si","ta"),allowNull:false},headline:D.STRING(250),subheadline:D.STRING(400),cta_text:D.STRING(100),alt_text:D.STRING(250)},{tableName:"banner_translations",timestamps:true,indexes:[{unique:true,fields:["banner_id","locale"]}]}); diff --git a/app/models/merchandising/coupon.model.js b/app/models/merchandising/coupon.model.js new file mode 100644 index 0000000..de0d79c --- /dev/null +++ b/app/models/merchandising/coupon.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("Coupon",{id:{type:D.STRING,primaryKey:true},code:{type:D.STRING(50),allowNull:false,unique:true},promotion_id:{type:D.STRING,allowNull:false},status:{type:D.ENUM("ACTIVE","INACTIVE","ARCHIVED"),allowNull:false,defaultValue:"ACTIVE"},starts_at:D.DATE,expires_at:D.DATE,max_uses:D.INTEGER,max_uses_per_user:D.INTEGER},{tableName:"coupons",timestamps:true,hooks:{beforeValidate:x=>{if(x.code)x.code=x.code.trim().toUpperCase();}}});M.associate=db=>M.belongsTo(db.Promotion,{foreignKey:"promotion_id",as:"promotion"});return M;}; diff --git a/app/models/merchandising/promotion.model.js b/app/models/merchandising/promotion.model.js new file mode 100644 index 0000000..d63299c --- /dev/null +++ b/app/models/merchandising/promotion.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("Promotion",{id:{type:D.STRING,primaryKey:true},name:{type:D.STRING(180),allowNull:false},type:{type:D.ENUM("PERCENTAGE","FIXED_AMOUNT","FIXED_PRICE"),allowNull:false},status:{type:D.ENUM("DRAFT","ACTIVE","INACTIVE","ARCHIVED"),allowNull:false,defaultValue:"DRAFT"},starts_at:D.DATE,ends_at:D.DATE,priority:{type:D.INTEGER,allowNull:false,defaultValue:0},stackable:{type:D.BOOLEAN,allowNull:false,defaultValue:false},discount_percent:D.DECIMAL(5,2),discount_amount:D.DECIMAL(15,2),fixed_price:D.DECIMAL(15,2),minimum_subtotal:D.DECIMAL(15,2),minimum_quantity:D.INTEGER,customer_type:D.STRING(40),created_by:{type:D.STRING,allowNull:false},updated_by:D.STRING},{tableName:"promotions",timestamps:true}); diff --git a/app/models/merchandising/promotionTarget.model.js b/app/models/merchandising/promotionTarget.model.js new file mode 100644 index 0000000..0cc8a7d --- /dev/null +++ b/app/models/merchandising/promotionTarget.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("PromotionTarget",{id:{type:D.STRING,primaryKey:true},promotion_id:{type:D.STRING,allowNull:false},target_type:{type:D.ENUM("ALL","PRODUCT","VARIANT","CATEGORY","BRAND","COLLECTION"),allowNull:false},target_id:D.STRING},{tableName:"promotion_targets",timestamps:true,indexes:[{unique:true,fields:["promotion_id","target_type","target_id"]}]}); diff --git a/app/models/pricing/businessPriceTier.model.js b/app/models/pricing/businessPriceTier.model.js new file mode 100644 index 0000000..b50526d --- /dev/null +++ b/app/models/pricing/businessPriceTier.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("BusinessPriceTier",{id:{type:D.STRING,primaryKey:true},business_price_id:{type:D.STRING,allowNull:false},min_quantity:{type:D.INTEGER,allowNull:false},max_quantity:D.INTEGER,unit_price:{type:D.DECIMAL(15,2),allowNull:false}},{tableName:"business_price_tiers",timestamps:true}); diff --git a/app/models/pricing/businessTier.model.js b/app/models/pricing/businessTier.model.js new file mode 100644 index 0000000..27eccbc --- /dev/null +++ b/app/models/pricing/businessTier.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("BusinessTier",{id:{type:D.STRING,primaryKey:true},code:{type:D.STRING(50),allowNull:false,unique:true},name:{type:D.STRING(120),allowNull:false},description:D.STRING(500),status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"business_tiers",timestamps:true}); diff --git a/app/models/pricing/variantBusinessPrice.model.js b/app/models/pricing/variantBusinessPrice.model.js new file mode 100644 index 0000000..688e721 --- /dev/null +++ b/app/models/pricing/variantBusinessPrice.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("VariantBusinessPrice",{id:{type:D.STRING,primaryKey:true},variant_id:{type:D.STRING,allowNull:false},business_tier_id:D.STRING,business_customer_id:D.STRING,currency:{type:D.STRING(3),allowNull:false},price:{type:D.DECIMAL(15,2),allowNull:false},minimum_quantity:{type:D.INTEGER,allowNull:false,defaultValue:1},status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"},starts_at:D.DATE,ends_at:D.DATE},{tableName:"variant_business_prices",timestamps:true});M.associate=db=>M.hasMany(db.BusinessPriceTier,{foreignKey:"business_price_id",as:"tiers"});return M;}; diff --git a/app/models/user/businessCustomer.model.js b/app/models/user/businessCustomer.model.js index fcae0e9..9c1243d 100644 --- a/app/models/user/businessCustomer.model.js +++ b/app/models/user/businessCustomer.model.js @@ -55,6 +55,7 @@ module.exports = (sequelize, DataTypes) => { approvedAt: { type: DataTypes.DATE, allowNull: false }, approvedBy: { type: DataTypes.STRING, allowNull: false }, settlement_term_id: { type: DataTypes.STRING, allowNull: true }, + business_tier_id: { type: DataTypes.STRING, allowNull: true }, }, { tableName: "business_customers", @@ -71,6 +72,7 @@ module.exports = (sequelize, DataTypes) => { BusinessCustomer.hasMany(db.Address, { foreignKey: "business_profile_id", as: "addresses" }); BusinessCustomer.hasOne(db.BusinessCreditAccount, { foreignKey: "business_profile_id", as: "creditAccount" }); BusinessCustomer.belongsTo(db.SettlementTerm, { foreignKey: "settlement_term_id", as: "settlementTerm" }); + BusinessCustomer.belongsTo(db.BusinessTier, { foreignKey: "business_tier_id", as: "businessTier" }); }; return BusinessCustomer; diff --git a/app/routes/catalogue/admin.routes.js b/app/routes/catalogue/admin.routes.js new file mode 100644 index 0000000..39e2552 --- /dev/null +++ b/app/routes/catalogue/admin.routes.js @@ -0,0 +1 @@ +const r=require("express").Router();const c=require("../../controllers/catalogue/admin.controller");const reviews=require("../../controllers/catalogue/review.controller");const {authenticate}=require("../../middleware/auth.middleware");const {checkPermission}=require("../../middleware/permission.middleware");const validate=require("../../middleware/validate.middleware");const s=require("../../validation/catalogue.schemas");r.use(authenticate);r.get("/products",checkPermission("catalogue.products.read",{custom:true}),c.listProducts);r.post("/products",checkPermission("catalogue.products.create",{custom:true}),validate(s.productCreate),c.createProduct);r.get("/products/:id",checkPermission("catalogue.products.read",{custom:true}),c.getProduct);r.patch("/products/:id",checkPermission("catalogue.products.update",{custom:true}),validate(s.productUpdate),c.updateProduct);r.delete("/products/:id",checkPermission("catalogue.products.delete",{custom:true}),c.archiveProduct);r.post("/products/:productId/variants",checkPermission("catalogue.products.update",{custom:true}),validate(s.variantCreate),c.createVariant);r.patch("/products/:productId/variants/:variantId",checkPermission("catalogue.products.update",{custom:true}),validate(s.variantUpdate),c.updateVariant);r.post("/products/:productId/options",checkPermission("catalogue.products.update",{custom:true}),validate(s.option),c.createOption);r.post("/products/:productId/media",checkPermission("catalogue.products.update",{custom:true}),validate(s.media),c.attachMedia);r.post("/products/:productId/relations",checkPermission("catalogue.products.update",{custom:true}),validate(s.relation),c.createRelation);r.post("/brands",checkPermission("catalogue.brands.manage",{custom:true}),validate(s.brand),c.createBrand);r.patch("/brands/:id",checkPermission("catalogue.brands.manage",{custom:true}),validate(s.brand),c.updateBrand);r.post("/categories",checkPermission("catalogue.categories.manage",{custom:true}),validate(s.category),c.createCategory);r.patch("/categories/:id",checkPermission("catalogue.categories.manage",{custom:true}),validate(s.category),c.updateCategory);r.post("/collections",checkPermission("catalogue.collections.manage",{custom:true}),validate(s.collection),c.createCollection);r.post("/size-guides",checkPermission("catalogue.size-guides.manage",{custom:true}),validate(s.sizeGuide),c.createSizeGuide);r.get("/reviews",checkPermission("catalogue.reviews.read",{custom:true}),reviews.listAdmin);r.patch("/reviews/:id/status",checkPermission("catalogue.reviews.moderate",{custom:true}),validate(s.reviewStatus),reviews.moderate);module.exports=r; diff --git a/app/routes/catalogue/public.routes.js b/app/routes/catalogue/public.routes.js new file mode 100644 index 0000000..0da831d --- /dev/null +++ b/app/routes/catalogue/public.routes.js @@ -0,0 +1 @@ +const r=require("express").Router();const c=require("../../controllers/catalogue/public.controller");const reviews=require("../../controllers/catalogue/review.controller");const {authenticate}=require("../../middleware/auth.middleware");const validate=require("../../middleware/validate.middleware");const s=require("../../validation/catalogue.schemas");r.get("/products",c.products);r.get("/products/:slug",c.product);r.post("/products/:productId/reviews",authenticate,validate(s.review),reviews.create);r.get("/categories",c.categories);r.get("/categories/:slug",c.category);r.get("/brands",c.brands);r.get("/brands/:slug",c.brand);r.get("/collections",c.collections);r.get("/collections/:slug",c.collection);module.exports=r; diff --git a/app/routes/index.js b/app/routes/index.js index 677a38d..d84cc7f 100644 --- a/app/routes/index.js +++ b/app/routes/index.js @@ -26,6 +26,12 @@ const adminUserRoutes = require("./adminUser.routes"); const addressRoutes = require("./address.routes"); const businessRoutes = require("./business.routes"); const adminBusinessRoutes = require("./adminBusiness.routes"); +const cataloguePublicRoutes = require("./catalogue/public.routes"); +const catalogueAdminRoutes = require("./catalogue/admin.routes"); +const inventoryAdminRoutes = require("./inventory/admin.routes"); +const merchandisingAdminRoutes = require("./merchandising/admin.routes"); +const pricingAdminRoutes = require("./pricing/admin.routes"); +const merchandisingPublicRoutes = require("./merchandising/public.routes"); const router = express.Router(); @@ -44,5 +50,11 @@ router.use("/admin/users", adminUserRoutes); router.use("/addresses", addressRoutes); router.use("/business", businessRoutes); router.use("/admin/business", adminBusinessRoutes); +router.use("/", cataloguePublicRoutes); +router.use("/admin", catalogueAdminRoutes); +router.use("/admin", inventoryAdminRoutes); +router.use("/admin", merchandisingAdminRoutes); +router.use("/admin", pricingAdminRoutes); +router.use("/", merchandisingPublicRoutes); module.exports = router; diff --git a/app/routes/inventory/admin.routes.js b/app/routes/inventory/admin.routes.js new file mode 100644 index 0000000..e2436f4 --- /dev/null +++ b/app/routes/inventory/admin.routes.js @@ -0,0 +1 @@ +const r=require("express").Router(),c=require("../../controllers/inventory/admin.controller"),{authenticate}=require("../../middleware/auth.middleware"),{checkPermission}=require("../../middleware/permission.middleware"),validate=require("../../middleware/validate.middleware"),s=require("../../validation/inventoryMerchandising.schemas");r.use(authenticate);r.get("/inventory",checkPermission("inventory.read",{custom:true}),c.list);r.get("/inventory/ledger",checkPermission("inventory.read",{custom:true}),c.ledger);r.get("/inventory/low-stock",checkPermission("inventory.read",{custom:true}),c.lowStock);r.get("/inventory/:variantId",checkPermission("inventory.read",{custom:true}),c.detail);r.post("/inventory/adjustments",checkPermission("inventory.adjust",{custom:true}),validate(s.adjustment),c.adjust);r.post("/inventory/transfers",checkPermission("inventory.transfer",{custom:true}),validate(s.transfer),c.transfer);r.get("/warehouses",checkPermission("inventory.read",{custom:true}),c.warehouses);r.post("/warehouses",checkPermission("inventory.warehouses.manage",{custom:true}),validate(s.warehouse),c.createWarehouse);r.patch("/warehouses/:id",checkPermission("inventory.warehouses.manage",{custom:true}),validate(s.warehouse),c.updateWarehouse);module.exports=r; diff --git a/app/routes/merchandising/admin.routes.js b/app/routes/merchandising/admin.routes.js new file mode 100644 index 0000000..3d9f5fa --- /dev/null +++ b/app/routes/merchandising/admin.routes.js @@ -0,0 +1 @@ +const r=require("express").Router(),c=require("../../controllers/merchandising/admin.controller"),{authenticate}=require("../../middleware/auth.middleware"),{checkPermission}=require("../../middleware/permission.middleware"),validate=require("../../middleware/validate.middleware"),s=require("../../validation/inventoryMerchandising.schemas");r.use(authenticate);r.get("/promotions",checkPermission("promotions.read",{custom:true}),c.promotions);r.post("/promotions",checkPermission("promotions.manage",{custom:true}),validate(s.promotion),c.createPromotion);r.get("/coupons",checkPermission("promotions.read",{custom:true}),c.coupons);r.post("/coupons",checkPermission("promotions.manage",{custom:true}),validate(s.coupon),c.createCoupon);r.get("/banners",checkPermission("merchandising.banners.manage",{custom:true}),c.banners);r.post("/banners",checkPermission("merchandising.banners.manage",{custom:true}),validate(s.banner),c.createBanner);module.exports=r; diff --git a/app/routes/merchandising/public.routes.js b/app/routes/merchandising/public.routes.js new file mode 100644 index 0000000..4d8c70d --- /dev/null +++ b/app/routes/merchandising/public.routes.js @@ -0,0 +1 @@ +const r=require("express").Router(),c=require("../../controllers/merchandising/public.controller");r.get("/banners",c.banners);r.get("/availability/:variantId",c.availability);module.exports=r; diff --git a/app/routes/pricing/admin.routes.js b/app/routes/pricing/admin.routes.js new file mode 100644 index 0000000..e9e7660 --- /dev/null +++ b/app/routes/pricing/admin.routes.js @@ -0,0 +1 @@ +const r=require("express").Router(),c=require("../../controllers/pricing/admin.controller"),{authenticate}=require("../../middleware/auth.middleware"),{checkPermission}=require("../../middleware/permission.middleware"),validate=require("../../middleware/validate.middleware"),s=require("../../validation/inventoryMerchandising.schemas");r.use(authenticate);r.get("/business-pricing",checkPermission("pricing.business.read",{custom:true}),c.list);r.post("/business-pricing",checkPermission("pricing.business.manage",{custom:true}),validate(s.businessPrice),c.create);module.exports=r; diff --git a/app/seeders/catalogueExample.seeder.js b/app/seeders/catalogueExample.seeder.js new file mode 100644 index 0000000..fc026e5 --- /dev/null +++ b/app/seeders/catalogueExample.seeder.js @@ -0,0 +1,3 @@ +const crypto=require("crypto");const db=require("../models"); +const examples=["Women","Men","Luxury","Beauty","Sports","Kids","Sale","Travel"]; +module.exports=async()=>db.sequelize.transaction(async transaction=>{for(const name of examples){const slug=name.toLowerCase();const [category]=await db.Category.findOrCreate({where:{slug},defaults:{id:crypto.randomUUID(),code:`EXAMPLE_${name.toUpperCase()}`,slug,status:"INACTIVE",sort_order:examples.indexOf(name)},transaction});await db.CategoryTranslation.findOrCreate({where:{category_id:category.id,locale:"en"},defaults:{id:crypto.randomUUID(),category_id:category.id,locale:"en",name},transaction});}}); diff --git a/app/services/catalogue/catalogue.service.js b/app/services/catalogue/catalogue.service.js new file mode 100644 index 0000000..1adc26b --- /dev/null +++ b/app/services/catalogue/catalogue.service.js @@ -0,0 +1,7 @@ +const crypto=require("crypto");const db=require("../../models"); +const validateMedia=async(ids,userId,transaction)=>{if(!ids.length)return[];const rows=await db.Upload.findAll({where:{id:ids,status:"AVAILABLE"},transaction,lock:transaction?.LOCK?.UPDATE});const imageTypes=new Set(["image/jpeg","image/png","image/webp"]);if(rows.length!==new Set(ids).size||rows.some(x=>!imageTypes.has(x.file_type)||(x.owner_id!==userId&&x.owner_type!=="CATALOGUE")))throw Object.assign(new Error("Catalogue media must be a supported available image owned by the actor"),{status:403,code:"INVALID_MEDIA_OWNERSHIP"});return rows;}; +const validatePublish=async(product,transaction)=>{const translation=await db.ProductTranslation.findOne({where:{product_id:product.id,locale:"en"},transaction});const variant=await db.ProductVariant.findOne({where:{product_id:product.id,status:"ACTIVE"},transaction});const media=await db.ProductMedia.findOne({where:{product_id:product.id,is_primary:true},transaction});if(!translation||!product.slug||!product.brand_id||!variant||!media)throw Object.assign(new Error("Publishing requires English content, brand, active variant, and primary media"),{status:409,code:"PUBLISH_REQUIREMENTS_NOT_MET"});}; +const createProduct=(actor,body)=>db.sequelize.transaction(async t=>{await validateMedia(body.mediaUploadIds,actor.id,t);const product=await db.Product.create({id:crypto.randomUUID(),brand_id:body.brandId,default_category_id:body.defaultCategoryId||body.categoryIds[0],slug:body.slug,product_code:body.productCode,status:"DRAFT",visibility:"HIDDEN",product_type:body.productType,featured:body.featured,new_arrival_until:body.newArrivalUntil,created_by:actor.id,updated_by:actor.id},{transaction:t});await db.ProductTranslation.bulkCreate(body.translations.map(x=>({id:crypto.randomUUID(),product_id:product.id,locale:x.locale,name:x.name,short_description:x.shortDescription,description:x.description,care_instructions:x.careInstructions,materials:x.materials,origin:x.origin,meta_title:x.metaTitle,meta_description:x.metaDescription})),{transaction:t});await db.ProductCategory.bulkCreate([...new Set(body.categoryIds)].map(category_id=>({id:crypto.randomUUID(),product_id:product.id,category_id})),{transaction:t});await db.ProductVariant.bulkCreate(body.variants.map(x=>({id:crypto.randomUUID(),product_id:product.id,sku:x.sku,barcode:x.barcode,base_price:x.basePrice,compare_at_price:x.compareAtPrice,currency:x.currency,weight:x.weight,sort_order:x.sortOrder})),{transaction:t});if(body.mediaUploadIds.length){await db.ProductMedia.bulkCreate(body.mediaUploadIds.map((upload_id,i)=>({id:crypto.randomUUID(),product_id:product.id,upload_id,type:"IMAGE",sort_order:i,is_primary:i===0})),{transaction:t});await db.Upload.update({owner_type:"CATALOGUE",owner_id:product.id,use_for:"PRODUCT_MEDIA"},{where:{id:body.mediaUploadIds},transaction:t});}return product;}); +const setProductState=(id,actor,updates)=>db.sequelize.transaction(async t=>{const product=await db.Product.findByPk(id,{transaction:t,lock:t.LOCK.UPDATE});if(!product)throw Object.assign(new Error("Product not found"),{status:404,code:"NOT_FOUND"});if(product.published_at&&updates.slug&&updates.slug!==product.slug)throw Object.assign(new Error("Published product slugs are immutable"),{status:409,code:"PUBLISHED_SLUG_IMMUTABLE"});if(updates.status==="ACTIVE"||updates.visibility==="PUBLIC")await validatePublish(product,t);const mapped={...(updates.brandId!==undefined&&{brand_id:updates.brandId}),...(updates.defaultCategoryId!==undefined&&{default_category_id:updates.defaultCategoryId}),...(updates.productType!==undefined&&{product_type:updates.productType}),...(updates.newArrivalUntil!==undefined&&{new_arrival_until:updates.newArrivalUntil}),...(updates.slug!==undefined&&{slug:updates.slug}),...(updates.featured!==undefined&&{featured:updates.featured}),...(updates.status!==undefined&&{status:updates.status}),...(updates.visibility!==undefined&&{visibility:updates.visibility})};await product.update({...mapped,updated_by:actor.id,...((updates.status==="ACTIVE"||updates.visibility==="PUBLIC")&&!product.published_at&&{published_at:new Date()})},{transaction:t});return product;}); +const assertCategoryParent=async(id,parentId,transaction)=>{if(!parentId)return;if(id===parentId)throw Object.assign(new Error("Category cannot parent itself"),{status:400,code:"CATEGORY_SELF_PARENT"});let cursor=parentId;const seen=new Set([id]);for(let depth=0;cursor&&depth<100;depth++){if(seen.has(cursor))throw Object.assign(new Error("Category hierarchy cycle detected"),{status:409,code:"CATEGORY_CYCLE"});seen.add(cursor);const node=await db.Category.findByPk(cursor,{transaction,attributes:["id","parent_id"]});if(!node)throw Object.assign(new Error("Parent category not found"),{status:400,code:"INVALID_PARENT"});cursor=node.parent_id;}if(cursor)throw Object.assign(new Error("Category hierarchy exceeds safe depth"),{status:400,code:"CATEGORY_DEPTH"});}; +module.exports={createProduct,setProductState,validatePublish,validateMedia,assertCategoryParent}; diff --git a/app/services/catalogue/locale.service.js b/app/services/catalogue/locale.service.js new file mode 100644 index 0000000..b7071ea --- /dev/null +++ b/app/services/catalogue/locale.service.js @@ -0,0 +1 @@ +const {SUPPORTED_LOCALES,DEFAULT_LOCALE}=require("../../constants/locales");const normalize=v=>SUPPORTED_LOCALES.includes(String(v||"").slice(0,2).toLowerCase())?String(v).slice(0,2).toLowerCase():null;const resolveLocale=req=>normalize(req.query.locale)||normalize(req.get?.("x-locale"))||normalize(req.user?.profile?.preferred_language)||normalize(req.get?.("accept-language"))||DEFAULT_LOCALE;const selectTranslation=(rows=[],locale=DEFAULT_LOCALE)=>rows.find(x=>x.locale===locale)||rows.find(x=>x.locale===DEFAULT_LOCALE)||rows[0]||null;module.exports={resolveLocale,selectTranslation}; diff --git a/app/services/catalogue/serializer.service.js b/app/services/catalogue/serializer.service.js new file mode 100644 index 0000000..2712c70 --- /dev/null +++ b/app/services/catalogue/serializer.service.js @@ -0,0 +1,3 @@ +const storage=require("../storage/storage.service");const {selectTranslation}=require("./locale.service");const cents=v=>{const [a,b=""]=String(v).split(".");return BigInt(a)*100n+BigInt((b+"00").slice(0,2));}; +const mediaDto=async rows=>Promise.all((rows||[]).sort((a,b)=>a.sort_order-b.sort_order).map(async m=>({id:m.id,type:m.type,altText:m.alt_text,isPrimary:m.is_primary,variantId:m.variant_id,url:await storage.createSignedDownloadUrl(m.upload.file_path)}))); +const summary=async(product,locale)=>{const tr=selectTranslation(product.translations,locale);const prices=(product.variants||[]).filter(v=>v.status==="ACTIVE").map(v=>String(v.base_price));prices.sort((a,b)=>cents(a)<cents(b)?-1:cents(a)>cents(b)?1:0);const primary=(product.media||[]).filter(m=>m.is_primary);return{id:product.id,slug:product.slug,name:tr?.name||null,brand:product.brand?{id:product.brand.id,name:product.brand.name,slug:product.brand.slug}:null,primaryImage:(await mediaDto(primary))[0]||null,minPrice:prices[0]||null,maxPrice:prices.at(-1)||null,currency:product.variants?.[0]?.currency||null,featured:product.featured,newArrival:Boolean(product.new_arrival_until&&new Date(product.new_arrival_until)>new Date()),compareAt:product.variants?.find(v=>v.compare_at_price)?.compare_at_price||null};};module.exports={summary,mediaDto,cents}; diff --git a/app/services/inventory/inventory.service.js b/app/services/inventory/inventory.service.js new file mode 100644 index 0000000..614b82c --- /dev/null +++ b/app/services/inventory/inventory.service.js @@ -0,0 +1,103 @@ +const crypto = require("crypto"); +const { Op } = require("sequelize"); +const db = require("../../models"); + +const fault = (message, code, status = 400) => Object.assign(new Error(message), { code, status }); +const ttlMinutes = () => Number(process.env.INVENTORY_RESERVATION_TTL_MINUTES || 15); +const assertQuantity = (value) => { if (!Number.isInteger(value) || value <= 0) throw fault("Quantity must be a positive integer", "INVALID_QUANTITY"); }; +const lockBalance = (warehouseId, variantId, transaction) => db.InventoryBalance.findOne({ where: { warehouse_id: warehouseId, variant_id: variantId }, transaction, lock: transaction.LOCK.UPDATE }); +const ledger = (data, transaction) => db.InventoryTransaction.create({ id: crypto.randomUUID(), occurred_at: new Date(), ...data }, { transaction }); +const availabilityStatus = ({ on_hand, reserved, low_stock_threshold }) => { + const available = Number(on_hand) - Number(reserved); + return available <= 0 ? "OUT_OF_STOCK" : available <= Number(low_stock_threshold) ? "LOW_STOCK" : "IN_STOCK"; +}; + +async function adjustStock({ eventId, warehouseId, variantId, quantityDelta, reason, actorUserId, requestId }) { + if (!eventId || !Number.isInteger(quantityDelta) || quantityDelta === 0) throw fault("A non-zero integer adjustment and eventId are required", "INVALID_ADJUSTMENT"); + return db.sequelize.transaction(async transaction => { + const prior = await db.InventoryTransaction.findOne({ where: { event_id: eventId }, transaction, lock: transaction.LOCK.UPDATE }); + if (prior) return { idempotent: true, transaction: prior }; + let balance = await lockBalance(warehouseId, variantId, transaction); + if (!balance) balance = await db.InventoryBalance.create({ id: crypto.randomUUID(), warehouse_id: warehouseId, variant_id: variantId, on_hand: 0, reserved: 0 }, { transaction }); + const next = Number(balance.on_hand) + quantityDelta; + if (next < 0 || next < Number(balance.reserved)) throw fault("Adjustment would violate available stock", "INSUFFICIENT_STOCK", 409); + await balance.update({ on_hand: next }, { transaction }); + const entry = await ledger({ event_id: eventId, warehouse_id: warehouseId, variant_id: variantId, type: "ADJUSTMENT", quantity_delta: quantityDelta, reason, actor_user_id: actorUserId, request_id: requestId }, transaction); + return { balance, transaction: entry, idempotent: false }; + }); +} + +async function selectWarehouse(variantId, quantity, transaction) { + const warehouses = await db.Warehouse.findAll({ where: { status: "ACTIVE" }, order: [["is_default", "DESC"], ["code", "ASC"]], transaction }); + for (const warehouse of warehouses) { + const balance = await lockBalance(warehouse.id, variantId, transaction); + if (balance && Number(balance.on_hand) - Number(balance.reserved) >= quantity) return { warehouse, balance }; + } + throw fault("Insufficient available stock", "INSUFFICIENT_STOCK", 409); +} + +async function reserveStock({ reservationKey, warehouseId, variantId, quantity, referenceType, referenceId, userId, expiresAt, requestId }) { + assertQuantity(quantity); if (!reservationKey) throw fault("reservationKey is required", "INVALID_RESERVATION"); + return db.sequelize.transaction(async transaction => { + const existing = await db.InventoryReservation.findOne({ where: { reservation_key: reservationKey }, transaction, lock: transaction.LOCK.UPDATE }); + if (existing) return { reservation: existing, idempotent: true }; + const variant = await db.ProductVariant.findOne({ where: { id: variantId, status: "ACTIVE" }, transaction }); + if (!variant) throw fault("Active variant not found", "VARIANT_UNAVAILABLE", 404); + let selected; + if (warehouseId) { + const warehouse = await db.Warehouse.findOne({ where: { id: warehouseId, status: "ACTIVE" }, transaction }); + if (!warehouse) throw fault("Active warehouse not found", "WAREHOUSE_UNAVAILABLE", 404); + const balance = await lockBalance(warehouseId, variantId, transaction); + if (!balance || Number(balance.on_hand) - Number(balance.reserved) < quantity) throw fault("Insufficient available stock", "INSUFFICIENT_STOCK", 409); + selected = { warehouse, balance }; + } else selected = await selectWarehouse(variantId, quantity, transaction); + await selected.balance.update({ reserved: Number(selected.balance.reserved) + quantity }, { transaction }); + const reservation = await db.InventoryReservation.create({ id: crypto.randomUUID(), reservation_key: reservationKey, warehouse_id: selected.warehouse.id, variant_id: variantId, quantity, reference_type: referenceType, reference_id: referenceId, user_id: userId, expires_at: expiresAt || new Date(Date.now() + ttlMinutes() * 60000) }, { transaction }); + await ledger({ event_id: `reserve:${reservationKey}`, warehouse_id: selected.warehouse.id, variant_id: variantId, type: "RESERVATION", reserved_delta: quantity, reference_type: "INVENTORY_RESERVATION", reference_id: reservation.id, request_id: requestId }, transaction); + return { reservation, idempotent: false }; + }); +} + +async function finishReservation(reservationKey, status, requestId) { + return db.sequelize.transaction(async transaction => { + const reservation = await db.InventoryReservation.findOne({ where: { reservation_key: reservationKey }, transaction, lock: transaction.LOCK.UPDATE }); + if (!reservation) throw fault("Reservation not found", "RESERVATION_NOT_FOUND", 404); + if (reservation.status === status) return { reservation, idempotent: true }; + if (reservation.status !== "ACTIVE") throw fault(`Reservation is ${reservation.status}`, "RESERVATION_NOT_ACTIVE", 409); + if (status === "CONSUMED" && new Date(reservation.expires_at) <= new Date()) throw fault("Reservation has expired", "RESERVATION_EXPIRED", 409); + const balance = await lockBalance(reservation.warehouse_id, reservation.variant_id, transaction); + if (!balance || Number(balance.reserved) < reservation.quantity) throw fault("Inventory invariant violated", "INVENTORY_INVARIANT", 409); + const consume = status === "CONSUMED"; + await balance.update({ reserved: Number(balance.reserved) - reservation.quantity, on_hand: Number(balance.on_hand) - (consume ? reservation.quantity : 0) }, { transaction }); + await reservation.update({ status, released_at: consume ? null : new Date(), consumed_at: consume ? new Date() : null }, { transaction }); + await ledger({ event_id: `${status.toLowerCase()}:${reservationKey}`, warehouse_id: reservation.warehouse_id, variant_id: reservation.variant_id, type: consume ? "RESERVATION_CONSUME" : "RESERVATION_RELEASE", quantity_delta: consume ? -reservation.quantity : 0, reserved_delta: -reservation.quantity, reference_type: "INVENTORY_RESERVATION", reference_id: reservation.id, request_id: requestId }, transaction); + return { reservation, idempotent: false }; + }); +} + +const releaseReservation = args => finishReservation(args.reservationKey, args.expired ? "EXPIRED" : "RELEASED", args.requestId); +const consumeReservation = args => finishReservation(args.reservationKey, "CONSUMED", args.requestId); + +async function transferStock({ eventId, sourceWarehouseId, destinationWarehouseId, variantId, quantity, actorUserId, requestId }) { + assertQuantity(quantity); if (sourceWarehouseId === destinationWarehouseId) throw fault("Warehouses must differ", "INVALID_TRANSFER"); + return db.sequelize.transaction(async transaction => { + const prior = await db.InventoryTransfer.findOne({ where: { event_id: eventId }, transaction, lock: transaction.LOCK.UPDATE }); if (prior) return { transfer: prior, idempotent: true }; + const ids = [sourceWarehouseId, destinationWarehouseId].sort(); const balances = {}; + for (const id of ids) balances[id] = await lockBalance(id, variantId, transaction); + const source = balances[sourceWarehouseId]; if (!source || Number(source.on_hand) - Number(source.reserved) < quantity) throw fault("Insufficient transferable stock", "INSUFFICIENT_STOCK", 409); + let destination = balances[destinationWarehouseId]; if (!destination) destination = await db.InventoryBalance.create({ id: crypto.randomUUID(), warehouse_id: destinationWarehouseId, variant_id: variantId, on_hand: 0, reserved: 0 }, { transaction }); + await source.update({ on_hand: Number(source.on_hand) - quantity }, { transaction }); await destination.update({ on_hand: Number(destination.on_hand) + quantity }, { transaction }); + const transfer = await db.InventoryTransfer.create({ id: crypto.randomUUID(), event_id: eventId, transfer_number: eventId, source_warehouse_id: sourceWarehouseId, destination_warehouse_id: destinationWarehouseId, variant_id: variantId, quantity, created_by: actorUserId, completed_by: actorUserId, completed_at: new Date() }, { transaction }); + await ledger({ event_id: `${eventId}:out`, warehouse_id: sourceWarehouseId, variant_id: variantId, type: "TRANSFER_OUT", quantity_delta: -quantity, reference_type: "INVENTORY_TRANSFER", reference_id: transfer.id, actor_user_id: actorUserId, request_id: requestId }, transaction); + await ledger({ event_id: `${eventId}:in`, warehouse_id: destinationWarehouseId, variant_id: variantId, type: "TRANSFER_IN", quantity_delta: quantity, reference_type: "INVENTORY_TRANSFER", reference_id: transfer.id, actor_user_id: actorUserId, request_id: requestId }, transaction); + return { transfer, idempotent: false }; + }); +} + +async function expireReservations(limit = 100) { + const rows = await db.InventoryReservation.findAll({ where: { status: "ACTIVE", expires_at: { [Op.lte]: new Date() } }, order: [["expires_at", "ASC"]], limit }); + for (const row of rows) { try { await releaseReservation({ reservationKey: row.reservation_key, expired: true }); } catch (error) { if (error.code !== "RESERVATION_NOT_ACTIVE") throw error; } } + return rows.length; +} + +module.exports = { adjustStock, reserveStock, releaseReservation, consumeReservation, transferStock, expireReservations, availabilityStatus, ttlMinutes }; diff --git a/app/services/pricing/businessPricing.service.js b/app/services/pricing/businessPricing.service.js new file mode 100644 index 0000000..034861c --- /dev/null +++ b/app/services/pricing/businessPricing.service.js @@ -0,0 +1,15 @@ +const { Op } = require("sequelize"); const db = require("../../models"); +const activeAt = at => ({ status: "ACTIVE", [Op.and]: [{ [Op.or]: [{ starts_at: null }, { starts_at: { [Op.lte]: at } }] }, { [Op.or]: [{ ends_at: null }, { ends_at: { [Op.gt]: at } }] }] }); +async function resolvePrice({ businessCustomer, variant, quantity = 1, currency = variant.currency, at = new Date() }) { + const retail = { source: "RETAIL", unitPrice: String(variant.base_price), minimumQuantity: 1, volumeTier: null }; + if (!businessCustomer || businessCustomer.status !== "ACTIVE" || !businessCustomer.approvedAt) return retail; + const common = { variant_id: variant.id, currency, ...activeAt(at) }; + let rule = await db.VariantBusinessPrice.findOne({ where: { ...common, business_customer_id: businessCustomer.business_customer_id }, order: [["createdAt", "DESC"]] }); + let source = "CUSTOMER"; + if (!rule && businessCustomer.business_tier_id) { rule = await db.VariantBusinessPrice.findOne({ where: { ...common, business_customer_id: null, business_tier_id: businessCustomer.business_tier_id }, order: [["createdAt", "DESC"]] }); source = "TIER"; } + if (!rule) return retail; + if (quantity < rule.minimum_quantity) throw Object.assign(new Error(`Minimum quantity is ${rule.minimum_quantity}`), { code: "MOQ_NOT_MET", status: 400 }); + const tier = await db.BusinessPriceTier.findOne({ where: { business_price_id: rule.id, min_quantity: { [Op.lte]: quantity }, [Op.or]: [{ max_quantity: null }, { max_quantity: { [Op.gte]: quantity } }] }, order: [["min_quantity", "DESC"]] }); + return { source, unitPrice: String(tier ? tier.unit_price : rule.price), minimumQuantity: rule.minimum_quantity, volumeTier: tier ? { minQuantity: tier.min_quantity, maxQuantity: tier.max_quantity, unitPrice: String(tier.unit_price) } : null }; +} +module.exports = { resolvePrice }; diff --git a/app/services/pricing/money.js b/app/services/pricing/money.js new file mode 100644 index 0000000..32f2040 --- /dev/null +++ b/app/services/pricing/money.js @@ -0,0 +1,5 @@ +const parse = value => { const match = String(value).match(/^(\d+)(?:\.(\d{1,2}))?$/); if (!match) throw Object.assign(new Error("Invalid money"), { code: "INVALID_MONEY", status: 400 }); return BigInt(match[1]) * 100n + BigInt((match[2] || "").padEnd(2, "0")); }; +const format = cents => `${cents / 100n}.${String(cents % 100n).padStart(2, "0")}`; +const subtractFloor = (a, b) => format(parse(a) > parse(b) ? parse(a) - parse(b) : 0n); +const percentOff = (amount, percent) => { const [whole,fraction=""] = String(percent).split("."); const basis = BigInt(whole) * 100n + BigInt(fraction.padEnd(2,"0")); return format(parse(amount) * basis / 10000n); }; +module.exports = { parse, format, subtractFloor, percentOff }; diff --git a/app/services/pricing/pricing.service.js b/app/services/pricing/pricing.service.js new file mode 100644 index 0000000..22b29dc --- /dev/null +++ b/app/services/pricing/pricing.service.js @@ -0,0 +1,14 @@ +const db = require("../../models"); const business = require("./businessPricing.service"); const money = require("./money"); +const applies = (promotion, quantity, at) => promotion.status === "ACTIVE" && (!promotion.starts_at || new Date(promotion.starts_at) <= at) && (!promotion.ends_at || new Date(promotion.ends_at) > at) && (!promotion.minimum_quantity || quantity >= promotion.minimum_quantity); +const discount = (price, promotion) => promotion.type === "PERCENTAGE" ? money.percentOff(price, promotion.discount_percent) : promotion.type === "FIXED_AMOUNT" ? String(promotion.discount_amount) : money.subtractFloor(price, promotion.fixed_price); +async function quoteVariant({ variantId, businessCustomer, quantity = 1, couponCode, at = new Date() }) { + const variant = await db.ProductVariant.findOne({ where: { id: variantId, status: "ACTIVE" } }); if (!variant) throw Object.assign(new Error("Variant not found"), { code: "VARIANT_NOT_FOUND", status: 404 }); + const base = await business.resolvePrice({ businessCustomer, variant, quantity, at }); + const promotions = await db.Promotion.findAll({ where: { status: "ACTIVE" }, order: [["priority", "DESC"], ["id", "ASC"]] }); + const automatic = promotions.filter(p => applies(p, quantity, at))[0] || null; let effective = base.unitPrice; let automaticDiscount = "0.00"; + if (automatic) { automaticDiscount = discount(effective, automatic); effective = money.subtractFloor(effective, automaticDiscount); } + let coupon = null; + if (couponCode) { coupon = await db.Coupon.findOne({ where: { code: couponCode.trim().toUpperCase(), status: "ACTIVE" }, include: [{ model: db.Promotion, as: "promotion" }] }); if (!coupon || (coupon.starts_at && new Date(coupon.starts_at) > at) || (coupon.expires_at && new Date(coupon.expires_at) <= at) || !applies(coupon.promotion, quantity, at)) throw Object.assign(new Error("Coupon is not valid"), { code: "INVALID_COUPON", status: 400 }); if (!automatic || automatic.stackable) effective = money.subtractFloor(effective, discount(effective, coupon.promotion)); } + return { cataloguePrice: String(variant.base_price), businessPrice: base.source === "RETAIL" ? null : base.unitPrice, baseEligiblePrice: base.unitPrice, effectiveUnitPrice: effective, currency: variant.currency, minimumQuantity: base.minimumQuantity, automaticPromotion: automatic && { id: automatic.id, name: automatic.name }, coupon: coupon && { code: coupon.code } }; +} +module.exports = { quoteVariant, applies, discount }; diff --git a/app/validation/catalogue.schemas.js b/app/validation/catalogue.schemas.js new file mode 100644 index 0000000..b0c2385 --- /dev/null +++ b/app/validation/catalogue.schemas.js @@ -0,0 +1,6 @@ +const {z}=require("zod");const req=(body)=>z.object({body:z.object(body).strict(),params:z.object({}).passthrough(),query:z.object({}).passthrough()});const id=z.string().min(1).max(100);const slug=z.string().trim().toLowerCase().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).max(200);const money=z.string().regex(/^\d{1,13}(?:\.\d{1,2})?$/);const locale=z.enum(["en","si","ta"]);const translation=z.object({locale,name:z.string().trim().min(1).max(220),shortDescription:z.string().trim().max(500).optional(),description:z.string().trim().max(20000).optional(),careInstructions:z.string().trim().max(5000).optional(),materials:z.string().trim().max(2000).optional(),origin:z.string().trim().max(120).optional(),metaTitle:z.string().trim().max(180).optional(),metaDescription:z.string().trim().max(320).optional()}).strict(); +const variant=z.object({sku:z.string().trim().regex(/^[A-Za-z0-9._-]+$/).max(100),barcode:z.string().trim().max(100).optional(),basePrice:money,compareAtPrice:money.optional().nullable(),currency:z.string().length(3).transform(v=>v.toUpperCase()).default("LKR"),weight:z.string().regex(/^\d+(?:\.\d{1,3})?$/).optional(),sortOrder:z.number().int().min(0).default(0)}).strict(); +module.exports={slug,money,locale,productCreate:req({brandId:id,defaultCategoryId:id.optional(),slug,productCode:z.string().trim().min(1).max(100),productType:z.string().trim().max(80).default("STANDARD"),featured:z.boolean().default(false),newArrivalUntil:z.coerce.date().optional().nullable(),translations:z.array(translation).min(1),categoryIds:z.array(id).min(1),variants:z.array(variant).min(1),mediaUploadIds:z.array(z.number().int().positive()).default([])}),productUpdate:req({brandId:id.optional(),defaultCategoryId:id.optional().nullable(),slug:slug.optional(),productType:z.string().trim().max(80).optional(),featured:z.boolean().optional(),newArrivalUntil:z.coerce.date().optional().nullable(),status:z.enum(["DRAFT","ACTIVE","INACTIVE","ARCHIVED"]).optional(),visibility:z.enum(["PUBLIC","HIDDEN"]).optional()}),variantCreate:req(variant.shape),variantUpdate:req({status:z.enum(["ACTIVE","INACTIVE"]).optional(),basePrice:money.optional(),compareAtPrice:money.optional().nullable(),currency:z.string().length(3).transform(v=>v.toUpperCase()).optional(),weight:z.string().regex(/^\d+(?:\.\d{1,3})?$/).optional(),sortOrder:z.number().int().min(0).optional(),optionValueIds:z.array(id).optional()}),brand:req({name:z.string().trim().min(1).max(160),slug,description:z.string().trim().max(5000).optional(),logoUploadId:z.number().int().positive().optional().nullable(),website:z.string().url().optional().nullable(),status:z.enum(["ACTIVE","INACTIVE"]).default("INACTIVE"),sortOrder:z.number().int().min(0).default(0)}),category:req({parentId:id.optional().nullable(),code:z.string().trim().regex(/^[A-Za-z0-9_-]+$/).max(80),slug,status:z.enum(["ACTIVE","INACTIVE"]).default("INACTIVE"),sortOrder:z.number().int().min(0).default(0),imageUploadId:z.number().int().positive().optional().nullable(),translations:z.array(translation.pick({locale:true,name:true,description:true,metaTitle:true,metaDescription:true})).min(1)}),collection:req({slug,type:z.string().trim().min(1).max(80),status:z.enum(["DRAFT","ACTIVE","INACTIVE"]).default("DRAFT"),startsAt:z.coerce.date().optional().nullable(),endsAt:z.coerce.date().optional().nullable(),heroUploadId:z.number().int().positive().optional().nullable(),sortOrder:z.number().int().min(0).default(0),translations:z.array(z.object({locale,name:z.string().trim().min(1).max(180),description:z.string().max(10000).optional(),headline:z.string().max(250).optional(),subheadline:z.string().max(300).optional()}).strict()).min(1),productIds:z.array(id).default([])}),review:req({rating:z.number().int().min(1).max(5),title:z.string().trim().min(1).max(160),body:z.string().trim().min(3).max(5000)}),reviewStatus:req({status:z.enum(["APPROVED","REJECTED"])}),sizeGuide:req({code:z.string().trim().regex(/^[A-Za-z0-9_-]+$/).max(80),name:z.string().trim().min(1).max(160),locale,data:z.object({columns:z.array(z.string().max(80)).min(1).max(20),rows:z.array(z.array(z.string().max(100)).max(20)).max(100)}).strict(),categoryId:id.optional().nullable(),status:z.enum(["ACTIVE","INACTIVE"]).default("ACTIVE")})}; +module.exports.option=req({name:z.string().trim().min(1).max(100),values:z.array(z.string().trim().min(1).max(100)).min(1),sortOrder:z.number().int().min(0).default(0)}); +module.exports.media=req({uploadId:z.number().int().positive(),variantId:id.optional().nullable(),altText:z.string().trim().max(250).optional(),isPrimary:z.boolean().default(false),sortOrder:z.number().int().min(0).default(0)}); +module.exports.relation=req({targetProductId:id,relationType:z.enum(["RELATED","SIMILAR","COMPLETE_THE_LOOK"]),sortOrder:z.number().int().min(0).default(0)}); diff --git a/app/validation/inventoryMerchandising.schemas.js b/app/validation/inventoryMerchandising.schemas.js new file mode 100644 index 0000000..cf530ea --- /dev/null +++ b/app/validation/inventoryMerchandising.schemas.js @@ -0,0 +1,8 @@ +const {z}=require("zod");const wrap=body=>z.object({body:body.strict(),params:z.object({}).passthrough(),query:z.object({}).passthrough()});const id=z.string().min(1).max(160),money=z.string().regex(/^\d+(\.\d{1,2})?$/),date=z.coerce.date(); +exports.adjustment=wrap(z.object({warehouseId:id,variantId:id,quantityDelta:z.number().int().refine(v=>v!==0),reason:z.string().min(1).max(500)})); +exports.transfer=wrap(z.object({sourceWarehouseId:id,destinationWarehouseId:id,variantId:id,quantity:z.number().int().positive()})); +exports.warehouse=wrap(z.object({code:z.string().trim().min(1).max(50),name:z.string().min(1).max(150),status:z.enum(["ACTIVE","INACTIVE"]).default("ACTIVE"),isDefault:z.boolean().default(false),timezone:z.string().max(80).optional(),city:z.string().max(100).optional(),countryCode:z.string().length(2).optional()})); +exports.businessPrice=wrap(z.object({variantId:id,businessTierId:id.optional(),businessCustomerId:id.optional(),currency:z.string().length(3).transform(v=>v.toUpperCase()),price:money,minimumQuantity:z.number().int().positive().default(1),status:z.enum(["ACTIVE","INACTIVE"]).default("ACTIVE"),startsAt:date.optional(),endsAt:date.optional(),tiers:z.array(z.object({minQuantity:z.number().int().positive(),maxQuantity:z.number().int().positive().nullable().optional(),unitPrice:money}).strict()).default([])}).refine(x=>Boolean(x.businessTierId)!==Boolean(x.businessCustomerId),"Exactly one audience required")); +exports.promotion=wrap(z.object({name:z.string().min(1).max(180),type:z.enum(["PERCENTAGE","FIXED_AMOUNT","FIXED_PRICE"]),status:z.enum(["DRAFT","ACTIVE","INACTIVE","ARCHIVED"]).default("DRAFT"),startsAt:date.optional(),endsAt:date.optional(),priority:z.number().int().default(0),stackable:z.boolean().default(false),discountPercent:money.optional(),discountAmount:money.optional(),fixedPrice:money.optional(),minimumQuantity:z.number().int().positive().optional(),targets:z.array(z.object({type:z.enum(["ALL","PRODUCT","VARIANT","CATEGORY","BRAND","COLLECTION"]),id:id.nullable().optional()}).strict()).default([{type:"ALL"}])})); +exports.coupon=wrap(z.object({code:z.string().trim().min(1).max(50).regex(/^[A-Za-z0-9_-]+$/).transform(v=>v.toUpperCase()),promotionId:id,status:z.enum(["ACTIVE","INACTIVE","ARCHIVED"]).default("ACTIVE"),startsAt:date.optional(),expiresAt:date.optional()})); +exports.banner=wrap(z.object({placement:z.string().trim().min(1).max(80),status:z.enum(["DRAFT","ACTIVE","INACTIVE","ARCHIVED"]).default("DRAFT"),startsAt:date.optional(),endsAt:date.optional(),imageUploadId:z.number().int().positive(),mobileImageUploadId:z.number().int().positive().optional(),targetUrl:z.string().url().optional(),audienceType:z.enum(["ALL","CUSTOMER","BUSINESS_CUSTOMER"]).default("ALL"),businessTierId:id.optional(),sortOrder:z.number().int().default(0),translations:z.array(z.object({locale:z.enum(["en","si","ta"]),headline:z.string().max(180).optional(),subheadline:z.string().max(500).optional(),ctaText:z.string().max(80).optional(),altText:z.string().max(255).optional()}).strict()).min(1)})); diff --git a/cron/index.js b/cron/index.js index ce37337..cfbf7e5 100644 --- a/cron/index.js +++ b/cron/index.js @@ -10,11 +10,12 @@ // cron/index.js const startCleanInactiveNotificationsCron = require("./notificationCleaning.cron"); +const startInventoryReservationExpiryCron = require("./inventoryReservationExpiry.cron"); function startAllCrons() { console.log("Starting Cron Jobs..."); - const tasks = [startCleanInactiveNotificationsCron()]; + const tasks = [startCleanInactiveNotificationsCron(), startInventoryReservationExpiryCron()]; return async () => { for (const task of tasks) { diff --git a/cron/inventoryReservationExpiry.cron.js b/cron/inventoryReservationExpiry.cron.js new file mode 100644 index 0000000..d6dbb88 --- /dev/null +++ b/cron/inventoryReservationExpiry.cron.js @@ -0,0 +1 @@ +const cron=require("node-cron"),inventory=require("../app/services/inventory/inventory.service");module.exports=()=>cron.schedule("* * * * *",async()=>{try{await inventory.expireReservations(100);}catch(error){console.error("Inventory reservation reconciliation failed",{name:error.name,message:error.message});}},{noOverlap:true}); diff --git a/migrations/20260903040000-phase-4-catalogue-content.js b/migrations/20260903040000-phase-4-catalogue-content.js new file mode 100644 index 0000000..7960431 --- /dev/null +++ b/migrations/20260903040000-phase-4-catalogue-content.js @@ -0,0 +1,21 @@ +"use strict"; +module.exports={async up(q,S){await q.sequelize.transaction(async transaction=>{const ts={createdAt:{type:S.DATE,allowNull:false},updatedAt:{type:S.DATE,allowNull:false}};const table=(name,fields)=>q.createTable(name,{...fields,...ts},{transaction}); +await table("brands",{id:{type:S.STRING,primaryKey:true},name:{type:S.STRING(160),allowNull:false},slug:{type:S.STRING(180),allowNull:false,unique:true},description:S.TEXT,logo_upload_id:S.INTEGER,website:S.STRING,status:{type:S.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"INACTIVE"},sort_order:{type:S.INTEGER,allowNull:false,defaultValue:0}});await q.addIndex("brands",["status","sort_order"],{name:"brands_public_idx",transaction}); +await table("categories",{id:{type:S.STRING,primaryKey:true},parent_id:{type:S.STRING,allowNull:true,references:{model:"categories",key:"id"},onDelete:"RESTRICT"},code:{type:S.STRING(80),allowNull:false,unique:true},slug:{type:S.STRING(180),allowNull:false,unique:true},status:{type:S.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"INACTIVE"},sort_order:{type:S.INTEGER,allowNull:false,defaultValue:0},image_upload_id:S.INTEGER});await q.addIndex("categories",["parent_id","status","sort_order"],{name:"categories_tree_idx",transaction}); +await table("category_translations",{id:{type:S.STRING,primaryKey:true},category_id:{type:S.STRING,allowNull:false,references:{model:"categories",key:"id"},onDelete:"CASCADE"},locale:{type:S.ENUM("en","si","ta"),allowNull:false},name:{type:S.STRING(180),allowNull:false},description:S.TEXT,meta_title:S.STRING(180),meta_description:S.STRING(320)});await q.addIndex("category_translations",["category_id","locale"],{unique:true,name:"category_translation_locale_unique",transaction}); +await table("size_guides",{id:{type:S.STRING,primaryKey:true},code:{type:S.STRING(80),allowNull:false,unique:true},name:{type:S.STRING(160),allowNull:false},locale:{type:S.ENUM("en","si","ta"),allowNull:false,defaultValue:"en"},data:{type:S.JSON,allowNull:false},category_id:{type:S.STRING,allowNull:true,references:{model:"categories",key:"id"},onDelete:"SET NULL"},status:{type:S.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"}}); +await table("products",{id:{type:S.STRING,primaryKey:true},brand_id:{type:S.STRING,allowNull:false,references:{model:"brands",key:"id"},onDelete:"RESTRICT"},default_category_id:{type:S.STRING,allowNull:true,references:{model:"categories",key:"id"},onDelete:"SET NULL"},slug:{type:S.STRING(200),allowNull:false,unique:true},product_code:{type:S.STRING(100),allowNull:false,unique:true},status:{type:S.ENUM("DRAFT","ACTIVE","INACTIVE","ARCHIVED"),allowNull:false,defaultValue:"DRAFT"},visibility:{type:S.ENUM("PUBLIC","HIDDEN"),allowNull:false,defaultValue:"HIDDEN"},product_type:{type:S.STRING(80),allowNull:false,defaultValue:"STANDARD"},featured:{type:S.BOOLEAN,allowNull:false,defaultValue:false},new_arrival_until:S.DATE,published_at:S.DATE,created_by:{type:S.STRING,allowNull:false,references:{model:"users",key:"id"}},updated_by:S.STRING,size_guide_id:{type:S.STRING,allowNull:true,references:{model:"size_guides",key:"id"},onDelete:"SET NULL"}});await q.addIndex("products",["status","visibility","published_at"],{name:"products_public_idx",transaction});await q.addIndex("products",["brand_id","featured","new_arrival_until"],{name:"products_merchandising_idx",transaction}); +await table("product_translations",{id:{type:S.STRING,primaryKey:true},product_id:{type:S.STRING,allowNull:false,references:{model:"products",key:"id"},onDelete:"CASCADE"},locale:{type:S.ENUM("en","si","ta"),allowNull:false},name:{type:S.STRING(220),allowNull:false},short_description:S.STRING(500),description:S.TEXT,care_instructions:S.TEXT,materials:S.TEXT,origin:S.STRING(120),meta_title:S.STRING(180),meta_description:S.STRING(320)});await q.addIndex("product_translations",["product_id","locale"],{unique:true,name:"product_translation_locale_unique",transaction});await q.addIndex("product_translations",["name"],{name:"product_translation_name_idx",transaction}); +await table("product_categories",{id:{type:S.STRING,primaryKey:true},product_id:{type:S.STRING,allowNull:false,references:{model:"products",key:"id"},onDelete:"CASCADE"},category_id:{type:S.STRING,allowNull:false,references:{model:"categories",key:"id"},onDelete:"RESTRICT"}});await q.addIndex("product_categories",["product_id","category_id"],{unique:true,name:"product_category_unique",transaction});await q.addIndex("product_categories",["category_id","product_id"],{name:"product_category_lookup_idx",transaction}); +await table("product_variants",{id:{type:S.STRING,primaryKey:true},product_id:{type:S.STRING,allowNull:false,references:{model:"products",key:"id"},onDelete:"CASCADE"},sku:{type:S.STRING(100),allowNull:false,unique:true},barcode:{type:S.STRING(100),allowNull:true,unique:true},status:{type:S.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"},base_price:{type:S.DECIMAL(15,2),allowNull:false},compare_at_price:S.DECIMAL(15,2),currency:{type:S.STRING(3),allowNull:false,defaultValue:"LKR"},weight:S.DECIMAL(10,3),sort_order:{type:S.INTEGER,allowNull:false,defaultValue:0}});await q.addIndex("product_variants",["product_id","status","base_price"],{name:"product_variant_public_idx",transaction}); +await table("product_options",{id:{type:S.STRING,primaryKey:true},product_id:{type:S.STRING,allowNull:false,references:{model:"products",key:"id"},onDelete:"CASCADE"},name:{type:S.STRING(100),allowNull:false},sort_order:{type:S.INTEGER,allowNull:false,defaultValue:0}});await q.addIndex("product_options",["product_id","name"],{unique:true,name:"product_option_name_unique",transaction}); +await table("product_option_values",{id:{type:S.STRING,primaryKey:true},option_id:{type:S.STRING,allowNull:false,references:{model:"product_options",key:"id"},onDelete:"CASCADE"},value:{type:S.STRING(100),allowNull:false},sort_order:{type:S.INTEGER,allowNull:false,defaultValue:0}});await q.addIndex("product_option_values",["option_id","value"],{unique:true,name:"product_option_value_unique",transaction}); +await table("variant_option_values",{id:{type:S.STRING,primaryKey:true},variant_id:{type:S.STRING,allowNull:false,references:{model:"product_variants",key:"id"},onDelete:"CASCADE"},option_value_id:{type:S.STRING,allowNull:false,references:{model:"product_option_values",key:"id"},onDelete:"CASCADE"}});await q.addIndex("variant_option_values",["variant_id","option_value_id"],{unique:true,name:"variant_option_value_unique",transaction}); +await table("product_attributes",{id:{type:S.STRING,primaryKey:true},product_id:{type:S.STRING,allowNull:false,references:{model:"products",key:"id"},onDelete:"CASCADE"},name:{type:S.STRING(100),allowNull:false},value:{type:S.STRING(500),allowNull:false},locale:{type:S.ENUM("en","si","ta"),allowNull:false,defaultValue:"en"},sort_order:{type:S.INTEGER,allowNull:false,defaultValue:0}});await q.addIndex("product_attributes",["product_id","locale"],{name:"product_attribute_lookup_idx",transaction}); +await table("product_media",{id:{type:S.STRING,primaryKey:true},product_id:{type:S.STRING,allowNull:false,references:{model:"products",key:"id"},onDelete:"CASCADE"},variant_id:{type:S.STRING,allowNull:true,references:{model:"product_variants",key:"id"},onDelete:"CASCADE"},upload_id:{type:S.INTEGER,allowNull:false,references:{model:"uploads",key:"id"},onDelete:"RESTRICT"},type:{type:S.ENUM("IMAGE"),allowNull:false,defaultValue:"IMAGE"},sort_order:{type:S.INTEGER,allowNull:false,defaultValue:0},alt_text:S.STRING(250),is_primary:{type:S.BOOLEAN,allowNull:false,defaultValue:false}});await q.addIndex("product_media",["product_id","upload_id"],{unique:true,name:"product_media_upload_unique",transaction});await q.addIndex("product_media",["product_id","is_primary","sort_order"],{name:"product_media_gallery_idx",transaction}); +await table("collections",{id:{type:S.STRING,primaryKey:true},slug:{type:S.STRING(180),allowNull:false,unique:true},type:{type:S.STRING(80),allowNull:false},status:{type:S.ENUM("DRAFT","ACTIVE","INACTIVE"),allowNull:false,defaultValue:"DRAFT"},starts_at:S.DATE,ends_at:S.DATE,hero_upload_id:S.INTEGER,sort_order:{type:S.INTEGER,allowNull:false,defaultValue:0}});await q.addIndex("collections",["status","starts_at","ends_at"],{name:"collections_publish_window_idx",transaction}); +await table("collection_translations",{id:{type:S.STRING,primaryKey:true},collection_id:{type:S.STRING,allowNull:false,references:{model:"collections",key:"id"},onDelete:"CASCADE"},locale:{type:S.ENUM("en","si","ta"),allowNull:false},name:{type:S.STRING(180),allowNull:false},description:S.TEXT,headline:S.STRING(250),subheadline:S.STRING(300)});await q.addIndex("collection_translations",["collection_id","locale"],{unique:true,name:"collection_translation_locale_unique",transaction}); +await table("collection_products",{id:{type:S.STRING,primaryKey:true},collection_id:{type:S.STRING,allowNull:false,references:{model:"collections",key:"id"},onDelete:"CASCADE"},product_id:{type:S.STRING,allowNull:false,references:{model:"products",key:"id"},onDelete:"CASCADE"},sort_order:{type:S.INTEGER,allowNull:false,defaultValue:0},featured:{type:S.BOOLEAN,allowNull:false,defaultValue:false}});await q.addIndex("collection_products",["collection_id","product_id"],{unique:true,name:"collection_product_unique",transaction});await q.addIndex("collection_products",["collection_id","sort_order"],{name:"collection_product_order_idx",transaction}); +await table("product_relations",{id:{type:S.STRING,primaryKey:true},source_product_id:{type:S.STRING,allowNull:false,references:{model:"products",key:"id"},onDelete:"CASCADE"},target_product_id:{type:S.STRING,allowNull:false,references:{model:"products",key:"id"},onDelete:"CASCADE"},relation_type:{type:S.ENUM("RELATED","SIMILAR","COMPLETE_THE_LOOK"),allowNull:false},sort_order:{type:S.INTEGER,allowNull:false,defaultValue:0}});await q.addIndex("product_relations",["source_product_id","target_product_id","relation_type"],{unique:true,name:"product_relation_unique",transaction});await q.sequelize.query("ALTER TABLE product_relations ADD CONSTRAINT product_relation_not_self CHECK (source_product_id <> target_product_id)",{transaction}); +await table("product_reviews",{id:{type:S.STRING,primaryKey:true},product_id:{type:S.STRING,allowNull:false,references:{model:"products",key:"id"},onDelete:"CASCADE"},user_id:{type:S.STRING,allowNull:false,references:{model:"users",key:"id"},onDelete:"RESTRICT"},rating:{type:S.INTEGER,allowNull:false},title:{type:S.STRING(160),allowNull:false},body:{type:S.TEXT,allowNull:false},status:{type:S.ENUM("PENDING","APPROVED","REJECTED"),allowNull:false,defaultValue:"PENDING"},verified_purchase:{type:S.BOOLEAN,allowNull:false,defaultValue:false},moderated_by:S.STRING,moderated_at:S.DATE});await q.addIndex("product_reviews",["user_id","product_id"],{unique:true,name:"product_review_user_unique",transaction});await q.addIndex("product_reviews",["product_id","status","createdAt"],{name:"product_review_public_idx",transaction});await q.sequelize.query("ALTER TABLE product_reviews ADD CONSTRAINT product_review_rating_check CHECK (rating BETWEEN 1 AND 5)",{transaction}); +});},async down(){throw new Error("Phase 4 migration is forward-only; restore from backup for rollback");}}; diff --git a/migrations/20260903050000-phase-5-inventory-merchandising.js b/migrations/20260903050000-phase-5-inventory-merchandising.js new file mode 100644 index 0000000..cc1f506 --- /dev/null +++ b/migrations/20260903050000-phase-5-inventory-merchandising.js @@ -0,0 +1,5 @@ +"use strict"; +module.exports={async up(q,S){const ID={type:S.STRING,allowNull:false,primaryKey:true},STRING={type:S.STRING},DATE={type:S.DATE},INT={type:S.INTEGER,allowNull:false,defaultValue:0},DEC={type:S.DECIMAL(15,2)},timestamps={createdAt:{type:S.DATE,allowNull:false},updatedAt:{type:S.DATE,allowNull:false}};await q.createTable("warehouses",{id:ID,code:{type:S.STRING(50),allowNull:false,unique:true},name:{type:S.STRING(150),allowNull:false},status:{type:S.ENUM("ACTIVE","INACTIVE"),allowNull:false},is_default:{type:S.BOOLEAN,allowNull:false,defaultValue:false},timezone:{type:S.STRING(80),allowNull:false},address_line_1:STRING,address_line_2:STRING,city:STRING,province:STRING,postal_code:STRING,country_code:{type:S.STRING(2),allowNull:false},...timestamps});await q.createTable("inventory_balances",{id:ID,warehouse_id:{type:S.STRING,allowNull:false},variant_id:{type:S.STRING,allowNull:false},on_hand:INT,reserved:INT,low_stock_threshold:INT,...timestamps});await q.addConstraint("inventory_balances",{fields:["warehouse_id","variant_id"],type:"unique",name:"uq_inventory_balance_warehouse_variant"}); +await q.createTable("inventory_transactions",{id:ID,event_id:{type:S.STRING,allowNull:false,unique:true},warehouse_id:str(false),variant_id:str(false),type:str(false),quantity_delta:INT,reserved_delta:INT,reference_type:STRING,reference_id:STRING,reason:{type:S.STRING(500)},actor_user_id:STRING,request_id:STRING,metadata:{type:S.JSON},occurred_at:{type:S.DATE,allowNull:false},createdAt:{type:S.DATE,allowNull:false}});await q.createTable("inventory_reservations",{id:ID,reservation_key:{type:S.STRING(160),allowNull:false,unique:true},warehouse_id:str(false),variant_id:str(false),quantity:{type:S.INTEGER,allowNull:false},status:str(false),reference_type:STRING,reference_id:STRING,user_id:STRING,expires_at:{type:S.DATE,allowNull:false},released_at:DATE,consumed_at:DATE,...timestamps});await q.createTable("inventory_transfers",{id:ID,event_id:{type:S.STRING,allowNull:false,unique:true},transfer_number:{type:S.STRING(80),allowNull:false,unique:true},source_warehouse_id:str(false),destination_warehouse_id:str(false),variant_id:str(false),quantity:{type:S.INTEGER,allowNull:false},status:str(false),created_by:str(false),completed_by:str(false),completed_at:{type:S.DATE,allowNull:false},...timestamps}); +await q.createTable("business_tiers",{id:ID,code:{type:S.STRING(60),allowNull:false,unique:true},name:str(false),description:{type:S.TEXT},status:str(false),sort_order:INT,...timestamps});await q.addColumn("business_customers","business_tier_id",STRING);await q.createTable("variant_business_prices",{id:ID,variant_id:str(false),business_tier_id:STRING,business_customer_id:STRING,currency:{type:S.STRING(3),allowNull:false},price:{...DEC,allowNull:false},minimum_quantity:{type:S.INTEGER,allowNull:false},status:str(false),starts_at:DATE,ends_at:DATE,...timestamps});await q.createTable("business_price_tiers",{id:ID,business_price_id:str(false),min_quantity:{type:S.INTEGER,allowNull:false},max_quantity:{type:S.INTEGER},unit_price:{...DEC,allowNull:false},...timestamps}); +await q.createTable("promotions",{id:ID,name:str(false),type:str(false),status:str(false),starts_at:DATE,ends_at:DATE,priority:INT,stackable:{type:S.BOOLEAN,allowNull:false},discount_percent:{type:S.DECIMAL(5,2)},discount_amount:DEC,fixed_price:DEC,minimum_subtotal:DEC,minimum_quantity:{type:S.INTEGER},customer_type:STRING,created_by:str(false),updated_by:STRING,...timestamps});await q.createTable("promotion_targets",{id:ID,promotion_id:str(false),target_type:str(false),target_id:STRING,...timestamps});await q.createTable("coupons",{id:ID,code:{type:S.STRING(50),allowNull:false,unique:true},promotion_id:str(false),status:str(false),starts_at:DATE,expires_at:DATE,max_uses:{type:S.INTEGER},max_uses_per_user:{type:S.INTEGER},...timestamps});await q.createTable("banners",{id:ID,placement:str(false),status:str(false),starts_at:DATE,ends_at:DATE,image_upload_id:{type:S.INTEGER,allowNull:false},mobile_image_upload_id:{type:S.INTEGER},target_url:STRING,audience_type:str(false),business_tier_id:STRING,sort_order:INT,created_by:str(false),updated_by:STRING,...timestamps});await q.createTable("banner_translations",{id:ID,banner_id:str(false),locale:{type:S.STRING(5),allowNull:false},headline:STRING,subheadline:{type:S.TEXT},cta_text:STRING,alt_text:STRING,...timestamps});await q.addConstraint("banner_translations",{fields:["banner_id","locale"],type:"unique",name:"uq_banner_translation_locale"});},async down(){throw new Error("Phase 5 migration is forward-only; restore from a verified backup instead");}}; diff --git a/tests/unit/catalogue.locale-serializer.test.js b/tests/unit/catalogue.locale-serializer.test.js new file mode 100644 index 0000000..141ee1e --- /dev/null +++ b/tests/unit/catalogue.locale-serializer.test.js @@ -0,0 +1,6 @@ +const {selectTranslation}=require("../../app/services/catalogue/locale.service");const {cents}=require("../../app/services/catalogue/serializer.service"); +describe("catalogue localization and money helpers",()=>{ +test("selects requested locale",()=>expect(selectTranslation([{locale:"en",name:"A"},{locale:"si",name:"B"}],"si").name).toBe("B")); +test("falls back to English",()=>expect(selectTranslation([{locale:"en",name:"A"}],"ta").name).toBe("A")); +test("compares decimal values without Number conversion",()=>{expect(cents("10.09")>cents("9.99")).toBe(true);expect(cents("1.5")).toBe(150n);}); +}); diff --git a/tests/unit/catalogue.schemas.test.js b/tests/unit/catalogue.schemas.test.js new file mode 100644 index 0000000..3d0dcd5 --- /dev/null +++ b/tests/unit/catalogue.schemas.test.js @@ -0,0 +1,12 @@ +const s=require("../../app/validation/catalogue.schemas");const wrap=body=>({body,params:{},query:{}});const base=()=>({brandId:"brand-1",slug:"linen-shirt",productCode:"SHIRT-1",translations:[{locale:"en",name:"Linen Shirt"}],categoryIds:["cat-1"],variants:[{sku:"SHIRT-1-M",basePrice:"1990.00",currency:"LKR"}],mediaUploadIds:[1]}); +describe("Phase 4 catalogue validation",()=>{ +test("accepts a minimal strict draft product",()=>expect(s.productCreate.safeParse(wrap(base())).success).toBe(true)); +test("rejects unknown administrative product fields",()=>expect(s.productCreate.safeParse(wrap({...base(),stock:100})).success).toBe(false)); +test("rejects invalid slug",()=>expect(s.productCreate.safeParse(wrap({...base(),slug:"Unsafe Slug!"})).success).toBe(false)); +test.each(["12","12.3","12.34"])("accepts decimal money %s",v=>expect(s.money.safeParse(v).success).toBe(true)); +test.each(["-1","1.234","NaN","1e3"])("rejects invalid money %s",v=>expect(s.money.safeParse(v).success).toBe(false)); +test("supports en si ta and rejects other translations",()=>{for(const locale of ["en","si","ta"])expect(s.productCreate.safeParse(wrap({...base(),translations:[{locale,name:"Name"}]})).success).toBe(true);expect(s.productCreate.safeParse(wrap({...base(),translations:[{locale:"fr",name:"Name"}]})).success).toBe(false);}); +test("client cannot set review verification or moderation",()=>{expect(s.review.safeParse(wrap({rating:5,title:"Great",body:"Excellent product"})).success).toBe(true);expect(s.review.safeParse(wrap({rating:5,title:"Great",body:"Excellent product",verifiedPurchase:true,status:"APPROVED"})).success).toBe(false);}); +test("validates rating range",()=>{expect(s.review.safeParse(wrap({rating:0,title:"Bad",body:"Not valid"})).success).toBe(false);expect(s.review.safeParse(wrap({rating:5,title:"Good",body:"Valid review"})).success).toBe(true);}); +test("size guide accepts structured data and not HTML",()=>{expect(s.sizeGuide.safeParse(wrap({code:"MEN_TOPS",name:"Men tops",locale:"en",data:{columns:["Size","Chest"],rows:[["M","40"]]}})).success).toBe(true);expect(s.sizeGuide.safeParse(wrap({code:"X",name:"X",locale:"en",data:{html:"<script>"}})).success).toBe(false);}); +}); diff --git a/tests/unit/catalogue.service.test.js b/tests/unit/catalogue.service.test.js new file mode 100644 index 0000000..c5ad8f4 --- /dev/null +++ b/tests/unit/catalogue.service.test.js @@ -0,0 +1,7 @@ +describe("catalogue hierarchy and publication rules",()=>{beforeEach(()=>jest.resetModules()); +const setup=(nodes={})=>{const models={Category:{findByPk:jest.fn(async id=>nodes[id]||null)},ProductTranslation:{findOne:jest.fn()},ProductVariant:{findOne:jest.fn()},ProductMedia:{findOne:jest.fn()}};jest.doMock("../../app/models",()=>models);return{service:require("../../app/services/catalogue/catalogue.service"),models};}; +test("blocks self-parent",async()=>{const {service}=setup();await expect(service.assertCategoryParent("a","a",{})).rejects.toMatchObject({code:"CATEGORY_SELF_PARENT"});}); +test("blocks category cycle",async()=>{const {service}=setup({b:{id:"b",parent_id:"a"}});await expect(service.assertCategoryParent("a","b",{})).rejects.toMatchObject({code:"CATEGORY_CYCLE"});}); +test("accepts valid hierarchy",async()=>{const {service}=setup({b:{id:"b",parent_id:null}});await expect(service.assertCategoryParent("a","b",{})).resolves.toBeUndefined();}); +test("publish requires English translation, variant, and primary media",async()=>{const {service,models}=setup();models.ProductTranslation.findOne.mockResolvedValue(null);await expect(service.validatePublish({id:"p",slug:"p",brand_id:"b"},{})).rejects.toMatchObject({code:"PUBLISH_REQUIREMENTS_NOT_MET"});models.ProductTranslation.findOne.mockResolvedValue({});models.ProductVariant.findOne.mockResolvedValue({});models.ProductMedia.findOne.mockResolvedValue({});await expect(service.validatePublish({id:"p",slug:"p",brand_id:"b"},{})).resolves.toBeUndefined();}); +}); diff --git a/tests/unit/inventory-merchandising.money.test.js b/tests/unit/inventory-merchandising.money.test.js new file mode 100644 index 0000000..bae698c --- /dev/null +++ b/tests/unit/inventory-merchandising.money.test.js @@ -0,0 +1,2 @@ +const money=require("../../app/services/pricing/money");const inventory=require("../../app/services/inventory/inventory.service"); +describe("Phase 5 decimal and availability rules",()=>{test("money remains integer-scaled and floors discounts at zero",()=>{expect(money.parse("900.05")).toBe(90005n);expect(money.percentOff("1000.00","12.50")).toBe("125.00");expect(money.subtractFloor("5.00","10.00")).toBe("0.00");});test.each([[{on_hand:10,reserved:1,low_stock_threshold:2},"IN_STOCK"],[{on_hand:3,reserved:1,low_stock_threshold:2},"LOW_STOCK"],[{on_hand:3,reserved:3,low_stock_threshold:2},"OUT_OF_STOCK"]])("availability projection",(balance,status)=>expect(inventory.availabilityStatus(balance)).toBe(status));test("reservation TTL is centralized",()=>expect(inventory.ttlMinutes()).toBeGreaterThan(0));}); diff --git a/tests/unit/inventory-merchandising.permissions.test.js b/tests/unit/inventory-merchandising.permissions.test.js new file mode 100644 index 0000000..fac53b9 --- /dev/null +++ b/tests/unit/inventory-merchandising.permissions.test.js @@ -0,0 +1,2 @@ +const {checkPermission}=require("../../app/middleware/permission.middleware"); +describe("Phase 5 permission boundaries",()=>{test("denies an authenticated customer without inventory permission",()=>{const req={user:{accountType:"CUSTOMER",permissions:[]},method:"POST"},res={status:jest.fn().mockReturnThis(),json:jest.fn()},next=jest.fn();checkPermission("inventory.adjust",{custom:true})(req,res,next);expect(res.status).toHaveBeenCalledWith(403);expect(next).not.toHaveBeenCalled();});test("allows explicit permission",()=>{const req={user:{accountType:"ADMIN",permissions:["pricing.business.manage"]},method:"POST"},res={},next=jest.fn();checkPermission("pricing.business.manage",{custom:true})(req,res,next);expect(next).toHaveBeenCalled();});}); diff --git a/tests/unit/inventory-merchandising.schemas.test.js b/tests/unit/inventory-merchandising.schemas.test.js new file mode 100644 index 0000000..e64ca47 --- /dev/null +++ b/tests/unit/inventory-merchandising.schemas.test.js @@ -0,0 +1,2 @@ +const s=require("../../app/validation/inventoryMerchandising.schemas");const input=body=>({body,params:{},query:{}}); +describe("Phase 5 strict validation",()=>{test("normalizes coupon codes",()=>expect(s.coupon.parse(input({code:"save_10",promotionId:"p",status:"ACTIVE"})).body.code).toBe("SAVE_10"));test("rejects sensitive unknown fields",()=>expect(()=>s.adjustment.parse(input({warehouseId:"w",variantId:"v",quantityDelta:1,reason:"receipt",onHand:999}))).toThrow());test("requires exactly one business audience",()=>expect(()=>s.businessPrice.parse(input({variantId:"v",currency:"LKR",price:"10.00",businessTierId:"t",businessCustomerId:"c"}))).toThrow());test("rejects floating quantities",()=>expect(()=>s.transfer.parse(input({sourceWarehouseId:"a",destinationWarehouseId:"b",variantId:"v",quantity:1.5}))).toThrow());}); From ad9287b80459a125aa6e9e8866654eb69fa44073 Mon Sep 17 00:00:00 2001 From: Sathira Sri Sathara <sathira.nirmal@gmail.com> Date: Thu, 3 Sep 2026 23:45:01 +0530 Subject: [PATCH 16/16] feat: Implement Phase 6 Shopping and Checkout - Introduced authenticated shopping state and atomic checkout process without creating orders or payments. - Reused existing components from previous phases including identities, addresses, catalogues, pricing, and inventory. - Developed cart architecture to support one active cart per user with unique items per variant. - Implemented dynamic cart pricing with various precedence rules for promotions and coupons. - Created a wishlist feature that exposes only visible active products without reserving inventory. - Integrated inventory validation to ensure availability of items before adding to cart. - Developed a checkout architecture that locks the cart, revalidates pricing and shipping, and reserves inventory. - Added shipping zones, methods, and rates with configurable options for international shipping and duty/tax boundaries. - Implemented checkout snapshots to capture immutable checkout details. - Introduced idempotency for checkout requests to prevent duplicate processing. - Added functionality for coupon integration and business checkout validation. - Established permissions for managing shipping zones, methods, and rates. - Created comprehensive unit tests covering various aspects of the shopping and checkout processes. - Added cron job for handling checkout expiry reconciliation. - Created migration script to set up new database tables and constraints for carts, checkout sessions, and shipping. --- Documentation/API_SHOPPING_CHECKOUT.md | 42 ++++++++++ Documentation/CURRENT_BACKEND_STATUS.md | 3 + Documentation/PHASE_6_SHOPPING_CHECKOUT.md | 76 +++++++++++++++++++ app/config/env.config.js | 2 + app/constants/permissions.js | 1 + .../shipping/shipping.controller.js | 4 + app/controllers/shopping/cart.controller.js | 1 + .../shopping/checkout.controller.js | 1 + .../shopping/wishlist.controller.js | 1 + app/models/index.js | 2 + app/models/shipping/shippingMethod.model.js | 1 + app/models/shipping/shippingRate.model.js | 1 + app/models/shipping/shippingZone.model.js | 1 + .../shipping/shippingZoneRegion.model.js | 1 + app/models/shopping/cart.model.js | 1 + app/models/shopping/cartItem.model.js | 1 + app/models/shopping/checkoutItem.model.js | 1 + app/models/shopping/checkoutSession.model.js | 1 + app/models/shopping/wishlistItem.model.js | 1 + app/routes/index.js | 4 + app/routes/shipping/admin.routes.js | 1 + app/routes/shopping.routes.js | 1 + app/services/inventory/inventory.service.js | 18 +++-- app/services/pricing/money.js | 3 +- app/services/shipping/shipping.service.js | 3 + app/services/shopping/cart.service.js | 9 +++ app/services/shopping/checkout.service.js | 6 ++ app/validation/shopping.schemas.js | 1 + cron/checkoutExpiry.cron.js | 1 + cron/index.js | 3 +- ...0260903060000-phase-6-shopping-checkout.js | 3 + tests/unit/shopping.money-validation.test.js | 1 + tests/unit/shopping.permissions.test.js | 1 + 33 files changed, 187 insertions(+), 10 deletions(-) create mode 100644 Documentation/API_SHOPPING_CHECKOUT.md create mode 100644 Documentation/PHASE_6_SHOPPING_CHECKOUT.md create mode 100644 app/controllers/shipping/shipping.controller.js create mode 100644 app/controllers/shopping/cart.controller.js create mode 100644 app/controllers/shopping/checkout.controller.js create mode 100644 app/controllers/shopping/wishlist.controller.js create mode 100644 app/models/shipping/shippingMethod.model.js create mode 100644 app/models/shipping/shippingRate.model.js create mode 100644 app/models/shipping/shippingZone.model.js create mode 100644 app/models/shipping/shippingZoneRegion.model.js create mode 100644 app/models/shopping/cart.model.js create mode 100644 app/models/shopping/cartItem.model.js create mode 100644 app/models/shopping/checkoutItem.model.js create mode 100644 app/models/shopping/checkoutSession.model.js create mode 100644 app/models/shopping/wishlistItem.model.js create mode 100644 app/routes/shipping/admin.routes.js create mode 100644 app/routes/shopping.routes.js create mode 100644 app/services/shipping/shipping.service.js create mode 100644 app/services/shopping/cart.service.js create mode 100644 app/services/shopping/checkout.service.js create mode 100644 app/validation/shopping.schemas.js create mode 100644 cron/checkoutExpiry.cron.js create mode 100644 migrations/20260903060000-phase-6-shopping-checkout.js create mode 100644 tests/unit/shopping.money-validation.test.js create mode 100644 tests/unit/shopping.permissions.test.js diff --git a/Documentation/API_SHOPPING_CHECKOUT.md b/Documentation/API_SHOPPING_CHECKOUT.md new file mode 100644 index 0000000..0850192 --- /dev/null +++ b/Documentation/API_SHOPPING_CHECKOUT.md @@ -0,0 +1,42 @@ +# Shopping and Checkout API + +All endpoints use `/api/v1` and require authentication unless stated otherwise. Ownership is derived exclusively from the authenticated identity; request bodies never accept `userId`. + +## Cart + +- `GET /cart` +- `POST /cart/items` with `variantId`, `quantity` +- `PATCH /cart/items/:itemId` with `quantity`; zero removes the item +- `DELETE /cart/items/:itemId` +- `DELETE /cart` +- `PUT /cart/coupon` and `DELETE /cart/coupon` + +Only one ACTIVE cart exists per user. Adding items does not reserve inventory. Responses revalidate catalogue state, availability, MOQ, business pricing, promotions, coupons, and integer-scaled totals. Unavailable items remain visible with an explanatory status. + +## Wishlist + +- `GET /wishlist` +- `POST /wishlist` with `productId` +- `DELETE /wishlist/:productId` + +Wishlist entries are owner-scoped, contain no quantity, and never reserve stock. + +## Shipping + +- `POST /shipping/quote` with an owned `addressId`; subtotal is read from the authoritative cart. +- Admin CRUD: `/admin/shipping/zones`, `/admin/shipping/methods`, `/admin/shipping/rates`. + +Zones match country, then optional province/district. Rates support schedules, subtotal bands, currency, and configurable free-shipping thresholds. Unsupported destinations return an explicit error. Duty mode is descriptive; tax is zero until authoritative configuration exists. + +## Checkout + +- `POST /checkout` requires `Idempotency-Key` and owned shipping/billing address IDs plus a shipping method ID. +- `GET /checkout/active` +- `GET /checkout/:id` +- `POST /checkout/:id/cancel` + +The server recalculates all prices, shipping, discounts, and availability. Client price/total fields are rejected. Checkout snapshots commerce-critical item/address/shipping data and atomically reserves every item using sorted lock order. The cart becomes `CHECKOUT_LOCKED`. Cancellation or bounded expiry reconciliation releases reservations and restores the cart. Same user/key/payload returns the existing checkout; a changed payload conflicts. + +Business checkout uses the same cart/session and revalidates active approved status, customer/tier pricing, MOQ, and volume tiers. Business credit is not consumed. + +No guest cart, Order, Payment, coupon redemption, tax provider, customs calculator, shipment, or delivery workflow exists in Phase 6. diff --git a/Documentation/CURRENT_BACKEND_STATUS.md b/Documentation/CURRENT_BACKEND_STATUS.md index 647540a..27976be 100644 --- a/Documentation/CURRENT_BACKEND_STATUS.md +++ b/Documentation/CURRENT_BACKEND_STATUS.md @@ -414,3 +414,6 @@ Remaining identity work is operational: validate/deduplicate deployed RBAC data ## Phase 5 Completion Update Date: 2026-09-03. Inventory/reservation foundations, business pricing, promotions/coupons, and localized scheduled banners are implemented behind new permissions and a forward-only migration. Phase 5 adds 13 models, secured administration routes, public availability/banner projections, audit calls, a bounded expiry reconciliation cron, and Phase 6 pricing/reservation service boundaries. Module 05 is 85%, Module 07 is 78%, and Module 13 is 75%. Inventory is 90%, reservations 90%, business pricing 82%, promotions/coupons 78%, and banners 82%. Automated verification: 18 suites and 89 tests passed; syntax passed for 232 JavaScript files. Migration was not executed. Staging must precheck legacy stock/pricing/promotion/banner data, apply the migration, seed permissions/default warehouse, and test genuine MySQL locking plus cron behavior before Phase 6 production use. +## Phase 6 Completion Update + +Date: 2026-09-03. Module 06 is 91%, Module 08 is 84%, and Module 07 is revised to 86%. Authenticated cart is 92%, wishlist 92%, shipping 86%, checkout 88%, and reservation integration 90%. Nine models, a forward-only migration, owner-scoped shopping APIs, permission-protected shipping administration, server-authoritative totals, atomic inventory reservation composition, idempotent checkout creation, and bounded expiry/cancellation release are implemented. Verification passes 20 suites/95 tests and 258 JavaScript syntax checks. The migration was not executed. Before Phase 7, staging must precheck legacy shopping/shipping tables and address/currency compatibility, seed shipping configuration/permissions, apply migrations, and validate real multi-connection InnoDB concurrency plus multi-instance expiry behavior. diff --git a/Documentation/PHASE_6_SHOPPING_CHECKOUT.md b/Documentation/PHASE_6_SHOPPING_CHECKOUT.md new file mode 100644 index 0000000..6f2e46a --- /dev/null +++ b/Documentation/PHASE_6_SHOPPING_CHECKOUT.md @@ -0,0 +1,76 @@ +# ZUMRI Phase 6 Shopping and Checkout + +## Objective +Add authenticated shopping state and an atomic, priced, inventory-reserved checkout handoff without creating orders or payments. + +## Existing Components Reused +Phase 3 identities/addresses/business accounts, Phase 4 catalogue, Phase 5 pricing/inventory/promotions, Phase 1 permissions, Phase 2 audit, Sequelize, Zod, and cron bootstrap. + +## Cart Architecture +One ACTIVE cart per identity; items are unique per variant and owner-scoped. Guest carts were intentionally omitted. Version increments detect state changes and checkout locks mutation. + +## Cart Pricing +Cart items are dynamically requoted with retail/business/volume/promotion/coupon precedence. DECIMAL strings use BigInt-scaled arithmetic. + +## Wishlist +Unique owner/product entries expose only visible active products and never reserve inventory. + +## Inventory Validation +Adding and projecting items checks authoritative aggregate availability. Unavailable items remain explainable. + +## Reservation Integration +Cart does not reserve. Checkout extends the Phase 5 service with external transaction composition and reserves sorted variants all-or-nothing. + +## Shipping Zones +Normalized country/province/district records provide deterministic destination matching, including international zones. + +## Shipping Methods +Configurable methods include delivery estimates and tracking capability. + +## Shipping Rates +Scheduled DECIMAL rates support currency, subtotal bands, and configurable free-shipping thresholds. + +## International Shipping +Only configured zones are eligible; unsupported destinations fail explicitly. + +## Duty/Tax Boundary +Zones declare NONE, ESTIMATED, PAYABLE_ON_DELIVERY, INCLUDED, or UNKNOWN. No customs amount is fabricated. Tax remains zero without authoritative configuration. + +## Checkout Architecture +A user-scoped transaction locks the cart, revalidates catalogue/pricing/shipping, reserves inventory, writes snapshots, and locks the cart. + +## Checkout Snapshots +Immutable checkout items capture product/variant/SKU, price, discount, total, currency, and pricing source. Owned address and shipping selections are copied as JSON. + +## Checkout Totals +Merchandise subtotal minus discounts plus shipping plus configured tax/duty equals grand total. Client totals are prohibited. + +## Idempotency +Unique `(userId, idempotencyKey)` plus SHA-256 request fingerprint returns the same session or rejects changed payloads. + +## Checkout Expiry +A no-overlap minute reconciliation processes 100 sessions, locks each session, releases reservations idempotently, marks EXPIRED, and restores its cart. + +## Coupon Integration +Cart coupons are provisional and revalidated at checkout. Authoritative redemption remains Phase 7. + +## Business Checkout +The shared flow revalidates approved ACTIVE business context, MOQ, customer/tier price, and volume tier. Credit is untouched. + +## Permissions +Shipping zone/method/rate read/manage permissions protect all administration. + +## Audit +Cart, wishlist, checkout, expiry/cancellation, and shipping configuration use stable Phase 2 activity types without full addresses or carts. + +## Database Changes +Forward-only migration `20260903060000` creates nine Phase 6 tables and required uniqueness/status-expiry indexes. + +## Tests +Phase 6 unit tests cover decimal totals, strict client-total rejection, quantity validation, deterministic fingerprints, safe address snapshots, and shipping permission denial. Existing suites remain green. + +## Remaining Known Issues +Real InnoDB concurrent checkout, migration, and multi-instance cron behavior require staging. Shipping weight bands, authoritative tax/duty, and coupon redemption are intentionally deferred. + +## Phase 7 Prerequisites +Run legacy table/address/currency/reservation prechecks, migrate staging, seed zones/methods/rates and permissions, and prove concurrent final-stock and duplicate-idempotency behavior with real MySQL connections. diff --git a/app/config/env.config.js b/app/config/env.config.js index 7302ff2..18eea3d 100644 --- a/app/config/env.config.js +++ b/app/config/env.config.js @@ -45,6 +45,8 @@ const envSchema = z.object({ DOCUMENT_QUEUE_CONCURRENCY: z.coerce.number().int().positive().default(2), NOTIFICATION_RETENTION_DAYS: z.coerce.number().int().positive().default(90), INVENTORY_RESERVATION_TTL_MINUTES: z.coerce.number().int().positive().default(15), + CHECKOUT_TTL_MINUTES: z.coerce.number().int().positive().default(15), + CART_MAX_ITEM_QUANTITY: z.coerce.number().int().positive().default(100), LOG_RETENTION_DAYS: z.coerce.number().int().positive().default(30), DOCS_USER: z.string().optional(), DOCS_PASS: z.string().optional(), GOOGLE_CLIENT_ID: z.string().optional(), APPLE_CLIENT_ID: z.string().optional(), diff --git a/app/constants/permissions.js b/app/constants/permissions.js index c70debf..86d9753 100644 --- a/app/constants/permissions.js +++ b/app/constants/permissions.js @@ -24,4 +24,5 @@ module.exports = { CATALOGUE_CATEGORIES_MANAGE:"catalogue.categories.manage",CATALOGUE_BRANDS_MANAGE:"catalogue.brands.manage",CATALOGUE_COLLECTIONS_MANAGE:"catalogue.collections.manage",CATALOGUE_SIZE_GUIDES_MANAGE:"catalogue.size-guides.manage",CATALOGUE_REVIEWS_READ:"catalogue.reviews.read",CATALOGUE_REVIEWS_MODERATE:"catalogue.reviews.moderate", INVENTORY_READ:"inventory.read",INVENTORY_ADJUST:"inventory.adjust",INVENTORY_TRANSFER:"inventory.transfer",INVENTORY_RESERVATIONS_READ:"inventory.reservations.read",INVENTORY_WAREHOUSES_MANAGE:"inventory.warehouses.manage", BUSINESS_PRICING_READ:"pricing.business.read",BUSINESS_PRICING_MANAGE:"pricing.business.manage",PROMOTIONS_READ:"promotions.read",PROMOTIONS_MANAGE:"promotions.manage",BANNERS_MANAGE:"merchandising.banners.manage", + SHIPPING_ZONES_READ:"shipping.zones.read",SHIPPING_ZONES_MANAGE:"shipping.zones.manage",SHIPPING_METHODS_READ:"shipping.methods.read",SHIPPING_METHODS_MANAGE:"shipping.methods.manage",SHIPPING_RATES_READ:"shipping.rates.read",SHIPPING_RATES_MANAGE:"shipping.rates.manage", }; diff --git a/app/controllers/shipping/shipping.controller.js b/app/controllers/shipping/shipping.controller.js new file mode 100644 index 0000000..b006738 --- /dev/null +++ b/app/controllers/shipping/shipping.controller.js @@ -0,0 +1,4 @@ +const crypto=require("crypto"),db=require("../../models"),service=require("../../services/shipping/shipping.service"),cart=require("../../services/shopping/cart.service"),{logActivity}=require("../../services/activity.service");const audit=(req,type,id)=>logActivity({user:req.user,type,module:"Shipping",targetId:id,requestId:req.id});exports.quote=async(req,res,next)=>{try{const address=await db.Address.findOne({where:{id:req.body.addressId,user_id:req.user.id}});if(!address)throw Object.assign(new Error("Address not found"),{status:404,code:"NOT_FOUND"});const projection=await cart.project(req.user.id);res.json({success:true,data:await service.getAvailableMethods({address,subtotal:projection.merchandiseTotal,currency:projection.currency})});}catch(e){next(e);}};const list=model=>async(req,res,next)=>{try{res.json({success:true,data:await model.findAll()});}catch(e){next(e);}};exports.zones=list(db.ShippingZone);exports.methods=list(db.ShippingMethod);exports.rates=list(db.ShippingRate);exports.createZone=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{row=await db.ShippingZone.create({id:crypto.randomUUID(),code:req.body.code.toUpperCase(),name:req.body.name,status:req.body.status,duty_mode:req.body.dutyMode},{transaction:t});await db.ShippingZoneRegion.bulkCreate(req.body.regions.map(x=>({id:crypto.randomUUID(),shipping_zone_id:row.id,country_code:x.countryCode.toUpperCase(),province:x.province,district:x.district})),{transaction:t});});await audit(req,"SHIPPING_ZONE_CREATED",row.id);res.status(201).json({success:true,data:row});}catch(e){next(e);}};exports.createMethod=async(req,res,next)=>{try{const b=req.body,row=await db.ShippingMethod.create({id:crypto.randomUUID(),code:b.code.toUpperCase(),name:b.name,description:b.description,status:b.status,estimated_min_days:b.estimatedMinDays,estimated_max_days:b.estimatedMaxDays,tracking_supported:b.trackingSupported});await audit(req,"SHIPPING_METHOD_CREATED",row.id);res.status(201).json({success:true,data:row});}catch(e){next(e);}};exports.createRate=async(req,res,next)=>{try{const b=req.body,row=await db.ShippingRate.create({id:crypto.randomUUID(),shipping_zone_id:b.shippingZoneId,shipping_method_id:b.shippingMethodId,currency:b.currency.toUpperCase(),base_amount:b.baseAmount,free_shipping_threshold:b.freeShippingThreshold,minimum_subtotal:b.minimumSubtotal,maximum_subtotal:b.maximumSubtotal,status:b.status,starts_at:b.startsAt,ends_at:b.endsAt});await audit(req,"SHIPPING_RATE_CREATED",row.id);res.status(201).json({success:true,data:row});}catch(e){next(e);}}; +exports.updateZone=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{row=await db.ShippingZone.findByPk(req.params.id,{transaction:t,lock:t.LOCK.UPDATE});if(!row)throw Object.assign(new Error("Zone not found"),{status:404,code:"NOT_FOUND"});await row.update({code:req.body.code.toUpperCase(),name:req.body.name,status:req.body.status,duty_mode:req.body.dutyMode},{transaction:t});await db.ShippingZoneRegion.destroy({where:{shipping_zone_id:row.id},transaction:t});await db.ShippingZoneRegion.bulkCreate(req.body.regions.map(x=>({id:crypto.randomUUID(),shipping_zone_id:row.id,country_code:x.countryCode.toUpperCase(),province:x.province,district:x.district})),{transaction:t});});await audit(req,"SHIPPING_ZONE_UPDATED",row.id);res.json({success:true,data:row});}catch(e){next(e);}}; +exports.updateMethod=async(req,res,next)=>{try{const b=req.body,row=await db.ShippingMethod.findByPk(req.params.id);if(!row)throw Object.assign(new Error("Method not found"),{status:404,code:"NOT_FOUND"});await row.update({code:b.code.toUpperCase(),name:b.name,description:b.description,status:b.status,estimated_min_days:b.estimatedMinDays,estimated_max_days:b.estimatedMaxDays,tracking_supported:b.trackingSupported});await audit(req,"SHIPPING_METHOD_UPDATED",row.id);res.json({success:true,data:row});}catch(e){next(e);}}; +exports.updateRate=async(req,res,next)=>{try{const b=req.body,row=await db.ShippingRate.findByPk(req.params.id);if(!row)throw Object.assign(new Error("Rate not found"),{status:404,code:"NOT_FOUND"});await row.update({shipping_zone_id:b.shippingZoneId,shipping_method_id:b.shippingMethodId,currency:b.currency.toUpperCase(),base_amount:b.baseAmount,free_shipping_threshold:b.freeShippingThreshold,minimum_subtotal:b.minimumSubtotal,maximum_subtotal:b.maximumSubtotal,status:b.status,starts_at:b.startsAt,ends_at:b.endsAt});await audit(req,"SHIPPING_RATE_UPDATED",row.id);res.json({success:true,data:row});}catch(e){next(e);}}; diff --git a/app/controllers/shopping/cart.controller.js b/app/controllers/shopping/cart.controller.js new file mode 100644 index 0000000..98b0062 --- /dev/null +++ b/app/controllers/shopping/cart.controller.js @@ -0,0 +1 @@ +const db=require("../../models"),service=require("../../services/shopping/cart.service"),{logActivity}=require("../../services/activity.service");const audit=(req,type,id)=>logActivity({user:req.user,type,module:"Shopping",description:type,targetType:"CART",targetId:id,requestId:req.id});exports.get=async(req,res,next)=>{try{res.json({success:true,data:await service.project(req.user.id)});}catch(e){next(e);}};exports.add=async(req,res,next)=>{try{const x=await service.add(req.user.id,req.body.variantId,req.body.quantity);await audit(req,"CART_ITEM_ADDED",x.id);res.status(201).json({success:true,data:await service.project(req.user.id)});}catch(e){next(e);}};exports.update=async(req,res,next)=>{try{const x=await service.mutate(req.user.id,req.params.itemId,req.body.quantity);await audit(req,"CART_ITEM_UPDATED",x.id);res.json({success:true,data:await service.project(req.user.id)});}catch(e){next(e);}};exports.remove=async(req,res,next)=>{try{await service.mutate(req.user.id,req.params.itemId,0);await audit(req,"CART_ITEM_REMOVED",req.params.itemId);res.status(204).end();}catch(e){next(e);}};exports.clear=async(req,res,next)=>{try{const cart=await service.activeCart(req.user.id);await db.CartItem.destroy({where:{cart_id:cart.id}});await cart.increment("version");await audit(req,"CART_CLEARED",cart.id);res.status(204).end();}catch(e){next(e);}};exports.coupon=async(req,res,next)=>{try{const cart=await service.activeCart(req.user.id);await cart.update({coupon_code:req.body.code});res.json({success:true,data:await service.project(req.user.id)});}catch(e){next(e);}};exports.removeCoupon=async(req,res,next)=>{try{const cart=await service.activeCart(req.user.id);await cart.update({coupon_code:null});res.status(204).end();}catch(e){next(e);}}; diff --git a/app/controllers/shopping/checkout.controller.js b/app/controllers/shopping/checkout.controller.js new file mode 100644 index 0000000..bd743cb --- /dev/null +++ b/app/controllers/shopping/checkout.controller.js @@ -0,0 +1 @@ +const service=require("../../services/shopping/checkout.service"),{logActivity}=require("../../services/activity.service");exports.create=async(req,res,next)=>{try{const result=await service.create({userId:req.user.id,...req.body,idempotencyKey:req.get("Idempotency-Key"),requestId:req.id});if(!result.idempotent)await logActivity({user:req.user,type:"CHECKOUT_CREATED",module:"Checkout",targetId:result.checkout.id,requestId:req.id});res.status(result.idempotent?200:201).json({success:true,data:result.checkout});}catch(e){next(e);}};exports.get=async(req,res,next)=>{try{res.json({success:true,data:await service.getOwned(req.params.id,req.user.id)});}catch(e){next(e);}};exports.active=async(req,res,next)=>{try{const db=require("../../models"),row=await db.CheckoutSession.findOne({where:{user_id:req.user.id,status:["PENDING","READY"]},include:[{model:db.CheckoutItem,as:"items"}],order:[["createdAt","DESC"]]});res.json({success:true,data:row});}catch(e){next(e);}};exports.cancel=async(req,res,next)=>{try{const row=await service.close({id:req.params.id,userId:req.user.id,status:"CANCELLED",requestId:req.id});await logActivity({user:req.user,type:"CHECKOUT_CANCELLED",module:"Checkout",targetId:row.id,requestId:req.id});res.json({success:true,data:row});}catch(e){next(e);}}; diff --git a/app/controllers/shopping/wishlist.controller.js b/app/controllers/shopping/wishlist.controller.js new file mode 100644 index 0000000..18050f4 --- /dev/null +++ b/app/controllers/shopping/wishlist.controller.js @@ -0,0 +1 @@ +const crypto=require("crypto"),db=require("../../models"),{logActivity}=require("../../services/activity.service");exports.list=async(req,res,next)=>{try{res.json({success:true,data:await db.WishlistItem.findAll({where:{user_id:req.user.id},include:[{model:db.Product,as:"product",where:{status:"ACTIVE",visibility:"PUBLIC"}}]})});}catch(e){next(e);}};exports.add=async(req,res,next)=>{try{const product=await db.Product.findOne({where:{id:req.body.productId,status:"ACTIVE",visibility:"PUBLIC"}});if(!product)throw Object.assign(new Error("Product not found"),{status:404,code:"NOT_FOUND"});const[row,created]=await db.WishlistItem.findOrCreate({where:{user_id:req.user.id,product_id:product.id},defaults:{id:crypto.randomUUID()}});if(created)await logActivity({user:req.user,type:"WISHLIST_ITEM_ADDED",module:"Shopping",targetId:row.id});res.status(created?201:200).json({success:true,data:row});}catch(e){next(e);}};exports.remove=async(req,res,next)=>{try{const n=await db.WishlistItem.destroy({where:{user_id:req.user.id,product_id:req.params.productId}});if(!n)throw Object.assign(new Error("Wishlist item not found"),{status:404,code:"NOT_FOUND"});await logActivity({user:req.user,type:"WISHLIST_ITEM_REMOVED",module:"Shopping",targetId:req.params.productId});res.status(204).end();}catch(e){next(e);}}; diff --git a/app/models/index.js b/app/models/index.js index 780c9d3..c0a012b 100644 --- a/app/models/index.js +++ b/app/models/index.js @@ -93,6 +93,8 @@ db.ProductReview = require("./catalogue/productReview.model")(sequelize, DataTyp db.Warehouse=require("./inventory/warehouse.model")(sequelize,DataTypes);db.InventoryBalance=require("./inventory/inventoryBalance.model")(sequelize,DataTypes);db.InventoryTransaction=require("./inventory/inventoryTransaction.model")(sequelize,DataTypes);db.InventoryReservation=require("./inventory/inventoryReservation.model")(sequelize,DataTypes);db.InventoryTransfer=require("./inventory/inventoryTransfer.model")(sequelize,DataTypes); db.BusinessTier=require("./pricing/businessTier.model")(sequelize,DataTypes);db.VariantBusinessPrice=require("./pricing/variantBusinessPrice.model")(sequelize,DataTypes);db.BusinessPriceTier=require("./pricing/businessPriceTier.model")(sequelize,DataTypes); db.Promotion=require("./merchandising/promotion.model")(sequelize,DataTypes);db.PromotionTarget=require("./merchandising/promotionTarget.model")(sequelize,DataTypes);db.Coupon=require("./merchandising/coupon.model")(sequelize,DataTypes);db.Banner=require("./merchandising/banner.model")(sequelize,DataTypes);db.BannerTranslation=require("./merchandising/bannerTranslation.model")(sequelize,DataTypes); +db.Cart=require("./shopping/cart.model")(sequelize,DataTypes);db.CartItem=require("./shopping/cartItem.model")(sequelize,DataTypes);db.WishlistItem=require("./shopping/wishlistItem.model")(sequelize,DataTypes);db.CheckoutSession=require("./shopping/checkoutSession.model")(sequelize,DataTypes);db.CheckoutItem=require("./shopping/checkoutItem.model")(sequelize,DataTypes); +db.ShippingZone=require("./shipping/shippingZone.model")(sequelize,DataTypes);db.ShippingZoneRegion=require("./shipping/shippingZoneRegion.model")(sequelize,DataTypes);db.ShippingMethod=require("./shipping/shippingMethod.model")(sequelize,DataTypes);db.ShippingRate=require("./shipping/shippingRate.model")(sequelize,DataTypes); /* Associations */ Object.keys(db).forEach(model => { diff --git a/app/models/shipping/shippingMethod.model.js b/app/models/shipping/shippingMethod.model.js new file mode 100644 index 0000000..5702024 --- /dev/null +++ b/app/models/shipping/shippingMethod.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("ShippingMethod",{id:{type:D.STRING,primaryKey:true},code:{type:D.STRING(60),allowNull:false,unique:true},name:{type:D.STRING(150),allowNull:false},description:D.TEXT,status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"},estimated_min_days:{type:D.INTEGER,allowNull:false},estimated_max_days:{type:D.INTEGER,allowNull:false},tracking_supported:{type:D.BOOLEAN,allowNull:false,defaultValue:false}},{tableName:"shipping_methods",timestamps:true}); diff --git a/app/models/shipping/shippingRate.model.js b/app/models/shipping/shippingRate.model.js new file mode 100644 index 0000000..01546ac --- /dev/null +++ b/app/models/shipping/shippingRate.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("ShippingRate",{id:{type:D.STRING,primaryKey:true},shipping_zone_id:{type:D.STRING,allowNull:false},shipping_method_id:{type:D.STRING,allowNull:false},currency:{type:D.STRING(3),allowNull:false},base_amount:{type:D.DECIMAL(15,2),allowNull:false},free_shipping_threshold:D.DECIMAL(15,2),minimum_subtotal:D.DECIMAL(15,2),maximum_subtotal:D.DECIMAL(15,2),status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"},starts_at:D.DATE,ends_at:D.DATE},{tableName:"shipping_rates",timestamps:true});M.associate=db=>M.belongsTo(db.ShippingMethod,{foreignKey:"shipping_method_id",as:"method"});return M;}; diff --git a/app/models/shipping/shippingZone.model.js b/app/models/shipping/shippingZone.model.js new file mode 100644 index 0000000..6abbf66 --- /dev/null +++ b/app/models/shipping/shippingZone.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("ShippingZone",{id:{type:D.STRING,primaryKey:true},code:{type:D.STRING(60),allowNull:false,unique:true},name:{type:D.STRING(150),allowNull:false},status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"},duty_mode:{type:D.ENUM("NONE","ESTIMATED","PAYABLE_ON_DELIVERY","INCLUDED","UNKNOWN"),allowNull:false,defaultValue:"UNKNOWN"}},{tableName:"shipping_zones",timestamps:true}); diff --git a/app/models/shipping/shippingZoneRegion.model.js b/app/models/shipping/shippingZoneRegion.model.js new file mode 100644 index 0000000..9b8dac5 --- /dev/null +++ b/app/models/shipping/shippingZoneRegion.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("ShippingZoneRegion",{id:{type:D.STRING,primaryKey:true},shipping_zone_id:{type:D.STRING,allowNull:false},country_code:{type:D.STRING(2),allowNull:false},province:D.STRING(100),district:D.STRING(100)},{tableName:"shipping_zone_regions",timestamps:true}); diff --git a/app/models/shopping/cart.model.js b/app/models/shopping/cart.model.js new file mode 100644 index 0000000..84f4d09 --- /dev/null +++ b/app/models/shopping/cart.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("Cart",{id:{type:D.STRING,primaryKey:true},user_id:{type:D.STRING,allowNull:false},status:{type:D.ENUM("ACTIVE","CHECKOUT_LOCKED","CONVERTED","ABANDONED"),allowNull:false,defaultValue:"ACTIVE"},currency:{type:D.STRING(3),allowNull:false,defaultValue:"LKR"},coupon_code:D.STRING(50),version:{type:D.INTEGER,allowNull:false,defaultValue:0},expires_at:D.DATE},{tableName:"carts",timestamps:true,indexes:[{unique:true,fields:["user_id","status"]}]});M.associate=db=>{M.hasMany(db.CartItem,{foreignKey:"cart_id",as:"items"});M.belongsTo(db.User,{foreignKey:"user_id",as:"owner"});};return M;}; diff --git a/app/models/shopping/cartItem.model.js b/app/models/shopping/cartItem.model.js new file mode 100644 index 0000000..60cf34b --- /dev/null +++ b/app/models/shopping/cartItem.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("CartItem",{id:{type:D.STRING,primaryKey:true},cart_id:{type:D.STRING,allowNull:false},variant_id:{type:D.STRING,allowNull:false},quantity:{type:D.INTEGER,allowNull:false}},{tableName:"cart_items",timestamps:true,indexes:[{unique:true,fields:["cart_id","variant_id"]}]});M.associate=db=>M.belongsTo(db.ProductVariant,{foreignKey:"variant_id",as:"variant"});return M;}; diff --git a/app/models/shopping/checkoutItem.model.js b/app/models/shopping/checkoutItem.model.js new file mode 100644 index 0000000..6f1f6a1 --- /dev/null +++ b/app/models/shopping/checkoutItem.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("CheckoutItem",{id:{type:D.STRING,primaryKey:true},checkout_session_id:{type:D.STRING,allowNull:false},product_id:{type:D.STRING,allowNull:false},variant_id:{type:D.STRING,allowNull:false},reservation_key:{type:D.STRING(160),allowNull:false},sku:{type:D.STRING(100),allowNull:false},product_name:{type:D.STRING(255),allowNull:false},variant_description:D.STRING(255),quantity:{type:D.INTEGER,allowNull:false},unit_price:{type:D.DECIMAL(15,2),allowNull:false},discount_amount:{type:D.DECIMAL(15,2),allowNull:false},line_total:{type:D.DECIMAL(15,2),allowNull:false},currency:{type:D.STRING(3),allowNull:false},metadata:D.JSON},{tableName:"checkout_items",timestamps:true}); diff --git a/app/models/shopping/checkoutSession.model.js b/app/models/shopping/checkoutSession.model.js new file mode 100644 index 0000000..6fea8c0 --- /dev/null +++ b/app/models/shopping/checkoutSession.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("CheckoutSession",{id:{type:D.STRING,primaryKey:true},checkout_number:{type:D.STRING(80),allowNull:false,unique:true},user_id:{type:D.STRING,allowNull:false},business_customer_id:D.STRING,cart_id:{type:D.STRING,allowNull:false},cart_version:{type:D.INTEGER,allowNull:false},status:{type:D.ENUM("PENDING","READY","EXPIRED","CANCELLED","CONVERTED"),allowNull:false,defaultValue:"PENDING"},currency:{type:D.STRING(3),allowNull:false},shipping_address_snapshot:{type:D.JSON,allowNull:false},billing_address_snapshot:{type:D.JSON,allowNull:false},shipping_method_id:{type:D.STRING,allowNull:false},shipping_snapshot:{type:D.JSON,allowNull:false},subtotal:{type:D.DECIMAL(15,2),allowNull:false},discount_total:{type:D.DECIMAL(15,2),allowNull:false},shipping_amount:{type:D.DECIMAL(15,2),allowNull:false},tax_amount:{type:D.DECIMAL(15,2),allowNull:false},duty_amount:D.DECIMAL(15,2),grand_total:{type:D.DECIMAL(15,2),allowNull:false},coupon_code:D.STRING(50),idempotency_key:{type:D.STRING(160),allowNull:false},request_fingerprint:{type:D.STRING(64),allowNull:false},expires_at:{type:D.DATE,allowNull:false}},{tableName:"checkout_sessions",timestamps:true,indexes:[{unique:true,fields:["user_id","idempotency_key"]}]});M.associate=db=>M.hasMany(db.CheckoutItem,{foreignKey:"checkout_session_id",as:"items"});return M;}; diff --git a/app/models/shopping/wishlistItem.model.js b/app/models/shopping/wishlistItem.model.js new file mode 100644 index 0000000..4ba6029 --- /dev/null +++ b/app/models/shopping/wishlistItem.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("WishlistItem",{id:{type:D.STRING,primaryKey:true},user_id:{type:D.STRING,allowNull:false},product_id:{type:D.STRING,allowNull:false}},{tableName:"wishlist_items",timestamps:true,indexes:[{unique:true,fields:["user_id","product_id"]}]});M.associate=db=>M.belongsTo(db.Product,{foreignKey:"product_id",as:"product"});return M;}; diff --git a/app/routes/index.js b/app/routes/index.js index d84cc7f..adf9da8 100644 --- a/app/routes/index.js +++ b/app/routes/index.js @@ -32,6 +32,8 @@ const inventoryAdminRoutes = require("./inventory/admin.routes"); const merchandisingAdminRoutes = require("./merchandising/admin.routes"); const pricingAdminRoutes = require("./pricing/admin.routes"); const merchandisingPublicRoutes = require("./merchandising/public.routes"); +const shoppingRoutes = require("./shopping.routes"); +const shippingAdminRoutes = require("./shipping/admin.routes"); const router = express.Router(); @@ -56,5 +58,7 @@ router.use("/admin", inventoryAdminRoutes); router.use("/admin", merchandisingAdminRoutes); router.use("/admin", pricingAdminRoutes); router.use("/", merchandisingPublicRoutes); +router.use("/", shoppingRoutes); +router.use("/admin", shippingAdminRoutes); module.exports = router; diff --git a/app/routes/shipping/admin.routes.js b/app/routes/shipping/admin.routes.js new file mode 100644 index 0000000..b804a48 --- /dev/null +++ b/app/routes/shipping/admin.routes.js @@ -0,0 +1 @@ +const r=require("express").Router(),c=require("../../controllers/shipping/shipping.controller"),{authenticate}=require("../../middleware/auth.middleware"),{checkPermission}=require("../../middleware/permission.middleware"),validate=require("../../middleware/validate.middleware"),s=require("../../validation/shopping.schemas");r.use(authenticate);r.get("/shipping/zones",checkPermission("shipping.zones.read",{custom:true}),c.zones);r.post("/shipping/zones",checkPermission("shipping.zones.manage",{custom:true}),validate(s.zone),c.createZone);r.patch("/shipping/zones/:id",checkPermission("shipping.zones.manage",{custom:true}),validate(s.zone),c.updateZone);r.get("/shipping/methods",checkPermission("shipping.methods.read",{custom:true}),c.methods);r.post("/shipping/methods",checkPermission("shipping.methods.manage",{custom:true}),validate(s.method),c.createMethod);r.patch("/shipping/methods/:id",checkPermission("shipping.methods.manage",{custom:true}),validate(s.method),c.updateMethod);r.get("/shipping/rates",checkPermission("shipping.rates.read",{custom:true}),c.rates);r.post("/shipping/rates",checkPermission("shipping.rates.manage",{custom:true}),validate(s.rate),c.createRate);r.patch("/shipping/rates/:id",checkPermission("shipping.rates.manage",{custom:true}),validate(s.rate),c.updateRate);module.exports=r; diff --git a/app/routes/shopping.routes.js b/app/routes/shopping.routes.js new file mode 100644 index 0000000..42518b2 --- /dev/null +++ b/app/routes/shopping.routes.js @@ -0,0 +1 @@ +const r=require("express").Router(),cart=require("../controllers/shopping/cart.controller"),wish=require("../controllers/shopping/wishlist.controller"),checkout=require("../controllers/shopping/checkout.controller"),shipping=require("../controllers/shipping/shipping.controller"),{authenticate}=require("../middleware/auth.middleware"),validate=require("../middleware/validate.middleware"),s=require("../validation/shopping.schemas");r.use(authenticate);r.get("/cart",cart.get);r.post("/cart/items",validate(s.cartItem),cart.add);r.patch("/cart/items/:itemId",validate(s.cartQuantity),cart.update);r.delete("/cart/items/:itemId",cart.remove);r.delete("/cart",cart.clear);r.put("/cart/coupon",validate(s.coupon),cart.coupon);r.delete("/cart/coupon",cart.removeCoupon);r.get("/wishlist",wish.list);r.post("/wishlist",validate(s.wishlist),wish.add);r.delete("/wishlist/:productId",wish.remove);r.post("/shipping/quote",validate(s.shippingQuote),shipping.quote);r.post("/checkout",validate(s.checkout),checkout.create);r.get("/checkout/active",checkout.active);r.get("/checkout/:id",checkout.get);r.post("/checkout/:id/cancel",checkout.cancel);module.exports=r; diff --git a/app/services/inventory/inventory.service.js b/app/services/inventory/inventory.service.js index 614b82c..98b959e 100644 --- a/app/services/inventory/inventory.service.js +++ b/app/services/inventory/inventory.service.js @@ -36,9 +36,9 @@ async function selectWarehouse(variantId, quantity, transaction) { throw fault("Insufficient available stock", "INSUFFICIENT_STOCK", 409); } -async function reserveStock({ reservationKey, warehouseId, variantId, quantity, referenceType, referenceId, userId, expiresAt, requestId }) { +async function reserveStock({ reservationKey, warehouseId, variantId, quantity, referenceType, referenceId, userId, expiresAt, requestId, transaction: externalTransaction }) { assertQuantity(quantity); if (!reservationKey) throw fault("reservationKey is required", "INVALID_RESERVATION"); - return db.sequelize.transaction(async transaction => { + const execute = async transaction => { const existing = await db.InventoryReservation.findOne({ where: { reservation_key: reservationKey }, transaction, lock: transaction.LOCK.UPDATE }); if (existing) return { reservation: existing, idempotent: true }; const variant = await db.ProductVariant.findOne({ where: { id: variantId, status: "ACTIVE" }, transaction }); @@ -55,11 +55,12 @@ async function reserveStock({ reservationKey, warehouseId, variantId, quantity, const reservation = await db.InventoryReservation.create({ id: crypto.randomUUID(), reservation_key: reservationKey, warehouse_id: selected.warehouse.id, variant_id: variantId, quantity, reference_type: referenceType, reference_id: referenceId, user_id: userId, expires_at: expiresAt || new Date(Date.now() + ttlMinutes() * 60000) }, { transaction }); await ledger({ event_id: `reserve:${reservationKey}`, warehouse_id: selected.warehouse.id, variant_id: variantId, type: "RESERVATION", reserved_delta: quantity, reference_type: "INVENTORY_RESERVATION", reference_id: reservation.id, request_id: requestId }, transaction); return { reservation, idempotent: false }; - }); + }; + return externalTransaction ? execute(externalTransaction) : db.sequelize.transaction(execute); } -async function finishReservation(reservationKey, status, requestId) { - return db.sequelize.transaction(async transaction => { +async function finishReservation(reservationKey, status, requestId, externalTransaction) { + const execute = async transaction => { const reservation = await db.InventoryReservation.findOne({ where: { reservation_key: reservationKey }, transaction, lock: transaction.LOCK.UPDATE }); if (!reservation) throw fault("Reservation not found", "RESERVATION_NOT_FOUND", 404); if (reservation.status === status) return { reservation, idempotent: true }; @@ -72,11 +73,12 @@ async function finishReservation(reservationKey, status, requestId) { await reservation.update({ status, released_at: consume ? null : new Date(), consumed_at: consume ? new Date() : null }, { transaction }); await ledger({ event_id: `${status.toLowerCase()}:${reservationKey}`, warehouse_id: reservation.warehouse_id, variant_id: reservation.variant_id, type: consume ? "RESERVATION_CONSUME" : "RESERVATION_RELEASE", quantity_delta: consume ? -reservation.quantity : 0, reserved_delta: -reservation.quantity, reference_type: "INVENTORY_RESERVATION", reference_id: reservation.id, request_id: requestId }, transaction); return { reservation, idempotent: false }; - }); + }; + return externalTransaction ? execute(externalTransaction) : db.sequelize.transaction(execute); } -const releaseReservation = args => finishReservation(args.reservationKey, args.expired ? "EXPIRED" : "RELEASED", args.requestId); -const consumeReservation = args => finishReservation(args.reservationKey, "CONSUMED", args.requestId); +const releaseReservation = args => finishReservation(args.reservationKey, args.expired ? "EXPIRED" : "RELEASED", args.requestId, args.transaction); +const consumeReservation = args => finishReservation(args.reservationKey, "CONSUMED", args.requestId, args.transaction); async function transferStock({ eventId, sourceWarehouseId, destinationWarehouseId, variantId, quantity, actorUserId, requestId }) { assertQuantity(quantity); if (sourceWarehouseId === destinationWarehouseId) throw fault("Warehouses must differ", "INVALID_TRANSFER"); diff --git a/app/services/pricing/money.js b/app/services/pricing/money.js index 32f2040..b3e5db9 100644 --- a/app/services/pricing/money.js +++ b/app/services/pricing/money.js @@ -2,4 +2,5 @@ const parse = value => { const match = String(value).match(/^(\d+)(?:\.(\d{1,2}) const format = cents => `${cents / 100n}.${String(cents % 100n).padStart(2, "0")}`; const subtractFloor = (a, b) => format(parse(a) > parse(b) ? parse(a) - parse(b) : 0n); const percentOff = (amount, percent) => { const [whole,fraction=""] = String(percent).split("."); const basis = BigInt(whole) * 100n + BigInt(fraction.padEnd(2,"0")); return format(parse(amount) * basis / 10000n); }; -module.exports = { parse, format, subtractFloor, percentOff }; +const add=(...values)=>format(values.reduce((sum,value)=>sum+parse(value),0n));const multiply=(amount,quantity)=>format(parse(amount)*BigInt(quantity)); +module.exports = { parse, format, subtractFloor, percentOff, add, multiply }; diff --git a/app/services/shipping/shipping.service.js b/app/services/shipping/shipping.service.js new file mode 100644 index 0000000..75f1670 --- /dev/null +++ b/app/services/shipping/shipping.service.js @@ -0,0 +1,3 @@ +const {Op}=require("sequelize"),db=require("../../models"),money=require("../pricing/money");const fault=(m,c,s=400)=>Object.assign(new Error(m),{code:c,status:s}); +async function getAvailableMethods({address,subtotal,currency="LKR",at=new Date()}){const regions=await db.ShippingZoneRegion.findAll({where:{country_code:String(address.country_code).toUpperCase(),[Op.and]:[{[Op.or]:[{province:null},{province:address.province}]},{[Op.or]:[{district:null},{district:address.district}]}]},order:[["province","DESC"],["district","DESC"]]});if(!regions.length)throw fault("Destination is not supported","UNSUPPORTED_DESTINATION",409);const zone=await db.ShippingZone.findOne({where:{id:regions[0].shipping_zone_id,status:"ACTIVE"}});if(!zone)throw fault("Destination is not supported","UNSUPPORTED_DESTINATION",409);const rates=await db.ShippingRate.findAll({where:{shipping_zone_id:zone.id,currency,status:"ACTIVE",[Op.and]:[{[Op.or]:[{starts_at:null},{starts_at:{[Op.lte]:at}}]},{[Op.or]:[{ends_at:null},{ends_at:{[Op.gt]:at}}]}]},include:[{model:db.ShippingMethod,as:"method",where:{status:"ACTIVE"}}]});return rates.filter(r=>(!r.minimum_subtotal||money.parse(subtotal)>=money.parse(r.minimum_subtotal))&&(!r.maximum_subtotal||money.parse(subtotal)<=money.parse(r.maximum_subtotal))).map(r=>{const free=Boolean(r.free_shipping_threshold&&money.parse(subtotal)>=money.parse(r.free_shipping_threshold));return {shippingRateId:r.id,methodId:r.method.id,code:r.method.code,name:r.method.name,amount:free?"0.00":String(r.base_amount),freeShippingApplied:free,estimatedMinDays:r.method.estimated_min_days,estimatedMaxDays:r.method.estimated_max_days,zone:{code:zone.code,name:zone.name},dutyMode:zone.duty_mode};});} +module.exports={getAvailableMethods}; diff --git a/app/services/shopping/cart.service.js b/app/services/shopping/cart.service.js new file mode 100644 index 0000000..bc068b5 --- /dev/null +++ b/app/services/shopping/cart.service.js @@ -0,0 +1,9 @@ +const crypto=require("crypto"),db=require("../../models"),pricing=require("../pricing/pricing.service"),money=require("../pricing/money"),inventory=require("../inventory/inventory.service");const fault=(m,c,s=400)=>Object.assign(new Error(m),{code:c,status:s}); +async function context(userId){return db.BusinessCustomer.findOne({where:{user_id:userId}});} +async function activeCart(userId,{transaction,lock}={}){let cart=await db.Cart.findOne({where:{user_id:userId,status:"ACTIVE"},transaction,lock:lock&&transaction.LOCK.UPDATE});if(!cart)cart=await db.Cart.create({id:crypto.randomUUID(),user_id:userId,status:"ACTIVE",currency:"LKR"},{transaction});return cart;} +async function validateVariant(variantId){const variant=await db.ProductVariant.findOne({where:{id:variantId,status:"ACTIVE"},include:[{model:db.Product,as:"product",where:{status:"ACTIVE",visibility:"PUBLIC"}},{model:db.ProductOptionValue,as:"optionValues"}]});if(!variant)throw fault("Product variant is unavailable","PRODUCT_UNAVAILABLE",409);return variant;} +async function availability(variantId,quantity){const balances=await db.InventoryBalance.findAll({where:{variant_id:variantId}}),available=balances.reduce((n,x)=>n+Number(x.on_hand)-Number(x.reserved),0),threshold=balances.reduce((n,x)=>n+Number(x.low_stock_threshold),0);return {status:available<quantity?(available<=0?"OUT_OF_STOCK":"INSUFFICIENT_QUANTITY"):(available<=threshold?"LOW_STOCK":"AVAILABLE"),available:available>=quantity};} +async function project(userId){const cart=await activeCart(userId),items=await db.CartItem.findAll({where:{cart_id:cart.id},order:[["createdAt","ASC"]]}),businessCustomer=await context(userId);let subtotal="0.00",merchandiseTotal="0.00";const projected=[];for(const item of items){let variant,state,quote;try{variant=await validateVariant(item.variant_id);quote=await pricing.quoteVariant({variantId:item.variant_id,businessCustomer,quantity:item.quantity,couponCode:cart.coupon_code});state=await availability(item.variant_id,item.quantity);}catch(e){projected.push({id:item.id,variantId:item.variant_id,quantity:item.quantity,status:e.code||"PRODUCT_UNAVAILABLE"});continue;}const lineSubtotal=money.multiply(quote.baseEligiblePrice,item.quantity),lineTotal=money.multiply(quote.effectiveUnitPrice,item.quantity),lineDiscount=money.subtractFloor(lineSubtotal,lineTotal);subtotal=money.add(subtotal,lineSubtotal);merchandiseTotal=money.add(merchandiseTotal,lineTotal);projected.push({id:item.id,variantId:item.variant_id,quantity:item.quantity,sku:variant.sku,status:state.status,unitPrice:quote.effectiveUnitPrice,lineSubtotal,discount:lineDiscount,lineTotal,pricing:quote});}return {id:cart.id,status:cart.status,version:cart.version,couponCode:cart.coupon_code,items:projected,subtotal,discountTotal:money.subtractFloor(subtotal,merchandiseTotal),merchandiseTotal,currency:cart.currency};} +async function add(userId,variantId,quantity){await validateVariant(variantId);const businessCustomer=await context(userId);await pricing.quoteVariant({variantId,businessCustomer,quantity});const state=await availability(variantId,quantity);if(!state.available)throw fault("Requested quantity is unavailable",state.status,409);return db.sequelize.transaction(async transaction=>{const cart=await activeCart(userId,{transaction,lock:true});let item=await db.CartItem.findOne({where:{cart_id:cart.id,variant_id:variantId},transaction,lock:transaction.LOCK.UPDATE});const next=(item?item.quantity:0)+quantity;if(next>Number(process.env.CART_MAX_ITEM_QUANTITY||100))throw fault("Cart quantity limit exceeded","CART_QUANTITY_LIMIT");if(item)await item.update({quantity:next},{transaction});else item=await db.CartItem.create({id:crypto.randomUUID(),cart_id:cart.id,variant_id:variantId,quantity},{transaction});await cart.increment("version",{transaction});return item;});} +async function mutate(userId,itemId,quantity){return db.sequelize.transaction(async transaction=>{const cart=await activeCart(userId,{transaction,lock:true}),item=await db.CartItem.findOne({where:{id:itemId,cart_id:cart.id},transaction,lock:transaction.LOCK.UPDATE});if(!item)throw fault("Cart item not found","NOT_FOUND",404);if(quantity===0)await item.destroy({transaction});else{await validateVariant(item.variant_id);await item.update({quantity},{transaction});}await cart.increment("version",{transaction});return item;});} +module.exports={activeCart,validateVariant,availability,project,add,mutate,context}; diff --git a/app/services/shopping/checkout.service.js b/app/services/shopping/checkout.service.js new file mode 100644 index 0000000..663e1a5 --- /dev/null +++ b/app/services/shopping/checkout.service.js @@ -0,0 +1,6 @@ +const crypto=require("crypto"),{Op}=require("sequelize"),db=require("../../models"),cartService=require("./cart.service"),pricing=require("../pricing/pricing.service"),shipping=require("../shipping/shipping.service"),inventory=require("../inventory/inventory.service"),money=require("../pricing/money");const fault=(m,c,s=400)=>Object.assign(new Error(m),{code:c,status:s});const ttl=()=>Number(process.env.CHECKOUT_TTL_MINUTES||15);const fingerprint=b=>crypto.createHash("sha256").update(JSON.stringify(b)).digest("hex");const addressSnapshot=a=>({recipientName:a.recipient_name,phone:a.phone_number,addressLine1:a.address_line_1,addressLine2:a.address_line_2,city:a.city,district:a.district,province:a.province,postalCode:a.postal_code,countryCode:a.country_code}); +async function getOwned(id,userId,transaction,lock=false){const row=await db.CheckoutSession.findOne({where:{id,user_id:userId},include:[{model:db.CheckoutItem,as:"items"}],transaction,lock:lock&&transaction.LOCK.UPDATE});if(!row)throw fault("Checkout not found","NOT_FOUND",404);return row;} +async function create({userId,shippingAddressId,billingAddressId,shippingMethodId,couponCode,idempotencyKey,requestId}){if(!idempotencyKey)throw fault("Idempotency-Key is required","IDEMPOTENCY_KEY_REQUIRED");const body={shippingAddressId,billingAddressId,shippingMethodId,couponCode:couponCode||null},hash=fingerprint(body);return db.sequelize.transaction(async transaction=>{const existing=await db.CheckoutSession.findOne({where:{user_id:userId,idempotency_key:idempotencyKey},transaction,lock:transaction.LOCK.UPDATE});if(existing){if(existing.request_fingerprint!==hash)throw fault("Idempotency key payload conflict","IDEMPOTENCY_CONFLICT",409);return {checkout:existing,idempotent:true};}const cart=await db.Cart.findOne({where:{user_id:userId,status:"ACTIVE"},transaction,lock:transaction.LOCK.UPDATE});if(!cart)throw fault("Active cart not found","EMPTY_CART",409);const items=await db.CartItem.findAll({where:{cart_id:cart.id},transaction});if(!items.length)throw fault("Cart is empty","EMPTY_CART",409);const addresses=await db.Address.findAll({where:{id:[shippingAddressId,billingAddressId],user_id:userId},transaction});const ship=addresses.find(x=>x.id===shippingAddressId),bill=addresses.find(x=>x.id===billingAddressId);if(!ship||!bill)throw fault("Owned addresses are required","INVALID_ADDRESS",404);const businessCustomer=await cartService.context(userId),checkoutId=crypto.randomUUID(),expiresAt=new Date(Date.now()+ttl()*60000);let subtotal="0.00",merchandise="0.00";const snapshots=[];for(const item of [...items].sort((a,b)=>a.variant_id.localeCompare(b.variant_id))){const variant=await cartService.validateVariant(item.variant_id),quote=await pricing.quoteVariant({variantId:item.variant_id,businessCustomer,quantity:item.quantity,couponCode:couponCode||cart.coupon_code}),lineSubtotal=money.multiply(quote.baseEligiblePrice,item.quantity),lineTotal=money.multiply(quote.effectiveUnitPrice,item.quantity);subtotal=money.add(subtotal,lineSubtotal);merchandise=money.add(merchandise,lineTotal);snapshots.push({id:crypto.randomUUID(),checkout_session_id:checkoutId,product_id:variant.product_id,variant_id:variant.id,reservation_key:`checkout:${checkoutId}:${variant.id}`,sku:variant.sku,product_name:variant.product.translations?.[0]?.name||variant.product.slug,variant_description:variant.sku,quantity:item.quantity,unit_price:quote.effectiveUnitPrice,discount_amount:money.subtractFloor(lineSubtotal,lineTotal),line_total:lineTotal,currency:variant.currency,metadata:{pricingSource:quote.businessPrice?"BUSINESS":"RETAIL"}});}const methods=await shipping.getAvailableMethods({address:ship,subtotal:merchandise,currency:cart.currency}),method=methods.find(x=>x.methodId===shippingMethodId);if(!method)throw fault("Shipping method is unavailable","INVALID_SHIPPING_METHOD",409);const grand=money.add(merchandise,method.amount,"0.00");const checkout=await db.CheckoutSession.create({id:checkoutId,checkout_number:`CHK-${checkoutId.slice(0,8).toUpperCase()}`,user_id:userId,business_customer_id:businessCustomer?.business_customer_id,cart_id:cart.id,cart_version:cart.version,status:"PENDING",currency:cart.currency,shipping_address_snapshot:addressSnapshot(ship),billing_address_snapshot:addressSnapshot(bill),shipping_method_id:shippingMethodId,shipping_snapshot:method,subtotal,discount_total:money.subtractFloor(subtotal,merchandise),shipping_amount:method.amount,tax_amount:"0.00",duty_amount:null,grand_total:grand,coupon_code:couponCode||cart.coupon_code,idempotency_key:idempotencyKey,request_fingerprint:hash,expires_at:expiresAt},{transaction});for(const item of snapshots)await inventory.reserveStock({reservationKey:item.reservation_key,variantId:item.variant_id,quantity:item.quantity,referenceType:"CHECKOUT",referenceId:checkout.id,userId,expiresAt,requestId,transaction});await db.CheckoutItem.bulkCreate(snapshots,{transaction});await checkout.update({status:"READY"},{transaction});await cart.update({status:"CHECKOUT_LOCKED"},{transaction});return {checkout,idempotent:false};});} +async function close({id,userId,status,requestId}){return db.sequelize.transaction(async transaction=>{const checkout=await getOwned(id,userId,transaction,true);if(checkout.status===status)return checkout;if(!["PENDING","READY"].includes(checkout.status))throw fault("Checkout is not cancellable","CHECKOUT_NOT_CANCELLABLE",409);for(const item of [...checkout.items].sort((a,b)=>a.variant_id.localeCompare(b.variant_id)))await inventory.releaseReservation({reservationKey:item.reservation_key,expired:status==="EXPIRED",requestId,transaction});await checkout.update({status},{transaction});await db.Cart.update({status:"ACTIVE"},{where:{id:checkout.cart_id,status:"CHECKOUT_LOCKED"},transaction});return checkout;});} +async function expire(limit=100){const rows=await db.CheckoutSession.findAll({where:{status:{[Op.in]:["PENDING","READY"]},expires_at:{[Op.lte]:new Date()}},limit,order:[["expires_at","ASC"]]});for(const row of rows){try{await close({id:row.id,userId:row.user_id,status:"EXPIRED"});}catch(e){if(e.code!=="CHECKOUT_NOT_CANCELLABLE")throw e;}}return rows.length;} +module.exports={create,getOwned,close,expire,ttl,fingerprint,addressSnapshot}; diff --git a/app/validation/shopping.schemas.js b/app/validation/shopping.schemas.js new file mode 100644 index 0000000..a868c5b --- /dev/null +++ b/app/validation/shopping.schemas.js @@ -0,0 +1 @@ +const{z}=require("zod"),wrap=body=>z.object({body:body.strict(),params:z.object({}).passthrough(),query:z.object({}).passthrough()}),id=z.string().min(1).max(160),money=z.string().regex(/^\d+(\.\d{1,2})?$/),date=z.coerce.date();exports.cartItem=wrap(z.object({variantId:id,quantity:z.number().int().positive().max(Number(process.env.CART_MAX_ITEM_QUANTITY||100))}));exports.cartQuantity=wrap(z.object({quantity:z.number().int().min(0).max(Number(process.env.CART_MAX_ITEM_QUANTITY||100))}));exports.coupon=wrap(z.object({code:z.string().trim().min(1).max(50).regex(/^[A-Za-z0-9_-]+$/).transform(x=>x.toUpperCase())}));exports.wishlist=wrap(z.object({productId:id}));exports.checkout=wrap(z.object({shippingAddressId:id,billingAddressId:id,shippingMethodId:id,couponCode:z.string().trim().max(50).optional()}));exports.shippingQuote=wrap(z.object({addressId:id}));exports.zone=wrap(z.object({code:z.string().min(1).max(60),name:z.string().min(1).max(150),status:z.enum(["ACTIVE","INACTIVE"]).default("ACTIVE"),dutyMode:z.enum(["NONE","ESTIMATED","PAYABLE_ON_DELIVERY","INCLUDED","UNKNOWN"]).default("UNKNOWN"),regions:z.array(z.object({countryCode:z.string().length(2),province:z.string().max(100).optional(),district:z.string().max(100).optional()}).strict()).min(1)}));exports.method=wrap(z.object({code:z.string().min(1).max(60),name:z.string().min(1).max(150),description:z.string().optional(),status:z.enum(["ACTIVE","INACTIVE"]).default("ACTIVE"),estimatedMinDays:z.number().int().nonnegative(),estimatedMaxDays:z.number().int().nonnegative(),trackingSupported:z.boolean().default(false)}));exports.rate=wrap(z.object({shippingZoneId:id,shippingMethodId:id,currency:z.string().length(3),baseAmount:money,freeShippingThreshold:money.optional(),minimumSubtotal:money.optional(),maximumSubtotal:money.optional(),status:z.enum(["ACTIVE","INACTIVE"]).default("ACTIVE"),startsAt:date.optional(),endsAt:date.optional()})); diff --git a/cron/checkoutExpiry.cron.js b/cron/checkoutExpiry.cron.js new file mode 100644 index 0000000..f7d40b6 --- /dev/null +++ b/cron/checkoutExpiry.cron.js @@ -0,0 +1 @@ +const cron=require("node-cron"),checkout=require("../app/services/shopping/checkout.service");module.exports=()=>cron.schedule("* * * * *",async()=>{try{await checkout.expire(100);}catch(error){console.error("Checkout expiry reconciliation failed",{name:error.name,message:error.message});}},{noOverlap:true}); diff --git a/cron/index.js b/cron/index.js index cfbf7e5..4318c8d 100644 --- a/cron/index.js +++ b/cron/index.js @@ -11,11 +11,12 @@ const startCleanInactiveNotificationsCron = require("./notificationCleaning.cron"); const startInventoryReservationExpiryCron = require("./inventoryReservationExpiry.cron"); +const startCheckoutExpiryCron = require("./checkoutExpiry.cron"); function startAllCrons() { console.log("Starting Cron Jobs..."); - const tasks = [startCleanInactiveNotificationsCron(), startInventoryReservationExpiryCron()]; + const tasks = [startCleanInactiveNotificationsCron(), startInventoryReservationExpiryCron(), startCheckoutExpiryCron()]; return async () => { for (const task of tasks) { diff --git a/migrations/20260903060000-phase-6-shopping-checkout.js b/migrations/20260903060000-phase-6-shopping-checkout.js new file mode 100644 index 0000000..0cf8b40 --- /dev/null +++ b/migrations/20260903060000-phase-6-shopping-checkout.js @@ -0,0 +1,3 @@ +"use strict";module.exports={async up(q,S){const ID={type:S.STRING,primaryKey:true,allowNull:false},STR={type:S.STRING},REQ={type:S.STRING,allowNull:false},DATE={type:S.DATE},DEC={type:S.DECIMAL(15,2)},TS={createdAt:{type:S.DATE,allowNull:false},updatedAt:{type:S.DATE,allowNull:false}};await q.createTable("carts",{id:ID,user_id:REQ,status:REQ,currency:{type:S.STRING(3),allowNull:false},coupon_code:STR,version:{type:S.INTEGER,allowNull:false,defaultValue:0},expires_at:DATE,...TS});await q.addConstraint("carts",{fields:["user_id","status"],type:"unique",name:"uq_cart_user_status"});await q.createTable("cart_items",{id:ID,cart_id:REQ,variant_id:REQ,quantity:{type:S.INTEGER,allowNull:false},...TS});await q.addConstraint("cart_items",{fields:["cart_id","variant_id"],type:"unique",name:"uq_cart_variant"});await q.createTable("wishlist_items",{id:ID,user_id:REQ,product_id:REQ,...TS});await q.addConstraint("wishlist_items",{fields:["user_id","product_id"],type:"unique",name:"uq_wishlist_product"}); +await q.createTable("shipping_zones",{id:ID,code:{type:S.STRING(60),allowNull:false,unique:true},name:REQ,status:REQ,duty_mode:REQ,...TS});await q.createTable("shipping_zone_regions",{id:ID,shipping_zone_id:REQ,country_code:{type:S.STRING(2),allowNull:false},province:STR,district:STR,...TS});await q.addIndex("shipping_zone_regions",["country_code","province","district"]);await q.createTable("shipping_methods",{id:ID,code:{type:S.STRING(60),allowNull:false,unique:true},name:REQ,description:{type:S.TEXT},status:REQ,estimated_min_days:{type:S.INTEGER,allowNull:false},estimated_max_days:{type:S.INTEGER,allowNull:false},tracking_supported:{type:S.BOOLEAN,allowNull:false},...TS});await q.createTable("shipping_rates",{id:ID,shipping_zone_id:REQ,shipping_method_id:REQ,currency:{type:S.STRING(3),allowNull:false},base_amount:{...DEC,allowNull:false},free_shipping_threshold:DEC,minimum_subtotal:DEC,maximum_subtotal:DEC,status:REQ,starts_at:DATE,ends_at:DATE,...TS});await q.addIndex("shipping_rates",["shipping_zone_id","shipping_method_id","status"]); +await q.createTable("checkout_sessions",{id:ID,checkout_number:{type:S.STRING(80),allowNull:false,unique:true},user_id:REQ,business_customer_id:STR,cart_id:REQ,cart_version:{type:S.INTEGER,allowNull:false},status:REQ,currency:{type:S.STRING(3),allowNull:false},shipping_address_snapshot:{type:S.JSON,allowNull:false},billing_address_snapshot:{type:S.JSON,allowNull:false},shipping_method_id:REQ,shipping_snapshot:{type:S.JSON,allowNull:false},subtotal:{...DEC,allowNull:false},discount_total:{...DEC,allowNull:false},shipping_amount:{...DEC,allowNull:false},tax_amount:{...DEC,allowNull:false},duty_amount:DEC,grand_total:{...DEC,allowNull:false},coupon_code:STR,idempotency_key:{type:S.STRING(160),allowNull:false},request_fingerprint:{type:S.STRING(64),allowNull:false},expires_at:{type:S.DATE,allowNull:false},...TS});await q.addConstraint("checkout_sessions",{fields:["user_id","idempotency_key"],type:"unique",name:"uq_checkout_user_idempotency"});await q.addIndex("checkout_sessions",["status","expires_at"]);await q.createTable("checkout_items",{id:ID,checkout_session_id:REQ,product_id:REQ,variant_id:REQ,reservation_key:{type:S.STRING(160),allowNull:false},sku:{type:S.STRING(100),allowNull:false},product_name:REQ,variant_description:STR,quantity:{type:S.INTEGER,allowNull:false},unit_price:{...DEC,allowNull:false},discount_amount:{...DEC,allowNull:false},line_total:{...DEC,allowNull:false},currency:{type:S.STRING(3),allowNull:false},metadata:{type:S.JSON},...TS});},async down(){throw new Error("Phase 6 migration is forward-only; restore from a verified backup instead");}}; diff --git a/tests/unit/shopping.money-validation.test.js b/tests/unit/shopping.money-validation.test.js new file mode 100644 index 0000000..837defa --- /dev/null +++ b/tests/unit/shopping.money-validation.test.js @@ -0,0 +1 @@ +const money=require("../../app/services/pricing/money"),s=require("../../app/validation/shopping.schemas"),checkout=require("../../app/services/shopping/checkout.service");const input=body=>({body,params:{},query:{}});describe("Phase 6 commerce boundaries",()=>{test("calculates line totals without floating point",()=>{expect(money.multiply("999.99",3)).toBe("2999.97");expect(money.add("2999.97","250.03")).toBe("3250.00");});test("rejects client-controlled checkout totals",()=>expect(()=>s.checkout.parse(input({shippingAddressId:"a",billingAddressId:"b",shippingMethodId:"m",grandTotal:"1.00"}))).toThrow());test("requires positive integer cart quantities",()=>expect(()=>s.cartItem.parse(input({variantId:"v",quantity:1.5}))).toThrow());test("idempotency fingerprints identical payloads deterministically",()=>expect(checkout.fingerprint({a:1})).toBe(checkout.fingerprint({a:1})));test("address snapshots omit ownership and internal fields",()=>expect(checkout.addressSnapshot({recipient_name:"A",phone_number:"1",address_line_1:"X",country_code:"LK",user_id:"secret"})).toEqual({recipientName:"A",phone:"1",addressLine1:"X",addressLine2:undefined,city:undefined,district:undefined,province:undefined,postalCode:undefined,countryCode:"LK"}));}); diff --git a/tests/unit/shopping.permissions.test.js b/tests/unit/shopping.permissions.test.js new file mode 100644 index 0000000..034d2e0 --- /dev/null +++ b/tests/unit/shopping.permissions.test.js @@ -0,0 +1 @@ +const{checkPermission}=require("../../app/middleware/permission.middleware");describe("shipping administration security",()=>{test("customer cannot manage shipping",()=>{const req={user:{accountType:"CUSTOMER",permissions:[]},method:"POST"},res={status:jest.fn().mockReturnThis(),json:jest.fn()},next=jest.fn();checkPermission("shipping.rates.manage",{custom:true})(req,res,next);expect(res.status).toHaveBeenCalledWith(403);expect(next).not.toHaveBeenCalled();});});