Auth #1

Merged
Sathira merged 17 commits from auth into main 2026-09-07 06:53:05 +00:00
252 changed files with 6961 additions and 2109 deletions
+59 -35
View File
@@ -1,53 +1,77 @@
# App Details # Required for API and worker startup
APP_NAME= APP_NAME=ZUMRI
NODE_ENV=development
PORT=3070
FRONTEND_URL=http://localhost:3000
TRUST_PROXY=0
# Database configuration variables DB_HOST=localhost
DB_HOST = DB_PORT=3306
DB_USER = DB_NAME=zumri
DB_PASSWORD = DB_USER=zumri
DB_NAME = DB_PASSWORD=replace_with_local_database_password
DB_PORT = MYSQL_ROOT_PASSWORD=replace_with_local_root_password
# Redis configuration variables
REDIS_HOST=localhost REDIS_HOST=localhost
REDIS_PORT=6379 REDIS_PORT=6379
REDIS_PASSWORD=replace_with_local_redis_password
# JWT secret key # Use separate randomly generated values of at least 32 characters.
JWT_SECRET = your_jwt_secret_key_here JWT_SECRET=replace_with_a_random_value_at_least_32_chars
JWT_EXPIRES_IN =1d 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 # Runtime controls
AWS_ACCESS_KEY_ID=your_aws_access_key_id_here RUN_CRON=false
AWS_SECRET_ACCESS_KEY=your_aws_secret_access_key_here CACHE=true
AWS_REGION=your_aws_region_here JSON_BODY_LIMIT=1mb
AWS_S3_BUCKET_NAME=your_aws_bucket_name_here 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_HOST=
MAIL_PORT=587 MAIL_PORT=587
MAIL_USER= MAIL_USER=
MAIL_PASS= MAIL_PASS=
MAIL_SECURE=false MAIL_SECURE=false
MAIL_FROM= 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_USER=
DOCS_PASS= DOCS_PASS=
# Admin email for receiving notifications # Optional application configuration
MANAGER_EMAIL= MANAGER_EMAIL=
# Caching configuration
CACHE=true
# Application environment
NODE_ENV=development
# User default password
DEFAULT_PASSWORD= DEFAULT_PASSWORD=
PASSWORD_RESET_TTL_SECONDS=900
# Frontend URL for CORS EMAIL_VERIFICATION_TTL_SECONDS=86400
FRONTEND_URL=http://localhost:3000 PUPPETEER_EXECUTABLE_PATH=
GOOGLE_CLIENT_ID=
# Puppeteer executable path (if needed, otherwise Puppeteer will use the bundled Chromium) APPLE_CLIENT_ID=
PUPPETEER_EXECUTABLE_PATH = C:\Users\User\.cache\puppeteer\chrome-headless-shell\win64-142.0.7444.162\chrome-headless-shell-win64\chrome-headless-shell.exe
+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 \ RUN apt-get update && apt-get install -y \
chromium \ chromium curl fonts-liberation libatk-bridge2.0-0 libatk1.0-0 libcups2 \
fonts-liberation \ libxcomposite1 libxrandr2 libxdamage1 libgbm1 libasound2 libpangocairo-1.0-0 \
libatk-bridge2.0-0 \ libpango-1.0-0 libnss3 libxss1 libgtk-3-0 libdrm2 libxshmfence1 ca-certificates \
libatk1.0-0 \ --no-install-recommends && rm -rf /var/lib/apt/lists/*
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
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium \
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
NODE_ENV=production
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci --omit=dev RUN npm ci --omit=dev && npm cache clean --force
COPY --chown=node:node . .
COPY . .
ENV NODE_ENV=production
USER node
EXPOSE 3070 EXPOSE 3070
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD ["node", "server.js"] 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.
@@ -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.
+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.
+264 -41
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
``` ```text
POST: http://localhost:3070/api/auth/req-otp 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 ```json
{ {
"email": "sathira@niolla.lk", "email": "sathira@niolla.lk",
"password": "Niolla@123" "password": "Niolla@123"
} }
``` ```
**Respond** ### Success response — `201 Created`
```json ```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.
``` **Endpoint:** `POST` [http://localhost:3070/api/auth/login](http://localhost:3070/api/auth/login)
POST: http://localhost:3070/api/auth/login
```
**Request Body** ### Request body
```json ```json
{ {
"email": "sathira@niolla.lk", "email": "sathira@niolla.lk",
"otp": "922304" "otp": "922304"
} }
``` ```
**Respond** ### Success response — `200 OK`
```json ```json
{ {
@@ -54,33 +83,40 @@ POST: http://localhost:3070/api/auth/login
"data": { "data": {
"id": "usr_5ff8afec-5ddc-47d9-a63f-b43df0d8c3b4", "id": "usr_5ff8afec-5ddc-47d9-a63f-b43df0d8c3b4",
"email": "sathira@niolla.lk", "email": "sathira@niolla.lk",
"firstName": "Jhon", "firstName": "Sathira",
"lastName": "Doe", "lastName": "Sri Sathsara",
"role": "System Developer", "role": null,
"accountType": "admin" "accountType": "admin",
"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.
``` **Endpoint:** `GET` [http://localhost:3070/api/auth/me](http://localhost:3070/api/auth/me)
GET: http://localhost:3070/api/auth/me
```
**Authorization**: Required (Bearer token) **Authentication:** Required
**Request Body** ### Request body
``` No request body.
No Body
```
**Response (200)** ### Success response — `200 OK`
```json ```json
{ {
@@ -90,7 +126,6 @@ No Body
"firstName": "Sathira", "firstName": "Sathira",
"lastName": "Sri Sathsara", "lastName": "Sri Sathsara",
"email": "sathira@niolla.lk", "email": "sathira@niolla.lk",
"role": "System Developer",
"accountType": "admin", "accountType": "admin",
"iat": 1778865265, "iat": 1778865265,
"exp": 1778868865, "exp": 1778868865,
@@ -99,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.
``` **Endpoint:** `POST` [http://localhost:3070/api/auth/refresh](http://localhost:3070/api/auth/refresh)
POST: http://localhost:3070/api/auth/logout
### 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`
``` Returned when the refresh cookie is missing or the session is invalid, expired, or no longer active.
No Body
---
## 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 ```json
{ {
"success": true, "success": true,
"message": "Logged out successfully" "message": "Logged out successfully"
} }
``` ```
### Error response — `500 Internal Server Error`
```json
{
"success": false,
"message": "Failed to logout"
}
```
+419
View File
@@ -0,0 +1,419 @@
# 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.
@@ -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,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.
+130 -17
View File
@@ -1,44 +1,157 @@
# User API # User API
#### Create New User #### Create New Customer Account
Creates a new customer account and sends an email verification link.
The account is initially created with:
```text
accountType = customer
accountStatus = PENDING_VERIFICATION
emailVerifiedAt = null
```
**Endpoint** **Endpoint**
``` ```text
POST: http://localhost:3070/api/user POST: http://localhost:3070/api/user
``` ```
**Request Body** **Request Body customer**
accontType = customer and bussiness_customer
```json ```json
{ {
"firstName": "Jhon", "firstName": "Isuru",
"lastName": "Doe", "lastName": "Bimsara",
"email": "kalanajayasekara@niolla.lk", "email": "ibimsara00@gmail.com",
"role": "System Developer", "password": "Hello@12346"
"accountType": "admin", "accountType": "customer",
"department": "IT Department"
"address": "Colombo, Sri Lanka",
"phoneNumber": "0771234567"
} }
``` ```
**Respond** **Response**
```json ```json
{ {
"success": true, "success": true,
"message": "User Created Successfully", "message": "User Created Successfully",
"data": { "data": {
"id": "usr_763107d9-b56f-4b81-9d86-1889f98a6e6c", "id": "usr_ei6i4n49",
"firstName": "Jhon", "firstName": "Isuru",
"lastName": "Doe", "lastName": "Bimsara",
"email": "kalanajayasekara@niolla.lk", "email": "ibimsara00@gmail.com",
"accountType": "admin", "accountType": "customer"
"role": "System Developer",
"department": "IT Department"
} }
} }
``` ```
After registration, the user receives an email containing a verification link.
#### Verify Email
Verifies the customer's email using the raw verification token received by email.
The token must:
```text
store in redis and expire token
```
**Endpoint**
```text
POST: http://localhost:3070/api/user/verify-email
```
**Request Body**
```json
{
"token": "RAW_VERIFICATION_TOKEN_FROM_EMAIL"
}
```
**Successful Response**
```json
{
"success": true,
"message": "Email verified successfully. Your account is now active."
}
```
After successful verification, the user record changes from:
```text
accountStatus = PENDING_VERIFICATION
emailVerifiedAt = NULL
```
to:
```text
accountStatus = ACTIVE
emailVerifiedAt = <current date and time>
```
The verification record is also updated:
```text
usedAt = <current date and time>
```
This prevents the same verification token from being successfully used again.
#### Get user's details
**Endpoint**
```
GET: http://localhost:3070/api/profile
```
**Respond**
```json
{
"success": true,
"message": "User profile retrieved successfully",
"data": {
"user": {
"id": "usr_572gtlpi",
"firstName": "Isuru",
"lastName": "Bimsara",
"email": "ibimsara00@gmail.com",
"accountType": "customer",
"accountStatus": "ACTIVE",
"emailVerifiedAt": "2026-08-20T16:26:04.000Z",
"passwordChangedAt": "2026-08-21T05:29:16.000Z",
"createdAt": "2026-08-20T16:25:30.000Z",
"updatedAt": "2026-08-21T05:29:16.000Z"
},
"accountDetails": {
"customer_id": "cust_c8avqwbc",
"user_id": "usr_572gtlpi",
"address": "Colombo, Sri Lanka",
"phoneNumber": "0771234567",
"createdAt": "2026-08-20T16:25:31.000Z",
"updatedAt": "2026-08-20T16:25:31.000Z"
}
}
}
```
#### Get All Users #### Get All Users
**Endpoint** **Endpoint**
+42 -72
View File
@@ -1,99 +1,69 @@
/** /**
* Copyright (c) 2026 Niolla * Copyright (c) 2026 Niolla
* All rights reserved. * 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 express = require("express");
const cors = require("cors"); const cors = require("cors");
const helmet = require("helmet");
const morgan = require("morgan"); const morgan = require("morgan");
const routes = require("./app/routes");
const cookieParser = require("cookie-parser"); const cookieParser = require("cookie-parser");
const { bullBoardRouter } = require("./app/config/bullBoard.config");
const path = require("path"); const path = require("path");
const startAllCrons = require("./cron");
const db = require("./app/models"); const routes = require("./app/routes");
const { healthRouter, live } = require("./app/routes/health.routes");
// Test DB connection and sync models const { bullBoardRouter } = require("./app/config/bullBoard.config");
(async () => { const { authenticate } = require("./app/middleware/auth.middleware");
try { const { authorizedAccountType } = require("./app/middleware/permission.middleware");
await db.sequelize.authenticate(); const requestId = require("./app/middleware/requestId.middleware");
console.log("Database connected."); const { generalApiLimiter } = require("./app/middleware/rateLimit.middleware");
const { notFound, errorHandler } = require("./app/middleware/error.middleware");
await db.sequelize.sync();
console.log("Tables synced.");
// Start all cron jobs
startAllCrons();
} catch (error) {
console.error("DB error:", error);
}
})();
const app = express(); const app = express();
const trustProxy = Number(process.env.TRUST_PROXY || 0);
if (trustProxy > 0) app.set("trust proxy", trustProxy);
app.disable("x-powered-by");
app.use(requestId);
app.use(helmet({
contentSecurityPolicy: false,
hsts: process.env.NODE_ENV === "production" ? undefined : false,
}));
app.use(cookieParser()); app.use(cookieParser());
app.use(cors({
// CORS configuration origin: process.env.FRONTEND_URL,
const corsOptions = {
origin: process.env.FRONTEND_URL || "https://oceanic-demo.vercel.app",
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"], allowedHeaders: ["Content-Type", "Authorization", "X-Request-ID"],
exposedHeaders: ["X-Request-ID"],
credentials: true, credentials: true,
}; }));
app.use(cors(corsOptions)); app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || "1mb" }));
app.options(/.*/, cors(corsOptions)); app.use(express.urlencoded({ extended: false, limit: process.env.JSON_BODY_LIMIT || "1mb" }));
app.use(express.json()); morgan.token("request-id", (req) => req.id);
app.use(morgan("dev")); 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) => { app.get("/health", live);
let dbStatus = "N/A"; app.use("/health", healthRouter);
let emailStatus = "N/A";
let redisStatus = "N/A";
db.sequelize // Keep the legacy path while all new clients migrate to the versioned path.
.authenticate() app.use("/api", generalApiLimiter, routes);
.then(() => { app.use("/api/v1", generalApiLimiter, routes);
console.log("DB connection successful.");
dbStatus = "OK";
})
.catch((err) => {
console.error("DB connection error:", err);
res.status(500).send("Internal Server Error");
});
emailStatus = "OK";
redisStatus = "OK";
res.send({
status: "Online ✅",
database: dbStatus,
emailService: emailStatus,
redis: redisStatus,
});
});
app.use("/api", routes);
app.use( app.use(
"/Documentation", "/Documentation",
(req, res, next) => { (req, res, next) => req.path.endsWith(".md") ? res.status(403).send("Forbidden") : next(),
if (req.path.endsWith(".md")) {
return res.status(403).send("Forbidden");
}
next();
},
express.static(path.join(__dirname, "Documentation")), 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; 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 { BullMQAdapter } = require("@bull-board/api/bullMQAdapter");
const activityQueue = require("../queues/activity.queue"); 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(); const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath("/admin/queues"); serverAdapter.setBasePath("/admin/queues");
const { addQueue, removeQueue, setQueues, replaceQueues } = const { addQueue, removeQueue, setQueues, replaceQueues } =
createBullBoard({ createBullBoard({
queues: [new BullMQAdapter(activityQueue)], queues: [activityQueue, documentQueue, logQueue, emailQueue].map((queue) => new BullMQAdapter(queue)),
serverAdapter, serverAdapter,
}); });
module.exports = { module.exports = {
bullBoardRouter: serverAdapter.getRouter(), 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(); require("dotenv").config();
module.exports = { module.exports = {
HOST: process.env.DB_HOST || "localhost", HOST: process.env.DB_HOST,
USER: process.env.DB_USER || "root", USER: process.env.DB_USER,
PASSWORD: process.env.DB_PASSWORD || "", PASSWORD: process.env.DB_PASSWORD,
DB: process.env.DB_NAME || "oceanic-db", DB: process.env.DB_NAME,
PORT: process.env.DB_PORT || 3306, PORT: process.env.DB_PORT || 3306,
DIALECT: "mysql", DIALECT: "mysql",
+81
View File
@@ -0,0 +1,81 @@
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),
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 };
+1 -1
View File
@@ -20,5 +20,5 @@ module.exports = {
user: process.env.MAIL_USER, user: process.env.MAIL_USER,
pass: process.env.MAIL_PASS, pass: process.env.MAIL_PASS,
}, },
from: `Oceanic Maritime Solutions <${process.env.MAIL_FROM}>`, from: `ZUMRI <${process.env.MAIL_FROM}>`,
}; };
+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 { Redis } = require("ioredis");
const createRedisConnection = () => { const createRedisConnection = (options = {}) => {
const redis = new Redis({ const redis = new Redis({
host: process.env.REDIS_HOST || "redis", host: process.env.REDIS_HOST || "redis",
port: process.env.REDIS_PORT || 6379, port: process.env.REDIS_PORT || 6379,
password: process.env.REDIS_PASSWORD, password: process.env.REDIS_PASSWORD,
maxRetriesPerRequest: null, maxRetriesPerRequest: null,
enableReadyCheck: false, enableReadyCheck: false,
lazyConnect: options.lazyConnect ?? true,
}); });
// Connection events // 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 createRedisConnection = require("./redis.config");
const redis = createRedisConnection(); const redis = createRedisConnection({ lazyConnect: true });
module.exports = redis; module.exports = redis;
+11 -20
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.
*/
// app/config/s3.config.js
const { S3Client } = require("@aws-sdk/client-s3"); const { S3Client } = require("@aws-sdk/client-s3");
const s3 = new S3Client({ module.exports = process.env.ENABLE_S3 === "true"
region: process.env.AWS_REGION, ? new S3Client({
credentials: { region: process.env.AWS_REGION,
accessKeyId: process.env.AWS_ACCESS_KEY_ID, credentials: {
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, accessKeyId: process.env.AWS_ACCESS_KEY_ID,
}, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
}); },
...(process.env.S3_ENDPOINT ? { endpoint: process.env.S3_ENDPOINT } : {}),
module.exports = s3; 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};
+13 -1
View File
@@ -13,4 +13,16 @@
module.exports = { module.exports = {
FINANCE_BASIS: "finance.basis", FINANCE_BASIS: "finance.basis",
PRECOST: "precost.precost", 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",
};
+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");
+173 -113
View File
@@ -1,132 +1,192 @@
/** const db = require("../models");
* Copyright (c) 2026 Niolla const authService = require("../services/auth/auth.service");
* All rights reserved. const sessionService = require("../services/auth/session.service");
* const { hashPassword, checkPassword } = require("../utils/hashPassword.util");
* This source code is proprietary and confidential. const { createPasswordReset, consumePasswordResetToken, sendPasswordResetEmail } = require("../utils/passwordReset.utill");
* Unauthorized copying, modification, distribution, or use const { createEmailVerification, consumeEmailVerificationToken, sendVerificationEmail } = require("../utils/emailVerification.util");
* of this file, via any medium, is strictly prohibited. const { sendPasswordChanged } = require("../services/auth/email.service");
*/ const { logActivity } = require("../services/activity.service");
const { verifyToken } = require("../utils/jwt.util");
// app/controllers/auth.controller.js 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 { checkPassword } = require("../utils/hashPassword.util"); exports.login = async (req, res, next) => {
const { sendMail } = require("../utils/mail.util");
const { generateOTP, validateOTP } = require("../utils/otp.util");
const {getCachedUser,clearUserCache} = require("../utils/cache.util");
const { generateToken } = require("../utils/jwt.util");
const { log } = require("../utils/consoleLog.utill");
const appName = process.env.APP_NAME || "Niolla";
// Login Step 1: Request OTP
exports.loginReq = async (req, res) => {
try { 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); exports.verifyOtp = async (req, res, next) => {
if (!user) { try {
return res const input = req.validated.body;
.status(404) const result = await authService.completeOtpLogin(input, req);
.send({ success: false, message: "User Not Found" }); 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); exports.refreshToken = async (req, res, next) => {
if (!passwordIsValid) { try {
return res const input = req.validated.body;
.status(401) const token = input.refreshToken || req.cookies?.refresh_token;
.send({ success: false, message: "Invalid Password" }); 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) });
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" });
} catch (error) { } catch (error) {
log("Error occurred while sending OTP:", error); clearCookies(res);
res.status(500).send({ success: false, message: error.message }); 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.logout = async (req, res, next) => {
exports.login = async (req, res) => {
try { try {
const { email, otp } = req.body; const token = req.body?.refreshToken || req.cookies?.refresh_token;
let id = sessionService.tokenId(token);
const user = await getCachedUser(email); if (!id) {
const accessToken = req.cookies?.access_token || (req.headers.authorization?.startsWith("Bearer ") ? req.headers.authorization.slice(7) : null);
if (!user) { try { id = accessToken ? verifyToken(accessToken).sid : null; } catch (_error) { id = null; }
return res
.status(404)
.send({ success: false, message: "User Not Found" });
} }
if (id) await sessionService.revokeSession(id, "LOGOUT");
clearCookies(res);
res.json({ success: true, message: "Logged out successfully" });
} catch (error) { next(error); }
};
const isValidOTP = validateOTP(email, otp); 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 (!isValidOTP) { exports.me = async (req, res, next) => {
return res try {
.status(401) const user = await db.User.findByPk(req.user.id, { attributes: { exclude: ["password", "tokenVersion", "passwordChangedAt"] }, include: [{ model: db.Profile, as: "profile" }] });
.send({ success: false, message: "Invalid or Expired OTP" }); 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);
} }
res.json({ success: true, message });
// Generate JWT token
const token = generateToken({
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
role: user.role,
accountType: user.accountType,
});
// 3. Set JWT as HttpOnly cookie
res.cookie("access_token", token, {
httpOnly: true, // JS cannot access
secure: process.env.NODE_ENV === "production", // HTTPS only in prod
sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax",
maxAge: 24 * 60 * 60 * 1000, // 1 day
});
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,
},
});
} catch (error) { } catch (error) {
log("Error occurred during login:", error); console.error(`[${req.id}] Password reset request failed`, { name: error.name, message: error.message });
res.status(500).send({ success: false, message: error.message }); res.json({ success: true, message });
} }
}; };
// Logout: Clear the JWT cookie exports.resetPassword = async (req, res, next) => {
exports.logout = (req, res) => { try {
res.clearCookie("access_token", { const input = req.validated.body;
httpOnly: true, const userId = await consumePasswordResetToken(input.token);
secure: process.env.NODE_ENV === "production", if (!userId) throw Object.assign(new Error("Reset token is invalid or expired"), { status: 400, code: "INVALID_RESET_TOKEN" });
sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax", let changedUser;
}); await db.sequelize.transaction(async (transaction) => {
const user = await db.User.findByPk(userId, { transaction, lock: transaction.LOCK.UPDATE });
res.json({ success: true, message: "Logged out successfully" }); 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); }
};
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);
}
res.json({ success: true, message });
} catch (error) {
console.error(`[${req.id}] Verification resend failed`, { name: error.name, message: error.message });
res.json({ success: true, message });
}
};
exports.oauth = (provider) => async (req, res, next) => {
try {
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.adminLogin = async (req, res, next) => {
try {
const { PRIVILEGED_ACCOUNT_TYPES } = require("../constants/accountTypes");
const result = await authService.beginPasswordLogin(req.validated.body, req, PRIVILEGED_ACCOUNT_TYPES);
res.status(202).json({ success: true, data: result, message: "If the credentials are valid, a verification code has been sent" });
} catch (error) { next(error); }
};
exports.riderLogin = async (req, res, next) => {
try {
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 {logActivity}=require("../../services/activity.service");
exports.create=async(req,res,next)=>{try{if(!["customer","business_customer"].includes(req.user.accountType))return res.status(403).json({success:false,error:{code:"FORBIDDEN",message:"Customer account required"}});const product=await db.Product.findOne({where:{id:req.params.productId,status:"ACTIVE",visibility:"PUBLIC"}});if(!product)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Product not found"}});const review=await db.ProductReview.create({id:crypto.randomUUID(),product_id:product.id,user_id:req.user.id,rating:req.body.rating,title:req.body.title,body:req.body.body,status:"PENDING",verified_purchase:false});await logActivity({user:req.user,type:"REVIEW_SUBMITTED",module:"Catalogue",description:"Product review submitted",targetType:"PRODUCT_REVIEW",targetId:review.id,requestId:req.id});return res.status(201).json({success:true,data:{id:review.id,status:review.status}});}catch(e){return next(e);}};
exports.listAdmin=async(req,res,next)=>{try{const page=Math.max(Number(req.query.page)||1,1),limit=Math.min(Number(req.query.limit)||20,100),where={};if(req.query.status)where.status=req.query.status;const x=await db.ProductReview.findAndCountAll({where,limit,offset:(page-1)*limit,order:[["createdAt","DESC"]]});return res.json({success:true,data:x.rows,pagination:{page,limit,total:x.count}});}catch(e){return next(e);}};
exports.moderate=async(req,res,next)=>{try{const r=await db.ProductReview.findByPk(req.params.id);if(!r)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Review not found"}});await r.update({status:req.body.status,moderated_by:req.user.id,moderated_at:new Date()});await logActivity({user:req.user,type:req.body.status==="APPROVED"?"REVIEW_APPROVED":"REVIEW_REJECTED",module:"Catalogue",description:"Product review moderated",targetType:"PRODUCT_REVIEW",targetId:r.id,requestId:req.id});return res.json({success:true,data:{id:r.id,status:r.status}});}catch(e){return next(e);}};
@@ -0,0 +1,11 @@
const 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 @@
/** const crypto = require("crypto");
* Copyright (c) 2026 Niolla const { z } = require("zod");
* 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 db = require("../models"); const db = require("../models");
const Document = db.Document; const documentQueue = require("../queues/document.queue");
const DocumentType = db.DocumentType; const registry = require("../logic/documents/registry");
const storage = require("../services/storage/storage.service");
const normalizeDocumentData = (value) => { 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();
if (!value) { const normalize = (value) => String(value).toLowerCase().replace(/[\s_-]+/g, "");
return {}; 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") { exports.getAvailableDocumentTypes = async (_req, res) => res.json({ success: true, data: Object.keys(registry) });
try { 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); } };
return JSON.parse(value); 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); } };
} catch (error) { exports.generateReferenceNo = (_req, res) => res.status(410).json({ success: false, error: { code: "DEPRECATED", message: "Reference numbers are assigned during document creation" } });
return {}; 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); } };
return value; 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); } };
};
/**
* 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,
});
}
};
@@ -0,0 +1,11 @@
const crypto=require("crypto"),db=require("../../models"),inventory=require("../../services/inventory/inventory.service"),{logActivity}=require("../../services/activity.service");
const audit=(req,type,id)=>logActivity({user:req.user,type,module:"Inventory",description:type,targetType:"INVENTORY",targetId:id,requestId:req.id});
exports.list=async(req,res,next)=>{try{const rows=await db.InventoryBalance.findAll({include:[{model:db.Warehouse,as:"warehouse",attributes:["id","code","name"]},{model:db.ProductVariant,as:"variant",include:[{model:db.Product,as:"product",attributes:["id","slug"]}]}]});res.json({success:true,data:rows});}catch(e){next(e);}};
exports.detail=async(req,res,next)=>{try{const rows=await db.InventoryBalance.findAll({where:{variant_id:req.params.variantId},include:[{model:db.Warehouse,as:"warehouse"}]});res.json({success:true,data:rows});}catch(e){next(e);}};
exports.ledger=async(req,res,next)=>{try{res.json({success:true,data:await db.InventoryTransaction.findAll({where:req.query.variantId?{variant_id:req.query.variantId}:{},limit:Math.min(Number(req.query.limit)||50,200),order:[["occurred_at","DESC"]]})});}catch(e){next(e);}};
exports.lowStock=async(req,res,next)=>{try{const rows=await db.InventoryBalance.findAll();res.json({success:true,data:rows.filter(x=>Number(x.on_hand)-Number(x.reserved)<=Number(x.low_stock_threshold))});}catch(e){next(e);}};
exports.adjust=async(req,res,next)=>{try{const eventId=req.get("Idempotency-Key")||crypto.randomUUID(),result=await inventory.adjustStock({...req.body,eventId,actorUserId:req.user.id,requestId:req.id});await audit(req,"INVENTORY_ADJUSTED",eventId);res.status(result.idempotent?200:201).json({success:true,data:result});}catch(e){next(e);}};
exports.transfer=async(req,res,next)=>{try{const eventId=req.get("Idempotency-Key")||crypto.randomUUID(),result=await inventory.transferStock({...req.body,eventId,actorUserId:req.user.id,requestId:req.id});await audit(req,"INVENTORY_TRANSFERRED",eventId);res.status(result.idempotent?200:201).json({success:true,data:result});}catch(e){next(e);}};
exports.warehouses=async(req,res,next)=>{try{res.json({success:true,data:await db.Warehouse.findAll({order:[["code","ASC"]]})});}catch(e){next(e);}};
exports.createWarehouse=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{if(req.body.isDefault)await db.Warehouse.update({is_default:false},{where:{},transaction:t});row=await db.Warehouse.create({id:crypto.randomUUID(),code:req.body.code.toUpperCase(),name:req.body.name,status:req.body.status,is_default:req.body.isDefault,timezone:req.body.timezone,city:req.body.city,country_code:req.body.countryCode},{transaction:t});});await audit(req,"WAREHOUSE_CREATED",row.id);res.status(201).json({success:true,data:row});}catch(e){next(e);}};
exports.updateWarehouse=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{row=await db.Warehouse.findByPk(req.params.id,{transaction:t,lock:t.LOCK.UPDATE});if(!row)throw Object.assign(new Error("Warehouse not found"),{status:404,code:"NOT_FOUND"});if(req.body.isDefault)await db.Warehouse.update({is_default:false},{where:{},transaction:t});await row.update({name:req.body.name,status:req.body.status,is_default:req.body.isDefault,timezone:req.body.timezone,city:req.body.city,country_code:req.body.countryCode},{transaction:t});});await audit(req,"WAREHOUSE_UPDATED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};
@@ -0,0 +1,5 @@
const crypto=require("crypto"),db=require("../../models"),{logActivity}=require("../../services/activity.service");const map=(b,u)=>({name:b.name,type:b.type,status:b.status,starts_at:b.startsAt,ends_at:b.endsAt,priority:b.priority,stackable:b.stackable,discount_percent:b.discountPercent,discount_amount:b.discountAmount,fixed_price:b.fixedPrice,minimum_quantity:b.minimumQuantity,updated_by:u});
const crud=model=>async(req,res,next)=>{try{res.json({success:true,data:await model.findAll({order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.promotions=crud(db.Promotion);exports.coupons=crud(db.Coupon);exports.banners=crud(db.Banner);
exports.createPromotion=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{row=await db.Promotion.create({id:crypto.randomUUID(),...map(req.body,req.user.id),created_by:req.user.id},{transaction:t});await db.PromotionTarget.bulkCreate(req.body.targets.map(x=>({id:crypto.randomUUID(),promotion_id:row.id,target_type:x.type,target_id:x.id||null})),{transaction:t});});await logActivity({user:req.user,type:"PROMOTION_CREATED",module:"Merchandising",targetId:row.id});res.status(201).json({success:true,data:row});}catch(e){next(e);}};
exports.createCoupon=async(req,res,next)=>{try{const b=req.body,row=await db.Coupon.create({id:crypto.randomUUID(),code:b.code,promotion_id:b.promotionId,status:b.status,starts_at:b.startsAt,expires_at:b.expiresAt});await logActivity({user:req.user,type:"COUPON_CREATED",module:"Merchandising",targetId:row.id});res.status(201).json({success:true,data:row});}catch(e){next(e);}};
exports.createBanner=async(req,res,next)=>{try{const b=req.body;let row;await db.sequelize.transaction(async t=>{const upload=await db.Upload.findOne({where:{id:b.imageUploadId,status:"AVAILABLE"},transaction:t});if(!upload||!String(upload.mime_type||upload.mimeType).startsWith("image/"))throw Object.assign(new Error("Available image upload required"),{status:400,code:"INVALID_BANNER_MEDIA"});row=await db.Banner.create({id:crypto.randomUUID(),placement:b.placement,status:b.status,starts_at:b.startsAt,ends_at:b.endsAt,image_upload_id:b.imageUploadId,mobile_image_upload_id:b.mobileImageUploadId,target_url:b.targetUrl,audience_type:b.audienceType,business_tier_id:b.businessTierId,sort_order:b.sortOrder,created_by:req.user.id},{transaction:t});await db.BannerTranslation.bulkCreate(b.translations.map(x=>({id:crypto.randomUUID(),banner_id:row.id,locale:x.locale,headline:x.headline,subheadline:x.subheadline,cta_text:x.ctaText,alt_text:x.altText})),{transaction:t});});await logActivity({user:req.user,type:"BANNER_CREATED",module:"Merchandising",targetId:row.id});res.status(201).json({success:true,data:row});}catch(e){next(e);}};
@@ -0,0 +1,2 @@
const {Op}=require("sequelize"),db=require("../../models");exports.banners=async(req,res,next)=>{try{const now=new Date(),audience=req.user?.accountType==="BUSINESS_CUSTOMER"?["ALL","BUSINESS_CUSTOMER"]:req.user?["ALL","CUSTOMER"]:["ALL"];const where={status:"ACTIVE",audience_type:{[Op.in]:audience},[Op.and]:[{[Op.or]:[{starts_at:null},{starts_at:{[Op.lte]:now}}]},{[Op.or]:[{ends_at:null},{ends_at:{[Op.gt]:now}}]}]};if(req.query.placement)where.placement=req.query.placement;const rows=await db.Banner.findAll({where,include:[{model:db.BannerTranslation,as:"translations"}],order:[["sort_order","ASC"],["id","ASC"]]});res.json({success:true,data:rows.map(x=>({id:x.id,placement:x.placement,targetUrl:x.target_url,image:{uploadId:x.image_upload_id},translation:(x.translations.find(t=>t.locale===(req.query.locale||"en"))||x.translations.find(t=>t.locale==="en")||x.translations[0])}))});}catch(e){next(e);}};
exports.availability=async(req,res,next)=>{try{const rows=await db.InventoryBalance.findAll({where:{variant_id:req.params.variantId},attributes:["on_hand","reserved","low_stock_threshold"]}),available=rows.reduce((n,x)=>n+Number(x.on_hand)-Number(x.reserved),0),threshold=rows.reduce((n,x)=>n+Number(x.low_stock_threshold),0),status=available<=0?"OUT_OF_STOCK":available<=threshold?"LOW_STOCK":"IN_STOCK";res.json({success:true,data:{variantId:req.params.variantId,status,availableForSale:available>0}});}catch(e){next(e);}};
+16 -134
View File
@@ -1,138 +1,20 @@
/** const crypto = require("crypto");
* 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 db = require("../models"); const db = require("../models");
const Notification = db.notification; const { Op } = require("sequelize");
const UserNotification = db.userNotification;
const log = require("../utils/consoleLog.utill").log; exports.createNotification = async (req, res, next) => {
const { notificationHeadline, notificationDescription, notificationType, userIds = [] } = req.body;
// Controller for managing notifications if (!notificationHeadline || !["USER", "ANNOUNCEMENT"].includes(notificationType)) return res.status(400).json({ success: false, error: { code: "VALIDATION_ERROR", message: "Valid headline and notificationType are required" } });
exports.createNotification = async (req, res) => { 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 { try {
const { notificationHeadline, notificationDescription, notificationType } = const notification = await db.notification.create({ notification_id: `notif_${crypto.randomUUID()}`, notificationHeadline, notificationDescription, notificationType, dateCreated: new Date() }, { transaction });
req.body; 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 });
const id = Date.now().toString(); // Generate a unique ID based on the current timestamp await transaction.commit(); return res.status(201).json({ success: true, data: { notification, assignedUsers: uniqueUsers.length } });
const notification_id = `notif_${id}`; // Prefix the ID with "notif_" } catch (error) { await transaction.rollback(); return next(error); }
// 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
});
}
}; };
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); } };
// Mark a notification as read for a user 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) => { 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); } };
try { 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); } };
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
});
}
};
@@ -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 // Get Profile avatar by user ID
exports.getProfileAvatar = async (req, res) => { exports.getProfileAvatar = async (req, res) => {
try { try {
const userId = req.params.userId; const userId = req.params.userId || req.user.id;
const profile = await Profile.findOne({ where: { user_id: userId } }); const profile = await Profile.findOne({ where: { user_id: userId } });
if (!profile) { if (!profile) {
return res.status(404).json({ error: "Profile not found" }); return res.status(404).json({ error: "Profile not found" });
} }
const uploadRecord = await Upload.findByPk(profile.profilePicture_id); 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); const fileUrl = await getSignedFileUrl(uploadRecord.file_path);
res.json({ res.json({
success: true, success: true,
data: { data: {
userId: profile.userId, userId: profile.user_id,
profilePictureUrl: fileUrl, profilePictureUrl: fileUrl,
}, },
}); });
@@ -58,7 +59,7 @@ exports.getProfileAvatar = async (req, res) => {
// Get profile background image by user ID // Get profile background image by user ID
exports.getProfileBackgroundImage = async (req, res) => { exports.getProfileBackgroundImage = async (req, res) => {
try { try {
const userId = req.params.userId; const userId = req.params.userId || req.user.id;
const profile = await Profile.findOne({ where: { user_id: userId } }); const profile = await Profile.findOne({ where: { user_id: userId } });
if (!profile) { if (!profile) {
return res return res
@@ -67,13 +68,14 @@ exports.getProfileBackgroundImage = async (req, res) => {
} }
const uploadRecord = await Upload.findByPk(profile.backgroundImage_id); 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); const fileUrl = await getSignedFileUrl(uploadRecord.file_path);
res.json({ res.json({
success: true, success: true,
data: { data: {
userId: profile.userId, userId: profile.user_id,
backgroundImageUrl: fileUrl, backgroundImageUrl: fileUrl,
}, },
}); });
@@ -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);}};
+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 db = require("../models");
const Upload = db.Upload; const storage = require("../services/storage/storage.service");
const Assets = db.Assets; const { validateUpload } = require("../services/storage/file-validation.service");
// Constants const BUSINESS_DOCUMENT_PURPOSES = new Set(["BUSINESS_REGISTRATION", "TAX_DOCUMENT", "IDENTITY_DOCUMENT", "OTHER_SUPPORTING_DOCUMENT"]);
const MAX_IMAGE_SIZE = 3 * 1024 * 1024; // 3MB 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 MAX_PDF_SIZE = 5 * 1024 * 1024; // 5MB
const isValidFileType = (mimetype) => { exports.uploadFile = async (req, res, next) => {
return mimetype.startsWith("image/") || mimetype === "application/pdf"; let objectKey;
};
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) => {
try { try {
// 1. Check file exists const details = validateUpload(req.file);
if (!req.file) { const purpose = String(req.body.use_for || "generic").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 60);
return res.status(400).json({ objectKey = storage.createObjectKey({ ownerId: req.user.id, mimeType: details.mimeType, purpose });
success: false, await storage.uploadBuffer({ buffer: req.file.buffer, objectKey, mimeType: details.mimeType, checksum: details.checksum });
message: "No file uploaded", 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 { mimetype, size, originalname } = req.file; 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 } });
// 2. Validate file type } catch (error) { return next(error); }
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,
});
}
}; };
// Get Uploaded File URL exports.getFileUrl = async (req, res, next) => {
exports.getFileUrl = async (req, res) => {
try { try {
const { id } = req.params; 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 uploadRecord = await Upload.findByPk(id); 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" } });
if (!uploadRecord) { const expiresIn = Number(process.env.S3_SIGNED_URL_TTL_SECONDS || 900);
return res.status(404).json({ return res.json({ success: true, data: { id: upload.id, url: await storage.createSignedDownloadUrl(upload.file_path, expiresIn), expiresIn } });
success: false, } catch (error) { return next(error); }
message: "File not found", };
});
} exports.deleteFile = async (req, res, next) => {
try {
const fileUrl = await getSignedFileUrl(uploadRecord.file_path); 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" } });
return res.status(200).json({ const allowed = upload.owner_id === req.user.id || req.user.accountType === "super_admin" || (req.user.permissions || []).includes("media.delete");
success: true, if (!allowed) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } });
message: "File URL retrieved successfully", await upload.update({ status: "DELETED", deletedAt: new Date() });
data: { await storage.deleteObject(upload.file_path).catch(() => undefined);
id: uploadRecord.id, return res.status(204).end();
file_url: fileUrl, } catch (error) { return next(error); }
},
});
} catch (error) {}
}; };
+283 -40
View File
@@ -11,9 +11,26 @@
const db = require("../models"); const db = require("../models");
const { hashPassword } = require("../utils/hashPassword.util"); const { hashPassword } = require("../utils/hashPassword.util");
const {
validatePassword,
} = require("../utils/validation/validatePassword.util");
const { validateEmail } = require("../utils/validation/validateEmail.util");
const { generateUserId, generateId } = require("../utils/idGen.util"); const { generateUserId, generateId } = require("../utils/idGen.util");
const {
createCustomerDetails,
} = require("../utils/users/createCustomerDetails.util");
const {
createBusinessCustomerDetails,
} = require("../utils/users/createBusinessCustomer.util");
const { logActivity } = require("../services/activity.service"); const { logActivity } = require("../services/activity.service");
const { sendMail } = require("../utils/mail.util"); const { sendMail } = require("../utils/mail.util");
const {
createEmailVerification,
verifyEmailVerificationToken,
sendVerificationEmail,
deleteEmailVerification,
} = require("../utils/emailVerification.util");
const { getUserProfile } = require("../utils/users/userProfileDetails.util");
const User = db.User; const User = db.User;
const Profile = db.Profile; const Profile = db.Profile;
@@ -26,14 +43,48 @@ exports.createNewUser = async (req, res) => {
firstName, firstName,
lastName, lastName,
email, email,
role, password,
roleID, address,
accountType, phoneNumber,
department, businessName,
businessRegistrationNumber,
businessType,
contactName,
businessEmail,
expectedMonthlyVolume,
note,
} = req.body; } = req.body;
const hashedPassword = await hashPassword(process.env.DEFAULT_PASSWORD); if (!firstName || !lastName || !email || !password) {
const userID = generateUserId(); await transaction.rollback();
return res.status(400).send({
success: false,
message:
"First name, last name, email and password are required",
});
}
if (req.body.accountType && req.body.accountType !== "customer") {
await transaction.rollback();
return res.status(400).send({
success: false,
message:
"Public registration creates customer accounts only",
});
}
const emailValid = validateEmail(email);
if (!emailValid) {
await transaction.rollback();
return res.status(400).send({
success: false,
message: "Invalid email address",
});
}
const userExists = await User.findOne({ where: { email } }); const userExists = await User.findOne({ where: { email } });
if (userExists) { if (userExists) {
@@ -45,6 +96,20 @@ exports.createNewUser = async (req, res) => {
}); });
} }
const validatePasswordResult = validatePassword(password);
if (!validatePasswordResult) {
await transaction.rollback();
return res.status(400).send({
success: false,
message: "Password does not meet the required criteria",
});
}
const hashedPassword = await hashPassword(password);
const userID = generateUserId();
const newUser = await User.create( const newUser = await User.create(
{ {
id: userID, id: userID,
@@ -52,10 +117,9 @@ exports.createNewUser = async (req, res) => {
lastName, lastName,
email, email,
password: hashedPassword, password: hashedPassword,
accountType, accountType: "customer",
role, accountStatus: "PENDING_VERIFICATION",
roleID: roleID || "N/A", emailVerifiedAt: null,
department: department || null,
}, },
{ transaction }, { transaction },
); );
@@ -74,22 +138,35 @@ exports.createNewUser = async (req, res) => {
{ transaction }, { transaction },
); );
await sendMail({ // Create the customer identity extension for public registration.
to: email, {
subject: "Welcome - Ocenic Titan", const customerData = {
templateName: "welcome", address,phoneNumber,
templateVars: { };
firstName: firstName,
email: email, await createCustomerDetails(
password: process.env.DEFAULT_PASSWORD, newUser.id,
}, customerData,
text: `Hello ${firstName}, your account has been created successfully.`, transaction,
}); );
}
await transaction.commit(); await transaction.commit();
const verificationToken = await createEmailVerification(newUser.id);
try {
await sendVerificationEmail(
newUser.email,
newUser.firstName,
verificationToken,
);
} catch (error) {
console.error("Error sending verification email:", error);
}
await logActivity({ await logActivity({
user: req.user, user: newUser,
description: `Created New User with ID: ${newUser.id}`, description: `Created New User with ID: ${newUser.id}`,
type: "CREATE_USER", type: "CREATE_USER",
module: "User Management", module: "User Management",
@@ -104,12 +181,15 @@ exports.createNewUser = async (req, res) => {
lastName: newUser.lastName, lastName: newUser.lastName,
email: newUser.email, email: newUser.email,
accountType: newUser.accountType, accountType: newUser.accountType,
role: newUser.role,
department: newUser.department,
}, },
}); });
} catch (error) { } catch (error) {
await transaction.rollback(); if (!transaction.finished) {
await transaction.rollback();
}
console.error("CREATE USER ERROR:", error);
res.status(500).send({ res.status(500).send({
success: false, success: false,
@@ -119,6 +199,102 @@ exports.createNewUser = async (req, res) => {
} }
}; };
// Verify customer email
exports.verifyEmail = async (req, res) => {
const transaction = await db.sequelize.transaction();
try {
const { token } = req.body;
if (!token) {
await transaction.rollback();
return res.status(400).send({
success: false,
message: "Verification token is required",
});
}
const verification = await verifyEmailVerificationToken(token);
if (!verification) {
await transaction.rollback();
return res.status(400).send({
success: false,
message: "Verification token is invalid or expired",
});
}
const { userId, redisKey } = verification;
const user = await User.findOne({
where: {
id: userId,
},
transaction,
});
if (!user) {
await transaction.rollback();
await deleteEmailVerification(redisKey);
return res.status(404).send({
success: false,
message: "User not found",
});
}
if (user.accountStatus === "ACTIVE" && user.emailVerifiedAt) {
await transaction.rollback();
// Token is no longer needed
await deleteEmailVerification(redisKey);
return res.status(400).send({
success: false,
message: "Email is already verified",
});
}
user.accountStatus = "ACTIVE";
user.emailVerifiedAt = new Date();
await user.save({
transaction,
});
await transaction.commit();
await deleteEmailVerification(redisKey);
return res.status(200).send({
success: true,
message: "Email verified successfully. Your account is now active.",
});
} catch (error) {
if (!transaction.finished) {
await transaction.rollback();
}
console.error("VERIFY EMAIL ERROR:", error);
return res.status(500).send({
success: false,
message: "Failed to verify email",
});
}
};
// Get users with pagination (20 per page) // Get users with pagination (20 per page)
exports.getAllUsers = async (req, res) => { exports.getAllUsers = async (req, res) => {
try { try {
@@ -152,36 +328,81 @@ exports.getAllUsers = async (req, res) => {
} }
}; };
// Get user details by ID //get user profile details
exports.getUserById = async (req, res) => { exports.userProfile = async (req, res) => {
try {
const { id } = req.params;
const user = await User.findOne({
where: { id },
attributes: { exclude: ["password"] },
include: [{ model: Profile, as: "profile" }],
});
if (!user) { try {
const userId = req.user.id;
const profile =
await getUserProfile(userId);
if (!profile) {
return res.status(404).send({ return res.status(404).send({
success: false, success: false,
message: "User not found", message: "User not found",
}); });
} }
res.status(200).send({
return res.status(200).send({
success: true, success: true,
data: user, message:
"User profile retrieved successfully",
data: profile,
}); });
} catch (error) { } catch (error) {
res.status(500).send({
console.error(
"GET USER PROFILE ERROR:",
error
);
return res.status(500).send({
success: false, success: false,
message: "Failed to retrieve user", message:
error: error.message, "Failed to retrieve user profile",
}); });
} }
}; };
// Get user details by ID
// exports.getUserById = async (req, res) => {
// try {
// const { id } = req.params;
// const user = await User.findOne({
// where: { id },
// attributes: { exclude: ["password"] },
// include: [{ model: Profile, as: "profile" }],
// });
// if (!user) {
// return res.status(404).send({
// success: false,
// message: "User not found",
// });
// }
// res.status(200).send({
// success: true,
// data: user,
// });
// } catch (error) {
// res.status(500).send({
// success: false,
// message: "Failed to retrieve user",
// error: error.message,
// });
// }
// };
// Update user details // Update user details
exports.updateUser = async (req, res) => { exports.updateUser = async (req, res) => {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
@@ -210,6 +431,7 @@ exports.updateUser = async (req, res) => {
}); });
if (!user) { if (!user) {
await transaction.rollback();
return res.status(404).send({ return res.status(404).send({
success: false, success: false,
message: "User not found", message: "User not found",
@@ -217,6 +439,7 @@ exports.updateUser = async (req, res) => {
} }
if (!UserProfile) { if (!UserProfile) {
await transaction.rollback();
return res.status(404).send({ return res.status(404).send({
success: false, success: false,
message: "User profile not found", message: "User profile not found",
@@ -294,6 +517,7 @@ exports.deleteUser = async (req, res) => {
where: { id }, where: { id },
}); });
if (!user) { if (!user) {
await transaction.rollback();
return res.status(404).send({ return res.status(404).send({
success: false, success: false,
message: "User not found", message: "User not found",
@@ -321,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); }
};
+5 -1
View File
@@ -69,4 +69,8 @@ const registry = {
console.log("📋 Registry initialized with keys:", Object.keys(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 { verifyToken } = require("../utils/jwt.util");
const { getEffectivePermissions } = require("../services/permission.service"); const { getEffectivePermissions } = require("../services/permission.service");
const db = require("../models");
const authenticate = async (req, res, next) => { const authenticate = async (req, res, next) => {
try { try {
let token = null; 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" } });
// 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 decoded = verifyToken(token); const decoded = verifyToken(token);
if (!decoded.sub || !decoded.sid || !Number.isInteger(decoded.tokenVersion)) throw new Error("Required claims missing");
if (process.env.NODE_ENV === "development") { const [user, session] = await Promise.all([
console.log("DECODED:", decoded); 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" } });
} }
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) };
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,
};
next(); next();
} catch (err) { } catch (error) {
console.error("AUTH ERROR:", err); res.clearCookie("access_token");
return res.status(401).json({ success: false, error: { code: "UNAUTHORIZED", message: "Invalid or expired access token" } });
// 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",
});
} }
}; };
module.exports = { authenticate }; module.exports = { authenticate, requireAuth: authenticate };
+6 -21
View File
@@ -1,21 +1,6 @@
/** const { authenticate } = require("./auth.middleware");
* Copyright (c) 2026 Niolla const { ACCOUNT_TYPES } = require("../constants/accountTypes");
* All rights reserved. module.exports = (req, res, next) => authenticate(req, res, () => {
* if ([ACCOUNT_TYPES.ADMIN, ACCOUNT_TYPES.SUPER_ADMIN].includes(req.user.accountType)) return next();
* This source code is proprietary and confidential. return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Administrative access required" } });
* 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",
});
};
+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 };
+18 -93
View File
@@ -1,100 +1,25 @@
/** const { ACCOUNT_TYPES } = require("../constants/accountTypes");
* 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/permission.middleware.js const authorizedAccountType = (allowedTypes) => (req, res, next) => {
if (!req.user) return res.status(401).json({ success: false, error: { code: "UNAUTHORIZED", message: "Authentication required" } });
const hasPermission = (userPermissions, requiredPermission) => { if (req.user.accountType !== ACCOUNT_TYPES.SUPER_ADMIN && !allowedTypes.includes(req.user.accountType)) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } });
return userPermissions.some(p => { next();
if (p === requiredPermission) return true;
// wildcard support
if (p.endsWith(".*")) {
const prefix = p.slice(0, -2);
return requiredPermission.startsWith(prefix);
}
return false;
});
}; };
const methodToAction = { const checkPermission = (baseOrFull, options = {}) => (req, res, next) => {
GET: "view", if (!req.user) return res.status(401).json({ success: false, error: { code: "UNAUTHORIZED", message: "Authentication required" } });
POST: "create", if (req.user.accountType === ACCOUNT_TYPES.SUPER_ADMIN) return next();
PUT: "update", const actions = { GET: "view", POST: "create", PUT: "update", PATCH: "update", DELETE: "delete" };
PATCH: "update", const required = options.custom ? baseOrFull : `${baseOrFull}.${actions[req.method]}`;
DELETE: "delete" const permissions = req.user.permissions || [];
const allowed = permissions.includes(required) || permissions.some((value) => value.endsWith(".*") && required.startsWith(value.slice(0, -1)));
if (!allowed) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } });
next();
}; };
const checkPermission = (baseOrFull, options = {}) => { const requireOwnership = (param = "id") => (req, res, next) => {
return (req, res, next) => { if (req.user.accountType === ACCOUNT_TYPES.SUPER_ADMIN || req.user.accountType === ACCOUNT_TYPES.ADMIN || req.user.id === req.params[param]) return next();
if (!req.user) { return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } });
return res.status(401).json({
success: false,
message: "Unauthorized"
});
}
// admin bypass
if (req.user.accountType === "admin") {
return next();
}
if (typeof baseOrFull !== "string") {
return res.status(500).json({
success: false,
message: "Permission must be a string"
});
}
let requiredPermission;
if (options.custom) {
requiredPermission = baseOrFull;
} else {
const action = methodToAction[req.method];
if (!action) {
return res.status(500).json({
success: false,
message: "Unknown HTTP method"
});
}
requiredPermission = `${baseOrFull}.${action}`;
}
const userPermissions = req.user.permissions || [];
const hasPermission =
userPermissions.includes(requiredPermission) ||
userPermissions.includes(`${baseOrFull}.*`);
if (!hasPermission) {
return res.status(403).json({
success: false,
message: `Forbidden - Missing ${requiredPermission}`
});
}
next();
};
}; };
const authorizedAccountType = (allowedTypes) => { module.exports = { authorizedAccountType, requireAccountType: authorizedAccountType, checkPermission, requirePermission: checkPermission, requireOwnership };
return (req, res, next) => {
if (!req.user || !allowedTypes.includes(req.user.accountType)) {
return res
.status(403)
.json({ success: false, message: "Forbidden" });
}
next();
}
}
module.exports = {authorizedAccountType, checkPermission};
+21
View File
@@ -0,0 +1,21 @@
const { rateLimit } = require("express-rate-limit");
const response = { success: false, error: { code: "RATE_LIMITED", message: "Too many requests" } };
const generalApiLimiter = rateLimit({
windowMs: Number(process.env.API_RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
limit: Number(process.env.API_RATE_LIMIT_MAX) || 300,
standardHeaders: "draft-8",
legacyHeaders: false,
message: response,
});
const sensitiveLimiter = rateLimit({
windowMs: Number(process.env.SENSITIVE_RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
limit: Number(process.env.SENSITIVE_RATE_LIMIT_MAX) || 20,
standardHeaders: "draft-8",
legacyHeaders: false,
message: response,
});
module.exports = { generalApiLimiter, sensitiveLimiter };
+10
View File
@@ -0,0 +1,10 @@
const { randomUUID } = require("crypto");
const SAFE_REQUEST_ID = /^[A-Za-z0-9_-]{8,128}$/;
module.exports = (req, res, next) => {
const supplied = req.get("x-request-id");
req.id = supplied && SAFE_REQUEST_ID.test(supplied) ? supplied : randomUUID();
res.setHeader("X-Request-ID", req.id);
next();
};
+4 -15
View File
@@ -16,23 +16,12 @@ const storage = multer.memoryStorage();
// File filter (only images + PDFs) // File filter (only images + PDFs)
const fileFilter = (req, file, cb) => { const fileFilter = (req, file, cb) => {
const allowedTypes = [ const isValid = ["image/jpeg", "image/png", "image/webp", "application/pdf", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"].includes(file.mimetype);
"image/",
"application/pdf",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx
"application/vnd.ms-excel" // .xls
];
const isValid =
file.mimetype.startsWith("image/") ||
file.mimetype === "application/pdf" ||
file.mimetype === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ||
file.mimetype === "application/vnd.ms-excel";
if (isValid) { if (isValid) {
cb(null, true); cb(null, true);
} else { } else {
cb(new Error("Only images, PDFs, and Excel files are allowed"), false); cb(new Error("Only JPEG, PNG, WebP, PDF, and XLSX files are allowed"), false);
} }
}; };
@@ -41,7 +30,7 @@ const upload = multer({
storage, storage,
fileFilter, fileFilter,
limits: { limits: {
fileSize: 5 * 1024 * 1024, // max 5MB (global limit) fileSize: Number(process.env.S3_MAX_UPLOAD_BYTES || 5 * 1024 * 1024),
}, },
}); });
@@ -61,4 +50,4 @@ module.exports = {
uploadSingle, uploadSingle,
uploadMultiple, uploadMultiple,
uploadFields, uploadFields,
}; };
+7
View File
@@ -0,0 +1,7 @@
module.exports = (schema) => (req, _res, next) => {
try {
req.validated = schema.parse({ body: req.body, params: req.params, query: req.query });
req.body = req.validated.body;
next();
} catch (error) { next(error); }
};
@@ -18,9 +18,10 @@ module.exports = (sequelize, DataTypes) => {
autoIncrement: true, autoIncrement: true,
primaryKey: true, primaryKey: true,
}, },
event_id: { type: DataTypes.STRING, allowNull: true, unique: true },
user_id: { user_id: {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: false, allowNull: true,
}, },
username: { username: {
type: DataTypes.STRING, type: DataTypes.STRING,
@@ -45,13 +46,17 @@ module.exports = (sequelize, DataTypes) => {
activity_date:{ activity_date:{
type: DataTypes.DATEONLY, type: DataTypes.DATEONLY,
allowNull: false, allowNull: false,
} },
target_type: DataTypes.STRING, target_id: DataTypes.STRING,
ip_address: DataTypes.STRING, user_agent: DataTypes.STRING, request_id: DataTypes.STRING,
metadata: DataTypes.JSON, occurred_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW },
}, },
{ {
tableName: "UserActivity", tableName: "UserActivity",
timestamps: true, timestamps: true,
}, },
); );
UserActivity.associate = (models) => UserActivity.belongsTo(models.User, { foreignKey: "user_id", as: "actor", constraints: false });
return UserActivity; return UserActivity;
}; };
+1
View File
@@ -0,0 +1 @@
module.exports=(s,D)=>{const M=s.define("Brand",{id:{type:D.STRING,primaryKey:true},name:{type:D.STRING(160),allowNull:false},slug:{type:D.STRING(180),allowNull:false,unique:true},description:D.TEXT,logo_upload_id:D.INTEGER,website:D.STRING,status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"INACTIVE"},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"brands",timestamps:true});M.associate=m=>{M.hasMany(m.Product,{foreignKey:"brand_id",as:"products"});M.belongsTo(m.Upload,{foreignKey:"logo_upload_id",as:"logo",constraints:false});};return M;};
+1
View File
@@ -0,0 +1 @@
module.exports=(s,D)=>{const M=s.define("Category",{id:{type:D.STRING,primaryKey:true},parent_id:D.STRING,code:{type:D.STRING(80),allowNull:false,unique:true},slug:{type:D.STRING(180),allowNull:false,unique:true},status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"INACTIVE"},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0},image_upload_id:D.INTEGER},{tableName:"categories",timestamps:true});M.associate=m=>{M.belongsTo(M,{foreignKey:"parent_id",as:"parent"});M.hasMany(M,{foreignKey:"parent_id",as:"children"});M.hasMany(m.CategoryTranslation,{foreignKey:"category_id",as:"translations"});M.belongsToMany(m.Product,{through:m.ProductCategory,foreignKey:"category_id",otherKey:"product_id",as:"products"});M.belongsTo(m.Upload,{foreignKey:"image_upload_id",as:"image",constraints:false});};return M;};
@@ -0,0 +1 @@
module.exports=(s,D)=>{const M=s.define("CategoryTranslation",{id:{type:D.STRING,primaryKey:true},category_id:{type:D.STRING,allowNull:false},locale:{type:D.ENUM("en","si","ta"),allowNull:false},name:{type:D.STRING(180),allowNull:false},description:D.TEXT,meta_title:D.STRING(180),meta_description:D.STRING(320)},{tableName:"category_translations",timestamps:true,indexes:[{unique:true,fields:["category_id","locale"]}]});M.associate=m=>M.belongsTo(m.Category,{foreignKey:"category_id",as:"category"});return M;};
+1
View File
@@ -0,0 +1 @@
module.exports=(s,D)=>{const M=s.define("Collection",{id:{type:D.STRING,primaryKey:true},slug:{type:D.STRING(180),allowNull:false,unique:true},type:{type:D.STRING(80),allowNull:false,defaultValue:"EDITORIAL"},status:{type:D.ENUM("DRAFT","ACTIVE","INACTIVE"),allowNull:false,defaultValue:"DRAFT"},starts_at:D.DATE,ends_at:D.DATE,hero_upload_id:D.INTEGER,sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"collections",timestamps:true});M.associate=m=>{M.hasMany(m.CollectionTranslation,{foreignKey:"collection_id",as:"translations"});M.belongsToMany(m.Product,{through:m.CollectionProduct,foreignKey:"collection_id",otherKey:"product_id",as:"products"});M.belongsTo(m.Upload,{foreignKey:"hero_upload_id",as:"hero",constraints:false});};return M;};
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("CollectionProduct",{id:{type:D.STRING,primaryKey:true},collection_id:{type:D.STRING,allowNull:false},product_id:{type:D.STRING,allowNull:false},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0},featured:{type:D.BOOLEAN,allowNull:false,defaultValue:false}},{tableName:"collection_products",timestamps:true,indexes:[{unique:true,fields:["collection_id","product_id"]}]});
@@ -0,0 +1 @@
module.exports=(s,D)=>{const M=s.define("CollectionTranslation",{id:{type:D.STRING,primaryKey:true},collection_id:{type:D.STRING,allowNull:false},locale:{type:D.ENUM("en","si","ta"),allowNull:false},name:{type:D.STRING(180),allowNull:false},description:D.TEXT,headline:D.STRING(250),subheadline:D.STRING(300)},{tableName:"collection_translations",timestamps:true,indexes:[{unique:true,fields:["collection_id","locale"]}]});M.associate=m=>M.belongsTo(m.Collection,{foreignKey:"collection_id",as:"collection"});return M;};
+1
View File
@@ -0,0 +1 @@
module.exports=(s,D)=>{const M=s.define("Product",{id:{type:D.STRING,primaryKey:true},brand_id:{type:D.STRING,allowNull:false},default_category_id:D.STRING,slug:{type:D.STRING(200),allowNull:false,unique:true},product_code:{type:D.STRING(100),allowNull:false,unique:true},status:{type:D.ENUM("DRAFT","ACTIVE","INACTIVE","ARCHIVED"),allowNull:false,defaultValue:"DRAFT"},visibility:{type:D.ENUM("PUBLIC","HIDDEN"),allowNull:false,defaultValue:"HIDDEN"},product_type:{type:D.STRING(80),allowNull:false,defaultValue:"STANDARD"},featured:{type:D.BOOLEAN,allowNull:false,defaultValue:false},new_arrival_until:D.DATE,published_at:D.DATE,created_by:{type:D.STRING,allowNull:false},updated_by:D.STRING,size_guide_id:D.STRING},{tableName:"products",timestamps:true});M.associate=m=>{M.belongsTo(m.Brand,{foreignKey:"brand_id",as:"brand"});M.belongsTo(m.Category,{foreignKey:"default_category_id",as:"defaultCategory"});M.hasMany(m.ProductTranslation,{foreignKey:"product_id",as:"translations"});M.hasMany(m.ProductVariant,{foreignKey:"product_id",as:"variants"});M.hasMany(m.ProductOption,{foreignKey:"product_id",as:"options"});M.hasMany(m.ProductAttribute,{foreignKey:"product_id",as:"attributes"});M.hasMany(m.ProductMedia,{foreignKey:"product_id",as:"media"});M.belongsToMany(m.Category,{through:m.ProductCategory,foreignKey:"product_id",otherKey:"category_id",as:"categories"});M.belongsTo(m.SizeGuide,{foreignKey:"size_guide_id",as:"sizeGuide"});M.hasMany(m.ProductReview,{foreignKey:"product_id",as:"reviews"});};return M;};
@@ -0,0 +1 @@
module.exports=(s,D)=>{const M=s.define("ProductAttribute",{id:{type:D.STRING,primaryKey:true},product_id:{type:D.STRING,allowNull:false},name:{type:D.STRING(100),allowNull:false},value:{type:D.STRING(500),allowNull:false},locale:{type:D.ENUM("en","si","ta"),allowNull:false,defaultValue:"en"},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"product_attributes",timestamps:true});M.associate=m=>M.belongsTo(m.Product,{foreignKey:"product_id",as:"product"});return M;};
@@ -0,0 +1 @@
module.exports=(s,D)=>{const M=s.define("ProductCategory",{id:{type:D.STRING,primaryKey:true},product_id:{type:D.STRING,allowNull:false},category_id:{type:D.STRING,allowNull:false}},{tableName:"product_categories",timestamps:true,indexes:[{unique:true,fields:["product_id","category_id"]}]});return M;};
@@ -0,0 +1 @@
module.exports=(s,D)=>{const M=s.define("ProductMedia",{id:{type:D.STRING,primaryKey:true},product_id:{type:D.STRING,allowNull:false},variant_id:D.STRING,upload_id:{type:D.INTEGER,allowNull:false},type:{type:D.ENUM("IMAGE"),allowNull:false,defaultValue:"IMAGE"},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0},alt_text:D.STRING(250),is_primary:{type:D.BOOLEAN,allowNull:false,defaultValue:false}},{tableName:"product_media",timestamps:true,indexes:[{unique:true,fields:["product_id","upload_id"]}]});M.associate=m=>{M.belongsTo(m.Product,{foreignKey:"product_id",as:"product"});M.belongsTo(m.ProductVariant,{foreignKey:"variant_id",as:"variant"});M.belongsTo(m.Upload,{foreignKey:"upload_id",as:"upload",constraints:false});};return M;};
@@ -0,0 +1 @@
module.exports=(s,D)=>{const M=s.define("ProductOption",{id:{type:D.STRING,primaryKey:true},product_id:{type:D.STRING,allowNull:false},name:{type:D.STRING(100),allowNull:false},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"product_options",timestamps:true,indexes:[{unique:true,fields:["product_id","name"]}]});M.associate=m=>{M.belongsTo(m.Product,{foreignKey:"product_id",as:"product"});M.hasMany(m.ProductOptionValue,{foreignKey:"option_id",as:"values"});};return M;};
@@ -0,0 +1 @@
module.exports=(s,D)=>{const M=s.define("ProductOptionValue",{id:{type:D.STRING,primaryKey:true},option_id:{type:D.STRING,allowNull:false},value:{type:D.STRING(100),allowNull:false},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"product_option_values",timestamps:true,indexes:[{unique:true,fields:["option_id","value"]}]});M.associate=m=>{M.belongsTo(m.ProductOption,{foreignKey:"option_id",as:"option"});};return M;};
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("ProductRelation",{id:{type:D.STRING,primaryKey:true},source_product_id:{type:D.STRING,allowNull:false},target_product_id:{type:D.STRING,allowNull:false},relation_type:{type:D.ENUM("RELATED","SIMILAR","COMPLETE_THE_LOOK"),allowNull:false},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"product_relations",timestamps:true,indexes:[{unique:true,fields:["source_product_id","target_product_id","relation_type"]}]});
@@ -0,0 +1 @@
module.exports=(s,D)=>{const M=s.define("ProductReview",{id:{type:D.STRING,primaryKey:true},product_id:{type:D.STRING,allowNull:false},user_id:{type:D.STRING,allowNull:false},rating:{type:D.INTEGER,allowNull:false,validate:{min:1,max:5}},title:{type:D.STRING(160),allowNull:false},body:{type:D.TEXT,allowNull:false},status:{type:D.ENUM("PENDING","APPROVED","REJECTED"),allowNull:false,defaultValue:"PENDING"},verified_purchase:{type:D.BOOLEAN,allowNull:false,defaultValue:false},moderated_by:D.STRING,moderated_at:D.DATE},{tableName:"product_reviews",timestamps:true,indexes:[{unique:true,fields:["user_id","product_id"]}]});M.associate=m=>{M.belongsTo(m.Product,{foreignKey:"product_id",as:"product"});M.belongsTo(m.User,{foreignKey:"user_id",as:"author"});};return M;};
@@ -0,0 +1 @@
module.exports=(s,D)=>{const M=s.define("ProductTranslation",{id:{type:D.STRING,primaryKey:true},product_id:{type:D.STRING,allowNull:false},locale:{type:D.ENUM("en","si","ta"),allowNull:false},name:{type:D.STRING(220),allowNull:false},short_description:D.STRING(500),description:D.TEXT,care_instructions:D.TEXT,materials:D.TEXT,origin:D.STRING(120),meta_title:D.STRING(180),meta_description:D.STRING(320)},{tableName:"product_translations",timestamps:true,indexes:[{unique:true,fields:["product_id","locale"]}]});M.associate=m=>M.belongsTo(m.Product,{foreignKey:"product_id",as:"product"});return M;};
@@ -0,0 +1 @@
module.exports=(s,D)=>{const M=s.define("ProductVariant",{id:{type:D.STRING,primaryKey:true},product_id:{type:D.STRING,allowNull:false},sku:{type:D.STRING(100),allowNull:false,unique:true},barcode:{type:D.STRING(100),unique:true},status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"},base_price:{type:D.DECIMAL(15,2),allowNull:false},compare_at_price:D.DECIMAL(15,2),currency:{type:D.STRING(3),allowNull:false,defaultValue:"LKR"},weight:D.DECIMAL(10,3),sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"product_variants",timestamps:true});M.associate=m=>{M.belongsTo(m.Product,{foreignKey:"product_id",as:"product"});M.belongsToMany(m.ProductOptionValue,{through:m.VariantOptionValue,foreignKey:"variant_id",otherKey:"option_value_id",as:"optionValues"});M.hasMany(m.ProductMedia,{foreignKey:"variant_id",as:"media"});};return M;};
+1
View File
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("SizeGuide",{id:{type:D.STRING,primaryKey:true},code:{type:D.STRING(80),allowNull:false,unique:true},name:{type:D.STRING(160),allowNull:false},locale:{type:D.ENUM("en","si","ta"),allowNull:false,defaultValue:"en"},data:{type:D.JSON,allowNull:false},category_id:D.STRING,status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"}},{tableName:"size_guides",timestamps:true});
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("VariantOptionValue",{id:{type:D.STRING,primaryKey:true},variant_id:{type:D.STRING,allowNull:false},option_value_id:{type:D.STRING,allowNull:false}},{tableName:"variant_option_values",timestamps:true,indexes:[{unique:true,fields:["variant_id","option_value_id"]}]});
+10 -2
View File
@@ -14,7 +14,7 @@ module.exports = (sequelize, DataTypes) => {
"Document", "Document",
{ {
doc_id:{ doc_id:{
type: DataTypes.STRING, type: DataTypes.ENUM("DRAFT", "QUEUED", "PROCESSING", "COMPLETED", "FAILED"),
primaryKey: true, primaryKey: true,
}, },
reference_no: { reference_no: {
@@ -34,13 +34,21 @@ module.exports = (sequelize, DataTypes) => {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: false, allowNull: false,
defaultValue: "DRAFT", defaultValue: "DRAFT",
} },
created_by: { type: DataTypes.STRING, allowNull: false },
owner_type: { type: DataTypes.STRING, allowNull: false, defaultValue: "USER" },
owner_id: { type: DataTypes.STRING, allowNull: false },
storage_upload_id: { type: DataTypes.INTEGER, allowNull: true },
job_id: { type: DataTypes.STRING, allowNull: true, unique: true },
generated_at: DataTypes.DATE, failed_at: DataTypes.DATE, failure_code: DataTypes.STRING,
version: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 1 },
}, },
{ {
tableName: "Document", tableName: "Document",
timestamps: true, timestamps: true,
}, },
); );
document.associate = (models) => { document.belongsTo(models.User, { foreignKey: "created_by", as: "creator", constraints: false }); document.belongsTo(models.Upload, { foreignKey: "storage_upload_id", as: "storageUpload", constraints: false }); };
return document; return document;
}; };
+2 -2
View File
@@ -20,8 +20,8 @@ module.exports = (sequelize, DataTypes) => {
}, },
doc_type_name: { doc_type_name: {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: true, allowNull: false,
defaultValue: "N/A" unique: true,
}, },
description: { description: {
type: DataTypes.JSON, type: DataTypes.JSON,
+38 -6
View File
@@ -13,11 +13,7 @@
const { Sequelize, DataTypes } = require("sequelize"); const { Sequelize, DataTypes } = require("sequelize");
const dbConfig = require("../config/db.config"); const dbConfig = require("../config/db.config");
let loggingOption = false; const loggingOption = process.env.NODE_ENV === "development" ? console.log : false;
if(process.env.NODE_ENV === 'production') {
loggingOption = true;
}
const sequelize = new Sequelize( const sequelize = new Sequelize(
dbConfig.DB, dbConfig.DB,
@@ -41,8 +37,17 @@ db.sequelize = sequelize;
// User and Authentication // User and Authentication
db.User = require("./user/user.model")(sequelize, DataTypes); db.User = require("./user/user.model")(sequelize, DataTypes);
db.Customer = require("./user/customer.model")(sequelize, DataTypes);
db.BusinessCustomer = require("./user/businessCustomer.model")(sequelize, DataTypes);
db.AuthSession = require("./user/authSession.model")(sequelize, DataTypes);
db.UserIdentity = require("./user/userIdentity.model")(sequelize, DataTypes);
db.UserActivity = require("./activities/userActivities.model")(sequelize, DataTypes); db.UserActivity = require("./activities/userActivities.model")(sequelize, DataTypes);
db.Profile = require("./user/profile.model")(sequelize, DataTypes); db.Profile = require("./user/profile.model")(sequelize, DataTypes);
db.Address = require("./user/address.model")(sequelize, DataTypes);
db.BusinessApplication = require("./user/businessApplication.model")(sequelize, DataTypes);
db.BusinessContact = require("./user/businessContact.model")(sequelize, DataTypes);
db.BusinessCreditAccount = require("./user/businessCreditAccount.model")(sequelize, DataTypes);
db.SettlementTerm = require("./user/settlementTerm.model")(sequelize, DataTypes);
// Uploads // Uploads
db.Upload = require("./upload/upload.model")(sequelize, DataTypes); db.Upload = require("./upload/upload.model")(sequelize, DataTypes);
@@ -52,6 +57,7 @@ db.roles = require("./permission/role.model")(sequelize, DataTypes);
db.permission = require("./permission/permission.model")(sequelize, DataTypes); db.permission = require("./permission/permission.model")(sequelize, DataTypes);
db.rolePermission = require("./permission/rolePermission.model")(sequelize, DataTypes); db.rolePermission = require("./permission/rolePermission.model")(sequelize, DataTypes);
db.userPermission = require("./permission/userPermission.model")(sequelize, DataTypes); db.userPermission = require("./permission/userPermission.model")(sequelize, DataTypes);
db.UserRole = require("./permission/userRole.model")(sequelize, DataTypes);
// Document Management // Document Management
db.Document = require("./document/document.model")(sequelize, DataTypes); db.Document = require("./document/document.model")(sequelize, DataTypes);
@@ -63,6 +69,32 @@ db.referenceNumber = require("./referenceNumbers/referenceNumber.model")(sequeli
// Notifications // Notifications
db.notification = require("./notification/notification.model")(sequelize, DataTypes); db.notification = require("./notification/notification.model")(sequelize, DataTypes);
db.userNotification = require("./notification/userNotification.model")(sequelize, DataTypes); db.userNotification = require("./notification/userNotification.model")(sequelize, DataTypes);
db.NotificationDelivery = require("./notification/notificationDelivery.model")(sequelize, DataTypes);
// Catalogue and localized content
db.Brand = require("./catalogue/brand.model")(sequelize, DataTypes);
db.Category = require("./catalogue/category.model")(sequelize, DataTypes);
db.CategoryTranslation = require("./catalogue/categoryTranslation.model")(sequelize, DataTypes);
db.Product = require("./catalogue/product.model")(sequelize, DataTypes);
db.ProductTranslation = require("./catalogue/productTranslation.model")(sequelize, DataTypes);
db.ProductCategory = require("./catalogue/productCategory.model")(sequelize, DataTypes);
db.ProductVariant = require("./catalogue/productVariant.model")(sequelize, DataTypes);
db.ProductOption = require("./catalogue/productOption.model")(sequelize, DataTypes);
db.ProductOptionValue = require("./catalogue/productOptionValue.model")(sequelize, DataTypes);
db.VariantOptionValue = require("./catalogue/variantOptionValue.model")(sequelize, DataTypes);
db.ProductAttribute = require("./catalogue/productAttribute.model")(sequelize, DataTypes);
db.ProductMedia = require("./catalogue/productMedia.model")(sequelize, DataTypes);
db.Collection = require("./catalogue/collection.model")(sequelize, DataTypes);
db.CollectionTranslation = require("./catalogue/collectionTranslation.model")(sequelize, DataTypes);
db.CollectionProduct = require("./catalogue/collectionProduct.model")(sequelize, DataTypes);
db.SizeGuide = require("./catalogue/sizeGuide.model")(sequelize, DataTypes);
db.ProductRelation = require("./catalogue/productRelation.model")(sequelize, DataTypes);
db.ProductReview = require("./catalogue/productReview.model")(sequelize, DataTypes);
db.Warehouse=require("./inventory/warehouse.model")(sequelize,DataTypes);db.InventoryBalance=require("./inventory/inventoryBalance.model")(sequelize,DataTypes);db.InventoryTransaction=require("./inventory/inventoryTransaction.model")(sequelize,DataTypes);db.InventoryReservation=require("./inventory/inventoryReservation.model")(sequelize,DataTypes);db.InventoryTransfer=require("./inventory/inventoryTransfer.model")(sequelize,DataTypes);
db.BusinessTier=require("./pricing/businessTier.model")(sequelize,DataTypes);db.VariantBusinessPrice=require("./pricing/variantBusinessPrice.model")(sequelize,DataTypes);db.BusinessPriceTier=require("./pricing/businessPriceTier.model")(sequelize,DataTypes);
db.Promotion=require("./merchandising/promotion.model")(sequelize,DataTypes);db.PromotionTarget=require("./merchandising/promotionTarget.model")(sequelize,DataTypes);db.Coupon=require("./merchandising/coupon.model")(sequelize,DataTypes);db.Banner=require("./merchandising/banner.model")(sequelize,DataTypes);db.BannerTranslation=require("./merchandising/bannerTranslation.model")(sequelize,DataTypes);
db.Cart=require("./shopping/cart.model")(sequelize,DataTypes);db.CartItem=require("./shopping/cartItem.model")(sequelize,DataTypes);db.WishlistItem=require("./shopping/wishlistItem.model")(sequelize,DataTypes);db.CheckoutSession=require("./shopping/checkoutSession.model")(sequelize,DataTypes);db.CheckoutItem=require("./shopping/checkoutItem.model")(sequelize,DataTypes);
db.ShippingZone=require("./shipping/shippingZone.model")(sequelize,DataTypes);db.ShippingZoneRegion=require("./shipping/shippingZoneRegion.model")(sequelize,DataTypes);db.ShippingMethod=require("./shipping/shippingMethod.model")(sequelize,DataTypes);db.ShippingRate=require("./shipping/shippingRate.model")(sequelize,DataTypes);
/* Associations */ /* Associations */
Object.keys(db).forEach(model => { Object.keys(db).forEach(model => {
@@ -74,4 +106,4 @@ Object.keys(db).forEach(model => {
module.exports = db; module.exports = db;
@@ -0,0 +1 @@
module.exports=(s,D)=>{const M=s.define("InventoryBalance",{id:{type:D.STRING,primaryKey:true},warehouse_id:{type:D.STRING,allowNull:false},variant_id:{type:D.STRING,allowNull:false},on_hand:{type:D.INTEGER,allowNull:false,defaultValue:0},reserved:{type:D.INTEGER,allowNull:false,defaultValue:0},low_stock_threshold:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"inventory_balances",timestamps:true,indexes:[{unique:true,fields:["warehouse_id","variant_id"]}]});M.associate=db=>{M.belongsTo(db.Warehouse,{foreignKey:"warehouse_id",as:"warehouse"});M.belongsTo(db.ProductVariant,{foreignKey:"variant_id",as:"variant"});};return M;};
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("InventoryReservation",{id:{type:D.STRING,primaryKey:true},reservation_key:{type:D.STRING(160),allowNull:false,unique:true},warehouse_id:{type:D.STRING,allowNull:false},variant_id:{type:D.STRING,allowNull:false},quantity:{type:D.INTEGER,allowNull:false},status:{type:D.ENUM("ACTIVE","RELEASED","EXPIRED","CONSUMED"),allowNull:false,defaultValue:"ACTIVE"},reference_type:D.STRING(80),reference_id:D.STRING,user_id:D.STRING,expires_at:{type:D.DATE,allowNull:false},released_at:D.DATE,consumed_at:D.DATE},{tableName:"inventory_reservations",timestamps:true});
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("InventoryTransaction",{id:{type:D.STRING,primaryKey:true},event_id:{type:D.STRING,allowNull:false,unique:true},warehouse_id:{type:D.STRING,allowNull:false},variant_id:{type:D.STRING,allowNull:false},type:{type:D.ENUM("INITIAL","RECEIPT","ADJUSTMENT","RESERVATION","RESERVATION_RELEASE","RESERVATION_CONSUME","TRANSFER_OUT","TRANSFER_IN","CORRECTION"),allowNull:false},quantity_delta:{type:D.INTEGER,allowNull:false,defaultValue:0},reserved_delta:{type:D.INTEGER,allowNull:false,defaultValue:0},reference_type:D.STRING(80),reference_id:D.STRING,reason:D.STRING(500),actor_user_id:D.STRING,request_id:D.STRING,metadata:D.JSON,occurred_at:{type:D.DATE,allowNull:false,defaultValue:D.NOW}},{tableName:"inventory_transactions",timestamps:true,updatedAt:false});
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("InventoryTransfer",{id:{type:D.STRING,primaryKey:true},event_id:{type:D.STRING,allowNull:false,unique:true},transfer_number:{type:D.STRING(80),allowNull:false,unique:true},source_warehouse_id:{type:D.STRING,allowNull:false},destination_warehouse_id:{type:D.STRING,allowNull:false},variant_id:{type:D.STRING,allowNull:false},quantity:{type:D.INTEGER,allowNull:false},status:{type:D.ENUM("COMPLETED"),allowNull:false,defaultValue:"COMPLETED"},created_by:{type:D.STRING,allowNull:false},completed_by:{type:D.STRING,allowNull:false},completed_at:{type:D.DATE,allowNull:false}},{tableName:"inventory_transfers",timestamps:true});
+1
View File
@@ -0,0 +1 @@
module.exports=(s,D)=>{const M=s.define("Warehouse",{id:{type:D.STRING,primaryKey:true},code:{type:D.STRING(50),allowNull:false,unique:true},name:{type:D.STRING(150),allowNull:false},status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"},is_default:{type:D.BOOLEAN,allowNull:false,defaultValue:false},timezone:{type:D.STRING(80),allowNull:false,defaultValue:"Asia/Colombo"},address_line_1:D.STRING,address_line_2:D.STRING,city:D.STRING,province:D.STRING,postal_code:D.STRING(20),country_code:{type:D.STRING(2),allowNull:false,defaultValue:"LK"}},{tableName:"warehouses",timestamps:true});M.associate=db=>M.hasMany(db.InventoryBalance,{foreignKey:"warehouse_id",as:"balances"});return M;};
+1
View File
@@ -0,0 +1 @@
module.exports=(s,D)=>{const M=s.define("Banner",{id:{type:D.STRING,primaryKey:true},placement:{type:D.STRING(80),allowNull:false},status:{type:D.ENUM("DRAFT","ACTIVE","INACTIVE","ARCHIVED"),allowNull:false,defaultValue:"DRAFT"},starts_at:D.DATE,ends_at:D.DATE,image_upload_id:{type:D.INTEGER,allowNull:false},mobile_image_upload_id:D.INTEGER,target_url:D.STRING,audience_type:{type:D.ENUM("ALL","CUSTOMER","BUSINESS_CUSTOMER"),allowNull:false,defaultValue:"ALL"},business_tier_id:D.STRING,sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0},created_by:{type:D.STRING,allowNull:false},updated_by:D.STRING},{tableName:"banners",timestamps:true});M.associate=db=>M.hasMany(db.BannerTranslation,{foreignKey:"banner_id",as:"translations"});return M;};
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("BannerTranslation",{id:{type:D.STRING,primaryKey:true},banner_id:{type:D.STRING,allowNull:false},locale:{type:D.ENUM("en","si","ta"),allowNull:false},headline:D.STRING(250),subheadline:D.STRING(400),cta_text:D.STRING(100),alt_text:D.STRING(250)},{tableName:"banner_translations",timestamps:true,indexes:[{unique:true,fields:["banner_id","locale"]}]});
+1
View File
@@ -0,0 +1 @@
module.exports=(s,D)=>{const M=s.define("Coupon",{id:{type:D.STRING,primaryKey:true},code:{type:D.STRING(50),allowNull:false,unique:true},promotion_id:{type:D.STRING,allowNull:false},status:{type:D.ENUM("ACTIVE","INACTIVE","ARCHIVED"),allowNull:false,defaultValue:"ACTIVE"},starts_at:D.DATE,expires_at:D.DATE,max_uses:D.INTEGER,max_uses_per_user:D.INTEGER},{tableName:"coupons",timestamps:true,hooks:{beforeValidate:x=>{if(x.code)x.code=x.code.trim().toUpperCase();}}});M.associate=db=>M.belongsTo(db.Promotion,{foreignKey:"promotion_id",as:"promotion"});return M;};
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("Promotion",{id:{type:D.STRING,primaryKey:true},name:{type:D.STRING(180),allowNull:false},type:{type:D.ENUM("PERCENTAGE","FIXED_AMOUNT","FIXED_PRICE"),allowNull:false},status:{type:D.ENUM("DRAFT","ACTIVE","INACTIVE","ARCHIVED"),allowNull:false,defaultValue:"DRAFT"},starts_at:D.DATE,ends_at:D.DATE,priority:{type:D.INTEGER,allowNull:false,defaultValue:0},stackable:{type:D.BOOLEAN,allowNull:false,defaultValue:false},discount_percent:D.DECIMAL(5,2),discount_amount:D.DECIMAL(15,2),fixed_price:D.DECIMAL(15,2),minimum_subtotal:D.DECIMAL(15,2),minimum_quantity:D.INTEGER,customer_type:D.STRING(40),created_by:{type:D.STRING,allowNull:false},updated_by:D.STRING},{tableName:"promotions",timestamps:true});
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("PromotionTarget",{id:{type:D.STRING,primaryKey:true},promotion_id:{type:D.STRING,allowNull:false},target_type:{type:D.ENUM("ALL","PRODUCT","VARIANT","CATEGORY","BRAND","COLLECTION"),allowNull:false},target_id:D.STRING},{tableName:"promotion_targets",timestamps:true,indexes:[{unique:true,fields:["promotion_id","target_type","target_id"]}]});
@@ -0,0 +1,11 @@
module.exports = (sequelize, DataTypes) => sequelize.define("NotificationDelivery", {
id: { type: DataTypes.STRING, primaryKey: true },
notificationId: { type: DataTypes.STRING, allowNull: true },
userId: { type: DataTypes.STRING, allowNull: true },
channel: { type: DataTypes.ENUM("EMAIL", "PUSH"), allowNull: false },
recipient: { type: DataTypes.STRING, allowNull: false },
templateKey: { type: DataTypes.STRING, allowNull: false },
status: { type: DataTypes.ENUM("QUEUED", "PROCESSING", "SENT", "FAILED"), allowNull: false, defaultValue: "QUEUED" },
attemptCount: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 },
lastAttemptAt: DataTypes.DATE, sentAt: DataTypes.DATE, failedAt: DataTypes.DATE, failureCode: DataTypes.STRING,
}, { tableName: "notification_deliveries", timestamps: true });
@@ -41,11 +41,13 @@ module.exports = (sequelize, DataTypes) => {
userNotification.associate = (models) => { userNotification.associate = (models) => {
userNotification.belongsTo(models.User, { userNotification.belongsTo(models.User, {
foreignKey: "user_id", foreignKey: "user_id",
as: "user",
constraints: false, constraints: false,
}); });
userNotification.belongsTo(models.notification, { userNotification.belongsTo(models.notification, {
foreignKey: "notification_id", foreignKey: "notification_id",
as: "notification",
constraints: false, constraints: false,
}); });
}; };

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