Compare commits

..

15 Commits

Author SHA1 Message Date
Sathira Sri Sathara 5158c52db5 feat: Add Phase 11 analytics and production readiness features
CI / test (push) Successful in 10m56s
CI / test (pull_request) Successful in 11m1s
- Introduced new endpoints for account overview, analytics, and metrics.
- Implemented authorization matrix and backup/restore documentation.
- Added smoke test script and updated package dependencies.
- Created detailed production checklist and runbook for deployment.
- Established cron operations and notification event matrix documentation.
- Enhanced security and validation audits for analytics and metrics services.
- Added unit tests for analytics and metrics functionalities.
2026-09-16 10:59:19 +05:30
Sathira Sri Sathara f920ca8920 feat: Implement Phase 10 support and recommendations features
CI / test (push) Successful in 10m22s
CI / test (pull_request) Successful in 10m53s
- Added models for support messages, SLA policies, tickets, ticket events, and ticket links.
- Created routes for help, help admin, newsletter, recommendations, and support for both customer and admin.
- Developed services for help, marketing (newsletter), and recommendations.
- Introduced support service for ticket management, including creation, replies, transitions, and attachments.
- Added validation schemas for support recommendations and ticket management.
- Implemented a cron job for support reconciliation and recommendation cleanup.
- Created migration for new support and recommendation database tables.
- Added unit tests for validation and policy checks related to Phase 10 features.
2026-09-09 16:07:31 +05:30
Sathira Sri Sathara abb760cc19 feat: Implement loyalty and wholesale services with models, routes, and validation
CI / test (push) Successful in 10m23s
- Added models for loyalty points allocation, redemption, rewards, tiers, tier history, and referrals.
- Created business credit ledger entries, settlements, and settlement items models.
- Developed services for loyalty operations including account management, point allocation, redemption, and referral handling.
- Implemented wholesale credit management services for transactions and settlements.
- Established routes for loyalty and wholesale admin and customer operations with appropriate middleware for authentication and permission checks.
- Introduced validation schemas for loyalty and wholesale operations.
- Set up cron jobs for loyalty reconciliation tasks such as birthday rewards and point expirations.
- Created migration scripts to set up the database schema for loyalty and wholesale features.
- Added unit tests for validation and policy enforcement in loyalty and wholesale services.
2026-09-09 13:56:37 +05:30
Sathira Sri Sathara 222483d194 feat: Implement Phase 8 Delivery and Rider Management
CI / test (push) Successful in 10m26s
- Introduced new Delivery and Rider API documentation.
- Added new models for RiderProfile, Shipment, ShipmentItem, ShipmentAssignment, ShipmentEvent, and ShipmentProof.
- Developed controllers for admin and rider logistics, including shipment management and rider actions.
- Created services for handling shipment creation, assignment, and state transitions.
- Implemented validation schemas for shipment and rider operations.
- Added new routes for admin and rider logistics, including tracking endpoints.
- Established a state machine for shipment status transitions.
- Created migration scripts for new database tables and relationships.
- Added unit tests for shipment state transitions and validation security.
2026-09-09 12:55:32 +05:30
Sathira Sri Sathara cd2c1c6d08 feat: Implement return and refund functionality in commerce module
CI / test (push) Successful in 10m27s
- Added return request handling in return.controller.js
- Implemented webhook handling for PayHere payments in webhook.controller.js
- Created models for coupon redemptions, invoices, orders, order items, payments, payment attempts, payment webhook events, refunds, return items, and return requests.
- Developed services for order management, payment processing, refunds, and returns.
- Introduced validation schemas for payment and return requests.
- Created migration scripts for new database tables related to orders, payments, refunds, and returns.
- Added unit tests for order state transitions and PayHere provider functionality.
2026-09-09 12:39:57 +05:30
Sathira Sri Sathara ad9287b804 feat: Implement Phase 6 Shopping and Checkout
CI / test (push) Has been cancelled
CI / test (pull_request) Has been cancelled
- Introduced authenticated shopping state and atomic checkout process without creating orders or payments.
- Reused existing components from previous phases including identities, addresses, catalogues, pricing, and inventory.
- Developed cart architecture to support one active cart per user with unique items per variant.
- Implemented dynamic cart pricing with various precedence rules for promotions and coupons.
- Created a wishlist feature that exposes only visible active products without reserving inventory.
- Integrated inventory validation to ensure availability of items before adding to cart.
- Developed a checkout architecture that locks the cart, revalidates pricing and shipping, and reserves inventory.
- Added shipping zones, methods, and rates with configurable options for international shipping and duty/tax boundaries.
- Implemented checkout snapshots to capture immutable checkout details.
- Introduced idempotency for checkout requests to prevent duplicate processing.
- Added functionality for coupon integration and business checkout validation.
- Established permissions for managing shipping zones, methods, and rates.
- Created comprehensive unit tests covering various aspects of the shopping and checkout processes.
- Added cron job for handling checkout expiry reconciliation.
- Created migration script to set up new database tables and constraints for carts, checkout sessions, and shipping.
2026-09-03 23:45:01 +05:30
Sathira Sri Sathara 5643d89236 feat: implement business pricing service and money utilities
- Added `businessPricing.service.js` to handle business customer pricing logic.
- Introduced `money.js` for money parsing, formatting, and calculations.
- Created `pricing.service.js` to manage variant quoting and promotion application.
- Updated validation schemas in `catalogue.schemas.js` and `inventoryMerchandising.schemas.js` for new pricing and inventory features.
- Implemented cron job for inventory reservation expiry in `inventoryReservationExpiry.cron.js`.
- Created database migrations for catalogue and inventory structures.
- Added unit tests for catalogue localization, validation, and inventory merchandising functionalities.
2026-09-03 19:25:13 +05:30
Sathira Sri Sathara fd4e324571 feat: implement customer profile management and business application features
- Added customer profile controller with endpoints for retrieving, updating, and deactivating user profiles.
- Introduced address model and routes for managing user addresses.
- Created business application model and service for handling business applications, including approval and rejection processes.
- Developed business contact and credit account models to support business customer functionalities.
- Implemented settlement term model for managing payment terms.
- Added email templates for business application notifications (approved, rejected, received, and status changes).
- Enhanced validation schemas for customer and business inputs to ensure data integrity.
- Created unit tests for business application service and validation schemas to ensure functionality and correctness.
- Added migration scripts to set up new database tables and columns for customer and business features.
2026-09-03 14:58:26 +05:30
Sathira Sri Sathara b6b345f245 feat: Implement Phase 2 cross-cutting services with email and notification enhancements
- Refactor email verification and password reset utilities to use new email service.
- Introduce email delivery queue and notification delivery model for better tracking.
- Enhance file validation and storage services for improved security and ownership management.
- Add cron job for cleaning inactive notifications with retention policy.
- Update document worker to handle document generation and storage more efficiently.
- Implement logging improvements in activity and log workers.
- Create comprehensive documentation for new API endpoints and services.
- Add unit tests for file validation and notification policies to ensure robustness.
2026-09-03 14:22:34 +05:30
Sathira Sri Sathara 9d3d431416 feat: implement identity and security features
- Added account types and privileged account types constants.
- Created admin user controller for updating user security fields.
- Developed role assignment controller for managing user roles.
- Implemented validation middleware for request schemas.
- Defined user role and auth session models for database interactions.
- Created services for authentication, email notifications, and OTP handling.
- Developed OAuth service for Google and Apple authentication.
- Added JWT utility functions for token generation and verification.
- Implemented comprehensive tests for authentication, session management, and password policies.
- Created migration for updating user schema and adding new tables for auth sessions and user identities.
2026-09-03 13:56:18 +05:30
Sathira Sri Sathara 267e80e2ec feat: stabilize API startup and lifecycle management
- Refactor server initialization to separate concerns and improve error handling.
- Implement centralized environment validation using Zod.
- Introduce database, Redis, and queue lifecycle management.
- Add health check endpoints for liveness and readiness.
- Enhance error handling middleware for better response structure.
- Implement rate limiting for API endpoints.
- Add request ID middleware for traceability.
- Create Sequelize CLI configuration and baseline migration for schema management.
- Establish CI workflow with Gitea for testing and syntax checks.
- Document foundational changes and migration strategy in PHASE_0_FOUNDATION_STABILIZATION.md.
- Add Docker Compose configuration for local development and testing.
- Implement unit and integration tests for critical functionality.
2026-09-03 13:33:26 +05:30
Sathira Sri Sathara 624fce31c5 Merge branch 'auth' of https://github.com/isuru-bimsara/ZUMRI-backend into auth 2026-09-03 13:02:31 +05:30
Sathira Sri Sathara 230962b1d0 Add current backend status documentation 2026-09-03 13:02:24 +05:30
Isuru Bimsara efb054f54d Add change password functionality with validation and email notification 2026-08-21 21:42:19 +05:30
Isuru Bimsara 7766614898 Enhance Authentication API documentation with detailed endpoints and error responses 2026-08-21 15:20:19 +05:30
387 changed files with 8220 additions and 4772 deletions
+62 -35
View File
@@ -1,53 +1,80 @@
# App Details
APP_NAME=
# Required for API and worker startup
APP_NAME=ZUMRI
NODE_ENV=development
PORT=3070
FRONTEND_URL=http://localhost:3000
TRUST_PROXY=0
# Database configuration variables
DB_HOST =
DB_USER =
DB_PASSWORD =
DB_NAME =
DB_PORT =
DB_HOST=localhost
DB_PORT=3306
DB_NAME=zumri
DB_USER=zumri
DB_PASSWORD=replace_with_local_database_password
MYSQL_ROOT_PASSWORD=replace_with_local_root_password
# Redis configuration variables
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=replace_with_local_redis_password
# JWT secret key
JWT_SECRET = your_jwt_secret_key_here
JWT_EXPIRES_IN =1d
# Use separate randomly generated values of at least 32 characters.
JWT_SECRET=replace_with_a_random_value_at_least_32_chars
JWT_EXPIRES_IN=15m
JWT_ISSUER=zumri-api
JWT_AUDIENCE=zumri-clients
ACCESS_TOKEN_TTL=15m
REFRESH_TOKEN_TTL_DAYS=7
REMEMBER_ME_REFRESH_TOKEN_TTL_DAYS=30
LOGIN_OTP_TTL_SECONDS=900
LOGIN_OTP_MAX_ATTEMPTS=5
# AWS S3 configuration
AWS_ACCESS_KEY_ID=your_aws_access_key_id_here
AWS_SECRET_ACCESS_KEY=your_aws_secret_access_key_here
AWS_REGION=your_aws_region_here
AWS_S3_BUCKET_NAME=your_aws_bucket_name_here
# Runtime controls
RUN_CRON=false
CACHE=true
JSON_BODY_LIMIT=1mb
API_RATE_LIMIT_WINDOW_MS=900000
API_RATE_LIMIT_MAX=300
SENSITIVE_RATE_LIMIT_WINDOW_MS=900000
SENSITIVE_RATE_LIMIT_MAX=20
SHUTDOWN_TIMEOUT_MS=10000
# Mail configuration
# Optional email feature
ENABLE_MAIL=false
MAIL_HOST=
MAIL_PORT=587
MAIL_USER=
MAIL_PASS=
MAIL_SECURE=false
MAIL_SECURE=false
MAIL_FROM=
# Documentation access credentials
# Optional S3 feature
ENABLE_S3=false
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION=
AWS_S3_BUCKET_NAME=
S3_ENDPOINT=
S3_FORCE_PATH_STYLE=false
S3_SIGNED_URL_TTL_SECONDS=900
S3_MAX_UPLOAD_BYTES=5242880
# Cross-cutting worker and retention settings
EMAIL_QUEUE_CONCURRENCY=5
DOCUMENT_QUEUE_CONCURRENCY=2
NOTIFICATION_RETENTION_DAYS=90
LOG_RETENTION_DAYS=30
# Optional documentation login
DOCS_USER=
DOCS_PASS=
# Admin email for receiving notifications
# Optional application configuration
MANAGER_EMAIL=
# Caching configuration
CACHE=true
# Application environment
NODE_ENV=development
# User default password
DEFAULT_PASSWORD=
# Frontend URL for CORS
FRONTEND_URL=http://localhost:3000
# Puppeteer executable path (if needed, otherwise Puppeteer will use the bundled Chromium)
PUPPETEER_EXECUTABLE_PATH = C:\Users\User\.cache\puppeteer\chrome-headless-shell\win64-142.0.7444.162\chrome-headless-shell-win64\chrome-headless-shell.exe
PASSWORD_RESET_TTL_SECONDS=900
EMAIL_VERIFICATION_TTL_SECONDS=86400
PUPPETEER_EXECUTABLE_PATH=
GOOGLE_CLIENT_ID=
APPLE_CLIENT_ID=
# Phase 10 bounded reconciliation and privacy retention
SUPPORT_SLA_RECONCILIATION_BATCH_SIZE=100
RECOMMENDATION_EVENT_RETENTION_DAYS=90
+18
View File
@@ -0,0 +1,18 @@
name: CI
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run check:syntax
- run: npm test -- --runInBand
+6
View File
@@ -0,0 +1,6 @@
const path = require("path");
module.exports = {
config: path.resolve("app/config/sequelize-cli.config.js"),
"migrations-path": path.resolve("migrations"),
};
+14 -36
View File
@@ -1,44 +1,22 @@
FROM node:20-slim
FROM node:22-slim
# Install Chromium + dependencies
RUN apt-get update && apt-get install -y \
chromium \
fonts-liberation \
libatk-bridge2.0-0 \
libatk1.0-0 \
libcups2 \
libxcomposite1 \
libxrandr2 \
libxdamage1 \
libgbm1 \
libasound2 \
libpangocairo-1.0-0 \
libpango-1.0-0 \
libnss3 \
libxss1 \
libgtk-3-0 \
libdrm2 \
libxshmfence1 \
ca-certificates \
--no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
# Tell Puppeteer to use system Chromium
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
# Prevent Puppeteer from downloading its own Chromium
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
chromium curl fonts-liberation libatk-bridge2.0-0 libatk1.0-0 libcups2 \
libxcomposite1 libxrandr2 libxdamage1 libgbm1 libasound2 libpangocairo-1.0-0 \
libpango-1.0-0 libnss3 libxss1 libgtk-3-0 libdrm2 libxshmfence1 ca-certificates \
--no-install-recommends && rm -rf /var/lib/apt/lists/*
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium \
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
ENV NODE_ENV=production
RUN npm ci --omit=dev && npm cache clean --force
COPY --chown=node:node . .
USER node
EXPOSE 3070
CMD ["node", "server.js"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl --fail --silent http://127.0.0.1:3070/health/live > /dev/null || exit 1
CMD ["node", "server.js"]
+95
View File
@@ -0,0 +1,95 @@
# ZUMRI Authentication API
All endpoints are available under both `/api/auth` and `/api/v1/auth`; new clients should use `/api/v1`. JSON errors use the Phase 0 standard error envelope. Examples contain placeholders only.
## Account states
- `PENDING_VERIFICATION`: registration exists but password/OAuth session creation is denied.
- `ACTIVE`: authentication and refresh are permitted.
- `SUSPENDED` and `DEACTIVATED`: login, refresh, and access-token middleware are denied.
## Register a customer
`POST /api/v1/auth/register`
```json
{"firstName":"Asha","lastName":"Perera","email":"asha@example.com","password":"Strong!1234x","phoneNumber":"+94000000000","address":"Customer-provided value"}
```
Public registration always creates `customer`; supplied privileged account fields are rejected/stripped by strict validation. Business and privileged registration are not public Phase 1 flows.
## Verify or resend email verification
- `POST /api/v1/auth/verify-email` — `{"token":"verification-token-from-email"}`
- `POST /api/v1/auth/resend-verification` — `{"email":"asha@example.com"}`
Tokens are random, stored only by hash in Redis, expire, and are single-use. Resend invalidates the previous token. Resend returns a generic response.
## Password plus OTP login
`POST /api/v1/auth/login`
```json
{"email":"asha@example.com","password":"Strong!1234x","rememberMe":true,"clientType":"WEB","deviceName":"Personal laptop"}
```
Successful credential verification returns HTTP 202 and a `challengeId`; it does not create a session. A hashed six-digit OTP challenge is stored in Redis for the configured TTL and attempt limit.
`POST /api/v1/auth/verify-otp`
```json
{"challengeId":"00000000-0000-4000-8000-000000000000","otp":"000000","clientType":"WEB"}
```
Successful verification consumes the challenge, creates a durable session, and issues tokens. `POST /api/v1/auth/req-otp` remains an alias for login challenge creation. The historical endpoint that submitted email+OTP to `/login` is intentionally superseded by challenge IDs.
## Token transport
`clientType: WEB` sets `access_token` and `refresh_token` as HttpOnly cookies. Production cookies use `Secure` and `SameSite=None` for the intended cross-subdomain frontend/API deployment. The refresh cookie is restricted to `/api`. The response includes the access token for current compatibility but never includes the web refresh token.
`clientType: MOBILE` returns access and refresh tokens in JSON and does not depend on cookies. Mobile clients must store the refresh token in operating-system secure storage. Access tokens remain short-lived regardless of Remember Me.
## Refresh and rotation
`POST /api/v1/auth/refresh`
Web request body may be `{}`; mobile sends `{"clientType":"MOBILE","refreshToken":"opaque-token"}`. Every successful call revokes/replaces the previous session row and returns a new token pair. Replaying a rotated token revokes its entire token family. Only SHA-256 refresh hashes are persisted.
## Current identity
`GET /api/v1/auth/me` requires the access cookie or `Authorization: Bearer <access-token>`. It returns identity fields, account state, verification status, profile, and effective permissions. It omits password, token version, session hashes, and OAuth internals.
## Logout
- `POST /api/v1/auth/logout` revokes the session identified by refresh or access token and is idempotent.
- `POST /api/v1/auth/logout-all` requires authentication, increments `tokenVersion`, revokes every active session, and clears cookies.
## Password recovery and change
- `POST /api/v1/auth/forgot-password` — `{"email":"asha@example.com"}`; response is generic.
- `POST /api/v1/auth/reset-password` — token, `newPassword`, `confirmPassword`.
- `POST /api/v1/auth/change-password` — authenticated; `currentPassword`, `newPassword`, `confirmPassword`.
Reset tokens are random, hash-only Redis records and atomically consumed. Reset/change enforce the same password policy, increment token version, and revoke all sessions. The user must log in again.
## Google and Apple
- `POST /api/v1/auth/google`
- `POST /api/v1/auth/apple`
```json
{"idToken":"provider-signed-id-token","rememberMe":false,"clientType":"WEB"}
```
Google verification validates signature/audience/issuer/expiry through Google's verifier and requires verified email. Apple validates the provider JWKS signature, issuer, audience, expiry, and stable subject. Identities persist `(provider, provider_subject)` uniquely; provider tokens are discarded. Verified email auto-linking is permitted only for customer accounts. Privileged and rider accounts are never automatically linked by email.
## Administrative and rider compatibility
- `POST /api/v1/admin/auth/login` and `/verify-otp` use the shared challenge/session flow but only accept privileged account types.
- `POST /api/v1/rider/auth/login` and `/verify-otp` use the same system and only accept rider accounts.
No separate token implementation exists for these actors.
## Password policy
Minimum 12 characters with uppercase, lowercase, a symbol, and at least four numeric characters. Registration, reset, and change use the same Zod schema.
+64
View File
@@ -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":"<product-id>","slug":"<slug>","name":"<localized-name>","primaryImage":{"url":"<short-lived-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":"<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.
@@ -0,0 +1,31 @@
# ZUMRI Cross-Cutting Services API
All paths also exist below `/api`; clients should use `/api/v1`. Protected routes accept the Phase 1 access cookie or bearer token. Examples use placeholders and never expose storage keys.
## Media
- `POST /api/v1/upload` — multipart field `file`, optional text field `use_for`; creates a private owner-bound upload.
- `GET /api/v1/upload/signed-url/:id` — returns `{ id, url, expiresIn }` after ownership/permission checks.
- `DELETE /api/v1/upload/:id` — marks an owned/authorized upload deleted and removes its object best-effort.
- `GET /api/v1/profile/me/avatar` and `/background` — signed self profile media access. Legacy owner-checked ID routes remain.
## Notifications
- `POST /api/v1/notification` — `notifications.manage`; body includes headline, description, `USER|ANNOUNCEMENT`, and `userIds` for USER messages.
- `GET /api/v1/notification/announcements`
- `GET /api/v1/notification/me`
- `PATCH /api/v1/notification/:notificationId/read`
- `PATCH /api/v1/notification/read-all`
## Documents
- `GET /api/v1/document/types`
- `GET /api/v1/document/saved`
- `POST /api/v1/document/draft`
- `POST /api/v1/document/generate` with `{ "document":"<registered-type>", "documentType":"pdf", "documentData":{} }`; returns HTTP 202 and persisted status.
- `GET /api/v1/document/jobs/:jobId`
- `GET /api/v1/document/:docId`
- `GET /api/v1/document/:docId/download` — returns a short-lived URL; it does not delete the artifact.
- `DELETE /api/v1/document/job/:jobId`
The legacy reference-number GET returns 410 because reads must not consume sequences. References are assigned as part of resource creation.
+79
View File
@@ -0,0 +1,79 @@
# ZUMRI Customer and Business API
All endpoints require a Phase 1 access token and are mounted under both `/api` and `/api/v1`; new clients should use `/api/v1`. Examples use placeholders.
## Customer profile
- `GET /api/v1/profile/me`
- `PATCH /api/v1/profile/me`
Editable fields are first/last name, phone, date of birth, `en|si|ta` locale, theme, marketing email/push preference, in-app preference, and owned avatar/background upload IDs. Identity state, type, roles, permissions, tokens, passwords, business review data, partner ID, and credit settings are rejected.
```json
{"firstName":"<first-name>","preferredLanguage":"en","marketingEmailEnabled":false}
```
Media responses contain an upload ID and short-lived authorized URL, never an object key.
## Addresses
- `GET /api/v1/addresses`
- `POST /api/v1/addresses`
- `GET /api/v1/addresses/:id`
- `PATCH /api/v1/addresses/:id`
- `DELETE /api/v1/addresses/:id`
No user ID is accepted. Every query includes the authenticated owner. Setting either default flag clears the prior default under a transaction and User row lock. Deleting a default leaves that default unset. Future orders must snapshot addresses; they must never depend on mutable Address rows.
```json
{"label":"Home","recipientName":"<name>","phoneNumber":"<phone>","addressLine1":"<line>","city":"<city>","countryCode":"LK","isDefaultShipping":true}
```
## Account deactivation
`POST /api/v1/user/me/deactivate` with `{"confirmation":"DEACTIVATE","password":"<current-password>"}`. Password is required for password-based identities. Social-only identities require explicit confirmation. The operation sets DEACTIVATED, increments token version, revokes all sessions, and clears cookies. Self-reactivation is not supported; an authorized manual administrative process is required.
## Business applications
- `POST /api/v1/business/applications`
- `GET /api/v1/business/applications/me`
- `GET /api/v1/business/applications/:id`
Only verified ACTIVE customer accounts can apply. Ownership is applied to ID reads. Concurrent active applications are serialized by locking the applicant User.
```json
{"businessName":"<business>","legalName":"<legal-name>","registrationNumber":"<registration>","businessType":"<type>","contactEmail":"owner@example.com","contactPhone":"<phone>"}
```
Private supporting documents use the Phase 2 upload API with purpose `BUSINESS_REGISTRATION`, `TAX_DOCUMENT`, `IDENTITY_DOCUMENT`, or `OTHER_SUPPORTING_DOCUMENT`. Only the owner or a reviewer with `business.applications.review` can obtain a signed URL.
## Business self-service
- `GET /api/v1/business/me`
- `PATCH /api/v1/business/me`
- `POST /api/v1/business/me/contacts`
- `POST /api/v1/business/me/addresses`
Partner ID, review status, domain status, credit, settlement term, identity type, and approval metadata cannot be changed through self-service.
## Administrative review
- `GET /api/v1/admin/business/applications` — `business.applications.read`
- `GET /api/v1/admin/business/applications/:id` — same permission
- `POST /api/v1/admin/business/applications/:id/approve` — `business.applications.review`
- `POST /api/v1/admin/business/applications/:id/reject` — same permission; requires a reason
- `GET /api/v1/admin/business/accounts` — `business.accounts.read`
- `PATCH /api/v1/admin/business/accounts/:id/status` — `business.accounts.update`
Lists accept bounded `page`, `limit`, status/name filters and allowlisted sorting. Approval atomically locks the application/applicant, creates one profile and disabled credit account, assigns a `ZUM-BIZ-######` partner ID, changes account type to `business_customer`, revokes sessions, and marks the application approved.
## Credit and settlement primitives
- `PATCH /api/v1/admin/business/accounts/:id/credit` — `business.credit.manage`
- `PATCH /api/v1/admin/business/accounts/:id/settlement-term` — `business.settlement.manage`
```json
{"creditLimit":"100000.00","currency":"LKR","status":"ACTIVE"}
```
Credit uses `DECIMAL(15,2)`. There is intentionally no used or available balance until a future authoritative commerce/settlement ledger exists. Settlement terms are seeded configuration records only; this phase creates no invoices or settlements.
+41
View File
@@ -0,0 +1,41 @@
# Delivery and Rider API
All endpoints use `/api/v1`. There is no public tracking endpoint.
## Admin fulfillment
- `GET /admin/shipments`, `GET /admin/shipments/:id`
- `POST /admin/shipments` creates a delivery from immutable Order address/shipping snapshots and item quantities.
- `POST /admin/shipments/return-pickup` creates one pickup for an approved return.
- `POST /admin/shipments/:id/assign`, `/unassign`, `/reschedule`, `/cancel`
- `GET /admin/dispatch` projects unassigned, active, and attention-needed shipments.
Administration requires explicit shipment, dispatch, rider, or return-logistics permission. Shipment creation supports partial quantities, locks the order, checks existing non-cancelled allocations, and uses `Idempotency-Key` event identity.
## Rider administration
- `GET /admin/riders`, `GET /admin/riders/:id`
- `POST /admin/riders` attaches an operational profile to an existing RIDER User; it does not create another identity/password system.
- `PATCH /admin/riders/:id`
- `GET /admin/riders/:id/assignments`
Profile status is independent of User account status. Availability is AVAILABLE, BUSY, or OFFLINE and capacity is configurable.
## Rider workflow
- `GET /rider/shipments`, `GET /rider/shipments/:id`
- Explicit actions: accept, reject, pickup, in-transit, out-for-delivery, deliver, and fail-delivery.
- `PUT /rider/location` stores optional latest coordinates; location is not included in customer tracking.
Every operation derives the rider from authentication and constrains the current assignment. State changes use row locks and unique event IDs. Delivery requires structured proof; linked photo/signature uploads must be AVAILABLE images uploaded by that rider.
## Customer tracking
- `GET /orders/:orderId/tracking`
- `GET /returns/:id/tracking`
Responses contain shipment status and ordered event projections, not rider phone, location, capacity, assignment history, payment, or provider data.
## Behavior boundaries
Delivered item quantities recalculate Order fulfillment without changing payment status. Failed delivery never refunds or restocks. Return-pickup delivery marks the RMA received for Phase 7 inspection; it does not refund or restock. External couriers, maps, route optimization, delivery OTP, live sockets, and AI dispatch are not implemented.
@@ -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.
+30
View File
@@ -0,0 +1,30 @@
# Loyalty and Rewards API
All customer APIs are authenticated and derive the loyalty owner from the session.
## Customer
- `GET /api/v1/loyalty` — balance, debt, lifetime points, current tier, benefits, and next-tier progress.
- `GET /api/v1/loyalty/history?page=&limit=` — paginated immutable ledger.
- `GET /api/v1/loyalty/tiers`, `GET /api/v1/loyalty/rewards`.
- `POST /api/v1/loyalty/redeem` — requires `Idempotency-Key` and reward ID.
- `GET /api/v1/loyalty/vouchers` — owner-specific coupon entitlements.
- `GET /api/v1/loyalty/referral`, `POST /api/v1/loyalty/referral/claim`.
## Administration
- Loyalty accounts and referral listing.
- Tier, earn-rule, and reward listing/creation.
- `POST /admin/loyalty/adjustments` requires `loyalty.points.adjust`, a non-zero integer delta, reason, and idempotency key.
## Policy
Purchase points are awarded only after authoritative PAID processing. Eligible value is discounted merchandise (`subtotal - discountTotal`), excluding shipping, tax, and duties. Money is divided by configured `amountUnit`, floored to whole units, then multiplied by configured points.
Verified-review rewards require APPROVED and verified purchase. Referral rewards require the referred account's qualifying paid Order. Birthday events use `BIRTHDAY:user:year`; February 29 follows the actual calendar date.
Ledger entries are never edited. Refund reversals append negative entries. If already-spent points prevent a complete debit, available points floor at zero and the remainder becomes explicit `pointsDebt`; later earnings repay debt first.
Redemption locks the account/reward, validates limits, spends earliest-expiring allocations first, and creates at most one result per account/idempotency key. Coupon rewards reuse Phase 5 Coupon and issue an owner-specific entitlement.
Expiry is configured per earn rule and reconciled in bounded daily batches. No direct balance/tier mutation API exists.
+9
View File
@@ -0,0 +1,9 @@
# Newsletter Consent API
`POST /api/v1/newsletter/subscribe` accepts `email`, `locale`, and an allowlisted source (`HOME_FOOTER`, `CHECKOUT`, or `ACCOUNT`). Email is trimmed, lowercased, and uniquely stored. Repeated subscription is idempotent and enumeration-safe.
The current product policy uses immediate single opt-in. Each subscription receives a cryptographically random unsubscribe token, while only its SHA-256 hash is stored. `POST /api/v1/newsletter/unsubscribe/:token` always returns a neutral success response. Resubscription records fresh consent and rotates the token.
Authenticated users may inspect their linked consent at `GET /api/v1/newsletter/me`. Admin listing is `GET /api/v1/admin/newsletter/subscribers` and requires `newsletter.subscribers.read`; token hashes are never returned. Export/manage permissions are reserved, and Phase 10 does not implement campaign sending.
Newsletter consent is legally distinct from Phase 3 profile marketing preferences. An email preference does not create newsletter consent, and an explicit newsletter unsubscribe takes precedence until a new explicit subscribe action occurs.
+42
View File
@@ -0,0 +1,42 @@
# Orders and Payments API
All `/api/v1` customer endpoints derive ownership from authentication.
## Orders
- `POST /orders/from-checkout` converts an owned READY checkout. Conversion locks the checkout, verifies ACTIVE reservations, copies immutable snapshots, converts the cart, and is idempotent by unique checkout ID.
- `GET /orders` and `GET /orders/:id` provide owner-scoped history/detail.
- `POST /orders/:id/cancel` releases unpaid reservations. Paid orders require an explicit refund.
- Admin: `GET /admin/orders`, `GET /admin/orders/:id`, cancel, and mark-processing action endpoints.
Legal transitions are centralized. Payment, order, and fulfillment status are separate.
## Payments and webhooks
- `POST /orders/:id/payment`, `GET /orders/:id/payment`
- `POST /payments/webhooks/payhere` is unauthenticated at the session layer but requires the PayHere merchant hash.
Online browser redirects are never authoritative. A verified webhook must match local payment reference, currency, and DECIMAL amount. Unique provider event IDs make retries idempotent. A first successful event consumes each reservation once, records coupon redemption, marks payment/order paid, and issues invoice metadata. Failure does not consume inventory.
PayHere configuration uses `PAYHERE_MERCHANT_ID`, `PAYHERE_MERCHANT_SECRET`, and notify/return/cancel URLs. Stripe has an explicit disabled adapter until its official SDK and webhook secret are configured. No raw card or provider secret is accepted or returned.
## Invoices
- `GET /orders/:id/invoice` is owner-scoped.
Invoice numbers use the locked ReferenceNumber sequence. Invoice metadata is created once per paid order. PDF generation is reserved for the existing document worker integration; no second PDF/storage subsystem was introduced.
## Refunds
- `POST /admin/orders/:id/refunds` requires `payments.refund` and `Idempotency-Key`.
Requested item quantities and captured totals are locked and validated. A refund never restocks inventory automatically. Provider refund execution remains disabled until provider API credentials/workflows are validated.
## Returns
- Customer: `POST /orders/:orderId/returns`, `GET /returns`, `GET /returns/:id`.
- Admin: list, approve, reject, mark-received, and complete actions.
The return window uses `RETURN_WINDOW_DAYS` (default 30). Quantity cannot exceed the remaining purchased quantity. Only accepted RESTOCKABLE items are added through the inventory service; damaged/non-restockable items are not. EXCHANGE records intent only.
Business credit purchasing is intentionally disabled: no locked credit ledger was added without an approved accounting policy. Delivery, loyalty, support AI, and analytics are outside Phase 7.
+14
View File
@@ -0,0 +1,14 @@
# Deterministic Recommendation API
Phase 10 recommendations are local, explainable rules—not AI or machine learning.
- `POST /api/v1/recommendations/events` accepts authenticated, idempotent `PRODUCT_VIEW` events only. Clients cannot claim purchases or other authoritative commerce events.
- `GET /api/v1/recommendations/recently-viewed` returns a customer's unique recent public products.
- `GET /api/v1/products/:productId/recommendations/related` prefers Phase 4 `ProductRelation`, then bounded same-category/brand fallback.
- `GET /api/v1/recommendations/trending` ranks a 30-day bounded aggregate with purchase/cart/wishlist/click/view weights.
- `GET /api/v1/recommendations/popular` uses eligible paid order item quantities net of refunded quantity, with a featured fallback.
- `GET /api/v1/recommendations/for-you` combines the authenticated user's recent product interests with aggregate candidates and falls back to trending/featured products.
Responses reuse the Phase 4 localized product summary and signed media projection. Only active/public products with an active variant are eligible. Exact stock is not exposed. Limits are capped at 24. Business-specific price quotation remains a known integration item; no customer-specific recommendation response is shared in cache.
Events store no email, IP, access token, cookie, raw session key, or full user agent. Session keys, if enabled later for anonymous ingestion, are hash-only. Retention defaults to 90 days and cleanup is bounded. The clean service boundary can later be replaced by a separately authenticated recommendation service; Phase 10 adds no URL, credential, bypass, LLM, embedding, vector store, or ML model.
+42
View File
@@ -0,0 +1,42 @@
# Shopping and Checkout API
All endpoints use `/api/v1` and require authentication unless stated otherwise. Ownership is derived exclusively from the authenticated identity; request bodies never accept `userId`.
## Cart
- `GET /cart`
- `POST /cart/items` with `variantId`, `quantity`
- `PATCH /cart/items/:itemId` with `quantity`; zero removes the item
- `DELETE /cart/items/:itemId`
- `DELETE /cart`
- `PUT /cart/coupon` and `DELETE /cart/coupon`
Only one ACTIVE cart exists per user. Adding items does not reserve inventory. Responses revalidate catalogue state, availability, MOQ, business pricing, promotions, coupons, and integer-scaled totals. Unavailable items remain visible with an explanatory status.
## Wishlist
- `GET /wishlist`
- `POST /wishlist` with `productId`
- `DELETE /wishlist/:productId`
Wishlist entries are owner-scoped, contain no quantity, and never reserve stock.
## Shipping
- `POST /shipping/quote` with an owned `addressId`; subtotal is read from the authoritative cart.
- Admin CRUD: `/admin/shipping/zones`, `/admin/shipping/methods`, `/admin/shipping/rates`.
Zones match country, then optional province/district. Rates support schedules, subtotal bands, currency, and configurable free-shipping thresholds. Unsupported destinations return an explicit error. Duty mode is descriptive; tax is zero until authoritative configuration exists.
## Checkout
- `POST /checkout` requires `Idempotency-Key` and owned shipping/billing address IDs plus a shipping method ID.
- `GET /checkout/active`
- `GET /checkout/:id`
- `POST /checkout/:id/cancel`
The server recalculates all prices, shipping, discounts, and availability. Client price/total fields are rejected. Checkout snapshots commerce-critical item/address/shipping data and atomically reserves every item using sorted lock order. The cart becomes `CHECKOUT_LOCKED`. Cancellation or bounded expiry reconciliation releases reservations and restores the cart. Same user/key/payload returns the existing checkout; a changed payload conflicts.
Business checkout uses the same cart/session and revalidates active approved status, customer/tier pricing, MOQ, and volume tiers. Business credit is not consumed.
No guest cart, Order, Payment, coupon redemption, tax provider, customs calculator, shipment, or delivery workflow exists in Phase 6.
+29
View File
@@ -0,0 +1,29 @@
# Support and Help API
Phase 10 adds a permission-gated support case system and a localized help center. All routes are under `/api/v1`.
## Customer support
- `POST /support/tickets` creates a self-owned ticket and initial public message in one transaction. Identity, business context, priority and status are server-derived.
- `GET /support/tickets` and `GET /support/tickets/:id` are self-only; internal notes and internal attachments are excluded.
- `POST /support/tickets/:id/messages` appends a public reply. A resolved ticket is explicitly reopened; closed/cancelled tickets reject replies.
- `POST /support/tickets/:id/close` performs a validated state transition.
- `GET /support/tickets/:id/attachments/:attachmentId` authorizes the ticket and visibility before issuing a short-lived signed S3 URL.
Ticket states are `OPEN`, `ASSIGNED`, `WAITING_FOR_CUSTOMER`, `WAITING_FOR_SUPPORT`, `RESOLVED`, `CLOSED`, and `CANCELLED`. Customers cannot select priority; new tickets default to `NORMAL`. Subjects are limited to 200 characters, messages to 10,000 characters, and five attachments per message. Attachments reuse Phase 2 uploads and allow JPEG, PNG, WebP, or PDF only.
Related resources support orders, payments, refunds, returns, shipments, loyalty redemptions, and business settlements. Creation verifies the resource against the authenticated customer or business; a resource identifier alone never grants access.
## Staff support
Under `/admin/support/tickets`, staff can list/detail, assign or atomically claim, reply, add internal notes, change priority, resolve, reopen, close, and download attachments. Permissions are granular: `support.tickets.read`, `.assign`, `.reply`, `.status`, `.priority`, `.internal_notes`, and `.escalate`. Category and SLA controls use `support.categories.manage` and `support.sla.manage`.
State/assignment operations lock the ticket row. Events are append-only. SLA deadlines are snapshotted from the active priority policy at creation using clock time; a bounded five-minute reconciliation creates idempotent first-response or resolution escalations. Business-hour calendars and multi-instance cron validation remain Phase 11 work.
## Help center
Public endpoints are `GET /help/categories`, `/help/categories/:slug`, `/help/articles`, `/help/articles/:slug`, and `/help/search?q=`. Only active categories and published articles are returned. `en`, `si`, and `ta` use the Phase 4 locale resolver with English fallback. Search is bounded to 2–100 query characters and at most 50 results.
Admin endpoints under `/admin/help` list/create categories and articles and explicitly publish/archive articles. They require `help.read`, `help.manage`, or `help.publish`. Article bodies are stored as plain Markdown-like text with HTML tags removed; consumers must render text/Markdown safely and must not treat it as trusted HTML.
All collection APIs use bounded pagination or bounded results and the standard `{ success, data, pagination? }` envelope.
+25
View File
@@ -0,0 +1,25 @@
# Wholesale Completion API
Business endpoints require an authenticated, ACTIVE `business_customer`; organization identity is never accepted from the request.
## Business account
- `GET /api/v1/business/dashboard`
- `GET /api/v1/business/credit`
- `POST /api/v1/business/orders/:id/use-credit` with `Idempotency-Key`
- `GET /api/v1/business/settlements`, `GET /api/v1/business/settlements/:id`
- `GET /api/v1/business/orders/recent`
The dashboard uses paid Order snapshots for monthly/lifetime volume and discounts, existing BusinessTier for classification, the immutable credit ledger for utilization, and settlement records for next payment information.
## Administration
- `POST /admin/wholesale/credit/transactions` (`wholesale.credit.manage`)
- `GET /admin/wholesale/businesses/:businessId/credit` (`wholesale.credit.read`)
- Settlement list/generation/issue/mark-paid endpoints with settlement permissions.
Credit formula: `available = creditLimit - ledger balance`. Captures/authorizations increase utilization; payment, release, and refund entries decrease it. Every operation locks the existing BusinessCreditAccount, validates ACTIVE business/account status and currency, and uses a unique event ID. Credit-backed Order placement atomically captures credit, consumes reservations, records an INTERNAL_CREDIT Payment, marks the Order paid, and reuses invoice issuance.
Settlements snapshot ledger activity for a unique business/period. Due dates come from the existing SettlementTerm. Numbers use ReferenceNumber, and only explicit lifecycle actions are accepted.
Payment allocation storage exists as a foundation. External bank matching, general ledger, ERP, tax-authority integration, and document-worker validation remain outside this phase.
+31
View File
@@ -0,0 +1,31 @@
# Authorization Matrix
This inventory is derived from the mounted route source. Both `/api` (legacy) and `/api/v1` mount the same router; new clients use `/api/v1`. `SUPER_ADMIN` bypasses permission checks through the central middleware.
| Route group | Methods/path | Auth | Permission/account constraint | Ownership/rate limit |
|---|---|---|---|---|
| Auth | `/auth/*` | Mixed | Public login/registration; authenticated session actions | Sensitive limiter on credential flows |
| User/profile/address | `/user/*`, `/profile/*`, `/addresses/*`, `/account/overview` | Yes | Self or explicit admin permission | User ID derived from token/self query |
| Upload/document | `/upload/*`, `/document/*` | Yes | Owner or media/document permissions | Signed URL after owner/permission check |
| Permissions/admin users | `/permissions/*`, `/admin/users/*` | Yes | Permission/admin-gated | No public mutations |
| Business | `/business/*`, `/admin/business/*` | Yes | Self business or business permissions | Business context resolved from user |
| Catalogue/help | `/products`, `/categories`, `/brands`, `/collections`, `/help/*` | Public GET | Published/active projection | General limiter; help search sensitive limiter |
| Catalogue admin | `/admin/products/*`, categories/brands/collections/reviews | Yes | Catalogue permissions | Strict schemas/action endpoints |
| Inventory/pricing/merchandising/shipping admin | `/admin/inventory/*`, `/admin/pricing/*`, `/admin/promotions/*`, `/admin/shipping/*` | Yes | Domain permissions | Strict schemas; bounded pages |
| Shopping | `/cart/*`, `/wishlist/*`, `/checkout/*` | Yes | Authenticated self | Identity derived from token; idempotency/reservations |
| Orders/payments/returns | `/orders/*`, `/returns/*` | Yes | Self ownership | Order/user join checks; strict payment actions |
| Commerce admin | `/admin/orders/*`, payments/refunds/returns | Yes | Orders/payments/returns permissions | Explicit actions; no generic status patch |
| Logistics public | `/tracking/*` | Public token/reference | Limited safe projection | Sensitive limiter where configured |
| Logistics rider/admin | `/rider/*`, `/admin/shipments/*` | Yes | Rider ownership or logistics permissions | Assignment/state checks |
| Loyalty/wholesale | `/loyalty/*`, `/wholesale/*`, corresponding `/admin/*` | Yes | Self or loyalty/wholesale permissions | Ledger identity server-derived |
| Support customer | `/support/tickets/*` | Yes | Self-owned ticket | Creation/replies rate-limited; internal visibility excluded |
| Support admin | `/admin/support/tickets/*` | Yes | Granular support permission per action | Row-lock assignment; staff-only internal notes |
| Newsletter | subscribe/unsubscribe | Public | Token authorizes unsubscribe | Sensitive limiter; enumeration-neutral response |
| Newsletter admin | `/admin/newsletter/subscribers` | Yes | `newsletter.subscribers.read` | Token hashes excluded |
| Recommendations | public reads; authenticated event/recent/for-you | Mixed | Self for behavioral data | Event allowlist/idempotency; sensitive limiter |
| Analytics | `/admin/dashboard/overview`, `/admin/analytics/*` | Yes | Matching `analytics.*.read` | UTC range ≤366 days; bounded SQL aggregates |
| Metrics | `/admin/metrics` | Yes | `system.metrics.read` | No user/resource labels |
| Queue board | `/admin/queues` | Yes | Admin/superadmin account type | Internal operational UI |
| Health | `/health`, `/health/live`, `/health/ready` | Public | None | No sensitive payload |
Detailed route definitions remain authoritative in `app/routes`. Security audit found no newly unprotected admin route. Remaining staging work includes an automated route-to-matrix drift check and full authenticated IDOR E2E execution.
+263 -39
View File
@@ -1,23 +1,48 @@
# Auth API
# Authentication API
#### Request OTP
The Authentication API provides OTP-based login, access-token renewal, password recovery, current-user lookup, and logout.
**Endpoint**
## Base URL
```
POST: http://localhost:3070/api/auth/req-otp
```text
http://localhost:3070/api/auth
```
**Request Body**
Requests and responses use JSON unless otherwise stated.
## Authentication
After a successful login, the API returns an access token in the response and sets two HTTP-only cookies:
- `access_token` — valid for 15 minutes
- `refresh_token` — valid for 7 days
Protected endpoints accept the access token through the `access_token` cookie or this header:
```http
Authorization: Bearer <access-token>
```
When using cookie authentication from a browser, send requests with credentials enabled.
---
## Request OTP
Validates the user's email and password, then sends a one-time password to the registered email address.
**Endpoint:** `POST` [http://localhost:3070/api/auth/req-otp](http://localhost:3070/api/auth/req-otp)
### Request body
```json
{
"email": "sathira@niolla.lk",
"email": "sathira@niolla.lk",
"password": "Niolla@123"
}
```
**Respond**
### Success response — `201 Created`
```json
{
@@ -26,26 +51,30 @@ POST: http://localhost:3070/api/auth/req-otp
}
```
### Error responses
- `401 Unauthorized` — invalid password
- `404 Not Found` — user not found
- `500 Internal Server Error` — OTP generation or email delivery failed
---
#### Login
## Login
**Endpoint**
Verifies the emailed OTP and creates an authenticated session. The user account must be active.
```
POST: http://localhost:3070/api/auth/login
```
**Endpoint:** `POST` [http://localhost:3070/api/auth/login](http://localhost:3070/api/auth/login)
**Request Body**
### Request body
```json
{
"email": "sathira@niolla.lk",
"email": "sathira@niolla.lk",
"otp": "922304"
}
```
**Respond**
### Success response — `200 OK`
```json
{
@@ -54,33 +83,40 @@ POST: http://localhost:3070/api/auth/login
"data": {
"id": "usr_5ff8afec-5ddc-47d9-a63f-b43df0d8c3b4",
"email": "sathira@niolla.lk",
"firstName": "Jhon",
"lastName": "Doe",
"firstName": "Sathira",
"lastName": "Sri Sathsara",
"role": null,
"accountType": "admin",
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9......"
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
}
```
The response also sets the `access_token` and `refresh_token` HTTP-only cookies.
### Error responses
- `400 Bad Request` — email or OTP is missing
- `401 Unauthorized` — OTP is invalid or expired
- `403 Forbidden` — account is not active
- `404 Not Found` — user not found
- `500 Internal Server Error` — login failed unexpectedly
---
#### Get Current User
## Get Current User
**Endpoint**
Returns the authenticated user's token claims and effective permissions.
```
GET: http://localhost:3070/api/auth/me
```
**Endpoint:** `GET` [http://localhost:3070/api/auth/me](http://localhost:3070/api/auth/me)
**Authorization**: Required (Bearer token)
**Authentication:** Required
**Request Body**
### Request body
```
No Body
```
No request body.
**Response (200)**
### Success response — `200 OK`
```json
{
@@ -98,27 +134,215 @@ No Body
}
```
### Error responses
- `401 Unauthorized` — token is missing, invalid, or expired
---
### Logout
## Refresh Session
**Endpoint**
Uses the HTTP-only refresh-token cookie to rotate the session and issue new access and refresh cookies.
```
POST: http://localhost:3070/api/auth/logout
**Endpoint:** `POST` [http://localhost:3070/api/auth/refresh](http://localhost:3070/api/auth/refresh)
### Request body
No request body. The `refresh_token` cookie is required.
### Success response — `200 OK`
```json
{
"success": true,
"message": "Session refreshed successfully"
}
```
**Request Body**
### Error response — `401 Unauthorized`
```
No Body
Returned when the refresh cookie is missing or the session is invalid, expired, or no longer active.
---
## Forgot Password
Sends a password-reset link when an account exists for the supplied email. The same success response is returned for unknown email addresses to prevent account discovery.
**Endpoint:** `POST` [http://localhost:3070/api/auth/forgot-password](http://localhost:3070/api/auth/forgot-password)
**Authentication:** Not required
### Request body
```json
{
"email": "sathira@niolla.lk"
}
```
**Respond**
### Success response — `200 OK`
```json
{
"success": true,
"message": "If an account exists for this email, a password reset link has been sent."
}
```
### Error responses
- `400 Bad Request` — email is missing or invalid
- `500 Internal Server Error` — the reset request could not be processed
---
## Reset Password
Sets a new password using the token from the password-reset email. A successful reset invalidates all existing refresh sessions for the user.
**Endpoint:** `POST` [http://localhost:3070/api/auth/reset-password](http://localhost:3070/api/auth/reset-password)
**Authentication:** Not required
### Request body
```json
{
"token": "password-reset-token",
"newPassword": "NewPassword@1234",
"confirmPassword": "NewPassword@1234"
}
```
The new password must contain at least one uppercase letter, one lowercase letter, one symbol, and four digits. It must differ from the current password.
### Success response — `200 OK`
```json
{
"success": true,
"message": "Password reset successfully. Please login again."
}
```
### Error responses
- `400 Bad Request` — fields are missing, passwords do not match, password rules are not met, the new password matches the current password, or the token is invalid or expired
- `500 Internal Server Error` — password reset failed unexpectedly
---
## Change Password
Changes the authenticated user's password. After a successful change, all refresh sessions are revoked, authentication cookies are cleared, and the user must log in again.
**Endpoint:** `POST` [http://localhost:3070/api/auth/change-password](http://localhost:3070/api/auth/change-password)
**Authentication:** Required
The access token may be supplied through the `access_token` cookie or as a Bearer token:
```http
Authorization: Bearer <access-token>
```
### Request body
```json
{
"currentPassword": "CurrentPassword@1234",
"newPassword": "NewPassword@5678",
"confirmPassword": "NewPassword@5678"
}
```
The new password:
- Must match `confirmPassword`
- Must differ from the current password
- Must contain at least one uppercase letter
- Must contain at least one lowercase letter
- Must contain at least one symbol
- Must contain at least four digits
### Success response — `200 OK`
```json
{
"success": true,
"message": "Password changed successfully. Please login again."
}
```
### Error responses
#### `400 Bad Request`
Returned when required fields are missing, the passwords do not match, the new password does not satisfy the password policy, or it matches the current password.
```json
{
"success": false,
"message": "New password and confirm password do not match"
}
```
#### `401 Unauthorized`
Returned when authentication fails or the current password is incorrect.
```json
{
"success": false,
"message": "Current password is incorrect"
}
```
#### `404 Not Found`
```json
{
"success": false,
"message": "User not found"
}
```
#### `500 Internal Server Error`
```json
{
"success": false,
"message": "Failed to change password"
}
```
---
## Logout
Deletes the current refresh session when available and clears both authentication cookies.
**Endpoint:** `POST` [http://localhost:3070/api/auth/logout](http://localhost:3070/api/auth/logout)
### Request body
No request body.
### Success response — `200 OK`
```json
{
"success": true,
"message": "Logged out successfully"
}
```
```
### Error response — `500 Internal Server Error`
```json
{
"success": false,
"message": "Failed to logout"
}
```
+11
View File
@@ -0,0 +1,11 @@
# Backup and Restore
Use encrypted, access-controlled backups with retention tiers and regular restore drills. Do not place credentials on command lines; use a protected client option file or secret injection.
1. Quiesce high-risk writes or capture a transactionally consistent MySQL backup (`mysqldump --single-transaction --routines --triggers`) and record schema migration state.
2. Enable S3 versioning, lifecycle rules, encryption and cross-account/region recovery appropriate to policy. Database-only recovery does not restore uploads/documents.
3. Treat Redis as ephemeral/reconstructable for queues/cache but security-sensitive for OTP/session challenges. Redis loss may invalidate challenges and lose queued work; MySQL remains business truth.
4. Restore MySQL into a temporary isolated database, run consistency queries, compare row counts/totals, verify migrations, and test application reads before promotion.
5. Recover in order: MySQL, object storage, Redis, API, workers, single cron scheduler, reverse proxy, then smoke/E2E checks.
Rollback after live commerce writes is restore/forward-fix based; do not assume destructive migration `down` functions are safe. Define RPO/RTO, backup owners, retention, restore approval and audit evidence before go-live.
+11
View File
@@ -0,0 +1,11 @@
# Cron Operations
| Job | Schedule | Authority / boundedness | Idempotency |
|---|---|---|---|
| Notification cleanup | configured source schedule | DB records; retention bounded | repeated delete safe |
| Inventory reservation expiry | frequent | batch-limited DB scan | reservation state/event key |
| Checkout expiry | frequent | batch-limited DB scan | checkout/reservation state |
| Loyalty reconciliation | scheduled | configured batch | ledger event IDs |
| Support SLA + recommendation retention | every 5 minutes | 100 SLA / 1,000 event max | deterministic escalation key / old-event deletion |
Cron starts only when the deployment enables `RUN_CRON`; use one dedicated scheduler replica. Current jobs use local `noOverlap` where available but do not all have a distributed lock, so multi-instance cron is prohibited until staging validates/implements Redis locking. All times are server/UTC clock time unless a domain snapshot says otherwise.
+467
View File
@@ -0,0 +1,467 @@
# 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.
Security impact: all customer resource identity is derived from Phase 1 authentication; mutation schemas are strict; profile media is owner-validated; address/application IDOR is constrained in queries; approval/deactivation revoke sessions after identity-boundary changes; and business financial/review fields are excluded from self-service. Six models were added and two existing models extended. Customer/business and admin APIs are documented in `Documentation/API_CUSTOMER_BUSINESS.md`.
The complete mocked regression suite passes 12 suites/57 tests, and syntax validation passes 171 JavaScript files. The Phase 3 migration is forward-only and was not executed. Staging must resolve legacy business/profile/address pre-checks, seed/grant permissions, and validate MySQL concurrency plus Redis/S3/SMTP flows before Phase 4. See `Documentation/PHASE_3_CUSTOMER_BUSINESS_FOUNDATIONS.md`.
## Phase 2 Completion Update
Completion date: 2026-09-03. Phase 2 hardens the existing shared-service foundation without adding commerce domains. Module 14 (notifications) is now approximately 72%; Module 19 (file/media) 82%; Module 20 (audit/config/logging) 68%; and Module 21 (background jobs) 78%. The document subsystem is approximately 82%.
Storage now has a reusable S3/S3-compatible boundary, actual-content validation, controlled keys, checksums, owner/status metadata, compensation, and authorization-safe signed URLs. Email is routed through BullMQ with delivery status and final-failure persistence. Notifications have fixed aliases, unique assignment migration, self-only inbox/read operations, announcement support, and preference policy. Queue defaults, idempotent job IDs, Bull Board registration, safe failure handling, stronger append-only activity records, and structured redacted logs are in place. Documents now have ownership, controlled registry validation, persisted generation lifecycle, safe status, and non-destructive signed download.
The full mocked suite contains 10 suites/40 tests and passes; syntax checks cover 153 JavaScript files. The new migration was not executed. Remaining work is staging migration/data pre-checks plus real MySQL, Redis, S3-compatible, SMTP, PDF/browser, retention-volume, and concurrency validation. After those operational checks and permission seeding, it is safe to begin Phase 3. See `Documentation/PHASE_2_CROSS_CUTTING_SERVICES.md` and `Documentation/API_CROSS_CUTTING_SERVICES.md`.
Audit date: 2026-09-03
Scope: repository source, configuration, lockfile, existing documentation, safe syntax/test/dependency checks. No database, Redis, S3, email, or other external service was mutated.
## 1. Executive Summary
This repository is an early modular-monolith foundation, not yet an e-commerce backend. It contains working-shaped user registration/email verification, OTP login, basic profiles, RBAC tables/controllers, notifications, activity logging, S3 upload plumbing, document generation, Redis/BullMQ workers, and one cron. Important pieces are incomplete or broken in integration. The estimated completion against the 25-module ZUMRI target is **about 12%**.
No original development-plan day is fully complete. Day 1 is approximately 55%: Express, Sequelize/MySQL, Redis/BullMQ, a Dockerfile, and a health route exist, but Swagger, robust health checks, Node 22 alignment, production bootstrap/error handling, Compose/Nginx, migrations, and tests do not. Day 2 is approximately 38%; Day 3 approximately 18%; notification/job/file/document/audit foundations from later days were built early.
The code still carries prior-project terminology (`Oceanic Titan`, `oceanic-db`, Oceanic demo URL) and role names that conflict with the actual user ENUM. Future work should preserve usable primitives, but first complete and secure foundation/authentication.
Status terms: **COMPLETE** = end-to-end implementation is credible from source; **PARTIAL** = meaningful code exists but requirements/integration are incomplete; **STUB** = structure only; **MISSING** = no implementation; **BROKEN** = known source/integration defect.
## 2. Current Architecture
- `server.js` loads `.env`, imports `app.js`, and listens on `0.0.0.0:${PORT|3070}`.
- `app.js` authenticates Sequelize and calls unrestricted `sequelize.sync()` in an unawaited startup IIFE, then starts cron jobs. The HTTP listener starts independently, so requests can arrive before database readiness, and DB failure does not stop the process.
- Middleware order is cookie parser, CORS, JSON parser, Morgan, `/health`, `/api`, static documentation, then Bull Board. There is no URL-encoded parser, request ID, Helmet, compression, rate limiting, global 404 handler, or global error handler.
- API base path is `/api` (not versioned). Routes delegate mostly directly to Sequelize-backed controllers; there is only a permission service and activity service.
- Redis connections are created at module import. One shared client is used by queues/workers, while password reset and S3 utilities create additional clients.
- Workers are a separate `npm run worker` process. The server does not start them. Activity, log, and document consumers exist.
- Cron runs in every API process after DB sync; there is no distributed lock, so multi-instance deployments duplicate scheduling.
- Sequelize models are registered centrally and their `associate` functions are invoked. No migrations are present.
- Environment keys exist locally; `.env` is ignored/untracked. `.env.sample` is tracked. No actual secret values are reproduced here.
### Bootstrap and operational findings
| Concern | Status | Evidence / impact |
|---|---|---|
| Express start and base path | Partial | `server.js`, `app.js`; `/api` and `/health` |
| JSON / cookies / CORS / logging | Partial | JSON, cookies, single-origin credentialed CORS, Morgan `dev`; no body-size configuration or URL-encoded parser |
| Security middleware | Missing | No Helmet or API rate limiter |
| 404/global errors | Missing | Errors depend on individual controllers; unmatched routes use Express defaults |
| Database readiness | Broken | Listener is not gated on authenticate/sync; failure is logged only |
| Redis readiness | Partial | Event logging exists; health endpoint never checks Redis |
| BullMQ/workers | Partial | Separate process required and undocumented operationally; retries only on document jobs |
| Cron initialization | Partial | Starts after DB sync, once per server process, with no leader lock |
| Health check | Broken | Sends response before asynchronous DB authentication completes and hardcodes mail/Redis as OK |
| Graceful shutdown | Missing | No SIGTERM/SIGINT cleanup for server, Sequelize, Redis, workers, or cron |
| Process errors | Missing | No `unhandledRejection`/`uncaughtException` policy |
## 3. Existing Infrastructure
| Component | Status | Files | Notes |
|---|---|---|---|
| Express API | Partial | `server.js`, `app.js`, `app/routes/*` | Express 5; no versioning/global errors/security middleware |
| MySQL/Sequelize | Partial | `app/config/db.config.js`, `app/models/index.js` | Pool configured; runtime `sync()`, no migrations |
| Redis | Partial | `redis.config.js`, `redisClient.js` | Functional client pattern; excessive clients, no shutdown/readiness |
| BullMQ | Partial | `app/queues/*`, `app/workers/*` | Three queues and matching consumers; only document jobs have attempts/backoff/retention |
| Bull Board | Broken/unsafe | `bullBoard.config.js`, `app.js` | Only activity queue shown; `/admin/queues` has no authentication |
| Cron | Partial | `cron/*` | Transactional notification deletion; no distributed lock or retention-age policy |
| Email | Partial | `mail.config.js`, `mail.util.js`, templates | SMTP/template sender exists; verifies on import, sends inline, no queue/retry/escaping |
| S3 storage | Broken | `s3.config.js`, `s3Upload.utill.js` | S3 client is entirely commented out, so `s3.send` fails |
| Uploads | Broken | upload middleware/controller/model | MIME/size checks exist; account guard is incompatible; no magic-byte validation/cleanup/ownership |
| Documents | Partial/broken | document controller, logic, templates, queue/worker | PDF/Excel framework is substantive; S3 breaks completion; access is overly broad and role guards mismatch |
| Notifications | Partial | notification models/controller/cron | In-app records only; no assignment API/service, email/FCM/preferences/delivery tracking |
| Activity/audit | Partial | activity service/model/queue/worker/controller | Async append-only-shaped records; no actor/IP/change metadata, integrity/retention, or broad coverage |
| Logging | Partial | console utility/log queue/worker | Local daily files; may receive sensitive payloads; no rotation/structured sink |
| API docs | Partial | `Documentation/*`, docs routes | Handwritten Markdown/HTML, not Swagger/OpenAPI; docs auth is forgeable |
| Tests | Missing | package script only | Jest finds zero tests; Supertest is not installed |
| Docker | Partial | `Dockerfile` | Uses Node 20 instead of target Node 22; API image only; no healthcheck/non-root user |
| Nginx/Compose/CI/CD | Missing | none | No reverse proxy, service orchestration, or pipeline files |
| Payments/FCM/OpenAI | Missing | none | No dependencies, config, models, or consumers |
Queue behavior: `document-generation` has 3 exponential attempts (2s base), completed retention, and retained failures. `activity-queue` and `logQueue` have consumers but no explicit retry/backoff/retention/dead-letter policy and are not idempotent. Re-delivery can duplicate activity rows or log lines. Queue event listeners attached to `Queue` are not a reliable substitute for `QueueEvents` for all lifecycle events. Worker failures are logged only. Document status is not reconciled on job failure/success.
## 4. Existing Database Models
All models use timestamps and none uses `paranoid`. Aside from the unique flags noted below, explicit indexes are absent.
| Model / table | Key and important fields | Purpose | Important relations | Status |
|---|---|---|---|---|
| User / `users` | PK string `id`; names, unique email, password, accountType ENUM, accountStatus ENUM, emailVerifiedAt | Identity/account | hasOne Profile; hasMany UserPermission | Partial; no role FK/`roleID` despite service/controller use, no sessions/tokenVersion/social IDs |
| Profile / `profiles` | PK `profile_id`; unique `user_id`; theme, notificationsEnabled, image IDs, DOB, phone | Basic preferences/profile | belongsTo User | Partial; no gender/language/address; image IDs lack FKs |
| UserActivity / `UserActivity` | integer PK; user/name/description/type/module/date/time | Activity trail | None | Partial; user FK absent, redundant time fields, no indexes |
| Upload / `uploads` | integer PK; S3 path/type/size/name/use/uploaded_by | Media metadata | None | Partial; uploader/user and ownership FKs absent |
| Permission / `permission` | PK `permission_id`; name/page/module/action | Permission definition | hasMany role/user grants | Partial |
| Role / `roles` | PK `role_id`; name/description | Named role | hasMany role grants | Partial; User has no role association |
| RolePermission / `rolePermission` | PK `rp_id`; role_id, permission_id | Role grant | belongsTo Role/Permission | Partial; no composite unique constraint |
| UserPermission / `userPermission` | integer PK; user_id, permission_id | Direct additive grant | belongsTo User/Permission | Partial; no composite unique; cannot deny/expire grants |
| Document / `Document` | PK `doc_id`; reference_no, doc_type, JSON data, status | Saved business documents | None | Partial; status/type unconstrained, no creator/owner/FKs/indexes |
| DocumentType / `DocumentType` | integer PK; nullable name, JSON description | Document registry metadata | None | Partial; name is neither required nor unique |
| ReferenceNumber / `referenceNumbers` | PK string; unique sequence_key, integer counter, last value | Atomic sequence generation | None | Reusable; transaction/row locking implemented by utility |
| Notification / `notification` | PK string; headline/body, USER/ANNOUNCEMENT ENUM, active/date | In-app notification content | hasMany UserNotification as `users` | Partial |
| UserNotification / `user_notification` | integer PK; user_id, notification_id, isRead | Per-user notification state | belongsTo User; belongsTo Notification | Partial; constraints disabled, no composite unique/index; include alias is inconsistent (controller asks `notification`, association defines no alias) |
Association registration does execute. However, User's `roleID` is not a declared attribute or FK, notifications deliberately disable FK constraints, activities/uploads/documents have no user association, and join-table uniqueness is not enforced. `sequelize.sync()` masks the absence of schema migrations and makes production schema evolution unsafe.
## 5. Existing API Endpoints
All paths below are derived from actual mounting. “Self-or-admin” is not implemented anywhere; parameterized user endpoints generally trust the requested ID after coarse account-type checks.
| Method | Endpoint | Auth | Permission / guard | Status | Notes |
|---|---|---|---|---|---|
| GET | `/health` | No | None | Broken | Returns before DB check; Redis/mail are hardcoded OK |
| GET | `/api/auth/me` | Yes | None | Partial | Returns token payload plus effective permissions |
| POST | `/api/auth/req-otp` | No | None | Partial/unsafe | Password + in-memory OTP; logs OTP; enumeration; no attempts/rate limit/status check |
| POST | `/api/auth/login` | No | None | Partial/unsafe | OTP to one-day access cookie; no refresh/session/rotation/status check |
| POST | `/api/auth/logout` | No | None | Partial | Clears cookie only; bearer tokens remain valid |
| POST | `/api/user` | No | None | Partial/bug | Registration + profile + verification; activity uses undefined `req.user` after commit |
| POST | `/api/user/verify-email` | No | None | Partial | Redis one-use hashed token; no resend endpoint |
| GET | `/api/user` | Yes | accountType `admin` | Partial | Paginated list |
| GET | `/api/user/:id` | Yes | listed legacy types | Broken/IDOR | Most listed types cannot exist; no ownership enforcement |
| PATCH | `/api/user/:id` | Yes | listed legacy types | Broken/privilege escalation | Mass updates accountType/undeclared role fields; no ownership/field validation; early returns leak transaction |
| DELETE | `/api/user/:id` | Yes | `admin` | Partial | Hard delete; related profile handling depends on DB state; early return leaks transaction |
| GET | `/api/activity` | Yes | `admin` | Partial | Full audit list, no pagination |
| GET | `/api/activity/user/:userId` | Yes | `admin` | Partial | No paging/index |
| POST | `/api/upload` | Yes | `admin` or nonexistent `staff` | Broken | S3 client undefined; DB requires `use_for`; upload not transactional |
| GET | `/api/upload/signed-url/:id` | Yes | `admin` or nonexistent `staff` | Broken | S3 undefined; catch sends no response; no ownership check |
| GET | `/api/document/types` | Yes | admin or nonexistent legacy types | Partial | Effectively admin-only under current ENUM |
| POST | `/api/document/saved` | Yes | same | Partial | Body filter on a read operation; no owner/pagination; Sequelize `exclude` placed incorrectly |
| POST | `/api/document/generate` | Yes | same | Broken | Queues correctly, but worker S3 upload fails; validation shallow |
| POST | `/api/document/draft` | Yes | same | Partial | Saves arbitrary JSON; no ownership/validation |
| GET | `/api/document/reference-number/:documentType` | Yes | same | Partial | Consumes sequence on GET |
| GET | `/api/document/job/:jobId/status` | Yes | same | Partial/IDOR | Exposes stacktrace and any job by ID |
| GET | `/api/document/download/:uuid` | Yes | same | Broken/unsafe | S3 undefined; deletes shared object after response; no ownership |
| DELETE | `/api/document/job/:jobId` | Yes | same | Partial/IDOR | Any allowed user can remove any removable job |
| GET | `/api/document/:docId` | Yes | same | Partial/IDOR | Arbitrary document access |
| POST | `/api/docs/login` | No | Static credentials | Unsafe | Sets unsigned boolean cookie; no expiry/rate limiting |
| POST | `/api/docs/logout` | No | None | Partial | Clears cookie |
| GET | `/api/docs/markdown-files` | docs cookie | `docsAuth === "true"` | Unsafe | Cookie can be forged by client |
| GET | `/api/docs/view/:file` | docs cookie | same | Partial | Extension/path checks; auth is weak |
| POST | `/api/profile/req-reset-password` | No | None | Partial/unsafe logging | Enumeration-resistant response; raw reset token is logged |
| POST | `/api/profile/reset-password` | No | None | Partial | Hashed one-use Redis token; new password is not policy-validated |
| POST | `/api/profile/change-password` | Yes | legacy account types | Broken/partial | Effectively admin-only; no new-password validation/session revocation |
| GET | `/api/profile/avatar/:userId` | Yes | legacy account types | Broken | S3 undefined, missing upload null check, wrong `profile.userId` property, IDOR |
| GET | `/api/profile/background/:userId` | Yes | legacy account types | Broken | Same issues |
| POST | `/api/notification` | Yes | admin or nonexistent `management` | Partial | Creates content only; no user assignment |
| GET | `/api/notification/announcements` | Yes | legacy account types | Partial | Effectively admin-only |
| GET | `/api/notification/user/:userId` | Yes | legacy account types | Broken/IDOR | Include alias mismatch likely throws; no ownership |
| PATCH | `/api/notification/user/:userId/notification/:notificationId/read` | Yes | legacy account types | Broken/IDOR | No ownership; effectively admin-only |
| ALL | `/admin/queues/*` | No | None | Unsafe | Bull Board exposed; only activity queue registered |
| GET | `/Documentation/*` | No | Blocks `.md` only | Partial | Static HTML documentation is public |
### Defined but unreachable permission routes
`app/routes/permission.routes.js` defines 18 endpoints under the intended permission router (permission CRUD/bulk; role CRUD and grants; user grants CRUD/bulk), but `app/routes/index.js` imports `permissionRoutes` and never calls `router.use(...)`. Therefore none has an actual URL and all are **BROKEN/unreachable**. If mounted as `/permission`, their paths would be `/`, `/bulk`, `/:permissionId`, `/roles`, `/roles/:roleId`, `/roles/:roleId/permissions`, `/roles/permissions/bulk`, `/users/:userId/permissions`, `/users/permissions`, `/users/permissions/bulk`, and `/users/permissions/:upId` with the methods declared in that file. Read endpoints require authentication; mutations require accountType exactly `admin`. Controllers are substantial CRUD logic, but integration, uniqueness, cache invalidation, validation, and transactions are inconsistent.
## 6. Development Plan Status
| Module | Completion | Status | Evidence | Remaining Work |
|---|---:|---|---|---|
| 01 Authentication & Authorization | 38% | Partial | Password hashing, register/verify, OTP access JWT, middleware, RBAC structures | Refresh/session lifecycle, OAuth/Apple, durable OTP/limits, status enforcement, role linkage, mount/fix RBAC |
| 02 Customer Profile & Address Management | 22% | Foundation | User/Profile with phone, DOB, images/preferences | Self-service ownership, addresses/default, gender/language, deactivation, robust image flow |
| 03 Product Catalogue Management | 0% | Missing | No models/routes | Full catalogue/category/variant/review/SEO model and APIs |
| 04 Multi-Language Content | 0% | Missing | No content translation system | Locale strategy and translated content |
| 05 Inventory Management | 0% | Missing | No inventory entities | Stock, reservations, warehouses, adjustments |
| 06 Cart & Wishlist | 0% | Missing | None | Entire module |
| 07 Promotions, Coupons & Banners | 0% | Missing | None | Entire module |
| 08 Checkout | 0% | Missing | None | Pricing/shipping/tax/transaction orchestration |
| 09 Order Management | 0% | Missing | Document names are not commerce orders | Full order state machine and returns |
| 10 Payment Management | 0% | Missing | No provider code/dependencies | PayHere/Stripe, webhooks, idempotency, refunds |
| 11 Delivery & Rider Management | 2% | Foundation only | `rider` account ENUM; document templates named delivery/dispatch | Rider profiles, assignments, tracking, zones/rates |
| 12 Loyalty, Reward Points & Membership | 0% | Missing | None | Ledger, tiers, generic earn rules, rewards/vouchers |
| 13 Wholesale / Business Accounts | 2% | Foundation only | `business_customer` ENUM only | Approval/profile/pricing/MOQ/tiers/credit/settlements/invoices/analytics |
| 14 Notification System | 28% | Partial | Notification/user join models, CRUD/read endpoints, cleanup cron, mail utility | Fix aliases/ownership; assignment and preferences; FCM/email jobs/templates/status |
| 15 Support Ticket System | 0% | Missing | None | Tickets, messages, SLA/escalation/help center |
| 16 AI Customer Support Chatbot | 0% | Missing | No OpenAI integration | Conversations, tools, safety, human escalation |
| 17 Product Recommendations | 0% | Missing | None | Events and recommendation service |
| 18 Admin Dashboard & Analytics | 1% | Foundation | Admin user type and raw activity endpoint | Metrics, aggregation, secured dashboard APIs |
| 19 File & Media Management | 25% | Broken foundation | Multer, Upload model, S3/presigned utilities | Restore/configure client, ownership, object validation/lifecycle, media transformations |
| 20 Audit Logging & System Configuration | 22% | Partial | Async activity records/log queue | Full audit schema/coverage, immutability, config models, secure structured logging |
| 21 Background Jobs & Queues | 35% | Partial | Redis, 3 queues/consumers, worker entry, document retry | Idempotency, policies for every queue, monitoring auth, graceful shutdown, scheduler topology |
| 22 Security | 15% | Weak foundation | bcrypt, JWT verification, HttpOnly cookie, basic MIME limits | Findings in §10; headers, throttles, validation, authorization, secrets/token discipline |
| 23 Testing & Quality Assurance | 2% | Missing | Jest dependency/script only | Tests, Supertest, fixtures, lint/type/static checks, CI |
| 24 API Documentation | 18% | Partial | Handwritten endpoint Markdown/HTML | OpenAPI/Swagger, synchronization, schemas/security/error contracts |
| 25 Deployment & Infrastructure | 18% | Partial | Dockerfile | Node 22, Compose, Nginx, healthcheck, non-root, migrations, CI/CD, observability |
## 7. Original 15-Day Plan Status
| Day | Intended Scope | Completion | Notes |
|---|---|---:|---|
| 1 | Foundation | 55% | Express/Sequelize/MySQL/Redis/BullMQ/Docker present; health broken; Swagger/Compose/migrations/production lifecycle missing |
| 2 | Authentication | 38% | Basic verified registration and OTP access JWT; core session/OAuth/security requirements incomplete |
| 3 | Customer + business accounts | 18% | Basic user/profile and enum only; no addresses/business domain |
| 4 | Products/categories/variants | 0% | Missing |
| 5 | Inventory/admin products | 0% | Missing |
| 6 | Cart/wishlist | 0% | Missing |
| 7 | Promotions/checkout | 0% | Missing |
| 8 | Orders | 0% | Missing |
| 9 | Payments | 0% | Missing |
| 10 | Delivery/rider | 2% | Rider enum and unrelated document templates only |
| 11 | Loyalty | 0% | Missing |
| 12 | Notifications/support | 16% | Partial in-app notification foundation; support absent |
| 13 | AI/recommendations | 0% | Missing |
| 14 | Analytics/security | 8% | Raw activities and scattered security controls only |
| 15 | QA/production | 5% | Dockerfile only; no tests/CI/Nginx/production hardening |
**Last genuinely complete day: none.** Development reached partway through Day 1 and Day 2, with selected infrastructure from Days 12, 14, and 15 implemented early.
## 8. Demo Website Requirement Gaps
| Feature | Present on Demo | Present Backend | Original Plan | Action Needed |
|---|---|---|---|---|
| Email/password login | Yes | Partial (password then email OTP) | Auth | Clarify desired login flow; secure OTP/session lifecycle |
| Remember me | Yes | No | Auth | Add session-specific lifetime safely |
| Forgot/reset password | Yes | Partial | Auth | Stop token logging, validate password, revoke sessions |
| Google sign-in | Yes | No | Auth | Add provider verification/linking |
| Apple sign-in | Yes | No | Potential added requirement | Add to auth scope explicitly |
| Account overview/recent orders/counts | Yes | No | Customer/orders | Aggregate dashboard endpoint after domain models |
| Points/vouchers/membership/progress | Yes | No | Loyalty | Ledger, tier/rule/reward architecture |
| Default address/profile/addresses | Yes | Profile partial; addresses absent | Customer | Ownership-safe profile and address CRUD/default constraint |
| Wishlist/order history/sign out | Yes | Sign-out cookie only; rest absent | Cart/orders/auth | Implement modules and true session revocation |
| Silver/Gold/Platinum tiers/benefits | Yes | No | Loyalty | Configurable tiers; do not hardcode demo examples |
| Point transaction history | Yes | No | Loyalty | Immutable ledger |
| Purchases/reviews/referrals/birthdays earning | Yes | No | Loyalty/reviews | Generic earn-source rules with idempotency |
| Reward redemption/vouchers/shipping rewards | Yes | No | Loyalty/promotions | Reward definitions, redemption transaction, vouchers |
| Business approval/Partner ID/tier | Yes | Account enum only | Wholesale | Business profile and approval workflow |
| Monthly/history/discount/top buyers/orders | Yes | No | Wholesale/analytics | Wholesale aggregates and reporting |
| Credit limit/available/utilization | Yes | No | Wholesale | Credit account/ledger and authorization rules |
| Settlement terms/dates/invoices | Yes | No | Wholesale/payment | Terms, statements, invoice/payment lifecycle |
| Wholesale catalogue/MOQ/pricing tiers/bulk | Yes | No | Wholesale/catalogue | Customer-segment pricing and volume tiers |
| Featured inventory/reorder | Yes | No | Inventory/wholesale | Stock and reorder workflows |
| Shipping thresholds/methods/zones/fees | Yes | No | Checkout/delivery | Configurable rate engine |
| International shipping/duties display | Yes | No | Delivery/checkout | Destination rules and duty estimate representation |
| Order tracking | Yes | No | Orders/delivery | Shipment event timeline |
| 30-day return/eligibility/reason/status | Yes | No | Orders (gap detail) | Configurable returns/RMA state machine |
| Pickup/refund/exchange | Yes | No | Delivery/payment/orders | Integrate RMA, courier, payment refund, exchange order |
| Help center/FAQs/sizing/payment help | Yes | No | Support/content | CMS/help content APIs |
| Email support/tickets | Yes | No | Support | Ticket/conversation/SLA module |
| AI Style Assistant/human escalation | Yes | No | AI/support | AI conversation with ticket/advisor handoff |
| Newsletter consent lifecycle | Yes | No | Demo gap | Dedicated subscriber consent/status/source/language model; not profile notification preference |
| New/featured/sale products | Yes | No | Catalogue/promotions | Merchandising fields/rules |
| Category/editorial collections | Yes | No | Catalogue/content | Collections and ordered merchandising |
| Related products | Yes | No | Recommendations/catalogue | Explicit and computed relations |
| Reviews/verified purchase reviews | Yes | No | Catalogue/recommendations | Review moderation and verified-order link |
| Size guides | Yes | No | Catalogue/content | Structured, category/product-linked guides |
| Product SEO | Yes | No | Catalogue | Slugs/meta/canonical data |
| Banners | Yes | No | Promotions | Placement, locale, schedule, targeting |
| Multiple languages | Yes | No | Multi-language | Localized product/content model |
Current order/delivery architecture cannot support shipping or returns: no commerce Order, OrderItem, Shipment, Address, Return, Refund, or state-transition entities exist. The similarly named generated documents are generic JSON documents and should not be treated as domain substitutes.
## 9. Technical Debt
### CRITICAL
- Restore a valid S3 client before any upload/document endpoint can work.
- Align authorization vocabulary and enforce ownership; current legacy guards, IDORs, and accountType mutation enable denial of access or privilege escalation.
- Remove raw OTP/reset-token and decoded-auth logging; rotate any credentials if operational logs captured them.
- Replace production `sequelize.sync()` with migrations and gate server readiness on required services.
- Implement a real access/refresh session model with rotation, revocation, account-status enforcement, and secure logout.
### HIGH
- Mount and repair permission routes; add User-role linkage and grant uniqueness/cache invalidation.
- Add validation schemas and centralized error handling; prevent arbitrary field updates.
- Protect Bull Board and documentation sessions.
- Fix notification association alias and user ownership checks.
- Add rate limits for login, OTP, verification, reset, docs login, and general API traffic.
- Add tests for auth/authorization, transactions, uploads/jobs, and failure paths.
- Fix transaction leaks on early returns in user update/delete.
### MEDIUM
- Standardize response/error formats and status codes; stop returning internal error messages/stack traces.
- Consolidate Redis connections and add lifecycle/readiness handling.
- Make jobs idempotent and add retry/backoff/retention/dead-letter/alert policies.
- Move email to a job and add retry/delivery tracking and safe template escaping.
- Add pagination/indexes to activity, notification, document, and user queries.
- Remove legacy Oceanic naming and reconcile API versioning.
### LOW
- Correct mojibake in source/log messages, inconsistent singular/plural table names, and `*.utill.js` spelling.
- Remove unused imports/constants and dead/commented code.
- Split giant permission controller and move business logic into services after behavior is tested.
## 10. Security Findings
- No tracked `.env` was detected. `.env.sample` is tracked. Potential secret-bearing configuration exists in local `.env`; values were not inspected/reproduced. If this file has ever been shared or committed elsewhere, rotate credentials.
- JWT falls back to a public placeholder secret if configuration is missing. There is no issuer/audience/session ID/tokenVersion or refresh-token revocation.
- Login OTP uses `Math.random`, process memory, and no attempt/rate limit; it fails across instances/restarts and is logged in plaintext.
- Password reset token is logged in plaintext. Reset/change paths do not validate the new password policy or revoke existing access tokens.
- Login does not reject pending, suspended, or deactivated accounts.
- User update allows coarse-authorized callers to target arbitrary IDs and set `accountType`, a privilege-escalation and IDOR risk. Many profile/notification/document endpoints have the same ownership defect.
- Bull Board is public. Docs protection is an unsigned client cookie equal to `true`; credentials are brute-forceable without rate limiting.
- CORS is a single credentialed origin and cookie flags are reasonable for cross-site production, but CSRF protection/origin validation is absent for cookie-authenticated mutations.
- Multer limits size and declared MIME, but image wildcard acceptance lacks content sniffing, image decompression safeguards, antivirus scanning, extension normalization policy, and object lifecycle cleanup.
- S3 `PutObject` sets no ACL (good default if bucket blocks public access), but actual bucket policy/encryption cannot be verified. Signed URLs are cached; authorization is checked only before URL issuance and ownership is not checked.
- Controllers expose `error.message`; job status exposes stack traces. Production Sequelize logging is inverted to `true`, which can leak query data.
- Request validation is handwritten and sparse; mass assignment exists in user update. Sequelize query values are generally parameterized, so no direct raw-SQL injection was found.
- Foreign-key constraints are disabled for notification joins; missing uniqueness and transactions create races/duplicates in grants and notifications.
- No Helmet, API rate limiting, CSRF strategy, audit integrity, session revocation, or centralized security error policy exists.
## 11. Broken / Suspicious Implementations
- `app/config/s3.config.js`: the entire client/export is commented; every S3 caller receives `{}`.
- `app/routes/index.js`: imports `permissionRoutes` but never mounts it.
- `app/utils/documentJob.util.js`: requires nonexistent `../queues/pdf.queue`; currently appears orphaned.
- `app/services/permission.service.js#getEffectivePermissions`: queries `user.roleID`, which is not a User model attribute; role grants cannot reliably apply.
- `app/routes/{user,profile,document,notification}.routes.js`: guards use `management`, `team_head`, `user`, or `staff`, none of which is in the User ENUM. Valid `manager`, `customer`, `business_customer`, `rider`, `support_agent`, and `superadmin` are largely excluded.
- `user.controller#createNewUser`: calls `logActivity({user: req.user})` on a public route, causing a post-commit exception after the account has been created; client may receive 500 and retry.
- `user.controller#updateUser`: accepts accountType and undeclared role/department fields; does not update email despite destructuring it; opens transaction before lookups and does not roll back early 404 responses.
- `user.controller#deleteUser`: early 404 does not roll back; hard delete may conflict with Profile because cascade is unspecified.
- `profile.controller#getProfileAvatar/getProfileBackgroundImage`: assumes upload exists, uses wrong `profile.userId` response property, lacks ownership, and reaches broken S3.
- `upload.controller#getFileUrl`: empty catch block can leave requests hanging.
- `notification.controller#getUserNotifications`: requests association alias `notification`, but `belongsTo` defines no alias; likely Sequelize eager-loading error.
- `notification.controller`: USER notifications are created but never assigned to users through an endpoint/service.
- `app.js#/health`: asynchronous DB result races with response; reports `N/A`/hardcoded OK rather than real readiness.
- `app.js` bootstrap: server listens before DB boot finishes; DB errors are swallowed; each instance starts cron.
- `app/middleware/permission.middleware.js`: unused `hasPermission`; admin bypass only recognizes `admin`, not `superadmin`; wildcard logic differs between helper and actual check.
- `docsSession.middleware.js`: trusts an unsigned, client-set boolean cookie.
- `document.controller#getSavedDocuments`: `exclude` is not nested under `attributes`, so JSON data may still be fetched; filter is in POST body despite message saying query parameter.
- `document.controller#downloadDocument`: destructive read deletes the object after delivery and lacks job/user ownership.
- `app/logic/documents/registry.js`: catches module load failure but still registers undefined functions, deferring failure to runtime.
- `consoleLog.utill.js` and auth/reset utilities: log pipelines may persist secrets and full error objects.
- Production DB logging is enabled while non-production logging is disabled, likely inverted.
Orphaned or unused-looking code includes `documentJob.util.js`, `notification.utill.js` (empty), `logic/documents/engine/pdf.engine.js` (generation uses `pdfGenerator.js`), several Excel/id/vendor/calendar/basis utilities not referenced by mounted features, `Assets` in upload controller (unregistered), imported `PERMISSIONS`/`checkPermission` in routes where checks are absent/commented, and unused dependencies likely including `pdfmake` and `nodeman`. `nodemon` is incorrectly a production dependency. Static analysis cannot prove every dynamic/template use; confirm before removal.
## 12. Reusable Existing Components
- Sequelize model registry/association convention can be extended after migrations replace runtime sync.
- User/Profile registration transaction, bcrypt utility, hashed one-use email verification token, and hashed password-reset-token concepts are sound foundations once error/logging/session issues are fixed.
- Cookie-or-Bearer authentication middleware structure is reusable after it loads current user/session/status and applies token claims.
- Permission, role, role-grant, and user-grant models/controllers/service are worth repairing rather than rebuilding; add role linkage, constraints, mounting, validation, and cache invalidation.
- Shared Redis factory/client and permission/user cache helpers can be consolidated and retained.
- Activity queue/service/worker is a useful async audit foundation; extend its schema and coverage.
- BullMQ worker entry pattern and document queue retry/backoff settings are reusable; standardize them across queues.
- Upload metadata model, Multer memory-storage limits, S3 key generation, and presigned URL caching are reusable after the S3 client and authorization/content validation are fixed.
- Document registry, PDF/Excel generators, templates, queue/worker, Document/DocumentType models, and atomic reference-number generator are substantive reusable subsystems. They are auxiliary business-document infrastructure, not order/payment replacements.
- Notification/UserNotification models, read-state concept, announcement query, and transactional cleanup cron can be repaired and extended for channel delivery.
- Email transport/template system and existing verification/reset/welcome templates can be moved behind an email queue.
## 13. Recommended Next Development Phase
Continue with a **Foundation, security, and authentication completion phase** before starting catalogue work.
First make startup deterministic and production-safe: Node 22 alignment, validated environment configuration, migrations, real readiness/liveness checks, global 404/errors, Helmet/rate limits, protected operations dashboards, graceful shutdown, and a test harness. Then complete identity: Redis-backed cryptographic OTP with limits, account-status checks, access/refresh sessions with rotation/revocation, secure logout/reset, validation, ownership rules, and a repaired/mounted role-permission system. Repair S3 only as part of restoring already-promised profile/media/document behavior.
After that baseline passes integration tests, finish customer/business profile and address primitives, reusing User/Profile, auth middleware, Redis, permission service, upload system, activity logger, email templates, queues, and reference-number utility. Only then begin catalogue/product models.
## 14. Recommended Updated Roadmap
1. **Phase 0 — Stabilize foundation:** Node 22, env validation, migrations, startup/readiness, global errors/security middleware, graceful shutdown, protected Bull Board, baseline Jest/Supertest and CI.
2. **Phase 1 — Complete identity and authorization:** OTP/session/refresh rotation, status checks, logout/revocation, password recovery hardening, OAuth Google/Apple, ownership rules, repaired roles/permissions.
3. **Phase 2 — Repair existing cross-cutting services:** S3/media, email jobs, notification aliases/assignment, queue reliability/idempotency, audit schema, document ownership and job lifecycle.
4. **Phase 3 — Customer and business foundations:** Profile completion, addresses/defaults, preferences/deactivation, business approval/partner identity/credit and settlement primitives.
5. **Phase 4 — Catalogue/content:** Categories, products, variants, media, localized content, SEO, size guides, collections, reviews.
6. **Phase 5 — Inventory and merchandising:** Warehouses/stock/reservations, wholesale pricing/MOQ/volume tiers, banners/promotions/coupons.
7. **Phase 6 — Shopping and checkout:** Cart, wishlist, pricing, shipping zones/rates/duties, checkout transactions.
8. **Phase 7 — Orders/payments:** Order state machine, PayHere/Stripe webhook idempotency, invoices, refunds, returns/exchanges.
9. **Phase 8 — Delivery/rider:** Assignments, tracking events, pickup/return logistics.
10. **Phase 9 — Loyalty and wholesale completion:** Generic earn ledger, tiers, rewards/vouchers/referrals; credit utilization/settlements/analytics.
11. **Phase 10 — Support/AI/recommendations:** Help content, tickets/SLA, advisor escalation, AI assistant, recommendation events/services.
12. **Phase 11 — Analytics and production QA:** Dashboards, security testing, load/integration/e2e tests, observability, Nginx/Compose/deployment/runbooks.
## 15. Do Not Rebuild List
Preserve and extend these concepts/files after adding tests: centralized Sequelize registration; User/Profile base tables; bcrypt helper; Redis connection/cache helpers; email verification/password-reset token hashing; auth middleware extraction of cookie/Bearer tokens; RBAC model/controller foundation; activity queue/service/worker; document registry/generators/templates/queue/worker; reference-number sequencing; Upload metadata/Multer limits/S3 utility interface; Notification/UserNotification read-state model; cleanup cron transaction; mail templates/transport interface; Docker Chromium setup for Puppeteer.
## Verification and Dependency Audit
- `node --check` passed for every repository JavaScript file.
- `npm test -- --runInBand` failed because Jest found **0 tests**.
- `npm ls --depth=0` completed without reporting missing installed top-level packages.
- `npm audit --omit=dev` reported **28 production dependency vulnerabilities**: 19 high, 8 moderate, 1 low, 0 critical. Dependency upgrades were intentionally not performed.
- Broken local import scan found active-looking `documentJob.util.js -> ../queues/pdf.queue`; the commented future `email.worker` reference is not an active defect.
- Targeted dependency observations: Supertest, Swagger/OpenAPI tooling, Helmet, rate limiting, FCM, payment SDKs, and OpenAI SDK are missing. `nodemon` should be dev-only; `nodeman` and `pdfmake` appear unused. Confirm with runtime coverage before removal.
## Phase 0 Completion Update
**Date:** 2026-09-03
**Revised Day 1 completion:** approximately **92%**.
Phase 0 stabilized the existing foundation without adding commerce modules. Node/Docker now target Node 22; Zod validates required startup configuration and feature-gated mail/S3 configuration; unsafe JWT secret fallbacks are removed. Express construction is independent from listening, and `server.js` waits for successful MySQL authentication and Redis connectivity before accepting traffic. Runtime `sequelize.sync()` was removed and Sequelize CLI plus a non-destructive current-model baseline migration were added.
New `/health/live` and `/health/ready` routes provide real liveness/readiness behavior, while `/health` remains a liveness compatibility alias. Request IDs, Helmet, explicit body limits, general and sensitive rate limits, centralized 404/error handling, safer production request logging, configurable cron startup, and API/worker graceful shutdown are now present. Bull Board requires an authenticated `admin` or `superadmin` and displays all three existing queues. The existing router is available on both `/api` and `/api/v1`.
Deployment additions include a hardened Node 22/Chromium/non-root Dockerfile with healthcheck, API/worker/MySQL/Redis Compose configuration, an Nginx reverse-proxy example, and a Gitea Actions CI baseline. Jest/Supertest tests now cover environment validation, liveness/readiness, errors/404, protected routes, Bull Board denial, and request correlation. The first test run exposed incompatible ESM-only `uuid@13`; it was safely pinned to CommonJS-compatible v11. `nodemon` moved to devDependencies.
Remaining foundation-adjacent work is intentionally deferred: production database baseline verification, distributed cron locking, full queue policy/idempotency, stronger documentation sessions, permission-router/role integration, and the Phase 1 authentication/ownership/security issues. The original audit above remains the historical baseline; statements such as “missing tests/Helmet/migrations” are superseded by this update and `Documentation/PHASE_0_FOUNDATION_STABILIZATION.md`.
## Phase 1 Completion Update
**Date:** 2026-09-03
**Authentication completion:** approximately **91%**.
**Revised Day 2 completion:** approximately **90%**.
Phase 1 replaced process-memory OTP and refresh sessions with Redis hash-only login challenges and durable MySQL auth-session families. OTP now uses `crypto.randomInt`, challenge UUIDs, TTL, atomic verification, bounded attempts, and no plaintext logging. Refresh tokens are opaque random values stored only by SHA-256 hash; row-locked transaction rotation detects replay and revokes the family. Access JWTs are short-lived, issuer/audience/algorithm constrained, and bound to `sub`, live session ID, and User token version. Middleware enforces current account state and session revocation.
New `auth_sessions`, `user_identities`, and `user_roles` models/tables support devices, Remember Me, Google/Apple stable subjects, and configurable roles. The User security migration adds token version/last login, expands canonical account types, and adds RBAC unique indexes. Permission routes are mounted behind SUPER_ADMIN, permission resolution now uses UserRole, and cache invalidation covers assignments/grants. Customer self-service update is allowlisted and ID-based mutation is privileged, closing the audited identity IDOR/mass-assignment path.
Auth endpoints now cover customer registration, verification/resend, password-to-OTP challenge, OTP completion, refresh rotation, current/all-device logout, forgot/reset/change password, Google, Apple, `/me`, and shared admin/rider compatibility flows. Both `/api` and `/api/v1` remain. Security-focused unit/integration tests were added without real providers/email/database/Redis.
Remaining identity work is operational: validate/deduplicate deployed RBAC data before migration, run staging MySQL/Redis concurrency tests, seed initial privileged assignments securely, configure provider audiences/mail, and design manual privileged OAuth linking and email change if required. Module 01 is now approximately 91%; migration/staging validation prevents claiming 100% production completion.
## Phase 5 Completion Update
Date: 2026-09-03. Inventory/reservation foundations, business pricing, promotions/coupons, and localized scheduled banners are implemented behind new permissions and a forward-only migration. Phase 5 adds 13 models, secured administration routes, public availability/banner projections, audit calls, a bounded expiry reconciliation cron, and Phase 6 pricing/reservation service boundaries. Module 05 is 85%, Module 07 is 78%, and Module 13 is 75%. Inventory is 90%, reservations 90%, business pricing 82%, promotions/coupons 78%, and banners 82%. Automated verification: 18 suites and 89 tests passed; syntax passed for 232 JavaScript files. Migration was not executed. Staging must precheck legacy stock/pricing/promotion/banner data, apply the migration, seed permissions/default warehouse, and test genuine MySQL locking plus cron behavior before Phase 6 production use.
## Phase 6 Completion Update
Date: 2026-09-03. Module 06 is 91%, Module 08 is 84%, and Module 07 is revised to 86%. Authenticated cart is 92%, wishlist 92%, shipping 86%, checkout 88%, and reservation integration 90%. Nine models, a forward-only migration, owner-scoped shopping APIs, permission-protected shipping administration, server-authoritative totals, atomic inventory reservation composition, idempotent checkout creation, and bounded expiry/cancellation release are implemented. Verification passes 20 suites/95 tests and 258 JavaScript syntax checks. The migration was not executed. Before Phase 7, staging must precheck legacy shopping/shipping tables and address/currency compatibility, seed shipping configuration/permissions, apply migrations, and validate real multi-connection InnoDB concurrency plus multi-instance expiry behavior.
## Phase 7 Completion Update
Date: 2026-09-09. Module 09 is 86%, Module 10 is 76%, and Module 13 is revised to 82%. Orders are 90%, payments 78%, invoices 72%, refunds 68%, returns 82%, coupon redemption 80%, and verified-purchase reviews 90%. Eleven commerce models, a forward-only migration, transactional checkout conversion, owner/admin APIs, explicit state machines, verified/idempotent webhook processing, payment-time inventory consumption, invoice sequencing, itemized refund validation, RMA handling, and return-only restocking were added. Migration and real provider/MySQL/document-worker validation remain staging requirements. Module 16 AI Customer Support Chatbot is intentionally excluded from this backend and planned as a separate service.
## Phase 8 Completion Update
Date: 2026-09-09. Module 11 is 88%; shipments are 90%, riders 86%, assignment/dispatch 86%, tracking 88%, proof of delivery 82%, and return logistics 84%. Module 09 is revised to 90% through physical fulfillment integration. Six logistics models, a forward-only migration, explicit shipment states/actions, partial fulfillment, locked rider capacity/assignment, append-only events, proof media validation, safe tracking, and approved-return pickup were added. Automated checks pass 25 suites/123 tests with 305 JavaScript files syntax-checked. The migration was not executed. Staging must validate real InnoDB concurrency, multi-instance idempotency, proof storage, notification fan-out, permission seeding, and return handoff before production or Phase 9 rollout. Module 16 AI Customer Support Chatbot remains excluded from this backend and will be developed separately.
## Phase 9 Completion Update
Date: 2026-09-09. Module 12 is 86% and Module 13 is revised to 88%. Loyalty accounts are 92%, points ledger 90%, earning rules 86%, membership 88%, rewards/redemption 86%, referrals 82%, birthday rewards 80%, and expiry 80%. Business tiers are 84%, business credit 84%, settlements 78%, wholesale dashboard 82%, and wholesale analytics 76%. Module 07 is revised to 82% through coupon-entitlement foundations; Modules 09/10 are unchanged except for paid-event loyalty and internal-credit integration. Fourteen models, a forward-only migration, bounded cron reconciliation, new owner/admin APIs, and immutable concurrency-safe ledgers were added. Migration and real MySQL/cron/document/notification validation remain staging requirements. Module 16 AI Customer Support Chatbot remains intentionally excluded and will be developed separately.
## Phase 10 Completion Update
Date: 2026-09-09
- Module 15 Support Ticket: **84%**
- Module 17 Product Recommendations: **78%**
- Support tickets 90%; messages 88%; assignment 86%; SLA/escalation 74%; attachments 82%; related-resource integration 78%.
- Help center 84%; help localization 86%; help search 76%; newsletter consent 84%.
- Recommendation events 80%; related 86%; recently viewed 84%; trending 78%; popular 74%; personalized deterministic 70%.
- Module 14 notifications is unchanged: event/template delivery fanout remains outstanding.
- Module 03 catalogue relationships are reused without a revised completion claim.
- Module 04 localization infrastructure is reused without a revised completion claim.
- Verification: **28 suites / 145 tests passing**; syntax passed for **373 JavaScript files**; `npm ls --depth=0` reports no dependency problems.
- Migration: `20260909100000-phase-10-support-recommendations.js` created but **not executed**. Phase 0–9 migrations were not changed.
- Staging requirements: legacy-data precheck; real MySQL migration/FK/query-plan and row-lock validation; Redis/BullMQ/cron multi-instance validation; S3 signed-download and file-content validation; SMTP notification wiring; business-price projection; authoritative shopping/commerce recommendation events; IDOR/E2E/concurrency tests.
- Phase 11 readiness: safe to begin hardening after the Phase 10 migration and infrastructure checks are scheduled; Phase 10 is not a production-readiness claim.
Module 16 AI Customer Support Chatbot is intentionally excluded from this backend. It will be developed as a separate service.
## Phase 11 Completion Update
Date: 2026-09-15
Overall backend code completion is estimated at **90%**; production readiness is **64%**. Module 16 is excluded from both calculations.
- 01 Authentication/Authorization 92%; 02 Customer/Profile/Address 90%; 03 Catalogue 88%; 04 Localization 86%; 05 Inventory 88%; 06 Cart/Wishlist 92%; 07 Merchandising 84%; 08 Checkout 90%.
- 09 Orders 91%; 10 Payments 82%; 11 Delivery/Rider 88%; 12 Loyalty 86%; 13 Wholesale 88%; 14 Notifications 74%; 15 Support 84%; 17 Recommendations 79%.
- 18 Admin Dashboard/Analytics 82%; 19 File/Media 84%; 20 Audit/System Configuration 74%; 21 Jobs/Queues 80%; 22 Security 82%; 23 Testing/QA 76%; 24 API Documentation 82%; 25 Deployment/Infrastructure 78%.
- Added permission-separated dashboard/sales/order/customer/product/inventory/payment/delivery/loyalty/support/recommendation analytics, bounded UTC date ranges, self-only account overview, protected low-cardinality metrics, OpenAPI 3.1 baseline, smoke test, and operational/recovery documentation.
- Final automated verification: **29 suites / 150 tests passing**, **383 JavaScript files** syntax checked, OpenAPI JSON valid, and `npm ls --depth=0` clean.
- Security upgrades reduced `npm audit` from 30 findings (20 high/9 moderate/1 low) to **19 findings (13 high/5 moderate/1 low)**. Remaining Puppeteer/transitive and major-version remediations require controlled upgrade/risk review.
- Security findings fixed: vulnerable current-major versions of AWS SDK, Axios, BullMQ, Morgan, Multer, MySQL2, Sequelize and related packages were upgraded; analytics/metrics permissions and bounded query inputs were added.
- Security findings remaining: full live IDOR/CSRF/proxy verification, antivirus defense, dependency majors/transitives, full route-matrix drift automation, and provider/infrastructure threat validation.
- Migrations: Phase 0–10 migrations remain unchanged; no Phase 11 migration was necessary and **no migration was executed**.
- Real infrastructure: MySQL migration/concurrency, Redis, BullMQ, multi-instance cron, S3, SMTP, Stripe, PayHere, Nginx/TLS, backup restore and load behavior are **IMPLEMENTED_UNVERIFIED**.
- E2E: mocked/unit regression is VERIFIED; real retail, failure, cancellation, delivery, return/refund, loyalty, wholesale, support, authorization and concurrency flows are UNVERIFIED.
- Deployment decision: **STAGING-READY**, not production-candidate. Complete the production checklist and generate real staging evidence before go-live.
Module 16 AI Customer Support Chatbot remains intentionally excluded. No AI/LLM, prompt, RAG, embedding, vector database, generated reply, or agent implementation was added.
@@ -0,0 +1,15 @@
# Notification Event Matrix
| Domain | Event | In-App | Email | Push | Mandatory | Recipient | Implemented | Tested |
|---|---|---:|---:|---:|---:|---|---|---|
| Identity | Email verification/password reset/security | Yes | Yes | No | Yes | User | Yes | Unit/mocked |
| Business | Application received/approved/rejected/status | Yes | Yes | No | Mixed | Applicant | Yes | Mocked |
| Orders | ORDER_PLACED / ORDER_CANCELLED | Partial | No | No | Yes | Customer | Partial | Audit tests only |
| Payments | PAYMENT_CONFIRMED / PAYMENT_FAILED | Partial | No | No | Yes | Customer | Partial | Provider unit tests |
| Refunds/returns | completion/status | No | No | No | Yes | Customer | No | No |
| Shipment | dispatched/delivered/failed | No | No | No | Yes | Customer | No | No |
| Loyalty | reward redeemed | No | No | No | Optional | Customer | No | Policy tests |
| Support | created/replied/resolved | No | No | No | Mixed | Customer/agent | No | State tests |
| Wholesale | credit/settlement due | No | No | No | Mixed | Business contact | No | Ledger tests |
Push is **NOT_IMPLEMENTED** because no device-token/FCM infrastructure exists. Phase 2 provides persisted in-app/email delivery primitives, preferences, BullMQ retry and failure storage. Domain fanout above remains staging work; it was not faked in Phase 11.
@@ -0,0 +1,140 @@
# ZUMRI Phase 0 Foundation Stabilization
## Objective
Stabilize the existing modular monolith so later identity and commerce work can build on deterministic startup, explicit schema management, observable health, baseline security, testability, and controlled shutdown. This phase does not add e-commerce domain behavior or intentionally redesign existing modules.
## Starting Problems
The API listener started before asynchronous database authentication, runtime `sequelize.sync()` was the schema strategy, `/health` returned before its database check and hardcoded other dependencies as healthy, Redis clients connected during imports, and there was no environment validation, global error/404 handling, request correlation, security headers, rate limiting, graceful shutdown, migration system, or tests. Bull Board was public. Docker targeted Node 20 while the architecture targets Node 22. See `Documentation/CURRENT_BACKEND_STATUS.md` for the full baseline audit.
## Changes Implemented
- Aligned package and container runtime to Node 22.
- Added centralized Zod environment validation with safe error messages.
- Separated Express construction (`app.js`) from dependency initialization and listening (`server.js`).
- Added database, Redis, queue, cron, API, and worker lifecycle handling.
- Removed runtime `sequelize.sync()` and introduced a Sequelize CLI baseline migration.
- Added liveness/readiness endpoints, request IDs, Helmet, body limits, general/sensitive rate limits, centralized errors, and centralized 404 behavior.
- Protected Bull Board with the existing JWT middleware and `admin`/`superadmin` account guard; registered activity, document, and log queues.
- Kept `/api` and added `/api/v1` as a backward-compatible alias.
- Added Jest/Supertest baseline tests and a portable JavaScript syntax-check command.
- Added Node 22 Docker hardening, development Compose, an Nginx example, and Gitea Actions CI.
- Restored the existing S3 utility interface through a feature-gated S3 client.
- Removed unsafe JWT/refresh-secret fallbacks and moved `nodemon` to development dependencies.
- Pinned `uuid` to the CommonJS-compatible v11 line after tests exposed that v13 could not be loaded by this CommonJS application.
## Application Startup Lifecycle
The API sequence is now:
1. Load `.env`.
2. Validate critical configuration without displaying values.
3. Authenticate Sequelize (no schema mutation).
4. connect to and ping Redis.
5. Load the Express app and queue resources.
6. Start cron only when `RUN_CRON=true`.
7. Start the HTTP listener.
8. On SIGTERM/SIGINT or fatal process error, stop accepting traffic, stop cron, close queues, Redis, and Sequelize, with a timeout guard.
Tests can import `app.js` without opening a TCP port. Startup failures prevent the listener from opening.
## Environment Variables
Required for API/worker startup: `NODE_ENV`, `PORT`, `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `JWT_SECRET`, `REDIS_HOST`, `REDIS_PORT`, and `FRONTEND_URL`. The JWT secret must contain at least 32 characters. Phase 1 replaced refresh JWTs with opaque random refresh tokens, so no refresh-token signing secret is required. `REDIS_PASSWORD` is optional at schema level for deployments without Redis authentication.
Runtime controls: `TRUST_PROXY` (numeric trusted proxy hop count; keep `0` when directly exposed), `JSON_BODY_LIMIT`, `API_RATE_LIMIT_WINDOW_MS`, `API_RATE_LIMIT_MAX`, `SENSITIVE_RATE_LIMIT_WINDOW_MS`, `SENSITIVE_RATE_LIMIT_MAX`, `RUN_CRON`, `CACHE`, and `SHUTDOWN_TIMEOUT_MS`.
Optional mail variables are required as a complete group only when `ENABLE_MAIL=true`: `MAIL_HOST`, `MAIL_PORT`, `MAIL_USER`, `MAIL_PASS`, `MAIL_FROM`; `MAIL_SECURE` is optional. Optional S3 variables are required as a complete group only when `ENABLE_S3=true`: `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_S3_BUCKET_NAME`. Docs credentials remain optional. See `.env.sample`; never commit real values.
## Database Migration Strategy
Sequelize CLI uses `.sequelizerc`, `app/config/sequelize-cli.config.js`, and `migrations/20260903000000-current-schema-baseline.js`. Commands:
```text
npm run db:migrate:status
npm run db:migrate
npm run db:migrate:undo
```
The baseline represents all currently registered models and creates only tables whose names are absent. It does not drop, alter, or validate columns during `up`. For an existing deployment: take a backup, compare its schema to the migration/model definitions, test against a restored copy, resolve drift explicitly, then run the migration so Sequelize records it. Do not blindly run the baseline undo in an existing environment; its standard `down` removes baseline tables. No migration was executed during this phase.
Future schema changes require new forward migrations. Production no longer calls `sequelize.sync()`.
## Health Endpoints
- `GET /health/live`: process-only liveness; no dependencies queried.
- `GET /health/ready`: checks MySQL and Redis concurrently; returns 200/`ready` or 503/`not_ready`, exposing only `ok`/`error` states.
- `GET /health`: backward-compatible alias to liveness so existing monitors are not broken.
Container orchestration should normally use liveness for process restart and readiness for traffic admission.
## Security Middleware
Helmet is enabled; CSP is disabled globally for compatibility with the existing Bull Board/static documentation, while the board itself is authorization-protected. The API disables `X-Powered-By`, limits JSON and URL-encoded bodies to 1 MB by default, retains current credentialed CORS behavior, and accepts `X-Request-ID` only in a constrained safe format. Unknown routes and uncaught request errors use a standard response. Production 500 responses hide internal details.
JWT helpers have no public fallback secrets. Startup rejects missing/weak secrets. Morgan does not log Authorization, Cookie, or request bodies. Error logs contain request ID plus error name/message, not arbitrary error objects.
## Rate Limiting
Both `/api` and `/api/v1` use the configurable general limiter. The entire authentication router uses the stricter limiter, as do password reset requests/submissions and docs login. Health endpoints are outside limiters. `TRUST_PROXY` is an explicit numeric hop count rather than universal trust; set it to the exact Nginx hop count in deployment.
## Bull Board Security
`/admin/queues` now runs through the existing authentication middleware and accepts only actual User model administrator values: `admin` and `superadmin`. It has no hardcoded secondary credentials. Activity, document, and log queues are registered.
## Logging / Request IDs
Every request receives `req.id` and `X-Request-ID`; a safe incoming ID may be preserved. Development retains Morgan `dev`; production uses a concise method/path/status/timing line with request ID and no auth headers. The existing log queue remains in place. Phase 1 must remove remaining OTP/reset/session logging inside legacy authentication flows.
## Graceful Shutdown
The API handles SIGTERM, SIGINT, unhandled rejections, and uncaught exceptions. It closes the HTTP listener, cron tasks, API-owned queues, shared Redis, and Sequelize. Workers initialize required dependencies before accepting jobs and close worker instances, Redis, and Sequelize on the same signals/fatal conditions. `SHUTDOWN_TIMEOUT_MS` protects against indefinitely stuck shutdown.
## Cron Deployment Model
Cron is disabled unless `RUN_CRON=true`; tests do not start it. Until a distributed scheduler lock is added, enable it on exactly one API/scheduler instance. The cron launcher returns a stopper used during graceful shutdown.
## Testing
```text
npm run check:syntax
npm test -- --runInBand
npm run test:unit
npm run test:integration
```
Tests mock external infrastructure. Coverage includes liveness and readiness success/failure, `/health` compatibility, centralized 404/error responses, environment validation and optional features, authentication-required rejection, Bull Board rejection, and request IDs. No real MySQL, Redis, email, or S3 is required by the baseline suite.
## Docker
The existing image now uses `node:22-slim`, keeps system Chromium/Puppeteer support, installs production dependencies, copies files as the unprivileged `node` user, and includes a `/health/live` healthcheck. Build and configuration are still environment-driven.
## Local Development
Copy `.env.sample` to an ignored `.env`, replace all placeholder credentials/secrets, then run migrations explicitly before starting the API. `compose.yaml` provides API, worker, MySQL 8.4, and Redis 7.4 using the same application image and named data volumes. It does not auto-run migrations. Only the API port is published; MySQL/Redis remain internal.
## CI
`.gitea/workflows/ci.yml` uses checkout/setup-node actions, Node 22, `npm ci`, syntax checks, and Jest. It performs no deployment and requires no production credentials. Runner action mirroring/network policy remains an installation-specific Gitea concern.
## API Versioning Strategy
The existing router is mounted at both `/api` and `/api/v1`. Existing frontend calls remain valid, while new consumers should adopt `/api/v1`. A later compatibility window can deprecate `/api`; no route was mass-renamed in Phase 0.
## Known Remaining Issues
- Authentication contains in-memory OTP/refresh-session behavior and needs the dedicated Phase 1 security/session design; no Phase 1 feature was implemented here.
- Ownership/IDOR and role/account naming inconsistencies remain in legacy controllers/routes.
- Permission routes remain imported but unmounted and role linkage/cache behavior needs repair.
- Existing authentication utilities may still log OTP/reset/session material; remove and test during Phase 1.
- Docs authentication still uses a weak boolean cookie and should receive a server-authenticated session design.
- Activity/log queues need standardized retry, retention, idempotency, and sensitive-data sanitation.
- S3 is now correctly constructed only when enabled, but object authorization/content validation and lifecycle remain later work.
- The dependency audit still reports transitive vulnerabilities; forced/major upgrades were intentionally avoided.
- Migration baseline schema drift must be reviewed against any deployed database before first use.
- A distributed cron lock is not yet present.
## Phase 1 Prerequisites
The foundation is ready to begin Phase 1 once the baseline migration has been reviewed/tested against a copy of the deployment database and deployment secrets are configured. Phase 1 should focus on authentication/session durability, secure OTP/reset behavior, account status, role/permission integration, and ownership authorization without starting commerce modules.
@@ -0,0 +1,53 @@
# ZUMRI Phase 10 Support, Help Center, Newsletter and Recommendation Foundations
## Objective
Provide practical customer support, localized self-service content, explicit newsletter consent, and deterministic recommendations while preserving Phase 0–9 domain ownership.
## Existing Components Reused
Phase 1 identity/RBAC; Phase 2 upload, signed S3 access, notification/audit infrastructure; Phase 4 locale, product relations and product DTO; Phase 5 inventory boundary; Phase 7 order/payment truth; Phase 8 shipment truth; Phase 9 loyalty/business ownership; atomic reference numbers and existing cron lifecycle.
## Support Architecture
`SupportTicket` owns case lifecycle, `SupportMessage` conversation, `SupportTicketEvent` append-only history, `SupportTicketLink` polymorphic links, and `SupportAttachment` upload references. Ticket creation is transactional. The status graph is explicit; generic status mutation is absent. Agent claim/assignment locks the row. Internal notes and attachments are filtered from customer reads. Resource links validate domain ownership, and signed downloads require ticket authorization.
## SLA and Escalation
An active per-priority `SupportSlaPolicy` snapshots first-response and resolution deadlines using `CLOCK_TIME`. The bounded cron detects overdue open work and uses deterministic event keys to create each `SupportEscalation` once. Full calendars, warning tiers, acknowledgement APIs, and verified multi-instance scheduling remain outstanding.
## Support Notifications, Permissions and Audit
Phase 2 notification infrastructure is retained as the delivery boundary; full template/fanout wiring is outstanding. New support/help/newsletter/recommendation permissions are explicit. Support lifecycle history is durable in ticket events, and privileged general audit calls are intentionally limited pending real queue integration testing.
## Help Center Architecture
Help categories and articles have `en`/`si`/`ta` translation tables and English fallback. Articles implement draft, publish, and archive lifecycle; public APIs query published content only. FAQ is an article type, avoiding a parallel engine. Search uses bounded MySQL `LIKE` queries. HTML tags are removed and bodies are treated as untrusted Markdown-like text.
## Newsletter Consent
Newsletter subscriptions are unique by normalized email, source/locale aware, idempotent, independently revocable, and linked to a user when known. Immediate opt-in is the documented policy. Unsubscribe authorization uses a random token with hash-only persistence. Profile marketing preferences never override explicit unsubscribe.
## Recommendation Architecture
Privacy-conscious interaction events retain meaningful signals only. Client ingestion is restricted to product views and protected by authentication, validation, rate limiting, and event-id uniqueness. Explicit product relations lead related results. Recently viewed is de-duplicated; trending uses weighted 30-day SQL aggregation; popular uses paid order items net of refunds; for-you deterministically prioritizes recent interests with a featured fallback. Queries and response sizes are bounded, inactive/hidden products are excluded, and the existing localized serializer is reused.
## Future External Recommendation Service Boundary
The current implementation is `LOCAL_DETERMINISTIC`. A future external service may implement the same recommendation input/output contract using scoped machine credentials. There is no insecure internal bypass or placeholder service URL.
## Explicit AI Exclusion
Module 16 AI Customer Support Chatbot is intentionally excluded. No OpenAI/LLM integration, prompts, embeddings, RAG, vector database, generated replies, agent, or ML training pipeline was implemented.
## Security and Database Changes
Customer ownership is server-derived; strict request schemas reject unknown fields; internal visibility is enforced; uploads and linked resources receive independent authorization; newsletter tokens are hash-only; recommendation telemetry cannot spoof purchases. Migration `20260909100000-phase-10-support-recommendations.js` is forward-only and creates 14 tables with uniqueness and query indexes. Precheck legacy support/FAQ/newsletter/event tables and duplicate normalized email addresses before staging. No backfill is fabricated.
## Tests and Known Limitations
Unit coverage exercises strict fields, priority/event spoofing, transition policy, token hashing, source allowlists, body safety, and permissions. Real MySQL FK/migration/concurrency, Redis/BullMQ, S3 signed downloads, SMTP notifications, business-price projection, authoritative event hooks, rich CMS update APIs, and multi-instance cron behavior require Phase 11 staging work.
## Phase 11 Prerequisites
Apply migrations only after schema/data prechecks and a verified backup. Seed categories/SLA policies and permissions, validate real infrastructure, add concurrency/IDOR/E2E coverage, wire support notifications and authoritative recommendation signals, and measure aggregate query plans before production readiness assessment.
@@ -0,0 +1,57 @@
# ZUMRI Phase 11 Analytics and Production Readiness
## Objective and Baseline
The final main backend phase adds bounded operational analytics, account overview, security/dependency hardening, observability, API/deployment operations, and an evidence-based readiness assessment. Baseline: Node 22.12.0, npm 10.9.0, 28 suites/145 tests, 373 syntax files, and 30 audit advisories before upgrades.
## Architecture Reviewed
Current source comprises identity/RBAC, customer/business, catalogue/localization, inventory/pricing/merchandising, shopping/checkout, commerce, logistics, loyalty/wholesale, support/help/newsletter/recommendations, shared uploads/notifications/audit/documents, four queues/workers, five cron families, eleven forward-only phase migrations, Docker/Compose and Nginx samples. Mounted routes retain legacy `/api` and preferred `/api/v1` prefixes.
## Admin Analytics and Account Overview
Permission-separated endpoints cover dashboard, sales, orders, customers, products, inventory, payments, delivery, loyalty, support, and recommendation signals. All use SQL aggregation and bounded results; date-based reports default to 30 days UTC and reject ranges over 366 days. Revenue means paid/captured payment amount; refunds mean completed refunds; net is their difference. Profit/valuation and recommendation conversion are explicitly unavailable. `/account/overview` is self-only and combines recent orders, wishlist, default address, loyalty, vouchers, and open support count. Existing wholesale dashboard remains authoritative.
## Notification Completion
The matrix documents actual Phase 2 delivery and domain gaps. Push is not implemented. High-value commerce/logistics/support fanout remains implemented-unverified or absent and is not falsely claimed.
## Security, Authorization, IDOR and Validation Audit
Central authentication checks issuer/audience-signed JWT claims, active user/session, rotation/token version and permissions. Admin analytics/metrics are permission gated. Customer domains derive identity or constrain queries by owner. Phase 10 support attachments, messages, internal visibility and resource links have independent authorization. Strict Zod schemas protect new mutations. Existing route source remains authoritative; full live authenticated IDOR and concurrency execution is outstanding.
Cookies plus Bearer tokens serve browser/mobile clients. CORS is restricted to `FRONTEND_URL` with credentials; cookie flags remain part of the Phase 1 implementation. Helmet defaults are retained except CSP (disabled for compatibility) and non-production HSTS. Production must validate same-site/origin behavior behind the exact proxy topology. Logs use request IDs and avoid request bodies; error middleware suppresses production stack/internal details. No secret values were copied into this report. `.env` is ignored; rotate any credential ever committed outside the reviewed history.
## Authentication, Payment and File Security
Shared password policy/session invalidation and provider verification/idempotency remain covered by prior tests. Stripe/PayHere cryptographic and refund behavior is IMPLEMENTED_UNVERIFIED without sandbox credentials. Uploads enforce size, allowed type, content sniffing/checksum, ownership, private storage and signed expiry; antivirus is not implemented and is a recommended defense-in-depth control.
## Dependency Audit
Current-major upgrades were applied for AWS SDK, Bull Board, Axios, BullMQ, dotenv, ioredis, Jest, Morgan, Multer, MySQL2, node-cron, Nodemailer 8, pdfmake, Sequelize and Zod. The audit fell from 30 to 19 advisories. Remaining findings are primarily transitive and include Puppeteer/extract-zip plus packages requiring major/breaking remediation; they must be triaged or accepted before production.
## E2E and Concurrency Testing
Mocked/unit regression verifies domain state, authorization/validation policies and deterministic calculations. Real retail, failed-payment, cancellation, delivery, return/refund, loyalty/referral, business, support, authorization, InnoDB contention and duplicate webhook/claim flows remain UNVERIFIED. Use isolated staging identities and provider sandboxes; never run destructive commerce smoke against production.
## Migration and Infrastructure Validation
Phase 0–10 migrations were left unchanged. No migration was executed. Fresh and restored-upgrade MySQL procedures, FK/index/query-plan validation, Redis, BullMQ, single-scheduler cron, S3, SMTP, Stripe and PayHere are IMPLEMENTED_UNVERIFIED pending credentials/services. Forward correction or verified restore is the rollback model after live writes.
## Queue/Cron Operations and Observability
Operational inventories are in `QUEUE_OPERATIONS.md` and `CRON_OPERATIONS.md`. `/health/live` tests process life; `/health/ready` tests required MySQL/Redis. Protected `/api/v1/admin/metrics` exports process memory, uptime, HTTP counts and duration sums using only method/status-class labels. Recommended alerts cover readiness, 5xx, DB/Redis, queues, webhooks, cron, latency and resource pressure.
## OpenAPI, Docker, Nginx and CI/CD
`openapi.json` provides a valid OpenAPI 3.1 security/error and critical-route baseline, not a claim of complete route coverage. Docker uses Node 22, Chromium, production installs, non-root execution and liveness healthcheck. Compose separates API/worker and health-gates MySQL/Redis; production secrets must not use `.env` files. Nginx forwards standard proxy headers and bounds upload/timeouts; production TLS/trusted-proxy validation is required. Existing Gitea CI plus syntax/tests/dependency/OpenAPI validation should gate releases.
## Backup/Restore and Production Runbook
See `BACKUP_RESTORE.md`, `PRODUCTION_RUNBOOK.md`, and `PRODUCTION_CHECKLIST.md`. Database and object storage must be recoverable together; Redis loss semantics, RPO/RTO, restore drills and rollback authority require approval.
## Remaining Unverified Items and Assessment
Code implementation is approximately 91%; security 82%; automated tests 76%; real integration validation 25%; observability 72%; deployment 78%; documentation 88%; backup/recovery 68%. Overall code completion is **90%**; production readiness is **64%**. Decision: **STAGING-READY**, not production-candidate, because migrations, real dependencies, provider sandboxes, concurrency/E2E, notification fanout, remaining dependency advisories, and restore drills lack evidence.
Module 16 AI Customer Support Chatbot is excluded and was not implemented. No LLM, prompt, RAG, embedding, vector database, generated reply, or AI agent code was added.
@@ -0,0 +1,119 @@
# ZUMRI Phase 1 Identity and Authorization
## Objective
Complete the identity security boundary without beginning commerce modules: verified registration, password+OTP authentication, durable revocable sessions, social identity verification, account-state enforcement, repaired configurable RBAC, and ownership-safe self service.
## Existing Components Reused
User/Profile and customer-extension models, Sequelize registration, Redis client, bcrypt helper, email templates/transport, hashed verification/reset concepts, activity queue, permission/role/grant models, permission cache, Phase 0 error/rate-limit/request-ID middleware, dual API mounting, tests, and migrations were extended rather than replaced.
## Identity Model
`User` is the account and contains broad `accountType`, state, password hash (nullable for social-only customers), verification/password timestamps, `tokenVersion`, and `lastLoginAt`. `UserIdentity` maps Google/Apple stable subjects to User. `AuthSession` persists refresh credentials/device context and rotation state. `UserRole` assigns configurable Roles independently from account type.
## Account Types
Canonical application constants map to persisted lowercase values: `SUPER_ADMIN=superadmin`, `ADMIN=admin`, `MANAGER=manager`, `CUSTOMER=customer`, `BUSINESS_CUSTOMER=business_customer`, `RIDER=rider`, `SUPPORT_AGENT=support_agent`. Existing lowercase values remain valid.
## Role vs Account Type
Account type is a broad trusted identity category used for hard security boundaries. Role is configurable authorization grouping. Users may have multiple roles through `UserRole`; effective permissions are the union of role grants and direct additive `UserPermission` grants. This removes dependence on undeclared `User.roleID`.
## Session Architecture
```text
Password -> OTP Challenge -> Verify OTP -> AuthSession
-> Access JWT + opaque Refresh Token
-> transactional rotation -> logout/revocation
```
Sessions support multiple devices, user-agent/IP/device metadata, Remember Me, token families, last use, expiry, revocation reason, and replacement linkage. Raw refresh tokens exist only at issuance/transport.
## Access Token
HS256 JWTs are short-lived and contain `sub`, `sid`, and `tokenVersion`, plus `iss`, `aud`, `iat`, and `exp`. Middleware enforces algorithm, signature, issuer, audience, expiry, live User state/token version, and live non-revoked session state. It loads permissions server-side rather than embedding them.
## Refresh Token Rotation
Refresh tokens are opaque `session-id.random-secret` values. The database stores only SHA-256 hashes. Rotation locks the current session row and User in a transaction, creates a same-family replacement, and revokes/links the old row.
## Reuse Detection
Presentation of a revoked/replaced or hash-mismatched known session token revokes all active members of that token family and returns the same invalid-session boundary. Row locks ensure two concurrent refresh calls cannot both succeed.
## Remember Me
Remember Me changes only refresh-session lifetime: `REFRESH_TOKEN_TTL_DAYS` versus `REMEMBER_ME_REFRESH_TOKEN_TTL_DAYS`. Access lifetime remains `ACCESS_TOKEN_TTL`.
## Account Status Enforcement
Only `ACTIVE` can finish login, refresh, or use protected endpoints. Pending, suspended, and deactivated accounts are rejected using current database state, not stale claims. Password/security administration increments token version and revokes sessions.
## Password Policy
One Zod policy requires at least 12 characters, uppercase, lowercase, a symbol, and four digits. It is used by registration, reset, and change-password flows. Reset/change require confirmation and reject reuse of the current password.
## Email Verification
Verification tokens are cryptographically random and hash-only in Redis. They expire, are consumed atomically, and activate the account. Per-user pointers let resend invalidate an earlier token. Resend responses are generic and rate limited.
## Password Recovery
Forgot-password normalizes/validates email and returns a generic response. Reset tokens are random, stored hashed with TTL, atomically consumed using Redis `GETDEL`, and never logged. Successful reset updates the hash/timestamp/tokenVersion and revokes every session.
## Google Authentication
The backend verifies Google ID tokens against configured audience and uses Google's `sub`; verified email is required. Provider access tokens are not stored. Automated tests mock Google's verifier.
## Apple Authentication
The backend obtains/caches Apple's JWKS, selects the signed key, and verifies RS256 signature, Apple issuer, configured audience, expiry, and `sub`. First-login email is used only when verified. Tests use a locally signed RSA token and mocked JWKS response.
## OAuth Account Linking Rules
Existing provider subject wins. Otherwise a strongly verified provider email may link/create a customer. Email-only automatic linking is denied for super admins, admins, managers, support agents, and riders. Provider subject is unique and provider tokens/secrets are not persisted. Manual privileged linking remains deferred.
## Role and Permission Architecture
`/api/v1/permissions` is now mounted and default-denied to SUPER_ADMIN at router level. Existing CRUD is retained behind that boundary. Role names and role/user grant pairs have unique constraints. UserRole pairs are unique. Model hooks and assignment controllers invalidate affected permission caches.
## Ownership Authorization
`GET/PATCH /user/me` provides self service. Self update allowlists only first and last name; account type/status/roles/security fields are rejected. ID-based legacy mutation is restricted to SUPER_ADMIN, and no arbitrary ID read route is exposed. Profile image access uses reusable self-or-admin ownership middleware.
## New Database Tables
- `auth_sessions`
- `user_identities`
- `user_roles`
User adds `tokenVersion` and `lastLoginAt`; password becomes nullable for verified social-only users; the account-type ENUM expands to all canonical types.
## New Migrations
`20260903010000-phase-1-identity-security.js` is forward-only relative to the Phase 0 baseline and was not executed. Before applying to an existing database, diagnose duplicate `roles.roleName`, `(role_id,permission_id)`, and `(user_id,permission_id)` rows because unique indexes intentionally fail on dirty data. Back up and test a restored database first.
## API Endpoints
Auth: register, login challenge, verify OTP, refresh, logout, logout-all, forgot/reset/change password, verify/resend email, Google, Apple, and me. Thin shared-flow admin and rider login routes exist. Admin User status/account-type endpoints and protected permission/UserRole endpoints are mounted. See `Documentation/API_AUTHENTICATION.md`.
## Security Controls
Hash-only OTP/reset/verification/refresh persistence; cryptographic random generation; bounded OTP attempts; single-use challenges; short access TTL; durable revocation; replay-family revocation; DB row locks; account-state/token-version checks; strict Zod bodies; generic enumeration responses; provider signature/audience checks; customer-only public registration; field allowlists; ownership checks; and no token/OTP credential logging.
## Tests
Unit coverage includes password policy, JWT claims/wrong issuer-audience/expiry/malformed input, OTP format/hash-only persistence/failure states, refresh hash-only persistence/rotation/replay, account-status/password-login behavior, and Google/Apple verification. Integration coverage preserves Phase 0 health/error behavior and checks RBAC denial, self mass-assignment, other-user mutation, suspended access, and Bull Board admin access. External DB/Redis/email/OAuth services are mocked.
## Legacy Compatibility
Both `/api` and `/api/v1` remain. `/auth/req-otp` aliases new login challenge creation; `/profile` password endpoints delegate to the same hardened controllers. The old email+OTP `/auth/login` second step is intentionally replaced by `/verify-otp` with challenge IDs because the old email-keyed flow could not meet security requirements.
## Remaining Known Issues
Manual privileged OAuth linking and secure email-change confirmation are deferred. Full email queue/delivery tracking remains Phase 2 infrastructure work. Existing business registration/account approval is not part of this phase. Role/grant uniqueness migration requires deployed-data diagnostics. The baseline test suite mocks MySQL/Redis; staging integration tests must run after migration review. Existing non-auth legacy routes may still contain account-name/ownership debt outside Phase 1 scope.
## Phase 2 Prerequisites
Review and run both migrations on a restored environment, configure mail plus Google/Apple client audiences where those flows are enabled, run staging MySQL/Redis integration tests, and seed the initial SUPER_ADMIN/roles/permissions through a controlled operational process. Once complete, identity is ready for the next non-commerce phase requested by the development roadmap.
@@ -0,0 +1,93 @@
# ZUMRI Phase 2 Cross-Cutting Services
## Objective
Harden the shared storage, messaging, queue, audit, logging, and document infrastructure without starting commerce modules.
## Existing Components Reused
The existing AWS SDK client, Upload/Notification/UserNotification/Document models, Redis/BullMQ topology, generators, templates, reference utility, workers, cron lifecycle, Phase 0 operations, and Phase 1 identity/RBAC remain the foundation.
## Storage Architecture
`storage.service.js` is the sole AWS SDK boundary. It supports AWS S3 and endpoint/path-style compatible providers, buffer upload, delete, HEAD existence checks, and signed downloads. Objects remain private.
## Upload Security
Multer performs an early allowlist/size check. The service then rejects empty content and validates JPEG, PNG, WebP, PDF, and XLSX magic bytes against the claimed MIME. It derives the extension, sanitizes display filenames, and stores SHA-256 checksums. Object keys contain a controlled owner identifier, UTC year/month, UUID, and detected extension; original filenames and personal data are excluded.
## File Ownership
Uploads record uploader, owner type/id, purpose, visibility, and lifecycle status. Signed URL and deletion endpoints load metadata and enforce owner or explicit permission access. DB persistence failure after upload triggers best-effort object deletion. Deletion marks metadata DELETED before object removal.
## Signed URL Policy
Downloads use `S3_SIGNED_URL_TTL_SECONDS` (default 900 seconds). URLs are generated after each authorization decision and are not globally cached or exposed with bucket/key details.
## Email Architecture
Auth email enters `email.service.js`, creates a minimal delivery record, and queues a template-keyed job. The worker alone calls Nodemailer. OTPs/links may exist transiently in job data, so jobs have aggressive completion retention and payloads must never be logged.
## Email Retry Strategy
Email uses five exponential attempts. Envelope/message and SMTP 5xx failures are treated as permanent; transient provider/network errors retry. Final state is persisted without storing message bodies or variables.
## Notification Architecture
Notification aliases are explicit. Admin publication can assign one or many users atomically; announcements need no join rows. Self-service listing/read/read-all always derives the user from the access token.
## Notification Preferences
The central policy permits mandatory login OTP, password reset/change, and email verification even when marketing notifications are disabled. Optional external-channel messages honor profile preferences. In-app messages remain available.
## Queue Architecture
Activity, log, document, and email queues define retry, backoff, success retention, and failure retention appropriate to each workload. Bull Board includes all four and retains Phase 0 admin protection.
## Idempotency
Activity uses an event ID, email uses event/delivery ID, and documents use the generation record ID as BullMQ job ID. Workers check persisted state where duplicate execution could create a second artifact.
## Failed Job Handling
Email and document final failures update their associated database record with a bounded error code and timestamp. Stack traces and job payloads are not returned by APIs.
## Audit Logging
Activity events now support event ID, nullable actor, target, action/type, request/IP/user-agent context, sanitized JSON metadata, and occurrence time. APIs expose read operations only; inserts are idempotent by event ID.
## Logging Security
Queued file logs are JSON lines. Error stacks, request bodies, Authorization/Cookie values, and fields named like passwords, OTPs, tokens, or secrets are excluded/redacted. Log jobs have bounded retention; `LOG_RETENTION_DAYS` documents the intended operational file-retention window.
## Document Generation Lifecycle
Generation validates a controlled registry and PDF/XLSX format before queueing, creates an owner-bound record, and moves through QUEUED, PROCESSING, COMPLETED, or FAILED. Successful output becomes an owned Upload. Downloads create a signed URL and never delete the object.
## Document Ownership
Creator and owner may view status/data/download. SUPER_ADMIN bypasses; other administrative access requires the appropriate document permission. Cancellation follows the same ownership boundary.
## Migrations
`20260903020000-phase-2-cross-cutting-services.js` is new and forward-only. Before execution, back up and test a restored database. Pre-check duplicate `(user_id, notification_id)`, duplicate activity event IDs, duplicate document job IDs, duplicate document type names, and legacy uploads/documents without resolvable owners. Resolve duplicates explicitly; the migration intentionally does not delete data.
## Permissions
Shared names are `media.read/upload/delete`, `documents.read/create/delete`, `notifications.manage/read`, `audit.read`, and `queues.read`. SUPER_ADMIN retains the Phase 1 bypass. Production permission rows/grants must be seeded through the environment's controlled authorization process.
## Environment Variables
Added: `S3_ENDPOINT`, `S3_FORCE_PATH_STYLE`, `S3_SIGNED_URL_TTL_SECONDS`, `S3_MAX_UPLOAD_BYTES`, `EMAIL_QUEUE_CONCURRENCY`, `DOCUMENT_QUEUE_CONCURRENCY`, `NOTIFICATION_RETENTION_DAYS`, and `LOG_RETENTION_DAYS`. Optional integrations remain optional unless enabled.
## Tests
Unit coverage verifies magic bytes, mismatch/empty rejection, checksums, safe keys, HTML escaping, security-notification preference policy, and email queue retry/retention. Existing Phase 0/1 tests remain in the full suite. AWS, SMTP, Redis, and MySQL are not contacted.
## Remaining Known Issues
The migration has not been run against staging data. Real S3-compatible provider, SMTP, MySQL migration, Redis concurrency, large notification retention, and actual PDF/browser generation need staging validation. File-log deletion/rotation still belongs to deployment logrotate or a future controlled maintenance worker. Push delivery is intentionally an architecture placeholder only.
## Phase 3 Prerequisites
Complete the documented data pre-checks, apply all migrations to a restored database, seed shared permissions, run API and worker processes against staging Redis/MySQL, and exercise one upload/email/document lifecycle with non-production provider credentials.
@@ -0,0 +1,99 @@
# ZUMRI Phase 3 Customer and Business Foundations
## Objective
Complete customer and business account foundations needed before catalogue and commerce work, without implementing commerce.
## Existing Components Reused
Phase 1 authentication, account states, session revocation, canonical `business_customer`, permissions, and ReferenceNumber are reused. Phase 2 owned uploads, signed media, audit events, notification persistence, email queue, and templates remain shared boundaries.
## Customer Profile
Profile retains its existing phone/date/media/theme data and adds locale plus separate marketing email, marketing push, and in-app preferences. First/last name and email remain canonical User identity fields. Strict self endpoints derive identity from authentication and return no security or storage internals.
## Address Architecture
Address is structured and may belong to exactly one User or approved business profile. It supports international ISO alpha-2 country codes while retaining district/province fields useful in Sri Lanka. An ownership CHECK constraint and foreign keys enforce integrity.
## Default Address Rules
A User/business profile may use one shipping and one billing default, and one row may be both. Mutations serialize on the owner row and clear the prior default in the same transaction. Deletion leaves the default unset. Future commerce records must snapshot address values.
## Preferences
Marketing email and push choices are distinct from in-app preference and Phase 2 mandatory security-message policy. Security messages cannot be disabled through these fields.
## Account Deactivation
Self-deactivation is a soft identity transition. Password accounts confirm the current password; social-only accounts provide the explicit DEACTIVATE confirmation. The transaction increments token version and revokes every session. Login remains denied and self-reactivation is deferred.
## Business Application Architecture
Applications preserve review history independently of the approved profile. Only verified ACTIVE customers can submit, and the applicant row lock prevents concurrent active submissions. States are PENDING, UNDER_REVIEW, APPROVED, REJECTED, and CANCELLED.
## Business Approval Workflow
Permission-gated approval locks the application and applicant, validates transition, generates one partner sequence, creates BusinessCustomer and a disabled zero-limit credit account, changes the canonical account type, increments token version/revokes sessions, and records approval. Double approval fails safely.
## Business Profile
The existing BusinessCustomer table is retained as the approved business profile. It now includes legal/tax/web data, immutable unique partner ID, separate ACTIVE/SUSPENDED/CLOSED domain status, approval metadata, and settlement reference. Self-editing is allowlisted.
## Partner Identity
Partner identity is generated transactionally from ReferenceNumber as `ZUM-BIZ-######`. It is unique, immutable through public APIs, safe to expose, and never previewed through GET.
## Business Contacts
BusinessContact holds non-authenticating contact people. A transaction serializes primary-contact changes without creating User identities or organization/team accounts.
## Business Documents
Applications reuse private Phase 2 Upload records and its MIME/signature/checksum controls. Defined purposes identify registration, tax, identity, and supporting records. Review permission extends signed-read access; storage keys remain private.
## Business Status
Business domain status is separate from User account status. Suspending a business capability does not automatically suspend login identity.
## Credit Foundation
BusinessCreditAccount stores currency, `DECIMAL(15,2)` limit, and DISABLED/ACTIVE/SUSPENDED configuration. Only privileged administration may change it and every change is audited. No used-credit field or fabricated availability is present.
## Settlement Terms
SettlementTerm provides code, display name, day count, and active state. PREPAID, NET_7, NET_14, and NET_30 initial records are migration data, not hardcoded behavior. No billing, invoice, settlement, or product-pricing logic exists.
## Ownership
Customer profile/address/application queries derive the authenticated User and constrain IDs at query time. Business self-service resolves the profile by authenticated owner. Reviewer and administration routes require explicit permissions.
## Permissions
Added `business.applications.read`, `business.applications.review`, `business.accounts.read`, `business.accounts.update`, `business.credit.manage`, and `business.settlement.manage`. SUPER_ADMIN retains the established bypass.
## Audit Events
Profile, address, deactivation, application review, profile/status, credit, and settlement changes emit Phase 2 events with stable names and IDs. Full addresses, documents, credentials, and reviewer internals are not placed in metadata.
## Notifications
Application receipt/approval/rejection and business status templates use Notification/UserNotification and the Phase 2 email queue. Controllers do not call SMTP. Push delivery remains deferred.
## Database Changes
The new forward-only Phase 3 migration extends Profile and BusinessCustomer, creates addresses, business applications/contacts/credit accounts and settlement terms, adds targeted indexes/constraints, and seeds initial settlement configuration.
Pre-check existing `business_customer` users, duplicate registration or partner IDs, invalid/null Profile relationships, legacy Customer address strings requiring manual migration, existing BusinessCustomer records that need partner/approval backfill, and duplicate profiles. No legacy data is silently deleted.
## Tests
New unit tests cover strict profile/business mass assignment, locale/address validation, deactivation confirmation, business application eligibility/duplicate prevention, rejection requirements, and credit validation. Existing Phase 0-2 suites remain mandatory.
## Remaining Known Issues
The migration has not run against staging. Legacy BusinessCustomer rows require an explicit partner/approval backfill before making new columns universally non-null. Real MySQL lock/concurrency behavior, Redis queues, SMTP notifications, S3 documents, and migration constraints need staging tests. Business document-to-application linking and administrative settlement-term CRUD may be added when real operational requirements are known.
## Phase 4 Prerequisites
Back up and restore staging data, complete pre-check/backfill decisions, apply migrations in order, seed/grant Phase 3 permissions, run concurrent application/default-address tests on MySQL, and verify one application approval plus notification flow with non-production integrations.
+103
View File
@@ -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.
@@ -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.
@@ -0,0 +1,76 @@
# ZUMRI Phase 6 Shopping and Checkout
## Objective
Add authenticated shopping state and an atomic, priced, inventory-reserved checkout handoff without creating orders or payments.
## Existing Components Reused
Phase 3 identities/addresses/business accounts, Phase 4 catalogue, Phase 5 pricing/inventory/promotions, Phase 1 permissions, Phase 2 audit, Sequelize, Zod, and cron bootstrap.
## Cart Architecture
One ACTIVE cart per identity; items are unique per variant and owner-scoped. Guest carts were intentionally omitted. Version increments detect state changes and checkout locks mutation.
## Cart Pricing
Cart items are dynamically requoted with retail/business/volume/promotion/coupon precedence. DECIMAL strings use BigInt-scaled arithmetic.
## Wishlist
Unique owner/product entries expose only visible active products and never reserve inventory.
## Inventory Validation
Adding and projecting items checks authoritative aggregate availability. Unavailable items remain explainable.
## Reservation Integration
Cart does not reserve. Checkout extends the Phase 5 service with external transaction composition and reserves sorted variants all-or-nothing.
## Shipping Zones
Normalized country/province/district records provide deterministic destination matching, including international zones.
## Shipping Methods
Configurable methods include delivery estimates and tracking capability.
## Shipping Rates
Scheduled DECIMAL rates support currency, subtotal bands, and configurable free-shipping thresholds.
## International Shipping
Only configured zones are eligible; unsupported destinations fail explicitly.
## Duty/Tax Boundary
Zones declare NONE, ESTIMATED, PAYABLE_ON_DELIVERY, INCLUDED, or UNKNOWN. No customs amount is fabricated. Tax remains zero without authoritative configuration.
## Checkout Architecture
A user-scoped transaction locks the cart, revalidates catalogue/pricing/shipping, reserves inventory, writes snapshots, and locks the cart.
## Checkout Snapshots
Immutable checkout items capture product/variant/SKU, price, discount, total, currency, and pricing source. Owned address and shipping selections are copied as JSON.
## Checkout Totals
Merchandise subtotal minus discounts plus shipping plus configured tax/duty equals grand total. Client totals are prohibited.
## Idempotency
Unique `(userId, idempotencyKey)` plus SHA-256 request fingerprint returns the same session or rejects changed payloads.
## Checkout Expiry
A no-overlap minute reconciliation processes 100 sessions, locks each session, releases reservations idempotently, marks EXPIRED, and restores its cart.
## Coupon Integration
Cart coupons are provisional and revalidated at checkout. Authoritative redemption remains Phase 7.
## Business Checkout
The shared flow revalidates approved ACTIVE business context, MOQ, customer/tier price, and volume tier. Credit is untouched.
## Permissions
Shipping zone/method/rate read/manage permissions protect all administration.
## Audit
Cart, wishlist, checkout, expiry/cancellation, and shipping configuration use stable Phase 2 activity types without full addresses or carts.
## Database Changes
Forward-only migration `20260903060000` creates nine Phase 6 tables and required uniqueness/status-expiry indexes.
## Tests
Phase 6 unit tests cover decimal totals, strict client-total rejection, quantity validation, deterministic fingerprints, safe address snapshots, and shipping permission denial. Existing suites remain green.
## Remaining Known Issues
Real InnoDB concurrent checkout, migration, and multi-instance cron behavior require staging. Shipping weight bands, authoritative tax/duty, and coupon redemption are intentionally deferred.
## Phase 7 Prerequisites
Run legacy table/address/currency/reservation prechecks, migrate staging, seed zones/methods/rates and permissions, and prove concurrent final-stock and duplicate-idempotency behavior with real MySQL connections.
+84
View File
@@ -0,0 +1,84 @@
# ZUMRI Phase 7 Orders and Payments
## Objective
Convert checkout snapshots into durable orders and establish authoritative payment, invoice, refund, and return lifecycles without delivery or rewards.
## Existing Components Reused
Checkout snapshots, inventory reservations, pricing money helpers, coupons, ReferenceNumber, document infrastructure, audit queue, authentication, and permissions.
## Order Architecture
Order/payment/fulfillment states are independent. Commercial and address/shipping details are immutable snapshots.
## Checkout Conversion
The centralized transaction locks an owned READY checkout, verifies reservations, returns any existing order, copies items, and converts checkout/cart.
## Order State Machine
Explicit transition sets reject illegal terminal-state transitions and arbitrary status patches.
## Order Snapshots
Items retain product/variant IDs, reservation key, SKU, names, quantity, unit price, discount, total, currency, and limited metadata.
## Payment Architecture
Payments retain immutable attempts. Only server/provider reconciliation changes authoritative financial state.
## Provider Adapters
Generic payment logic delegates verification/parsing/initiation/refund/status behavior to provider modules.
## PayHere
Merchant secrets are environmental. Browser redirects are non-authoritative; the notify hash, reference, amount, and currency must validate.
## Stripe
An explicit disabled boundary is present. Integration awaits official SDK/configuration rather than accepting unverified callbacks.
## Webhook Verification
Invalid signatures, currencies, amounts, and references are rejected.
## Webhook Idempotency
Unique provider/event records and locked payment/order rows ensure duplicate success cannot repeat side effects.
## Inventory Consumption
Checkout reserves; confirmed online payment consumes. Failed payment does not consume. Unpaid cancellation releases.
## Payment Reconciliation
Webhook persistence supports reconciliation, but remote status polling is deferred until the enabled provider supplies a validated status API.
## Business Credit
Disabled pending an approved immutable credit-ledger/accounting policy; concurrent unsafe balance mutation was not introduced.
## Invoice Architecture
One invoice per order uses transaction-safe ReferenceNumber sequencing. The existing document worker remains the PDF integration point.
## Refund Architecture
Idempotent itemized requests validate remaining quantity and captured amount. Provider processing is separate from request approval.
## Return/RMA Architecture
Owner-scoped requests and explicit admin transitions track physical receipt/condition independently from refunds.
## Exchange Boundary
EXCHANGE is recorded as resolution intent; no replacement order or fulfillment is created.
## Coupon Redemption
Redemption occurs once on confirmed payment while the coupon row is locked. Full-refund restoration is deferred by policy.
## Verified Purchase Reviews
Review creation derives verification only from an actual paid order containing the product.
## Permissions
Orders read/manage/cancel, payments read/manage/refund, invoices read, and returns read/manage were added.
## Audit Events
Order, payment creation, cancellation, refund request, return lifecycle, and admin operations reuse sanitized Phase 2 activity logging.
## Database Changes
Forward-only migration `20260909070000` creates eleven commerce tables with unique references/idempotency and lifecycle indexes.
## Tests
Unit tests cover order transitions, PayHere signature validation, forged notifications, secure return/refund inputs, and configurable return eligibility.
## Remaining Known Issues
Real PayHere, refunds, document generation, migration, webhook delivery, and MySQL concurrency were not exercised. Refund approval/provider completion APIs and reconciliation polling require provider policy/configuration.
## Phase 8 Prerequisites
Apply all migrations on restored staging, seed permissions, configure/validate PayHere sandbox, test concurrent duplicate webhooks, final-stock payment consumption, coupon quotas, invoice jobs, refund callbacks, and return restocking.
Module 16 AI Customer Support Chatbot is intentionally excluded and planned as a separate service.
+87
View File
@@ -0,0 +1,87 @@
# ZUMRI Phase 8 Delivery and Rider Management
## Objective
Add physical delivery and approved-return transportation after the paid Order boundary.
## Existing Components Reused
User/RIDER authentication, Order/OrderItem snapshots, ReturnRequest, ReferenceNumber, Upload, audit, notification foundations, and permissions.
## Shipment Architecture
Orders can have many CUSTOMER_DELIVERY shipments; returns have one RETURN_PICKUP. Shipment owns immutable destination and operational timestamps, never prices or payments.
## Shipment Items
ShipmentItem references OrderItem and supports partial allocation. Locked allocation checks prevent non-cancelled totals exceeding purchases.
## Shipment State Machine
Central legal transitions cover readiness, assignment, pickup, transit, delivery, failure, rescheduling, and cancellation. No generic status patch exists.
## Order Fulfillment Integration
Delivered shipment quantities derive UNFULFILLED, PARTIALLY_FULFILLED, or FULFILLED. Delivery never changes payment or initiates a refund.
## Rider Profile
Operational data attaches one-to-one to an existing RIDER User; authentication and identity remain on User.
## Rider Availability
ACTIVE/INACTIVE/SUSPENDED is separate from AVAILABLE/BUSY/OFFLINE. Assignment locks the rider and checks active capacity.
## Assignment Architecture
Append-preserved assignment rows record assignment, acceptance/rejection, unassignment, and completion. Shipment caches only the current rider.
## Dispatch
The dispatch view queries ready, active, failed, and rescheduled shipments without adding redundant persistence.
## Rider Workflow
Riders see and act only on their own current assignments through explicit legal action endpoints.
## Delivery Events
ShipmentEvent is append-only. Unique event IDs provide retry idempotency and chronological customer timelines.
## Proof of Delivery
Structured proof supports photo, signature, recipient confirmation, and return-pickup photo. Existing private Upload records are ownership/status/MIME checked.
## Failed Deliveries
Failure requires a reason code and increments attempts. It does not refund, cancel the Order, or restock.
## Rescheduling
Admin may schedule a failed delivery with bounded window/reason fields; no calendar optimizer is introduced.
## Customer Tracking
Authenticated ownership is mandatory. Safe projections exclude private rider/location and financial data.
## Location Privacy
Only latest optional coordinates are retained. They are operational data, absent from customer responses and broad audit metadata.
## Return Logistics
Physical transport remains separate from Phase 7 inspection, refund, and inventory decisions.
## Return Pickup
Only approved returns create an idempotent pickup. Delivery marks the request RECEIVED without premature refund/restock.
## Notifications
Existing Phase 2 infrastructure remains the integration boundary. Automated delivery notification fan-out needs durable outbox/worker hardening before production.
## Permissions
Shipment read/create/manage/assign, rider read/manage, dispatch read/manage, proof read, and return-logistics read/manage permissions were added.
## Audit
Creation, assignment, rider actions, cancellation, reschedule, and return pickup use stable Phase 2 activity types without precise location.
## Idempotency
Creation, assignment, and rider transitions use client keys mapped to unique ShipmentEvent IDs. Repeated delivery cannot duplicate proof/event/fulfillment changes.
## Concurrency
Transactions and row locks protect creation allocations, assignment, capacity, transitions, completion, and return-pickup uniqueness. Real multi-connection InnoDB tests remain required.
## Database Changes
Forward-only migration `20260909080000` creates six logistics tables with reference, event, lookup, and uniqueness indexes.
## Tests
Unit tests cover all primary legal/illegal transitions, owner-derived rider authorization, address injection rejection, failure reason validation, and location ranges.
## Remaining Known Issues
Real migrations/concurrency, durable notification fan-out, upload storage, pagination tuning, and assignment race testing require staging. No external courier or real-time GPS system exists.
## Phase 9 Prerequisites
Apply migrations on restored staging, seed permissions/rider profiles, validate real concurrent allocation/assignment/delivery requests, proof uploads, notification idempotency, and return receipt handoff.
Module 16 AI Customer Support Chatbot remains intentionally outside this backend and will be developed separately.
@@ -0,0 +1,90 @@
# ZUMRI Phase 9 Loyalty and Wholesale Completion
## Objective
Connect immutable loyalty rewards and wholesale credit/settlement accounting to existing users, paid orders, reviews, coupons, payments, and invoices.
## Existing Components Reused
User/Profile DOB, BusinessCustomer/Tier/CreditAccount/SettlementTerm, Orders, Payments, Refunds, Invoices, Reviews, Coupons, pricing money helpers, ReferenceNumber, cron, notifications, audit, and RBAC.
## Loyalty Architecture
One account caches balances while an append-only ledger remains authoritative. All mutations lock the account and use unique events.
## Loyalty Account
ACTIVE/SUSPENDED/CLOSED state, spendable/pending/debt balances, lifetime totals, tier, and last activity are maintained transactionally.
## Points Ledger
Integer EARN, REDEEM, EXPIRE, ADJUSTMENT, and REVERSAL entries store source, resulting balance, expiry, and sanitized metadata.
## Earning Rules
Data-driven effective rules support amount-based purchases and fixed review/referral/birthday awards.
## Purchase Rewards
Confirmed PAID processing awards discounted merchandise value only, with integer-safe floor rounding and deterministic Order event identity.
## Refund Reversals
Reversals append history. Insufficient available points produce tracked debt rather than silently discarding value.
## Verified Review Rewards
Only APPROVED verified-purchase reviews earn, once per review.
## Referral Rewards
Opaque random codes prevent self/multiple referral claims; signup alone does not reward, while a qualifying paid Order does.
## Birthday Rewards
Daily bounded reconciliation uses a unique user/calendar-year event, preventing DOB edits or retries from duplicating rewards.
## Points Expiry
Earn rules may assign expiry. Allocation records support earliest-expiring-first redemption and idempotent expiry debits.
## Membership Tiers
Active data-driven LIFETIME_POINTS thresholds select the highest rank and append tier history.
## Reward Catalogue
Scheduled, limited COUPON or MANUAL rewards use integer point costs.
## Reward Redemption
Account/reward/allocation locks enforce balance, stock, per-user limits, FIFO spending, and idempotency.
## Voucher Integration
COUPON rewards reference Phase 5 Coupon and issue customer entitlements rather than creating another coupon engine.
## Loyalty Notifications and Permissions
Phase 2 notification/audit remain the delivery boundaries. Accounts read, points adjustment, management, and referral-read permissions were added.
## Wholesale Architecture
Existing business identity/pricing remain authoritative. Phase 9 adds financial ledgers and period snapshots.
## Business Credit Ledger
Unique immutable events store amount, balance delta/after, currency, Order/Payment references, and event time.
## Credit Utilization
Account row locks prevent limits being exceeded. Cached `usedCredit` was not added to BusinessCreditAccount; the latest ledger balance is authoritative.
## Credit Purchase Integration
An eligible pending wholesale Order is captured, inventory-consumed, paid, and invoiced in one transaction.
## Settlement Terms and Lifecycle
Existing term days derive due dates. DRAFT, ISSUED, PARTIALLY_PAID, PAID, OVERDUE, and CANCELLED use explicit transitions.
## Business Statements, Invoice Integration, Payment Allocation
Settlement items reference source Orders/Invoices/ledger entries. Existing Invoice and Payment are reused; allocation persistence prepares later bank matching.
## Wholesale Dashboard and Analytics
Owner-scoped SQL aggregates provide monthly/lifetime volume, discount totals, credit utilization, next settlement, and recent Orders without loading all history.
## Security, Audit, Idempotency, Concurrency
No IDs select another organization on business routes. Strict schemas reject balance/tier/client financial fields. Unique event keys plus account/settlement/reward locks protect retries and competing writes.
## Database Changes
Forward-only `20260909090000` creates fourteen focused loyalty/wholesale tables. Earlier migrations are unchanged.
## Tests
Policy tests cover integer money conversion, configured caps, referral entropy, strict inputs, terminal settlement state, and permission denial, alongside Phase 0–8 regression.
## Remaining Known Issues
Refund-completion reversal wiring, coupon-entitlement enforcement in the Phase 6 quote path, payment allocation APIs, settlement document generation, notification fan-out, and broader integration/concurrency tests require hardening.
## Phase 10 Prerequisites
Run legacy loyalty/credit/voucher/DOB/reference prechecks, migrate restored staging, seed rules/tiers/rewards/permissions, and test real concurrent redemptions, credit purchases, settlements, webhook rewards, refunds, birthday, and expiry jobs.
Module 16 AI Customer Support Chatbot is intentionally excluded and will be developed as a separate service.
+23
View File
@@ -0,0 +1,23 @@
# Production Checklist
- [ ] Infrastructure sized, isolated, patched, TLS-enabled
- [ ] Secrets generated, injected, access-limited, rotation tested
- [ ] MySQL fresh/upgrade migrations verified on restored data
- [ ] Backup and restore drill meets approved RPO/RTO
- [ ] Redis auth/persistence/loss behavior validated
- [ ] Private S3 upload, signed download, expiry, delete, versioning tested
- [ ] SMTP security and non-customer test recipients verified
- [ ] Stripe and PayHere sandbox flows/webhooks/refunds validated
- [ ] Roles and all Phase 1–11 permissions seeded/reviewed
- [ ] Authorization matrix and IDOR E2E suite approved
- [ ] Dependency/image scan risk accepted; remaining majors planned
- [ ] API, workers, and exactly one cron scheduler deployed
- [ ] Queue retry/backlog/failure alerts tested
- [ ] Health and protected metrics scraped/alerted
- [ ] Nginx proxy trust, size, timeout, webhook, HTTPS behavior tested
- [ ] Frontend/mobile uses `/api/v1` and handles standard errors
- [ ] Full retail/business/payment/delivery/return/loyalty/support E2E passes
- [ ] Concurrency/idempotency tests pass on real InnoDB/Redis
- [ ] Non-destructive production smoke passes
- [ ] Go-live owner, rollback authority and incident contacts assigned
- [ ] Post-go-live reconciliation and monitoring window scheduled
+18
View File
@@ -0,0 +1,18 @@
# Production Runbook
## Deployment
Provision MySQL 8.4, authenticated Redis, private encrypted S3-compatible storage, SMTP, payment-provider credentials, TLS reverse proxy, API replicas, independent worker replicas, and exactly one cron scheduler. Inject secrets; never bake them into images.
1. Validate `.env` with `node server.js` in a sealed staging environment.
2. Take database/object-storage backups and run legacy prechecks.
3. Run `npx sequelize-cli db:migrate` against staging first; verify `SequelizeMeta`, constraints, indexes and counts.
4. Seed required roles/permissions, document types, shipping/configuration, SLA/help data using approved idempotent procedures.
5. Build the Node 22 image, scan it, deploy workers, API, and one `RUN_CRON=true` scheduler.
6. Configure Nginx/TLS/proxy trust; verify `/health/live` and `/health/ready`.
7. Run `npm run smoke`; run the staging-only commerce sequence with designated test identities/provider sandbox.
8. Monitor 5xx rate, readiness, DB/Redis, queue failures/backlog, webhook failures, cron failures, latency, memory and disk/log pressure.
Commands: API `npm start`; worker `node app/workers/index.js`; syntax `npm run check:syntax`; tests `npm test -- --runInBand`; dependencies `npm ls --depth=0`; audit `npm audit`; smoke `npm run smoke`; OpenAPI `npm run validate:openapi`.
Incident basics: stop hazardous writers, preserve logs/request IDs/provider event IDs, assess customer impact, rotate exposed credentials, prefer forward fixes, reconcile payments/inventory/ledgers, and communicate from verified database/provider truth. Roll back application images only when schema compatibility is proven; restore data only through the approved recovery procedure.
+10
View File
@@ -0,0 +1,10 @@
# Queue Operations
| Queue | Producer | Consumer | Idempotency/failure |
|---|---|---|---|
| activity | audit service | activity worker | event-based job ID; database activity record |
| email | email service | email worker | event job ID; retry/backoff; delivery status |
| document | document service | document worker | persisted document state; bounded concurrency |
| log | structured log utility | log worker | redacted payload; retained operationally |
Workers run independently with `node app/workers/index.js`; the API does not consume jobs. Validate Redis authentication, queue prefixes, retries, retention, dead/final failures, and Bull Board access in staging. Alert on failed jobs and sustained backlog. Redis/BullMQ were **IMPLEMENTED_UNVERIFIED** in this environment.
+1
View File
@@ -0,0 +1 @@
{"openapi":"3.1.0","info":{"title":"ZUMRI Backend API","version":"1.0.0","description":"Phase 11 maintained baseline. Module 16 AI support is intentionally excluded."},"servers":[{"url":"/api/v1"}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT"},"cookieAuth":{"type":"apiKey","in":"cookie","name":"access_token"}},"schemas":{"Error":{"type":"object","required":["success","error"],"properties":{"success":{"const":false},"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"requestId":{"type":"string"}}}}}}},"paths":{"/products":{"get":{"summary":"List public products","responses":{"200":{"description":"Product list"}}}},"/help/categories":{"get":{"summary":"List published help categories","responses":{"200":{"description":"Help categories"}}}},"/auth/me":{"get":{"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"responses":{"200":{"description":"Current identity"},"401":{"description":"Unauthenticated"}}}},"/account/overview":{"get":{"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"responses":{"200":{"description":"Self-owned account overview"}}}},"/support/tickets":{"get":{"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"responses":{"200":{"description":"Self-owned tickets"}}},"post":{"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"responses":{"201":{"description":"Ticket created"}}}},"/admin/dashboard/overview":{"get":{"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"responses":{"200":{"description":"Requires analytics.dashboard.read"},"403":{"description":"Permission denied"}}}},"/admin/analytics/sales":{"get":{"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"from","in":"query","schema":{"type":"string","format":"date"}},{"name":"to","in":"query","schema":{"type":"string","format":"date"}}],"responses":{"200":{"description":"Requires analytics.sales.read"}}}},"/recommendations/trending":{"get":{"responses":{"200":{"description":"Deterministic trending products"}}}},"/admin/metrics":{"get":{"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"responses":{"200":{"description":"Prometheus text; requires system.metrics.read"}}}}}}
+44 -72
View File
@@ -1,99 +1,71 @@
/**
* Copyright (c) 2026 Niolla
* All rights reserved.
*
* This source code is proprietary and confidential.
* Unauthorized copying, modification, distribution, or use
* of this file, via any medium, is strictly prohibited.
*/
// app.js
const express = require("express");
const cors = require("cors");
const helmet = require("helmet");
const morgan = require("morgan");
const routes = require("./app/routes");
const cookieParser = require("cookie-parser");
const { bullBoardRouter } = require("./app/config/bullBoard.config");
const path = require("path");
const startAllCrons = require("./cron");
const db = require("./app/models");
// Test DB connection and sync models
(async () => {
try {
await db.sequelize.authenticate();
console.log("Database connected.");
await db.sequelize.sync();
console.log("Tables synced.");
// Start all cron jobs
startAllCrons();
} catch (error) {
console.error("DB error:", error);
}
})();
const routes = require("./app/routes");
const { healthRouter, live } = require("./app/routes/health.routes");
const { bullBoardRouter } = require("./app/config/bullBoard.config");
const { authenticate } = require("./app/middleware/auth.middleware");
const { authorizedAccountType } = require("./app/middleware/permission.middleware");
const requestId = require("./app/middleware/requestId.middleware");
const { generalApiLimiter } = require("./app/middleware/rateLimit.middleware");
const { notFound, errorHandler } = require("./app/middleware/error.middleware");
const metrics = require("./app/services/metrics.service");
const app = express();
const trustProxy = Number(process.env.TRUST_PROXY || 0);
if (trustProxy > 0) app.set("trust proxy", trustProxy);
app.disable("x-powered-by");
app.use(requestId);
app.use(metrics.middleware);
app.use(helmet({
contentSecurityPolicy: false,
hsts: process.env.NODE_ENV === "production" ? undefined : false,
}));
app.use(cookieParser());
// CORS configuration
const corsOptions = {
origin: process.env.FRONTEND_URL || "https://oceanic-demo.vercel.app",
app.use(cors({
origin: process.env.FRONTEND_URL,
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
allowedHeaders: ["Content-Type", "Authorization", "X-Request-ID"],
exposedHeaders: ["X-Request-ID"],
credentials: true,
};
app.use(cors(corsOptions));
app.options(/.*/, cors(corsOptions));
}));
app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || "1mb" }));
app.use(express.urlencoded({ extended: false, limit: process.env.JSON_BODY_LIMIT || "1mb" }));
app.use(express.json());
app.use(morgan("dev"));
morgan.token("request-id", (req) => req.id);
app.use(morgan(process.env.NODE_ENV === "production" ? ':remote-addr - :method :url :status :response-time ms req-id=:request-id' : "dev"));
app.get("/health", (req, res) => {
let dbStatus = "N/A";
let emailStatus = "N/A";
let redisStatus = "N/A";
app.get("/health", live);
app.use("/health", healthRouter);
db.sequelize
.authenticate()
.then(() => {
console.log("DB connection successful.");
dbStatus = "OK";
})
.catch((err) => {
console.error("DB connection error:", err);
res.status(500).send("Internal Server Error");
});
// Keep the legacy path while all new clients migrate to the versioned path.
app.use("/api", generalApiLimiter, routes);
app.use("/api/v1", generalApiLimiter, routes);
emailStatus = "OK";
redisStatus = "OK";
res.send({
status: "Online ✅",
database: dbStatus,
emailService: emailStatus,
redis: redisStatus,
});
});
app.use("/api", routes);
app.use(
"/Documentation",
(req, res, next) => {
if (req.path.endsWith(".md")) {
return res.status(403).send("Forbidden");
}
next();
},
(req, res, next) => req.path.endsWith(".md") ? res.status(403).send("Forbidden") : next(),
express.static(path.join(__dirname, "Documentation")),
);
app.use("/admin/queues", bullBoardRouter);
app.use(
"/admin/queues",
authenticate,
authorizedAccountType(["admin", "superadmin"]),
bullBoardRouter,
);
app.use(notFound);
app.use(errorHandler);
module.exports = app;
+5 -2
View File
@@ -14,16 +14,19 @@ const { ExpressAdapter } = require("@bull-board/express");
const { BullMQAdapter } = require("@bull-board/api/bullMQAdapter");
const activityQueue = require("../queues/activity.queue");
const documentQueue = require("../queues/document.queue");
const logQueue = require("../queues/log.queue");
const emailQueue = require("../queues/email.queue");
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath("/admin/queues");
const { addQueue, removeQueue, setQueues, replaceQueues } =
createBullBoard({
queues: [new BullMQAdapter(activityQueue)],
queues: [activityQueue, documentQueue, logQueue, emailQueue].map((queue) => new BullMQAdapter(queue)),
serverAdapter,
});
module.exports = {
bullBoardRouter: serverAdapter.getRouter(),
};
};
+9
View File
@@ -0,0 +1,9 @@
const db = require("../models");
const initializeDatabase = async () => db.sequelize.authenticate();
const checkDatabase = async () => {
try { await db.sequelize.authenticate(); return true; } catch (_error) { return false; }
};
const closeDatabase = async () => db.sequelize.close();
module.exports = { initializeDatabase, checkDatabase, closeDatabase };
+4 -4
View File
@@ -12,10 +12,10 @@
require("dotenv").config();
module.exports = {
HOST: process.env.DB_HOST || "localhost",
USER: process.env.DB_USER || "root",
PASSWORD: process.env.DB_PASSWORD || "",
DB: process.env.DB_NAME || "oceanic-db",
HOST: process.env.DB_HOST,
USER: process.env.DB_USER,
PASSWORD: process.env.DB_PASSWORD,
DB: process.env.DB_NAME,
PORT: process.env.DB_PORT || 3306,
DIALECT: "mysql",
+87
View File
@@ -0,0 +1,87 @@
const { z } = require("zod");
const booleanString = z.enum(["true", "false"]).default("false").transform((value) => value === "true");
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().min(1).max(65535).default(3070),
DB_HOST: z.string().min(1),
DB_PORT: z.coerce.number().int().min(1).max(65535).default(3306),
DB_NAME: z.string().min(1),
DB_USER: z.string().min(1),
DB_PASSWORD: z.string(),
JWT_SECRET: z.string().min(32, "JWT_SECRET must contain at least 32 characters"),
JWT_ISSUER: z.string().min(1).default("zumri-api"),
JWT_AUDIENCE: z.string().min(1).default("zumri-clients"),
ACCESS_TOKEN_TTL: z.string().default("15m"),
REFRESH_TOKEN_TTL_DAYS: z.coerce.number().int().positive().default(7),
REMEMBER_ME_REFRESH_TOKEN_TTL_DAYS: z.coerce.number().int().positive().default(30),
LOGIN_OTP_TTL_SECONDS: z.coerce.number().int().positive().default(900),
LOGIN_OTP_MAX_ATTEMPTS: z.coerce.number().int().min(1).max(10).default(5),
REDIS_HOST: z.string().min(1),
REDIS_PORT: z.coerce.number().int().min(1).max(65535).default(6379),
REDIS_PASSWORD: z.string().optional(),
FRONTEND_URL: z.string().url(),
TRUST_PROXY: z.coerce.number().int().min(0).max(10).default(0),
JSON_BODY_LIMIT: z.string().default("1mb"),
API_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(900000),
API_RATE_LIMIT_MAX: z.coerce.number().int().positive().default(300),
SENSITIVE_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(900000),
SENSITIVE_RATE_LIMIT_MAX: z.coerce.number().int().positive().default(20),
RUN_CRON: booleanString,
ENABLE_MAIL: booleanString,
ENABLE_S3: booleanString,
SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().positive().default(10000),
CACHE: booleanString,
MAIL_HOST: z.string().min(1).optional(), MAIL_PORT: z.coerce.number().int().positive().optional(),
MAIL_SECURE: z.enum(["true", "false"]).optional(), MAIL_USER: z.string().optional(),
MAIL_PASS: z.string().optional(), MAIL_FROM: z.string().optional(),
AWS_REGION: z.string().optional(), AWS_ACCESS_KEY_ID: z.string().optional(),
AWS_SECRET_ACCESS_KEY: z.string().optional(), AWS_S3_BUCKET_NAME: z.string().optional(),
S3_ENDPOINT: z.string().url().optional(), S3_FORCE_PATH_STYLE: booleanString,
S3_SIGNED_URL_TTL_SECONDS: z.coerce.number().int().min(60).max(86400).default(900),
S3_MAX_UPLOAD_BYTES: z.coerce.number().int().positive().default(5242880),
EMAIL_QUEUE_CONCURRENCY: z.coerce.number().int().positive().default(5),
DOCUMENT_QUEUE_CONCURRENCY: z.coerce.number().int().positive().default(2),
NOTIFICATION_RETENTION_DAYS: z.coerce.number().int().positive().default(90),
INVENTORY_RESERVATION_TTL_MINUTES: z.coerce.number().int().positive().default(15),
CHECKOUT_TTL_MINUTES: z.coerce.number().int().positive().default(15),
CART_MAX_ITEM_QUANTITY: z.coerce.number().int().positive().default(100),
RETURN_WINDOW_DAYS: z.coerce.number().int().positive().default(30),
PAYHERE_MERCHANT_ID: z.string().optional(), PAYHERE_MERCHANT_SECRET: z.string().optional(),
PAYHERE_NOTIFY_URL: z.string().url().optional(), PAYHERE_RETURN_URL: z.string().url().optional(), PAYHERE_CANCEL_URL: z.string().url().optional(),
LOYALTY_RECONCILIATION_BATCH_SIZE: z.coerce.number().int().positive().max(1000).default(100),
SUPPORT_SLA_RECONCILIATION_BATCH_SIZE: z.coerce.number().int().positive().max(1000).default(100),
RECOMMENDATION_EVENT_RETENTION_DAYS: z.coerce.number().int().positive().default(90),
LOG_RETENTION_DAYS: z.coerce.number().int().positive().default(30),
DOCS_USER: z.string().optional(), DOCS_PASS: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(), APPLE_CLIENT_ID: z.string().optional(),
}).superRefine((env, context) => {
const requireFeature = (enabled, names) => {
if (!enabled) return;
for (const name of names) {
if (!env[name]) context.addIssue({ code: "custom", path: [name], message: `${name} is required when enabled` });
}
};
requireFeature(env.ENABLE_MAIL, ["MAIL_HOST", "MAIL_PORT", "MAIL_USER", "MAIL_PASS", "MAIL_FROM"]);
requireFeature(env.ENABLE_S3, ["AWS_REGION", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_S3_BUCKET_NAME"]);
});
let validatedEnv;
const validateEnvironment = (source = process.env) => {
const result = envSchema.safeParse(source);
if (!result.success) {
const names = [...new Set(result.error.issues.map((issue) => issue.path.join(".") || "environment"))];
throw new Error(`Invalid environment configuration: ${names.join(", ")}`);
}
validatedEnv = result.data;
return validatedEnv;
};
const getEnvironment = () => validatedEnv || validateEnvironment();
const getOptionalFeatureStatus = (env = process.env) => ({
mail: Boolean(env.MAIL_HOST && env.MAIL_PORT && env.MAIL_USER && env.MAIL_PASS && env.MAIL_FROM),
s3: Boolean(env.AWS_REGION && env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY && env.AWS_S3_BUCKET_NAME),
docs: Boolean(env.DOCS_USER && env.DOCS_PASS),
});
module.exports = { validateEnvironment, getEnvironment, getOptionalFeatureStatus };
+10
View File
@@ -0,0 +1,10 @@
const activityQueue = require("../queues/activity.queue");
const documentQueue = require("../queues/document.queue");
const logQueue = require("../queues/log.queue");
const emailQueue = require("../queues/email.queue");
const closeQueues = async () => {
await Promise.allSettled([activityQueue.close(), documentQueue.close(), logQueue.close(), emailQueue.close()]);
};
module.exports = { closeQueues };
+2 -1
View File
@@ -11,13 +11,14 @@
const { Redis } = require("ioredis");
const createRedisConnection = () => {
const createRedisConnection = (options = {}) => {
const redis = new Redis({
host: process.env.REDIS_HOST || "redis",
port: process.env.REDIS_PORT || 6379,
password: process.env.REDIS_PASSWORD,
maxRetriesPerRequest: null,
enableReadyCheck: false,
lazyConnect: options.lazyConnect ?? true,
});
// Connection events
+14
View File
@@ -0,0 +1,14 @@
const redis = require("./redisClient");
const initializeRedis = async () => {
if (redis.status === "wait") await redis.connect();
if (redis.status !== "ready") await redis.ping();
};
const checkRedis = async () => {
try { return (await redis.ping()) === "PONG"; } catch (_error) { return false; }
};
const closeRedis = async () => {
if (redis.status !== "end") await redis.quit();
};
module.exports = { initializeRedis, checkRedis, closeRedis };
+2 -2
View File
@@ -12,6 +12,6 @@
const createRedisConnection = require("./redis.config");
const redis = createRedisConnection();
const redis = createRedisConnection({ lazyConnect: true });
module.exports = redis;
module.exports = redis;
+12 -21
View File
@@ -1,22 +1,13 @@
/**
* Copyright (c) 2026 Niolla
* All rights reserved.
*
* This source code is proprietary and confidential.
* Unauthorized copying, modification, distribution, or use
* of this file, via any medium, is strictly prohibited.
*/
const { S3Client } = require("@aws-sdk/client-s3");
// app/config/s3.config.js
// const { S3Client } = require("@aws-sdk/client-s3");
// const s3 = new S3Client({
// region: process.env.AWS_REGION,
// credentials: {
// accessKeyId: process.env.AWS_ACCESS_KEY_ID,
// secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
// },
// });
// module.exports = s3;
module.exports = process.env.ENABLE_S3 === "true"
? new S3Client({
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
...(process.env.S3_ENDPOINT ? { endpoint: process.env.S3_ENDPOINT } : {}),
forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true",
})
: { send: async () => { throw new Error("S3 functionality is not enabled"); } };
+17
View File
@@ -0,0 +1,17 @@
require("dotenv").config();
const required = ["DB_HOST", "DB_NAME", "DB_USER"];
const missing = required.filter((name) => !process.env[name]);
if (missing.length) throw new Error(`Missing migration environment variables: ${missing.join(", ")}`);
const configuration = {
username: process.env.DB_USER,
password: process.env.DB_PASSWORD || "",
database: process.env.DB_NAME,
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT || 3306),
dialect: "mysql",
logging: false,
};
module.exports = { development: configuration, test: configuration, production: configuration };
+15
View File
@@ -0,0 +1,15 @@
const ACCOUNT_TYPES = Object.freeze({
SUPER_ADMIN: "superadmin",
ADMIN: "admin",
MANAGER: "manager",
CUSTOMER: "customer",
BUSINESS_CUSTOMER: "business_customer",
RIDER: "rider",
SUPPORT_AGENT: "support_agent",
});
const PRIVILEGED_ACCOUNT_TYPES = Object.freeze([
ACCOUNT_TYPES.SUPER_ADMIN, ACCOUNT_TYPES.ADMIN, ACCOUNT_TYPES.MANAGER, ACCOUNT_TYPES.SUPPORT_AGENT,
]);
module.exports = { ACCOUNT_TYPES, PRIVILEGED_ACCOUNT_TYPES, ACCOUNT_TYPE_VALUES: Object.values(ACCOUNT_TYPES) };
+1
View File
@@ -0,0 +1 @@
const SUPPORTED_LOCALES=Object.freeze(["en","si","ta"]);const DEFAULT_LOCALE="en";module.exports={SUPPORTED_LOCALES,DEFAULT_LOCALE};
+19 -1
View File
@@ -13,4 +13,22 @@
module.exports = {
FINANCE_BASIS: "finance.basis",
PRECOST: "precost.precost",
};
MEDIA_READ: "media.read", MEDIA_UPLOAD: "media.upload", MEDIA_DELETE: "media.delete",
DOCUMENTS_READ: "documents.read", DOCUMENTS_CREATE: "documents.create", DOCUMENTS_DELETE: "documents.delete",
NOTIFICATIONS_MANAGE: "notifications.manage", NOTIFICATIONS_READ: "notifications.read",
AUDIT_READ: "audit.read", QUEUES_READ: "queues.read",
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",
SHIPPING_ZONES_READ:"shipping.zones.read",SHIPPING_ZONES_MANAGE:"shipping.zones.manage",SHIPPING_METHODS_READ:"shipping.methods.read",SHIPPING_METHODS_MANAGE:"shipping.methods.manage",SHIPPING_RATES_READ:"shipping.rates.read",SHIPPING_RATES_MANAGE:"shipping.rates.manage",
ORDERS_READ:"orders.read",ORDERS_MANAGE:"orders.manage",ORDERS_CANCEL:"orders.cancel",PAYMENTS_READ:"payments.read",PAYMENTS_MANAGE:"payments.manage",PAYMENTS_REFUND:"payments.refund",INVOICES_READ:"invoices.read",RETURNS_READ:"returns.read",RETURNS_MANAGE:"returns.manage",
SHIPMENTS_READ:"shipments.read",SHIPMENTS_CREATE:"shipments.create",SHIPMENTS_MANAGE:"shipments.manage",SHIPMENTS_ASSIGN:"shipments.assign",RIDERS_READ:"riders.read",RIDERS_MANAGE:"riders.manage",DISPATCH_READ:"dispatch.read",DISPATCH_MANAGE:"dispatch.manage",DELIVERY_PROOF_READ:"delivery.proof.read",RETURNS_LOGISTICS_READ:"returns.logistics.read",RETURNS_LOGISTICS_MANAGE:"returns.logistics.manage",
LOYALTY_ACCOUNTS_READ:"loyalty.accounts.read",LOYALTY_POINTS_ADJUST:"loyalty.points.adjust",LOYALTY_MANAGE:"loyalty.manage",LOYALTY_REFERRALS_READ:"loyalty.referrals.read",WHOLESALE_CREDIT_READ:"wholesale.credit.read",WHOLESALE_CREDIT_MANAGE:"wholesale.credit.manage",WHOLESALE_SETTLEMENTS_READ:"wholesale.settlements.read",WHOLESALE_SETTLEMENTS_MANAGE:"wholesale.settlements.manage",WHOLESALE_ANALYTICS_READ:"wholesale.analytics.read",
SUPPORT_TICKETS_READ:"support.tickets.read",SUPPORT_TICKETS_ASSIGN:"support.tickets.assign",SUPPORT_TICKETS_REPLY:"support.tickets.reply",SUPPORT_TICKETS_STATUS:"support.tickets.status",SUPPORT_TICKETS_PRIORITY:"support.tickets.priority",SUPPORT_TICKETS_INTERNAL_NOTES:"support.tickets.internal_notes",SUPPORT_TICKETS_ESCALATE:"support.tickets.escalate",SUPPORT_CATEGORIES_MANAGE:"support.categories.manage",SUPPORT_SLA_MANAGE:"support.sla.manage",HELP_READ:"help.read",HELP_MANAGE:"help.manage",HELP_PUBLISH:"help.publish",NEWSLETTER_SUBSCRIBERS_READ:"newsletter.subscribers.read",NEWSLETTER_SUBSCRIBERS_EXPORT:"newsletter.subscribers.export",NEWSLETTER_SUBSCRIBERS_MANAGE:"newsletter.subscribers.manage",RECOMMENDATIONS_READ:"recommendations.read",RECOMMENDATIONS_MANAGE:"recommendations.manage",
ANALYTICS_DASHBOARD_READ:"analytics.dashboard.read",ANALYTICS_SALES_READ:"analytics.sales.read",ANALYTICS_ORDERS_READ:"analytics.orders.read",ANALYTICS_CUSTOMERS_READ:"analytics.customers.read",ANALYTICS_PRODUCTS_READ:"analytics.products.read",ANALYTICS_INVENTORY_READ:"analytics.inventory.read",ANALYTICS_PAYMENTS_READ:"analytics.payments.read",ANALYTICS_DELIVERY_READ:"analytics.delivery.read",ANALYTICS_LOYALTY_READ:"analytics.loyalty.read",ANALYTICS_WHOLESALE_READ:"analytics.wholesale.read",ANALYTICS_SUPPORT_READ:"analytics.support.read",ANALYTICS_RECOMMENDATIONS_READ:"analytics.recommendations.read",
SYSTEM_METRICS_READ:"system.metrics.read",
};
@@ -0,0 +1 @@
const db=require("../models");exports.overview=async(req,res,next)=>{try{const userId=req.user.id,[recentOrders,orderCount,wishlistCount,defaultAddress,loyalty,openTickets,vouchers]=await Promise.all([db.Order.findAll({where:{user_id:userId},attributes:["id","order_number","status","payment_status","grand_total","currency","placed_at"],order:[["placed_at","DESC"]],limit:5}),db.Order.count({where:{user_id:userId}}),db.WishlistItem.count({where:{user_id:userId}}),db.Address.findOne({where:{user_id:userId,is_default:true},attributes:{exclude:["user_id"]}}),db.LoyaltyAccount.findOne({where:{user_id:userId},attributes:["available_points","current_tier_id","lifetime_points_earned"]}),db.SupportTicket.count({where:{customer_user_id:userId,status:["OPEN","ASSIGNED","WAITING_FOR_CUSTOMER","WAITING_FOR_SUPPORT"]}}),db.CustomerCouponEntitlement.count({where:{user_id:userId,status:"ACTIVE"}})]);res.json({success:true,data:{recentOrders,orderCount,wishlistCount,defaultAddress,loyalty:loyalty&&{availablePoints:loyalty.available_points,lifetimePointsEarned:loyalty.lifetime_points_earned,tierId:loyalty.current_tier_id},availableVoucherCount:vouchers,openSupportTicketCount:openTickets}});}catch(e){next(e);}};
+8
View File
@@ -0,0 +1,8 @@
const crypto=require("crypto"); const db=require("../models"); const {logActivity}=require("../services/activity.service");
const map=(b)=>({label:b.label,recipient_name:b.recipientName,phone_number:b.phoneNumber,address_line_1:b.addressLine1,address_line_2:b.addressLine2,city:b.city,district:b.district,province:b.province,postal_code:b.postalCode,country_code:b.countryCode,is_default_shipping:b.isDefaultShipping,is_default_billing:b.isDefaultBilling});
const setDefaults=async(userId,id,b,t)=>{if(b.isDefaultShipping)await db.Address.update({is_default_shipping:false},{where:{user_id:userId},transaction:t});if(b.isDefaultBilling)await db.Address.update({is_default_billing:false},{where:{user_id:userId},transaction:t});};
exports.list=async(req,res,next)=>{try{return res.json({success:true,data:await db.Address.findAll({where:{user_id:req.user.id},order:[["createdAt","ASC"]]})});}catch(e){return next(e);}};
exports.get=async(req,res,next)=>{try{const row=await db.Address.findOne({where:{id:req.params.id,user_id:req.user.id}});if(!row)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Address not found"}});return res.json({success:true,data:row});}catch(e){return next(e);}};
exports.create=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{await db.User.findByPk(req.user.id,{transaction:t,lock:t.LOCK.UPDATE});await setDefaults(req.user.id,null,req.body,t);row=await db.Address.create({id:crypto.randomUUID(),user_id:req.user.id,...map(req.body)},{transaction:t});});await logActivity({user:req.user,type:"ADDRESS_CREATED",module:"Customer",description:"Customer address created",targetType:"ADDRESS",targetId:row.id,requestId:req.id});return res.status(201).json({success:true,data:row});}catch(e){return next(e);}};
exports.update=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{await db.User.findByPk(req.user.id,{transaction:t,lock:t.LOCK.UPDATE});row=await db.Address.findOne({where:{id:req.params.id,user_id:req.user.id},transaction:t,lock:t.LOCK.UPDATE});if(!row)throw Object.assign(new Error("Address not found"),{status:404,code:"NOT_FOUND"});await setDefaults(req.user.id,row.id,req.body,t);await row.update(map(req.body),{transaction:t});});await logActivity({user:req.user,type:"ADDRESS_UPDATED",module:"Customer",description:"Customer address updated",targetType:"ADDRESS",targetId:row.id,requestId:req.id});return res.json({success:true,data:row});}catch(e){return next(e);}};
exports.remove=async(req,res,next)=>{try{const count=await db.Address.destroy({where:{id:req.params.id,user_id:req.user.id}});if(!count)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Address not found"}});await logActivity({user:req.user,type:"ADDRESS_DELETED",module:"Customer",description:"Customer address deleted",targetType:"ADDRESS",targetId:req.params.id,requestId:req.id});return res.status(204).end();}catch(e){return next(e);}};
+24
View File
@@ -0,0 +1,24 @@
const db = require("../models");
const { ACCOUNT_TYPE_VALUES } = require("../constants/accountTypes");
const { revokeAllUserSessions } = require("../services/auth/session.service");
const { logActivity } = require("../services/activity.service");
const updateSecurityField = (field) => async (req, res, next) => {
try {
const value = req.body[field];
if (field === "accountType" && !ACCOUNT_TYPE_VALUES.includes(value)) return res.status(400).json({ success: false, error: { code: "INVALID_ACCOUNT_TYPE", message: "Invalid account type" } });
if (field === "accountStatus" && !["PENDING_VERIFICATION", "ACTIVE", "SUSPENDED", "DEACTIVATED"].includes(value)) return res.status(400).json({ success: false, error: { code: "INVALID_ACCOUNT_STATUS", message: "Invalid account status" } });
if (req.params.id === req.user.id) return res.status(400).json({ success: false, error: { code: "SELF_SECURITY_CHANGE_DENIED", message: "Security-sensitive self changes are not allowed" } });
let target; let previous;
await db.sequelize.transaction(async (transaction) => {
target = await db.User.findByPk(req.params.id, { transaction, lock: transaction.LOCK.UPDATE });
if (!target) throw Object.assign(new Error("User not found"), { status: 404, code: "USER_NOT_FOUND" });
previous = target[field]; target[field] = value; target.tokenVersion += 1; await target.save({ transaction });
await revokeAllUserSessions(target.id, `ADMIN_${field.toUpperCase()}_CHANGE`, transaction);
});
await logActivity({ user: req.user, description: `${field} changed for ${target.id} from ${previous} to ${value}; reason: ${req.body.reason || "not supplied"}; request: ${req.id}`, type: field === "accountStatus" ? "ACCOUNT_STATUS_CHANGED" : "ACCOUNT_TYPE_CHANGED", module: "Identity Administration" });
res.json({ success: true, data: { id: target.id, [field]: target[field] } });
} catch (error) { next(error); }
};
exports.updateStatus = updateSecurityField("accountStatus");
exports.updateAccountType = updateSecurityField("accountType");
+1
View File
@@ -0,0 +1 @@
const a=require("../services/analytics/analytics.service");const run=fn=>async(req,res,next)=>{try{res.json({success:true,data:await fn(req.query)});}catch(e){next(e);}};exports.overview=run(a.overview);exports.sales=run(a.sales);exports.orders=run(a.orders);exports.customers=run(a.customers);exports.products=run(a.products);exports.inventory=run(a.inventory);exports.payments=run(a.payments);exports.delivery=run(a.delivery);exports.loyalty=run(a.loyalty);exports.support=run(a.support);exports.recommendations=run(a.recommendations);
+159 -400
View File
@@ -1,433 +1,192 @@
/**
* Copyright (c) 2026 Niolla
* All rights reserved.
*
* This source code is proprietary and confidential.
* Unauthorized copying, modification, distribution, or use
* of this file, via any medium, is strictly prohibited.
*/
// app/controllers/auth.controller.js
const { checkPassword, hashPassword } = require("../utils/hashPassword.util");
const { sendMail } = require("../utils/mail.util");
const {
validatePassword,
} = require("../utils/validation/validatePassword.util");
const { validateEmail } = require("../utils/validation/validateEmail.util");
const { generateOTP, validateOTP } = require("../utils/otp.util");
const { getCachedUser, clearUserCache } = require("../utils/cache.util");
const { generateToken } = require("../utils/jwt.util");
const {
createRefreshSession,
validateRefreshSession,
deleteRefreshSession,
deleteAllUserSessions,
} = require("../utils/refreshSession.util");
const {
createPasswordReset,
verifyPasswordResetToken,
deletePasswordReset,
sendPasswordResetEmail,
sendPasswordChangedEmail,
} = require("../utils/passwordReset.utill");
const db = require("../models");
const { log } = require("../utils/consoleLog.utill");
const authService = require("../services/auth/auth.service");
const sessionService = require("../services/auth/session.service");
const { hashPassword, checkPassword } = require("../utils/hashPassword.util");
const { createPasswordReset, consumePasswordResetToken, sendPasswordResetEmail } = require("../utils/passwordReset.utill");
const { createEmailVerification, consumeEmailVerificationToken, sendVerificationEmail } = require("../utils/emailVerification.util");
const { sendPasswordChanged } = require("../services/auth/email.service");
const { logActivity } = require("../services/activity.service");
const { verifyToken } = require("../utils/jwt.util");
const appName = process.env.APP_NAME || "Niolla";
const cookieOptions = (maxAge, path = "/") => ({ httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: process.env.NODE_ENV === "production" ? "none" : "lax", maxAge, path });
const clearCookies = (res) => {
res.clearCookie("access_token", cookieOptions(undefined, "/"));
res.clearCookie("refresh_token", cookieOptions(undefined, "/api"));
};
const projectUser = (user) => ({ id: user.id, firstName: user.firstName, lastName: user.lastName, email: user.email, accountType: user.accountType, accountStatus: user.accountStatus, emailVerified: Boolean(user.emailVerifiedAt) });
const deliverTokens = (req, res, result, clientType = "WEB") => {
const accessMs = 15 * 60 * 1000;
const refreshMs = Math.max(0, new Date(result.refreshExpiresAt).getTime() - Date.now());
if (clientType === "WEB") {
res.cookie("access_token", result.accessToken, cookieOptions(accessMs));
res.cookie("refresh_token", result.refreshToken, cookieOptions(refreshMs, "/api"));
return { accessToken: result.accessToken };
}
return { accessToken: result.accessToken, refreshToken: result.refreshToken, refreshExpiresAt: result.refreshExpiresAt };
};
const User = db.User;
// Login Step 1: Request OTP
exports.loginReq = async (req, res) => {
exports.login = async (req, res, next) => {
try {
const { email, password } = req.body;
const result = await authService.beginPasswordLogin(req.validated.body, req);
res.status(202).json({ success: true, data: result, message: "If the credentials are valid, a verification code has been sent" });
} catch (error) { next(error); }
};
exports.loginReq = exports.login;
const user = await getCachedUser(email);
if (!user) {
return res
.status(404)
.send({ success: false, message: "User Not Found" });
}
exports.verifyOtp = async (req, res, next) => {
try {
const input = req.validated.body;
const result = await authService.completeOtpLogin(input, req);
const tokens = deliverTokens(req, res, result, input.clientType);
await logActivity({ user: result.user, description: "Authentication session created", type: "LOGIN_SUCCEEDED", module: "Authentication" });
res.json({ success: true, data: { user: projectUser(result.user), ...tokens } });
} catch (error) { next(error); }
};
const passwordIsValid = await checkPassword(password, user.password);
if (!passwordIsValid) {
return res
.status(401)
.send({ success: false, message: "Invalid Password" });
}
const otp = generateOTP(email);
await sendMail({
to: email,
subject: `OTP for Your ${appName} Account`,
templateName: "otp",
templateVars: {
firstName: user.firstName,
otp: otp,
},
text: `Hello ${user.firstName}, your otp is ${otp}`,
});
log(`OTP for ${email}: ${otp}`);
log(`OTP sent to ${email} successfully.`);
res.status(201).send({ success: true, message: "OTP Sent Successfully" });
exports.refreshToken = async (req, res, next) => {
try {
const input = req.validated.body;
const token = input.refreshToken || req.cookies?.refresh_token;
if (!token) throw Object.assign(new Error("Invalid session"), { status: 401, code: "INVALID_SESSION" });
const result = await authService.refresh(token, req);
res.json({ success: true, data: deliverTokens(req, res, result, input.clientType) });
} catch (error) {
log("Error occurred while sending OTP:", error);
res.status(500).send({ success: false, message: error.message });
clearCookies(res);
if (error.code === "REFRESH_TOKEN_REUSE") console.warn(`[${req.id}] Refresh token replay detected; token family revoked`);
next(Object.assign(new Error("Invalid session"), { status: 401, code: "INVALID_SESSION" }));
}
};
// Login Step 2: Verify OTP and issue JWT
exports.login = async (req, res) => {
exports.logout = async (req, res, next) => {
try {
const { email, otp } = req.body;
if (!email || !otp) {
return res
.status(400)
.send({ success: false, message: "Email and OTP are required" });
const token = req.body?.refreshToken || req.cookies?.refresh_token;
let id = sessionService.tokenId(token);
if (!id) {
const accessToken = req.cookies?.access_token || (req.headers.authorization?.startsWith("Bearer ") ? req.headers.authorization.slice(7) : null);
try { id = accessToken ? verifyToken(accessToken).sid : null; } catch (_error) { id = null; }
}
if (id) await sessionService.revokeSession(id, "LOGOUT");
clearCookies(res);
res.json({ success: true, message: "Logged out successfully" });
} catch (error) { next(error); }
};
const user = await getCachedUser(email);
exports.logoutAll = async (req, res, next) => {
try {
await db.sequelize.transaction(async (transaction) => {
const user = await db.User.findByPk(req.user.id, { transaction, lock: transaction.LOCK.UPDATE });
user.tokenVersion += 1; await user.save({ transaction });
await sessionService.revokeAllUserSessions(user.id, "LOGOUT_ALL", transaction);
});
clearCookies(res);
await logActivity({ user: req.user, description: "All authentication sessions revoked", type: "LOGOUT_ALL", module: "Authentication" });
res.json({ success: true, message: "Logged out from all devices" });
} catch (error) { next(error); }
};
if (!user) {
return res
.status(404)
.send({ success: false, message: "User Not Found" });
exports.me = async (req, res, next) => {
try {
const user = await db.User.findByPk(req.user.id, { attributes: { exclude: ["password", "tokenVersion", "passwordChangedAt"] }, include: [{ model: db.Profile, as: "profile" }] });
res.json({ success: true, data: { ...projectUser(user), profile: user.profile, effectivePermissions: req.user.permissions || [] } });
} catch (error) { next(error); }
};
exports.forgotPassword = async (req, res, next) => {
const message = "If an account exists for this email, a password reset link has been sent";
try {
const user = await db.User.findOne({ where: { email: req.validated.body.email } });
if (user) {
const token = await createPasswordReset(user.id);
await sendPasswordResetEmail(user.email, user.firstName, token);
}
if (user.accountStatus !== "ACTIVE") {
return res.status(403).send({
success: false,
message: "Account is not active",
});
}
const isValidOTP = validateOTP(email, String(otp));
if (!isValidOTP) {
return res
.status(401)
.send({ success: false, message: "Invalid or Expired OTP" });
}
// Generate JWT token
const token = generateToken({
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
accountType: user.accountType,
});
const { refreshToken } = createRefreshSession(user.id);
// 3. Set JWT as HttpOnly cookie
res.cookie("access_token", token, {
httpOnly: true, // JS cannot access
secure: process.env.NODE_ENV === "production", // HTTPS only in prod
sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax",
maxAge: 15 * 60 * 1000, // 1 day
});
res.cookie("refresh_token", refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax",
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
log(`JWT issued for ${email}`);
clearUserCache(email);
res.status(200).send({
success: true,
message: "Login Successful",
data: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
accountType: user.accountType,
accessToken: token,
},
});
res.json({ success: true, message });
} catch (error) {
log("Error occurred during login:", error);
res.status(500).send({ success: false, message: error.message });
console.error(`[${req.id}] Password reset request failed`, { name: error.name, message: error.message });
res.json({ success: true, message });
}
};
exports.refreshToken = async (req, res) => {
exports.resetPassword = async (req, res, next) => {
try {
const refreshToken = req.cookies?.refresh_token;
const input = req.validated.body;
const userId = await consumePasswordResetToken(input.token);
if (!userId) throw Object.assign(new Error("Reset token is invalid or expired"), { status: 400, code: "INVALID_RESET_TOKEN" });
let changedUser;
await db.sequelize.transaction(async (transaction) => {
const user = await db.User.findByPk(userId, { transaction, lock: transaction.LOCK.UPDATE });
if (!user || (user.password && await checkPassword(input.newPassword, user.password))) throw Object.assign(new Error("Invalid password change"), { status: 400, code: "INVALID_PASSWORD_CHANGE" });
user.password = await hashPassword(input.newPassword); user.passwordChangedAt = new Date(); user.tokenVersion += 1;
await user.save({ transaction }); await sessionService.revokeAllUserSessions(user.id, "PASSWORD_RESET", transaction); changedUser = user;
});
sendPasswordChanged(changedUser).catch(() => {});
await logActivity({ user: changedUser, description: "Password reset and sessions revoked", type: "PASSWORD_RESET", module: "Authentication" });
res.json({ success: true, message: "Password reset successfully. Please login again" });
} catch (error) { next(error); }
};
if (!refreshToken) {
return res.status(401).send({
success: false,
message: "Refresh token is required",
});
exports.changePassword = async (req, res, next) => {
try {
const input = req.validated.body;
let changedUser;
await db.sequelize.transaction(async (transaction) => {
const user = await db.User.findByPk(req.user.id, { transaction, lock: transaction.LOCK.UPDATE });
if (!user?.password || !await checkPassword(input.currentPassword, user.password)) throw Object.assign(new Error("Current password is incorrect"), { status: 401, code: "INVALID_CREDENTIALS" });
if (await checkPassword(input.newPassword, user.password)) throw Object.assign(new Error("New password must be different"), { status: 400, code: "INVALID_PASSWORD_CHANGE" });
user.password = await hashPassword(input.newPassword); user.passwordChangedAt = new Date(); user.tokenVersion += 1;
await user.save({ transaction }); await sessionService.revokeAllUserSessions(user.id, "PASSWORD_CHANGED", transaction); changedUser = user;
});
clearCookies(res); sendPasswordChanged(changedUser).catch(() => {});
await logActivity({ user: changedUser, description: "Password changed and sessions revoked", type: "PASSWORD_CHANGED", module: "Authentication" });
res.json({ success: true, message: "Password changed successfully. Please login again" });
} catch (error) { next(error); }
};
exports.verifyEmail = async (req, res, next) => {
try {
const userId = await consumeEmailVerificationToken(req.validated.body.token);
if (!userId) throw Object.assign(new Error("Verification token is invalid or expired"), { status: 400, code: "INVALID_VERIFICATION_TOKEN" });
const user = await db.User.findByPk(userId);
if (!user) throw Object.assign(new Error("Verification token is invalid or expired"), { status: 400, code: "INVALID_VERIFICATION_TOKEN" });
if (!user.emailVerifiedAt) { user.emailVerifiedAt = new Date(); user.accountStatus = "ACTIVE"; await user.save(); }
await logActivity({ user, description: "Email address verified", type: "EMAIL_VERIFIED", module: "Authentication" });
res.json({ success: true, message: "Email verified" });
} catch (error) { next(error); }
};
exports.resendVerification = async (req, res, next) => {
const message = "If verification is required, a new email has been sent";
try {
const user = await db.User.findOne({ where: { email: req.validated.body.email } });
if (user && !user.emailVerifiedAt && user.accountStatus === "PENDING_VERIFICATION") {
const token = await createEmailVerification(user.id); await sendVerificationEmail(user.email, user.firstName, token);
}
const session = validateRefreshSession(refreshToken);
if (!session) {
res.clearCookie("refresh_token");
return res.status(401).send({
success: false,
message: "Invalid or expired session. Please login again.",
});
}
const user = await User.findByPk(session.userId);
if (!user || user.accountStatus !== "ACTIVE") {
deleteRefreshSession(session.sessionId);
return res.status(401).send({
success: false,
message: "Session is no longer valid",
});
}
// Generate new Access Token
const token = generateToken({
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
accountType: user.accountType,
});
// Generate new Refresh Token
const { refreshToken: newRefreshToken } = createRefreshSession(user.id);
deleteRefreshSession(session.sessionId);
// Replace access cookie
res.cookie("access_token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax",
maxAge: 15 * 60 * 1000,
});
// Replace refresh cookie
res.cookie("refresh_token", newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax",
maxAge: 7 * 24 * 60 * 60 * 1000,
});
return res.status(200).send({
success: true,
message: "Session refreshed successfully",
});
res.json({ success: true, message });
} catch (error) {
console.error("REFRESH ERROR:", error);
return res.status(401).send({
success: false,
message: "Invalid or expired session. Please login again.",
});
console.error(`[${req.id}] Verification resend failed`, { name: error.name, message: error.message });
res.json({ success: true, message });
}
};
exports.forgotPassword = async (req, res) => {
exports.oauth = (provider) => async (req, res, next) => {
try {
let { email } = req.body;
if (!email) {
return res.status(400).send({
success: false,
message: "Email is required",
});
}
email = email.trim().toLowerCase();
if (!validateEmail(email)) {
return res.status(400).send({
success: false,
message: "Invalid email address",
});
}
const user = await User.findOne({
where: { email },
});
if (!user) {
return res.status(200).send({
success: true,
message:
"If an account exists for this email, a password reset link has been sent.",
});
}
const resetToken = await createPasswordReset(user.id);
await sendPasswordResetEmail(user.email, user.firstName, resetToken);
return res.status(200).send({
success: true,
message:
"If an account exists for this email, a password reset link has been sent.",
});
} catch (error) {
console.error("FORGOT PASSWORD ERROR:", error);
return res.status(500).send({
success: false,
message: "Unable to process password reset request",
});
}
const input = req.validated.body; const result = await authService.authenticateOAuth(provider, input, req);
const tokens = deliverTokens(req, res, result, input.clientType);
await logActivity({ user: result.user, description: `${provider} identity authenticated`, type: `${provider.toUpperCase()}_ACCOUNT_LINKED`, module: "Authentication" });
res.json({ success: true, data: { user: projectUser(result.user), ...tokens } });
} catch (error) { next(Object.assign(error, { status: error.status || 401, code: error.code || "OAUTH_FAILED" })); }
};
exports.resetPassword = async (req, res) => {
exports.adminLogin = async (req, res, next) => {
try {
const { token, newPassword, confirmPassword } = req.body;
if (!token || !newPassword || !confirmPassword) {
return res.status(400).send({
success: false,
message: "Token, new password and confirm password are required",
});
}
if (newPassword !== confirmPassword) {
return res.status(400).send({
success: false,
message: "Passwords do not match",
});
}
if (!validatePassword(newPassword)) {
return res.status(400).send({
success: false,
message: "Password does not meet the required criteria",
});
}
const verification = await verifyPasswordResetToken(token);
if (!verification) {
return res.status(400).send({
success: false,
message: "Reset token is invalid or expired",
});
}
const { userId, redisKey } = verification;
const user = await User.findByPk(userId);
if (!user) {
await deletePasswordReset(redisKey);
return res.status(400).send({
success: false,
message: "Reset token is invalid or expired",
});
}
const samePassword = await checkPassword(newPassword, user.password);
if (samePassword) {
return res.status(400).send({
success: false,
message: "New password must be different from the current password",
});
}
const hashedPassword = await hashPassword(newPassword);
user.password = hashedPassword;
user.passwordChangedAt = new Date();
await user.save();
await sendPasswordChangedEmail(user.email, user.firstName);
await deletePasswordReset(redisKey);
deleteAllUserSessions(user.id);
return res.status(200).send({
success: true,
message: "Password reset successfully. Please login again.",
});
} catch (error) {
console.error("RESET PASSWORD ERROR:", error);
return res.status(500).send({
success: false,
message: "Failed to reset password",
});
}
const { PRIVILEGED_ACCOUNT_TYPES } = require("../constants/accountTypes");
const result = await authService.beginPasswordLogin(req.validated.body, req, PRIVILEGED_ACCOUNT_TYPES);
res.status(202).json({ success: true, data: result, message: "If the credentials are valid, a verification code has been sent" });
} catch (error) { next(error); }
};
// Logout: Clear the JWT cookie
exports.logout = async (req, res) => {
exports.riderLogin = async (req, res, next) => {
try {
// 1. Get refresh token from cookie
const refreshToken = req.cookies?.refresh_token;
// 2. If refresh token exists, find its session
if (refreshToken) {
const session = validateRefreshSession(refreshToken);
// 3. Delete refresh session from server RAM
if (session) {
deleteRefreshSession(session.sessionId);
console.log(
`Refresh session deleted: ${session.sessionId}`
);
}
}
// 4. Clear access token cookie
res.clearCookie("access_token", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite:
process.env.NODE_ENV === "production"
? "None"
: "Lax",
});
// 5. Clear refresh token cookie
res.clearCookie("refresh_token", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite:
process.env.NODE_ENV === "production"
? "None"
: "Lax",
});
// 6. Send response
return res.status(200).json({
success: true,
message: "Logged out successfully",
});
} catch (error) {
console.error("LOGOUT ERROR:", error);
return res.status(500).json({
success: false,
message: "Failed to logout",
});
}
const { ACCOUNT_TYPES } = require("../constants/accountTypes");
const result = await authService.beginPasswordLogin(req.validated.body, req, [ACCOUNT_TYPES.RIDER]);
res.status(202).json({ success: true, data: result, message: "If the credentials are valid, a verification code has been sent" });
} catch (error) { next(error); }
};
+20
View File
@@ -0,0 +1,20 @@
const crypto=require("crypto"); const {Op}=require("sequelize"); const db=require("../models"); const service=require("../services/business/business.service"); const {logActivity}=require("../services/activity.service"); const notifications=require("../services/notification/notification.service");
const safeApp=a=>({id:a.id,businessName:a.business_name,legalName:a.legal_name,registrationNumber:a.registration_number,taxNumber:a.tax_number,businessType:a.business_type,contactEmail:a.contact_email,contactPhone:a.contact_phone,website:a.website,status:a.status,reviewedAt:a.reviewed_at,rejectionReason:a.rejection_reason,createdAt:a.createdAt});
const safeProfile=p=>({id:p.business_customer_id,businessName:p.businessName,legalName:p.legalName,registrationNumber:p.businessRegistrationNumber,taxNumber:p.taxNumber,businessType:p.businessType,contactEmail:p.businessEmail,contactPhone:p.phoneNumber,website:p.website,partnerId:p.partnerId,status:p.status,approvedAt:p.approvedAt,settlementTermId:p.settlement_term_id});
exports.apply=async(req,res,next)=>{try{const app=await service.apply(req.user.id,req.body);await logActivity({user:req.user,type:"BUSINESS_APPLICATION_SUBMITTED",module:"Business",description:"Business application submitted",targetType:"BUSINESS_APPLICATION",targetId:app.id,requestId:req.id});await notifications.publish({type:"BUSINESS_APPLICATION_SUBMITTED",user:req.user,headline:"Business application received",description:"Your application is awaiting review.",templateKey:"businessApplicationReceived",variables:{firstName:req.user.firstName,businessName:app.business_name,applicationId:app.id},correlationId:req.id}).catch(()=>undefined);return res.status(201).json({success:true,data:safeApp(app)});}catch(e){return next(e);}};
exports.myApplications=async(req,res,next)=>{try{return res.json({success:true,data:(await db.BusinessApplication.findAll({where:{user_id:req.user.id},order:[["createdAt","DESC"]]})).map(safeApp)});}catch(e){return next(e);}};
exports.getApplication=async(req,res,next)=>{try{const a=await db.BusinessApplication.findOne({where:{id:req.params.id,user_id:req.user.id}});if(!a)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Application not found"}});return res.json({success:true,data:safeApp(a)});}catch(e){return next(e);}};
exports.getMe=async(req,res,next)=>{try{const p=await db.BusinessCustomer.findOne({where:{user_id:req.user.id},include:[{model:db.BusinessContact,as:"contacts"},{model:db.Address,as:"addresses"},{model:db.BusinessCreditAccount,as:"creditAccount",attributes:["currency","credit_limit","status"]},{model:db.SettlementTerm,as:"settlementTerm"}]});if(!p)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Business profile not found"}});return res.json({success:true,data:{...safeProfile(p),contacts:p.contacts,addresses:p.addresses,creditAccount:p.creditAccount,settlementTerm:p.settlementTerm}});}catch(e){return next(e);}};
exports.updateMe=async(req,res,next)=>{try{const p=await db.BusinessCustomer.findOne({where:{user_id:req.user.id}});if(!p)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Business profile not found"}});const b=req.body;await p.update({...(b.businessName!==undefined&&{businessName:b.businessName}),...(b.legalName!==undefined&&{legalName:b.legalName}),...(b.taxNumber!==undefined&&{taxNumber:b.taxNumber}),...(b.businessType!==undefined&&{businessType:b.businessType}),...(b.contactEmail!==undefined&&{businessEmail:b.contactEmail}),...(b.contactPhone!==undefined&&{phoneNumber:b.contactPhone}),...(b.website!==undefined&&{website:b.website})});await logActivity({user:req.user,type:"BUSINESS_PROFILE_UPDATED",module:"Business",description:"Business profile updated",targetType:"BUSINESS_PROFILE",targetId:p.business_customer_id,requestId:req.id});return res.json({success:true,data:safeProfile(p)});}catch(e){return next(e);}};
exports.addContact=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{const p=await db.BusinessCustomer.findOne({where:{user_id:req.user.id},transaction:t,lock:t.LOCK.UPDATE});if(!p)throw Object.assign(new Error("Business profile not found"),{status:404,code:"NOT_FOUND"});if(req.body.isPrimary)await db.BusinessContact.update({is_primary:false},{where:{business_profile_id:p.business_customer_id},transaction:t});row=await db.BusinessContact.create({id:crypto.randomUUID(),business_profile_id:p.business_customer_id,name:req.body.name,title:req.body.title,email:req.body.email,phone:req.body.phone,is_primary:Boolean(req.body.isPrimary)},{transaction:t});});return res.status(201).json({success:true,data:row});}catch(e){return next(e);}};
exports.addAddress=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{const p=await db.BusinessCustomer.findOne({where:{user_id:req.user.id},transaction:t,lock:t.LOCK.UPDATE});if(!p)throw Object.assign(new Error("Business profile not found"),{status:404,code:"NOT_FOUND"});const b=req.body;if(b.isDefaultShipping)await db.Address.update({is_default_shipping:false},{where:{business_profile_id:p.business_customer_id},transaction:t});if(b.isDefaultBilling)await db.Address.update({is_default_billing:false},{where:{business_profile_id:p.business_customer_id},transaction:t});row=await db.Address.create({id:crypto.randomUUID(),business_profile_id:p.business_customer_id,label:b.label,recipient_name:b.recipientName,phone_number:b.phoneNumber,address_line_1:b.addressLine1,address_line_2:b.addressLine2,city:b.city,district:b.district,province:b.province,postal_code:b.postalCode,country_code:b.countryCode,address_type:"DELIVERY",is_default_shipping:Boolean(b.isDefaultShipping),is_default_billing:Boolean(b.isDefaultBilling)},{transaction:t});});return res.status(201).json({success:true,data:row});}catch(e){return next(e);}};
const paging=q=>({limit:Math.min(Number(q.limit)||20,100),offset:(Math.max(Number(q.page)||1,1)-1)*Math.min(Number(q.limit)||20,100)});
exports.adminListApplications=async(req,res,next)=>{try{const where={};if(req.query.status)where.status=req.query.status;if(req.query.businessName)where.business_name={[Op.like]:`%${req.query.businessName.slice(0,100)}%`};const {limit,offset}=paging(req.query);const result=await db.BusinessApplication.findAndCountAll({where,limit,offset,order:[[req.query.sort==="status"?"status":"createdAt",req.query.direction==="asc"?"ASC":"DESC"]]});return res.json({success:true,data:result.rows.map(safeApp),pagination:{total:result.count,limit,offset}});}catch(e){return next(e);}};
exports.adminGetApplication=async(req,res,next)=>{try{const a=await db.BusinessApplication.findByPk(req.params.id);if(!a)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Application not found"}});return res.json({success:true,data:safeApp(a)});}catch(e){return next(e);}};
exports.approve=async(req,res,next)=>{try{const {application,profile}=await service.approve(req.params.id,req.user.id);const applicant=await db.User.findByPk(application.user_id);await logActivity({user:req.user,type:"BUSINESS_APPLICATION_APPROVED",module:"Business",description:"Business application approved",targetType:"BUSINESS_APPLICATION",targetId:application.id,requestId:req.id,metadata:{partnerId:profile.partnerId}});await notifications.publish({type:"BUSINESS_APPLICATION_APPROVED",user:applicant,headline:"Business application approved",description:"Your business account is active.",templateKey:"businessApplicationApproved",variables:{firstName:applicant.firstName,businessName:profile.businessName,partnerId:profile.partnerId},correlationId:req.id}).catch(()=>undefined);return res.json({success:true,data:{application:safeApp(application),profile:safeProfile(profile)}});}catch(e){return next(e);}};
exports.reject=async(req,res,next)=>{try{const a=await service.reject(req.params.id,req.user.id,req.body.reason);const applicant=await db.User.findByPk(a.user_id);await logActivity({user:req.user,type:"BUSINESS_APPLICATION_REJECTED",module:"Business",description:"Business application rejected",targetType:"BUSINESS_APPLICATION",targetId:a.id,requestId:req.id});await notifications.publish({type:"BUSINESS_APPLICATION_REJECTED",user:applicant,headline:"Business application reviewed",description:"Your application was not approved.",templateKey:"businessApplicationRejected",variables:{firstName:applicant.firstName,businessName:a.business_name,reason:a.rejection_reason},correlationId:req.id}).catch(()=>undefined);return res.json({success:true,data:safeApp(a)});}catch(e){return next(e);}};
exports.adminListBusinesses=async(req,res,next)=>{try{const where={};if(req.query.status)where.status=req.query.status;if(req.query.partnerId)where.partnerId=req.query.partnerId;if(req.query.businessName)where.businessName={[Op.like]:`%${req.query.businessName.slice(0,100)}%`};const {limit,offset}=paging(req.query);const x=await db.BusinessCustomer.findAndCountAll({where,limit,offset,order:[["createdAt","DESC"]]});return res.json({success:true,data:x.rows.map(safeProfile),pagination:{total:x.count,limit,offset}});}catch(e){return next(e);}};
exports.setCredit=async(req,res,next)=>{try{let row,old;await db.sequelize.transaction(async t=>{const p=await db.BusinessCustomer.findByPk(req.params.id,{transaction:t,lock:t.LOCK.UPDATE});if(!p)throw Object.assign(new Error("Business not found"),{status:404,code:"NOT_FOUND"});row=await db.BusinessCreditAccount.findOne({where:{business_profile_id:p.business_customer_id},transaction:t,lock:t.LOCK.UPDATE});old=row.credit_limit;await row.update({credit_limit:req.body.creditLimit,currency:req.body.currency,status:req.body.status},{transaction:t});});await logActivity({user:req.user,type:"BUSINESS_CREDIT_LIMIT_CHANGED",module:"Business",description:"Business credit configuration changed",targetType:"BUSINESS_PROFILE",targetId:req.params.id,requestId:req.id,metadata:{oldValue:String(old),newValue:String(req.body.creditLimit),currency:req.body.currency}});return res.json({success:true,data:row});}catch(e){return next(e);}};
exports.setSettlement=async(req,res,next)=>{try{const term=await db.SettlementTerm.findOne({where:{id:req.body.settlementTermId,is_active:true}});if(!term)return res.status(400).json({success:false,error:{code:"INVALID_SETTLEMENT_TERM",message:"Settlement term unavailable"}});const [count]=await db.BusinessCustomer.update({settlement_term_id:term.id},{where:{business_customer_id:req.params.id}});if(!count)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Business not found"}});await logActivity({user:req.user,type:"BUSINESS_SETTLEMENT_TERM_CHANGED",module:"Business",description:"Business settlement term changed",targetType:"BUSINESS_PROFILE",targetId:req.params.id,requestId:req.id,metadata:{settlementTermId:term.id}});return res.json({success:true});}catch(e){return next(e);}};
exports.setStatus=async(req,res,next)=>{try{const p=await db.BusinessCustomer.findByPk(req.params.id);if(!p)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Business not found"}});await p.update({status:req.body.status});await logActivity({user:req.user,type:"BUSINESS_STATUS_CHANGED",module:"Business",description:"Business domain status changed",targetType:"BUSINESS_PROFILE",targetId:p.business_customer_id,requestId:req.id,metadata:{status:req.body.status}});const owner=await db.User.findByPk(p.user_id);if(owner)await notifications.publish({type:"BUSINESS_STATUS_CHANGED",user:owner,headline:"Business account status changed",description:`Your business account is now ${p.status}.`,templateKey:"businessAccountStatusChanged",variables:{firstName:owner.firstName,status:p.status},correlationId:req.id}).catch(()=>undefined);return res.json({success:true,data:safeProfile(p)});}catch(e){return next(e);}};
module.exports.safeApp=safeApp;module.exports.safeProfile=safeProfile;
@@ -0,0 +1,18 @@
const crypto=require("crypto");const db=require("../../models");const service=require("../../services/catalogue/catalogue.service");const {logActivity}=require("../../services/activity.service");
const audit=(req,type,targetType,targetId,description)=>logActivity({user:req.user,type,module:"Catalogue",description,targetType,targetId,requestId:req.id});
exports.listProducts=async(req,res,next)=>{try{const page=Math.max(Number(req.query.page)||1,1),limit=Math.min(Number(req.query.limit)||20,100),where={};if(req.query.status)where.status=req.query.status;return res.json({success:true,...await db.Product.findAndCountAll({where,limit,offset:(page-1)*limit,include:[{model:db.ProductTranslation,as:"translations"},{model:db.ProductVariant,as:"variants"}],order:[["createdAt","DESC"]]})});}catch(e){return next(e);}};
exports.createProduct=async(req,res,next)=>{try{const p=await service.createProduct(req.user,req.body);await audit(req,"PRODUCT_CREATED","PRODUCT",p.id,"Product created");return res.status(201).json({success:true,data:{id:p.id,slug:p.slug,status:p.status}});}catch(e){return next(e);}};
exports.getProduct=async(req,res,next)=>{try{const p=await db.Product.findByPk(req.params.id,{include:[{model:db.ProductTranslation,as:"translations"},{model:db.ProductVariant,as:"variants"},{model:db.Category,as:"categories",through:{attributes:[]}},{model:db.ProductMedia,as:"media"}]});if(!p)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Product not found"}});return res.json({success:true,data:p});}catch(e){return next(e);}};
exports.updateProduct=async(req,res,next)=>{try{const p=await service.setProductState(req.params.id,req.user,req.body);await audit(req,p.status==="ACTIVE"?"PRODUCT_PUBLISHED":p.status==="ARCHIVED"?"PRODUCT_ARCHIVED":"PRODUCT_UPDATED","PRODUCT",p.id,"Product updated");return res.json({success:true,data:{id:p.id,slug:p.slug,status:p.status,visibility:p.visibility}});}catch(e){return next(e);}};
exports.archiveProduct=async(req,res,next)=>{try{const p=await service.setProductState(req.params.id,req.user,{status:"ARCHIVED",visibility:"HIDDEN"});await audit(req,"PRODUCT_ARCHIVED","PRODUCT",p.id,"Product archived");return res.json({success:true,data:{id:p.id,status:p.status}});}catch(e){return next(e);}};
exports.createVariant=async(req,res,next)=>{try{const p=await db.Product.findByPk(req.params.productId);if(!p)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Product not found"}});const b=req.body,v=await db.ProductVariant.create({id:crypto.randomUUID(),product_id:p.id,sku:b.sku,barcode:b.barcode,base_price:b.basePrice,compare_at_price:b.compareAtPrice,currency:b.currency,weight:b.weight,sort_order:b.sortOrder});await audit(req,"VARIANT_CREATED","PRODUCT_VARIANT",v.id,"Product variant created");return res.status(201).json({success:true,data:v});}catch(e){return next(e);}};
exports.updateVariant=async(req,res,next)=>{try{let v;await db.sequelize.transaction(async t=>{v=await db.ProductVariant.findOne({where:{id:req.params.variantId,product_id:req.params.productId},transaction:t,lock:t.LOCK.UPDATE});if(!v)throw Object.assign(new Error("Variant not found"),{status:404,code:"NOT_FOUND"});const b=req.body;await v.update({...(b.status!==undefined&&{status:b.status}),...(b.basePrice!==undefined&&{base_price:b.basePrice}),...(b.compareAtPrice!==undefined&&{compare_at_price:b.compareAtPrice}),...(b.currency!==undefined&&{currency:b.currency}),...(b.weight!==undefined&&{weight:b.weight}),...(b.sortOrder!==undefined&&{sort_order:b.sortOrder})},{transaction:t});if(b.optionValueIds){const values=await db.ProductOptionValue.findAll({where:{id:b.optionValueIds},include:[{model:db.ProductOption,as:"option",where:{product_id:req.params.productId}}],transaction:t});if(values.length!==new Set(b.optionValueIds).size)throw Object.assign(new Error("Option values must belong to this product"),{status:400,code:"CROSS_PRODUCT_OPTION"});await db.VariantOptionValue.destroy({where:{variant_id:v.id},transaction:t});await db.VariantOptionValue.bulkCreate(values.map(x=>({id:crypto.randomUUID(),variant_id:v.id,option_value_id:x.id})),{transaction:t});}});await audit(req,"VARIANT_UPDATED","PRODUCT_VARIANT",v.id,"Product variant updated");return res.json({success:true,data:v});}catch(e){return next(e);}};
exports.createBrand=async(req,res,next)=>{try{if(req.body.logoUploadId)await service.validateMedia([req.body.logoUploadId],req.user.id);const b=req.body,x=await db.Brand.create({id:crypto.randomUUID(),name:b.name,slug:b.slug,description:b.description,logo_upload_id:b.logoUploadId,website:b.website,status:b.status,sort_order:b.sortOrder});await audit(req,"BRAND_CREATED","BRAND",x.id,"Brand created");return res.status(201).json({success:true,data:x});}catch(e){return next(e);}};
exports.updateBrand=async(req,res,next)=>{try{const x=await db.Brand.findByPk(req.params.id);if(!x)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Brand not found"}});await x.update(req.body);await audit(req,"BRAND_UPDATED","BRAND",x.id,"Brand updated");return res.json({success:true,data:x});}catch(e){return next(e);}};
exports.createCategory=async(req,res,next)=>{try{let x;await db.sequelize.transaction(async t=>{await service.assertCategoryParent(null,req.body.parentId,t);const b=req.body;x=await db.Category.create({id:crypto.randomUUID(),parent_id:b.parentId,code:b.code,slug:b.slug,status:b.status,sort_order:b.sortOrder,image_upload_id:b.imageUploadId},{transaction:t});await db.CategoryTranslation.bulkCreate(b.translations.map(y=>({id:crypto.randomUUID(),category_id:x.id,locale:y.locale,name:y.name,description:y.description,meta_title:y.metaTitle,meta_description:y.metaDescription})),{transaction:t});});await audit(req,"CATEGORY_CREATED","CATEGORY",x.id,"Category created");return res.status(201).json({success:true,data:x});}catch(e){return next(e);}};
exports.updateCategory=async(req,res,next)=>{try{let x;await db.sequelize.transaction(async t=>{x=await db.Category.findByPk(req.params.id,{transaction:t,lock:t.LOCK.UPDATE});if(!x)throw Object.assign(new Error("Category not found"),{status:404,code:"NOT_FOUND"});await service.assertCategoryParent(x.id,req.body.parentId,t);await x.update({parent_id:req.body.parentId,code:req.body.code,slug:req.body.slug,status:req.body.status,sort_order:req.body.sortOrder,image_upload_id:req.body.imageUploadId},{transaction:t});});await audit(req,"CATEGORY_UPDATED","CATEGORY",x.id,"Category updated");return res.json({success:true,data:x});}catch(e){return next(e);}};
exports.createCollection=async(req,res,next)=>{try{let x;await db.sequelize.transaction(async t=>{const b=req.body;if(b.endsAt&&b.startsAt&&b.endsAt<=b.startsAt)throw Object.assign(new Error("Collection end must follow start"),{status:400,code:"INVALID_WINDOW"});x=await db.Collection.create({id:crypto.randomUUID(),slug:b.slug,type:b.type,status:b.status,starts_at:b.startsAt,ends_at:b.endsAt,hero_upload_id:b.heroUploadId,sort_order:b.sortOrder},{transaction:t});await db.CollectionTranslation.bulkCreate(b.translations.map(y=>({id:crypto.randomUUID(),collection_id:x.id,locale:y.locale,name:y.name,description:y.description,headline:y.headline,subheadline:y.subheadline})),{transaction:t});await db.CollectionProduct.bulkCreate([...new Set(b.productIds)].map((product_id,i)=>({id:crypto.randomUUID(),collection_id:x.id,product_id,sort_order:i})),{transaction:t});});await audit(req,"COLLECTION_CREATED","COLLECTION",x.id,"Collection created");return res.status(201).json({success:true,data:x});}catch(e){return next(e);}};
exports.createSizeGuide=async(req,res,next)=>{try{const b=req.body,x=await db.SizeGuide.create({id:crypto.randomUUID(),code:b.code,name:b.name,locale:b.locale,data:b.data,category_id:b.categoryId,status:b.status});return res.status(201).json({success:true,data:x});}catch(e){return next(e);}};
exports.createOption=async(req,res,next)=>{try{let option;await db.sequelize.transaction(async t=>{const product=await db.Product.findByPk(req.params.productId,{transaction:t});if(!product)throw Object.assign(new Error("Product not found"),{status:404,code:"NOT_FOUND"});option=await db.ProductOption.create({id:crypto.randomUUID(),product_id:product.id,name:req.body.name,sort_order:req.body.sortOrder},{transaction:t});await db.ProductOptionValue.bulkCreate([...new Set(req.body.values)].map((value,i)=>({id:crypto.randomUUID(),option_id:option.id,value,sort_order:i})),{transaction:t});});return res.status(201).json({success:true,data:option});}catch(e){return next(e);}};
exports.attachMedia=async(req,res,next)=>{try{let media;await db.sequelize.transaction(async t=>{const product=await db.Product.findByPk(req.params.productId,{transaction:t,lock:t.LOCK.UPDATE});if(!product)throw Object.assign(new Error("Product not found"),{status:404,code:"NOT_FOUND"});await service.validateMedia([req.body.uploadId],req.user.id,t);if(req.body.variantId){const variant=await db.ProductVariant.findOne({where:{id:req.body.variantId,product_id:product.id},transaction:t});if(!variant)throw Object.assign(new Error("Variant does not belong to product"),{status:400,code:"CROSS_PRODUCT_VARIANT"});}if(req.body.isPrimary)await db.ProductMedia.update({is_primary:false},{where:{product_id:product.id},transaction:t});media=await db.ProductMedia.create({id:crypto.randomUUID(),product_id:product.id,variant_id:req.body.variantId,upload_id:req.body.uploadId,type:"IMAGE",sort_order:req.body.sortOrder,alt_text:req.body.altText,is_primary:req.body.isPrimary},{transaction:t});await db.Upload.update({owner_type:"CATALOGUE",owner_id:product.id,use_for:"PRODUCT_MEDIA"},{where:{id:req.body.uploadId},transaction:t});});return res.status(201).json({success:true,data:{id:media.id,isPrimary:media.is_primary}});}catch(e){return next(e);}};
exports.createRelation=async(req,res,next)=>{try{if(req.params.productId===req.body.targetProductId)return res.status(400).json({success:false,error:{code:"SELF_RELATION",message:"A product cannot relate to itself"}});const count=await db.Product.count({where:{id:[req.params.productId,req.body.targetProductId]}});if(count!==2)return res.status(400).json({success:false,error:{code:"INVALID_PRODUCT",message:"Both products must exist"}});const x=await db.ProductRelation.create({id:crypto.randomUUID(),source_product_id:req.params.productId,target_product_id:req.body.targetProductId,relation_type:req.body.relationType,sort_order:req.body.sortOrder});return res.status(201).json({success:true,data:x});}catch(e){return next(e);}};
@@ -0,0 +1,10 @@
const {Op,fn,col,literal}=require("sequelize");const db=require("../../models");const {resolveLocale,selectTranslation}=require("../../services/catalogue/locale.service");const serializer=require("../../services/catalogue/serializer.service");
const productIncludes=()=>[{model:db.ProductTranslation,as:"translations",required:true},{model:db.Brand,as:"brand",where:{status:"ACTIVE"},required:true},{model:db.ProductVariant,as:"variants",where:{status:"ACTIVE"},required:true},{model:db.ProductMedia,as:"media",required:false,include:[{model:db.Upload,as:"upload",where:{status:"AVAILABLE"},required:true}]}];
exports.products=async(req,res,next)=>{try{const page=Math.max(Number(req.query.page)||1,1),limit=Math.min(Math.max(Number(req.query.limit)||20,1),100),where={status:"ACTIVE",visibility:"PUBLIC"};if(req.query.brand)where.brand_id=req.query.brand;if(req.query.featured!==undefined)where.featured=req.query.featured==="true";if(req.query.newArrival==="true")where.new_arrival_until={[Op.gt]:new Date()};if(req.query.search)where[Op.or]=[{"$translations.name$":{[Op.like]:`%${req.query.search.slice(0,100)}%`}},{product_code:{[Op.like]:`%${req.query.search.slice(0,100)}%`}},{"$brand.name$":{[Op.like]:`%${req.query.search.slice(0,100)}%`}}];const allowed={newest:[["published_at","DESC"]],price_asc:[[{model:db.ProductVariant,as:"variants"},"base_price","ASC"]],price_desc:[[{model:db.ProductVariant,as:"variants"},"base_price","DESC"]],name:[[{model:db.ProductTranslation,as:"translations"},"name","ASC"]],featured:[["featured","DESC"],["published_at","DESC"]]};if(req.query.sort&&!allowed[req.query.sort])return res.status(400).json({success:false,error:{code:"INVALID_SORT",message:"Unsupported sort"}});const include=productIncludes();if(req.query.category)include.push({model:db.Category,as:"categories",where:{id:req.query.category,status:"ACTIVE"},through:{attributes:[]},required:true});if(req.query.minPrice||req.query.maxPrice){const v=include.find(x=>x.as==="variants");v.where.base_price={...(req.query.minPrice&&{[Op.gte]:req.query.minPrice}),...(req.query.maxPrice&&{[Op.lte]:req.query.maxPrice})};}const result=await db.Product.findAndCountAll({where,include,distinct:true,limit,offset:(page-1)*limit,order:allowed[req.query.sort||"newest"]});const locale=resolveLocale(req);return res.json({success:true,data:await Promise.all(result.rows.map(x=>serializer.summary(x,locale))),pagination:{page,limit,total:result.count,pages:Math.ceil(result.count/limit)}});}catch(e){return next(e);}};
exports.product=async(req,res,next)=>{try{const p=await db.Product.findOne({where:{slug:req.params.slug,status:"ACTIVE",visibility:"PUBLIC"},include:[...productIncludes(),{model:db.Category,as:"categories",where:{status:"ACTIVE"},through:{attributes:[]},required:false},{model:db.ProductOption,as:"options",include:[{model:db.ProductOptionValue,as:"values"}]},{model:db.ProductAttribute,as:"attributes"},{model:db.SizeGuide,as:"sizeGuide",required:false},{model:db.ProductReview,as:"reviews",where:{status:"APPROVED"},required:false,include:[{model:db.User,as:"author",attributes:["id","firstName"]}]}]});if(!p)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Product not found"}});const locale=resolveLocale(req),tr=selectTranslation(p.translations,locale),summary=await serializer.summary(p,locale),reviews=p.reviews||[],average=reviews.length?(reviews.reduce((n,r)=>n+r.rating,0)/reviews.length).toFixed(2):null;return res.json({success:true,data:{...summary,content:tr&&{shortDescription:tr.short_description,description:tr.description,careInstructions:tr.care_instructions,materials:tr.materials,origin:tr.origin},seo:tr&&{title:tr.meta_title,description:tr.meta_description},categories:p.categories?.map(c=>({id:c.id,slug:c.slug,name:selectTranslation(c.translations,locale)?.name})),variants:p.variants?.map(v=>({id:v.id,sku:v.sku,basePrice:String(v.base_price),compareAtPrice:v.compare_at_price&&String(v.compare_at_price),currency:v.currency,optionValues:v.optionValues})),options:p.options,attributes:p.attributes?.filter(a=>a.locale===locale||a.locale==="en"),media:await serializer.mediaDto(p.media),sizeGuide:p.sizeGuide,rating:{averageRating:average,reviewCount:reviews.length},reviews:reviews.slice(0,10).map(r=>({id:r.id,rating:r.rating,title:r.title,body:r.body,verifiedPurchase:r.verified_purchase,author:r.author&&{firstName:r.author.firstName},createdAt:r.createdAt}))}});}catch(e){return next(e);}};
exports.categories=async(req,res,next)=>{try{const locale=resolveLocale(req),rows=await db.Category.findAll({where:{status:"ACTIVE"},include:[{model:db.CategoryTranslation,as:"translations"}],order:[["sort_order","ASC"]]});const nodes=rows.map(x=>({id:x.id,parentId:x.parent_id,slug:x.slug,name:selectTranslation(x.translations,locale)?.name,children:[]})),byId=new Map(nodes.map(x=>[x.id,x]));for(const n of nodes)if(n.parentId&&byId.has(n.parentId))byId.get(n.parentId).children.push(n);return res.json({success:true,data:req.query.flat==="true"?nodes:nodes.filter(x=>!x.parentId)});}catch(e){return next(e);}};
exports.category=async(req,res,next)=>{try{const x=await db.Category.findOne({where:{slug:req.params.slug,status:"ACTIVE"},include:[{model:db.CategoryTranslation,as:"translations"}]});if(!x)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Category not found"}});const t=selectTranslation(x.translations,resolveLocale(req));return res.json({success:true,data:{id:x.id,slug:x.slug,parentId:x.parent_id,name:t?.name,description:t?.description,seo:{title:t?.meta_title,description:t?.meta_description}}});}catch(e){return next(e);}};
exports.brands=async(req,res,next)=>{try{return res.json({success:true,data:await db.Brand.findAll({where:{status:"ACTIVE"},attributes:["id","name","slug","description","website"],order:[["sort_order","ASC"]]})});}catch(e){return next(e);}};
exports.brand=async(req,res,next)=>{try{const x=await db.Brand.findOne({where:{slug:req.params.slug,status:"ACTIVE"},attributes:["id","name","slug","description","website"]});if(!x)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Brand not found"}});return res.json({success:true,data:x});}catch(e){return next(e);}};
exports.collections=async(req,res,next)=>{try{const now=new Date(),rows=await db.Collection.findAll({where:{status:"ACTIVE",[Op.and]:[{[Op.or]:[{starts_at:null},{starts_at:{[Op.lte]:now}}]},{[Op.or]:[{ends_at:null},{ends_at:{[Op.gte]:now}}]}]},include:[{model:db.CollectionTranslation,as:"translations"}],order:[["sort_order","ASC"]]});const locale=resolveLocale(req);return res.json({success:true,data:rows.map(x=>({id:x.id,slug:x.slug,type:x.type,...selectTranslation(x.translations,locale)}))});}catch(e){return next(e);}};
exports.collection=async(req,res,next)=>{try{const now=new Date(),x=await db.Collection.findOne({where:{slug:req.params.slug,status:"ACTIVE",[Op.and]:[{[Op.or]:[{starts_at:null},{starts_at:{[Op.lte]:now}}]},{[Op.or]:[{ends_at:null},{ends_at:{[Op.gte]:now}}]}]},include:[{model:db.CollectionTranslation,as:"translations"},{model:db.Product,as:"products",where:{status:"ACTIVE",visibility:"PUBLIC"},required:false,through:{attributes:["sort_order"]},include:productIncludes()}]});if(!x)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Collection not found"}});const locale=resolveLocale(req);return res.json({success:true,data:{id:x.id,slug:x.slug,...selectTranslation(x.translations,locale),products:await Promise.all((x.products||[]).map(p=>serializer.summary(p,locale)))}});}catch(e){return next(e);}};
@@ -0,0 +1,4 @@
const crypto=require("crypto");const db=require("../../models");const loyalty=require("../../services/loyalty/loyalty.service");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 purchased=await db.Order.count({where:{user_id:req.user.id,payment_status:"PAID"},include:[{model:db.OrderItem,as:"items",where:{product_id:product.id},attributes:[]}]});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:purchased>0});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,verifiedPurchase:review.verified_purchase}});}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()});if(r.status==="APPROVED")await loyalty.earnReview(r);await logActivity({user:req.user,type:req.body.status==="APPROVED"?"REVIEW_APPROVED":"REVIEW_REJECTED",module:"Catalogue",description:"Product review moderated",targetType:"PRODUCT_REVIEW",targetId:r.id,requestId:req.id});return res.json({success:true,data:{id:r.id,status:r.status}});}catch(e){return next(e);}};
@@ -0,0 +1 @@
const db=require("../../models"),orders=require("../../services/commerce/order.service"),returns=require("../../services/commerce/return.service"),refunds=require("../../services/commerce/refund.service"),state=require("../../services/commerce/orderState.service"),{logActivity}=require("../../services/activity.service");exports.orders=async(req,res,next)=>{try{res.json({success:true,data:await db.Order.findAll({include:[{model:db.OrderItem,as:"items"}],order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.order=async(req,res,next)=>{try{const row=await db.Order.findByPk(req.params.id,{include:[{model:db.OrderItem,as:"items"},{model:db.Payment,as:"payments"}]});if(!row)throw Object.assign(new Error("Order not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:row});}catch(e){next(e);}};exports.cancel=async(req,res,next)=>{try{const row=await orders.cancel({orderId:req.params.id,admin:true,requestId:req.id});await logActivity({user:req.user,type:"ORDER_CANCELLED",module:"Orders",targetId:row.id,requestId:req.id});res.json({success:true,data:row});}catch(e){next(e);}};exports.processing=async(req,res,next)=>{try{const row=await db.Order.findByPk(req.params.id);if(!row)throw Object.assign(new Error("Order not found"),{status:404,code:"NOT_FOUND"});state.assertTransition(row.status,"PROCESSING");await row.update({status:"PROCESSING"});res.json({success:true,data:row});}catch(e){next(e);}};exports.returns=async(req,res,next)=>{try{res.json({success:true,data:await db.ReturnRequest.findAll({order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.returnAction=status=>async(req,res,next)=>{try{const row=await returns.transition({id:req.params.id,status,actorUserId:req.user.id,conditions:req.body.conditions||[]});await logActivity({user:req.user,type:`RETURN_${status}`,module:"Returns",targetId:row.id,requestId:req.id});res.json({success:true,data:row});}catch(e){next(e);}};exports.refund=async(req,res,next)=>{try{const result=await refunds.request({orderId:req.params.id,actorUserId:req.user.id,operationKey:req.get("Idempotency-Key"),...req.body});await logActivity({user:req.user,type:"REFUND_REQUESTED",module:"Refunds",targetId:result.refund.id,requestId:req.id});res.status(result.idempotent?200:201).json({success:true,data:result.refund});}catch(e){next(e);}};
@@ -0,0 +1,2 @@
const db=require("../../models"),orders=require("../../services/commerce/order.service"),payments=require("../../services/commerce/payment.service"),{logActivity}=require("../../services/activity.service");const audit=(req,type,id)=>logActivity({user:req.user,type,module:"Orders",targetType:"ORDER",targetId:id,requestId:req.id});exports.create=async(req,res,next)=>{try{const result=await orders.createFromCheckout({checkoutId:req.body.checkoutId,userId:req.user.id});if(!result.idempotent)await audit(req,"ORDER_CREATED",result.order.id);res.status(result.idempotent?200:201).json({success:true,data:result.order});}catch(e){next(e);}};
exports.list=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={user_id:req.user.id};if(req.query.status)where.status=req.query.status;const x=await db.Order.findAndCountAll({where,limit,offset:(page-1)*limit,order:[["createdAt","DESC"]]});res.json({success:true,data:x.rows,pagination:{page,limit,total:x.count}});}catch(e){next(e);}};exports.get=async(req,res,next)=>{try{const row=await db.Order.findOne({where:{id:req.params.id,user_id:req.user.id},include:[{model:db.OrderItem,as:"items"},{model:db.Payment,as:"payments",attributes:{exclude:["failure_message"]}},{model:db.Invoice,as:"invoice"}]});if(!row)throw Object.assign(new Error("Order not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:row});}catch(e){next(e);}};exports.cancel=async(req,res,next)=>{try{const row=await orders.cancel({orderId:req.params.id,userId:req.user.id,requestId:req.id});await audit(req,"ORDER_CANCELLED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};exports.payment=async(req,res,next)=>{try{const row=await db.Payment.findOne({include:[{model:db.Order,as:"order",where:{id:req.params.id,user_id:req.user.id},attributes:[]}],attributes:{exclude:["failure_message"]}});res.json({success:true,data:row});}catch(e){next(e);}};exports.invoice=async(req,res,next)=>{try{const row=await db.Invoice.findOne({include:[{model:db.Order,as:"order",where:{id:req.params.id,user_id:req.user.id},attributes:[]}]});if(!row)throw Object.assign(new Error("Invoice not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:row});}catch(e){next(e);}};exports.startPayment=async(req,res,next)=>{try{const row=await payments.create({orderId:req.params.id,userId:req.user.id,...req.body});await audit(req,"PAYMENT_CREATED",row.id);res.status(201).json({success:true,data:{id:row.id,paymentReference:row.payment_reference,provider:row.provider,status:row.status,amount:String(row.amount),currency:row.currency}});}catch(e){next(e);}};
@@ -0,0 +1 @@
const db=require("../../models"),service=require("../../services/commerce/return.service"),{logActivity}=require("../../services/activity.service");exports.request=async(req,res,next)=>{try{const row=await service.request({orderId:req.params.orderId,userId:req.user.id,...req.body});await logActivity({user:req.user,type:"RETURN_REQUESTED",module:"Returns",targetId:row.id,requestId:req.id});res.status(201).json({success:true,data:row});}catch(e){next(e);}};exports.list=async(req,res,next)=>{try{res.json({success:true,data:await db.ReturnRequest.findAll({where:{user_id:req.user.id},order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.get=async(req,res,next)=>{try{const row=await db.ReturnRequest.findOne({where:{id:req.params.id,user_id:req.user.id}});if(!row)throw Object.assign(new Error("Return not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:row});}catch(e){next(e);}};
@@ -0,0 +1 @@
const service=require("../../services/commerce/payment.service");exports.payhere=async(req,res,next)=>{try{await service.handleWebhook("PAYHERE",req.body);res.status(200).send("OK");}catch(e){next(e);}};
@@ -0,0 +1,11 @@
const db = require("../models");
const { checkPassword } = require("../utils/hashPassword.util");
const { revokeAllUserSessions } = require("../services/auth/session.service");
const { logActivity } = require("../services/activity.service");
const storage = require("../services/storage/storage.service");
const media = async (id, userId) => { if (!id || id === "N/A") return null; const upload = await db.Upload.findOne({ where: { id, owner_id: userId, status: "AVAILABLE" } }); if (!upload) return null; const expiresIn = Number(process.env.S3_SIGNED_URL_TTL_SECONDS || 900); return { id: upload.id, url: await storage.createSignedDownloadUrl(upload.file_path, expiresIn), expiresIn }; };
const response = async (user, profile) => ({ id: user.id, firstName: user.firstName, lastName: user.lastName, email: user.email, phoneNumber: profile.phone_number, dateOfBirth: profile.dob, preferredLanguage: profile.preferred_language, accountType: user.accountType, accountStatus: user.accountStatus, emailVerified: Boolean(user.emailVerifiedAt), avatar: await media(profile.profilePicture_id, user.id), background: await media(profile.backgroundImage_id, user.id), preferences: { theme: profile.theme, marketingEmailEnabled: profile.marketing_email_enabled, marketingPushEnabled: profile.marketing_push_enabled, inAppNotificationsEnabled: profile.in_app_notifications_enabled } });
exports.getMe = async (req, res, next) => { try { const user = await db.User.findByPk(req.user.id, { include: [{ model: db.Profile, as: "profile" }] }); if (!user?.profile) return res.status(404).json({ success: false, error: { code: "PROFILE_NOT_FOUND", message: "Profile not found" } }); return res.json({ success: true, data: await response(user, user.profile) }); } catch (e) { return next(e); } };
exports.updateMe = async (req, res, next) => { try { let user, profile; await db.sequelize.transaction(async transaction => { user = await db.User.findByPk(req.user.id, { transaction, lock: transaction.LOCK.UPDATE }); profile = await db.Profile.findOne({ where: { user_id: req.user.id }, transaction, lock: transaction.LOCK.UPDATE }); const b=req.body; for(const id of [b.avatarUploadId,b.backgroundUploadId].filter(Boolean)){const owned=await db.Upload.findOne({where:{id,owner_id:req.user.id,status:"AVAILABLE"},transaction});if(!owned)throw Object.assign(new Error("Profile media must be an available owned upload"),{status:400,code:"INVALID_PROFILE_MEDIA"});} await user.update({ ...(b.firstName !== undefined && { firstName:b.firstName }), ...(b.lastName !== undefined && { lastName:b.lastName }) }, { transaction }); await profile.update({ ...(b.phoneNumber !== undefined && { phone_number:b.phoneNumber }), ...(b.dateOfBirth !== undefined && { dob:b.dateOfBirth }), ...(b.preferredLanguage !== undefined && { preferred_language:b.preferredLanguage }), ...(b.theme !== undefined && { theme:b.theme }), ...(b.marketingEmailEnabled !== undefined && { marketing_email_enabled:b.marketingEmailEnabled }), ...(b.marketingPushEnabled !== undefined && { marketing_push_enabled:b.marketingPushEnabled }), ...(b.inAppNotificationsEnabled !== undefined && { in_app_notifications_enabled:b.inAppNotificationsEnabled, notificationsEnabled:b.inAppNotificationsEnabled }), ...(b.avatarUploadId !== undefined && { profilePicture_id:b.avatarUploadId }), ...(b.backgroundUploadId !== undefined && { backgroundImage_id:b.backgroundUploadId }) }, { transaction }); }); await logActivity({ user:req.user, type:"PROFILE_UPDATED", module:"Customer", description:"Customer profile updated", targetType:"USER", targetId:req.user.id, requestId:req.id }); return res.json({ success:true, data:await response(user,profile) }); } catch(e){ return next(e); } };
exports.deactivate = async (req,res,next) => { try { await db.sequelize.transaction(async transaction => { const user=await db.User.findByPk(req.user.id,{transaction,lock:transaction.LOCK.UPDATE}); if(user.password && (!req.body.password || !(await checkPassword(req.body.password,user.password)))) throw Object.assign(new Error("Password confirmation failed"),{status:403,code:"INVALID_CONFIRMATION"}); await user.update({accountStatus:"DEACTIVATED",tokenVersion:user.tokenVersion+1},{transaction}); await revokeAllUserSessions(user.id,"SELF_DEACTIVATED",transaction); }); await logActivity({user:req.user,type:"ACCOUNT_DEACTIVATED",module:"Identity",description:"Customer deactivated account",targetType:"USER",targetId:req.user.id,requestId:req.id}); res.clearCookie("access_token"); res.clearCookie("refresh_token",{path:"/api"}); return res.json({success:true,message:"Account deactivated"}); } catch(e){return next(e);} };
+18 -414
View File
@@ -1,417 +1,21 @@
/**
* Copyright (c) 2026 Niolla
* All rights reserved.
*
* This source code is proprietary and confidential.
* Unauthorized copying, modification, distribution, or use
* of this file, via any medium, is strictly prohibited.
*/
// app/controllers/generateDocument.controller.js
const fs = require("fs");
const path = require("path");
const documentQueue = require("../queues/document.queue");
const { createDocumentData } = require("../utils/document.utill");
const {
generateId,
generateDocumentReferenceNo,
} = require("../utils/idGen.util");
const { GetObjectCommand, DeleteObjectCommand } = require("@aws-sdk/client-s3");
const {log} = require("../utils/consoleLog.utill");
const s3 = require("../config/s3.config");
const crypto = require("crypto");
const { z } = require("zod");
const db = require("../models");
const Document = db.Document;
const DocumentType = db.DocumentType;
const documentQueue = require("../queues/document.queue");
const registry = require("../logic/documents/registry");
const storage = require("../services/storage/storage.service");
const normalizeDocumentData = (value) => {
if (!value) {
return {};
}
const requestSchema = z.object({ document: z.string().min(1).max(64), documentType: z.enum(["pdf", "excel"]), documentData: z.record(z.string(), z.unknown()).optional(), data: z.record(z.string(), z.unknown()).optional() }).passthrough();
const normalize = (value) => String(value).toLowerCase().replace(/[\s_-]+/g, "");
const allowed = (user, doc, permission = "documents.read") => doc.owner_id === user.id || doc.created_by === user.id || user.accountType === "super_admin" || (user.permissions || []).includes(permission);
const publicDoc = (doc) => ({ id: doc.doc_id, referenceNo: doc.reference_no, documentType: doc.doc_type, status: doc.status, jobId: doc.job_id, generatedAt: doc.generated_at, failedAt: doc.failed_at, failureCode: doc.failure_code, createdAt: doc.createdAt });
if (typeof value === "string") {
try {
return JSON.parse(value);
} catch (error) {
return {};
}
}
return value;
};
/**
* Get available document types
*/
exports.getAvailableDocumentTypes = async (req, res) => {
try {
const doc_types = await DocumentType.findAll({
attributes: ["doc_type_name"],
});
const types = doc_types.map((t) => t.doc_type_name);
return res.json({
success: true,
availableDocumentTypes: types,
note: "Use these document type names in your requests (case-insensitive)",
});
} catch (error) {
log("Error fetching document types:", error.message);
return res.status(500).json({
success: false,
message: error.message,
});
}
};
// Get Saved documents
exports.getSavedDocuments = async (req, res) => {
try {
const { documentType } = req.body;
if (!documentType) {
return res.status(400).json({
success: false,
message: "documentType query parameter is required",
});
}
// Fetch saved documents based on documentType
const savedDocuments = await Document.findAll({
where: { doc_type: documentType.toUpperCase() },
order: [["createdAt", "DESC"]],
exclude: ["data"],
});
// extract only necessary fields to return
const formattedDocuments = savedDocuments.map((doc) => ({
doc_id: doc.doc_id,
reference_no: doc.reference_no,
doc_type: doc.doc_type,
status: doc.status,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
}));
return res.json({
success: true,
savedDocuments: formattedDocuments,
});
} catch (error) {
log("Error fetching saved documents:", error.message);
return res.status(500).json({
success: false,
message: error.message,
});
}
};
// Get specific document data by doc_id
exports.getDocumentData = async (req, res) => {
try {
const { docId } = req.params;
if (!docId) {
return res.status(400).json({
success: false,
message: "docId parameter is required",
});
}
const document = await Document.findOne({
where: { doc_id: docId },
});
if (!document) {
return res.status(404).json({
success: false,
message: "Document not found",
});
}
return res.json({
success: true,
document: {
doc_id: document.doc_id,
reference_no: document.reference_no,
doc_type: document.doc_type,
data: document.data,
status: document.status,
createdAt: document.createdAt,
updatedAt: document.updatedAt,
},
});
} catch (error) {
log("Error fetching document data:", error.message);
return res.status(500).json({
success: false,
message: error.message,
});
}
}
exports.generateReferenceNo = async (req, res) => {
try {
const { documentType } = req.params;
if (!documentType) {
return res.status(400).json({
success: false,
message: "documentType is required",
});
}
// Generate reference number
const reference_no = await generateDocumentReferenceNo(documentType);
return res.json({
success: true,
reference_no,
});
} catch (error) {
log("Error generating reference number:", error.message);
return res.status(500).json({
success: false,
message: error.message,
});
}
};
exports.generateDraftDocument = async (req, res) => {
try {
const { document } = req.body;
const documentData = normalizeDocumentData(req.body.documentData || req.body.data || req.body);
// Validation
if (!document || !documentData) {
return res.status(400).json({
success: false,
message: "document and documentData are required",
});
}
let documentDetails;
if (document !== "PRECOST") {
// Save document details before generating
documentDetails = await createDocumentData(
document,
documentData,
"DRAFT",
);
} else {
return res.status(400).json({
success: false,
message:
"Invalid document type, This document type is not allowed to be generated as draft",
});
}
return res.status(201).json({
success: true,
message: "Draft document created successfully",
documentDetails,
});
} catch (error) {
log("Error generating draft document:", error.message);
return res.status(500).json({
success: false,
message: error.message,
});
}
};
/**
* Generate document asynchronously
* Returns jobId immediately
*/
exports.generateDocument = async (req, res) => {
try {
const { document, documentType } = req.body;
const documentData = normalizeDocumentData(req.body.documentData || req.body.data || req.body);
// Validation
if (!document || !documentType || !documentData) {
return res.status(400).json({
success: false,
message: "document, documentType, and documentData are required",
});
}
console.log(
`📨 generateDocument request: document="${document}", documentType="${documentType}"`,
);
if (document !== "PRECOST") {
// Save document details before generating
// If doc_id exists, finalize (update existing); otherwise create as DRAFT
const status = documentData.doc_id ? "FINAL" : "DRAFT";
await createDocumentData(document, documentData, status);
}
// Add job to queue
const job = await documentQueue.add("generate-document", {
document,
documentType,
data: documentData,
});
log(
`📋 Document generation job queued: ${job.id} (document="${document}", type="${documentType}")`,
);
return res.status(202).json({
success: true,
message: "Document generation started",
jobId: job.id,
});
} catch (error) {
log("Error queuing document generation:", error.message);
return res.status(500).json({
success: false,
message: error.message,
});
}
};
/**
* Get job status and result
*/
exports.getJobStatus = async (req, res) => {
try {
const { jobId } = req.params;
if (!jobId) {
return res.status(400).json({
success: false,
message: "jobId is required",
});
}
// Get job from queue
const job = await documentQueue.getJob(jobId);
if (!job) {
return res.status(404).json({
success: false,
message: "Job not found",
});
}
// Get job state
const state = await job.getState();
const result = job.returnvalue;
const failedReason = job.failedReason;
return res.json({
success: true,
jobId: job.id,
state, // "waiting" | "active" | "completed" | "failed" | "delayed"
result: state === "completed" ? result : null,
error: state === "failed" ? failedReason : null,
attempts: job.attemptsMade,
stacktrace: job.stacktrace,
});
} catch (error) {
log("Error fetching job status:", error.message);
return res.status(500).json({
success: false,
message: error.message,
});
}
};
/**
* Download generated document
*/
exports.downloadDocument = async (req, res) => {
try {
const { uuid } = req.params;
const key = `uploads/${uuid}.pdf`;
const command = new GetObjectCommand({
Bucket: process.env.AWS_S3_BUCKET_NAME,
Key: key,
});
const response = await s3.send(command);
res.setHeader(
"Content-Type",
response.ContentType || "application/octet-stream",
);
res.setHeader("Content-Disposition", `attachment; filename="${uuid}.pdf"`);
response.Body.pipe(res);
res.on("finish", async () => {
try {
await s3.send(
new DeleteObjectCommand({
Bucket: process.env.AWS_S3_BUCKET_NAME,
Key: key,
}),
);
log(`Deleted from S3: ${key}`);
} catch (err) {
log(`Failed to delete ${key}:`, err.message);
}
});
} catch (error) {
log("Download error:", error.message);
return res.status(404).json({
success: false,
message: "Document not found",
});
}
};
/**
* Cancel/delete a job
*/
exports.cancelJob = async (req, res) => {
try {
const { jobId } = req.params;
if (!jobId) {
return res.status(400).json({
success: false,
message: "jobId is required",
});
}
const job = await documentQueue.getJob(jobId);
if (!job) {
return res.status(404).json({
success: false,
message: "Job not found",
});
}
await job.remove();
return res.json({
success: true,
message: "Job cancelled successfully",
jobId,
});
} catch (error) {
log("Error cancelling job:", error.message);
return res.status(500).json({
success: false,
message: error.message,
});
}
};
exports.getAvailableDocumentTypes = async (_req, res) => res.json({ success: true, data: Object.keys(registry) });
exports.getSavedDocuments = async (req, res, next) => { try { const docs = await db.Document.findAll({ where: { owner_id: req.user.id }, attributes: { exclude: ["data"] }, order: [["createdAt", "DESC"]] }); return res.json({ success: true, data: docs.map(publicDoc) }); } catch (e) { return next(e); } };
exports.getDocumentData = async (req, res, next) => { try { const doc = await db.Document.findByPk(req.params.docId); if (!doc) return res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Document not found" } }); if (!allowed(req.user, doc)) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } }); return res.json({ success: true, data: { ...publicDoc(doc), documentData: doc.data } }); } catch (e) { return next(e); } };
exports.generateReferenceNo = (_req, res) => res.status(410).json({ success: false, error: { code: "DEPRECATED", message: "Reference numbers are assigned during document creation" } });
exports.generateDraftDocument = async (req, res, next) => { try { const parsed = requestSchema.safeParse({ ...req.body, documentType: req.body.documentType || "pdf" }); if (!parsed.success) return res.status(400).json({ success: false, error: { code: "VALIDATION_ERROR", message: "Invalid document request" } }); const key = normalize(parsed.data.document); if (!registry[key]) return res.status(400).json({ success: false, error: { code: "INVALID_DOCUMENT_TYPE", message: "Unsupported document type" } }); const doc = await db.Document.create({ doc_id: crypto.randomUUID(), reference_no: "N/A", doc_type: key.toUpperCase(), data: parsed.data.documentData || parsed.data.data || {}, status: "DRAFT", created_by: req.user.id, owner_type: "USER", owner_id: req.user.id }); return res.status(201).json({ success: true, data: publicDoc(doc) }); } catch (e) { return next(e); } };
exports.generateDocument = async (req, res, next) => { try { const parsed = requestSchema.safeParse(req.body); if (!parsed.success) return res.status(400).json({ success: false, error: { code: "VALIDATION_ERROR", message: "Invalid document request", details: parsed.error.issues.map(i => ({ path: i.path.join("."), message: i.message })) } }); const key = normalize(parsed.data.document); const entry = registry[key]; if (!entry || (parsed.data.documentType === "excel" && !entry.excelBuilder)) return res.status(400).json({ success: false, error: { code: "INVALID_DOCUMENT_TYPE", message: "Document generator is unavailable" } }); const id = crypto.randomUUID(); const data = parsed.data.documentData || parsed.data.data || {}; const doc = await db.Document.create({ doc_id: id, reference_no: data.reference_no || "N/A", doc_type: key.toUpperCase(), data, status: "QUEUED", created_by: req.user.id, owner_type: "USER", owner_id: req.user.id, job_id: `document-${id}` }); try { await documentQueue.add("generate-document", { documentId: id, document: key, documentType: parsed.data.documentType, data }, { jobId: `document-${id}` }); } catch (e) { await doc.update({ status: "FAILED", failed_at: new Date(), failure_code: "QUEUE_FAILED" }); throw e; } return res.status(202).json({ success: true, data: publicDoc(doc) }); } catch (e) { return next(e); } };
exports.getJobStatus = async (req, res, next) => { try { const doc = await db.Document.findOne({ where: { job_id: req.params.jobId } }); if (!doc) return res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Job not found" } }); if (!allowed(req.user, doc)) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } }); return res.json({ success: true, data: publicDoc(doc) }); } catch (e) { return next(e); } };
exports.downloadDocument = async (req, res, next) => { try { const doc = await db.Document.findByPk(req.params.docId, { include: [{ model: db.Upload, as: "storageUpload" }] }); if (!doc || doc.status !== "COMPLETED" || !doc.storageUpload) return res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Document not available" } }); if (!allowed(req.user, doc)) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } }); const expiresIn = Number(process.env.S3_SIGNED_URL_TTL_SECONDS || 900); return res.json({ success: true, data: { url: await storage.createSignedDownloadUrl(doc.storageUpload.file_path, expiresIn), expiresIn } }); } catch (e) { return next(e); } };
exports.cancelJob = async (req, res, next) => { try { const doc = await db.Document.findOne({ where: { job_id: req.params.jobId } }); if (!doc) return res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Job not found" } }); if (!allowed(req.user, doc, "documents.delete")) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } }); const job = await documentQueue.getJob(doc.job_id); if (job) await job.remove(); await doc.update({ status: "FAILED", failed_at: new Date(), failure_code: "CANCELLED" }); return res.json({ success: true }); } catch (e) { return next(e); } };
+1
View File
@@ -0,0 +1 @@
const db=require("../models"),help=require("../services/help/help.service"),{resolveLocale}=require("../services/catalogue/locale.service");const locale=req=>resolveLocale(req);exports.categories=async(req,res,next)=>{try{res.json({success:true,data:await help.categories(locale(req))});}catch(e){next(e);}};exports.category=async(req,res,next)=>{try{const row=await db.HelpCategory.findOne({where:{slug:req.params.slug,status:"ACTIVE"}});if(!row)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Help category not found"}});res.json({success:true,data:{category:(await help.categories(locale(req))).find(x=>x.id===row.id),articles:await help.articles({locale:locale(req),categoryId:row.id})}});}catch(e){next(e);}};exports.articles=async(req,res,next)=>{try{res.json({success:true,data:await help.articles({locale:locale(req),categoryId:req.query.category,featured:req.query.featured==null?undefined:req.query.featured==="true",limit:Math.min(+req.query.limit||20,50)})});}catch(e){next(e);}};exports.article=async(req,res,next)=>{try{const a=await db.HelpArticle.findOne({where:{slug:req.params.slug,status:"PUBLISHED"}});if(!a)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Help article not found"}});const tr=help.pick(await db.HelpArticleTranslation.findAll({where:{article_id:a.id}}),locale(req));res.json({success:true,data:{id:a.id,slug:a.slug,categoryId:a.category_id,type:a.article_type,title:tr?.title||null,summary:tr?.summary||null,body:tr?.body||null,locale:tr?.locale||locale(req),isFeatured:a.is_featured,publishedAt:a.published_at}});}catch(e){next(e);}};exports.search=async(req,res,next)=>{try{const q=String(req.query.q||"").trim();if(q.length<2||q.length>100)return res.status(400).json({success:false,error:{code:"INVALID_QUERY",message:"q must contain 2-100 characters"}});res.json({success:true,data:await help.articles({locale:locale(req),q,limit:Math.min(+req.query.limit||20,50)})});}catch(e){next(e);}};
+1
View File
@@ -0,0 +1 @@
const db=require("../models"),help=require("../services/help/help.service");exports.categories=async(req,res,next)=>{try{res.json({success:true,data:await db.HelpCategory.findAll({order:[["sort_order","ASC"]]})});}catch(e){next(e);}};exports.createCategory=async(req,res,next)=>{try{res.status(201).json({success:true,data:await help.createCategory(req.body)});}catch(e){next(e);}};exports.articles=async(req,res,next)=>{try{res.json({success:true,data:await db.HelpArticle.findAll({order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.createArticle=async(req,res,next)=>{try{res.status(201).json({success:true,data:await help.createArticle(req.user,req.body)});}catch(e){next(e);}};exports.publish=status=>async(req,res,next)=>{try{res.json({success:true,data:await help.publish(req.params.id,req.user,status)});}catch(e){next(e);}};
@@ -0,0 +1,11 @@
const crypto=require("crypto"),db=require("../../models"),inventory=require("../../services/inventory/inventory.service"),{logActivity}=require("../../services/activity.service");
const audit=(req,type,id)=>logActivity({user:req.user,type,module:"Inventory",description:type,targetType:"INVENTORY",targetId:id,requestId:req.id});
exports.list=async(req,res,next)=>{try{const rows=await db.InventoryBalance.findAll({include:[{model:db.Warehouse,as:"warehouse",attributes:["id","code","name"]},{model:db.ProductVariant,as:"variant",include:[{model:db.Product,as:"product",attributes:["id","slug"]}]}]});res.json({success:true,data:rows});}catch(e){next(e);}};
exports.detail=async(req,res,next)=>{try{const rows=await db.InventoryBalance.findAll({where:{variant_id:req.params.variantId},include:[{model:db.Warehouse,as:"warehouse"}]});res.json({success:true,data:rows});}catch(e){next(e);}};
exports.ledger=async(req,res,next)=>{try{res.json({success:true,data:await db.InventoryTransaction.findAll({where:req.query.variantId?{variant_id:req.query.variantId}:{},limit:Math.min(Number(req.query.limit)||50,200),order:[["occurred_at","DESC"]]})});}catch(e){next(e);}};
exports.lowStock=async(req,res,next)=>{try{const rows=await db.InventoryBalance.findAll();res.json({success:true,data:rows.filter(x=>Number(x.on_hand)-Number(x.reserved)<=Number(x.low_stock_threshold))});}catch(e){next(e);}};
exports.adjust=async(req,res,next)=>{try{const eventId=req.get("Idempotency-Key")||crypto.randomUUID(),result=await inventory.adjustStock({...req.body,eventId,actorUserId:req.user.id,requestId:req.id});await audit(req,"INVENTORY_ADJUSTED",eventId);res.status(result.idempotent?200:201).json({success:true,data:result});}catch(e){next(e);}};
exports.transfer=async(req,res,next)=>{try{const eventId=req.get("Idempotency-Key")||crypto.randomUUID(),result=await inventory.transferStock({...req.body,eventId,actorUserId:req.user.id,requestId:req.id});await audit(req,"INVENTORY_TRANSFERRED",eventId);res.status(result.idempotent?200:201).json({success:true,data:result});}catch(e){next(e);}};
exports.warehouses=async(req,res,next)=>{try{res.json({success:true,data:await db.Warehouse.findAll({order:[["code","ASC"]]})});}catch(e){next(e);}};
exports.createWarehouse=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{if(req.body.isDefault)await db.Warehouse.update({is_default:false},{where:{},transaction:t});row=await db.Warehouse.create({id:crypto.randomUUID(),code:req.body.code.toUpperCase(),name:req.body.name,status:req.body.status,is_default:req.body.isDefault,timezone:req.body.timezone,city:req.body.city,country_code:req.body.countryCode},{transaction:t});});await audit(req,"WAREHOUSE_CREATED",row.id);res.status(201).json({success:true,data:row});}catch(e){next(e);}};
exports.updateWarehouse=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{row=await db.Warehouse.findByPk(req.params.id,{transaction:t,lock:t.LOCK.UPDATE});if(!row)throw Object.assign(new Error("Warehouse not found"),{status:404,code:"NOT_FOUND"});if(req.body.isDefault)await db.Warehouse.update({is_default:false},{where:{},transaction:t});await row.update({name:req.body.name,status:req.body.status,is_default:req.body.isDefault,timezone:req.body.timezone,city:req.body.city,country_code:req.body.countryCode},{transaction:t});});await audit(req,"WAREHOUSE_UPDATED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};
@@ -0,0 +1 @@
const crypto=require("crypto"),{Op}=require("sequelize"),db=require("../../models"),service=require("../../services/logistics/shipment.service"),state=require("../../services/logistics/shipmentState.service"),{logActivity}=require("../../services/activity.service");const key=req=>req.get("Idempotency-Key")||crypto.randomUUID(),audit=(req,type,id)=>logActivity({user:req.user,type,module:"Logistics",targetType:"SHIPMENT",targetId:id,requestId:req.id});exports.shipments=async(req,res,next)=>{try{const where={};if(req.query.status)where.status=req.query.status;res.json({success:true,data:await db.Shipment.findAll({where,include:[{model:db.ShipmentItem,as:"items"},{model:db.ShipmentAssignment,as:"assignments"}],order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.shipment=async(req,res,next)=>{try{const row=await db.Shipment.findByPk(req.params.id,{include:[{model:db.ShipmentItem,as:"items"},{model:db.ShipmentAssignment,as:"assignments"},{model:db.ShipmentEvent,as:"events"},{model:db.ShipmentProof,as:"proofs"}]});if(!row)throw Object.assign(new Error("Shipment not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:row});}catch(e){next(e);}};exports.create=async(req,res,next)=>{try{const result=await service.createDelivery({...req.body,actorUserId:req.user.id,eventId:`create:${key(req)}`});await audit(req,"SHIPMENT_CREATED",result.shipment.id);res.status(result.idempotent?200:201).json({success:true,data:result.shipment});}catch(e){next(e);}};exports.returnPickup=async(req,res,next)=>{try{const result=await service.createReturnPickup({...req.body,actorUserId:req.user.id,eventId:`return-pickup:${key(req)}`});res.status(result.idempotent?200:201).json({success:true,data:result.shipment});}catch(e){next(e);}};exports.assign=async(req,res,next)=>{try{const result=await service.assign({shipmentId:req.params.id,riderId:req.body.riderId,actorUserId:req.user.id,eventId:`assign:${key(req)}`});await audit(req,"RIDER_ASSIGNED",result.shipment.id);res.json({success:true,data:result.shipment});}catch(e){next(e);}};exports.unassign=async(req,res,next)=>{try{const row=await service.unassign({shipmentId:req.params.id,actorUserId:req.user.id,eventId:`unassign:${key(req)}`,reason:req.body.note});await audit(req,"RIDER_UNASSIGNED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};exports.reschedule=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{row=await db.Shipment.findByPk(req.params.id,{transaction:t,lock:t.LOCK.UPDATE});if(!row)throw Object.assign(new Error("Shipment not found"),{status:404,code:"NOT_FOUND"});state.assertTransition(row.status,"RESCHEDULED");await row.update({status:"RESCHEDULED",scheduled_date:req.body.scheduledDate,time_window_start:req.body.timeWindowStart,time_window_end:req.body.timeWindowEnd,notes:req.body.reason},{transaction:t});await db.ShipmentEvent.create({id:crypto.randomUUID(),shipment_id:row.id,event_id:`reschedule:${key(req)}`,type:"SHIPMENT_RESCHEDULED",status:"RESCHEDULED",actor_user_id:req.user.id,note:req.body.reason,occurred_at:new Date()},{transaction:t});});await audit(req,"SHIPMENT_RESCHEDULED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};exports.cancel=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{row=await db.Shipment.findByPk(req.params.id,{transaction:t,lock:t.LOCK.UPDATE});state.assertTransition(row.status,"CANCELLED");await row.update({status:"CANCELLED",cancelled_at:new Date()},{transaction:t});});await audit(req,"SHIPMENT_CANCELLED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};exports.dispatch=async(req,res,next)=>{try{res.json({success:true,data:{unassigned:await db.Shipment.findAll({where:{status:"READY_FOR_ASSIGNMENT"}}),active:await db.Shipment.findAll({where:{status:{[Op.in]:["ASSIGNED","PICKUP_PENDING","PICKED_UP","IN_TRANSIT","OUT_FOR_DELIVERY"]}}}),attention:await db.Shipment.findAll({where:{status:{[Op.in]:["DELIVERY_FAILED","RESCHEDULED"]}}})}});}catch(e){next(e);}};
@@ -0,0 +1,4 @@
const crypto=require("crypto"),{Op}=require("sequelize"),db=require("../../models"),service=require("../../services/logistics/shipment.service"),{logActivity}=require("../../services/activity.service");const key=req=>req.get("Idempotency-Key")||crypto.randomUUID();exports.list=async(req,res,next)=>{try{const rider=await db.RiderProfile.findOne({where:{user_id:req.user.id,status:"ACTIVE"}});if(!rider)throw Object.assign(new Error("Active rider required"),{status:403,code:"RIDER_UNAVAILABLE"});res.json({success:true,data:await db.Shipment.findAll({where:{assigned_rider_id:rider.id},include:[{model:db.ShipmentItem,as:"items"}],order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.get=async(req,res,next)=>{try{const rider=await db.RiderProfile.findOne({where:{user_id:req.user.id,status:"ACTIVE"}}),row=rider&&await db.Shipment.findOne({where:{id:req.params.id,assigned_rider_id:rider.id},include:[{model:db.ShipmentItem,as:"items"},{model:db.ShipmentEvent,as:"events"}]});if(!row)throw Object.assign(new Error("Assigned shipment not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:{id:row.id,shipmentNumber:row.shipment_number,recipientName:row.recipient_name,recipientPhone:row.recipient_phone,address:row.address_snapshot,status:row.status,items:row.items,events:row.events}});}catch(e){next(e);}};const action=to=>async(req,res,next)=>{try{const result=await service.riderAction({shipmentId:req.params.id,userId:req.user.id,to,eventId:`${to}:${key(req)}`,note:req.body.note,proof:req.body.type?req.body:undefined});await logActivity({user:req.user,type:`SHIPMENT_${to}`,module:"Logistics",targetId:result.shipment.id,requestId:req.id});res.json({success:true,data:result.shipment});}catch(e){next(e);}};exports.accept=action("PICKUP_PENDING");exports.pickup=action("PICKED_UP");exports.inTransit=action("IN_TRANSIT");exports.outForDelivery=action("OUT_FOR_DELIVERY");exports.deliver=action("DELIVERED");exports.fail=action("DELIVERY_FAILED");exports.location=async(req,res,next)=>{try{const rider=await db.RiderProfile.findOne({where:{user_id:req.user.id,status:"ACTIVE"}});if(!rider)throw Object.assign(new Error("Active rider required"),{status:403,code:"RIDER_UNAVAILABLE"});await rider.update({current_latitude:req.body.latitude,current_longitude:req.body.longitude,last_location_at:new Date()});res.status(204).end();}catch(e){next(e);}};
exports.reject=async(req,res,next)=>{try{const rider=await db.RiderProfile.findOne({where:{user_id:req.user.id,status:"ACTIVE"}}),shipment=rider&&await db.Shipment.findOne({where:{id:req.params.id,assigned_rider_id:rider.id}});if(!shipment)throw Object.assign(new Error("Assigned shipment not found"),{status:404,code:"NOT_FOUND"});const row=await service.unassign({shipmentId:shipment.id,actorUserId:req.user.id,eventId:`reject:${key(req)}`,reason:req.body.note});await db.ShipmentAssignment.update({status:"REJECTED",rejected_at:new Date()},{where:{shipment_id:shipment.id,rider_id:rider.id,status:"UNASSIGNED"}});res.json({success:true,data:row});}catch(e){next(e);}};
exports.adminUpdate=async(req,res,next)=>{try{const row=await db.RiderProfile.findByPk(req.params.id);if(!row)throw Object.assign(new Error("Rider not found"),{status:404,code:"NOT_FOUND"});const b=req.body;await row.update({employee_code:b.employeeCode,status:b.status,availability_status:b.availabilityStatus,phone_override:b.phoneOverride,vehicle_type:b.vehicleType,vehicle_registration:b.vehicleRegistration,max_active_assignments:b.maxActiveAssignments});await logActivity({user:req.user,type:"RIDER_UPDATED",module:"Logistics",targetId:row.id,requestId:req.id});res.json({success:true,data:row});}catch(e){next(e);}};
exports.adminList=async(req,res,next)=>{try{res.json({success:true,data:await db.RiderProfile.findAll({include:[{model:db.User,as:"user",attributes:["id","firstName","lastName","accountStatus"]}]})});}catch(e){next(e);}};exports.adminGet=async(req,res,next)=>{try{const row=await db.RiderProfile.findByPk(req.params.id,{include:[{model:db.User,as:"user",attributes:["id","firstName","lastName","accountStatus"]}]});if(!row)throw Object.assign(new Error("Rider not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:row});}catch(e){next(e);}};exports.adminCreate=async(req,res,next)=>{try{const user=await db.User.findOne({where:{id:req.body.userId,accountType:"rider"}});if(!user)throw Object.assign(new Error("Existing RIDER user required"),{status:400,code:"RIDER_USER_REQUIRED"});const b=req.body,row=await db.RiderProfile.create({id:crypto.randomUUID(),user_id:user.id,employee_code:b.employeeCode,status:b.status,availability_status:b.availabilityStatus,phone_override:b.phoneOverride,vehicle_type:b.vehicleType,vehicle_registration:b.vehicleRegistration,max_active_assignments:b.maxActiveAssignments});await logActivity({user:req.user,type:"RIDER_CREATED",module:"Logistics",targetId:row.id,requestId:req.id});res.status(201).json({success:true,data:row});}catch(e){next(e);}};exports.assignments=async(req,res,next)=>{try{res.json({success:true,data:await db.ShipmentAssignment.findAll({where:{rider_id:req.params.id},order:[["assigned_at","DESC"]]})});}catch(e){next(e);}};
@@ -0,0 +1 @@
const db=require("../../models");const project=s=>({shipmentNumber:s.shipment_number,type:s.type,status:s.status,shippingMethod:s.shipping_method_name,deliveredAt:s.delivered_at,events:s.events.map(e=>({type:e.type,status:e.status,note:e.note,occurredAt:e.occurred_at}))});exports.order=async(req,res,next)=>{try{const order=await db.Order.findOne({where:{id:req.params.orderId,user_id:req.user.id}});if(!order)throw Object.assign(new Error("Order not found"),{status:404,code:"NOT_FOUND"});const shipments=await db.Shipment.findAll({where:{order_id:order.id,type:"CUSTOMER_DELIVERY"},include:[{model:db.ShipmentEvent,as:"events",attributes:["type","status","note","occurred_at"]}],order:[["createdAt","ASC"]]});res.json({success:true,data:{orderNumber:order.order_number,shipments:shipments.map(project)}});}catch(e){next(e);}};exports.return=async(req,res,next)=>{try{const request=await db.ReturnRequest.findOne({where:{id:req.params.id,user_id:req.user.id}});if(!request)throw Object.assign(new Error("Return not found"),{status:404,code:"NOT_FOUND"});const shipment=await db.Shipment.findOne({where:{return_request_id:request.id,type:"RETURN_PICKUP"},include:[{model:db.ShipmentEvent,as:"events",attributes:["type","status","note","occurred_at"]}]});res.json({success:true,data:{returnNumber:request.return_number,shipment:shipment&&project(shipment)}});}catch(e){next(e);}};
@@ -0,0 +1 @@
const crypto=require("crypto"),db=require("../../models"),loyalty=require("../../services/loyalty/loyalty.service"),{logActivity}=require("../../services/activity.service");const list=model=>async(req,res,next)=>{try{res.json({success:true,data:await model.findAll({order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.accounts=list(db.LoyaltyAccount);exports.tiers=list(db.LoyaltyTier);exports.rules=list(db.LoyaltyEarnRule);exports.rewards=list(db.LoyaltyReward);exports.referrals=list(db.Referral);exports.adjust=async(req,res,next)=>{try{const result=await loyalty.post({userId:req.body.userId,eventId:`LOYALTY:ADMIN:${req.get("Idempotency-Key")}`,type:"ADJUSTMENT",sourceType:"ADMIN_ADJUSTMENT",points:req.body.pointsDelta,descriptionCode:req.body.reasonCode,metadata:{note:req.body.note,actorUserId:req.user.id}});await logActivity({user:req.user,type:"LOYALTY_POINTS_ADJUSTED",module:"Loyalty",targetId:result.entry.id,requestId:req.id});res.status(result.idempotent?200:201).json({success:true,data:result.entry});}catch(e){next(e);}};exports.createTier=async(req,res,next)=>{try{const b=req.body,row=await db.LoyaltyTier.create({id:crypto.randomUUID(),code:b.code.toUpperCase(),name:b.name,status:b.status,rank:b.rank,qualification_threshold:b.qualificationThreshold,qualification_metric:"LIFETIME_POINTS",benefits_json:b.benefits,sort_order:b.sortOrder});res.status(201).json({success:true,data:row});}catch(e){next(e);}};exports.createRule=async(req,res,next)=>{try{const b=req.body,row=await db.LoyaltyEarnRule.create({id:crypto.randomUUID(),source_type:b.sourceType,status:b.status,points_per_amount:b.pointsPerAmount,amount_unit:b.amountUnit,fixed_points:b.fixedPoints,minimum_amount:b.minimumAmount,maximum_points:b.maximumPoints,expiry_days:b.expiryDays,effective_from:b.effectiveFrom,effective_to:b.effectiveTo});res.status(201).json({success:true,data:row});}catch(e){next(e);}};exports.createReward=async(req,res,next)=>{try{const b=req.body,row=await db.LoyaltyReward.create({id:crypto.randomUUID(),code:b.code.toUpperCase(),name:b.name,description:b.description,type:b.type,points_cost:b.pointsCost,status:b.status,starts_at:b.startsAt,ends_at:b.endsAt,stock_limit:b.stockLimit,per_user_limit:b.perUserLimit,configuration:b.configuration});res.status(201).json({success:true,data:row});}catch(e){next(e);}};
@@ -0,0 +1 @@
const{Op}=require("sequelize"),db=require("../../models"),loyalty=require("../../services/loyalty/loyalty.service"),referrals=require("../../services/loyalty/referral.service");exports.summary=async(req,res,next)=>{try{const account=await db.sequelize.transaction(t=>loyalty.accountFor(req.user.id,t)),tier=account.current_tier_id&&await db.LoyaltyTier.findByPk(account.current_tier_id),next=await db.LoyaltyTier.findOne({where:{status:"ACTIVE",qualification_threshold:{[Op.gt]:account.lifetime_points_earned}},order:[["qualification_threshold","ASC"]]});res.json({success:true,data:{status:account.status,availablePoints:account.available_points,pendingPoints:account.pending_points,pointsDebt:account.points_debt,lifetimePointsEarned:account.lifetime_points_earned,tier:tier&&{code:tier.code,name:tier.name,benefits:tier.benefits_json},nextTier:next&&{name:next.name,pointsRequired:next.qualification_threshold-account.lifetime_points_earned}}});}catch(e){next(e);}};exports.history=async(req,res,next)=>{try{const account=await db.LoyaltyAccount.findOne({where:{user_id:req.user.id}});if(!account)return res.json({success:true,data:[],pagination:{page:1,total:0}});const page=Math.max(Number(req.query.page)||1,1),limit=Math.min(Number(req.query.limit)||20,100),x=await db.LoyaltyLedgerEntry.findAndCountAll({where:{loyalty_account_id:account.id},limit,offset:(page-1)*limit,order:[["occurred_at","DESC"]]});res.json({success:true,data:x.rows,pagination:{page,limit,total:x.count}});}catch(e){next(e);}};exports.tiers=async(req,res,next)=>{try{res.json({success:true,data:await db.LoyaltyTier.findAll({where:{status:"ACTIVE"},order:[["rank","ASC"]]})});}catch(e){next(e);}};exports.rewards=async(req,res,next)=>{try{res.json({success:true,data:await db.LoyaltyReward.findAll({where:{status:"ACTIVE"},order:[["points_cost","ASC"]]})});}catch(e){next(e);}};exports.redeem=async(req,res,next)=>{try{const result=await loyalty.redeem({userId:req.user.id,rewardId:req.body.rewardId,idempotencyKey:req.get("Idempotency-Key")});res.status(result.idempotent?200:201).json({success:true,data:result.redemption});}catch(e){next(e);}};exports.vouchers=async(req,res,next)=>{try{res.json({success:true,data:await db.CustomerCouponEntitlement.findAll({where:{user_id:req.user.id,status:"ACTIVE"},attributes:{exclude:["user_id"]}})});}catch(e){next(e);}};exports.referral=async(req,res,next)=>{try{res.json({success:true,data:await referrals.getOrCreate(req.user.id)});}catch(e){next(e);}};exports.claimReferral=async(req,res,next)=>{try{res.status(201).json({success:true,data:await referrals.claim({code:req.body.code,userId:req.user.id})});}catch(e){next(e);}};
@@ -0,0 +1,5 @@
const crypto=require("crypto"),db=require("../../models"),{logActivity}=require("../../services/activity.service");const map=(b,u)=>({name:b.name,type:b.type,status:b.status,starts_at:b.startsAt,ends_at:b.endsAt,priority:b.priority,stackable:b.stackable,discount_percent:b.discountPercent,discount_amount:b.discountAmount,fixed_price:b.fixedPrice,minimum_quantity:b.minimumQuantity,updated_by:u});
const crud=model=>async(req,res,next)=>{try{res.json({success:true,data:await model.findAll({order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.promotions=crud(db.Promotion);exports.coupons=crud(db.Coupon);exports.banners=crud(db.Banner);
exports.createPromotion=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{row=await db.Promotion.create({id:crypto.randomUUID(),...map(req.body,req.user.id),created_by:req.user.id},{transaction:t});await db.PromotionTarget.bulkCreate(req.body.targets.map(x=>({id:crypto.randomUUID(),promotion_id:row.id,target_type:x.type,target_id:x.id||null})),{transaction:t});});await logActivity({user:req.user,type:"PROMOTION_CREATED",module:"Merchandising",targetId:row.id});res.status(201).json({success:true,data:row});}catch(e){next(e);}};
exports.createCoupon=async(req,res,next)=>{try{const b=req.body,row=await db.Coupon.create({id:crypto.randomUUID(),code:b.code,promotion_id:b.promotionId,status:b.status,starts_at:b.startsAt,expires_at:b.expiresAt});await logActivity({user:req.user,type:"COUPON_CREATED",module:"Merchandising",targetId:row.id});res.status(201).json({success:true,data:row});}catch(e){next(e);}};
exports.createBanner=async(req,res,next)=>{try{const b=req.body;let row;await db.sequelize.transaction(async t=>{const upload=await db.Upload.findOne({where:{id:b.imageUploadId,status:"AVAILABLE"},transaction:t});if(!upload||!String(upload.mime_type||upload.mimeType).startsWith("image/"))throw Object.assign(new Error("Available image upload required"),{status:400,code:"INVALID_BANNER_MEDIA"});row=await db.Banner.create({id:crypto.randomUUID(),placement:b.placement,status:b.status,starts_at:b.startsAt,ends_at:b.endsAt,image_upload_id:b.imageUploadId,mobile_image_upload_id:b.mobileImageUploadId,target_url:b.targetUrl,audience_type:b.audienceType,business_tier_id:b.businessTierId,sort_order:b.sortOrder,created_by:req.user.id},{transaction:t});await db.BannerTranslation.bulkCreate(b.translations.map(x=>({id:crypto.randomUUID(),banner_id:row.id,locale:x.locale,headline:x.headline,subheadline:x.subheadline,cta_text:x.ctaText,alt_text:x.altText})),{transaction:t});});await logActivity({user:req.user,type:"BANNER_CREATED",module:"Merchandising",targetId:row.id});res.status(201).json({success:true,data:row});}catch(e){next(e);}};
@@ -0,0 +1,2 @@
const {Op}=require("sequelize"),db=require("../../models");exports.banners=async(req,res,next)=>{try{const now=new Date(),audience=req.user?.accountType==="BUSINESS_CUSTOMER"?["ALL","BUSINESS_CUSTOMER"]:req.user?["ALL","CUSTOMER"]:["ALL"];const where={status:"ACTIVE",audience_type:{[Op.in]:audience},[Op.and]:[{[Op.or]:[{starts_at:null},{starts_at:{[Op.lte]:now}}]},{[Op.or]:[{ends_at:null},{ends_at:{[Op.gt]:now}}]}]};if(req.query.placement)where.placement=req.query.placement;const rows=await db.Banner.findAll({where,include:[{model:db.BannerTranslation,as:"translations"}],order:[["sort_order","ASC"],["id","ASC"]]});res.json({success:true,data:rows.map(x=>({id:x.id,placement:x.placement,targetUrl:x.target_url,image:{uploadId:x.image_upload_id},translation:(x.translations.find(t=>t.locale===(req.query.locale||"en"))||x.translations.find(t=>t.locale==="en")||x.translations[0])}))});}catch(e){next(e);}};
exports.availability=async(req,res,next)=>{try{const rows=await db.InventoryBalance.findAll({where:{variant_id:req.params.variantId},attributes:["on_hand","reserved","low_stock_threshold"]}),available=rows.reduce((n,x)=>n+Number(x.on_hand)-Number(x.reserved),0),threshold=rows.reduce((n,x)=>n+Number(x.low_stock_threshold),0),status=available<=0?"OUT_OF_STOCK":available<=threshold?"LOW_STOCK":"IN_STOCK";res.json({success:true,data:{variantId:req.params.variantId,status,availableForSale:available>0}});}catch(e){next(e);}};
+1
View File
@@ -0,0 +1 @@
const metrics=require("../services/metrics.service");exports.read=(_req,res)=>res.type("text/plain; version=0.0.4").send(metrics.render());
+1
View File
@@ -0,0 +1 @@
const db=require("../models"),service=require("../services/marketing/newsletter.service");exports.subscribe=async(req,res,next)=>{try{const x=await service.subscribe({...req.body,userId:req.user?.id});res.status(x.idempotent?200:201).json({success:true,message:"Subscription request processed",data:{status:x.subscription.status}});}catch(e){next(e);}};exports.unsubscribe=async(req,res,next)=>{try{await service.unsubscribe(req.params.token);res.json({success:true,message:"Unsubscribe request processed"});}catch(e){next(e);}};exports.mine=async(req,res,next)=>{try{res.json({success:true,data:await db.NewsletterSubscription.findOne({where:{user_id:req.user.id},attributes:{exclude:["unsubscribe_token_hash"]}})});}catch(e){next(e);}};exports.list=async(req,res,next)=>{try{const page=Math.max(+req.query.page||1,1),limit=Math.min(+req.query.limit||50,100),x=await db.NewsletterSubscription.findAndCountAll({attributes:{exclude:["unsubscribe_token_hash"]},limit,offset:(page-1)*limit,order:[["createdAt","DESC"]]});res.json({success:true,data:x.rows,pagination:{page,limit,total:x.count}});}catch(e){next(e);}};
+16 -134
View File
@@ -1,138 +1,20 @@
/**
* Copyright (c) 2026 Niolla
* All rights reserved.
*
* This source code is proprietary and confidential.
* Unauthorized copying, modification, distribution, or use
* of this file, via any medium, is strictly prohibited.
*/
// app/controllers/notification.controller.js
const crypto = require("crypto");
const db = require("../models");
const Notification = db.notification;
const UserNotification = db.userNotification;
const { Op } = require("sequelize");
const log = require("../utils/consoleLog.utill").log;
// Controller for managing notifications
exports.createNotification = async (req, res) => {
exports.createNotification = async (req, res, next) => {
const { notificationHeadline, notificationDescription, notificationType, userIds = [] } = req.body;
if (!notificationHeadline || !["USER", "ANNOUNCEMENT"].includes(notificationType)) return res.status(400).json({ success: false, error: { code: "VALIDATION_ERROR", message: "Valid headline and notificationType are required" } });
if (notificationType === "USER" && (!Array.isArray(userIds) || !userIds.length)) return res.status(400).json({ success: false, error: { code: "VALIDATION_ERROR", message: "USER notifications require userIds" } });
const transaction = await db.sequelize.transaction();
try {
const { notificationHeadline, notificationDescription, notificationType } =
req.body;
const id = Date.now().toString(); // Generate a unique ID based on the current timestamp
const notification_id = `notif_${id}`; // Prefix the ID with "notif_"
// Create a new notification
const newNotification = await Notification.create({
notification_id,
notificationHeadline,
notificationDescription,
notificationType,
dateCreated: new Date(),
});
res.status(201).json({
success: true,
message: "Notification created successfully",
notification: newNotification,
});
} catch (error) {
log("Error creating notification:", error.message);
res.status(500).json({
success: false,
message: "Internal server error",
error: error.message
});
}
const notification = await db.notification.create({ notification_id: `notif_${crypto.randomUUID()}`, notificationHeadline, notificationDescription, notificationType, dateCreated: new Date() }, { transaction });
const uniqueUsers = [...new Set(userIds)];
if (uniqueUsers.length) await db.userNotification.bulkCreate(uniqueUsers.map((user_id) => ({ user_id, notification_id: notification.notification_id })), { transaction, ignoreDuplicates: true });
await transaction.commit(); return res.status(201).json({ success: true, data: { notification, assignedUsers: uniqueUsers.length } });
} catch (error) { await transaction.rollback(); return next(error); }
};
// Mark a notification as read for a user
exports.markAsRead = async (req, res) => {
try {
const { userId, notificationId } = req.params;
// Find the user notification entry
const userNotification = await UserNotification.findOne({
where: { user_id: userId, notification_id: notificationId },
});
if (!userNotification) {
return res.status(404).json({ success: false, error: "User notification not found" });
}
// Mark the notification as read
userNotification.isRead = true;
await userNotification.save();
res.status(200).json({
success: true,
message: "Notification marked as read",
});
}
catch (error) {
log("Error marking notification as read:", error.message);
res.status(500).json({
success: false,
message: "Internal server error",
error: error.message
});
}
};
// Get announcements for all users
exports.getAnnouncements = async (req, res) => {
try {
// Fetch all announcements
const announcements = await Notification.findAll({
where: { notificationType: "ANNOUNCEMENT", isActive: true },
});
res.status(200).json({
success: true,
announcements,
});
} catch (error) {
log("Error fetching announcements:", error.message);
res.status(500).json({
success: false,
message: "Internal server error",
error: error.message
});
}
}
// Get all notifications for a user
exports.getUserNotifications = async (req, res) => {
try {
const { userId } = req.params;
// Fetch notifications for the user
const notifications = await UserNotification.findAll({
where: { user_id: userId, isRead: false },
include: [
{
model: Notification,
as: "notification",
},
],
});
res.status(200).json({
success: true,
notifications,
});
}
catch (error) {
log("Error fetching user notifications:", error.message);
res.status(500).json({
success: false,
message: "Internal server error",
error: error.message
});
}
};
exports.getAnnouncements = async (_req, res, next) => { try { return res.json({ success: true, data: await db.notification.findAll({ where: { notificationType: "ANNOUNCEMENT", isActive: true }, order: [["createdAt", "DESC"]] }) }); } catch (e) { return next(e); } };
exports.getMyNotifications = async (req, res, next) => { try { const rows = await db.userNotification.findAll({ where: { user_id: req.user.id }, include: [{ model: db.notification, as: "notification", where: { isActive: true } }], order: [["createdAt", "DESC"]] }); return res.json({ success: true, data: rows }); } catch (e) { return next(e); } };
exports.markAsRead = async (req, res, next) => { try { const [count] = await db.userNotification.update({ isRead: true }, { where: { user_id: req.user.id, notification_id: req.params.notificationId } }); if (!count) return res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Notification not found" } }); return res.json({ success: true }); } catch (e) { return next(e); } };
exports.markAllAsRead = async (req, res, next) => { try { const [count] = await db.userNotification.update({ isRead: true }, { where: { user_id: req.user.id, isRead: false } }); return res.json({ success: true, data: { updated: count } }); } catch (e) { return next(e); } };
@@ -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);}};
+6 -4
View File
@@ -28,20 +28,21 @@ const { log } = require("../utils/consoleLog.utill");
// Get Profile avatar by user ID
exports.getProfileAvatar = async (req, res) => {
try {
const userId = req.params.userId;
const userId = req.params.userId || req.user.id;
const profile = await Profile.findOne({ where: { user_id: userId } });
if (!profile) {
return res.status(404).json({ error: "Profile not found" });
}
const uploadRecord = await Upload.findByPk(profile.profilePicture_id);
if (!uploadRecord || uploadRecord.status !== "AVAILABLE" || (uploadRecord.visibility !== "PUBLIC" && uploadRecord.owner_id !== userId)) return res.status(404).json({ success: false, message: "Profile image not found" });
const fileUrl = await getSignedFileUrl(uploadRecord.file_path);
res.json({
success: true,
data: {
userId: profile.userId,
userId: profile.user_id,
profilePictureUrl: fileUrl,
},
});
@@ -58,7 +59,7 @@ exports.getProfileAvatar = async (req, res) => {
// Get profile background image by user ID
exports.getProfileBackgroundImage = async (req, res) => {
try {
const userId = req.params.userId;
const userId = req.params.userId || req.user.id;
const profile = await Profile.findOne({ where: { user_id: userId } });
if (!profile) {
return res
@@ -67,13 +68,14 @@ exports.getProfileBackgroundImage = async (req, res) => {
}
const uploadRecord = await Upload.findByPk(profile.backgroundImage_id);
if (!uploadRecord || uploadRecord.status !== "AVAILABLE" || (uploadRecord.visibility !== "PUBLIC" && uploadRecord.owner_id !== userId)) return res.status(404).json({ success: false, message: "Profile background not found" });
const fileUrl = await getSignedFileUrl(uploadRecord.file_path);
res.json({
success: true,
data: {
userId: profile.userId,
userId: profile.user_id,
backgroundImageUrl: fileUrl,
},
});
@@ -0,0 +1 @@
const service=require("../services/recommendation/recommendation.service"),{resolveLocale}=require("../services/catalogue/locale.service");const loc=req=>resolveLocale(req),limit=req=>Math.min(Math.max(+req.query.limit||12,1),24);exports.event=async(req,res,next)=>{try{const x=await service.recordView({...req.body,userId:req.user?.id});res.status(x.created?201:200).json({success:true,data:{accepted:true,idempotent:!x.created}});}catch(e){next(e);}};exports.related=async(req,res,next)=>{try{res.json({success:true,data:await service.related(req.params.productId,loc(req),limit(req))});}catch(e){next(e);}};exports.trending=async(req,res,next)=>{try{res.json({success:true,data:await service.ranked({type:"trending",locale:loc(req),limit:limit(req)})});}catch(e){next(e);}};exports.popular=async(req,res,next)=>{try{res.json({success:true,data:await service.ranked({type:"popular",locale:loc(req),limit:limit(req)})});}catch(e){next(e);}};exports.recent=async(req,res,next)=>{try{res.json({success:true,data:await service.recent(req.user.id,loc(req),limit(req))});}catch(e){next(e);}};exports.forYou=async(req,res,next)=>{try{res.json({success:true,data:await service.ranked({type:"for-you",userId:req.user.id,locale:loc(req),limit:limit(req)})});}catch(e){next(e);}};
@@ -0,0 +1,21 @@
const db = require("../models");
const { clearPermissionCache } = require("../utils/cache.util");
const { logActivity } = require("../services/activity.service");
exports.assignRole = async (req, res, next) => {
try {
const [assignment] = await db.UserRole.findOrCreate({ where: { user_id: req.params.userId, role_id: req.params.roleId } });
await clearPermissionCache(req.params.userId);
await logActivity({ user: req.user, description: `Role ${req.params.roleId} assigned to user ${req.params.userId}`, type: "ROLE_ASSIGNED", module: "Authorization" });
res.status(201).json({ success: true, data: { id: assignment.id, userId: assignment.user_id, roleId: assignment.role_id } });
} catch (error) { next(error); }
};
exports.removeRole = async (req, res, next) => {
try {
await db.UserRole.destroy({ where: { user_id: req.params.userId, role_id: req.params.roleId } });
await clearPermissionCache(req.params.userId);
await logActivity({ user: req.user, description: `Role ${req.params.roleId} removed from user ${req.params.userId}`, type: "ROLE_REMOVED", module: "Authorization" });
res.json({ success: true, message: "Role removed" });
} catch (error) { next(error); }
};
@@ -0,0 +1,4 @@
const crypto=require("crypto"),db=require("../../models"),service=require("../../services/shipping/shipping.service"),cart=require("../../services/shopping/cart.service"),{logActivity}=require("../../services/activity.service");const audit=(req,type,id)=>logActivity({user:req.user,type,module:"Shipping",targetId:id,requestId:req.id});exports.quote=async(req,res,next)=>{try{const address=await db.Address.findOne({where:{id:req.body.addressId,user_id:req.user.id}});if(!address)throw Object.assign(new Error("Address not found"),{status:404,code:"NOT_FOUND"});const projection=await cart.project(req.user.id);res.json({success:true,data:await service.getAvailableMethods({address,subtotal:projection.merchandiseTotal,currency:projection.currency})});}catch(e){next(e);}};const list=model=>async(req,res,next)=>{try{res.json({success:true,data:await model.findAll()});}catch(e){next(e);}};exports.zones=list(db.ShippingZone);exports.methods=list(db.ShippingMethod);exports.rates=list(db.ShippingRate);exports.createZone=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{row=await db.ShippingZone.create({id:crypto.randomUUID(),code:req.body.code.toUpperCase(),name:req.body.name,status:req.body.status,duty_mode:req.body.dutyMode},{transaction:t});await db.ShippingZoneRegion.bulkCreate(req.body.regions.map(x=>({id:crypto.randomUUID(),shipping_zone_id:row.id,country_code:x.countryCode.toUpperCase(),province:x.province,district:x.district})),{transaction:t});});await audit(req,"SHIPPING_ZONE_CREATED",row.id);res.status(201).json({success:true,data:row});}catch(e){next(e);}};exports.createMethod=async(req,res,next)=>{try{const b=req.body,row=await db.ShippingMethod.create({id:crypto.randomUUID(),code:b.code.toUpperCase(),name:b.name,description:b.description,status:b.status,estimated_min_days:b.estimatedMinDays,estimated_max_days:b.estimatedMaxDays,tracking_supported:b.trackingSupported});await audit(req,"SHIPPING_METHOD_CREATED",row.id);res.status(201).json({success:true,data:row});}catch(e){next(e);}};exports.createRate=async(req,res,next)=>{try{const b=req.body,row=await db.ShippingRate.create({id:crypto.randomUUID(),shipping_zone_id:b.shippingZoneId,shipping_method_id:b.shippingMethodId,currency:b.currency.toUpperCase(),base_amount:b.baseAmount,free_shipping_threshold:b.freeShippingThreshold,minimum_subtotal:b.minimumSubtotal,maximum_subtotal:b.maximumSubtotal,status:b.status,starts_at:b.startsAt,ends_at:b.endsAt});await audit(req,"SHIPPING_RATE_CREATED",row.id);res.status(201).json({success:true,data:row});}catch(e){next(e);}};
exports.updateZone=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{row=await db.ShippingZone.findByPk(req.params.id,{transaction:t,lock:t.LOCK.UPDATE});if(!row)throw Object.assign(new Error("Zone not found"),{status:404,code:"NOT_FOUND"});await row.update({code:req.body.code.toUpperCase(),name:req.body.name,status:req.body.status,duty_mode:req.body.dutyMode},{transaction:t});await db.ShippingZoneRegion.destroy({where:{shipping_zone_id:row.id},transaction:t});await db.ShippingZoneRegion.bulkCreate(req.body.regions.map(x=>({id:crypto.randomUUID(),shipping_zone_id:row.id,country_code:x.countryCode.toUpperCase(),province:x.province,district:x.district})),{transaction:t});});await audit(req,"SHIPPING_ZONE_UPDATED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};
exports.updateMethod=async(req,res,next)=>{try{const b=req.body,row=await db.ShippingMethod.findByPk(req.params.id);if(!row)throw Object.assign(new Error("Method not found"),{status:404,code:"NOT_FOUND"});await row.update({code:b.code.toUpperCase(),name:b.name,description:b.description,status:b.status,estimated_min_days:b.estimatedMinDays,estimated_max_days:b.estimatedMaxDays,tracking_supported:b.trackingSupported});await audit(req,"SHIPPING_METHOD_UPDATED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};
exports.updateRate=async(req,res,next)=>{try{const b=req.body,row=await db.ShippingRate.findByPk(req.params.id);if(!row)throw Object.assign(new Error("Rate not found"),{status:404,code:"NOT_FOUND"});await row.update({shipping_zone_id:b.shippingZoneId,shipping_method_id:b.shippingMethodId,currency:b.currency.toUpperCase(),base_amount:b.baseAmount,free_shipping_threshold:b.freeShippingThreshold,minimum_subtotal:b.minimumSubtotal,maximum_subtotal:b.maximumSubtotal,status:b.status,starts_at:b.startsAt,ends_at:b.endsAt});await audit(req,"SHIPPING_RATE_UPDATED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};
@@ -0,0 +1 @@
const db=require("../../models"),service=require("../../services/shopping/cart.service"),{logActivity}=require("../../services/activity.service");const audit=(req,type,id)=>logActivity({user:req.user,type,module:"Shopping",description:type,targetType:"CART",targetId:id,requestId:req.id});exports.get=async(req,res,next)=>{try{res.json({success:true,data:await service.project(req.user.id)});}catch(e){next(e);}};exports.add=async(req,res,next)=>{try{const x=await service.add(req.user.id,req.body.variantId,req.body.quantity);await audit(req,"CART_ITEM_ADDED",x.id);res.status(201).json({success:true,data:await service.project(req.user.id)});}catch(e){next(e);}};exports.update=async(req,res,next)=>{try{const x=await service.mutate(req.user.id,req.params.itemId,req.body.quantity);await audit(req,"CART_ITEM_UPDATED",x.id);res.json({success:true,data:await service.project(req.user.id)});}catch(e){next(e);}};exports.remove=async(req,res,next)=>{try{await service.mutate(req.user.id,req.params.itemId,0);await audit(req,"CART_ITEM_REMOVED",req.params.itemId);res.status(204).end();}catch(e){next(e);}};exports.clear=async(req,res,next)=>{try{const cart=await service.activeCart(req.user.id);await db.CartItem.destroy({where:{cart_id:cart.id}});await cart.increment("version");await audit(req,"CART_CLEARED",cart.id);res.status(204).end();}catch(e){next(e);}};exports.coupon=async(req,res,next)=>{try{const cart=await service.activeCart(req.user.id);await cart.update({coupon_code:req.body.code});res.json({success:true,data:await service.project(req.user.id)});}catch(e){next(e);}};exports.removeCoupon=async(req,res,next)=>{try{const cart=await service.activeCart(req.user.id);await cart.update({coupon_code:null});res.status(204).end();}catch(e){next(e);}};
@@ -0,0 +1 @@
const service=require("../../services/shopping/checkout.service"),{logActivity}=require("../../services/activity.service");exports.create=async(req,res,next)=>{try{const result=await service.create({userId:req.user.id,...req.body,idempotencyKey:req.get("Idempotency-Key"),requestId:req.id});if(!result.idempotent)await logActivity({user:req.user,type:"CHECKOUT_CREATED",module:"Checkout",targetId:result.checkout.id,requestId:req.id});res.status(result.idempotent?200:201).json({success:true,data:result.checkout});}catch(e){next(e);}};exports.get=async(req,res,next)=>{try{res.json({success:true,data:await service.getOwned(req.params.id,req.user.id)});}catch(e){next(e);}};exports.active=async(req,res,next)=>{try{const db=require("../../models"),row=await db.CheckoutSession.findOne({where:{user_id:req.user.id,status:["PENDING","READY"]},include:[{model:db.CheckoutItem,as:"items"}],order:[["createdAt","DESC"]]});res.json({success:true,data:row});}catch(e){next(e);}};exports.cancel=async(req,res,next)=>{try{const row=await service.close({id:req.params.id,userId:req.user.id,status:"CANCELLED",requestId:req.id});await logActivity({user:req.user,type:"CHECKOUT_CANCELLED",module:"Checkout",targetId:row.id,requestId:req.id});res.json({success:true,data:row});}catch(e){next(e);}};
@@ -0,0 +1 @@
const crypto=require("crypto"),db=require("../../models"),{logActivity}=require("../../services/activity.service");exports.list=async(req,res,next)=>{try{res.json({success:true,data:await db.WishlistItem.findAll({where:{user_id:req.user.id},include:[{model:db.Product,as:"product",where:{status:"ACTIVE",visibility:"PUBLIC"}}]})});}catch(e){next(e);}};exports.add=async(req,res,next)=>{try{const product=await db.Product.findOne({where:{id:req.body.productId,status:"ACTIVE",visibility:"PUBLIC"}});if(!product)throw Object.assign(new Error("Product not found"),{status:404,code:"NOT_FOUND"});const[row,created]=await db.WishlistItem.findOrCreate({where:{user_id:req.user.id,product_id:product.id},defaults:{id:crypto.randomUUID()}});if(created)await logActivity({user:req.user,type:"WISHLIST_ITEM_ADDED",module:"Shopping",targetId:row.id});res.status(created?201:200).json({success:true,data:row});}catch(e){next(e);}};exports.remove=async(req,res,next)=>{try{const n=await db.WishlistItem.destroy({where:{user_id:req.user.id,product_id:req.params.productId}});if(!n)throw Object.assign(new Error("Wishlist item not found"),{status:404,code:"NOT_FOUND"});await logActivity({user:req.user,type:"WISHLIST_ITEM_REMOVED",module:"Shopping",targetId:req.params.productId});res.status(204).end();}catch(e){next(e);}};
@@ -0,0 +1 @@
const{Op}=require("sequelize"),db=require("../../models"),support=require("../../services/support/support.service");exports.list=async(req,res,next)=>{try{const page=Math.max(+req.query.page||1,1),limit=Math.min(+req.query.limit||20,100),where={...(req.query.status&&{status:req.query.status}),...(req.query.priority&&{priority:req.query.priority}),...(req.query.category&&{category_id:req.query.category}),...(req.query.assignedAgentId&&{assigned_agent_id:req.query.assignedAgentId}),...(req.query.business&&{business_customer_id:req.query.business}),...(req.query.unassigned==="true"&&{assigned_agent_id:null}),...(req.query.overdue==="true"&&{resolution_due_at:{[Op.lt]:new Date()},status:{[Op.notIn]:["RESOLVED","CLOSED","CANCELLED"]}})},x=await db.SupportTicket.findAndCountAll({where,order:[["createdAt","DESC"]],limit,offset:(page-1)*limit});res.json({success:true,data:x.rows,pagination:{page,limit,total:x.count}});}catch(e){next(e);}};exports.detail=async(req,res,next)=>{try{const ticket=await db.SupportTicket.findByPk(req.params.id);if(!ticket)return res.status(404).json({success:false,error:{code:"TICKET_NOT_FOUND",message:"Ticket not found"}});const[messages,attachments,links,events,escalations]=await Promise.all([db.SupportMessage.findAll({where:{ticket_id:ticket.id},order:[["createdAt","ASC"]]}),db.SupportAttachment.findAll({where:{ticket_id:ticket.id}}),db.SupportTicketLink.findAll({where:{ticket_id:ticket.id}}),db.SupportTicketEvent.findAll({where:{ticket_id:ticket.id},order:[["occurred_at","ASC"]]}),db.SupportEscalation.findAll({where:{ticket_id:ticket.id}})]);res.json({success:true,data:{ticket,messages,attachments,links,events,escalations}});}catch(e){next(e);}};exports.assign=async(req,res,next)=>{try{res.json({success:true,data:await support.assign(req.params.id,req.body.agentId,req.user)});}catch(e){next(e);}};exports.claim=async(req,res,next)=>{try{res.json({success:true,data:await support.assign(req.params.id,req.user.id,req.user,true)});}catch(e){next(e);}};exports.reply=async(req,res,next)=>{try{res.status(201).json({success:true,data:await support.agentMessage(req.user,req.params.id,req.body)});}catch(e){next(e);}};exports.note=async(req,res,next)=>{try{res.status(201).json({success:true,data:await support.agentMessage(req.user,req.params.id,req.body,true)});}catch(e){next(e);}};exports.action=to=>async(req,res,next)=>{try{res.json({success:true,data:await support.transition(req.params.id,to,req.user)});}catch(e){next(e);}};exports.priority=async(req,res,next)=>{try{const row=await db.SupportTicket.findByPk(req.params.id);if(!row)return res.status(404).json({success:false,error:{code:"TICKET_NOT_FOUND",message:"Ticket not found"}});const from=row.priority;await row.update({priority:req.body.priority});await db.SupportTicketEvent.create({id:require("crypto").randomUUID(),event_id:require("crypto").randomUUID(),ticket_id:row.id,actor_user_id:req.user.id,type:"PRIORITY_CHANGED",from_value:from,to_value:row.priority,occurred_at:new Date()});res.json({success:true,data:row});}catch(e){next(e);}};exports.attachment=async(req,res,next)=>{try{res.json({success:true,data:{url:await support.attachmentUrl(req.params.id,req.params.attachmentId,req.user,true)}});}catch(e){next(e);}};
@@ -0,0 +1 @@
const db=require("../../models"),support=require("../../services/support/support.service");const page=req=>({page:Math.max(Number(req.query.page)||1,1),limit:Math.min(Math.max(Number(req.query.limit)||20,1),100)});exports.create=async(req,res,next)=>{try{res.status(201).json({success:true,data:await support.createTicket(req.user,req.body)});}catch(e){next(e);}};exports.list=async(req,res,next)=>{try{const p=page(req),where={customer_user_id:req.user.id,...(req.query.status&&{status:req.query.status}),...(req.query.category&&{category_id:req.query.category})},x=await db.SupportTicket.findAndCountAll({where,order:[["createdAt","DESC"]],limit:p.limit,offset:(p.page-1)*p.limit});res.json({success:true,data:x.rows,pagination:{...p,total:x.count}});}catch(e){next(e);}};exports.detail=async(req,res,next)=>{try{const ticket=await support.ownTicket(req.params.id,req.user.id),[messages,attachments,links,events]=await Promise.all([db.SupportMessage.findAll({where:{ticket_id:ticket.id,visibility:"CUSTOMER_VISIBLE"},order:[["createdAt","ASC"]]}),db.SupportAttachment.findAll({where:{ticket_id:ticket.id,visibility:"CUSTOMER_VISIBLE"}}),db.SupportTicketLink.findAll({where:{ticket_id:ticket.id}}),db.SupportTicketEvent.findAll({where:{ticket_id:ticket.id,type:["TICKET_CREATED","TICKET_ASSIGNED","TICKET_RESOLVED","TICKET_CLOSED","TICKET_REOPENED"]},attributes:{exclude:["metadata"]},order:[["occurred_at","ASC"]]})]);res.json({success:true,data:{ticket,messages,attachments,links,events}});}catch(e){next(e);}};exports.reply=async(req,res,next)=>{try{res.status(201).json({success:true,data:await support.customerReply(req.user,req.params.id,req.body)});}catch(e){next(e);}};exports.close=async(req,res,next)=>{try{await support.ownTicket(req.params.id,req.user.id);res.json({success:true,data:await support.transition(req.params.id,"CLOSED",req.user)});}catch(e){next(e);}};exports.attachment=async(req,res,next)=>{try{res.json({success:true,data:{url:await support.attachmentUrl(req.params.id,req.params.attachmentId,req.user,false)}});}catch(e){next(e);}};
+37 -123
View File
@@ -1,131 +1,45 @@
/**
* Copyright (c) 2026 Niolla
* All rights reserved.
*
* This source code is proprietary and confidential.
* Unauthorized copying, modification, distribution, or use
* of this file, via any medium, is strictly prohibited.
*/
// app/controllers/activity.controller.js
const { uploadToS3, getSignedFileUrl } = require("../utils/s3Upload.utill");
const { log } = require("../utils/consoleLog.utill");
const db = require("../models");
const Upload = db.Upload;
const Assets = db.Assets;
const storage = require("../services/storage/storage.service");
const { validateUpload } = require("../services/storage/file-validation.service");
// Constants
const MAX_IMAGE_SIZE = 3 * 1024 * 1024; // 3MB
const MAX_PDF_SIZE = 5 * 1024 * 1024; // 5MB
const BUSINESS_DOCUMENT_PURPOSES = new Set(["BUSINESS_REGISTRATION", "TAX_DOCUMENT", "IDENTITY_DOCUMENT", "OTHER_SUPPORTING_DOCUMENT"]);
const canAccess = (user, upload) => upload.visibility === "PUBLIC" || upload.owner_id === user.id || user.accountType === "superadmin" || (user.permissions || []).includes("media.read") || (BUSINESS_DOCUMENT_PURPOSES.has(upload.use_for) && (user.permissions || []).includes("business.applications.review"));
const isValidFileType = (mimetype) => {
return mimetype.startsWith("image/") || mimetype === "application/pdf";
};
const isValidFileSize = (mimetype, size) => {
if (mimetype.startsWith("image/")) return size <= MAX_IMAGE_SIZE;
if (mimetype === "application/pdf") return size <= MAX_PDF_SIZE;
return false;
};
exports.uploadFile = async (req, res) => {
exports.uploadFile = async (req, res, next) => {
let objectKey;
try {
// 1. Check file exists
if (!req.file) {
return res.status(400).json({
success: false,
message: "No file uploaded",
});
}
const { mimetype, size, originalname } = req.file;
// 2. Validate file type
if (!isValidFileType(mimetype)) {
return res.status(400).json({
success: false,
message: "Only images and PDFs are allowed",
});
}
// 3. Validate file size
if (!isValidFileSize(mimetype, size)) {
return res.status(400).json({
success: false,
message: mimetype.startsWith("image/")
? "Image too large (max 1MB)"
: "PDF too large (max 5MB)",
});
}
log("File validation passed:", {
mimetype,
size,
originalname,
use_for: req.body.use_for,
});
// 4. Upload to S3
const fileKey = await uploadToS3(req.file, req.body.use_for);
const fileUrl = await getSignedFileUrl(fileKey);
// 5. Save to DB
const uploadData = {
file_path: fileKey,
file_type: mimetype,
file_size: size,
original_name: originalname,
use_for: req.body.use_for || null,
uploaded_by: req.user?.id || null,
};
const newUpload = await Upload.create(uploadData);
newUpload.dataValues.file_url = fileUrl; // Add URL to response
// 6. Response
return res.status(201).json({
success: true,
message: "File uploaded successfully",
data: newUpload,
});
} catch (error) {
console.error("Upload Controller Error:", error);
return res.status(500).json({
success: false,
message: "Failed to upload file",
error: process.env.NODE_ENV === "development" ? error.message : undefined,
});
}
const details = validateUpload(req.file);
const purpose = String(req.body.use_for || "generic").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 60);
objectKey = storage.createObjectKey({ ownerId: req.user.id, mimeType: details.mimeType, purpose });
await storage.uploadBuffer({ buffer: req.file.buffer, objectKey, mimeType: details.mimeType, checksum: details.checksum });
let record;
try {
record = await db.Upload.create({ file_path: objectKey, file_type: details.mimeType, file_size: details.size, original_name: req.file.originalname, safe_name: details.safeName, checksum: details.checksum, use_for: purpose, uploaded_by: req.user.id, owner_type: "USER", owner_id: req.user.id, visibility: "PRIVATE", status: "AVAILABLE" });
} catch (error) { await storage.deleteObject(objectKey).catch(() => undefined); throw error; }
const expiresIn = Number(process.env.S3_SIGNED_URL_TTL_SECONDS || 900);
return res.status(201).json({ success: true, data: { id: record.id, originalName: record.original_name, mimeType: record.file_type, size: record.file_size, purpose: record.use_for, status: record.status, url: await storage.createSignedDownloadUrl(objectKey, expiresIn), expiresIn } });
} catch (error) { return next(error); }
};
// Get Uploaded File URL
exports.getFileUrl = async (req, res) => {
exports.getFileUrl = async (req, res, next) => {
try {
const { id } = req.params;
const uploadRecord = await Upload.findByPk(id);
if (!uploadRecord) {
return res.status(404).json({
success: false,
message: "File not found",
});
}
const fileUrl = await getSignedFileUrl(uploadRecord.file_path);
return res.status(200).json({
success: true,
message: "File URL retrieved successfully",
data: {
id: uploadRecord.id,
file_url: fileUrl,
},
});
} catch (error) {}
const upload = await db.Upload.findByPk(req.params.id);
if (!upload || upload.status === "DELETED") return res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "File not found" } });
if (!canAccess(req.user, upload)) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } });
if (upload.status !== "AVAILABLE") return res.status(409).json({ success: false, error: { code: "FILE_UNAVAILABLE", message: "File is not available" } });
const expiresIn = Number(process.env.S3_SIGNED_URL_TTL_SECONDS || 900);
return res.json({ success: true, data: { id: upload.id, url: await storage.createSignedDownloadUrl(upload.file_path, expiresIn), expiresIn } });
} catch (error) { return next(error); }
};
exports.deleteFile = async (req, res, next) => {
try {
const upload = await db.Upload.findByPk(req.params.id);
if (!upload || upload.status === "DELETED") return res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "File not found" } });
const allowed = upload.owner_id === req.user.id || req.user.accountType === "super_admin" || (req.user.permissions || []).includes("media.delete");
if (!allowed) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } });
await upload.update({ status: "DELETED", deletedAt: new Date() });
await storage.deleteObject(upload.file_path).catch(() => undefined);
return res.status(204).end();
} catch (error) { return next(error); }
};
+29 -25
View File
@@ -44,7 +44,6 @@ exports.createNewUser = async (req, res) => {
lastName,
email,
password,
accountType,
address,
phoneNumber,
businessName,
@@ -56,23 +55,23 @@ exports.createNewUser = async (req, res) => {
note,
} = req.body;
if (!firstName || !lastName || !email || !password || !accountType) {
if (!firstName || !lastName || !email || !password) {
await transaction.rollback();
return res.status(400).send({
success: false,
message:
"First name, last name, email, password and account type are required",
"First name, last name, email and password are required",
});
}
if (accountType !== "customer" && accountType !== "business_customer") {
if (req.body.accountType && req.body.accountType !== "customer") {
await transaction.rollback();
return res.status(400).send({
success: false,
message:
"Invalid account type. Must be either 'customer' or 'business_customer'",
"Public registration creates customer accounts only",
});
}
@@ -118,7 +117,7 @@ exports.createNewUser = async (req, res) => {
lastName,
email,
password: hashedPassword,
accountType,
accountType: "customer",
accountStatus: "PENDING_VERIFICATION",
emailVerifiedAt: null,
},
@@ -139,8 +138,8 @@ exports.createNewUser = async (req, res) => {
{ transaction },
);
//create customer and business customer
if (accountType === "customer") {
// Create the customer identity extension for public registration.
{
const customerData = {
address,phoneNumber,
};
@@ -150,23 +149,6 @@ exports.createNewUser = async (req, res) => {
customerData,
transaction,
);
} else if (accountType === "business_customer") {
const businessData = {
businessName,
businessRegistrationNumber,
businessType,
contactName,
phoneNumber,
businessEmail,
expectedMonthlyVolume,
note,
};
await createBusinessCustomerDetails(
newUser.id,
businessData,
transaction,
);
}
await transaction.commit();
@@ -449,6 +431,7 @@ exports.updateUser = async (req, res) => {
});
if (!user) {
await transaction.rollback();
return res.status(404).send({
success: false,
message: "User not found",
@@ -456,6 +439,7 @@ exports.updateUser = async (req, res) => {
}
if (!UserProfile) {
await transaction.rollback();
return res.status(404).send({
success: false,
message: "User profile not found",
@@ -533,6 +517,7 @@ exports.deleteUser = async (req, res) => {
where: { id },
});
if (!user) {
await transaction.rollback();
return res.status(404).send({
success: false,
message: "User not found",
@@ -560,3 +545,22 @@ exports.deleteUser = async (req, res) => {
});
}
};
exports.getCurrentUser = async (req, res, next) => {
try {
const user = await User.findByPk(req.user.id, { attributes: { exclude: ["password", "tokenVersion", "passwordChangedAt"] }, include: [{ model: Profile, as: "profile" }] });
res.json({ success: true, data: user });
} catch (error) { next(error); }
};
exports.updateCurrentUser = async (req, res, next) => {
try {
const allowed = ["firstName", "lastName"];
const supplied = Object.keys(req.body);
if (supplied.some((key) => !allowed.includes(key))) return res.status(400).json({ success: false, error: { code: "UNSAFE_FIELD", message: "Only firstName and lastName may be updated" } });
const updates = Object.fromEntries(supplied.map((key) => [key, req.body[key]]).filter(([, value]) => typeof value === "string" && value.trim()));
await User.update(updates, { where: { id: req.user.id } });
const user = await User.findByPk(req.user.id, { attributes: ["id", "firstName", "lastName", "email", "accountType", "accountStatus"] });
res.json({ success: true, data: user });
} catch (error) { next(error); }
};
@@ -0,0 +1 @@
const db=require("../../models"),credit=require("../../services/wholesale/credit.service"),settlements=require("../../services/wholesale/settlement.service"),{logActivity}=require("../../services/activity.service");exports.credit=async(req,res,next)=>{try{if(!req.get("Idempotency-Key"))throw Object.assign(new Error("Idempotency-Key is required"),{status:400,code:"IDEMPOTENCY_KEY_REQUIRED"});const result=await credit.post({...req.body,currency:req.body.currency.toUpperCase(),eventId:`CREDIT:${req.get("Idempotency-Key")}`,actorUserId:req.user.id});await logActivity({user:req.user,type:`CREDIT_${req.body.type}`,module:"Wholesale",targetId:result.entry.id,requestId:req.id});res.status(result.idempotent?200:201).json({success:true,data:result});}catch(e){next(e);}};exports.creditHistory=async(req,res,next)=>{try{res.json({success:true,data:await db.BusinessCreditLedgerEntry.findAll({where:{business_customer_id:req.params.businessId},order:[["occurred_at","DESC"]],limit:200})});}catch(e){next(e);}};exports.generate=async(req,res,next)=>{try{const result=await settlements.generate({...req.body,currency:req.body.currency.toUpperCase()});res.status(result.idempotent?200:201).json({success:true,data:result.settlement});}catch(e){next(e);}};exports.settlements=async(req,res,next)=>{try{res.json({success:true,data:await db.BusinessSettlement.findAll({order:[["period_end","DESC"]]})});}catch(e){next(e);}};exports.action=to=>async(req,res,next)=>{try{res.json({success:true,data:await settlements.transition(req.params.id,to,req.user.id)});}catch(e){next(e);}};
@@ -0,0 +1 @@
const service=require("../../services/wholesale/creditPurchase.service"),{logActivity}=require("../../services/activity.service");exports.purchase=async(req,res,next)=>{try{const key=req.get("Idempotency-Key");if(!key)throw Object.assign(new Error("Idempotency-Key is required"),{status:400,code:"IDEMPOTENCY_KEY_REQUIRED"});const result=await service.purchase({orderId:req.params.id,userId:req.user.id,eventId:`CREDIT:ORDER:${key}`});if(!result.idempotent)await logActivity({user:req.user,type:"CREDIT_CAPTURED",module:"Wholesale",targetId:result.credit.entry.id,requestId:req.id});res.status(result.idempotent?200:201).json({success:true,data:{orderId:result.order.id,paymentStatus:result.order.payment_status,credit:result.credit}});}catch(e){next(e);}};
@@ -0,0 +1 @@
const{QueryTypes}=require("sequelize"),db=require("../../models"),money=require("../../services/pricing/money");async function business(userId){const row=await db.BusinessCustomer.findOne({where:{user_id:userId,status:"ACTIVE"}});if(!row)throw Object.assign(new Error("Active business account required"),{status:403,code:"BUSINESS_REQUIRED"});return row;}exports.dashboard=async(req,res,next)=>{try{const b=await business(req.user.id),credit=await db.BusinessCreditAccount.findOne({where:{business_profile_id:b.business_customer_id}}),last=credit&&await db.BusinessCreditLedgerEntry.findOne({where:{business_credit_account_id:credit.id},order:[["occurred_at","DESC"]]}),used=last?String(last.balance_after):"0.00",tier=b.business_tier_id&&await db.BusinessTier.findByPk(b.business_tier_id),settlement=await db.BusinessSettlement.findOne({where:{business_customer_id:b.business_customer_id,status:["ISSUED","PARTIALLY_PAID","OVERDUE"]},order:[["due_date","ASC"]]}),stats=(await db.sequelize.query("SELECT COALESCE(SUM(grand_total),0) AS lifetimeVolume, COALESCE(SUM(CASE WHEN placed_at >= DATE_FORMAT(UTC_DATE(), '%Y-%m-01') THEN grand_total ELSE 0 END),0) AS monthlyVolume, COALESCE(SUM(discount_total),0) AS discountTotal, COALESCE(SUM(subtotal),0) AS subtotalTotal FROM orders WHERE business_customer_id = :id AND payment_status = 'PAID'",{replacements:{id:b.business_customer_id},type:QueryTypes.SELECT}))[0];res.json({success:true,data:{partnerId:b.partnerId,tier:tier&&{code:tier.code,name:tier.name},credit:credit&&{status:credit.status,currency:credit.currency,limit:String(credit.credit_limit),used,available:money.subtractFloor(credit.credit_limit,used)},monthlyVolume:String(stats.monthlyVolume),lifetimeVolume:String(stats.lifetimeVolume),discountTotal:String(stats.discountTotal),subtotalTotal:String(stats.subtotalTotal),nextSettlement:settlement&&{number:settlement.settlement_number,dueDate:settlement.due_date,balanceDue:String(settlement.balance_due)}}});}catch(e){next(e);}};exports.credit=async(req,res,next)=>{try{const b=await business(req.user.id),account=await db.BusinessCreditAccount.findOne({where:{business_profile_id:b.business_customer_id}}),rows=account?await db.BusinessCreditLedgerEntry.findAll({where:{business_credit_account_id:account.id},order:[["occurred_at","DESC"]],limit:100}):[];res.json({success:true,data:rows});}catch(e){next(e);}};exports.settlements=async(req,res,next)=>{try{const b=await business(req.user.id);res.json({success:true,data:await db.BusinessSettlement.findAll({where:{business_customer_id:b.business_customer_id},order:[["period_end","DESC"]]})});}catch(e){next(e);}};exports.settlement=async(req,res,next)=>{try{const b=await business(req.user.id),row=await db.BusinessSettlement.findOne({where:{id:req.params.id,business_customer_id:b.business_customer_id}});if(!row)throw Object.assign(new Error("Settlement not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:row});}catch(e){next(e);}};exports.orders=async(req,res,next)=>{try{const b=await business(req.user.id);res.json({success:true,data:await db.Order.findAll({where:{business_customer_id:b.business_customer_id},order:[["createdAt","DESC"]],limit:20})});}catch(e){next(e);}};
+5 -1
View File
@@ -69,4 +69,8 @@ const registry = {
console.log("📋 Registry initialized with keys:", Object.keys(registry));
module.exports = registry;
for (const [key, entry] of Object.entries(registry)) {
if (typeof entry.pdfTemplate !== "function") delete registry[key];
}
module.exports = registry;
+15 -82
View File
@@ -1,93 +1,26 @@
/**
* Copyright (c) 2026 Niolla
* All rights reserved.
*
* This source code is proprietary and confidential.
* Unauthorized copying, modification, distribution, or use
* of this file, via any medium, is strictly prohibited.
*/
// app/middleware/auth.middleware.js
const { verifyToken } = require("../utils/jwt.util");
const { getEffectivePermissions } = require("../services/permission.service");
const db = require("../models");
const authenticate = async (req, res, next) => {
try {
let token = null;
// Get token from cookie
if (req.cookies?.access_token) {
token = req.cookies.access_token;
}
// Fallback to Bearer token
if (!token && req.headers.authorization?.startsWith("Bearer ")) {
token = req.headers.authorization.split(" ")[1];
}
if (!token) {
return res.status(401).json({
success: false,
message: "Unauthorized",
});
}
// Verify token
const token = req.cookies?.access_token || (req.headers.authorization?.startsWith("Bearer ") ? req.headers.authorization.slice(7) : null);
if (!token) return res.status(401).json({ success: false, error: { code: "UNAUTHORIZED", message: "Authentication required" } });
const decoded = verifyToken(token);
if (process.env.NODE_ENV === "development") {
console.log("DECODED:", decoded);
if (!decoded.sub || !decoded.sid || !Number.isInteger(decoded.tokenVersion)) throw new Error("Required claims missing");
const [user, session] = await Promise.all([
db.User.findByPk(decoded.sub),
db.AuthSession.findByPk(decoded.sid),
]);
if (!user || user.accountStatus !== "ACTIVE" || user.tokenVersion !== decoded.tokenVersion || !session || session.user_id !== user.id || session.revoked_at || session.expires_at <= new Date() || session.token_version !== user.tokenVersion) {
return res.status(401).json({ success: false, error: { code: "SESSION_INVALID", message: "Session is no longer valid" } });
}
const userId = decoded.sub || decoded.id;
if (!userId) {
throw new Error("User ID missing in token");
}
// Load permissions
const permissions = await getEffectivePermissions(userId);
req.user = {
...decoded,
id: userId,
permissions,
};
req.user = { id: user.id, sessionId: session.id, firstName: user.firstName, lastName: user.lastName, email: user.email, accountType: user.accountType, accountStatus: user.accountStatus, permissions: await getEffectivePermissions(user.id) };
next();
} catch (err) {
console.error("AUTH ERROR:", err);
// Clear invalid/expired cookie
res.clearCookie("access_token", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax",
});
// Token expired
if (err.name === "TokenExpiredError") {
return res.status(401).json({
success: false,
message: "Session expired. Please login again.",
});
}
// Invalid token
if (err.name === "JsonWebTokenError") {
return res.status(401).json({
success: false,
message: "Invalid token",
});
}
// Default
return res.status(401).json({
success: false,
message: "Authentication failed",
});
} catch (error) {
res.clearCookie("access_token");
return res.status(401).json({ success: false, error: { code: "UNAUTHORIZED", message: "Invalid or expired access token" } });
}
};
module.exports = { authenticate };
module.exports = { authenticate, requireAuth: authenticate };
+6 -21
View File
@@ -1,21 +1,6 @@
/**
* Copyright (c) 2026 Niolla
* All rights reserved.
*
* This source code is proprietary and confidential.
* Unauthorized copying, modification, distribution, or use
* of this file, via any medium, is strictly prohibited.
*/
// app/middleware/docsSession.middleware.js
module.exports = (req, res, next) => {
if (req.cookies && req.cookies.docsAuth === "true") {
return next();
}
return res.status(401).json({
success: false,
message: "Unauthorized: Please login to access docs",
});
};
const { authenticate } = require("./auth.middleware");
const { ACCOUNT_TYPES } = require("../constants/accountTypes");
module.exports = (req, res, next) => authenticate(req, res, () => {
if ([ACCOUNT_TYPES.ADMIN, ACCOUNT_TYPES.SUPER_ADMIN].includes(req.user.accountType)) return next();
return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Administrative access required" } });
});
+46
View File
@@ -0,0 +1,46 @@
const { ValidationError, UniqueConstraintError } = require("sequelize");
const { ZodError } = require("zod");
class AppError extends Error {
constructor(status, code, message, details) {
super(message);
this.status = status;
this.code = code;
this.details = details;
}
}
const notFound = (req, _res, next) => next(new AppError(404, "NOT_FOUND", "Route not found"));
const errorHandler = (error, req, res, _next) => {
let status = error.status || error.statusCode || 500;
let code = error.code || "INTERNAL_ERROR";
let message = error.message || "An unexpected error occurred";
let details = error.details;
if (error instanceof ZodError) {
status = 400; code = "VALIDATION_ERROR"; message = "Invalid request data";
details = error.issues.map(({ path, message: detailMessage }) => ({ field: path.join("."), message: detailMessage }));
} else if (error instanceof UniqueConstraintError) {
status = 409; code = "CONFLICT"; message = "A record with these values already exists";
details = error.errors?.map(({ path, message: detailMessage }) => ({ field: path, message: detailMessage }));
} else if (error instanceof ValidationError) {
status = 400; code = "VALIDATION_ERROR"; message = "Invalid request data";
details = error.errors?.map(({ path, message: detailMessage }) => ({ field: path, message: detailMessage }));
} else if (error.type === "entity.too.large") {
status = 413; code = "PAYLOAD_TOO_LARGE"; message = "Request body is too large";
} else if (error.name === "UnauthorizedError" || error.name === "JsonWebTokenError") {
status = 401; code = "UNAUTHORIZED"; message = "Authentication failed";
}
if (status >= 500) {
console.error(`[${req.id || "no-request-id"}] Request failed`, { name: error.name, message: error.message });
if (process.env.NODE_ENV === "production") message = "An unexpected error occurred";
}
const payload = { success: false, error: { code, message }, requestId: req.id };
if (details && status < 500) payload.error.details = details;
res.status(status).json(payload);
};
module.exports = { AppError, notFound, errorHandler };

Some files were not shown because too many files have changed in this diff Show More