diff --git a/Documentation/API_CATALOGUE.md b/Documentation/API_CATALOGUE.md new file mode 100644 index 0000000..b35dcec --- /dev/null +++ b/Documentation/API_CATALOGUE.md @@ -0,0 +1,64 @@ +# ZUMRI Catalogue API + +Catalogue reads use `/api/v1`; `/api` remains compatible. Public endpoints require no authentication. Administrative endpoints require Phase 1 permissions. Examples use placeholders. + +## Localization + +Locale priority is `?locale=en|si|ta`, `X-Locale`, authenticated profile preference, `Accept-Language`, then English. Missing requested content falls back to English and then the first available translation. + +## Public products + +- `GET /api/v1/products` +- `GET /api/v1/products/:slug` +- `POST /api/v1/products/:productId/reviews` — authenticated customer + +List query parameters: `page`, `limit` (maximum 100), `category`, `brand`, `search`, `minPrice`, `maxPrice`, `featured`, `newArrival`, `locale`, and `sort=newest|price_asc|price_desc|name|featured`. Unsupported sorts return 400. Stock and wholesale availability are intentionally absent. + +```json +{"data":[{"id":"","slug":"","name":"","primaryImage":{"url":""},"minPrice":"1000.00","maxPrice":"1200.00","currency":"LKR","featured":false,"newArrival":true}]} +``` + +Detail returns localized content/SEO, brand, categories, active variants, options, descriptive attributes, signed gallery media, a structured size guide, rating summary, and approved review preview. DRAFT, INACTIVE, ARCHIVED, and HIDDEN products always return 404 publicly. + +Review body is `{"rating":5,"title":"","body":"<review>"}`. `status`, `verifiedPurchase`, moderator, and user IDs are rejected. One review per user/product is enforced. Purchase verification defaults false until order integration exists. + +## Categories, brands, and collections + +- `GET /api/v1/categories` — tree; add `flat=true` for a flat list +- `GET /api/v1/categories/:slug` +- `GET /api/v1/brands` +- `GET /api/v1/brands/:slug` +- `GET /api/v1/collections` +- `GET /api/v1/collections/:slug` + +Only active categories/brands and currently scheduled active collections are returned. + +## Administrative products + +- `GET /api/v1/admin/products` — `catalogue.products.read` +- `POST /api/v1/admin/products` — `catalogue.products.create` +- `GET /api/v1/admin/products/:id` — read permission +- `PATCH /api/v1/admin/products/:id` — `catalogue.products.update` +- `DELETE /api/v1/admin/products/:id` — `catalogue.products.delete`; archives instead of deleting +- `POST /api/v1/admin/products/:productId/variants` +- `PATCH /api/v1/admin/products/:productId/variants/:variantId` +- `POST /api/v1/admin/products/:productId/options` +- `POST /api/v1/admin/products/:productId/media` +- `POST /api/v1/admin/products/:productId/relations` + +Product creation atomically persists translations, category joins, variants, and owned Phase 2 uploads. Prices are decimal strings. Publishing requires English content, a brand, an active variant, and primary media. Published slugs are immutable. + +## Administrative content + +- `POST|PATCH /api/v1/admin/brands[/:id]` — `catalogue.brands.manage` +- `POST|PATCH /api/v1/admin/categories[/:id]` — `catalogue.categories.manage` +- `POST /api/v1/admin/collections` — `catalogue.collections.manage` +- `POST /api/v1/admin/size-guides` — `catalogue.size-guides.manage` +- `GET /api/v1/admin/reviews` — `catalogue.reviews.read` +- `PATCH /api/v1/admin/reviews/:id/status` — `catalogue.reviews.moderate` + +Categories cannot parent themselves or form cycles. Referenced categories/brands have no hard-delete API. Collections use deterministic join ordering and publish windows. Size guides accept structured columns/rows, never HTML. + +## Media and prices + +Media must be an AVAILABLE upload owned by the acting administrator. Linking transfers metadata ownership to the catalogue Product. Public responses contain short-lived signed URLs but never object keys, bucket names, or credentials. `basePrice` and optional `compareAtPrice` are display catalogue prices only; promotions and authoritative shopping pricing are deferred. diff --git a/Documentation/API_INVENTORY_MERCHANDISING.md b/Documentation/API_INVENTORY_MERCHANDISING.md new file mode 100644 index 0000000..2855576 --- /dev/null +++ b/Documentation/API_INVENTORY_MERCHANDISING.md @@ -0,0 +1,42 @@ +# Inventory and Merchandising API + +All routes use the `/api/v1` prefix. Admin routes require authentication and the named Phase 5 permission. + +## Inventory and warehouses + +- `GET /admin/inventory` (`inventory.read`) +- `GET /admin/inventory/:variantId` (`inventory.read`) +- `GET /admin/inventory/ledger` (`inventory.read`) +- `GET /admin/inventory/low-stock` (`inventory.read`) +- `POST /admin/inventory/adjustments` (`inventory.adjust`); send `Idempotency-Key` +- `POST /admin/inventory/transfers` (`inventory.transfer`); send `Idempotency-Key` +- `GET /admin/warehouses` (`inventory.read`) +- `POST /admin/warehouses`, `PATCH /admin/warehouses/:id` (`inventory.warehouses.manage`) +- `GET /availability/:variantId` returns only `IN_STOCK`, `LOW_STOCK`, or `OUT_OF_STOCK` and `availableForSale`; it never exposes warehouse quantities. + +Inventory mutations are internal service operations: `reserveStock`, `releaseReservation`, and `consumeReservation`. Reservations default to `INVENTORY_RESERVATION_TTL_MINUTES=15`. The minute reconciliation job expires bounded batches of 100; the database remains authoritative. + +## Business pricing + +- `GET /admin/business-pricing` (`pricing.business.read`) +- `POST /admin/business-pricing` (`pricing.business.manage`) + +Rules support exactly one tier or customer audience, effective dates, MOQ, and non-overlapping volume ranges. Precedence is customer override, business tier, then retail. Money is stored as DECIMAL and calculated using integer-scaled helpers. + +## Promotions and coupons + +- `GET|POST /admin/promotions` (`promotions.read` / `promotions.manage`) +- `GET|POST /admin/coupons` (`promotions.read` / `promotions.manage`) + +The quote boundary resolves retail/business base price, then the highest-priority eligible automatic promotion, then a coupon only when stacking permits. Discounts floor at zero. Coupon codes are canonical uppercase. Usage redemption is intentionally deferred until orders exist. + +## Banners + +- `GET /banners?placement=&locale=` returns active, scheduled, audience-eligible localized banners. +- `GET|POST /admin/banners` requires `merchandising.banners.manage`. + +Banner media reuses Upload records and only returns safe upload identifiers, never bucket/object keys. + +## Not implemented + +Cart, checkout, orders, coupon redemption, shipping, tax, payment, and delivery remain outside Phase 5. diff --git a/Documentation/CURRENT_BACKEND_STATUS.md b/Documentation/CURRENT_BACKEND_STATUS.md index d1780cd..647540a 100644 --- a/Documentation/CURRENT_BACKEND_STATUS.md +++ b/Documentation/CURRENT_BACKEND_STATUS.md @@ -1,5 +1,13 @@ # ZUMRI Current Backend Status +## Phase 4 Completion Update + +Completion date: 2026-09-03. Module 03 (product catalogue) is approximately 84%; Module 04 (multi-language content) is approximately 82%. Phase 4 adds 18 catalogue models covering brands, hierarchical localized categories, products/translations/multi-category joins, DECIMAL-price variants, configurable options and attributes, owned media, scheduled localized collections, structured size guides, explicit relations, and moderated reviews. + +Public APIs now provide active/public product lists and slug detail, safe search/filter/sort/pagination, locale fallback, categories, brands, current collections, signed media DTOs, price ranges, and approved review summaries. Permission-gated admin APIs cover product lifecycle, variants/options/media/relations, brands, cycle-safe categories, collections, size guides, and review moderation. No inventory quantity, wholesale pricing, promotion, or shopping behavior was introduced. + +The mocked regression suite passes 15 suites/78 tests and syntax validation covers 204 JavaScript files. The Phase 4 migration is forward-only and was not executed. Restored-staging migration checks, permission seeding, real MySQL query/concurrency testing, and signed-media volume validation remain required before Phase 5. See `Documentation/PHASE_4_CATALOGUE_CONTENT.md` and `Documentation/API_CATALOGUE.md`. + ## Phase 3 Completion Update Completion date: 2026-09-03. Module 02 (customer profile/address management) is now approximately 88%; Module 13 (business accounts) is approximately 78%. Customer profile/preferences, structured owned addresses with transactional defaults, secure self-deactivation, business applications, permission-gated transactional approval, immutable partner identity, business profiles/contacts/addresses, domain status, DECIMAL credit configuration, and settlement-term primitives are implemented. @@ -403,3 +411,6 @@ New `auth_sessions`, `user_identities`, and `user_roles` models/tables support d Auth endpoints now cover customer registration, verification/resend, password-to-OTP challenge, OTP completion, refresh rotation, current/all-device logout, forgot/reset/change password, Google, Apple, `/me`, and shared admin/rider compatibility flows. Both `/api` and `/api/v1` remain. Security-focused unit/integration tests were added without real providers/email/database/Redis. Remaining identity work is operational: validate/deduplicate deployed RBAC data before migration, run staging MySQL/Redis concurrency tests, seed initial privileged assignments securely, configure provider audiences/mail, and design manual privileged OAuth linking and email change if required. Module 01 is now approximately 91%; migration/staging validation prevents claiming 100% production completion. +## Phase 5 Completion Update + +Date: 2026-09-03. Inventory/reservation foundations, business pricing, promotions/coupons, and localized scheduled banners are implemented behind new permissions and a forward-only migration. Phase 5 adds 13 models, secured administration routes, public availability/banner projections, audit calls, a bounded expiry reconciliation cron, and Phase 6 pricing/reservation service boundaries. Module 05 is 85%, Module 07 is 78%, and Module 13 is 75%. Inventory is 90%, reservations 90%, business pricing 82%, promotions/coupons 78%, and banners 82%. Automated verification: 18 suites and 89 tests passed; syntax passed for 232 JavaScript files. Migration was not executed. Staging must precheck legacy stock/pricing/promotion/banner data, apply the migration, seed permissions/default warehouse, and test genuine MySQL locking plus cron behavior before Phase 6 production use. diff --git a/Documentation/PHASE_4_CATALOGUE_CONTENT.md b/Documentation/PHASE_4_CATALOGUE_CONTENT.md new file mode 100644 index 0000000..09adf7d --- /dev/null +++ b/Documentation/PHASE_4_CATALOGUE_CONTENT.md @@ -0,0 +1,103 @@ +# ZUMRI Phase 4 Catalogue and Content + +## Objective + +Create the product/content foundation required before inventory and shopping, with no stock, reservation, wholesale-pricing, cart, order, or payment behavior. + +## Architecture + +Catalogue models live under `app/models/catalogue`, domain services under `app/services/catalogue`, controllers/routes under their catalogue folders, and one new forward migration owns the schema. Phase 1 RBAC/audit and Phase 2 Upload/storage are reused. + +## Brand + +Brand has immutable identity, unique normalized slug, editorial description, optional owned logo, website, ACTIVE/INACTIVE state, and deterministic order. Names remain language-neutral; no unnecessary BrandTranslation table was added. + +## Category Hierarchy + +Category separates structural code/slug/parent/status from localized content. Root and nested categories are supported. A bounded ancestor walk rejects invalid parents, self-parenting, and cycles. Public APIs offer tree or flat representations. + +## Product + +Product stores structural identity, unique slug/code, brand/default category, DRAFT/ACTIVE/INACTIVE/ARCHIVED state, PUBLIC/HIDDEN visibility, featured flag, publication/new-arrival dates, and audit actors. It stores no stock quantity. + +## Product Variants + +Variants carry globally unique SKU, optional barcode, state, `DECIMAL(15,2)` base/compare-at prices, ISO-style currency code, optional weight, and order. No inventory columns exist. + +## Product Options + +Normalized ProductOption and ProductOptionValue records define variant dimensions. VariantOptionValue links validated same-product values. ProductAttribute holds non-variant descriptive facts separately. + +## Pricing Boundary + +Phase 4 persists catalogue display price and optional comparison price only. Decimal values remain strings in validation/serialization. Promotions, coupons, business tiers, wholesale/MOQ/volume pricing, and final checkout pricing are excluded. + +## Product Media + +ProductMedia associates Phase 2 Upload records with products and optional same-product variants. Only supported image uploads may be linked. Primary selection is transactional. Upload metadata moves from administrator ownership to CATALOGUE/Product ownership. Public access uses short signed URLs and never exposes storage internals. + +## Localization + +ProductTranslation and CategoryTranslation enforce one row per `en|si|ta` locale. CollectionTranslation localizes editorial collections. Resolution uses explicit locale, profile/header language, and English fallback without hardcoded UI translations. + +## SEO / Slugs + +Unique lowercase safe slugs exist for brands, categories, products, and collections. Product/category translations carry meta title/description. Published product slugs are restricted from mutation, so a slug-history subsystem is not currently necessary. + +## Collections + +Collections support type, state, publish window, hero upload, translations, and deterministic CollectionProduct ordering. `featured` on Product is the canonical global featured flag; collection membership is contextual editorial merchandising. + +## Size Guides + +Reusable SizeGuide records use strictly validated structured column/row JSON and may link to a category or product. Raw arbitrary HTML is prohibited. + +## Product Relationships + +Explicit RELATED, SIMILAR, and COMPLETE_THE_LOOK relations are unique and reject self-relations. They are curated content, not AI recommendations. + +## Reviews + +Authenticated customer/business-customer accounts may create one pending review per public active product. Rating is 1–5. Public detail exposes APPROVED reviews only. Permission-gated moderation records actor/time and audit events. + +## Verified Purchase Future Integration + +Clients cannot submit `verifiedPurchase`; it always starts false. A future order subsystem may derive or update it from authoritative completed order lines. Phase 4 fabricates no purchase evidence. + +## Public Catalogue + +Public product/category/brand/collection routes filter state and visibility at query time, resolve localized content, use eager associations, return small DTOs, compute active-variant price ranges, and hide internal catalogue/storage fields. + +## Admin Catalogue + +Permission-scoped routes support product creation/update/archive, variants, options, media, relations, brands, hierarchical categories, collections, size guides, and review moderation. Product creation is transactional and never deletes pre-existing Upload objects on rollback. + +## Search and Filtering + +MySQL/Sequelize search covers localized name, product code/SKU foundation, and brand name. Filters include category, brand, decimal price bounds, featured, and new arrival. Sort modes are allowlisted. Inventory and wholesale filters are absent. The search boundary can later be replaced without changing public DTOs. + +## Permissions + +Added `catalogue.products.read/create/update/delete`, `catalogue.categories.manage`, `catalogue.brands.manage`, `catalogue.collections.manage`, `catalogue.size-guides.manage`, `catalogue.reviews.read`, and `catalogue.reviews.moderate`. SUPER_ADMIN retains the Phase 1 bypass. + +## Audit + +Stable events cover product/variant/brand/category/collection creation and updates, publishing/archive, and review submission/moderation. Events store identifiers and concise metadata rather than descriptions or media contents. + +## Database Changes + +`20260903040000-phase-4-catalogue-content.js` creates only the models used by Phase 4, with foreign keys, uniqueness, state/search indexes, rating/self-relation checks, and DECIMAL prices. It is forward-only and was not run. + +Migration pre-checks: identify legacy product/category/brand tables, duplicate slugs/SKUs/barcodes, currency inconsistencies, legacy media ownership, missing/orphan Uploads, and conflicting table names. Back up and resolve explicitly; no data is silently removed. `catalogueExample.seeder.js` is optional and creates inactive editable examples only. + +## Tests + +Phase 4 tests cover strict schemas, slug/money/rating/locale validation, verified-purchase spoofing, structured size guides, fallback localization, exact decimal comparison, category cycles, and centralized publish requirements. The full Phase 0–3 regression suite remains required. + +## Remaining Known Issues + +The migration and real MySQL constraints/query plans are untested. Staging must validate multi-include pagination, concurrent slug/SKU/media-primary operations, signed-media volume, and actual migration ordering. Review approval/rejection notification was intentionally not enabled to avoid spam without a confirmed product requirement. Product relation projection and full admin update/reorder endpoints can expand when frontend workflows are finalized. + +## Phase 5 Prerequisites + +Complete legacy pre-checks, apply migrations to restored staging, seed permissions, load optional editable content if desired, run public-query EXPLAIN tests with realistic volume, validate signed media and concurrency, and freeze the ProductVariant identifier contract needed by inventory. diff --git a/Documentation/PHASE_5_INVENTORY_MERCHANDISING.md b/Documentation/PHASE_5_INVENTORY_MERCHANDISING.md new file mode 100644 index 0000000..bfb0291 --- /dev/null +++ b/Documentation/PHASE_5_INVENTORY_MERCHANDISING.md @@ -0,0 +1,85 @@ +# ZUMRI Phase 5 Inventory and Merchandising + +## Objective +Provide authoritative multi-warehouse stock, reservation primitives, wholesale pricing, price quotes, promotions, coupons, and scheduled localized banners for Phase 6 consumers. + +## Existing Components Reused +ProductVariant, BusinessCustomer, Upload, authorization middleware, audit queue, cron bootstrap, Zod, Sequelize, and catalogue localization are reused. + +## Inventory Architecture +Catalogue never stores stock. `InventoryBalance` is authoritative per warehouse/variant; availability is `onHand - reserved`. All writes pass through one service and append an immutable ledger event. + +## Warehouses +Multiple active/inactive warehouses and a transactionally selected default are supported. Default warehouse selection is deterministic. + +## Inventory Balance +Quantities are integers. Service invariants prevent negative stock and `reserved > onHand`. + +## Inventory Ledger +Every adjustment, reservation lifecycle event, and transfer has a unique event ID. There is no ledger update API. + +## Stock Adjustments +Adjustments lock balances, enforce invariants, append a ledger row, support HTTP idempotency keys, and emit audit events. + +## Transfers +Synchronous transfers lock warehouse IDs in sorted order, then create paired OUT/IN ledger rows. + +## Reservation Architecture +Unique reservation keys make reserve/release/consume idempotent. Generic references avoid premature cart/order coupling. + +## Reservation Concurrency +MySQL transactions and row-level `FOR UPDATE` locks serialize competitors for final units. Real multi-connection InnoDB validation remains a staging prerequisite. + +## Reservation Expiry +A no-overlap minute cron reconciles up to 100 expired ACTIVE records per run; each record is locked and the database state is authoritative. + +## Availability Projection +Public responses expose status and boolean sale availability only. Out-of-stock catalogue items remain visible. + +## Low Stock +Low stock is centrally defined as available quantity less than or equal to the configured balance threshold. + +## Business Pricing +Wholesale rules remain separate from retail base price and are limited to approved ACTIVE business accounts. + +## Business Tiers +Business customers may reference an active tier; customer-specific negotiated rules are also supported. + +## MOQ +MOQ belongs to a wholesale rule and never applies to retail fallback. + +## Volume Pricing +Integer, non-overlapping ranges select the greatest eligible minimum quantity; final maximum may be null. + +## Pricing Precedence +Retail -> eligible customer override (preferred over tier) -> volume tier -> highest-priority automatic promotion -> permitted coupon. Effective price floors at zero. + +## Promotions +Percentage, fixed amount, and fixed price campaigns support dates, priority, minimum quantity, audiences, stacking, and normalized targets. + +## Coupons +Codes are uppercase and validated against coupon/promotion state and schedule. Redemption counters are not fabricated before orders. + +## Banners +Placements are string-configurable. Status, schedule, sort order, audience, and optional business tier drive projection. + +## Localization +Banner translations use `en`, `si`, and `ta`, with requested locale then English fallback. + +## Permissions +Inventory read/adjust/transfer/reservation/warehouse, business pricing read/manage, promotion read/manage, and banner manage permissions were added. + +## Audit Events +Warehouse, adjustment, transfer, business-price, promotion, coupon, and banner mutations enqueue sanitized Phase 2 audit activities. + +## Database Changes +The forward-only `20260903050000` migration adds 13 domain tables and the BusinessCustomer tier reference. Earlier migrations are unchanged. + +## Tests +Unit coverage verifies decimal precision, zero floor, availability states, strict input, coupon normalization, and permission boundaries. Existing regression tests remain green. + +## Remaining Known Issues +Patch endpoints for promotion/coupon/banner/business pricing and real infrastructure integration tests remain follow-up hardening. No production migration was run. + +## Phase 6 Prerequisites +Run legacy-data prechecks and migration on staging; validate constraints and concurrent reservations using two real InnoDB connections; verify cron in a multi-instance deployment; seed permissions and warehouse data. diff --git a/app/config/env.config.js b/app/config/env.config.js index 369c22c..7302ff2 100644 --- a/app/config/env.config.js +++ b/app/config/env.config.js @@ -44,6 +44,7 @@ const envSchema = z.object({ EMAIL_QUEUE_CONCURRENCY: z.coerce.number().int().positive().default(5), DOCUMENT_QUEUE_CONCURRENCY: z.coerce.number().int().positive().default(2), NOTIFICATION_RETENTION_DAYS: z.coerce.number().int().positive().default(90), + INVENTORY_RESERVATION_TTL_MINUTES: z.coerce.number().int().positive().default(15), LOG_RETENTION_DAYS: z.coerce.number().int().positive().default(30), DOCS_USER: z.string().optional(), DOCS_PASS: z.string().optional(), GOOGLE_CLIENT_ID: z.string().optional(), APPLE_CLIENT_ID: z.string().optional(), diff --git a/app/constants/locales.js b/app/constants/locales.js new file mode 100644 index 0000000..66d9b83 --- /dev/null +++ b/app/constants/locales.js @@ -0,0 +1 @@ +const SUPPORTED_LOCALES=Object.freeze(["en","si","ta"]);const DEFAULT_LOCALE="en";module.exports={SUPPORTED_LOCALES,DEFAULT_LOCALE}; diff --git a/app/constants/permissions.js b/app/constants/permissions.js index 7f89de6..c70debf 100644 --- a/app/constants/permissions.js +++ b/app/constants/permissions.js @@ -20,4 +20,8 @@ module.exports = { BUSINESS_APPLICATIONS_READ: "business.applications.read", BUSINESS_APPLICATIONS_REVIEW: "business.applications.review", BUSINESS_ACCOUNTS_READ: "business.accounts.read", BUSINESS_ACCOUNTS_UPDATE: "business.accounts.update", BUSINESS_CREDIT_MANAGE: "business.credit.manage", BUSINESS_SETTLEMENT_MANAGE: "business.settlement.manage", + CATALOGUE_PRODUCTS_READ:"catalogue.products.read",CATALOGUE_PRODUCTS_CREATE:"catalogue.products.create",CATALOGUE_PRODUCTS_UPDATE:"catalogue.products.update",CATALOGUE_PRODUCTS_DELETE:"catalogue.products.delete", + CATALOGUE_CATEGORIES_MANAGE:"catalogue.categories.manage",CATALOGUE_BRANDS_MANAGE:"catalogue.brands.manage",CATALOGUE_COLLECTIONS_MANAGE:"catalogue.collections.manage",CATALOGUE_SIZE_GUIDES_MANAGE:"catalogue.size-guides.manage",CATALOGUE_REVIEWS_READ:"catalogue.reviews.read",CATALOGUE_REVIEWS_MODERATE:"catalogue.reviews.moderate", + INVENTORY_READ:"inventory.read",INVENTORY_ADJUST:"inventory.adjust",INVENTORY_TRANSFER:"inventory.transfer",INVENTORY_RESERVATIONS_READ:"inventory.reservations.read",INVENTORY_WAREHOUSES_MANAGE:"inventory.warehouses.manage", + BUSINESS_PRICING_READ:"pricing.business.read",BUSINESS_PRICING_MANAGE:"pricing.business.manage",PROMOTIONS_READ:"promotions.read",PROMOTIONS_MANAGE:"promotions.manage",BANNERS_MANAGE:"merchandising.banners.manage", }; diff --git a/app/controllers/catalogue/admin.controller.js b/app/controllers/catalogue/admin.controller.js new file mode 100644 index 0000000..ef43e1c --- /dev/null +++ b/app/controllers/catalogue/admin.controller.js @@ -0,0 +1,18 @@ +const crypto=require("crypto");const db=require("../../models");const service=require("../../services/catalogue/catalogue.service");const {logActivity}=require("../../services/activity.service"); +const audit=(req,type,targetType,targetId,description)=>logActivity({user:req.user,type,module:"Catalogue",description,targetType,targetId,requestId:req.id}); +exports.listProducts=async(req,res,next)=>{try{const page=Math.max(Number(req.query.page)||1,1),limit=Math.min(Number(req.query.limit)||20,100),where={};if(req.query.status)where.status=req.query.status;return res.json({success:true,...await db.Product.findAndCountAll({where,limit,offset:(page-1)*limit,include:[{model:db.ProductTranslation,as:"translations"},{model:db.ProductVariant,as:"variants"}],order:[["createdAt","DESC"]]})});}catch(e){return next(e);}}; +exports.createProduct=async(req,res,next)=>{try{const p=await service.createProduct(req.user,req.body);await audit(req,"PRODUCT_CREATED","PRODUCT",p.id,"Product created");return res.status(201).json({success:true,data:{id:p.id,slug:p.slug,status:p.status}});}catch(e){return next(e);}}; +exports.getProduct=async(req,res,next)=>{try{const p=await db.Product.findByPk(req.params.id,{include:[{model:db.ProductTranslation,as:"translations"},{model:db.ProductVariant,as:"variants"},{model:db.Category,as:"categories",through:{attributes:[]}},{model:db.ProductMedia,as:"media"}]});if(!p)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Product not found"}});return res.json({success:true,data:p});}catch(e){return next(e);}}; +exports.updateProduct=async(req,res,next)=>{try{const p=await service.setProductState(req.params.id,req.user,req.body);await audit(req,p.status==="ACTIVE"?"PRODUCT_PUBLISHED":p.status==="ARCHIVED"?"PRODUCT_ARCHIVED":"PRODUCT_UPDATED","PRODUCT",p.id,"Product updated");return res.json({success:true,data:{id:p.id,slug:p.slug,status:p.status,visibility:p.visibility}});}catch(e){return next(e);}}; +exports.archiveProduct=async(req,res,next)=>{try{const p=await service.setProductState(req.params.id,req.user,{status:"ARCHIVED",visibility:"HIDDEN"});await audit(req,"PRODUCT_ARCHIVED","PRODUCT",p.id,"Product archived");return res.json({success:true,data:{id:p.id,status:p.status}});}catch(e){return next(e);}}; +exports.createVariant=async(req,res,next)=>{try{const p=await db.Product.findByPk(req.params.productId);if(!p)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Product not found"}});const b=req.body,v=await db.ProductVariant.create({id:crypto.randomUUID(),product_id:p.id,sku:b.sku,barcode:b.barcode,base_price:b.basePrice,compare_at_price:b.compareAtPrice,currency:b.currency,weight:b.weight,sort_order:b.sortOrder});await audit(req,"VARIANT_CREATED","PRODUCT_VARIANT",v.id,"Product variant created");return res.status(201).json({success:true,data:v});}catch(e){return next(e);}}; +exports.updateVariant=async(req,res,next)=>{try{let v;await db.sequelize.transaction(async t=>{v=await db.ProductVariant.findOne({where:{id:req.params.variantId,product_id:req.params.productId},transaction:t,lock:t.LOCK.UPDATE});if(!v)throw Object.assign(new Error("Variant not found"),{status:404,code:"NOT_FOUND"});const b=req.body;await v.update({...(b.status!==undefined&&{status:b.status}),...(b.basePrice!==undefined&&{base_price:b.basePrice}),...(b.compareAtPrice!==undefined&&{compare_at_price:b.compareAtPrice}),...(b.currency!==undefined&&{currency:b.currency}),...(b.weight!==undefined&&{weight:b.weight}),...(b.sortOrder!==undefined&&{sort_order:b.sortOrder})},{transaction:t});if(b.optionValueIds){const values=await db.ProductOptionValue.findAll({where:{id:b.optionValueIds},include:[{model:db.ProductOption,as:"option",where:{product_id:req.params.productId}}],transaction:t});if(values.length!==new Set(b.optionValueIds).size)throw Object.assign(new Error("Option values must belong to this product"),{status:400,code:"CROSS_PRODUCT_OPTION"});await db.VariantOptionValue.destroy({where:{variant_id:v.id},transaction:t});await db.VariantOptionValue.bulkCreate(values.map(x=>({id:crypto.randomUUID(),variant_id:v.id,option_value_id:x.id})),{transaction:t});}});await audit(req,"VARIANT_UPDATED","PRODUCT_VARIANT",v.id,"Product variant updated");return res.json({success:true,data:v});}catch(e){return next(e);}}; +exports.createBrand=async(req,res,next)=>{try{if(req.body.logoUploadId)await service.validateMedia([req.body.logoUploadId],req.user.id);const b=req.body,x=await db.Brand.create({id:crypto.randomUUID(),name:b.name,slug:b.slug,description:b.description,logo_upload_id:b.logoUploadId,website:b.website,status:b.status,sort_order:b.sortOrder});await audit(req,"BRAND_CREATED","BRAND",x.id,"Brand created");return res.status(201).json({success:true,data:x});}catch(e){return next(e);}}; +exports.updateBrand=async(req,res,next)=>{try{const x=await db.Brand.findByPk(req.params.id);if(!x)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Brand not found"}});await x.update(req.body);await audit(req,"BRAND_UPDATED","BRAND",x.id,"Brand updated");return res.json({success:true,data:x});}catch(e){return next(e);}}; +exports.createCategory=async(req,res,next)=>{try{let x;await db.sequelize.transaction(async t=>{await service.assertCategoryParent(null,req.body.parentId,t);const b=req.body;x=await db.Category.create({id:crypto.randomUUID(),parent_id:b.parentId,code:b.code,slug:b.slug,status:b.status,sort_order:b.sortOrder,image_upload_id:b.imageUploadId},{transaction:t});await db.CategoryTranslation.bulkCreate(b.translations.map(y=>({id:crypto.randomUUID(),category_id:x.id,locale:y.locale,name:y.name,description:y.description,meta_title:y.metaTitle,meta_description:y.metaDescription})),{transaction:t});});await audit(req,"CATEGORY_CREATED","CATEGORY",x.id,"Category created");return res.status(201).json({success:true,data:x});}catch(e){return next(e);}}; +exports.updateCategory=async(req,res,next)=>{try{let x;await db.sequelize.transaction(async t=>{x=await db.Category.findByPk(req.params.id,{transaction:t,lock:t.LOCK.UPDATE});if(!x)throw Object.assign(new Error("Category not found"),{status:404,code:"NOT_FOUND"});await service.assertCategoryParent(x.id,req.body.parentId,t);await x.update({parent_id:req.body.parentId,code:req.body.code,slug:req.body.slug,status:req.body.status,sort_order:req.body.sortOrder,image_upload_id:req.body.imageUploadId},{transaction:t});});await audit(req,"CATEGORY_UPDATED","CATEGORY",x.id,"Category updated");return res.json({success:true,data:x});}catch(e){return next(e);}}; +exports.createCollection=async(req,res,next)=>{try{let x;await db.sequelize.transaction(async t=>{const b=req.body;if(b.endsAt&&b.startsAt&&b.endsAt<=b.startsAt)throw Object.assign(new Error("Collection end must follow start"),{status:400,code:"INVALID_WINDOW"});x=await db.Collection.create({id:crypto.randomUUID(),slug:b.slug,type:b.type,status:b.status,starts_at:b.startsAt,ends_at:b.endsAt,hero_upload_id:b.heroUploadId,sort_order:b.sortOrder},{transaction:t});await db.CollectionTranslation.bulkCreate(b.translations.map(y=>({id:crypto.randomUUID(),collection_id:x.id,locale:y.locale,name:y.name,description:y.description,headline:y.headline,subheadline:y.subheadline})),{transaction:t});await db.CollectionProduct.bulkCreate([...new Set(b.productIds)].map((product_id,i)=>({id:crypto.randomUUID(),collection_id:x.id,product_id,sort_order:i})),{transaction:t});});await audit(req,"COLLECTION_CREATED","COLLECTION",x.id,"Collection created");return res.status(201).json({success:true,data:x});}catch(e){return next(e);}}; +exports.createSizeGuide=async(req,res,next)=>{try{const b=req.body,x=await db.SizeGuide.create({id:crypto.randomUUID(),code:b.code,name:b.name,locale:b.locale,data:b.data,category_id:b.categoryId,status:b.status});return res.status(201).json({success:true,data:x});}catch(e){return next(e);}}; +exports.createOption=async(req,res,next)=>{try{let option;await db.sequelize.transaction(async t=>{const product=await db.Product.findByPk(req.params.productId,{transaction:t});if(!product)throw Object.assign(new Error("Product not found"),{status:404,code:"NOT_FOUND"});option=await db.ProductOption.create({id:crypto.randomUUID(),product_id:product.id,name:req.body.name,sort_order:req.body.sortOrder},{transaction:t});await db.ProductOptionValue.bulkCreate([...new Set(req.body.values)].map((value,i)=>({id:crypto.randomUUID(),option_id:option.id,value,sort_order:i})),{transaction:t});});return res.status(201).json({success:true,data:option});}catch(e){return next(e);}}; +exports.attachMedia=async(req,res,next)=>{try{let media;await db.sequelize.transaction(async t=>{const product=await db.Product.findByPk(req.params.productId,{transaction:t,lock:t.LOCK.UPDATE});if(!product)throw Object.assign(new Error("Product not found"),{status:404,code:"NOT_FOUND"});await service.validateMedia([req.body.uploadId],req.user.id,t);if(req.body.variantId){const variant=await db.ProductVariant.findOne({where:{id:req.body.variantId,product_id:product.id},transaction:t});if(!variant)throw Object.assign(new Error("Variant does not belong to product"),{status:400,code:"CROSS_PRODUCT_VARIANT"});}if(req.body.isPrimary)await db.ProductMedia.update({is_primary:false},{where:{product_id:product.id},transaction:t});media=await db.ProductMedia.create({id:crypto.randomUUID(),product_id:product.id,variant_id:req.body.variantId,upload_id:req.body.uploadId,type:"IMAGE",sort_order:req.body.sortOrder,alt_text:req.body.altText,is_primary:req.body.isPrimary},{transaction:t});await db.Upload.update({owner_type:"CATALOGUE",owner_id:product.id,use_for:"PRODUCT_MEDIA"},{where:{id:req.body.uploadId},transaction:t});});return res.status(201).json({success:true,data:{id:media.id,isPrimary:media.is_primary}});}catch(e){return next(e);}}; +exports.createRelation=async(req,res,next)=>{try{if(req.params.productId===req.body.targetProductId)return res.status(400).json({success:false,error:{code:"SELF_RELATION",message:"A product cannot relate to itself"}});const count=await db.Product.count({where:{id:[req.params.productId,req.body.targetProductId]}});if(count!==2)return res.status(400).json({success:false,error:{code:"INVALID_PRODUCT",message:"Both products must exist"}});const x=await db.ProductRelation.create({id:crypto.randomUUID(),source_product_id:req.params.productId,target_product_id:req.body.targetProductId,relation_type:req.body.relationType,sort_order:req.body.sortOrder});return res.status(201).json({success:true,data:x});}catch(e){return next(e);}}; diff --git a/app/controllers/catalogue/public.controller.js b/app/controllers/catalogue/public.controller.js new file mode 100644 index 0000000..841956c --- /dev/null +++ b/app/controllers/catalogue/public.controller.js @@ -0,0 +1,10 @@ +const {Op,fn,col,literal}=require("sequelize");const db=require("../../models");const {resolveLocale,selectTranslation}=require("../../services/catalogue/locale.service");const serializer=require("../../services/catalogue/serializer.service"); +const productIncludes=()=>[{model:db.ProductTranslation,as:"translations",required:true},{model:db.Brand,as:"brand",where:{status:"ACTIVE"},required:true},{model:db.ProductVariant,as:"variants",where:{status:"ACTIVE"},required:true},{model:db.ProductMedia,as:"media",required:false,include:[{model:db.Upload,as:"upload",where:{status:"AVAILABLE"},required:true}]}]; +exports.products=async(req,res,next)=>{try{const page=Math.max(Number(req.query.page)||1,1),limit=Math.min(Math.max(Number(req.query.limit)||20,1),100),where={status:"ACTIVE",visibility:"PUBLIC"};if(req.query.brand)where.brand_id=req.query.brand;if(req.query.featured!==undefined)where.featured=req.query.featured==="true";if(req.query.newArrival==="true")where.new_arrival_until={[Op.gt]:new Date()};if(req.query.search)where[Op.or]=[{"$translations.name$":{[Op.like]:`%${req.query.search.slice(0,100)}%`}},{product_code:{[Op.like]:`%${req.query.search.slice(0,100)}%`}},{"$brand.name$":{[Op.like]:`%${req.query.search.slice(0,100)}%`}}];const allowed={newest:[["published_at","DESC"]],price_asc:[[{model:db.ProductVariant,as:"variants"},"base_price","ASC"]],price_desc:[[{model:db.ProductVariant,as:"variants"},"base_price","DESC"]],name:[[{model:db.ProductTranslation,as:"translations"},"name","ASC"]],featured:[["featured","DESC"],["published_at","DESC"]]};if(req.query.sort&&!allowed[req.query.sort])return res.status(400).json({success:false,error:{code:"INVALID_SORT",message:"Unsupported sort"}});const include=productIncludes();if(req.query.category)include.push({model:db.Category,as:"categories",where:{id:req.query.category,status:"ACTIVE"},through:{attributes:[]},required:true});if(req.query.minPrice||req.query.maxPrice){const v=include.find(x=>x.as==="variants");v.where.base_price={...(req.query.minPrice&&{[Op.gte]:req.query.minPrice}),...(req.query.maxPrice&&{[Op.lte]:req.query.maxPrice})};}const result=await db.Product.findAndCountAll({where,include,distinct:true,limit,offset:(page-1)*limit,order:allowed[req.query.sort||"newest"]});const locale=resolveLocale(req);return res.json({success:true,data:await Promise.all(result.rows.map(x=>serializer.summary(x,locale))),pagination:{page,limit,total:result.count,pages:Math.ceil(result.count/limit)}});}catch(e){return next(e);}}; +exports.product=async(req,res,next)=>{try{const p=await db.Product.findOne({where:{slug:req.params.slug,status:"ACTIVE",visibility:"PUBLIC"},include:[...productIncludes(),{model:db.Category,as:"categories",where:{status:"ACTIVE"},through:{attributes:[]},required:false},{model:db.ProductOption,as:"options",include:[{model:db.ProductOptionValue,as:"values"}]},{model:db.ProductAttribute,as:"attributes"},{model:db.SizeGuide,as:"sizeGuide",required:false},{model:db.ProductReview,as:"reviews",where:{status:"APPROVED"},required:false,include:[{model:db.User,as:"author",attributes:["id","firstName"]}]}]});if(!p)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Product not found"}});const locale=resolveLocale(req),tr=selectTranslation(p.translations,locale),summary=await serializer.summary(p,locale),reviews=p.reviews||[],average=reviews.length?(reviews.reduce((n,r)=>n+r.rating,0)/reviews.length).toFixed(2):null;return res.json({success:true,data:{...summary,content:tr&&{shortDescription:tr.short_description,description:tr.description,careInstructions:tr.care_instructions,materials:tr.materials,origin:tr.origin},seo:tr&&{title:tr.meta_title,description:tr.meta_description},categories:p.categories?.map(c=>({id:c.id,slug:c.slug,name:selectTranslation(c.translations,locale)?.name})),variants:p.variants?.map(v=>({id:v.id,sku:v.sku,basePrice:String(v.base_price),compareAtPrice:v.compare_at_price&&String(v.compare_at_price),currency:v.currency,optionValues:v.optionValues})),options:p.options,attributes:p.attributes?.filter(a=>a.locale===locale||a.locale==="en"),media:await serializer.mediaDto(p.media),sizeGuide:p.sizeGuide,rating:{averageRating:average,reviewCount:reviews.length},reviews:reviews.slice(0,10).map(r=>({id:r.id,rating:r.rating,title:r.title,body:r.body,verifiedPurchase:r.verified_purchase,author:r.author&&{firstName:r.author.firstName},createdAt:r.createdAt}))}});}catch(e){return next(e);}}; +exports.categories=async(req,res,next)=>{try{const locale=resolveLocale(req),rows=await db.Category.findAll({where:{status:"ACTIVE"},include:[{model:db.CategoryTranslation,as:"translations"}],order:[["sort_order","ASC"]]});const nodes=rows.map(x=>({id:x.id,parentId:x.parent_id,slug:x.slug,name:selectTranslation(x.translations,locale)?.name,children:[]})),byId=new Map(nodes.map(x=>[x.id,x]));for(const n of nodes)if(n.parentId&&byId.has(n.parentId))byId.get(n.parentId).children.push(n);return res.json({success:true,data:req.query.flat==="true"?nodes:nodes.filter(x=>!x.parentId)});}catch(e){return next(e);}}; +exports.category=async(req,res,next)=>{try{const x=await db.Category.findOne({where:{slug:req.params.slug,status:"ACTIVE"},include:[{model:db.CategoryTranslation,as:"translations"}]});if(!x)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Category not found"}});const t=selectTranslation(x.translations,resolveLocale(req));return res.json({success:true,data:{id:x.id,slug:x.slug,parentId:x.parent_id,name:t?.name,description:t?.description,seo:{title:t?.meta_title,description:t?.meta_description}}});}catch(e){return next(e);}}; +exports.brands=async(req,res,next)=>{try{return res.json({success:true,data:await db.Brand.findAll({where:{status:"ACTIVE"},attributes:["id","name","slug","description","website"],order:[["sort_order","ASC"]]})});}catch(e){return next(e);}}; +exports.brand=async(req,res,next)=>{try{const x=await db.Brand.findOne({where:{slug:req.params.slug,status:"ACTIVE"},attributes:["id","name","slug","description","website"]});if(!x)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Brand not found"}});return res.json({success:true,data:x});}catch(e){return next(e);}}; +exports.collections=async(req,res,next)=>{try{const now=new Date(),rows=await db.Collection.findAll({where:{status:"ACTIVE",[Op.and]:[{[Op.or]:[{starts_at:null},{starts_at:{[Op.lte]:now}}]},{[Op.or]:[{ends_at:null},{ends_at:{[Op.gte]:now}}]}]},include:[{model:db.CollectionTranslation,as:"translations"}],order:[["sort_order","ASC"]]});const locale=resolveLocale(req);return res.json({success:true,data:rows.map(x=>({id:x.id,slug:x.slug,type:x.type,...selectTranslation(x.translations,locale)}))});}catch(e){return next(e);}}; +exports.collection=async(req,res,next)=>{try{const now=new Date(),x=await db.Collection.findOne({where:{slug:req.params.slug,status:"ACTIVE",[Op.and]:[{[Op.or]:[{starts_at:null},{starts_at:{[Op.lte]:now}}]},{[Op.or]:[{ends_at:null},{ends_at:{[Op.gte]:now}}]}]},include:[{model:db.CollectionTranslation,as:"translations"},{model:db.Product,as:"products",where:{status:"ACTIVE",visibility:"PUBLIC"},required:false,through:{attributes:["sort_order"]},include:productIncludes()}]});if(!x)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Collection not found"}});const locale=resolveLocale(req);return res.json({success:true,data:{id:x.id,slug:x.slug,...selectTranslation(x.translations,locale),products:await Promise.all((x.products||[]).map(p=>serializer.summary(p,locale)))}});}catch(e){return next(e);}}; diff --git a/app/controllers/catalogue/review.controller.js b/app/controllers/catalogue/review.controller.js new file mode 100644 index 0000000..c44e943 --- /dev/null +++ b/app/controllers/catalogue/review.controller.js @@ -0,0 +1,4 @@ +const crypto=require("crypto");const db=require("../../models");const {logActivity}=require("../../services/activity.service"); +exports.create=async(req,res,next)=>{try{if(!["customer","business_customer"].includes(req.user.accountType))return res.status(403).json({success:false,error:{code:"FORBIDDEN",message:"Customer account required"}});const product=await db.Product.findOne({where:{id:req.params.productId,status:"ACTIVE",visibility:"PUBLIC"}});if(!product)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Product not found"}});const review=await db.ProductReview.create({id:crypto.randomUUID(),product_id:product.id,user_id:req.user.id,rating:req.body.rating,title:req.body.title,body:req.body.body,status:"PENDING",verified_purchase:false});await logActivity({user:req.user,type:"REVIEW_SUBMITTED",module:"Catalogue",description:"Product review submitted",targetType:"PRODUCT_REVIEW",targetId:review.id,requestId:req.id});return res.status(201).json({success:true,data:{id:review.id,status:review.status}});}catch(e){return next(e);}}; +exports.listAdmin=async(req,res,next)=>{try{const page=Math.max(Number(req.query.page)||1,1),limit=Math.min(Number(req.query.limit)||20,100),where={};if(req.query.status)where.status=req.query.status;const x=await db.ProductReview.findAndCountAll({where,limit,offset:(page-1)*limit,order:[["createdAt","DESC"]]});return res.json({success:true,data:x.rows,pagination:{page,limit,total:x.count}});}catch(e){return next(e);}}; +exports.moderate=async(req,res,next)=>{try{const r=await db.ProductReview.findByPk(req.params.id);if(!r)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Review not found"}});await r.update({status:req.body.status,moderated_by:req.user.id,moderated_at:new Date()});await logActivity({user:req.user,type:req.body.status==="APPROVED"?"REVIEW_APPROVED":"REVIEW_REJECTED",module:"Catalogue",description:"Product review moderated",targetType:"PRODUCT_REVIEW",targetId:r.id,requestId:req.id});return res.json({success:true,data:{id:r.id,status:r.status}});}catch(e){return next(e);}}; diff --git a/app/controllers/inventory/admin.controller.js b/app/controllers/inventory/admin.controller.js new file mode 100644 index 0000000..b76f759 --- /dev/null +++ b/app/controllers/inventory/admin.controller.js @@ -0,0 +1,11 @@ +const crypto=require("crypto"),db=require("../../models"),inventory=require("../../services/inventory/inventory.service"),{logActivity}=require("../../services/activity.service"); +const audit=(req,type,id)=>logActivity({user:req.user,type,module:"Inventory",description:type,targetType:"INVENTORY",targetId:id,requestId:req.id}); +exports.list=async(req,res,next)=>{try{const rows=await db.InventoryBalance.findAll({include:[{model:db.Warehouse,as:"warehouse",attributes:["id","code","name"]},{model:db.ProductVariant,as:"variant",include:[{model:db.Product,as:"product",attributes:["id","slug"]}]}]});res.json({success:true,data:rows});}catch(e){next(e);}}; +exports.detail=async(req,res,next)=>{try{const rows=await db.InventoryBalance.findAll({where:{variant_id:req.params.variantId},include:[{model:db.Warehouse,as:"warehouse"}]});res.json({success:true,data:rows});}catch(e){next(e);}}; +exports.ledger=async(req,res,next)=>{try{res.json({success:true,data:await db.InventoryTransaction.findAll({where:req.query.variantId?{variant_id:req.query.variantId}:{},limit:Math.min(Number(req.query.limit)||50,200),order:[["occurred_at","DESC"]]})});}catch(e){next(e);}}; +exports.lowStock=async(req,res,next)=>{try{const rows=await db.InventoryBalance.findAll();res.json({success:true,data:rows.filter(x=>Number(x.on_hand)-Number(x.reserved)<=Number(x.low_stock_threshold))});}catch(e){next(e);}}; +exports.adjust=async(req,res,next)=>{try{const eventId=req.get("Idempotency-Key")||crypto.randomUUID(),result=await inventory.adjustStock({...req.body,eventId,actorUserId:req.user.id,requestId:req.id});await audit(req,"INVENTORY_ADJUSTED",eventId);res.status(result.idempotent?200:201).json({success:true,data:result});}catch(e){next(e);}}; +exports.transfer=async(req,res,next)=>{try{const eventId=req.get("Idempotency-Key")||crypto.randomUUID(),result=await inventory.transferStock({...req.body,eventId,actorUserId:req.user.id,requestId:req.id});await audit(req,"INVENTORY_TRANSFERRED",eventId);res.status(result.idempotent?200:201).json({success:true,data:result});}catch(e){next(e);}}; +exports.warehouses=async(req,res,next)=>{try{res.json({success:true,data:await db.Warehouse.findAll({order:[["code","ASC"]]})});}catch(e){next(e);}}; +exports.createWarehouse=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{if(req.body.isDefault)await db.Warehouse.update({is_default:false},{where:{},transaction:t});row=await db.Warehouse.create({id:crypto.randomUUID(),code:req.body.code.toUpperCase(),name:req.body.name,status:req.body.status,is_default:req.body.isDefault,timezone:req.body.timezone,city:req.body.city,country_code:req.body.countryCode},{transaction:t});});await audit(req,"WAREHOUSE_CREATED",row.id);res.status(201).json({success:true,data:row});}catch(e){next(e);}}; +exports.updateWarehouse=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{row=await db.Warehouse.findByPk(req.params.id,{transaction:t,lock:t.LOCK.UPDATE});if(!row)throw Object.assign(new Error("Warehouse not found"),{status:404,code:"NOT_FOUND"});if(req.body.isDefault)await db.Warehouse.update({is_default:false},{where:{},transaction:t});await row.update({name:req.body.name,status:req.body.status,is_default:req.body.isDefault,timezone:req.body.timezone,city:req.body.city,country_code:req.body.countryCode},{transaction:t});});await audit(req,"WAREHOUSE_UPDATED",row.id);res.json({success:true,data:row});}catch(e){next(e);}}; diff --git a/app/controllers/merchandising/admin.controller.js b/app/controllers/merchandising/admin.controller.js new file mode 100644 index 0000000..daaf8db --- /dev/null +++ b/app/controllers/merchandising/admin.controller.js @@ -0,0 +1,5 @@ +const crypto=require("crypto"),db=require("../../models"),{logActivity}=require("../../services/activity.service");const map=(b,u)=>({name:b.name,type:b.type,status:b.status,starts_at:b.startsAt,ends_at:b.endsAt,priority:b.priority,stackable:b.stackable,discount_percent:b.discountPercent,discount_amount:b.discountAmount,fixed_price:b.fixedPrice,minimum_quantity:b.minimumQuantity,updated_by:u}); +const crud=model=>async(req,res,next)=>{try{res.json({success:true,data:await model.findAll({order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.promotions=crud(db.Promotion);exports.coupons=crud(db.Coupon);exports.banners=crud(db.Banner); +exports.createPromotion=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{row=await db.Promotion.create({id:crypto.randomUUID(),...map(req.body,req.user.id),created_by:req.user.id},{transaction:t});await db.PromotionTarget.bulkCreate(req.body.targets.map(x=>({id:crypto.randomUUID(),promotion_id:row.id,target_type:x.type,target_id:x.id||null})),{transaction:t});});await logActivity({user:req.user,type:"PROMOTION_CREATED",module:"Merchandising",targetId:row.id});res.status(201).json({success:true,data:row});}catch(e){next(e);}}; +exports.createCoupon=async(req,res,next)=>{try{const b=req.body,row=await db.Coupon.create({id:crypto.randomUUID(),code:b.code,promotion_id:b.promotionId,status:b.status,starts_at:b.startsAt,expires_at:b.expiresAt});await logActivity({user:req.user,type:"COUPON_CREATED",module:"Merchandising",targetId:row.id});res.status(201).json({success:true,data:row});}catch(e){next(e);}}; +exports.createBanner=async(req,res,next)=>{try{const b=req.body;let row;await db.sequelize.transaction(async t=>{const upload=await db.Upload.findOne({where:{id:b.imageUploadId,status:"AVAILABLE"},transaction:t});if(!upload||!String(upload.mime_type||upload.mimeType).startsWith("image/"))throw Object.assign(new Error("Available image upload required"),{status:400,code:"INVALID_BANNER_MEDIA"});row=await db.Banner.create({id:crypto.randomUUID(),placement:b.placement,status:b.status,starts_at:b.startsAt,ends_at:b.endsAt,image_upload_id:b.imageUploadId,mobile_image_upload_id:b.mobileImageUploadId,target_url:b.targetUrl,audience_type:b.audienceType,business_tier_id:b.businessTierId,sort_order:b.sortOrder,created_by:req.user.id},{transaction:t});await db.BannerTranslation.bulkCreate(b.translations.map(x=>({id:crypto.randomUUID(),banner_id:row.id,locale:x.locale,headline:x.headline,subheadline:x.subheadline,cta_text:x.ctaText,alt_text:x.altText})),{transaction:t});});await logActivity({user:req.user,type:"BANNER_CREATED",module:"Merchandising",targetId:row.id});res.status(201).json({success:true,data:row});}catch(e){next(e);}}; diff --git a/app/controllers/merchandising/public.controller.js b/app/controllers/merchandising/public.controller.js new file mode 100644 index 0000000..03c6852 --- /dev/null +++ b/app/controllers/merchandising/public.controller.js @@ -0,0 +1,2 @@ +const {Op}=require("sequelize"),db=require("../../models");exports.banners=async(req,res,next)=>{try{const now=new Date(),audience=req.user?.accountType==="BUSINESS_CUSTOMER"?["ALL","BUSINESS_CUSTOMER"]:req.user?["ALL","CUSTOMER"]:["ALL"];const where={status:"ACTIVE",audience_type:{[Op.in]:audience},[Op.and]:[{[Op.or]:[{starts_at:null},{starts_at:{[Op.lte]:now}}]},{[Op.or]:[{ends_at:null},{ends_at:{[Op.gt]:now}}]}]};if(req.query.placement)where.placement=req.query.placement;const rows=await db.Banner.findAll({where,include:[{model:db.BannerTranslation,as:"translations"}],order:[["sort_order","ASC"],["id","ASC"]]});res.json({success:true,data:rows.map(x=>({id:x.id,placement:x.placement,targetUrl:x.target_url,image:{uploadId:x.image_upload_id},translation:(x.translations.find(t=>t.locale===(req.query.locale||"en"))||x.translations.find(t=>t.locale==="en")||x.translations[0])}))});}catch(e){next(e);}}; +exports.availability=async(req,res,next)=>{try{const rows=await db.InventoryBalance.findAll({where:{variant_id:req.params.variantId},attributes:["on_hand","reserved","low_stock_threshold"]}),available=rows.reduce((n,x)=>n+Number(x.on_hand)-Number(x.reserved),0),threshold=rows.reduce((n,x)=>n+Number(x.low_stock_threshold),0),status=available<=0?"OUT_OF_STOCK":available<=threshold?"LOW_STOCK":"IN_STOCK";res.json({success:true,data:{variantId:req.params.variantId,status,availableForSale:available>0}});}catch(e){next(e);}}; diff --git a/app/controllers/pricing/admin.controller.js b/app/controllers/pricing/admin.controller.js new file mode 100644 index 0000000..418ed5d --- /dev/null +++ b/app/controllers/pricing/admin.controller.js @@ -0,0 +1,3 @@ +const crypto=require("crypto"),db=require("../../models"),{logActivity}=require("../../services/activity.service"); +exports.list=async(req,res,next)=>{try{res.json({success:true,data:await db.VariantBusinessPrice.findAll({include:[{model:db.BusinessPriceTier,as:"tiers"}],order:[["createdAt","DESC"]]})});}catch(e){next(e);}}; +exports.create=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{const b=req.body;for(let i=0;i<b.tiers.length;i++){const a=b.tiers[i];if(a.maxQuantity!==null&&a.maxQuantity!==undefined&&a.maxQuantity<a.minQuantity)throw Object.assign(new Error("Invalid volume range"),{status:400,code:"INVALID_VOLUME_RANGE"});for(let j=i+1;j<b.tiers.length;j++){const c=b.tiers[j],amax=a.maxQuantity??Infinity,cmax=c.maxQuantity??Infinity;if(a.minQuantity<=cmax&&c.minQuantity<=amax)throw Object.assign(new Error("Volume tiers overlap"),{status:400,code:"OVERLAPPING_VOLUME_TIERS"});}}row=await db.VariantBusinessPrice.create({id:crypto.randomUUID(),variant_id:b.variantId,business_tier_id:b.businessTierId,business_customer_id:b.businessCustomerId,currency:b.currency,price:b.price,minimum_quantity:b.minimumQuantity,status:b.status,starts_at:b.startsAt,ends_at:b.endsAt},{transaction:t});await db.BusinessPriceTier.bulkCreate(b.tiers.map(x=>({id:crypto.randomUUID(),business_price_id:row.id,min_quantity:x.minQuantity,max_quantity:x.maxQuantity,unit_price:x.unitPrice})),{transaction:t});});await logActivity({user:req.user,type:"BUSINESS_PRICE_CREATED",module:"Pricing",targetId:row.id});res.status(201).json({success:true,data:row});}catch(e){next(e);}}; diff --git a/app/models/catalogue/brand.model.js b/app/models/catalogue/brand.model.js new file mode 100644 index 0000000..12b6b00 --- /dev/null +++ b/app/models/catalogue/brand.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("Brand",{id:{type:D.STRING,primaryKey:true},name:{type:D.STRING(160),allowNull:false},slug:{type:D.STRING(180),allowNull:false,unique:true},description:D.TEXT,logo_upload_id:D.INTEGER,website:D.STRING,status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"INACTIVE"},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"brands",timestamps:true});M.associate=m=>{M.hasMany(m.Product,{foreignKey:"brand_id",as:"products"});M.belongsTo(m.Upload,{foreignKey:"logo_upload_id",as:"logo",constraints:false});};return M;}; diff --git a/app/models/catalogue/category.model.js b/app/models/catalogue/category.model.js new file mode 100644 index 0000000..b7d10aa --- /dev/null +++ b/app/models/catalogue/category.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("Category",{id:{type:D.STRING,primaryKey:true},parent_id:D.STRING,code:{type:D.STRING(80),allowNull:false,unique:true},slug:{type:D.STRING(180),allowNull:false,unique:true},status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"INACTIVE"},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0},image_upload_id:D.INTEGER},{tableName:"categories",timestamps:true});M.associate=m=>{M.belongsTo(M,{foreignKey:"parent_id",as:"parent"});M.hasMany(M,{foreignKey:"parent_id",as:"children"});M.hasMany(m.CategoryTranslation,{foreignKey:"category_id",as:"translations"});M.belongsToMany(m.Product,{through:m.ProductCategory,foreignKey:"category_id",otherKey:"product_id",as:"products"});M.belongsTo(m.Upload,{foreignKey:"image_upload_id",as:"image",constraints:false});};return M;}; diff --git a/app/models/catalogue/categoryTranslation.model.js b/app/models/catalogue/categoryTranslation.model.js new file mode 100644 index 0000000..5d934ad --- /dev/null +++ b/app/models/catalogue/categoryTranslation.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("CategoryTranslation",{id:{type:D.STRING,primaryKey:true},category_id:{type:D.STRING,allowNull:false},locale:{type:D.ENUM("en","si","ta"),allowNull:false},name:{type:D.STRING(180),allowNull:false},description:D.TEXT,meta_title:D.STRING(180),meta_description:D.STRING(320)},{tableName:"category_translations",timestamps:true,indexes:[{unique:true,fields:["category_id","locale"]}]});M.associate=m=>M.belongsTo(m.Category,{foreignKey:"category_id",as:"category"});return M;}; diff --git a/app/models/catalogue/collection.model.js b/app/models/catalogue/collection.model.js new file mode 100644 index 0000000..5986549 --- /dev/null +++ b/app/models/catalogue/collection.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("Collection",{id:{type:D.STRING,primaryKey:true},slug:{type:D.STRING(180),allowNull:false,unique:true},type:{type:D.STRING(80),allowNull:false,defaultValue:"EDITORIAL"},status:{type:D.ENUM("DRAFT","ACTIVE","INACTIVE"),allowNull:false,defaultValue:"DRAFT"},starts_at:D.DATE,ends_at:D.DATE,hero_upload_id:D.INTEGER,sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"collections",timestamps:true});M.associate=m=>{M.hasMany(m.CollectionTranslation,{foreignKey:"collection_id",as:"translations"});M.belongsToMany(m.Product,{through:m.CollectionProduct,foreignKey:"collection_id",otherKey:"product_id",as:"products"});M.belongsTo(m.Upload,{foreignKey:"hero_upload_id",as:"hero",constraints:false});};return M;}; diff --git a/app/models/catalogue/collectionProduct.model.js b/app/models/catalogue/collectionProduct.model.js new file mode 100644 index 0000000..97d7a07 --- /dev/null +++ b/app/models/catalogue/collectionProduct.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("CollectionProduct",{id:{type:D.STRING,primaryKey:true},collection_id:{type:D.STRING,allowNull:false},product_id:{type:D.STRING,allowNull:false},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0},featured:{type:D.BOOLEAN,allowNull:false,defaultValue:false}},{tableName:"collection_products",timestamps:true,indexes:[{unique:true,fields:["collection_id","product_id"]}]}); diff --git a/app/models/catalogue/collectionTranslation.model.js b/app/models/catalogue/collectionTranslation.model.js new file mode 100644 index 0000000..4b01b1c --- /dev/null +++ b/app/models/catalogue/collectionTranslation.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("CollectionTranslation",{id:{type:D.STRING,primaryKey:true},collection_id:{type:D.STRING,allowNull:false},locale:{type:D.ENUM("en","si","ta"),allowNull:false},name:{type:D.STRING(180),allowNull:false},description:D.TEXT,headline:D.STRING(250),subheadline:D.STRING(300)},{tableName:"collection_translations",timestamps:true,indexes:[{unique:true,fields:["collection_id","locale"]}]});M.associate=m=>M.belongsTo(m.Collection,{foreignKey:"collection_id",as:"collection"});return M;}; diff --git a/app/models/catalogue/product.model.js b/app/models/catalogue/product.model.js new file mode 100644 index 0000000..16a63d4 --- /dev/null +++ b/app/models/catalogue/product.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("Product",{id:{type:D.STRING,primaryKey:true},brand_id:{type:D.STRING,allowNull:false},default_category_id:D.STRING,slug:{type:D.STRING(200),allowNull:false,unique:true},product_code:{type:D.STRING(100),allowNull:false,unique:true},status:{type:D.ENUM("DRAFT","ACTIVE","INACTIVE","ARCHIVED"),allowNull:false,defaultValue:"DRAFT"},visibility:{type:D.ENUM("PUBLIC","HIDDEN"),allowNull:false,defaultValue:"HIDDEN"},product_type:{type:D.STRING(80),allowNull:false,defaultValue:"STANDARD"},featured:{type:D.BOOLEAN,allowNull:false,defaultValue:false},new_arrival_until:D.DATE,published_at:D.DATE,created_by:{type:D.STRING,allowNull:false},updated_by:D.STRING,size_guide_id:D.STRING},{tableName:"products",timestamps:true});M.associate=m=>{M.belongsTo(m.Brand,{foreignKey:"brand_id",as:"brand"});M.belongsTo(m.Category,{foreignKey:"default_category_id",as:"defaultCategory"});M.hasMany(m.ProductTranslation,{foreignKey:"product_id",as:"translations"});M.hasMany(m.ProductVariant,{foreignKey:"product_id",as:"variants"});M.hasMany(m.ProductOption,{foreignKey:"product_id",as:"options"});M.hasMany(m.ProductAttribute,{foreignKey:"product_id",as:"attributes"});M.hasMany(m.ProductMedia,{foreignKey:"product_id",as:"media"});M.belongsToMany(m.Category,{through:m.ProductCategory,foreignKey:"product_id",otherKey:"category_id",as:"categories"});M.belongsTo(m.SizeGuide,{foreignKey:"size_guide_id",as:"sizeGuide"});M.hasMany(m.ProductReview,{foreignKey:"product_id",as:"reviews"});};return M;}; diff --git a/app/models/catalogue/productAttribute.model.js b/app/models/catalogue/productAttribute.model.js new file mode 100644 index 0000000..0203643 --- /dev/null +++ b/app/models/catalogue/productAttribute.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("ProductAttribute",{id:{type:D.STRING,primaryKey:true},product_id:{type:D.STRING,allowNull:false},name:{type:D.STRING(100),allowNull:false},value:{type:D.STRING(500),allowNull:false},locale:{type:D.ENUM("en","si","ta"),allowNull:false,defaultValue:"en"},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"product_attributes",timestamps:true});M.associate=m=>M.belongsTo(m.Product,{foreignKey:"product_id",as:"product"});return M;}; diff --git a/app/models/catalogue/productCategory.model.js b/app/models/catalogue/productCategory.model.js new file mode 100644 index 0000000..340087c --- /dev/null +++ b/app/models/catalogue/productCategory.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("ProductCategory",{id:{type:D.STRING,primaryKey:true},product_id:{type:D.STRING,allowNull:false},category_id:{type:D.STRING,allowNull:false}},{tableName:"product_categories",timestamps:true,indexes:[{unique:true,fields:["product_id","category_id"]}]});return M;}; diff --git a/app/models/catalogue/productMedia.model.js b/app/models/catalogue/productMedia.model.js new file mode 100644 index 0000000..aa741e8 --- /dev/null +++ b/app/models/catalogue/productMedia.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("ProductMedia",{id:{type:D.STRING,primaryKey:true},product_id:{type:D.STRING,allowNull:false},variant_id:D.STRING,upload_id:{type:D.INTEGER,allowNull:false},type:{type:D.ENUM("IMAGE"),allowNull:false,defaultValue:"IMAGE"},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0},alt_text:D.STRING(250),is_primary:{type:D.BOOLEAN,allowNull:false,defaultValue:false}},{tableName:"product_media",timestamps:true,indexes:[{unique:true,fields:["product_id","upload_id"]}]});M.associate=m=>{M.belongsTo(m.Product,{foreignKey:"product_id",as:"product"});M.belongsTo(m.ProductVariant,{foreignKey:"variant_id",as:"variant"});M.belongsTo(m.Upload,{foreignKey:"upload_id",as:"upload",constraints:false});};return M;}; diff --git a/app/models/catalogue/productOption.model.js b/app/models/catalogue/productOption.model.js new file mode 100644 index 0000000..2122436 --- /dev/null +++ b/app/models/catalogue/productOption.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("ProductOption",{id:{type:D.STRING,primaryKey:true},product_id:{type:D.STRING,allowNull:false},name:{type:D.STRING(100),allowNull:false},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"product_options",timestamps:true,indexes:[{unique:true,fields:["product_id","name"]}]});M.associate=m=>{M.belongsTo(m.Product,{foreignKey:"product_id",as:"product"});M.hasMany(m.ProductOptionValue,{foreignKey:"option_id",as:"values"});};return M;}; diff --git a/app/models/catalogue/productOptionValue.model.js b/app/models/catalogue/productOptionValue.model.js new file mode 100644 index 0000000..0da4de5 --- /dev/null +++ b/app/models/catalogue/productOptionValue.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("ProductOptionValue",{id:{type:D.STRING,primaryKey:true},option_id:{type:D.STRING,allowNull:false},value:{type:D.STRING(100),allowNull:false},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"product_option_values",timestamps:true,indexes:[{unique:true,fields:["option_id","value"]}]});M.associate=m=>{M.belongsTo(m.ProductOption,{foreignKey:"option_id",as:"option"});};return M;}; diff --git a/app/models/catalogue/productRelation.model.js b/app/models/catalogue/productRelation.model.js new file mode 100644 index 0000000..a1c5789 --- /dev/null +++ b/app/models/catalogue/productRelation.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("ProductRelation",{id:{type:D.STRING,primaryKey:true},source_product_id:{type:D.STRING,allowNull:false},target_product_id:{type:D.STRING,allowNull:false},relation_type:{type:D.ENUM("RELATED","SIMILAR","COMPLETE_THE_LOOK"),allowNull:false},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"product_relations",timestamps:true,indexes:[{unique:true,fields:["source_product_id","target_product_id","relation_type"]}]}); diff --git a/app/models/catalogue/productReview.model.js b/app/models/catalogue/productReview.model.js new file mode 100644 index 0000000..f5f0ee4 --- /dev/null +++ b/app/models/catalogue/productReview.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("ProductReview",{id:{type:D.STRING,primaryKey:true},product_id:{type:D.STRING,allowNull:false},user_id:{type:D.STRING,allowNull:false},rating:{type:D.INTEGER,allowNull:false,validate:{min:1,max:5}},title:{type:D.STRING(160),allowNull:false},body:{type:D.TEXT,allowNull:false},status:{type:D.ENUM("PENDING","APPROVED","REJECTED"),allowNull:false,defaultValue:"PENDING"},verified_purchase:{type:D.BOOLEAN,allowNull:false,defaultValue:false},moderated_by:D.STRING,moderated_at:D.DATE},{tableName:"product_reviews",timestamps:true,indexes:[{unique:true,fields:["user_id","product_id"]}]});M.associate=m=>{M.belongsTo(m.Product,{foreignKey:"product_id",as:"product"});M.belongsTo(m.User,{foreignKey:"user_id",as:"author"});};return M;}; diff --git a/app/models/catalogue/productTranslation.model.js b/app/models/catalogue/productTranslation.model.js new file mode 100644 index 0000000..5c4aa6f --- /dev/null +++ b/app/models/catalogue/productTranslation.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("ProductTranslation",{id:{type:D.STRING,primaryKey:true},product_id:{type:D.STRING,allowNull:false},locale:{type:D.ENUM("en","si","ta"),allowNull:false},name:{type:D.STRING(220),allowNull:false},short_description:D.STRING(500),description:D.TEXT,care_instructions:D.TEXT,materials:D.TEXT,origin:D.STRING(120),meta_title:D.STRING(180),meta_description:D.STRING(320)},{tableName:"product_translations",timestamps:true,indexes:[{unique:true,fields:["product_id","locale"]}]});M.associate=m=>M.belongsTo(m.Product,{foreignKey:"product_id",as:"product"});return M;}; diff --git a/app/models/catalogue/productVariant.model.js b/app/models/catalogue/productVariant.model.js new file mode 100644 index 0000000..f75dcee --- /dev/null +++ b/app/models/catalogue/productVariant.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("ProductVariant",{id:{type:D.STRING,primaryKey:true},product_id:{type:D.STRING,allowNull:false},sku:{type:D.STRING(100),allowNull:false,unique:true},barcode:{type:D.STRING(100),unique:true},status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"},base_price:{type:D.DECIMAL(15,2),allowNull:false},compare_at_price:D.DECIMAL(15,2),currency:{type:D.STRING(3),allowNull:false,defaultValue:"LKR"},weight:D.DECIMAL(10,3),sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"product_variants",timestamps:true});M.associate=m=>{M.belongsTo(m.Product,{foreignKey:"product_id",as:"product"});M.belongsToMany(m.ProductOptionValue,{through:m.VariantOptionValue,foreignKey:"variant_id",otherKey:"option_value_id",as:"optionValues"});M.hasMany(m.ProductMedia,{foreignKey:"variant_id",as:"media"});};return M;}; diff --git a/app/models/catalogue/sizeGuide.model.js b/app/models/catalogue/sizeGuide.model.js new file mode 100644 index 0000000..726c09a --- /dev/null +++ b/app/models/catalogue/sizeGuide.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("SizeGuide",{id:{type:D.STRING,primaryKey:true},code:{type:D.STRING(80),allowNull:false,unique:true},name:{type:D.STRING(160),allowNull:false},locale:{type:D.ENUM("en","si","ta"),allowNull:false,defaultValue:"en"},data:{type:D.JSON,allowNull:false},category_id:D.STRING,status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"}},{tableName:"size_guides",timestamps:true}); diff --git a/app/models/catalogue/variantOptionValue.model.js b/app/models/catalogue/variantOptionValue.model.js new file mode 100644 index 0000000..1176005 --- /dev/null +++ b/app/models/catalogue/variantOptionValue.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("VariantOptionValue",{id:{type:D.STRING,primaryKey:true},variant_id:{type:D.STRING,allowNull:false},option_value_id:{type:D.STRING,allowNull:false}},{tableName:"variant_option_values",timestamps:true,indexes:[{unique:true,fields:["variant_id","option_value_id"]}]}); diff --git a/app/models/index.js b/app/models/index.js index 12dcf61..780c9d3 100644 --- a/app/models/index.js +++ b/app/models/index.js @@ -71,6 +71,29 @@ db.notification = require("./notification/notification.model")(sequelize, DataTy db.userNotification = require("./notification/userNotification.model")(sequelize, DataTypes); db.NotificationDelivery = require("./notification/notificationDelivery.model")(sequelize, DataTypes); +// Catalogue and localized content +db.Brand = require("./catalogue/brand.model")(sequelize, DataTypes); +db.Category = require("./catalogue/category.model")(sequelize, DataTypes); +db.CategoryTranslation = require("./catalogue/categoryTranslation.model")(sequelize, DataTypes); +db.Product = require("./catalogue/product.model")(sequelize, DataTypes); +db.ProductTranslation = require("./catalogue/productTranslation.model")(sequelize, DataTypes); +db.ProductCategory = require("./catalogue/productCategory.model")(sequelize, DataTypes); +db.ProductVariant = require("./catalogue/productVariant.model")(sequelize, DataTypes); +db.ProductOption = require("./catalogue/productOption.model")(sequelize, DataTypes); +db.ProductOptionValue = require("./catalogue/productOptionValue.model")(sequelize, DataTypes); +db.VariantOptionValue = require("./catalogue/variantOptionValue.model")(sequelize, DataTypes); +db.ProductAttribute = require("./catalogue/productAttribute.model")(sequelize, DataTypes); +db.ProductMedia = require("./catalogue/productMedia.model")(sequelize, DataTypes); +db.Collection = require("./catalogue/collection.model")(sequelize, DataTypes); +db.CollectionTranslation = require("./catalogue/collectionTranslation.model")(sequelize, DataTypes); +db.CollectionProduct = require("./catalogue/collectionProduct.model")(sequelize, DataTypes); +db.SizeGuide = require("./catalogue/sizeGuide.model")(sequelize, DataTypes); +db.ProductRelation = require("./catalogue/productRelation.model")(sequelize, DataTypes); +db.ProductReview = require("./catalogue/productReview.model")(sequelize, DataTypes); +db.Warehouse=require("./inventory/warehouse.model")(sequelize,DataTypes);db.InventoryBalance=require("./inventory/inventoryBalance.model")(sequelize,DataTypes);db.InventoryTransaction=require("./inventory/inventoryTransaction.model")(sequelize,DataTypes);db.InventoryReservation=require("./inventory/inventoryReservation.model")(sequelize,DataTypes);db.InventoryTransfer=require("./inventory/inventoryTransfer.model")(sequelize,DataTypes); +db.BusinessTier=require("./pricing/businessTier.model")(sequelize,DataTypes);db.VariantBusinessPrice=require("./pricing/variantBusinessPrice.model")(sequelize,DataTypes);db.BusinessPriceTier=require("./pricing/businessPriceTier.model")(sequelize,DataTypes); +db.Promotion=require("./merchandising/promotion.model")(sequelize,DataTypes);db.PromotionTarget=require("./merchandising/promotionTarget.model")(sequelize,DataTypes);db.Coupon=require("./merchandising/coupon.model")(sequelize,DataTypes);db.Banner=require("./merchandising/banner.model")(sequelize,DataTypes);db.BannerTranslation=require("./merchandising/bannerTranslation.model")(sequelize,DataTypes); + /* Associations */ Object.keys(db).forEach(model => { if (db[model].associate) { diff --git a/app/models/inventory/inventoryBalance.model.js b/app/models/inventory/inventoryBalance.model.js new file mode 100644 index 0000000..3db6803 --- /dev/null +++ b/app/models/inventory/inventoryBalance.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("InventoryBalance",{id:{type:D.STRING,primaryKey:true},warehouse_id:{type:D.STRING,allowNull:false},variant_id:{type:D.STRING,allowNull:false},on_hand:{type:D.INTEGER,allowNull:false,defaultValue:0},reserved:{type:D.INTEGER,allowNull:false,defaultValue:0},low_stock_threshold:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"inventory_balances",timestamps:true,indexes:[{unique:true,fields:["warehouse_id","variant_id"]}]});M.associate=db=>{M.belongsTo(db.Warehouse,{foreignKey:"warehouse_id",as:"warehouse"});M.belongsTo(db.ProductVariant,{foreignKey:"variant_id",as:"variant"});};return M;}; diff --git a/app/models/inventory/inventoryReservation.model.js b/app/models/inventory/inventoryReservation.model.js new file mode 100644 index 0000000..7ac39b3 --- /dev/null +++ b/app/models/inventory/inventoryReservation.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("InventoryReservation",{id:{type:D.STRING,primaryKey:true},reservation_key:{type:D.STRING(160),allowNull:false,unique:true},warehouse_id:{type:D.STRING,allowNull:false},variant_id:{type:D.STRING,allowNull:false},quantity:{type:D.INTEGER,allowNull:false},status:{type:D.ENUM("ACTIVE","RELEASED","EXPIRED","CONSUMED"),allowNull:false,defaultValue:"ACTIVE"},reference_type:D.STRING(80),reference_id:D.STRING,user_id:D.STRING,expires_at:{type:D.DATE,allowNull:false},released_at:D.DATE,consumed_at:D.DATE},{tableName:"inventory_reservations",timestamps:true}); diff --git a/app/models/inventory/inventoryTransaction.model.js b/app/models/inventory/inventoryTransaction.model.js new file mode 100644 index 0000000..3db9477 --- /dev/null +++ b/app/models/inventory/inventoryTransaction.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("InventoryTransaction",{id:{type:D.STRING,primaryKey:true},event_id:{type:D.STRING,allowNull:false,unique:true},warehouse_id:{type:D.STRING,allowNull:false},variant_id:{type:D.STRING,allowNull:false},type:{type:D.ENUM("INITIAL","RECEIPT","ADJUSTMENT","RESERVATION","RESERVATION_RELEASE","RESERVATION_CONSUME","TRANSFER_OUT","TRANSFER_IN","CORRECTION"),allowNull:false},quantity_delta:{type:D.INTEGER,allowNull:false,defaultValue:0},reserved_delta:{type:D.INTEGER,allowNull:false,defaultValue:0},reference_type:D.STRING(80),reference_id:D.STRING,reason:D.STRING(500),actor_user_id:D.STRING,request_id:D.STRING,metadata:D.JSON,occurred_at:{type:D.DATE,allowNull:false,defaultValue:D.NOW}},{tableName:"inventory_transactions",timestamps:true,updatedAt:false}); diff --git a/app/models/inventory/inventoryTransfer.model.js b/app/models/inventory/inventoryTransfer.model.js new file mode 100644 index 0000000..3b123ae --- /dev/null +++ b/app/models/inventory/inventoryTransfer.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("InventoryTransfer",{id:{type:D.STRING,primaryKey:true},event_id:{type:D.STRING,allowNull:false,unique:true},transfer_number:{type:D.STRING(80),allowNull:false,unique:true},source_warehouse_id:{type:D.STRING,allowNull:false},destination_warehouse_id:{type:D.STRING,allowNull:false},variant_id:{type:D.STRING,allowNull:false},quantity:{type:D.INTEGER,allowNull:false},status:{type:D.ENUM("COMPLETED"),allowNull:false,defaultValue:"COMPLETED"},created_by:{type:D.STRING,allowNull:false},completed_by:{type:D.STRING,allowNull:false},completed_at:{type:D.DATE,allowNull:false}},{tableName:"inventory_transfers",timestamps:true}); diff --git a/app/models/inventory/warehouse.model.js b/app/models/inventory/warehouse.model.js new file mode 100644 index 0000000..ddaf641 --- /dev/null +++ b/app/models/inventory/warehouse.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("Warehouse",{id:{type:D.STRING,primaryKey:true},code:{type:D.STRING(50),allowNull:false,unique:true},name:{type:D.STRING(150),allowNull:false},status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"},is_default:{type:D.BOOLEAN,allowNull:false,defaultValue:false},timezone:{type:D.STRING(80),allowNull:false,defaultValue:"Asia/Colombo"},address_line_1:D.STRING,address_line_2:D.STRING,city:D.STRING,province:D.STRING,postal_code:D.STRING(20),country_code:{type:D.STRING(2),allowNull:false,defaultValue:"LK"}},{tableName:"warehouses",timestamps:true});M.associate=db=>M.hasMany(db.InventoryBalance,{foreignKey:"warehouse_id",as:"balances"});return M;}; diff --git a/app/models/merchandising/banner.model.js b/app/models/merchandising/banner.model.js new file mode 100644 index 0000000..d22e1b3 --- /dev/null +++ b/app/models/merchandising/banner.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("Banner",{id:{type:D.STRING,primaryKey:true},placement:{type:D.STRING(80),allowNull:false},status:{type:D.ENUM("DRAFT","ACTIVE","INACTIVE","ARCHIVED"),allowNull:false,defaultValue:"DRAFT"},starts_at:D.DATE,ends_at:D.DATE,image_upload_id:{type:D.INTEGER,allowNull:false},mobile_image_upload_id:D.INTEGER,target_url:D.STRING,audience_type:{type:D.ENUM("ALL","CUSTOMER","BUSINESS_CUSTOMER"),allowNull:false,defaultValue:"ALL"},business_tier_id:D.STRING,sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0},created_by:{type:D.STRING,allowNull:false},updated_by:D.STRING},{tableName:"banners",timestamps:true});M.associate=db=>M.hasMany(db.BannerTranslation,{foreignKey:"banner_id",as:"translations"});return M;}; diff --git a/app/models/merchandising/bannerTranslation.model.js b/app/models/merchandising/bannerTranslation.model.js new file mode 100644 index 0000000..6ee699a --- /dev/null +++ b/app/models/merchandising/bannerTranslation.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("BannerTranslation",{id:{type:D.STRING,primaryKey:true},banner_id:{type:D.STRING,allowNull:false},locale:{type:D.ENUM("en","si","ta"),allowNull:false},headline:D.STRING(250),subheadline:D.STRING(400),cta_text:D.STRING(100),alt_text:D.STRING(250)},{tableName:"banner_translations",timestamps:true,indexes:[{unique:true,fields:["banner_id","locale"]}]}); diff --git a/app/models/merchandising/coupon.model.js b/app/models/merchandising/coupon.model.js new file mode 100644 index 0000000..de0d79c --- /dev/null +++ b/app/models/merchandising/coupon.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("Coupon",{id:{type:D.STRING,primaryKey:true},code:{type:D.STRING(50),allowNull:false,unique:true},promotion_id:{type:D.STRING,allowNull:false},status:{type:D.ENUM("ACTIVE","INACTIVE","ARCHIVED"),allowNull:false,defaultValue:"ACTIVE"},starts_at:D.DATE,expires_at:D.DATE,max_uses:D.INTEGER,max_uses_per_user:D.INTEGER},{tableName:"coupons",timestamps:true,hooks:{beforeValidate:x=>{if(x.code)x.code=x.code.trim().toUpperCase();}}});M.associate=db=>M.belongsTo(db.Promotion,{foreignKey:"promotion_id",as:"promotion"});return M;}; diff --git a/app/models/merchandising/promotion.model.js b/app/models/merchandising/promotion.model.js new file mode 100644 index 0000000..d63299c --- /dev/null +++ b/app/models/merchandising/promotion.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("Promotion",{id:{type:D.STRING,primaryKey:true},name:{type:D.STRING(180),allowNull:false},type:{type:D.ENUM("PERCENTAGE","FIXED_AMOUNT","FIXED_PRICE"),allowNull:false},status:{type:D.ENUM("DRAFT","ACTIVE","INACTIVE","ARCHIVED"),allowNull:false,defaultValue:"DRAFT"},starts_at:D.DATE,ends_at:D.DATE,priority:{type:D.INTEGER,allowNull:false,defaultValue:0},stackable:{type:D.BOOLEAN,allowNull:false,defaultValue:false},discount_percent:D.DECIMAL(5,2),discount_amount:D.DECIMAL(15,2),fixed_price:D.DECIMAL(15,2),minimum_subtotal:D.DECIMAL(15,2),minimum_quantity:D.INTEGER,customer_type:D.STRING(40),created_by:{type:D.STRING,allowNull:false},updated_by:D.STRING},{tableName:"promotions",timestamps:true}); diff --git a/app/models/merchandising/promotionTarget.model.js b/app/models/merchandising/promotionTarget.model.js new file mode 100644 index 0000000..0cc8a7d --- /dev/null +++ b/app/models/merchandising/promotionTarget.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("PromotionTarget",{id:{type:D.STRING,primaryKey:true},promotion_id:{type:D.STRING,allowNull:false},target_type:{type:D.ENUM("ALL","PRODUCT","VARIANT","CATEGORY","BRAND","COLLECTION"),allowNull:false},target_id:D.STRING},{tableName:"promotion_targets",timestamps:true,indexes:[{unique:true,fields:["promotion_id","target_type","target_id"]}]}); diff --git a/app/models/pricing/businessPriceTier.model.js b/app/models/pricing/businessPriceTier.model.js new file mode 100644 index 0000000..b50526d --- /dev/null +++ b/app/models/pricing/businessPriceTier.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("BusinessPriceTier",{id:{type:D.STRING,primaryKey:true},business_price_id:{type:D.STRING,allowNull:false},min_quantity:{type:D.INTEGER,allowNull:false},max_quantity:D.INTEGER,unit_price:{type:D.DECIMAL(15,2),allowNull:false}},{tableName:"business_price_tiers",timestamps:true}); diff --git a/app/models/pricing/businessTier.model.js b/app/models/pricing/businessTier.model.js new file mode 100644 index 0000000..27eccbc --- /dev/null +++ b/app/models/pricing/businessTier.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>s.define("BusinessTier",{id:{type:D.STRING,primaryKey:true},code:{type:D.STRING(50),allowNull:false,unique:true},name:{type:D.STRING(120),allowNull:false},description:D.STRING(500),status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"business_tiers",timestamps:true}); diff --git a/app/models/pricing/variantBusinessPrice.model.js b/app/models/pricing/variantBusinessPrice.model.js new file mode 100644 index 0000000..688e721 --- /dev/null +++ b/app/models/pricing/variantBusinessPrice.model.js @@ -0,0 +1 @@ +module.exports=(s,D)=>{const M=s.define("VariantBusinessPrice",{id:{type:D.STRING,primaryKey:true},variant_id:{type:D.STRING,allowNull:false},business_tier_id:D.STRING,business_customer_id:D.STRING,currency:{type:D.STRING(3),allowNull:false},price:{type:D.DECIMAL(15,2),allowNull:false},minimum_quantity:{type:D.INTEGER,allowNull:false,defaultValue:1},status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"},starts_at:D.DATE,ends_at:D.DATE},{tableName:"variant_business_prices",timestamps:true});M.associate=db=>M.hasMany(db.BusinessPriceTier,{foreignKey:"business_price_id",as:"tiers"});return M;}; diff --git a/app/models/user/businessCustomer.model.js b/app/models/user/businessCustomer.model.js index fcae0e9..9c1243d 100644 --- a/app/models/user/businessCustomer.model.js +++ b/app/models/user/businessCustomer.model.js @@ -55,6 +55,7 @@ module.exports = (sequelize, DataTypes) => { approvedAt: { type: DataTypes.DATE, allowNull: false }, approvedBy: { type: DataTypes.STRING, allowNull: false }, settlement_term_id: { type: DataTypes.STRING, allowNull: true }, + business_tier_id: { type: DataTypes.STRING, allowNull: true }, }, { tableName: "business_customers", @@ -71,6 +72,7 @@ module.exports = (sequelize, DataTypes) => { BusinessCustomer.hasMany(db.Address, { foreignKey: "business_profile_id", as: "addresses" }); BusinessCustomer.hasOne(db.BusinessCreditAccount, { foreignKey: "business_profile_id", as: "creditAccount" }); BusinessCustomer.belongsTo(db.SettlementTerm, { foreignKey: "settlement_term_id", as: "settlementTerm" }); + BusinessCustomer.belongsTo(db.BusinessTier, { foreignKey: "business_tier_id", as: "businessTier" }); }; return BusinessCustomer; diff --git a/app/routes/catalogue/admin.routes.js b/app/routes/catalogue/admin.routes.js new file mode 100644 index 0000000..39e2552 --- /dev/null +++ b/app/routes/catalogue/admin.routes.js @@ -0,0 +1 @@ +const r=require("express").Router();const c=require("../../controllers/catalogue/admin.controller");const reviews=require("../../controllers/catalogue/review.controller");const {authenticate}=require("../../middleware/auth.middleware");const {checkPermission}=require("../../middleware/permission.middleware");const validate=require("../../middleware/validate.middleware");const s=require("../../validation/catalogue.schemas");r.use(authenticate);r.get("/products",checkPermission("catalogue.products.read",{custom:true}),c.listProducts);r.post("/products",checkPermission("catalogue.products.create",{custom:true}),validate(s.productCreate),c.createProduct);r.get("/products/:id",checkPermission("catalogue.products.read",{custom:true}),c.getProduct);r.patch("/products/:id",checkPermission("catalogue.products.update",{custom:true}),validate(s.productUpdate),c.updateProduct);r.delete("/products/:id",checkPermission("catalogue.products.delete",{custom:true}),c.archiveProduct);r.post("/products/:productId/variants",checkPermission("catalogue.products.update",{custom:true}),validate(s.variantCreate),c.createVariant);r.patch("/products/:productId/variants/:variantId",checkPermission("catalogue.products.update",{custom:true}),validate(s.variantUpdate),c.updateVariant);r.post("/products/:productId/options",checkPermission("catalogue.products.update",{custom:true}),validate(s.option),c.createOption);r.post("/products/:productId/media",checkPermission("catalogue.products.update",{custom:true}),validate(s.media),c.attachMedia);r.post("/products/:productId/relations",checkPermission("catalogue.products.update",{custom:true}),validate(s.relation),c.createRelation);r.post("/brands",checkPermission("catalogue.brands.manage",{custom:true}),validate(s.brand),c.createBrand);r.patch("/brands/:id",checkPermission("catalogue.brands.manage",{custom:true}),validate(s.brand),c.updateBrand);r.post("/categories",checkPermission("catalogue.categories.manage",{custom:true}),validate(s.category),c.createCategory);r.patch("/categories/:id",checkPermission("catalogue.categories.manage",{custom:true}),validate(s.category),c.updateCategory);r.post("/collections",checkPermission("catalogue.collections.manage",{custom:true}),validate(s.collection),c.createCollection);r.post("/size-guides",checkPermission("catalogue.size-guides.manage",{custom:true}),validate(s.sizeGuide),c.createSizeGuide);r.get("/reviews",checkPermission("catalogue.reviews.read",{custom:true}),reviews.listAdmin);r.patch("/reviews/:id/status",checkPermission("catalogue.reviews.moderate",{custom:true}),validate(s.reviewStatus),reviews.moderate);module.exports=r; diff --git a/app/routes/catalogue/public.routes.js b/app/routes/catalogue/public.routes.js new file mode 100644 index 0000000..0da831d --- /dev/null +++ b/app/routes/catalogue/public.routes.js @@ -0,0 +1 @@ +const r=require("express").Router();const c=require("../../controllers/catalogue/public.controller");const reviews=require("../../controllers/catalogue/review.controller");const {authenticate}=require("../../middleware/auth.middleware");const validate=require("../../middleware/validate.middleware");const s=require("../../validation/catalogue.schemas");r.get("/products",c.products);r.get("/products/:slug",c.product);r.post("/products/:productId/reviews",authenticate,validate(s.review),reviews.create);r.get("/categories",c.categories);r.get("/categories/:slug",c.category);r.get("/brands",c.brands);r.get("/brands/:slug",c.brand);r.get("/collections",c.collections);r.get("/collections/:slug",c.collection);module.exports=r; diff --git a/app/routes/index.js b/app/routes/index.js index 677a38d..d84cc7f 100644 --- a/app/routes/index.js +++ b/app/routes/index.js @@ -26,6 +26,12 @@ const adminUserRoutes = require("./adminUser.routes"); const addressRoutes = require("./address.routes"); const businessRoutes = require("./business.routes"); const adminBusinessRoutes = require("./adminBusiness.routes"); +const cataloguePublicRoutes = require("./catalogue/public.routes"); +const catalogueAdminRoutes = require("./catalogue/admin.routes"); +const inventoryAdminRoutes = require("./inventory/admin.routes"); +const merchandisingAdminRoutes = require("./merchandising/admin.routes"); +const pricingAdminRoutes = require("./pricing/admin.routes"); +const merchandisingPublicRoutes = require("./merchandising/public.routes"); const router = express.Router(); @@ -44,5 +50,11 @@ router.use("/admin/users", adminUserRoutes); router.use("/addresses", addressRoutes); router.use("/business", businessRoutes); router.use("/admin/business", adminBusinessRoutes); +router.use("/", cataloguePublicRoutes); +router.use("/admin", catalogueAdminRoutes); +router.use("/admin", inventoryAdminRoutes); +router.use("/admin", merchandisingAdminRoutes); +router.use("/admin", pricingAdminRoutes); +router.use("/", merchandisingPublicRoutes); module.exports = router; diff --git a/app/routes/inventory/admin.routes.js b/app/routes/inventory/admin.routes.js new file mode 100644 index 0000000..e2436f4 --- /dev/null +++ b/app/routes/inventory/admin.routes.js @@ -0,0 +1 @@ +const r=require("express").Router(),c=require("../../controllers/inventory/admin.controller"),{authenticate}=require("../../middleware/auth.middleware"),{checkPermission}=require("../../middleware/permission.middleware"),validate=require("../../middleware/validate.middleware"),s=require("../../validation/inventoryMerchandising.schemas");r.use(authenticate);r.get("/inventory",checkPermission("inventory.read",{custom:true}),c.list);r.get("/inventory/ledger",checkPermission("inventory.read",{custom:true}),c.ledger);r.get("/inventory/low-stock",checkPermission("inventory.read",{custom:true}),c.lowStock);r.get("/inventory/:variantId",checkPermission("inventory.read",{custom:true}),c.detail);r.post("/inventory/adjustments",checkPermission("inventory.adjust",{custom:true}),validate(s.adjustment),c.adjust);r.post("/inventory/transfers",checkPermission("inventory.transfer",{custom:true}),validate(s.transfer),c.transfer);r.get("/warehouses",checkPermission("inventory.read",{custom:true}),c.warehouses);r.post("/warehouses",checkPermission("inventory.warehouses.manage",{custom:true}),validate(s.warehouse),c.createWarehouse);r.patch("/warehouses/:id",checkPermission("inventory.warehouses.manage",{custom:true}),validate(s.warehouse),c.updateWarehouse);module.exports=r; diff --git a/app/routes/merchandising/admin.routes.js b/app/routes/merchandising/admin.routes.js new file mode 100644 index 0000000..3d9f5fa --- /dev/null +++ b/app/routes/merchandising/admin.routes.js @@ -0,0 +1 @@ +const r=require("express").Router(),c=require("../../controllers/merchandising/admin.controller"),{authenticate}=require("../../middleware/auth.middleware"),{checkPermission}=require("../../middleware/permission.middleware"),validate=require("../../middleware/validate.middleware"),s=require("../../validation/inventoryMerchandising.schemas");r.use(authenticate);r.get("/promotions",checkPermission("promotions.read",{custom:true}),c.promotions);r.post("/promotions",checkPermission("promotions.manage",{custom:true}),validate(s.promotion),c.createPromotion);r.get("/coupons",checkPermission("promotions.read",{custom:true}),c.coupons);r.post("/coupons",checkPermission("promotions.manage",{custom:true}),validate(s.coupon),c.createCoupon);r.get("/banners",checkPermission("merchandising.banners.manage",{custom:true}),c.banners);r.post("/banners",checkPermission("merchandising.banners.manage",{custom:true}),validate(s.banner),c.createBanner);module.exports=r; diff --git a/app/routes/merchandising/public.routes.js b/app/routes/merchandising/public.routes.js new file mode 100644 index 0000000..4d8c70d --- /dev/null +++ b/app/routes/merchandising/public.routes.js @@ -0,0 +1 @@ +const r=require("express").Router(),c=require("../../controllers/merchandising/public.controller");r.get("/banners",c.banners);r.get("/availability/:variantId",c.availability);module.exports=r; diff --git a/app/routes/pricing/admin.routes.js b/app/routes/pricing/admin.routes.js new file mode 100644 index 0000000..e9e7660 --- /dev/null +++ b/app/routes/pricing/admin.routes.js @@ -0,0 +1 @@ +const r=require("express").Router(),c=require("../../controllers/pricing/admin.controller"),{authenticate}=require("../../middleware/auth.middleware"),{checkPermission}=require("../../middleware/permission.middleware"),validate=require("../../middleware/validate.middleware"),s=require("../../validation/inventoryMerchandising.schemas");r.use(authenticate);r.get("/business-pricing",checkPermission("pricing.business.read",{custom:true}),c.list);r.post("/business-pricing",checkPermission("pricing.business.manage",{custom:true}),validate(s.businessPrice),c.create);module.exports=r; diff --git a/app/seeders/catalogueExample.seeder.js b/app/seeders/catalogueExample.seeder.js new file mode 100644 index 0000000..fc026e5 --- /dev/null +++ b/app/seeders/catalogueExample.seeder.js @@ -0,0 +1,3 @@ +const crypto=require("crypto");const db=require("../models"); +const examples=["Women","Men","Luxury","Beauty","Sports","Kids","Sale","Travel"]; +module.exports=async()=>db.sequelize.transaction(async transaction=>{for(const name of examples){const slug=name.toLowerCase();const [category]=await db.Category.findOrCreate({where:{slug},defaults:{id:crypto.randomUUID(),code:`EXAMPLE_${name.toUpperCase()}`,slug,status:"INACTIVE",sort_order:examples.indexOf(name)},transaction});await db.CategoryTranslation.findOrCreate({where:{category_id:category.id,locale:"en"},defaults:{id:crypto.randomUUID(),category_id:category.id,locale:"en",name},transaction});}}); diff --git a/app/services/catalogue/catalogue.service.js b/app/services/catalogue/catalogue.service.js new file mode 100644 index 0000000..1adc26b --- /dev/null +++ b/app/services/catalogue/catalogue.service.js @@ -0,0 +1,7 @@ +const crypto=require("crypto");const db=require("../../models"); +const validateMedia=async(ids,userId,transaction)=>{if(!ids.length)return[];const rows=await db.Upload.findAll({where:{id:ids,status:"AVAILABLE"},transaction,lock:transaction?.LOCK?.UPDATE});const imageTypes=new Set(["image/jpeg","image/png","image/webp"]);if(rows.length!==new Set(ids).size||rows.some(x=>!imageTypes.has(x.file_type)||(x.owner_id!==userId&&x.owner_type!=="CATALOGUE")))throw Object.assign(new Error("Catalogue media must be a supported available image owned by the actor"),{status:403,code:"INVALID_MEDIA_OWNERSHIP"});return rows;}; +const validatePublish=async(product,transaction)=>{const translation=await db.ProductTranslation.findOne({where:{product_id:product.id,locale:"en"},transaction});const variant=await db.ProductVariant.findOne({where:{product_id:product.id,status:"ACTIVE"},transaction});const media=await db.ProductMedia.findOne({where:{product_id:product.id,is_primary:true},transaction});if(!translation||!product.slug||!product.brand_id||!variant||!media)throw Object.assign(new Error("Publishing requires English content, brand, active variant, and primary media"),{status:409,code:"PUBLISH_REQUIREMENTS_NOT_MET"});}; +const createProduct=(actor,body)=>db.sequelize.transaction(async t=>{await validateMedia(body.mediaUploadIds,actor.id,t);const product=await db.Product.create({id:crypto.randomUUID(),brand_id:body.brandId,default_category_id:body.defaultCategoryId||body.categoryIds[0],slug:body.slug,product_code:body.productCode,status:"DRAFT",visibility:"HIDDEN",product_type:body.productType,featured:body.featured,new_arrival_until:body.newArrivalUntil,created_by:actor.id,updated_by:actor.id},{transaction:t});await db.ProductTranslation.bulkCreate(body.translations.map(x=>({id:crypto.randomUUID(),product_id:product.id,locale:x.locale,name:x.name,short_description:x.shortDescription,description:x.description,care_instructions:x.careInstructions,materials:x.materials,origin:x.origin,meta_title:x.metaTitle,meta_description:x.metaDescription})),{transaction:t});await db.ProductCategory.bulkCreate([...new Set(body.categoryIds)].map(category_id=>({id:crypto.randomUUID(),product_id:product.id,category_id})),{transaction:t});await db.ProductVariant.bulkCreate(body.variants.map(x=>({id:crypto.randomUUID(),product_id:product.id,sku:x.sku,barcode:x.barcode,base_price:x.basePrice,compare_at_price:x.compareAtPrice,currency:x.currency,weight:x.weight,sort_order:x.sortOrder})),{transaction:t});if(body.mediaUploadIds.length){await db.ProductMedia.bulkCreate(body.mediaUploadIds.map((upload_id,i)=>({id:crypto.randomUUID(),product_id:product.id,upload_id,type:"IMAGE",sort_order:i,is_primary:i===0})),{transaction:t});await db.Upload.update({owner_type:"CATALOGUE",owner_id:product.id,use_for:"PRODUCT_MEDIA"},{where:{id:body.mediaUploadIds},transaction:t});}return product;}); +const setProductState=(id,actor,updates)=>db.sequelize.transaction(async t=>{const product=await db.Product.findByPk(id,{transaction:t,lock:t.LOCK.UPDATE});if(!product)throw Object.assign(new Error("Product not found"),{status:404,code:"NOT_FOUND"});if(product.published_at&&updates.slug&&updates.slug!==product.slug)throw Object.assign(new Error("Published product slugs are immutable"),{status:409,code:"PUBLISHED_SLUG_IMMUTABLE"});if(updates.status==="ACTIVE"||updates.visibility==="PUBLIC")await validatePublish(product,t);const mapped={...(updates.brandId!==undefined&&{brand_id:updates.brandId}),...(updates.defaultCategoryId!==undefined&&{default_category_id:updates.defaultCategoryId}),...(updates.productType!==undefined&&{product_type:updates.productType}),...(updates.newArrivalUntil!==undefined&&{new_arrival_until:updates.newArrivalUntil}),...(updates.slug!==undefined&&{slug:updates.slug}),...(updates.featured!==undefined&&{featured:updates.featured}),...(updates.status!==undefined&&{status:updates.status}),...(updates.visibility!==undefined&&{visibility:updates.visibility})};await product.update({...mapped,updated_by:actor.id,...((updates.status==="ACTIVE"||updates.visibility==="PUBLIC")&&!product.published_at&&{published_at:new Date()})},{transaction:t});return product;}); +const assertCategoryParent=async(id,parentId,transaction)=>{if(!parentId)return;if(id===parentId)throw Object.assign(new Error("Category cannot parent itself"),{status:400,code:"CATEGORY_SELF_PARENT"});let cursor=parentId;const seen=new Set([id]);for(let depth=0;cursor&&depth<100;depth++){if(seen.has(cursor))throw Object.assign(new Error("Category hierarchy cycle detected"),{status:409,code:"CATEGORY_CYCLE"});seen.add(cursor);const node=await db.Category.findByPk(cursor,{transaction,attributes:["id","parent_id"]});if(!node)throw Object.assign(new Error("Parent category not found"),{status:400,code:"INVALID_PARENT"});cursor=node.parent_id;}if(cursor)throw Object.assign(new Error("Category hierarchy exceeds safe depth"),{status:400,code:"CATEGORY_DEPTH"});}; +module.exports={createProduct,setProductState,validatePublish,validateMedia,assertCategoryParent}; diff --git a/app/services/catalogue/locale.service.js b/app/services/catalogue/locale.service.js new file mode 100644 index 0000000..b7071ea --- /dev/null +++ b/app/services/catalogue/locale.service.js @@ -0,0 +1 @@ +const {SUPPORTED_LOCALES,DEFAULT_LOCALE}=require("../../constants/locales");const normalize=v=>SUPPORTED_LOCALES.includes(String(v||"").slice(0,2).toLowerCase())?String(v).slice(0,2).toLowerCase():null;const resolveLocale=req=>normalize(req.query.locale)||normalize(req.get?.("x-locale"))||normalize(req.user?.profile?.preferred_language)||normalize(req.get?.("accept-language"))||DEFAULT_LOCALE;const selectTranslation=(rows=[],locale=DEFAULT_LOCALE)=>rows.find(x=>x.locale===locale)||rows.find(x=>x.locale===DEFAULT_LOCALE)||rows[0]||null;module.exports={resolveLocale,selectTranslation}; diff --git a/app/services/catalogue/serializer.service.js b/app/services/catalogue/serializer.service.js new file mode 100644 index 0000000..2712c70 --- /dev/null +++ b/app/services/catalogue/serializer.service.js @@ -0,0 +1,3 @@ +const storage=require("../storage/storage.service");const {selectTranslation}=require("./locale.service");const cents=v=>{const [a,b=""]=String(v).split(".");return BigInt(a)*100n+BigInt((b+"00").slice(0,2));}; +const mediaDto=async rows=>Promise.all((rows||[]).sort((a,b)=>a.sort_order-b.sort_order).map(async m=>({id:m.id,type:m.type,altText:m.alt_text,isPrimary:m.is_primary,variantId:m.variant_id,url:await storage.createSignedDownloadUrl(m.upload.file_path)}))); +const summary=async(product,locale)=>{const tr=selectTranslation(product.translations,locale);const prices=(product.variants||[]).filter(v=>v.status==="ACTIVE").map(v=>String(v.base_price));prices.sort((a,b)=>cents(a)<cents(b)?-1:cents(a)>cents(b)?1:0);const primary=(product.media||[]).filter(m=>m.is_primary);return{id:product.id,slug:product.slug,name:tr?.name||null,brand:product.brand?{id:product.brand.id,name:product.brand.name,slug:product.brand.slug}:null,primaryImage:(await mediaDto(primary))[0]||null,minPrice:prices[0]||null,maxPrice:prices.at(-1)||null,currency:product.variants?.[0]?.currency||null,featured:product.featured,newArrival:Boolean(product.new_arrival_until&&new Date(product.new_arrival_until)>new Date()),compareAt:product.variants?.find(v=>v.compare_at_price)?.compare_at_price||null};};module.exports={summary,mediaDto,cents}; diff --git a/app/services/inventory/inventory.service.js b/app/services/inventory/inventory.service.js new file mode 100644 index 0000000..614b82c --- /dev/null +++ b/app/services/inventory/inventory.service.js @@ -0,0 +1,103 @@ +const crypto = require("crypto"); +const { Op } = require("sequelize"); +const db = require("../../models"); + +const fault = (message, code, status = 400) => Object.assign(new Error(message), { code, status }); +const ttlMinutes = () => Number(process.env.INVENTORY_RESERVATION_TTL_MINUTES || 15); +const assertQuantity = (value) => { if (!Number.isInteger(value) || value <= 0) throw fault("Quantity must be a positive integer", "INVALID_QUANTITY"); }; +const lockBalance = (warehouseId, variantId, transaction) => db.InventoryBalance.findOne({ where: { warehouse_id: warehouseId, variant_id: variantId }, transaction, lock: transaction.LOCK.UPDATE }); +const ledger = (data, transaction) => db.InventoryTransaction.create({ id: crypto.randomUUID(), occurred_at: new Date(), ...data }, { transaction }); +const availabilityStatus = ({ on_hand, reserved, low_stock_threshold }) => { + const available = Number(on_hand) - Number(reserved); + return available <= 0 ? "OUT_OF_STOCK" : available <= Number(low_stock_threshold) ? "LOW_STOCK" : "IN_STOCK"; +}; + +async function adjustStock({ eventId, warehouseId, variantId, quantityDelta, reason, actorUserId, requestId }) { + if (!eventId || !Number.isInteger(quantityDelta) || quantityDelta === 0) throw fault("A non-zero integer adjustment and eventId are required", "INVALID_ADJUSTMENT"); + return db.sequelize.transaction(async transaction => { + const prior = await db.InventoryTransaction.findOne({ where: { event_id: eventId }, transaction, lock: transaction.LOCK.UPDATE }); + if (prior) return { idempotent: true, transaction: prior }; + let balance = await lockBalance(warehouseId, variantId, transaction); + if (!balance) balance = await db.InventoryBalance.create({ id: crypto.randomUUID(), warehouse_id: warehouseId, variant_id: variantId, on_hand: 0, reserved: 0 }, { transaction }); + const next = Number(balance.on_hand) + quantityDelta; + if (next < 0 || next < Number(balance.reserved)) throw fault("Adjustment would violate available stock", "INSUFFICIENT_STOCK", 409); + await balance.update({ on_hand: next }, { transaction }); + const entry = await ledger({ event_id: eventId, warehouse_id: warehouseId, variant_id: variantId, type: "ADJUSTMENT", quantity_delta: quantityDelta, reason, actor_user_id: actorUserId, request_id: requestId }, transaction); + return { balance, transaction: entry, idempotent: false }; + }); +} + +async function selectWarehouse(variantId, quantity, transaction) { + const warehouses = await db.Warehouse.findAll({ where: { status: "ACTIVE" }, order: [["is_default", "DESC"], ["code", "ASC"]], transaction }); + for (const warehouse of warehouses) { + const balance = await lockBalance(warehouse.id, variantId, transaction); + if (balance && Number(balance.on_hand) - Number(balance.reserved) >= quantity) return { warehouse, balance }; + } + throw fault("Insufficient available stock", "INSUFFICIENT_STOCK", 409); +} + +async function reserveStock({ reservationKey, warehouseId, variantId, quantity, referenceType, referenceId, userId, expiresAt, requestId }) { + assertQuantity(quantity); if (!reservationKey) throw fault("reservationKey is required", "INVALID_RESERVATION"); + return db.sequelize.transaction(async transaction => { + const existing = await db.InventoryReservation.findOne({ where: { reservation_key: reservationKey }, transaction, lock: transaction.LOCK.UPDATE }); + if (existing) return { reservation: existing, idempotent: true }; + const variant = await db.ProductVariant.findOne({ where: { id: variantId, status: "ACTIVE" }, transaction }); + if (!variant) throw fault("Active variant not found", "VARIANT_UNAVAILABLE", 404); + let selected; + if (warehouseId) { + const warehouse = await db.Warehouse.findOne({ where: { id: warehouseId, status: "ACTIVE" }, transaction }); + if (!warehouse) throw fault("Active warehouse not found", "WAREHOUSE_UNAVAILABLE", 404); + const balance = await lockBalance(warehouseId, variantId, transaction); + if (!balance || Number(balance.on_hand) - Number(balance.reserved) < quantity) throw fault("Insufficient available stock", "INSUFFICIENT_STOCK", 409); + selected = { warehouse, balance }; + } else selected = await selectWarehouse(variantId, quantity, transaction); + await selected.balance.update({ reserved: Number(selected.balance.reserved) + quantity }, { transaction }); + const reservation = await db.InventoryReservation.create({ id: crypto.randomUUID(), reservation_key: reservationKey, warehouse_id: selected.warehouse.id, variant_id: variantId, quantity, reference_type: referenceType, reference_id: referenceId, user_id: userId, expires_at: expiresAt || new Date(Date.now() + ttlMinutes() * 60000) }, { transaction }); + await ledger({ event_id: `reserve:${reservationKey}`, warehouse_id: selected.warehouse.id, variant_id: variantId, type: "RESERVATION", reserved_delta: quantity, reference_type: "INVENTORY_RESERVATION", reference_id: reservation.id, request_id: requestId }, transaction); + return { reservation, idempotent: false }; + }); +} + +async function finishReservation(reservationKey, status, requestId) { + return db.sequelize.transaction(async transaction => { + const reservation = await db.InventoryReservation.findOne({ where: { reservation_key: reservationKey }, transaction, lock: transaction.LOCK.UPDATE }); + if (!reservation) throw fault("Reservation not found", "RESERVATION_NOT_FOUND", 404); + if (reservation.status === status) return { reservation, idempotent: true }; + if (reservation.status !== "ACTIVE") throw fault(`Reservation is ${reservation.status}`, "RESERVATION_NOT_ACTIVE", 409); + if (status === "CONSUMED" && new Date(reservation.expires_at) <= new Date()) throw fault("Reservation has expired", "RESERVATION_EXPIRED", 409); + const balance = await lockBalance(reservation.warehouse_id, reservation.variant_id, transaction); + if (!balance || Number(balance.reserved) < reservation.quantity) throw fault("Inventory invariant violated", "INVENTORY_INVARIANT", 409); + const consume = status === "CONSUMED"; + await balance.update({ reserved: Number(balance.reserved) - reservation.quantity, on_hand: Number(balance.on_hand) - (consume ? reservation.quantity : 0) }, { transaction }); + await reservation.update({ status, released_at: consume ? null : new Date(), consumed_at: consume ? new Date() : null }, { transaction }); + await ledger({ event_id: `${status.toLowerCase()}:${reservationKey}`, warehouse_id: reservation.warehouse_id, variant_id: reservation.variant_id, type: consume ? "RESERVATION_CONSUME" : "RESERVATION_RELEASE", quantity_delta: consume ? -reservation.quantity : 0, reserved_delta: -reservation.quantity, reference_type: "INVENTORY_RESERVATION", reference_id: reservation.id, request_id: requestId }, transaction); + return { reservation, idempotent: false }; + }); +} + +const releaseReservation = args => finishReservation(args.reservationKey, args.expired ? "EXPIRED" : "RELEASED", args.requestId); +const consumeReservation = args => finishReservation(args.reservationKey, "CONSUMED", args.requestId); + +async function transferStock({ eventId, sourceWarehouseId, destinationWarehouseId, variantId, quantity, actorUserId, requestId }) { + assertQuantity(quantity); if (sourceWarehouseId === destinationWarehouseId) throw fault("Warehouses must differ", "INVALID_TRANSFER"); + return db.sequelize.transaction(async transaction => { + const prior = await db.InventoryTransfer.findOne({ where: { event_id: eventId }, transaction, lock: transaction.LOCK.UPDATE }); if (prior) return { transfer: prior, idempotent: true }; + const ids = [sourceWarehouseId, destinationWarehouseId].sort(); const balances = {}; + for (const id of ids) balances[id] = await lockBalance(id, variantId, transaction); + const source = balances[sourceWarehouseId]; if (!source || Number(source.on_hand) - Number(source.reserved) < quantity) throw fault("Insufficient transferable stock", "INSUFFICIENT_STOCK", 409); + let destination = balances[destinationWarehouseId]; if (!destination) destination = await db.InventoryBalance.create({ id: crypto.randomUUID(), warehouse_id: destinationWarehouseId, variant_id: variantId, on_hand: 0, reserved: 0 }, { transaction }); + await source.update({ on_hand: Number(source.on_hand) - quantity }, { transaction }); await destination.update({ on_hand: Number(destination.on_hand) + quantity }, { transaction }); + const transfer = await db.InventoryTransfer.create({ id: crypto.randomUUID(), event_id: eventId, transfer_number: eventId, source_warehouse_id: sourceWarehouseId, destination_warehouse_id: destinationWarehouseId, variant_id: variantId, quantity, created_by: actorUserId, completed_by: actorUserId, completed_at: new Date() }, { transaction }); + await ledger({ event_id: `${eventId}:out`, warehouse_id: sourceWarehouseId, variant_id: variantId, type: "TRANSFER_OUT", quantity_delta: -quantity, reference_type: "INVENTORY_TRANSFER", reference_id: transfer.id, actor_user_id: actorUserId, request_id: requestId }, transaction); + await ledger({ event_id: `${eventId}:in`, warehouse_id: destinationWarehouseId, variant_id: variantId, type: "TRANSFER_IN", quantity_delta: quantity, reference_type: "INVENTORY_TRANSFER", reference_id: transfer.id, actor_user_id: actorUserId, request_id: requestId }, transaction); + return { transfer, idempotent: false }; + }); +} + +async function expireReservations(limit = 100) { + const rows = await db.InventoryReservation.findAll({ where: { status: "ACTIVE", expires_at: { [Op.lte]: new Date() } }, order: [["expires_at", "ASC"]], limit }); + for (const row of rows) { try { await releaseReservation({ reservationKey: row.reservation_key, expired: true }); } catch (error) { if (error.code !== "RESERVATION_NOT_ACTIVE") throw error; } } + return rows.length; +} + +module.exports = { adjustStock, reserveStock, releaseReservation, consumeReservation, transferStock, expireReservations, availabilityStatus, ttlMinutes }; diff --git a/app/services/pricing/businessPricing.service.js b/app/services/pricing/businessPricing.service.js new file mode 100644 index 0000000..034861c --- /dev/null +++ b/app/services/pricing/businessPricing.service.js @@ -0,0 +1,15 @@ +const { Op } = require("sequelize"); const db = require("../../models"); +const activeAt = at => ({ status: "ACTIVE", [Op.and]: [{ [Op.or]: [{ starts_at: null }, { starts_at: { [Op.lte]: at } }] }, { [Op.or]: [{ ends_at: null }, { ends_at: { [Op.gt]: at } }] }] }); +async function resolvePrice({ businessCustomer, variant, quantity = 1, currency = variant.currency, at = new Date() }) { + const retail = { source: "RETAIL", unitPrice: String(variant.base_price), minimumQuantity: 1, volumeTier: null }; + if (!businessCustomer || businessCustomer.status !== "ACTIVE" || !businessCustomer.approvedAt) return retail; + const common = { variant_id: variant.id, currency, ...activeAt(at) }; + let rule = await db.VariantBusinessPrice.findOne({ where: { ...common, business_customer_id: businessCustomer.business_customer_id }, order: [["createdAt", "DESC"]] }); + let source = "CUSTOMER"; + if (!rule && businessCustomer.business_tier_id) { rule = await db.VariantBusinessPrice.findOne({ where: { ...common, business_customer_id: null, business_tier_id: businessCustomer.business_tier_id }, order: [["createdAt", "DESC"]] }); source = "TIER"; } + if (!rule) return retail; + if (quantity < rule.minimum_quantity) throw Object.assign(new Error(`Minimum quantity is ${rule.minimum_quantity}`), { code: "MOQ_NOT_MET", status: 400 }); + const tier = await db.BusinessPriceTier.findOne({ where: { business_price_id: rule.id, min_quantity: { [Op.lte]: quantity }, [Op.or]: [{ max_quantity: null }, { max_quantity: { [Op.gte]: quantity } }] }, order: [["min_quantity", "DESC"]] }); + return { source, unitPrice: String(tier ? tier.unit_price : rule.price), minimumQuantity: rule.minimum_quantity, volumeTier: tier ? { minQuantity: tier.min_quantity, maxQuantity: tier.max_quantity, unitPrice: String(tier.unit_price) } : null }; +} +module.exports = { resolvePrice }; diff --git a/app/services/pricing/money.js b/app/services/pricing/money.js new file mode 100644 index 0000000..32f2040 --- /dev/null +++ b/app/services/pricing/money.js @@ -0,0 +1,5 @@ +const parse = value => { const match = String(value).match(/^(\d+)(?:\.(\d{1,2}))?$/); if (!match) throw Object.assign(new Error("Invalid money"), { code: "INVALID_MONEY", status: 400 }); return BigInt(match[1]) * 100n + BigInt((match[2] || "").padEnd(2, "0")); }; +const format = cents => `${cents / 100n}.${String(cents % 100n).padStart(2, "0")}`; +const subtractFloor = (a, b) => format(parse(a) > parse(b) ? parse(a) - parse(b) : 0n); +const percentOff = (amount, percent) => { const [whole,fraction=""] = String(percent).split("."); const basis = BigInt(whole) * 100n + BigInt(fraction.padEnd(2,"0")); return format(parse(amount) * basis / 10000n); }; +module.exports = { parse, format, subtractFloor, percentOff }; diff --git a/app/services/pricing/pricing.service.js b/app/services/pricing/pricing.service.js new file mode 100644 index 0000000..22b29dc --- /dev/null +++ b/app/services/pricing/pricing.service.js @@ -0,0 +1,14 @@ +const db = require("../../models"); const business = require("./businessPricing.service"); const money = require("./money"); +const applies = (promotion, quantity, at) => promotion.status === "ACTIVE" && (!promotion.starts_at || new Date(promotion.starts_at) <= at) && (!promotion.ends_at || new Date(promotion.ends_at) > at) && (!promotion.minimum_quantity || quantity >= promotion.minimum_quantity); +const discount = (price, promotion) => promotion.type === "PERCENTAGE" ? money.percentOff(price, promotion.discount_percent) : promotion.type === "FIXED_AMOUNT" ? String(promotion.discount_amount) : money.subtractFloor(price, promotion.fixed_price); +async function quoteVariant({ variantId, businessCustomer, quantity = 1, couponCode, at = new Date() }) { + const variant = await db.ProductVariant.findOne({ where: { id: variantId, status: "ACTIVE" } }); if (!variant) throw Object.assign(new Error("Variant not found"), { code: "VARIANT_NOT_FOUND", status: 404 }); + const base = await business.resolvePrice({ businessCustomer, variant, quantity, at }); + const promotions = await db.Promotion.findAll({ where: { status: "ACTIVE" }, order: [["priority", "DESC"], ["id", "ASC"]] }); + const automatic = promotions.filter(p => applies(p, quantity, at))[0] || null; let effective = base.unitPrice; let automaticDiscount = "0.00"; + if (automatic) { automaticDiscount = discount(effective, automatic); effective = money.subtractFloor(effective, automaticDiscount); } + let coupon = null; + if (couponCode) { coupon = await db.Coupon.findOne({ where: { code: couponCode.trim().toUpperCase(), status: "ACTIVE" }, include: [{ model: db.Promotion, as: "promotion" }] }); if (!coupon || (coupon.starts_at && new Date(coupon.starts_at) > at) || (coupon.expires_at && new Date(coupon.expires_at) <= at) || !applies(coupon.promotion, quantity, at)) throw Object.assign(new Error("Coupon is not valid"), { code: "INVALID_COUPON", status: 400 }); if (!automatic || automatic.stackable) effective = money.subtractFloor(effective, discount(effective, coupon.promotion)); } + return { cataloguePrice: String(variant.base_price), businessPrice: base.source === "RETAIL" ? null : base.unitPrice, baseEligiblePrice: base.unitPrice, effectiveUnitPrice: effective, currency: variant.currency, minimumQuantity: base.minimumQuantity, automaticPromotion: automatic && { id: automatic.id, name: automatic.name }, coupon: coupon && { code: coupon.code } }; +} +module.exports = { quoteVariant, applies, discount }; diff --git a/app/validation/catalogue.schemas.js b/app/validation/catalogue.schemas.js new file mode 100644 index 0000000..b0c2385 --- /dev/null +++ b/app/validation/catalogue.schemas.js @@ -0,0 +1,6 @@ +const {z}=require("zod");const req=(body)=>z.object({body:z.object(body).strict(),params:z.object({}).passthrough(),query:z.object({}).passthrough()});const id=z.string().min(1).max(100);const slug=z.string().trim().toLowerCase().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).max(200);const money=z.string().regex(/^\d{1,13}(?:\.\d{1,2})?$/);const locale=z.enum(["en","si","ta"]);const translation=z.object({locale,name:z.string().trim().min(1).max(220),shortDescription:z.string().trim().max(500).optional(),description:z.string().trim().max(20000).optional(),careInstructions:z.string().trim().max(5000).optional(),materials:z.string().trim().max(2000).optional(),origin:z.string().trim().max(120).optional(),metaTitle:z.string().trim().max(180).optional(),metaDescription:z.string().trim().max(320).optional()}).strict(); +const variant=z.object({sku:z.string().trim().regex(/^[A-Za-z0-9._-]+$/).max(100),barcode:z.string().trim().max(100).optional(),basePrice:money,compareAtPrice:money.optional().nullable(),currency:z.string().length(3).transform(v=>v.toUpperCase()).default("LKR"),weight:z.string().regex(/^\d+(?:\.\d{1,3})?$/).optional(),sortOrder:z.number().int().min(0).default(0)}).strict(); +module.exports={slug,money,locale,productCreate:req({brandId:id,defaultCategoryId:id.optional(),slug,productCode:z.string().trim().min(1).max(100),productType:z.string().trim().max(80).default("STANDARD"),featured:z.boolean().default(false),newArrivalUntil:z.coerce.date().optional().nullable(),translations:z.array(translation).min(1),categoryIds:z.array(id).min(1),variants:z.array(variant).min(1),mediaUploadIds:z.array(z.number().int().positive()).default([])}),productUpdate:req({brandId:id.optional(),defaultCategoryId:id.optional().nullable(),slug:slug.optional(),productType:z.string().trim().max(80).optional(),featured:z.boolean().optional(),newArrivalUntil:z.coerce.date().optional().nullable(),status:z.enum(["DRAFT","ACTIVE","INACTIVE","ARCHIVED"]).optional(),visibility:z.enum(["PUBLIC","HIDDEN"]).optional()}),variantCreate:req(variant.shape),variantUpdate:req({status:z.enum(["ACTIVE","INACTIVE"]).optional(),basePrice:money.optional(),compareAtPrice:money.optional().nullable(),currency:z.string().length(3).transform(v=>v.toUpperCase()).optional(),weight:z.string().regex(/^\d+(?:\.\d{1,3})?$/).optional(),sortOrder:z.number().int().min(0).optional(),optionValueIds:z.array(id).optional()}),brand:req({name:z.string().trim().min(1).max(160),slug,description:z.string().trim().max(5000).optional(),logoUploadId:z.number().int().positive().optional().nullable(),website:z.string().url().optional().nullable(),status:z.enum(["ACTIVE","INACTIVE"]).default("INACTIVE"),sortOrder:z.number().int().min(0).default(0)}),category:req({parentId:id.optional().nullable(),code:z.string().trim().regex(/^[A-Za-z0-9_-]+$/).max(80),slug,status:z.enum(["ACTIVE","INACTIVE"]).default("INACTIVE"),sortOrder:z.number().int().min(0).default(0),imageUploadId:z.number().int().positive().optional().nullable(),translations:z.array(translation.pick({locale:true,name:true,description:true,metaTitle:true,metaDescription:true})).min(1)}),collection:req({slug,type:z.string().trim().min(1).max(80),status:z.enum(["DRAFT","ACTIVE","INACTIVE"]).default("DRAFT"),startsAt:z.coerce.date().optional().nullable(),endsAt:z.coerce.date().optional().nullable(),heroUploadId:z.number().int().positive().optional().nullable(),sortOrder:z.number().int().min(0).default(0),translations:z.array(z.object({locale,name:z.string().trim().min(1).max(180),description:z.string().max(10000).optional(),headline:z.string().max(250).optional(),subheadline:z.string().max(300).optional()}).strict()).min(1),productIds:z.array(id).default([])}),review:req({rating:z.number().int().min(1).max(5),title:z.string().trim().min(1).max(160),body:z.string().trim().min(3).max(5000)}),reviewStatus:req({status:z.enum(["APPROVED","REJECTED"])}),sizeGuide:req({code:z.string().trim().regex(/^[A-Za-z0-9_-]+$/).max(80),name:z.string().trim().min(1).max(160),locale,data:z.object({columns:z.array(z.string().max(80)).min(1).max(20),rows:z.array(z.array(z.string().max(100)).max(20)).max(100)}).strict(),categoryId:id.optional().nullable(),status:z.enum(["ACTIVE","INACTIVE"]).default("ACTIVE")})}; +module.exports.option=req({name:z.string().trim().min(1).max(100),values:z.array(z.string().trim().min(1).max(100)).min(1),sortOrder:z.number().int().min(0).default(0)}); +module.exports.media=req({uploadId:z.number().int().positive(),variantId:id.optional().nullable(),altText:z.string().trim().max(250).optional(),isPrimary:z.boolean().default(false),sortOrder:z.number().int().min(0).default(0)}); +module.exports.relation=req({targetProductId:id,relationType:z.enum(["RELATED","SIMILAR","COMPLETE_THE_LOOK"]),sortOrder:z.number().int().min(0).default(0)}); diff --git a/app/validation/inventoryMerchandising.schemas.js b/app/validation/inventoryMerchandising.schemas.js new file mode 100644 index 0000000..cf530ea --- /dev/null +++ b/app/validation/inventoryMerchandising.schemas.js @@ -0,0 +1,8 @@ +const {z}=require("zod");const wrap=body=>z.object({body:body.strict(),params:z.object({}).passthrough(),query:z.object({}).passthrough()});const id=z.string().min(1).max(160),money=z.string().regex(/^\d+(\.\d{1,2})?$/),date=z.coerce.date(); +exports.adjustment=wrap(z.object({warehouseId:id,variantId:id,quantityDelta:z.number().int().refine(v=>v!==0),reason:z.string().min(1).max(500)})); +exports.transfer=wrap(z.object({sourceWarehouseId:id,destinationWarehouseId:id,variantId:id,quantity:z.number().int().positive()})); +exports.warehouse=wrap(z.object({code:z.string().trim().min(1).max(50),name:z.string().min(1).max(150),status:z.enum(["ACTIVE","INACTIVE"]).default("ACTIVE"),isDefault:z.boolean().default(false),timezone:z.string().max(80).optional(),city:z.string().max(100).optional(),countryCode:z.string().length(2).optional()})); +exports.businessPrice=wrap(z.object({variantId:id,businessTierId:id.optional(),businessCustomerId:id.optional(),currency:z.string().length(3).transform(v=>v.toUpperCase()),price:money,minimumQuantity:z.number().int().positive().default(1),status:z.enum(["ACTIVE","INACTIVE"]).default("ACTIVE"),startsAt:date.optional(),endsAt:date.optional(),tiers:z.array(z.object({minQuantity:z.number().int().positive(),maxQuantity:z.number().int().positive().nullable().optional(),unitPrice:money}).strict()).default([])}).refine(x=>Boolean(x.businessTierId)!==Boolean(x.businessCustomerId),"Exactly one audience required")); +exports.promotion=wrap(z.object({name:z.string().min(1).max(180),type:z.enum(["PERCENTAGE","FIXED_AMOUNT","FIXED_PRICE"]),status:z.enum(["DRAFT","ACTIVE","INACTIVE","ARCHIVED"]).default("DRAFT"),startsAt:date.optional(),endsAt:date.optional(),priority:z.number().int().default(0),stackable:z.boolean().default(false),discountPercent:money.optional(),discountAmount:money.optional(),fixedPrice:money.optional(),minimumQuantity:z.number().int().positive().optional(),targets:z.array(z.object({type:z.enum(["ALL","PRODUCT","VARIANT","CATEGORY","BRAND","COLLECTION"]),id:id.nullable().optional()}).strict()).default([{type:"ALL"}])})); +exports.coupon=wrap(z.object({code:z.string().trim().min(1).max(50).regex(/^[A-Za-z0-9_-]+$/).transform(v=>v.toUpperCase()),promotionId:id,status:z.enum(["ACTIVE","INACTIVE","ARCHIVED"]).default("ACTIVE"),startsAt:date.optional(),expiresAt:date.optional()})); +exports.banner=wrap(z.object({placement:z.string().trim().min(1).max(80),status:z.enum(["DRAFT","ACTIVE","INACTIVE","ARCHIVED"]).default("DRAFT"),startsAt:date.optional(),endsAt:date.optional(),imageUploadId:z.number().int().positive(),mobileImageUploadId:z.number().int().positive().optional(),targetUrl:z.string().url().optional(),audienceType:z.enum(["ALL","CUSTOMER","BUSINESS_CUSTOMER"]).default("ALL"),businessTierId:id.optional(),sortOrder:z.number().int().default(0),translations:z.array(z.object({locale:z.enum(["en","si","ta"]),headline:z.string().max(180).optional(),subheadline:z.string().max(500).optional(),ctaText:z.string().max(80).optional(),altText:z.string().max(255).optional()}).strict()).min(1)})); diff --git a/cron/index.js b/cron/index.js index ce37337..cfbf7e5 100644 --- a/cron/index.js +++ b/cron/index.js @@ -10,11 +10,12 @@ // cron/index.js const startCleanInactiveNotificationsCron = require("./notificationCleaning.cron"); +const startInventoryReservationExpiryCron = require("./inventoryReservationExpiry.cron"); function startAllCrons() { console.log("Starting Cron Jobs..."); - const tasks = [startCleanInactiveNotificationsCron()]; + const tasks = [startCleanInactiveNotificationsCron(), startInventoryReservationExpiryCron()]; return async () => { for (const task of tasks) { diff --git a/cron/inventoryReservationExpiry.cron.js b/cron/inventoryReservationExpiry.cron.js new file mode 100644 index 0000000..d6dbb88 --- /dev/null +++ b/cron/inventoryReservationExpiry.cron.js @@ -0,0 +1 @@ +const cron=require("node-cron"),inventory=require("../app/services/inventory/inventory.service");module.exports=()=>cron.schedule("* * * * *",async()=>{try{await inventory.expireReservations(100);}catch(error){console.error("Inventory reservation reconciliation failed",{name:error.name,message:error.message});}},{noOverlap:true}); diff --git a/migrations/20260903040000-phase-4-catalogue-content.js b/migrations/20260903040000-phase-4-catalogue-content.js new file mode 100644 index 0000000..7960431 --- /dev/null +++ b/migrations/20260903040000-phase-4-catalogue-content.js @@ -0,0 +1,21 @@ +"use strict"; +module.exports={async up(q,S){await q.sequelize.transaction(async transaction=>{const ts={createdAt:{type:S.DATE,allowNull:false},updatedAt:{type:S.DATE,allowNull:false}};const table=(name,fields)=>q.createTable(name,{...fields,...ts},{transaction}); +await table("brands",{id:{type:S.STRING,primaryKey:true},name:{type:S.STRING(160),allowNull:false},slug:{type:S.STRING(180),allowNull:false,unique:true},description:S.TEXT,logo_upload_id:S.INTEGER,website:S.STRING,status:{type:S.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"INACTIVE"},sort_order:{type:S.INTEGER,allowNull:false,defaultValue:0}});await q.addIndex("brands",["status","sort_order"],{name:"brands_public_idx",transaction}); +await table("categories",{id:{type:S.STRING,primaryKey:true},parent_id:{type:S.STRING,allowNull:true,references:{model:"categories",key:"id"},onDelete:"RESTRICT"},code:{type:S.STRING(80),allowNull:false,unique:true},slug:{type:S.STRING(180),allowNull:false,unique:true},status:{type:S.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"INACTIVE"},sort_order:{type:S.INTEGER,allowNull:false,defaultValue:0},image_upload_id:S.INTEGER});await q.addIndex("categories",["parent_id","status","sort_order"],{name:"categories_tree_idx",transaction}); +await table("category_translations",{id:{type:S.STRING,primaryKey:true},category_id:{type:S.STRING,allowNull:false,references:{model:"categories",key:"id"},onDelete:"CASCADE"},locale:{type:S.ENUM("en","si","ta"),allowNull:false},name:{type:S.STRING(180),allowNull:false},description:S.TEXT,meta_title:S.STRING(180),meta_description:S.STRING(320)});await q.addIndex("category_translations",["category_id","locale"],{unique:true,name:"category_translation_locale_unique",transaction}); +await table("size_guides",{id:{type:S.STRING,primaryKey:true},code:{type:S.STRING(80),allowNull:false,unique:true},name:{type:S.STRING(160),allowNull:false},locale:{type:S.ENUM("en","si","ta"),allowNull:false,defaultValue:"en"},data:{type:S.JSON,allowNull:false},category_id:{type:S.STRING,allowNull:true,references:{model:"categories",key:"id"},onDelete:"SET NULL"},status:{type:S.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"}}); +await table("products",{id:{type:S.STRING,primaryKey:true},brand_id:{type:S.STRING,allowNull:false,references:{model:"brands",key:"id"},onDelete:"RESTRICT"},default_category_id:{type:S.STRING,allowNull:true,references:{model:"categories",key:"id"},onDelete:"SET NULL"},slug:{type:S.STRING(200),allowNull:false,unique:true},product_code:{type:S.STRING(100),allowNull:false,unique:true},status:{type:S.ENUM("DRAFT","ACTIVE","INACTIVE","ARCHIVED"),allowNull:false,defaultValue:"DRAFT"},visibility:{type:S.ENUM("PUBLIC","HIDDEN"),allowNull:false,defaultValue:"HIDDEN"},product_type:{type:S.STRING(80),allowNull:false,defaultValue:"STANDARD"},featured:{type:S.BOOLEAN,allowNull:false,defaultValue:false},new_arrival_until:S.DATE,published_at:S.DATE,created_by:{type:S.STRING,allowNull:false,references:{model:"users",key:"id"}},updated_by:S.STRING,size_guide_id:{type:S.STRING,allowNull:true,references:{model:"size_guides",key:"id"},onDelete:"SET NULL"}});await q.addIndex("products",["status","visibility","published_at"],{name:"products_public_idx",transaction});await q.addIndex("products",["brand_id","featured","new_arrival_until"],{name:"products_merchandising_idx",transaction}); +await table("product_translations",{id:{type:S.STRING,primaryKey:true},product_id:{type:S.STRING,allowNull:false,references:{model:"products",key:"id"},onDelete:"CASCADE"},locale:{type:S.ENUM("en","si","ta"),allowNull:false},name:{type:S.STRING(220),allowNull:false},short_description:S.STRING(500),description:S.TEXT,care_instructions:S.TEXT,materials:S.TEXT,origin:S.STRING(120),meta_title:S.STRING(180),meta_description:S.STRING(320)});await q.addIndex("product_translations",["product_id","locale"],{unique:true,name:"product_translation_locale_unique",transaction});await q.addIndex("product_translations",["name"],{name:"product_translation_name_idx",transaction}); +await table("product_categories",{id:{type:S.STRING,primaryKey:true},product_id:{type:S.STRING,allowNull:false,references:{model:"products",key:"id"},onDelete:"CASCADE"},category_id:{type:S.STRING,allowNull:false,references:{model:"categories",key:"id"},onDelete:"RESTRICT"}});await q.addIndex("product_categories",["product_id","category_id"],{unique:true,name:"product_category_unique",transaction});await q.addIndex("product_categories",["category_id","product_id"],{name:"product_category_lookup_idx",transaction}); +await table("product_variants",{id:{type:S.STRING,primaryKey:true},product_id:{type:S.STRING,allowNull:false,references:{model:"products",key:"id"},onDelete:"CASCADE"},sku:{type:S.STRING(100),allowNull:false,unique:true},barcode:{type:S.STRING(100),allowNull:true,unique:true},status:{type:S.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"},base_price:{type:S.DECIMAL(15,2),allowNull:false},compare_at_price:S.DECIMAL(15,2),currency:{type:S.STRING(3),allowNull:false,defaultValue:"LKR"},weight:S.DECIMAL(10,3),sort_order:{type:S.INTEGER,allowNull:false,defaultValue:0}});await q.addIndex("product_variants",["product_id","status","base_price"],{name:"product_variant_public_idx",transaction}); +await table("product_options",{id:{type:S.STRING,primaryKey:true},product_id:{type:S.STRING,allowNull:false,references:{model:"products",key:"id"},onDelete:"CASCADE"},name:{type:S.STRING(100),allowNull:false},sort_order:{type:S.INTEGER,allowNull:false,defaultValue:0}});await q.addIndex("product_options",["product_id","name"],{unique:true,name:"product_option_name_unique",transaction}); +await table("product_option_values",{id:{type:S.STRING,primaryKey:true},option_id:{type:S.STRING,allowNull:false,references:{model:"product_options",key:"id"},onDelete:"CASCADE"},value:{type:S.STRING(100),allowNull:false},sort_order:{type:S.INTEGER,allowNull:false,defaultValue:0}});await q.addIndex("product_option_values",["option_id","value"],{unique:true,name:"product_option_value_unique",transaction}); +await table("variant_option_values",{id:{type:S.STRING,primaryKey:true},variant_id:{type:S.STRING,allowNull:false,references:{model:"product_variants",key:"id"},onDelete:"CASCADE"},option_value_id:{type:S.STRING,allowNull:false,references:{model:"product_option_values",key:"id"},onDelete:"CASCADE"}});await q.addIndex("variant_option_values",["variant_id","option_value_id"],{unique:true,name:"variant_option_value_unique",transaction}); +await table("product_attributes",{id:{type:S.STRING,primaryKey:true},product_id:{type:S.STRING,allowNull:false,references:{model:"products",key:"id"},onDelete:"CASCADE"},name:{type:S.STRING(100),allowNull:false},value:{type:S.STRING(500),allowNull:false},locale:{type:S.ENUM("en","si","ta"),allowNull:false,defaultValue:"en"},sort_order:{type:S.INTEGER,allowNull:false,defaultValue:0}});await q.addIndex("product_attributes",["product_id","locale"],{name:"product_attribute_lookup_idx",transaction}); +await table("product_media",{id:{type:S.STRING,primaryKey:true},product_id:{type:S.STRING,allowNull:false,references:{model:"products",key:"id"},onDelete:"CASCADE"},variant_id:{type:S.STRING,allowNull:true,references:{model:"product_variants",key:"id"},onDelete:"CASCADE"},upload_id:{type:S.INTEGER,allowNull:false,references:{model:"uploads",key:"id"},onDelete:"RESTRICT"},type:{type:S.ENUM("IMAGE"),allowNull:false,defaultValue:"IMAGE"},sort_order:{type:S.INTEGER,allowNull:false,defaultValue:0},alt_text:S.STRING(250),is_primary:{type:S.BOOLEAN,allowNull:false,defaultValue:false}});await q.addIndex("product_media",["product_id","upload_id"],{unique:true,name:"product_media_upload_unique",transaction});await q.addIndex("product_media",["product_id","is_primary","sort_order"],{name:"product_media_gallery_idx",transaction}); +await table("collections",{id:{type:S.STRING,primaryKey:true},slug:{type:S.STRING(180),allowNull:false,unique:true},type:{type:S.STRING(80),allowNull:false},status:{type:S.ENUM("DRAFT","ACTIVE","INACTIVE"),allowNull:false,defaultValue:"DRAFT"},starts_at:S.DATE,ends_at:S.DATE,hero_upload_id:S.INTEGER,sort_order:{type:S.INTEGER,allowNull:false,defaultValue:0}});await q.addIndex("collections",["status","starts_at","ends_at"],{name:"collections_publish_window_idx",transaction}); +await table("collection_translations",{id:{type:S.STRING,primaryKey:true},collection_id:{type:S.STRING,allowNull:false,references:{model:"collections",key:"id"},onDelete:"CASCADE"},locale:{type:S.ENUM("en","si","ta"),allowNull:false},name:{type:S.STRING(180),allowNull:false},description:S.TEXT,headline:S.STRING(250),subheadline:S.STRING(300)});await q.addIndex("collection_translations",["collection_id","locale"],{unique:true,name:"collection_translation_locale_unique",transaction}); +await table("collection_products",{id:{type:S.STRING,primaryKey:true},collection_id:{type:S.STRING,allowNull:false,references:{model:"collections",key:"id"},onDelete:"CASCADE"},product_id:{type:S.STRING,allowNull:false,references:{model:"products",key:"id"},onDelete:"CASCADE"},sort_order:{type:S.INTEGER,allowNull:false,defaultValue:0},featured:{type:S.BOOLEAN,allowNull:false,defaultValue:false}});await q.addIndex("collection_products",["collection_id","product_id"],{unique:true,name:"collection_product_unique",transaction});await q.addIndex("collection_products",["collection_id","sort_order"],{name:"collection_product_order_idx",transaction}); +await table("product_relations",{id:{type:S.STRING,primaryKey:true},source_product_id:{type:S.STRING,allowNull:false,references:{model:"products",key:"id"},onDelete:"CASCADE"},target_product_id:{type:S.STRING,allowNull:false,references:{model:"products",key:"id"},onDelete:"CASCADE"},relation_type:{type:S.ENUM("RELATED","SIMILAR","COMPLETE_THE_LOOK"),allowNull:false},sort_order:{type:S.INTEGER,allowNull:false,defaultValue:0}});await q.addIndex("product_relations",["source_product_id","target_product_id","relation_type"],{unique:true,name:"product_relation_unique",transaction});await q.sequelize.query("ALTER TABLE product_relations ADD CONSTRAINT product_relation_not_self CHECK (source_product_id <> target_product_id)",{transaction}); +await table("product_reviews",{id:{type:S.STRING,primaryKey:true},product_id:{type:S.STRING,allowNull:false,references:{model:"products",key:"id"},onDelete:"CASCADE"},user_id:{type:S.STRING,allowNull:false,references:{model:"users",key:"id"},onDelete:"RESTRICT"},rating:{type:S.INTEGER,allowNull:false},title:{type:S.STRING(160),allowNull:false},body:{type:S.TEXT,allowNull:false},status:{type:S.ENUM("PENDING","APPROVED","REJECTED"),allowNull:false,defaultValue:"PENDING"},verified_purchase:{type:S.BOOLEAN,allowNull:false,defaultValue:false},moderated_by:S.STRING,moderated_at:S.DATE});await q.addIndex("product_reviews",["user_id","product_id"],{unique:true,name:"product_review_user_unique",transaction});await q.addIndex("product_reviews",["product_id","status","createdAt"],{name:"product_review_public_idx",transaction});await q.sequelize.query("ALTER TABLE product_reviews ADD CONSTRAINT product_review_rating_check CHECK (rating BETWEEN 1 AND 5)",{transaction}); +});},async down(){throw new Error("Phase 4 migration is forward-only; restore from backup for rollback");}}; diff --git a/migrations/20260903050000-phase-5-inventory-merchandising.js b/migrations/20260903050000-phase-5-inventory-merchandising.js new file mode 100644 index 0000000..cc1f506 --- /dev/null +++ b/migrations/20260903050000-phase-5-inventory-merchandising.js @@ -0,0 +1,5 @@ +"use strict"; +module.exports={async up(q,S){const ID={type:S.STRING,allowNull:false,primaryKey:true},STRING={type:S.STRING},DATE={type:S.DATE},INT={type:S.INTEGER,allowNull:false,defaultValue:0},DEC={type:S.DECIMAL(15,2)},timestamps={createdAt:{type:S.DATE,allowNull:false},updatedAt:{type:S.DATE,allowNull:false}};await q.createTable("warehouses",{id:ID,code:{type:S.STRING(50),allowNull:false,unique:true},name:{type:S.STRING(150),allowNull:false},status:{type:S.ENUM("ACTIVE","INACTIVE"),allowNull:false},is_default:{type:S.BOOLEAN,allowNull:false,defaultValue:false},timezone:{type:S.STRING(80),allowNull:false},address_line_1:STRING,address_line_2:STRING,city:STRING,province:STRING,postal_code:STRING,country_code:{type:S.STRING(2),allowNull:false},...timestamps});await q.createTable("inventory_balances",{id:ID,warehouse_id:{type:S.STRING,allowNull:false},variant_id:{type:S.STRING,allowNull:false},on_hand:INT,reserved:INT,low_stock_threshold:INT,...timestamps});await q.addConstraint("inventory_balances",{fields:["warehouse_id","variant_id"],type:"unique",name:"uq_inventory_balance_warehouse_variant"}); +await q.createTable("inventory_transactions",{id:ID,event_id:{type:S.STRING,allowNull:false,unique:true},warehouse_id:str(false),variant_id:str(false),type:str(false),quantity_delta:INT,reserved_delta:INT,reference_type:STRING,reference_id:STRING,reason:{type:S.STRING(500)},actor_user_id:STRING,request_id:STRING,metadata:{type:S.JSON},occurred_at:{type:S.DATE,allowNull:false},createdAt:{type:S.DATE,allowNull:false}});await q.createTable("inventory_reservations",{id:ID,reservation_key:{type:S.STRING(160),allowNull:false,unique:true},warehouse_id:str(false),variant_id:str(false),quantity:{type:S.INTEGER,allowNull:false},status:str(false),reference_type:STRING,reference_id:STRING,user_id:STRING,expires_at:{type:S.DATE,allowNull:false},released_at:DATE,consumed_at:DATE,...timestamps});await q.createTable("inventory_transfers",{id:ID,event_id:{type:S.STRING,allowNull:false,unique:true},transfer_number:{type:S.STRING(80),allowNull:false,unique:true},source_warehouse_id:str(false),destination_warehouse_id:str(false),variant_id:str(false),quantity:{type:S.INTEGER,allowNull:false},status:str(false),created_by:str(false),completed_by:str(false),completed_at:{type:S.DATE,allowNull:false},...timestamps}); +await q.createTable("business_tiers",{id:ID,code:{type:S.STRING(60),allowNull:false,unique:true},name:str(false),description:{type:S.TEXT},status:str(false),sort_order:INT,...timestamps});await q.addColumn("business_customers","business_tier_id",STRING);await q.createTable("variant_business_prices",{id:ID,variant_id:str(false),business_tier_id:STRING,business_customer_id:STRING,currency:{type:S.STRING(3),allowNull:false},price:{...DEC,allowNull:false},minimum_quantity:{type:S.INTEGER,allowNull:false},status:str(false),starts_at:DATE,ends_at:DATE,...timestamps});await q.createTable("business_price_tiers",{id:ID,business_price_id:str(false),min_quantity:{type:S.INTEGER,allowNull:false},max_quantity:{type:S.INTEGER},unit_price:{...DEC,allowNull:false},...timestamps}); +await q.createTable("promotions",{id:ID,name:str(false),type:str(false),status:str(false),starts_at:DATE,ends_at:DATE,priority:INT,stackable:{type:S.BOOLEAN,allowNull:false},discount_percent:{type:S.DECIMAL(5,2)},discount_amount:DEC,fixed_price:DEC,minimum_subtotal:DEC,minimum_quantity:{type:S.INTEGER},customer_type:STRING,created_by:str(false),updated_by:STRING,...timestamps});await q.createTable("promotion_targets",{id:ID,promotion_id:str(false),target_type:str(false),target_id:STRING,...timestamps});await q.createTable("coupons",{id:ID,code:{type:S.STRING(50),allowNull:false,unique:true},promotion_id:str(false),status:str(false),starts_at:DATE,expires_at:DATE,max_uses:{type:S.INTEGER},max_uses_per_user:{type:S.INTEGER},...timestamps});await q.createTable("banners",{id:ID,placement:str(false),status:str(false),starts_at:DATE,ends_at:DATE,image_upload_id:{type:S.INTEGER,allowNull:false},mobile_image_upload_id:{type:S.INTEGER},target_url:STRING,audience_type:str(false),business_tier_id:STRING,sort_order:INT,created_by:str(false),updated_by:STRING,...timestamps});await q.createTable("banner_translations",{id:ID,banner_id:str(false),locale:{type:S.STRING(5),allowNull:false},headline:STRING,subheadline:{type:S.TEXT},cta_text:STRING,alt_text:STRING,...timestamps});await q.addConstraint("banner_translations",{fields:["banner_id","locale"],type:"unique",name:"uq_banner_translation_locale"});},async down(){throw new Error("Phase 5 migration is forward-only; restore from a verified backup instead");}}; diff --git a/tests/unit/catalogue.locale-serializer.test.js b/tests/unit/catalogue.locale-serializer.test.js new file mode 100644 index 0000000..141ee1e --- /dev/null +++ b/tests/unit/catalogue.locale-serializer.test.js @@ -0,0 +1,6 @@ +const {selectTranslation}=require("../../app/services/catalogue/locale.service");const {cents}=require("../../app/services/catalogue/serializer.service"); +describe("catalogue localization and money helpers",()=>{ +test("selects requested locale",()=>expect(selectTranslation([{locale:"en",name:"A"},{locale:"si",name:"B"}],"si").name).toBe("B")); +test("falls back to English",()=>expect(selectTranslation([{locale:"en",name:"A"}],"ta").name).toBe("A")); +test("compares decimal values without Number conversion",()=>{expect(cents("10.09")>cents("9.99")).toBe(true);expect(cents("1.5")).toBe(150n);}); +}); diff --git a/tests/unit/catalogue.schemas.test.js b/tests/unit/catalogue.schemas.test.js new file mode 100644 index 0000000..3d0dcd5 --- /dev/null +++ b/tests/unit/catalogue.schemas.test.js @@ -0,0 +1,12 @@ +const s=require("../../app/validation/catalogue.schemas");const wrap=body=>({body,params:{},query:{}});const base=()=>({brandId:"brand-1",slug:"linen-shirt",productCode:"SHIRT-1",translations:[{locale:"en",name:"Linen Shirt"}],categoryIds:["cat-1"],variants:[{sku:"SHIRT-1-M",basePrice:"1990.00",currency:"LKR"}],mediaUploadIds:[1]}); +describe("Phase 4 catalogue validation",()=>{ +test("accepts a minimal strict draft product",()=>expect(s.productCreate.safeParse(wrap(base())).success).toBe(true)); +test("rejects unknown administrative product fields",()=>expect(s.productCreate.safeParse(wrap({...base(),stock:100})).success).toBe(false)); +test("rejects invalid slug",()=>expect(s.productCreate.safeParse(wrap({...base(),slug:"Unsafe Slug!"})).success).toBe(false)); +test.each(["12","12.3","12.34"])("accepts decimal money %s",v=>expect(s.money.safeParse(v).success).toBe(true)); +test.each(["-1","1.234","NaN","1e3"])("rejects invalid money %s",v=>expect(s.money.safeParse(v).success).toBe(false)); +test("supports en si ta and rejects other translations",()=>{for(const locale of ["en","si","ta"])expect(s.productCreate.safeParse(wrap({...base(),translations:[{locale,name:"Name"}]})).success).toBe(true);expect(s.productCreate.safeParse(wrap({...base(),translations:[{locale:"fr",name:"Name"}]})).success).toBe(false);}); +test("client cannot set review verification or moderation",()=>{expect(s.review.safeParse(wrap({rating:5,title:"Great",body:"Excellent product"})).success).toBe(true);expect(s.review.safeParse(wrap({rating:5,title:"Great",body:"Excellent product",verifiedPurchase:true,status:"APPROVED"})).success).toBe(false);}); +test("validates rating range",()=>{expect(s.review.safeParse(wrap({rating:0,title:"Bad",body:"Not valid"})).success).toBe(false);expect(s.review.safeParse(wrap({rating:5,title:"Good",body:"Valid review"})).success).toBe(true);}); +test("size guide accepts structured data and not HTML",()=>{expect(s.sizeGuide.safeParse(wrap({code:"MEN_TOPS",name:"Men tops",locale:"en",data:{columns:["Size","Chest"],rows:[["M","40"]]}})).success).toBe(true);expect(s.sizeGuide.safeParse(wrap({code:"X",name:"X",locale:"en",data:{html:"<script>"}})).success).toBe(false);}); +}); diff --git a/tests/unit/catalogue.service.test.js b/tests/unit/catalogue.service.test.js new file mode 100644 index 0000000..c5ad8f4 --- /dev/null +++ b/tests/unit/catalogue.service.test.js @@ -0,0 +1,7 @@ +describe("catalogue hierarchy and publication rules",()=>{beforeEach(()=>jest.resetModules()); +const setup=(nodes={})=>{const models={Category:{findByPk:jest.fn(async id=>nodes[id]||null)},ProductTranslation:{findOne:jest.fn()},ProductVariant:{findOne:jest.fn()},ProductMedia:{findOne:jest.fn()}};jest.doMock("../../app/models",()=>models);return{service:require("../../app/services/catalogue/catalogue.service"),models};}; +test("blocks self-parent",async()=>{const {service}=setup();await expect(service.assertCategoryParent("a","a",{})).rejects.toMatchObject({code:"CATEGORY_SELF_PARENT"});}); +test("blocks category cycle",async()=>{const {service}=setup({b:{id:"b",parent_id:"a"}});await expect(service.assertCategoryParent("a","b",{})).rejects.toMatchObject({code:"CATEGORY_CYCLE"});}); +test("accepts valid hierarchy",async()=>{const {service}=setup({b:{id:"b",parent_id:null}});await expect(service.assertCategoryParent("a","b",{})).resolves.toBeUndefined();}); +test("publish requires English translation, variant, and primary media",async()=>{const {service,models}=setup();models.ProductTranslation.findOne.mockResolvedValue(null);await expect(service.validatePublish({id:"p",slug:"p",brand_id:"b"},{})).rejects.toMatchObject({code:"PUBLISH_REQUIREMENTS_NOT_MET"});models.ProductTranslation.findOne.mockResolvedValue({});models.ProductVariant.findOne.mockResolvedValue({});models.ProductMedia.findOne.mockResolvedValue({});await expect(service.validatePublish({id:"p",slug:"p",brand_id:"b"},{})).resolves.toBeUndefined();}); +}); diff --git a/tests/unit/inventory-merchandising.money.test.js b/tests/unit/inventory-merchandising.money.test.js new file mode 100644 index 0000000..bae698c --- /dev/null +++ b/tests/unit/inventory-merchandising.money.test.js @@ -0,0 +1,2 @@ +const money=require("../../app/services/pricing/money");const inventory=require("../../app/services/inventory/inventory.service"); +describe("Phase 5 decimal and availability rules",()=>{test("money remains integer-scaled and floors discounts at zero",()=>{expect(money.parse("900.05")).toBe(90005n);expect(money.percentOff("1000.00","12.50")).toBe("125.00");expect(money.subtractFloor("5.00","10.00")).toBe("0.00");});test.each([[{on_hand:10,reserved:1,low_stock_threshold:2},"IN_STOCK"],[{on_hand:3,reserved:1,low_stock_threshold:2},"LOW_STOCK"],[{on_hand:3,reserved:3,low_stock_threshold:2},"OUT_OF_STOCK"]])("availability projection",(balance,status)=>expect(inventory.availabilityStatus(balance)).toBe(status));test("reservation TTL is centralized",()=>expect(inventory.ttlMinutes()).toBeGreaterThan(0));}); diff --git a/tests/unit/inventory-merchandising.permissions.test.js b/tests/unit/inventory-merchandising.permissions.test.js new file mode 100644 index 0000000..fac53b9 --- /dev/null +++ b/tests/unit/inventory-merchandising.permissions.test.js @@ -0,0 +1,2 @@ +const {checkPermission}=require("../../app/middleware/permission.middleware"); +describe("Phase 5 permission boundaries",()=>{test("denies an authenticated customer without inventory permission",()=>{const req={user:{accountType:"CUSTOMER",permissions:[]},method:"POST"},res={status:jest.fn().mockReturnThis(),json:jest.fn()},next=jest.fn();checkPermission("inventory.adjust",{custom:true})(req,res,next);expect(res.status).toHaveBeenCalledWith(403);expect(next).not.toHaveBeenCalled();});test("allows explicit permission",()=>{const req={user:{accountType:"ADMIN",permissions:["pricing.business.manage"]},method:"POST"},res={},next=jest.fn();checkPermission("pricing.business.manage",{custom:true})(req,res,next);expect(next).toHaveBeenCalled();});}); diff --git a/tests/unit/inventory-merchandising.schemas.test.js b/tests/unit/inventory-merchandising.schemas.test.js new file mode 100644 index 0000000..e64ca47 --- /dev/null +++ b/tests/unit/inventory-merchandising.schemas.test.js @@ -0,0 +1,2 @@ +const s=require("../../app/validation/inventoryMerchandising.schemas");const input=body=>({body,params:{},query:{}}); +describe("Phase 5 strict validation",()=>{test("normalizes coupon codes",()=>expect(s.coupon.parse(input({code:"save_10",promotionId:"p",status:"ACTIVE"})).body.code).toBe("SAVE_10"));test("rejects sensitive unknown fields",()=>expect(()=>s.adjustment.parse(input({warehouseId:"w",variantId:"v",quantityDelta:1,reason:"receipt",onHand:999}))).toThrow());test("requires exactly one business audience",()=>expect(()=>s.businessPrice.parse(input({variantId:"v",currency:"LKR",price:"10.00",businessTierId:"t",businessCustomerId:"c"}))).toThrow());test("rejects floating quantities",()=>expect(()=>s.transfer.parse(input({sourceWarehouseId:"a",destinationWarehouseId:"b",variantId:"v",quantity:1.5}))).toThrow());});