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.
This commit is contained in:
Sathira Sri Sathara
2026-09-03 19:25:13 +05:30
parent fd4e324571
commit 5643d89236
75 changed files with 663 additions and 1 deletions
+1
View File
@@ -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(),
+1
View File
@@ -0,0 +1 @@
const SUPPORTED_LOCALES=Object.freeze(["en","si","ta"]);const DEFAULT_LOCALE="en";module.exports={SUPPORTED_LOCALES,DEFAULT_LOCALE};
+4
View File
@@ -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",
};
@@ -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);}};
@@ -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);}};
@@ -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);}};
@@ -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);}};
@@ -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);}};
@@ -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);}};
@@ -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);}};
+1
View File
@@ -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;};
+1
View File
@@ -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;};
@@ -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;};
+1
View File
@@ -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;};
@@ -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"]}]});
@@ -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;};
+1
View File
@@ -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;};
@@ -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;};
@@ -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;};
@@ -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;};
@@ -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;};
@@ -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;};
@@ -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"]}]});
@@ -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;};
@@ -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;};
@@ -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;};
+1
View File
@@ -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});
@@ -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"]}]});
+23
View File
@@ -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) {
@@ -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;};
@@ -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});
@@ -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});
@@ -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});
+1
View File
@@ -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;};
+1
View File
@@ -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;};
@@ -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"]}]});
+1
View File
@@ -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;};
@@ -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});
@@ -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"]}]});
@@ -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});
+1
View File
@@ -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});
@@ -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;};
@@ -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;
+1
View File
@@ -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;
+1
View File
@@ -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;
+12
View File
@@ -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;
+1
View File
@@ -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;
+1
View File
@@ -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;
@@ -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;
+1
View File
@@ -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;
+3
View File
@@ -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});}});
@@ -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};
+1
View File
@@ -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};
@@ -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};
+103
View File
@@ -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 };
@@ -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 };
+5
View File
@@ -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 };
+14
View File
@@ -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 };
+6
View File
@@ -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)});
@@ -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)}));