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.
This commit is contained in:
Sathira Sri Sathara
2026-09-03 14:58:26 +05:30
parent b6b345f245
commit fd4e324571
36 changed files with 406 additions and 6 deletions
+3
View File
@@ -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",
};
+8
View File
@@ -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);}};
+20
View File
@@ -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;
@@ -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);} };
+2 -1
View File
@@ -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;
+5
View File
@@ -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);
+13
View File
@@ -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;
};
@@ -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;
};
+4
View File
@@ -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;
};
@@ -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;
};
+13 -1
View File
@@ -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;
+5 -1
View File
@@ -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",
+1
View File
@@ -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 });
+2
View File
@@ -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;
+8 -3
View File
@@ -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 });
}
+2
View File
@@ -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;
+1
View File
@@ -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;
+1
View File
@@ -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;
+6
View File
@@ -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;
+5
View File
@@ -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);
+4
View File
@@ -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(
@@ -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};
+1
View File
@@ -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");
@@ -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};
@@ -0,0 +1 @@
<!doctype html><html><body><p>Hello {{firstName}},</p><p>Your business account status is now {{status}}.</p></body></html>
@@ -0,0 +1 @@
<!doctype html><html><body><p>Hello {{firstName}},</p><p>Your business application for {{businessName}} was approved. Partner ID: {{partnerId}}.</p></body></html>
@@ -0,0 +1 @@
<!doctype html><html><body><p>Hello {{firstName}},</p><p>We received your business application for {{businessName}}. Reference: {{applicationId}}.</p></body></html>
@@ -0,0 +1 @@
<!doctype html><html><body><p>Hello {{firstName}},</p><p>Your business application for {{businessName}} was not approved. Reason: {{reason}}.</p></body></html>
+3
View File
@@ -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()})};
+9
View File
@@ -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() }),
};