Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5158c52db5 | |||
| f920ca8920 | |||
| abb760cc19 | |||
| 222483d194 | |||
| cd2c1c6d08 | |||
| ad9287b804 | |||
| 5643d89236 | |||
| fd4e324571 | |||
| b6b345f245 | |||
| 9d3d431416 | |||
| 267e80e2ec | |||
| 624fce31c5 | |||
| 230962b1d0 | |||
| efb054f54d | |||
| 7766614898 | |||
| 5d29a8f78f | |||
| 2a35d86ebf | |||
| 6a4fe852e5 | |||
| b2c5e198a1 | |||
| 621d348eb1 | |||
| c3d9f899a1 | |||
| ef8db0988b |
+61
-34
@@ -1,28 +1,44 @@
|
||||
# App Details
|
||||
APP_NAME=
|
||||
# Required for API and worker startup
|
||||
APP_NAME=ZUMRI
|
||||
NODE_ENV=development
|
||||
PORT=3070
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
TRUST_PROXY=0
|
||||
|
||||
# Database configuration variables
|
||||
DB_HOST =
|
||||
DB_USER =
|
||||
DB_PASSWORD =
|
||||
DB_NAME =
|
||||
DB_PORT =
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_NAME=zumri
|
||||
DB_USER=zumri
|
||||
DB_PASSWORD=replace_with_local_database_password
|
||||
MYSQL_ROOT_PASSWORD=replace_with_local_root_password
|
||||
|
||||
# Redis configuration variables
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=replace_with_local_redis_password
|
||||
|
||||
# JWT secret key
|
||||
JWT_SECRET = your_jwt_secret_key_here
|
||||
JWT_EXPIRES_IN =1d
|
||||
# Use separate randomly generated values of at least 32 characters.
|
||||
JWT_SECRET=replace_with_a_random_value_at_least_32_chars
|
||||
JWT_EXPIRES_IN=15m
|
||||
JWT_ISSUER=zumri-api
|
||||
JWT_AUDIENCE=zumri-clients
|
||||
ACCESS_TOKEN_TTL=15m
|
||||
REFRESH_TOKEN_TTL_DAYS=7
|
||||
REMEMBER_ME_REFRESH_TOKEN_TTL_DAYS=30
|
||||
LOGIN_OTP_TTL_SECONDS=900
|
||||
LOGIN_OTP_MAX_ATTEMPTS=5
|
||||
|
||||
# AWS S3 configuration
|
||||
AWS_ACCESS_KEY_ID=your_aws_access_key_id_here
|
||||
AWS_SECRET_ACCESS_KEY=your_aws_secret_access_key_here
|
||||
AWS_REGION=your_aws_region_here
|
||||
AWS_S3_BUCKET_NAME=your_aws_bucket_name_here
|
||||
# Runtime controls
|
||||
RUN_CRON=false
|
||||
CACHE=true
|
||||
JSON_BODY_LIMIT=1mb
|
||||
API_RATE_LIMIT_WINDOW_MS=900000
|
||||
API_RATE_LIMIT_MAX=300
|
||||
SENSITIVE_RATE_LIMIT_WINDOW_MS=900000
|
||||
SENSITIVE_RATE_LIMIT_MAX=20
|
||||
SHUTDOWN_TIMEOUT_MS=10000
|
||||
|
||||
# Mail configuration
|
||||
# Optional email feature
|
||||
ENABLE_MAIL=false
|
||||
MAIL_HOST=
|
||||
MAIL_PORT=587
|
||||
MAIL_USER=
|
||||
@@ -30,24 +46,35 @@ MAIL_PASS=
|
||||
MAIL_SECURE=false
|
||||
MAIL_FROM=
|
||||
|
||||
# Documentation access credentials
|
||||
# Optional S3 feature
|
||||
ENABLE_S3=false
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_REGION=
|
||||
AWS_S3_BUCKET_NAME=
|
||||
S3_ENDPOINT=
|
||||
S3_FORCE_PATH_STYLE=false
|
||||
S3_SIGNED_URL_TTL_SECONDS=900
|
||||
S3_MAX_UPLOAD_BYTES=5242880
|
||||
|
||||
# Cross-cutting worker and retention settings
|
||||
EMAIL_QUEUE_CONCURRENCY=5
|
||||
DOCUMENT_QUEUE_CONCURRENCY=2
|
||||
NOTIFICATION_RETENTION_DAYS=90
|
||||
LOG_RETENTION_DAYS=30
|
||||
|
||||
# Optional documentation login
|
||||
DOCS_USER=
|
||||
DOCS_PASS=
|
||||
|
||||
# Admin email for receiving notifications
|
||||
# Optional application configuration
|
||||
MANAGER_EMAIL=
|
||||
|
||||
# Caching configuration
|
||||
CACHE=true
|
||||
|
||||
# Application environment
|
||||
NODE_ENV=development
|
||||
|
||||
# User default password
|
||||
DEFAULT_PASSWORD=
|
||||
|
||||
# Frontend URL for CORS
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
|
||||
# Puppeteer executable path (if needed, otherwise Puppeteer will use the bundled Chromium)
|
||||
PUPPETEER_EXECUTABLE_PATH = C:\Users\User\.cache\puppeteer\chrome-headless-shell\win64-142.0.7444.162\chrome-headless-shell-win64\chrome-headless-shell.exe
|
||||
PASSWORD_RESET_TTL_SECONDS=900
|
||||
EMAIL_VERIFICATION_TTL_SECONDS=86400
|
||||
PUPPETEER_EXECUTABLE_PATH=
|
||||
GOOGLE_CLIENT_ID=
|
||||
APPLE_CLIENT_ID=
|
||||
# Phase 10 bounded reconciliation and privacy retention
|
||||
SUPPORT_SLA_RECONCILIATION_BATCH_SIZE=100
|
||||
RECOMMENDATION_EVENT_RETENTION_DAYS=90
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
const path = require("path");
|
||||
|
||||
module.exports = {
|
||||
config: path.resolve("app/config/sequelize-cli.config.js"),
|
||||
"migrations-path": path.resolve("migrations"),
|
||||
};
|
||||
+13
-35
@@ -1,44 +1,22 @@
|
||||
FROM node:20-slim
|
||||
FROM node:22-slim
|
||||
|
||||
# Install Chromium + dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
chromium \
|
||||
fonts-liberation \
|
||||
libatk-bridge2.0-0 \
|
||||
libatk1.0-0 \
|
||||
libcups2 \
|
||||
libxcomposite1 \
|
||||
libxrandr2 \
|
||||
libxdamage1 \
|
||||
libgbm1 \
|
||||
libasound2 \
|
||||
libpangocairo-1.0-0 \
|
||||
libpango-1.0-0 \
|
||||
libnss3 \
|
||||
libxss1 \
|
||||
libgtk-3-0 \
|
||||
libdrm2 \
|
||||
libxshmfence1 \
|
||||
ca-certificates \
|
||||
--no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Tell Puppeteer to use system Chromium
|
||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
|
||||
# Prevent Puppeteer from downloading its own Chromium
|
||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
|
||||
chromium curl fonts-liberation libatk-bridge2.0-0 libatk1.0-0 libcups2 \
|
||||
libxcomposite1 libxrandr2 libxdamage1 libgbm1 libasound2 libpangocairo-1.0-0 \
|
||||
libpango-1.0-0 libnss3 libxss1 libgtk-3-0 libdrm2 libxshmfence1 ca-certificates \
|
||||
--no-install-recommends && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium \
|
||||
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
||||
NODE_ENV=production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY . .
|
||||
|
||||
ENV NODE_ENV=production
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
COPY --chown=node:node . .
|
||||
|
||||
USER node
|
||||
EXPOSE 3070
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD curl --fail --silent http://127.0.0.1:3070/health/live > /dev/null || exit 1
|
||||
CMD ["node", "server.js"]
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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,41 @@
|
||||
# Delivery and Rider API
|
||||
|
||||
All endpoints use `/api/v1`. There is no public tracking endpoint.
|
||||
|
||||
## Admin fulfillment
|
||||
|
||||
- `GET /admin/shipments`, `GET /admin/shipments/:id`
|
||||
- `POST /admin/shipments` creates a delivery from immutable Order address/shipping snapshots and item quantities.
|
||||
- `POST /admin/shipments/return-pickup` creates one pickup for an approved return.
|
||||
- `POST /admin/shipments/:id/assign`, `/unassign`, `/reschedule`, `/cancel`
|
||||
- `GET /admin/dispatch` projects unassigned, active, and attention-needed shipments.
|
||||
|
||||
Administration requires explicit shipment, dispatch, rider, or return-logistics permission. Shipment creation supports partial quantities, locks the order, checks existing non-cancelled allocations, and uses `Idempotency-Key` event identity.
|
||||
|
||||
## Rider administration
|
||||
|
||||
- `GET /admin/riders`, `GET /admin/riders/:id`
|
||||
- `POST /admin/riders` attaches an operational profile to an existing RIDER User; it does not create another identity/password system.
|
||||
- `PATCH /admin/riders/:id`
|
||||
- `GET /admin/riders/:id/assignments`
|
||||
|
||||
Profile status is independent of User account status. Availability is AVAILABLE, BUSY, or OFFLINE and capacity is configurable.
|
||||
|
||||
## Rider workflow
|
||||
|
||||
- `GET /rider/shipments`, `GET /rider/shipments/:id`
|
||||
- Explicit actions: accept, reject, pickup, in-transit, out-for-delivery, deliver, and fail-delivery.
|
||||
- `PUT /rider/location` stores optional latest coordinates; location is not included in customer tracking.
|
||||
|
||||
Every operation derives the rider from authentication and constrains the current assignment. State changes use row locks and unique event IDs. Delivery requires structured proof; linked photo/signature uploads must be AVAILABLE images uploaded by that rider.
|
||||
|
||||
## Customer tracking
|
||||
|
||||
- `GET /orders/:orderId/tracking`
|
||||
- `GET /returns/:id/tracking`
|
||||
|
||||
Responses contain shipment status and ordered event projections, not rider phone, location, capacity, assignment history, payment, or provider data.
|
||||
|
||||
## Behavior boundaries
|
||||
|
||||
Delivered item quantities recalculate Order fulfillment without changing payment status. Failed delivery never refunds or restocks. Return-pickup delivery marks the RMA received for Phase 7 inspection; it does not refund or restock. External couriers, maps, route optimization, delivery OTP, live sockets, and AI dispatch are not implemented.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Inventory and Merchandising API
|
||||
|
||||
All routes use the `/api/v1` prefix. Admin routes require authentication and the named Phase 5 permission.
|
||||
|
||||
## Inventory and warehouses
|
||||
|
||||
- `GET /admin/inventory` (`inventory.read`)
|
||||
- `GET /admin/inventory/:variantId` (`inventory.read`)
|
||||
- `GET /admin/inventory/ledger` (`inventory.read`)
|
||||
- `GET /admin/inventory/low-stock` (`inventory.read`)
|
||||
- `POST /admin/inventory/adjustments` (`inventory.adjust`); send `Idempotency-Key`
|
||||
- `POST /admin/inventory/transfers` (`inventory.transfer`); send `Idempotency-Key`
|
||||
- `GET /admin/warehouses` (`inventory.read`)
|
||||
- `POST /admin/warehouses`, `PATCH /admin/warehouses/:id` (`inventory.warehouses.manage`)
|
||||
- `GET /availability/:variantId` returns only `IN_STOCK`, `LOW_STOCK`, or `OUT_OF_STOCK` and `availableForSale`; it never exposes warehouse quantities.
|
||||
|
||||
Inventory mutations are internal service operations: `reserveStock`, `releaseReservation`, and `consumeReservation`. Reservations default to `INVENTORY_RESERVATION_TTL_MINUTES=15`. The minute reconciliation job expires bounded batches of 100; the database remains authoritative.
|
||||
|
||||
## Business pricing
|
||||
|
||||
- `GET /admin/business-pricing` (`pricing.business.read`)
|
||||
- `POST /admin/business-pricing` (`pricing.business.manage`)
|
||||
|
||||
Rules support exactly one tier or customer audience, effective dates, MOQ, and non-overlapping volume ranges. Precedence is customer override, business tier, then retail. Money is stored as DECIMAL and calculated using integer-scaled helpers.
|
||||
|
||||
## Promotions and coupons
|
||||
|
||||
- `GET|POST /admin/promotions` (`promotions.read` / `promotions.manage`)
|
||||
- `GET|POST /admin/coupons` (`promotions.read` / `promotions.manage`)
|
||||
|
||||
The quote boundary resolves retail/business base price, then the highest-priority eligible automatic promotion, then a coupon only when stacking permits. Discounts floor at zero. Coupon codes are canonical uppercase. Usage redemption is intentionally deferred until orders exist.
|
||||
|
||||
## Banners
|
||||
|
||||
- `GET /banners?placement=&locale=` returns active, scheduled, audience-eligible localized banners.
|
||||
- `GET|POST /admin/banners` requires `merchandising.banners.manage`.
|
||||
|
||||
Banner media reuses Upload records and only returns safe upload identifiers, never bucket/object keys.
|
||||
|
||||
## Not implemented
|
||||
|
||||
Cart, checkout, orders, coupon redemption, shipping, tax, payment, and delivery remain outside Phase 5.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Loyalty and Rewards API
|
||||
|
||||
All customer APIs are authenticated and derive the loyalty owner from the session.
|
||||
|
||||
## Customer
|
||||
|
||||
- `GET /api/v1/loyalty` — balance, debt, lifetime points, current tier, benefits, and next-tier progress.
|
||||
- `GET /api/v1/loyalty/history?page=&limit=` — paginated immutable ledger.
|
||||
- `GET /api/v1/loyalty/tiers`, `GET /api/v1/loyalty/rewards`.
|
||||
- `POST /api/v1/loyalty/redeem` — requires `Idempotency-Key` and reward ID.
|
||||
- `GET /api/v1/loyalty/vouchers` — owner-specific coupon entitlements.
|
||||
- `GET /api/v1/loyalty/referral`, `POST /api/v1/loyalty/referral/claim`.
|
||||
|
||||
## Administration
|
||||
|
||||
- Loyalty accounts and referral listing.
|
||||
- Tier, earn-rule, and reward listing/creation.
|
||||
- `POST /admin/loyalty/adjustments` requires `loyalty.points.adjust`, a non-zero integer delta, reason, and idempotency key.
|
||||
|
||||
## Policy
|
||||
|
||||
Purchase points are awarded only after authoritative PAID processing. Eligible value is discounted merchandise (`subtotal - discountTotal`), excluding shipping, tax, and duties. Money is divided by configured `amountUnit`, floored to whole units, then multiplied by configured points.
|
||||
|
||||
Verified-review rewards require APPROVED and verified purchase. Referral rewards require the referred account's qualifying paid Order. Birthday events use `BIRTHDAY:user:year`; February 29 follows the actual calendar date.
|
||||
|
||||
Ledger entries are never edited. Refund reversals append negative entries. If already-spent points prevent a complete debit, available points floor at zero and the remainder becomes explicit `pointsDebt`; later earnings repay debt first.
|
||||
|
||||
Redemption locks the account/reward, validates limits, spends earliest-expiring allocations first, and creates at most one result per account/idempotency key. Coupon rewards reuse Phase 5 Coupon and issue an owner-specific entitlement.
|
||||
|
||||
Expiry is configured per earn rule and reconciled in bounded daily batches. No direct balance/tier mutation API exists.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Newsletter Consent API
|
||||
|
||||
`POST /api/v1/newsletter/subscribe` accepts `email`, `locale`, and an allowlisted source (`HOME_FOOTER`, `CHECKOUT`, or `ACCOUNT`). Email is trimmed, lowercased, and uniquely stored. Repeated subscription is idempotent and enumeration-safe.
|
||||
|
||||
The current product policy uses immediate single opt-in. Each subscription receives a cryptographically random unsubscribe token, while only its SHA-256 hash is stored. `POST /api/v1/newsletter/unsubscribe/:token` always returns a neutral success response. Resubscription records fresh consent and rotates the token.
|
||||
|
||||
Authenticated users may inspect their linked consent at `GET /api/v1/newsletter/me`. Admin listing is `GET /api/v1/admin/newsletter/subscribers` and requires `newsletter.subscribers.read`; token hashes are never returned. Export/manage permissions are reserved, and Phase 10 does not implement campaign sending.
|
||||
|
||||
Newsletter consent is legally distinct from Phase 3 profile marketing preferences. An email preference does not create newsletter consent, and an explicit newsletter unsubscribe takes precedence until a new explicit subscribe action occurs.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Orders and Payments API
|
||||
|
||||
All `/api/v1` customer endpoints derive ownership from authentication.
|
||||
|
||||
## Orders
|
||||
|
||||
- `POST /orders/from-checkout` converts an owned READY checkout. Conversion locks the checkout, verifies ACTIVE reservations, copies immutable snapshots, converts the cart, and is idempotent by unique checkout ID.
|
||||
- `GET /orders` and `GET /orders/:id` provide owner-scoped history/detail.
|
||||
- `POST /orders/:id/cancel` releases unpaid reservations. Paid orders require an explicit refund.
|
||||
- Admin: `GET /admin/orders`, `GET /admin/orders/:id`, cancel, and mark-processing action endpoints.
|
||||
|
||||
Legal transitions are centralized. Payment, order, and fulfillment status are separate.
|
||||
|
||||
## Payments and webhooks
|
||||
|
||||
- `POST /orders/:id/payment`, `GET /orders/:id/payment`
|
||||
- `POST /payments/webhooks/payhere` is unauthenticated at the session layer but requires the PayHere merchant hash.
|
||||
|
||||
Online browser redirects are never authoritative. A verified webhook must match local payment reference, currency, and DECIMAL amount. Unique provider event IDs make retries idempotent. A first successful event consumes each reservation once, records coupon redemption, marks payment/order paid, and issues invoice metadata. Failure does not consume inventory.
|
||||
|
||||
PayHere configuration uses `PAYHERE_MERCHANT_ID`, `PAYHERE_MERCHANT_SECRET`, and notify/return/cancel URLs. Stripe has an explicit disabled adapter until its official SDK and webhook secret are configured. No raw card or provider secret is accepted or returned.
|
||||
|
||||
## Invoices
|
||||
|
||||
- `GET /orders/:id/invoice` is owner-scoped.
|
||||
|
||||
Invoice numbers use the locked ReferenceNumber sequence. Invoice metadata is created once per paid order. PDF generation is reserved for the existing document worker integration; no second PDF/storage subsystem was introduced.
|
||||
|
||||
## Refunds
|
||||
|
||||
- `POST /admin/orders/:id/refunds` requires `payments.refund` and `Idempotency-Key`.
|
||||
|
||||
Requested item quantities and captured totals are locked and validated. A refund never restocks inventory automatically. Provider refund execution remains disabled until provider API credentials/workflows are validated.
|
||||
|
||||
## Returns
|
||||
|
||||
- Customer: `POST /orders/:orderId/returns`, `GET /returns`, `GET /returns/:id`.
|
||||
- Admin: list, approve, reject, mark-received, and complete actions.
|
||||
|
||||
The return window uses `RETURN_WINDOW_DAYS` (default 30). Quantity cannot exceed the remaining purchased quantity. Only accepted RESTOCKABLE items are added through the inventory service; damaged/non-restockable items are not. EXCHANGE records intent only.
|
||||
|
||||
Business credit purchasing is intentionally disabled: no locked credit ledger was added without an approved accounting policy. Delivery, loyalty, support AI, and analytics are outside Phase 7.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Deterministic Recommendation API
|
||||
|
||||
Phase 10 recommendations are local, explainable rules—not AI or machine learning.
|
||||
|
||||
- `POST /api/v1/recommendations/events` accepts authenticated, idempotent `PRODUCT_VIEW` events only. Clients cannot claim purchases or other authoritative commerce events.
|
||||
- `GET /api/v1/recommendations/recently-viewed` returns a customer's unique recent public products.
|
||||
- `GET /api/v1/products/:productId/recommendations/related` prefers Phase 4 `ProductRelation`, then bounded same-category/brand fallback.
|
||||
- `GET /api/v1/recommendations/trending` ranks a 30-day bounded aggregate with purchase/cart/wishlist/click/view weights.
|
||||
- `GET /api/v1/recommendations/popular` uses eligible paid order item quantities net of refunded quantity, with a featured fallback.
|
||||
- `GET /api/v1/recommendations/for-you` combines the authenticated user's recent product interests with aggregate candidates and falls back to trending/featured products.
|
||||
|
||||
Responses reuse the Phase 4 localized product summary and signed media projection. Only active/public products with an active variant are eligible. Exact stock is not exposed. Limits are capped at 24. Business-specific price quotation remains a known integration item; no customer-specific recommendation response is shared in cache.
|
||||
|
||||
Events store no email, IP, access token, cookie, raw session key, or full user agent. Session keys, if enabled later for anonymous ingestion, are hash-only. Retention defaults to 90 days and cleanup is bounded. The clean service boundary can later be replaced by a separately authenticated recommendation service; Phase 10 adds no URL, credential, bypass, LLM, embedding, vector store, or ML model.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Support and Help API
|
||||
|
||||
Phase 10 adds a permission-gated support case system and a localized help center. All routes are under `/api/v1`.
|
||||
|
||||
## Customer support
|
||||
|
||||
- `POST /support/tickets` creates a self-owned ticket and initial public message in one transaction. Identity, business context, priority and status are server-derived.
|
||||
- `GET /support/tickets` and `GET /support/tickets/:id` are self-only; internal notes and internal attachments are excluded.
|
||||
- `POST /support/tickets/:id/messages` appends a public reply. A resolved ticket is explicitly reopened; closed/cancelled tickets reject replies.
|
||||
- `POST /support/tickets/:id/close` performs a validated state transition.
|
||||
- `GET /support/tickets/:id/attachments/:attachmentId` authorizes the ticket and visibility before issuing a short-lived signed S3 URL.
|
||||
|
||||
Ticket states are `OPEN`, `ASSIGNED`, `WAITING_FOR_CUSTOMER`, `WAITING_FOR_SUPPORT`, `RESOLVED`, `CLOSED`, and `CANCELLED`. Customers cannot select priority; new tickets default to `NORMAL`. Subjects are limited to 200 characters, messages to 10,000 characters, and five attachments per message. Attachments reuse Phase 2 uploads and allow JPEG, PNG, WebP, or PDF only.
|
||||
|
||||
Related resources support orders, payments, refunds, returns, shipments, loyalty redemptions, and business settlements. Creation verifies the resource against the authenticated customer or business; a resource identifier alone never grants access.
|
||||
|
||||
## Staff support
|
||||
|
||||
Under `/admin/support/tickets`, staff can list/detail, assign or atomically claim, reply, add internal notes, change priority, resolve, reopen, close, and download attachments. Permissions are granular: `support.tickets.read`, `.assign`, `.reply`, `.status`, `.priority`, `.internal_notes`, and `.escalate`. Category and SLA controls use `support.categories.manage` and `support.sla.manage`.
|
||||
|
||||
State/assignment operations lock the ticket row. Events are append-only. SLA deadlines are snapshotted from the active priority policy at creation using clock time; a bounded five-minute reconciliation creates idempotent first-response or resolution escalations. Business-hour calendars and multi-instance cron validation remain Phase 11 work.
|
||||
|
||||
## Help center
|
||||
|
||||
Public endpoints are `GET /help/categories`, `/help/categories/:slug`, `/help/articles`, `/help/articles/:slug`, and `/help/search?q=`. Only active categories and published articles are returned. `en`, `si`, and `ta` use the Phase 4 locale resolver with English fallback. Search is bounded to 2–100 query characters and at most 50 results.
|
||||
|
||||
Admin endpoints under `/admin/help` list/create categories and articles and explicitly publish/archive articles. They require `help.read`, `help.manage`, or `help.publish`. Article bodies are stored as plain Markdown-like text with HTML tags removed; consumers must render text/Markdown safely and must not treat it as trusted HTML.
|
||||
|
||||
All collection APIs use bounded pagination or bounded results and the standard `{ success, data, pagination? }` envelope.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Wholesale Completion API
|
||||
|
||||
Business endpoints require an authenticated, ACTIVE `business_customer`; organization identity is never accepted from the request.
|
||||
|
||||
## Business account
|
||||
|
||||
- `GET /api/v1/business/dashboard`
|
||||
- `GET /api/v1/business/credit`
|
||||
- `POST /api/v1/business/orders/:id/use-credit` with `Idempotency-Key`
|
||||
- `GET /api/v1/business/settlements`, `GET /api/v1/business/settlements/:id`
|
||||
- `GET /api/v1/business/orders/recent`
|
||||
|
||||
The dashboard uses paid Order snapshots for monthly/lifetime volume and discounts, existing BusinessTier for classification, the immutable credit ledger for utilization, and settlement records for next payment information.
|
||||
|
||||
## Administration
|
||||
|
||||
- `POST /admin/wholesale/credit/transactions` (`wholesale.credit.manage`)
|
||||
- `GET /admin/wholesale/businesses/:businessId/credit` (`wholesale.credit.read`)
|
||||
- Settlement list/generation/issue/mark-paid endpoints with settlement permissions.
|
||||
|
||||
Credit formula: `available = creditLimit - ledger balance`. Captures/authorizations increase utilization; payment, release, and refund entries decrease it. Every operation locks the existing BusinessCreditAccount, validates ACTIVE business/account status and currency, and uses a unique event ID. Credit-backed Order placement atomically captures credit, consumes reservations, records an INTERNAL_CREDIT Payment, marks the Order paid, and reuses invoice issuance.
|
||||
|
||||
Settlements snapshot ledger activity for a unique business/period. Due dates come from the existing SettlementTerm. Numbers use ReferenceNumber, and only explicit lifecycle actions are accepted.
|
||||
|
||||
Payment allocation storage exists as a foundation. External bank matching, general ledger, ERP, tax-authority integration, and document-worker validation remain outside this phase.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Authorization Matrix
|
||||
|
||||
This inventory is derived from the mounted route source. Both `/api` (legacy) and `/api/v1` mount the same router; new clients use `/api/v1`. `SUPER_ADMIN` bypasses permission checks through the central middleware.
|
||||
|
||||
| Route group | Methods/path | Auth | Permission/account constraint | Ownership/rate limit |
|
||||
|---|---|---|---|---|
|
||||
| Auth | `/auth/*` | Mixed | Public login/registration; authenticated session actions | Sensitive limiter on credential flows |
|
||||
| User/profile/address | `/user/*`, `/profile/*`, `/addresses/*`, `/account/overview` | Yes | Self or explicit admin permission | User ID derived from token/self query |
|
||||
| Upload/document | `/upload/*`, `/document/*` | Yes | Owner or media/document permissions | Signed URL after owner/permission check |
|
||||
| Permissions/admin users | `/permissions/*`, `/admin/users/*` | Yes | Permission/admin-gated | No public mutations |
|
||||
| Business | `/business/*`, `/admin/business/*` | Yes | Self business or business permissions | Business context resolved from user |
|
||||
| Catalogue/help | `/products`, `/categories`, `/brands`, `/collections`, `/help/*` | Public GET | Published/active projection | General limiter; help search sensitive limiter |
|
||||
| Catalogue admin | `/admin/products/*`, categories/brands/collections/reviews | Yes | Catalogue permissions | Strict schemas/action endpoints |
|
||||
| Inventory/pricing/merchandising/shipping admin | `/admin/inventory/*`, `/admin/pricing/*`, `/admin/promotions/*`, `/admin/shipping/*` | Yes | Domain permissions | Strict schemas; bounded pages |
|
||||
| Shopping | `/cart/*`, `/wishlist/*`, `/checkout/*` | Yes | Authenticated self | Identity derived from token; idempotency/reservations |
|
||||
| Orders/payments/returns | `/orders/*`, `/returns/*` | Yes | Self ownership | Order/user join checks; strict payment actions |
|
||||
| Commerce admin | `/admin/orders/*`, payments/refunds/returns | Yes | Orders/payments/returns permissions | Explicit actions; no generic status patch |
|
||||
| Logistics public | `/tracking/*` | Public token/reference | Limited safe projection | Sensitive limiter where configured |
|
||||
| Logistics rider/admin | `/rider/*`, `/admin/shipments/*` | Yes | Rider ownership or logistics permissions | Assignment/state checks |
|
||||
| Loyalty/wholesale | `/loyalty/*`, `/wholesale/*`, corresponding `/admin/*` | Yes | Self or loyalty/wholesale permissions | Ledger identity server-derived |
|
||||
| Support customer | `/support/tickets/*` | Yes | Self-owned ticket | Creation/replies rate-limited; internal visibility excluded |
|
||||
| Support admin | `/admin/support/tickets/*` | Yes | Granular support permission per action | Row-lock assignment; staff-only internal notes |
|
||||
| Newsletter | subscribe/unsubscribe | Public | Token authorizes unsubscribe | Sensitive limiter; enumeration-neutral response |
|
||||
| Newsletter admin | `/admin/newsletter/subscribers` | Yes | `newsletter.subscribers.read` | Token hashes excluded |
|
||||
| Recommendations | public reads; authenticated event/recent/for-you | Mixed | Self for behavioral data | Event allowlist/idempotency; sensitive limiter |
|
||||
| Analytics | `/admin/dashboard/overview`, `/admin/analytics/*` | Yes | Matching `analytics.*.read` | UTC range ≤366 days; bounded SQL aggregates |
|
||||
| Metrics | `/admin/metrics` | Yes | `system.metrics.read` | No user/resource labels |
|
||||
| Queue board | `/admin/queues` | Yes | Admin/superadmin account type | Internal operational UI |
|
||||
| Health | `/health`, `/health/live`, `/health/ready` | Public | None | No sensitive payload |
|
||||
|
||||
Detailed route definitions remain authoritative in `app/routes`. Security audit found no newly unprotected admin route. Remaining staging work includes an automated route-to-matrix drift check and full authenticated IDOR E2E execution.
|
||||
+261
-38
@@ -1,14 +1,39 @@
|
||||
# Auth API
|
||||
# Authentication API
|
||||
|
||||
#### Request OTP
|
||||
The Authentication API provides OTP-based login, access-token renewal, password recovery, current-user lookup, and logout.
|
||||
|
||||
**Endpoint**
|
||||
## Base URL
|
||||
|
||||
```
|
||||
POST: http://localhost:3070/api/auth/req-otp
|
||||
```text
|
||||
http://localhost:3070/api/auth
|
||||
```
|
||||
|
||||
**Request Body**
|
||||
Requests and responses use JSON unless otherwise stated.
|
||||
|
||||
## Authentication
|
||||
|
||||
After a successful login, the API returns an access token in the response and sets two HTTP-only cookies:
|
||||
|
||||
- `access_token` — valid for 15 minutes
|
||||
- `refresh_token` — valid for 7 days
|
||||
|
||||
Protected endpoints accept the access token through the `access_token` cookie or this header:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <access-token>
|
||||
```
|
||||
|
||||
When using cookie authentication from a browser, send requests with credentials enabled.
|
||||
|
||||
---
|
||||
|
||||
## Request OTP
|
||||
|
||||
Validates the user's email and password, then sends a one-time password to the registered email address.
|
||||
|
||||
**Endpoint:** `POST` [http://localhost:3070/api/auth/req-otp](http://localhost:3070/api/auth/req-otp)
|
||||
|
||||
### Request body
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -17,7 +42,7 @@ POST: http://localhost:3070/api/auth/req-otp
|
||||
}
|
||||
```
|
||||
|
||||
**Respond**
|
||||
### Success response — `201 Created`
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -26,17 +51,21 @@ POST: http://localhost:3070/api/auth/req-otp
|
||||
}
|
||||
```
|
||||
|
||||
### Error responses
|
||||
|
||||
- `401 Unauthorized` — invalid password
|
||||
- `404 Not Found` — user not found
|
||||
- `500 Internal Server Error` — OTP generation or email delivery failed
|
||||
|
||||
---
|
||||
|
||||
#### Login
|
||||
## Login
|
||||
|
||||
**Endpoint**
|
||||
Verifies the emailed OTP and creates an authenticated session. The user account must be active.
|
||||
|
||||
```
|
||||
POST: http://localhost:3070/api/auth/login
|
||||
```
|
||||
**Endpoint:** `POST` [http://localhost:3070/api/auth/login](http://localhost:3070/api/auth/login)
|
||||
|
||||
**Request Body**
|
||||
### Request body
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -45,7 +74,7 @@ POST: http://localhost:3070/api/auth/login
|
||||
}
|
||||
```
|
||||
|
||||
**Respond**
|
||||
### Success response — `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -54,33 +83,40 @@ POST: http://localhost:3070/api/auth/login
|
||||
"data": {
|
||||
"id": "usr_5ff8afec-5ddc-47d9-a63f-b43df0d8c3b4",
|
||||
"email": "sathira@niolla.lk",
|
||||
"firstName": "Jhon",
|
||||
"lastName": "Doe",
|
||||
"role": "System Developer",
|
||||
"accountType": "admin"
|
||||
"firstName": "Sathira",
|
||||
"lastName": "Sri Sathsara",
|
||||
"role": null,
|
||||
"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.
|
||||
|
||||
```
|
||||
GET: http://localhost:3070/api/auth/me
|
||||
```
|
||||
**Endpoint:** `GET` [http://localhost:3070/api/auth/me](http://localhost:3070/api/auth/me)
|
||||
|
||||
**Authorization**: Required (Bearer token)
|
||||
**Authentication:** Required
|
||||
|
||||
**Request Body**
|
||||
### Request body
|
||||
|
||||
```
|
||||
No Body
|
||||
```
|
||||
No request body.
|
||||
|
||||
**Response (200)**
|
||||
### Success response — `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -90,7 +126,6 @@ No Body
|
||||
"firstName": "Sathira",
|
||||
"lastName": "Sri Sathsara",
|
||||
"email": "sathira@niolla.lk",
|
||||
"role": "System Developer",
|
||||
"accountType": "admin",
|
||||
"iat": 1778865265,
|
||||
"exp": 1778868865,
|
||||
@@ -99,23 +134,202 @@ No Body
|
||||
}
|
||||
```
|
||||
|
||||
### Error responses
|
||||
|
||||
- `401 Unauthorized` — token is missing, invalid, or expired
|
||||
|
||||
---
|
||||
|
||||
### Logout
|
||||
## Refresh Session
|
||||
|
||||
**Endpoint**
|
||||
Uses the HTTP-only refresh-token cookie to rotate the session and issue new access and refresh cookies.
|
||||
|
||||
```
|
||||
POST: http://localhost:3070/api/auth/logout
|
||||
**Endpoint:** `POST` [http://localhost:3070/api/auth/refresh](http://localhost:3070/api/auth/refresh)
|
||||
|
||||
### Request body
|
||||
|
||||
No request body. The `refresh_token` cookie is required.
|
||||
|
||||
### Success response — `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Session refreshed successfully"
|
||||
}
|
||||
```
|
||||
|
||||
**Request Body**
|
||||
### Error response — `401 Unauthorized`
|
||||
|
||||
```
|
||||
No Body
|
||||
Returned when the refresh cookie is missing or the session is invalid, expired, or no longer active.
|
||||
|
||||
---
|
||||
|
||||
## Forgot Password
|
||||
|
||||
Sends a password-reset link when an account exists for the supplied email. The same success response is returned for unknown email addresses to prevent account discovery.
|
||||
|
||||
**Endpoint:** `POST` [http://localhost:3070/api/auth/forgot-password](http://localhost:3070/api/auth/forgot-password)
|
||||
|
||||
**Authentication:** Not required
|
||||
|
||||
### Request body
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "sathira@niolla.lk"
|
||||
}
|
||||
```
|
||||
|
||||
**Respond**
|
||||
### Success response — `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "If an account exists for this email, a password reset link has been sent."
|
||||
}
|
||||
```
|
||||
|
||||
### Error responses
|
||||
|
||||
- `400 Bad Request` — email is missing or invalid
|
||||
- `500 Internal Server Error` — the reset request could not be processed
|
||||
|
||||
---
|
||||
|
||||
## Reset Password
|
||||
|
||||
Sets a new password using the token from the password-reset email. A successful reset invalidates all existing refresh sessions for the user.
|
||||
|
||||
**Endpoint:** `POST` [http://localhost:3070/api/auth/reset-password](http://localhost:3070/api/auth/reset-password)
|
||||
|
||||
**Authentication:** Not required
|
||||
|
||||
### Request body
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "password-reset-token",
|
||||
"newPassword": "NewPassword@1234",
|
||||
"confirmPassword": "NewPassword@1234"
|
||||
}
|
||||
```
|
||||
|
||||
The new password must contain at least one uppercase letter, one lowercase letter, one symbol, and four digits. It must differ from the current password.
|
||||
|
||||
### Success response — `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Password reset successfully. Please login again."
|
||||
}
|
||||
```
|
||||
|
||||
### Error responses
|
||||
|
||||
- `400 Bad Request` — fields are missing, passwords do not match, password rules are not met, the new password matches the current password, or the token is invalid or expired
|
||||
- `500 Internal Server Error` — password reset failed unexpectedly
|
||||
|
||||
---
|
||||
|
||||
## Change Password
|
||||
|
||||
Changes the authenticated user's password. After a successful change, all refresh sessions are revoked, authentication cookies are cleared, and the user must log in again.
|
||||
|
||||
**Endpoint:** `POST` [http://localhost:3070/api/auth/change-password](http://localhost:3070/api/auth/change-password)
|
||||
|
||||
**Authentication:** Required
|
||||
|
||||
The access token may be supplied through the `access_token` cookie or as a Bearer token:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <access-token>
|
||||
```
|
||||
|
||||
### Request body
|
||||
|
||||
```json
|
||||
{
|
||||
"currentPassword": "CurrentPassword@1234",
|
||||
"newPassword": "NewPassword@5678",
|
||||
"confirmPassword": "NewPassword@5678"
|
||||
}
|
||||
```
|
||||
|
||||
The new password:
|
||||
|
||||
- Must match `confirmPassword`
|
||||
- Must differ from the current password
|
||||
- Must contain at least one uppercase letter
|
||||
- Must contain at least one lowercase letter
|
||||
- Must contain at least one symbol
|
||||
- Must contain at least four digits
|
||||
|
||||
### Success response — `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Password changed successfully. Please login again."
|
||||
}
|
||||
```
|
||||
|
||||
### Error responses
|
||||
|
||||
#### `400 Bad Request`
|
||||
|
||||
Returned when required fields are missing, the passwords do not match, the new password does not satisfy the password policy, or it matches the current password.
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "New password and confirm password do not match"
|
||||
}
|
||||
```
|
||||
|
||||
#### `401 Unauthorized`
|
||||
|
||||
Returned when authentication fails or the current password is incorrect.
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "Current password is incorrect"
|
||||
}
|
||||
```
|
||||
|
||||
#### `404 Not Found`
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "User not found"
|
||||
}
|
||||
```
|
||||
|
||||
#### `500 Internal Server Error`
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "Failed to change password"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Logout
|
||||
|
||||
Deletes the current refresh session when available and clears both authentication cookies.
|
||||
|
||||
**Endpoint:** `POST` [http://localhost:3070/api/auth/logout](http://localhost:3070/api/auth/logout)
|
||||
|
||||
### Request body
|
||||
|
||||
No request body.
|
||||
|
||||
### Success response — `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -123,3 +337,12 @@ No Body
|
||||
"message": "Logged out successfully"
|
||||
}
|
||||
```
|
||||
|
||||
### Error response — `500 Internal Server Error`
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "Failed to logout"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Backup and Restore
|
||||
|
||||
Use encrypted, access-controlled backups with retention tiers and regular restore drills. Do not place credentials on command lines; use a protected client option file or secret injection.
|
||||
|
||||
1. Quiesce high-risk writes or capture a transactionally consistent MySQL backup (`mysqldump --single-transaction --routines --triggers`) and record schema migration state.
|
||||
2. Enable S3 versioning, lifecycle rules, encryption and cross-account/region recovery appropriate to policy. Database-only recovery does not restore uploads/documents.
|
||||
3. Treat Redis as ephemeral/reconstructable for queues/cache but security-sensitive for OTP/session challenges. Redis loss may invalidate challenges and lose queued work; MySQL remains business truth.
|
||||
4. Restore MySQL into a temporary isolated database, run consistency queries, compare row counts/totals, verify migrations, and test application reads before promotion.
|
||||
5. Recover in order: MySQL, object storage, Redis, API, workers, single cron scheduler, reverse proxy, then smoke/E2E checks.
|
||||
|
||||
Rollback after live commerce writes is restore/forward-fix based; do not assume destructive migration `down` functions are safe. Define RPO/RTO, backup owners, retention, restore approval and audit evidence before go-live.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Cron Operations
|
||||
|
||||
| Job | Schedule | Authority / boundedness | Idempotency |
|
||||
|---|---|---|---|
|
||||
| Notification cleanup | configured source schedule | DB records; retention bounded | repeated delete safe |
|
||||
| Inventory reservation expiry | frequent | batch-limited DB scan | reservation state/event key |
|
||||
| Checkout expiry | frequent | batch-limited DB scan | checkout/reservation state |
|
||||
| Loyalty reconciliation | scheduled | configured batch | ledger event IDs |
|
||||
| Support SLA + recommendation retention | every 5 minutes | 100 SLA / 1,000 event max | deterministic escalation key / old-event deletion |
|
||||
|
||||
Cron starts only when the deployment enables `RUN_CRON`; use one dedicated scheduler replica. Current jobs use local `noOverlap` where available but do not all have a distributed lock, so multi-instance cron is prohibited until staging validates/implements Redis locking. All times are server/UTC clock time unless a domain snapshot says otherwise.
|
||||
@@ -0,0 +1,467 @@
|
||||
# ZUMRI Current Backend Status
|
||||
|
||||
## Phase 4 Completion Update
|
||||
|
||||
Completion date: 2026-09-03. Module 03 (product catalogue) is approximately 84%; Module 04 (multi-language content) is approximately 82%. Phase 4 adds 18 catalogue models covering brands, hierarchical localized categories, products/translations/multi-category joins, DECIMAL-price variants, configurable options and attributes, owned media, scheduled localized collections, structured size guides, explicit relations, and moderated reviews.
|
||||
|
||||
Public APIs now provide active/public product lists and slug detail, safe search/filter/sort/pagination, locale fallback, categories, brands, current collections, signed media DTOs, price ranges, and approved review summaries. Permission-gated admin APIs cover product lifecycle, variants/options/media/relations, brands, cycle-safe categories, collections, size guides, and review moderation. No inventory quantity, wholesale pricing, promotion, or shopping behavior was introduced.
|
||||
|
||||
The mocked regression suite passes 15 suites/78 tests and syntax validation covers 204 JavaScript files. The Phase 4 migration is forward-only and was not executed. Restored-staging migration checks, permission seeding, real MySQL query/concurrency testing, and signed-media volume validation remain required before Phase 5. See `Documentation/PHASE_4_CATALOGUE_CONTENT.md` and `Documentation/API_CATALOGUE.md`.
|
||||
|
||||
## Phase 3 Completion Update
|
||||
|
||||
Completion date: 2026-09-03. Module 02 (customer profile/address management) is now approximately 88%; Module 13 (business accounts) is approximately 78%. Customer profile/preferences, structured owned addresses with transactional defaults, secure self-deactivation, business applications, permission-gated transactional approval, immutable partner identity, business profiles/contacts/addresses, domain status, DECIMAL credit configuration, and settlement-term primitives are implemented.
|
||||
|
||||
Security impact: all customer resource identity is derived from Phase 1 authentication; mutation schemas are strict; profile media is owner-validated; address/application IDOR is constrained in queries; approval/deactivation revoke sessions after identity-boundary changes; and business financial/review fields are excluded from self-service. Six models were added and two existing models extended. Customer/business and admin APIs are documented in `Documentation/API_CUSTOMER_BUSINESS.md`.
|
||||
|
||||
The complete mocked regression suite passes 12 suites/57 tests, and syntax validation passes 171 JavaScript files. The Phase 3 migration is forward-only and was not executed. Staging must resolve legacy business/profile/address pre-checks, seed/grant permissions, and validate MySQL concurrency plus Redis/S3/SMTP flows before Phase 4. See `Documentation/PHASE_3_CUSTOMER_BUSINESS_FOUNDATIONS.md`.
|
||||
|
||||
## Phase 2 Completion Update
|
||||
|
||||
Completion date: 2026-09-03. Phase 2 hardens the existing shared-service foundation without adding commerce domains. Module 14 (notifications) is now approximately 72%; Module 19 (file/media) 82%; Module 20 (audit/config/logging) 68%; and Module 21 (background jobs) 78%. The document subsystem is approximately 82%.
|
||||
|
||||
Storage now has a reusable S3/S3-compatible boundary, actual-content validation, controlled keys, checksums, owner/status metadata, compensation, and authorization-safe signed URLs. Email is routed through BullMQ with delivery status and final-failure persistence. Notifications have fixed aliases, unique assignment migration, self-only inbox/read operations, announcement support, and preference policy. Queue defaults, idempotent job IDs, Bull Board registration, safe failure handling, stronger append-only activity records, and structured redacted logs are in place. Documents now have ownership, controlled registry validation, persisted generation lifecycle, safe status, and non-destructive signed download.
|
||||
|
||||
The full mocked suite contains 10 suites/40 tests and passes; syntax checks cover 153 JavaScript files. The new migration was not executed. Remaining work is staging migration/data pre-checks plus real MySQL, Redis, S3-compatible, SMTP, PDF/browser, retention-volume, and concurrency validation. After those operational checks and permission seeding, it is safe to begin Phase 3. See `Documentation/PHASE_2_CROSS_CUTTING_SERVICES.md` and `Documentation/API_CROSS_CUTTING_SERVICES.md`.
|
||||
|
||||
Audit date: 2026-09-03
|
||||
Scope: repository source, configuration, lockfile, existing documentation, safe syntax/test/dependency checks. No database, Redis, S3, email, or other external service was mutated.
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
This repository is an early modular-monolith foundation, not yet an e-commerce backend. It contains working-shaped user registration/email verification, OTP login, basic profiles, RBAC tables/controllers, notifications, activity logging, S3 upload plumbing, document generation, Redis/BullMQ workers, and one cron. Important pieces are incomplete or broken in integration. The estimated completion against the 25-module ZUMRI target is **about 12%**.
|
||||
|
||||
No original development-plan day is fully complete. Day 1 is approximately 55%: Express, Sequelize/MySQL, Redis/BullMQ, a Dockerfile, and a health route exist, but Swagger, robust health checks, Node 22 alignment, production bootstrap/error handling, Compose/Nginx, migrations, and tests do not. Day 2 is approximately 38%; Day 3 approximately 18%; notification/job/file/document/audit foundations from later days were built early.
|
||||
|
||||
The code still carries prior-project terminology (`Oceanic Titan`, `oceanic-db`, Oceanic demo URL) and role names that conflict with the actual user ENUM. Future work should preserve usable primitives, but first complete and secure foundation/authentication.
|
||||
|
||||
Status terms: **COMPLETE** = end-to-end implementation is credible from source; **PARTIAL** = meaningful code exists but requirements/integration are incomplete; **STUB** = structure only; **MISSING** = no implementation; **BROKEN** = known source/integration defect.
|
||||
|
||||
## 2. Current Architecture
|
||||
|
||||
- `server.js` loads `.env`, imports `app.js`, and listens on `0.0.0.0:${PORT|3070}`.
|
||||
- `app.js` authenticates Sequelize and calls unrestricted `sequelize.sync()` in an unawaited startup IIFE, then starts cron jobs. The HTTP listener starts independently, so requests can arrive before database readiness, and DB failure does not stop the process.
|
||||
- Middleware order is cookie parser, CORS, JSON parser, Morgan, `/health`, `/api`, static documentation, then Bull Board. There is no URL-encoded parser, request ID, Helmet, compression, rate limiting, global 404 handler, or global error handler.
|
||||
- API base path is `/api` (not versioned). Routes delegate mostly directly to Sequelize-backed controllers; there is only a permission service and activity service.
|
||||
- Redis connections are created at module import. One shared client is used by queues/workers, while password reset and S3 utilities create additional clients.
|
||||
- Workers are a separate `npm run worker` process. The server does not start them. Activity, log, and document consumers exist.
|
||||
- Cron runs in every API process after DB sync; there is no distributed lock, so multi-instance deployments duplicate scheduling.
|
||||
- Sequelize models are registered centrally and their `associate` functions are invoked. No migrations are present.
|
||||
- Environment keys exist locally; `.env` is ignored/untracked. `.env.sample` is tracked. No actual secret values are reproduced here.
|
||||
|
||||
### Bootstrap and operational findings
|
||||
|
||||
| Concern | Status | Evidence / impact |
|
||||
|---|---|---|
|
||||
| Express start and base path | Partial | `server.js`, `app.js`; `/api` and `/health` |
|
||||
| JSON / cookies / CORS / logging | Partial | JSON, cookies, single-origin credentialed CORS, Morgan `dev`; no body-size configuration or URL-encoded parser |
|
||||
| Security middleware | Missing | No Helmet or API rate limiter |
|
||||
| 404/global errors | Missing | Errors depend on individual controllers; unmatched routes use Express defaults |
|
||||
| Database readiness | Broken | Listener is not gated on authenticate/sync; failure is logged only |
|
||||
| Redis readiness | Partial | Event logging exists; health endpoint never checks Redis |
|
||||
| BullMQ/workers | Partial | Separate process required and undocumented operationally; retries only on document jobs |
|
||||
| Cron initialization | Partial | Starts after DB sync, once per server process, with no leader lock |
|
||||
| Health check | Broken | Sends response before asynchronous DB authentication completes and hardcodes mail/Redis as OK |
|
||||
| Graceful shutdown | Missing | No SIGTERM/SIGINT cleanup for server, Sequelize, Redis, workers, or cron |
|
||||
| Process errors | Missing | No `unhandledRejection`/`uncaughtException` policy |
|
||||
|
||||
## 3. Existing Infrastructure
|
||||
|
||||
| Component | Status | Files | Notes |
|
||||
|---|---|---|---|
|
||||
| Express API | Partial | `server.js`, `app.js`, `app/routes/*` | Express 5; no versioning/global errors/security middleware |
|
||||
| MySQL/Sequelize | Partial | `app/config/db.config.js`, `app/models/index.js` | Pool configured; runtime `sync()`, no migrations |
|
||||
| Redis | Partial | `redis.config.js`, `redisClient.js` | Functional client pattern; excessive clients, no shutdown/readiness |
|
||||
| BullMQ | Partial | `app/queues/*`, `app/workers/*` | Three queues and matching consumers; only document jobs have attempts/backoff/retention |
|
||||
| Bull Board | Broken/unsafe | `bullBoard.config.js`, `app.js` | Only activity queue shown; `/admin/queues` has no authentication |
|
||||
| Cron | Partial | `cron/*` | Transactional notification deletion; no distributed lock or retention-age policy |
|
||||
| Email | Partial | `mail.config.js`, `mail.util.js`, templates | SMTP/template sender exists; verifies on import, sends inline, no queue/retry/escaping |
|
||||
| S3 storage | Broken | `s3.config.js`, `s3Upload.utill.js` | S3 client is entirely commented out, so `s3.send` fails |
|
||||
| Uploads | Broken | upload middleware/controller/model | MIME/size checks exist; account guard is incompatible; no magic-byte validation/cleanup/ownership |
|
||||
| Documents | Partial/broken | document controller, logic, templates, queue/worker | PDF/Excel framework is substantive; S3 breaks completion; access is overly broad and role guards mismatch |
|
||||
| Notifications | Partial | notification models/controller/cron | In-app records only; no assignment API/service, email/FCM/preferences/delivery tracking |
|
||||
| Activity/audit | Partial | activity service/model/queue/worker/controller | Async append-only-shaped records; no actor/IP/change metadata, integrity/retention, or broad coverage |
|
||||
| Logging | Partial | console utility/log queue/worker | Local daily files; may receive sensitive payloads; no rotation/structured sink |
|
||||
| API docs | Partial | `Documentation/*`, docs routes | Handwritten Markdown/HTML, not Swagger/OpenAPI; docs auth is forgeable |
|
||||
| Tests | Missing | package script only | Jest finds zero tests; Supertest is not installed |
|
||||
| Docker | Partial | `Dockerfile` | Uses Node 20 instead of target Node 22; API image only; no healthcheck/non-root user |
|
||||
| Nginx/Compose/CI/CD | Missing | none | No reverse proxy, service orchestration, or pipeline files |
|
||||
| Payments/FCM/OpenAI | Missing | none | No dependencies, config, models, or consumers |
|
||||
|
||||
Queue behavior: `document-generation` has 3 exponential attempts (2s base), completed retention, and retained failures. `activity-queue` and `logQueue` have consumers but no explicit retry/backoff/retention/dead-letter policy and are not idempotent. Re-delivery can duplicate activity rows or log lines. Queue event listeners attached to `Queue` are not a reliable substitute for `QueueEvents` for all lifecycle events. Worker failures are logged only. Document status is not reconciled on job failure/success.
|
||||
|
||||
## 4. Existing Database Models
|
||||
|
||||
All models use timestamps and none uses `paranoid`. Aside from the unique flags noted below, explicit indexes are absent.
|
||||
|
||||
| Model / table | Key and important fields | Purpose | Important relations | Status |
|
||||
|---|---|---|---|---|
|
||||
| User / `users` | PK string `id`; names, unique email, password, accountType ENUM, accountStatus ENUM, emailVerifiedAt | Identity/account | hasOne Profile; hasMany UserPermission | Partial; no role FK/`roleID` despite service/controller use, no sessions/tokenVersion/social IDs |
|
||||
| Profile / `profiles` | PK `profile_id`; unique `user_id`; theme, notificationsEnabled, image IDs, DOB, phone | Basic preferences/profile | belongsTo User | Partial; no gender/language/address; image IDs lack FKs |
|
||||
| UserActivity / `UserActivity` | integer PK; user/name/description/type/module/date/time | Activity trail | None | Partial; user FK absent, redundant time fields, no indexes |
|
||||
| Upload / `uploads` | integer PK; S3 path/type/size/name/use/uploaded_by | Media metadata | None | Partial; uploader/user and ownership FKs absent |
|
||||
| Permission / `permission` | PK `permission_id`; name/page/module/action | Permission definition | hasMany role/user grants | Partial |
|
||||
| Role / `roles` | PK `role_id`; name/description | Named role | hasMany role grants | Partial; User has no role association |
|
||||
| RolePermission / `rolePermission` | PK `rp_id`; role_id, permission_id | Role grant | belongsTo Role/Permission | Partial; no composite unique constraint |
|
||||
| UserPermission / `userPermission` | integer PK; user_id, permission_id | Direct additive grant | belongsTo User/Permission | Partial; no composite unique; cannot deny/expire grants |
|
||||
| Document / `Document` | PK `doc_id`; reference_no, doc_type, JSON data, status | Saved business documents | None | Partial; status/type unconstrained, no creator/owner/FKs/indexes |
|
||||
| DocumentType / `DocumentType` | integer PK; nullable name, JSON description | Document registry metadata | None | Partial; name is neither required nor unique |
|
||||
| ReferenceNumber / `referenceNumbers` | PK string; unique sequence_key, integer counter, last value | Atomic sequence generation | None | Reusable; transaction/row locking implemented by utility |
|
||||
| Notification / `notification` | PK string; headline/body, USER/ANNOUNCEMENT ENUM, active/date | In-app notification content | hasMany UserNotification as `users` | Partial |
|
||||
| UserNotification / `user_notification` | integer PK; user_id, notification_id, isRead | Per-user notification state | belongsTo User; belongsTo Notification | Partial; constraints disabled, no composite unique/index; include alias is inconsistent (controller asks `notification`, association defines no alias) |
|
||||
|
||||
Association registration does execute. However, User's `roleID` is not a declared attribute or FK, notifications deliberately disable FK constraints, activities/uploads/documents have no user association, and join-table uniqueness is not enforced. `sequelize.sync()` masks the absence of schema migrations and makes production schema evolution unsafe.
|
||||
|
||||
## 5. Existing API Endpoints
|
||||
|
||||
All paths below are derived from actual mounting. “Self-or-admin” is not implemented anywhere; parameterized user endpoints generally trust the requested ID after coarse account-type checks.
|
||||
|
||||
| Method | Endpoint | Auth | Permission / guard | Status | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| GET | `/health` | No | None | Broken | Returns before DB check; Redis/mail are hardcoded OK |
|
||||
| GET | `/api/auth/me` | Yes | None | Partial | Returns token payload plus effective permissions |
|
||||
| POST | `/api/auth/req-otp` | No | None | Partial/unsafe | Password + in-memory OTP; logs OTP; enumeration; no attempts/rate limit/status check |
|
||||
| POST | `/api/auth/login` | No | None | Partial/unsafe | OTP to one-day access cookie; no refresh/session/rotation/status check |
|
||||
| POST | `/api/auth/logout` | No | None | Partial | Clears cookie only; bearer tokens remain valid |
|
||||
| POST | `/api/user` | No | None | Partial/bug | Registration + profile + verification; activity uses undefined `req.user` after commit |
|
||||
| POST | `/api/user/verify-email` | No | None | Partial | Redis one-use hashed token; no resend endpoint |
|
||||
| GET | `/api/user` | Yes | accountType `admin` | Partial | Paginated list |
|
||||
| GET | `/api/user/:id` | Yes | listed legacy types | Broken/IDOR | Most listed types cannot exist; no ownership enforcement |
|
||||
| PATCH | `/api/user/:id` | Yes | listed legacy types | Broken/privilege escalation | Mass updates accountType/undeclared role fields; no ownership/field validation; early returns leak transaction |
|
||||
| DELETE | `/api/user/:id` | Yes | `admin` | Partial | Hard delete; related profile handling depends on DB state; early return leaks transaction |
|
||||
| GET | `/api/activity` | Yes | `admin` | Partial | Full audit list, no pagination |
|
||||
| GET | `/api/activity/user/:userId` | Yes | `admin` | Partial | No paging/index |
|
||||
| POST | `/api/upload` | Yes | `admin` or nonexistent `staff` | Broken | S3 client undefined; DB requires `use_for`; upload not transactional |
|
||||
| GET | `/api/upload/signed-url/:id` | Yes | `admin` or nonexistent `staff` | Broken | S3 undefined; catch sends no response; no ownership check |
|
||||
| GET | `/api/document/types` | Yes | admin or nonexistent legacy types | Partial | Effectively admin-only under current ENUM |
|
||||
| POST | `/api/document/saved` | Yes | same | Partial | Body filter on a read operation; no owner/pagination; Sequelize `exclude` placed incorrectly |
|
||||
| POST | `/api/document/generate` | Yes | same | Broken | Queues correctly, but worker S3 upload fails; validation shallow |
|
||||
| POST | `/api/document/draft` | Yes | same | Partial | Saves arbitrary JSON; no ownership/validation |
|
||||
| GET | `/api/document/reference-number/:documentType` | Yes | same | Partial | Consumes sequence on GET |
|
||||
| GET | `/api/document/job/:jobId/status` | Yes | same | Partial/IDOR | Exposes stacktrace and any job by ID |
|
||||
| GET | `/api/document/download/:uuid` | Yes | same | Broken/unsafe | S3 undefined; deletes shared object after response; no ownership |
|
||||
| DELETE | `/api/document/job/:jobId` | Yes | same | Partial/IDOR | Any allowed user can remove any removable job |
|
||||
| GET | `/api/document/:docId` | Yes | same | Partial/IDOR | Arbitrary document access |
|
||||
| POST | `/api/docs/login` | No | Static credentials | Unsafe | Sets unsigned boolean cookie; no expiry/rate limiting |
|
||||
| POST | `/api/docs/logout` | No | None | Partial | Clears cookie |
|
||||
| GET | `/api/docs/markdown-files` | docs cookie | `docsAuth === "true"` | Unsafe | Cookie can be forged by client |
|
||||
| GET | `/api/docs/view/:file` | docs cookie | same | Partial | Extension/path checks; auth is weak |
|
||||
| POST | `/api/profile/req-reset-password` | No | None | Partial/unsafe logging | Enumeration-resistant response; raw reset token is logged |
|
||||
| POST | `/api/profile/reset-password` | No | None | Partial | Hashed one-use Redis token; new password is not policy-validated |
|
||||
| POST | `/api/profile/change-password` | Yes | legacy account types | Broken/partial | Effectively admin-only; no new-password validation/session revocation |
|
||||
| GET | `/api/profile/avatar/:userId` | Yes | legacy account types | Broken | S3 undefined, missing upload null check, wrong `profile.userId` property, IDOR |
|
||||
| GET | `/api/profile/background/:userId` | Yes | legacy account types | Broken | Same issues |
|
||||
| POST | `/api/notification` | Yes | admin or nonexistent `management` | Partial | Creates content only; no user assignment |
|
||||
| GET | `/api/notification/announcements` | Yes | legacy account types | Partial | Effectively admin-only |
|
||||
| GET | `/api/notification/user/:userId` | Yes | legacy account types | Broken/IDOR | Include alias mismatch likely throws; no ownership |
|
||||
| PATCH | `/api/notification/user/:userId/notification/:notificationId/read` | Yes | legacy account types | Broken/IDOR | No ownership; effectively admin-only |
|
||||
| ALL | `/admin/queues/*` | No | None | Unsafe | Bull Board exposed; only activity queue registered |
|
||||
| GET | `/Documentation/*` | No | Blocks `.md` only | Partial | Static HTML documentation is public |
|
||||
|
||||
### Defined but unreachable permission routes
|
||||
|
||||
`app/routes/permission.routes.js` defines 18 endpoints under the intended permission router (permission CRUD/bulk; role CRUD and grants; user grants CRUD/bulk), but `app/routes/index.js` imports `permissionRoutes` and never calls `router.use(...)`. Therefore none has an actual URL and all are **BROKEN/unreachable**. If mounted as `/permission`, their paths would be `/`, `/bulk`, `/:permissionId`, `/roles`, `/roles/:roleId`, `/roles/:roleId/permissions`, `/roles/permissions/bulk`, `/users/:userId/permissions`, `/users/permissions`, `/users/permissions/bulk`, and `/users/permissions/:upId` with the methods declared in that file. Read endpoints require authentication; mutations require accountType exactly `admin`. Controllers are substantial CRUD logic, but integration, uniqueness, cache invalidation, validation, and transactions are inconsistent.
|
||||
|
||||
## 6. Development Plan Status
|
||||
|
||||
| Module | Completion | Status | Evidence | Remaining Work |
|
||||
|---|---:|---|---|---|
|
||||
| 01 Authentication & Authorization | 38% | Partial | Password hashing, register/verify, OTP access JWT, middleware, RBAC structures | Refresh/session lifecycle, OAuth/Apple, durable OTP/limits, status enforcement, role linkage, mount/fix RBAC |
|
||||
| 02 Customer Profile & Address Management | 22% | Foundation | User/Profile with phone, DOB, images/preferences | Self-service ownership, addresses/default, gender/language, deactivation, robust image flow |
|
||||
| 03 Product Catalogue Management | 0% | Missing | No models/routes | Full catalogue/category/variant/review/SEO model and APIs |
|
||||
| 04 Multi-Language Content | 0% | Missing | No content translation system | Locale strategy and translated content |
|
||||
| 05 Inventory Management | 0% | Missing | No inventory entities | Stock, reservations, warehouses, adjustments |
|
||||
| 06 Cart & Wishlist | 0% | Missing | None | Entire module |
|
||||
| 07 Promotions, Coupons & Banners | 0% | Missing | None | Entire module |
|
||||
| 08 Checkout | 0% | Missing | None | Pricing/shipping/tax/transaction orchestration |
|
||||
| 09 Order Management | 0% | Missing | Document names are not commerce orders | Full order state machine and returns |
|
||||
| 10 Payment Management | 0% | Missing | No provider code/dependencies | PayHere/Stripe, webhooks, idempotency, refunds |
|
||||
| 11 Delivery & Rider Management | 2% | Foundation only | `rider` account ENUM; document templates named delivery/dispatch | Rider profiles, assignments, tracking, zones/rates |
|
||||
| 12 Loyalty, Reward Points & Membership | 0% | Missing | None | Ledger, tiers, generic earn rules, rewards/vouchers |
|
||||
| 13 Wholesale / Business Accounts | 2% | Foundation only | `business_customer` ENUM only | Approval/profile/pricing/MOQ/tiers/credit/settlements/invoices/analytics |
|
||||
| 14 Notification System | 28% | Partial | Notification/user join models, CRUD/read endpoints, cleanup cron, mail utility | Fix aliases/ownership; assignment and preferences; FCM/email jobs/templates/status |
|
||||
| 15 Support Ticket System | 0% | Missing | None | Tickets, messages, SLA/escalation/help center |
|
||||
| 16 AI Customer Support Chatbot | 0% | Missing | No OpenAI integration | Conversations, tools, safety, human escalation |
|
||||
| 17 Product Recommendations | 0% | Missing | None | Events and recommendation service |
|
||||
| 18 Admin Dashboard & Analytics | 1% | Foundation | Admin user type and raw activity endpoint | Metrics, aggregation, secured dashboard APIs |
|
||||
| 19 File & Media Management | 25% | Broken foundation | Multer, Upload model, S3/presigned utilities | Restore/configure client, ownership, object validation/lifecycle, media transformations |
|
||||
| 20 Audit Logging & System Configuration | 22% | Partial | Async activity records/log queue | Full audit schema/coverage, immutability, config models, secure structured logging |
|
||||
| 21 Background Jobs & Queues | 35% | Partial | Redis, 3 queues/consumers, worker entry, document retry | Idempotency, policies for every queue, monitoring auth, graceful shutdown, scheduler topology |
|
||||
| 22 Security | 15% | Weak foundation | bcrypt, JWT verification, HttpOnly cookie, basic MIME limits | Findings in §10; headers, throttles, validation, authorization, secrets/token discipline |
|
||||
| 23 Testing & Quality Assurance | 2% | Missing | Jest dependency/script only | Tests, Supertest, fixtures, lint/type/static checks, CI |
|
||||
| 24 API Documentation | 18% | Partial | Handwritten endpoint Markdown/HTML | OpenAPI/Swagger, synchronization, schemas/security/error contracts |
|
||||
| 25 Deployment & Infrastructure | 18% | Partial | Dockerfile | Node 22, Compose, Nginx, healthcheck, non-root, migrations, CI/CD, observability |
|
||||
|
||||
## 7. Original 15-Day Plan Status
|
||||
|
||||
| Day | Intended Scope | Completion | Notes |
|
||||
|---|---|---:|---|
|
||||
| 1 | Foundation | 55% | Express/Sequelize/MySQL/Redis/BullMQ/Docker present; health broken; Swagger/Compose/migrations/production lifecycle missing |
|
||||
| 2 | Authentication | 38% | Basic verified registration and OTP access JWT; core session/OAuth/security requirements incomplete |
|
||||
| 3 | Customer + business accounts | 18% | Basic user/profile and enum only; no addresses/business domain |
|
||||
| 4 | Products/categories/variants | 0% | Missing |
|
||||
| 5 | Inventory/admin products | 0% | Missing |
|
||||
| 6 | Cart/wishlist | 0% | Missing |
|
||||
| 7 | Promotions/checkout | 0% | Missing |
|
||||
| 8 | Orders | 0% | Missing |
|
||||
| 9 | Payments | 0% | Missing |
|
||||
| 10 | Delivery/rider | 2% | Rider enum and unrelated document templates only |
|
||||
| 11 | Loyalty | 0% | Missing |
|
||||
| 12 | Notifications/support | 16% | Partial in-app notification foundation; support absent |
|
||||
| 13 | AI/recommendations | 0% | Missing |
|
||||
| 14 | Analytics/security | 8% | Raw activities and scattered security controls only |
|
||||
| 15 | QA/production | 5% | Dockerfile only; no tests/CI/Nginx/production hardening |
|
||||
|
||||
**Last genuinely complete day: none.** Development reached partway through Day 1 and Day 2, with selected infrastructure from Days 12, 14, and 15 implemented early.
|
||||
|
||||
## 8. Demo Website Requirement Gaps
|
||||
|
||||
| Feature | Present on Demo | Present Backend | Original Plan | Action Needed |
|
||||
|---|---|---|---|---|
|
||||
| Email/password login | Yes | Partial (password then email OTP) | Auth | Clarify desired login flow; secure OTP/session lifecycle |
|
||||
| Remember me | Yes | No | Auth | Add session-specific lifetime safely |
|
||||
| Forgot/reset password | Yes | Partial | Auth | Stop token logging, validate password, revoke sessions |
|
||||
| Google sign-in | Yes | No | Auth | Add provider verification/linking |
|
||||
| Apple sign-in | Yes | No | Potential added requirement | Add to auth scope explicitly |
|
||||
| Account overview/recent orders/counts | Yes | No | Customer/orders | Aggregate dashboard endpoint after domain models |
|
||||
| Points/vouchers/membership/progress | Yes | No | Loyalty | Ledger, tier/rule/reward architecture |
|
||||
| Default address/profile/addresses | Yes | Profile partial; addresses absent | Customer | Ownership-safe profile and address CRUD/default constraint |
|
||||
| Wishlist/order history/sign out | Yes | Sign-out cookie only; rest absent | Cart/orders/auth | Implement modules and true session revocation |
|
||||
| Silver/Gold/Platinum tiers/benefits | Yes | No | Loyalty | Configurable tiers; do not hardcode demo examples |
|
||||
| Point transaction history | Yes | No | Loyalty | Immutable ledger |
|
||||
| Purchases/reviews/referrals/birthdays earning | Yes | No | Loyalty/reviews | Generic earn-source rules with idempotency |
|
||||
| Reward redemption/vouchers/shipping rewards | Yes | No | Loyalty/promotions | Reward definitions, redemption transaction, vouchers |
|
||||
| Business approval/Partner ID/tier | Yes | Account enum only | Wholesale | Business profile and approval workflow |
|
||||
| Monthly/history/discount/top buyers/orders | Yes | No | Wholesale/analytics | Wholesale aggregates and reporting |
|
||||
| Credit limit/available/utilization | Yes | No | Wholesale | Credit account/ledger and authorization rules |
|
||||
| Settlement terms/dates/invoices | Yes | No | Wholesale/payment | Terms, statements, invoice/payment lifecycle |
|
||||
| Wholesale catalogue/MOQ/pricing tiers/bulk | Yes | No | Wholesale/catalogue | Customer-segment pricing and volume tiers |
|
||||
| Featured inventory/reorder | Yes | No | Inventory/wholesale | Stock and reorder workflows |
|
||||
| Shipping thresholds/methods/zones/fees | Yes | No | Checkout/delivery | Configurable rate engine |
|
||||
| International shipping/duties display | Yes | No | Delivery/checkout | Destination rules and duty estimate representation |
|
||||
| Order tracking | Yes | No | Orders/delivery | Shipment event timeline |
|
||||
| 30-day return/eligibility/reason/status | Yes | No | Orders (gap detail) | Configurable returns/RMA state machine |
|
||||
| Pickup/refund/exchange | Yes | No | Delivery/payment/orders | Integrate RMA, courier, payment refund, exchange order |
|
||||
| Help center/FAQs/sizing/payment help | Yes | No | Support/content | CMS/help content APIs |
|
||||
| Email support/tickets | Yes | No | Support | Ticket/conversation/SLA module |
|
||||
| AI Style Assistant/human escalation | Yes | No | AI/support | AI conversation with ticket/advisor handoff |
|
||||
| Newsletter consent lifecycle | Yes | No | Demo gap | Dedicated subscriber consent/status/source/language model; not profile notification preference |
|
||||
| New/featured/sale products | Yes | No | Catalogue/promotions | Merchandising fields/rules |
|
||||
| Category/editorial collections | Yes | No | Catalogue/content | Collections and ordered merchandising |
|
||||
| Related products | Yes | No | Recommendations/catalogue | Explicit and computed relations |
|
||||
| Reviews/verified purchase reviews | Yes | No | Catalogue/recommendations | Review moderation and verified-order link |
|
||||
| Size guides | Yes | No | Catalogue/content | Structured, category/product-linked guides |
|
||||
| Product SEO | Yes | No | Catalogue | Slugs/meta/canonical data |
|
||||
| Banners | Yes | No | Promotions | Placement, locale, schedule, targeting |
|
||||
| Multiple languages | Yes | No | Multi-language | Localized product/content model |
|
||||
|
||||
Current order/delivery architecture cannot support shipping or returns: no commerce Order, OrderItem, Shipment, Address, Return, Refund, or state-transition entities exist. The similarly named generated documents are generic JSON documents and should not be treated as domain substitutes.
|
||||
|
||||
## 9. Technical Debt
|
||||
|
||||
### CRITICAL
|
||||
|
||||
- Restore a valid S3 client before any upload/document endpoint can work.
|
||||
- Align authorization vocabulary and enforce ownership; current legacy guards, IDORs, and accountType mutation enable denial of access or privilege escalation.
|
||||
- Remove raw OTP/reset-token and decoded-auth logging; rotate any credentials if operational logs captured them.
|
||||
- Replace production `sequelize.sync()` with migrations and gate server readiness on required services.
|
||||
- Implement a real access/refresh session model with rotation, revocation, account-status enforcement, and secure logout.
|
||||
|
||||
### HIGH
|
||||
|
||||
- Mount and repair permission routes; add User-role linkage and grant uniqueness/cache invalidation.
|
||||
- Add validation schemas and centralized error handling; prevent arbitrary field updates.
|
||||
- Protect Bull Board and documentation sessions.
|
||||
- Fix notification association alias and user ownership checks.
|
||||
- Add rate limits for login, OTP, verification, reset, docs login, and general API traffic.
|
||||
- Add tests for auth/authorization, transactions, uploads/jobs, and failure paths.
|
||||
- Fix transaction leaks on early returns in user update/delete.
|
||||
|
||||
### MEDIUM
|
||||
|
||||
- Standardize response/error formats and status codes; stop returning internal error messages/stack traces.
|
||||
- Consolidate Redis connections and add lifecycle/readiness handling.
|
||||
- Make jobs idempotent and add retry/backoff/retention/dead-letter/alert policies.
|
||||
- Move email to a job and add retry/delivery tracking and safe template escaping.
|
||||
- Add pagination/indexes to activity, notification, document, and user queries.
|
||||
- Remove legacy Oceanic naming and reconcile API versioning.
|
||||
|
||||
### LOW
|
||||
|
||||
- Correct mojibake in source/log messages, inconsistent singular/plural table names, and `*.utill.js` spelling.
|
||||
- Remove unused imports/constants and dead/commented code.
|
||||
- Split giant permission controller and move business logic into services after behavior is tested.
|
||||
|
||||
## 10. Security Findings
|
||||
|
||||
- No tracked `.env` was detected. `.env.sample` is tracked. Potential secret-bearing configuration exists in local `.env`; values were not inspected/reproduced. If this file has ever been shared or committed elsewhere, rotate credentials.
|
||||
- JWT falls back to a public placeholder secret if configuration is missing. There is no issuer/audience/session ID/tokenVersion or refresh-token revocation.
|
||||
- Login OTP uses `Math.random`, process memory, and no attempt/rate limit; it fails across instances/restarts and is logged in plaintext.
|
||||
- Password reset token is logged in plaintext. Reset/change paths do not validate the new password policy or revoke existing access tokens.
|
||||
- Login does not reject pending, suspended, or deactivated accounts.
|
||||
- User update allows coarse-authorized callers to target arbitrary IDs and set `accountType`, a privilege-escalation and IDOR risk. Many profile/notification/document endpoints have the same ownership defect.
|
||||
- Bull Board is public. Docs protection is an unsigned client cookie equal to `true`; credentials are brute-forceable without rate limiting.
|
||||
- CORS is a single credentialed origin and cookie flags are reasonable for cross-site production, but CSRF protection/origin validation is absent for cookie-authenticated mutations.
|
||||
- Multer limits size and declared MIME, but image wildcard acceptance lacks content sniffing, image decompression safeguards, antivirus scanning, extension normalization policy, and object lifecycle cleanup.
|
||||
- S3 `PutObject` sets no ACL (good default if bucket blocks public access), but actual bucket policy/encryption cannot be verified. Signed URLs are cached; authorization is checked only before URL issuance and ownership is not checked.
|
||||
- Controllers expose `error.message`; job status exposes stack traces. Production Sequelize logging is inverted to `true`, which can leak query data.
|
||||
- Request validation is handwritten and sparse; mass assignment exists in user update. Sequelize query values are generally parameterized, so no direct raw-SQL injection was found.
|
||||
- Foreign-key constraints are disabled for notification joins; missing uniqueness and transactions create races/duplicates in grants and notifications.
|
||||
- No Helmet, API rate limiting, CSRF strategy, audit integrity, session revocation, or centralized security error policy exists.
|
||||
|
||||
## 11. Broken / Suspicious Implementations
|
||||
|
||||
- `app/config/s3.config.js`: the entire client/export is commented; every S3 caller receives `{}`.
|
||||
- `app/routes/index.js`: imports `permissionRoutes` but never mounts it.
|
||||
- `app/utils/documentJob.util.js`: requires nonexistent `../queues/pdf.queue`; currently appears orphaned.
|
||||
- `app/services/permission.service.js#getEffectivePermissions`: queries `user.roleID`, which is not a User model attribute; role grants cannot reliably apply.
|
||||
- `app/routes/{user,profile,document,notification}.routes.js`: guards use `management`, `team_head`, `user`, or `staff`, none of which is in the User ENUM. Valid `manager`, `customer`, `business_customer`, `rider`, `support_agent`, and `superadmin` are largely excluded.
|
||||
- `user.controller#createNewUser`: calls `logActivity({user: req.user})` on a public route, causing a post-commit exception after the account has been created; client may receive 500 and retry.
|
||||
- `user.controller#updateUser`: accepts accountType and undeclared role/department fields; does not update email despite destructuring it; opens transaction before lookups and does not roll back early 404 responses.
|
||||
- `user.controller#deleteUser`: early 404 does not roll back; hard delete may conflict with Profile because cascade is unspecified.
|
||||
- `profile.controller#getProfileAvatar/getProfileBackgroundImage`: assumes upload exists, uses wrong `profile.userId` response property, lacks ownership, and reaches broken S3.
|
||||
- `upload.controller#getFileUrl`: empty catch block can leave requests hanging.
|
||||
- `notification.controller#getUserNotifications`: requests association alias `notification`, but `belongsTo` defines no alias; likely Sequelize eager-loading error.
|
||||
- `notification.controller`: USER notifications are created but never assigned to users through an endpoint/service.
|
||||
- `app.js#/health`: asynchronous DB result races with response; reports `N/A`/hardcoded OK rather than real readiness.
|
||||
- `app.js` bootstrap: server listens before DB boot finishes; DB errors are swallowed; each instance starts cron.
|
||||
- `app/middleware/permission.middleware.js`: unused `hasPermission`; admin bypass only recognizes `admin`, not `superadmin`; wildcard logic differs between helper and actual check.
|
||||
- `docsSession.middleware.js`: trusts an unsigned, client-set boolean cookie.
|
||||
- `document.controller#getSavedDocuments`: `exclude` is not nested under `attributes`, so JSON data may still be fetched; filter is in POST body despite message saying query parameter.
|
||||
- `document.controller#downloadDocument`: destructive read deletes the object after delivery and lacks job/user ownership.
|
||||
- `app/logic/documents/registry.js`: catches module load failure but still registers undefined functions, deferring failure to runtime.
|
||||
- `consoleLog.utill.js` and auth/reset utilities: log pipelines may persist secrets and full error objects.
|
||||
- Production DB logging is enabled while non-production logging is disabled, likely inverted.
|
||||
|
||||
Orphaned or unused-looking code includes `documentJob.util.js`, `notification.utill.js` (empty), `logic/documents/engine/pdf.engine.js` (generation uses `pdfGenerator.js`), several Excel/id/vendor/calendar/basis utilities not referenced by mounted features, `Assets` in upload controller (unregistered), imported `PERMISSIONS`/`checkPermission` in routes where checks are absent/commented, and unused dependencies likely including `pdfmake` and `nodeman`. `nodemon` is incorrectly a production dependency. Static analysis cannot prove every dynamic/template use; confirm before removal.
|
||||
|
||||
## 12. Reusable Existing Components
|
||||
|
||||
- Sequelize model registry/association convention can be extended after migrations replace runtime sync.
|
||||
- User/Profile registration transaction, bcrypt utility, hashed one-use email verification token, and hashed password-reset-token concepts are sound foundations once error/logging/session issues are fixed.
|
||||
- Cookie-or-Bearer authentication middleware structure is reusable after it loads current user/session/status and applies token claims.
|
||||
- Permission, role, role-grant, and user-grant models/controllers/service are worth repairing rather than rebuilding; add role linkage, constraints, mounting, validation, and cache invalidation.
|
||||
- Shared Redis factory/client and permission/user cache helpers can be consolidated and retained.
|
||||
- Activity queue/service/worker is a useful async audit foundation; extend its schema and coverage.
|
||||
- BullMQ worker entry pattern and document queue retry/backoff settings are reusable; standardize them across queues.
|
||||
- Upload metadata model, Multer memory-storage limits, S3 key generation, and presigned URL caching are reusable after the S3 client and authorization/content validation are fixed.
|
||||
- Document registry, PDF/Excel generators, templates, queue/worker, Document/DocumentType models, and atomic reference-number generator are substantive reusable subsystems. They are auxiliary business-document infrastructure, not order/payment replacements.
|
||||
- Notification/UserNotification models, read-state concept, announcement query, and transactional cleanup cron can be repaired and extended for channel delivery.
|
||||
- Email transport/template system and existing verification/reset/welcome templates can be moved behind an email queue.
|
||||
|
||||
## 13. Recommended Next Development Phase
|
||||
|
||||
Continue with a **Foundation, security, and authentication completion phase** before starting catalogue work.
|
||||
|
||||
First make startup deterministic and production-safe: Node 22 alignment, validated environment configuration, migrations, real readiness/liveness checks, global 404/errors, Helmet/rate limits, protected operations dashboards, graceful shutdown, and a test harness. Then complete identity: Redis-backed cryptographic OTP with limits, account-status checks, access/refresh sessions with rotation/revocation, secure logout/reset, validation, ownership rules, and a repaired/mounted role-permission system. Repair S3 only as part of restoring already-promised profile/media/document behavior.
|
||||
|
||||
After that baseline passes integration tests, finish customer/business profile and address primitives, reusing User/Profile, auth middleware, Redis, permission service, upload system, activity logger, email templates, queues, and reference-number utility. Only then begin catalogue/product models.
|
||||
|
||||
## 14. Recommended Updated Roadmap
|
||||
|
||||
1. **Phase 0 — Stabilize foundation:** Node 22, env validation, migrations, startup/readiness, global errors/security middleware, graceful shutdown, protected Bull Board, baseline Jest/Supertest and CI.
|
||||
2. **Phase 1 — Complete identity and authorization:** OTP/session/refresh rotation, status checks, logout/revocation, password recovery hardening, OAuth Google/Apple, ownership rules, repaired roles/permissions.
|
||||
3. **Phase 2 — Repair existing cross-cutting services:** S3/media, email jobs, notification aliases/assignment, queue reliability/idempotency, audit schema, document ownership and job lifecycle.
|
||||
4. **Phase 3 — Customer and business foundations:** Profile completion, addresses/defaults, preferences/deactivation, business approval/partner identity/credit and settlement primitives.
|
||||
5. **Phase 4 — Catalogue/content:** Categories, products, variants, media, localized content, SEO, size guides, collections, reviews.
|
||||
6. **Phase 5 — Inventory and merchandising:** Warehouses/stock/reservations, wholesale pricing/MOQ/volume tiers, banners/promotions/coupons.
|
||||
7. **Phase 6 — Shopping and checkout:** Cart, wishlist, pricing, shipping zones/rates/duties, checkout transactions.
|
||||
8. **Phase 7 — Orders/payments:** Order state machine, PayHere/Stripe webhook idempotency, invoices, refunds, returns/exchanges.
|
||||
9. **Phase 8 — Delivery/rider:** Assignments, tracking events, pickup/return logistics.
|
||||
10. **Phase 9 — Loyalty and wholesale completion:** Generic earn ledger, tiers, rewards/vouchers/referrals; credit utilization/settlements/analytics.
|
||||
11. **Phase 10 — Support/AI/recommendations:** Help content, tickets/SLA, advisor escalation, AI assistant, recommendation events/services.
|
||||
12. **Phase 11 — Analytics and production QA:** Dashboards, security testing, load/integration/e2e tests, observability, Nginx/Compose/deployment/runbooks.
|
||||
|
||||
## 15. Do Not Rebuild List
|
||||
|
||||
Preserve and extend these concepts/files after adding tests: centralized Sequelize registration; User/Profile base tables; bcrypt helper; Redis connection/cache helpers; email verification/password-reset token hashing; auth middleware extraction of cookie/Bearer tokens; RBAC model/controller foundation; activity queue/service/worker; document registry/generators/templates/queue/worker; reference-number sequencing; Upload metadata/Multer limits/S3 utility interface; Notification/UserNotification read-state model; cleanup cron transaction; mail templates/transport interface; Docker Chromium setup for Puppeteer.
|
||||
|
||||
## Verification and Dependency Audit
|
||||
|
||||
- `node --check` passed for every repository JavaScript file.
|
||||
- `npm test -- --runInBand` failed because Jest found **0 tests**.
|
||||
- `npm ls --depth=0` completed without reporting missing installed top-level packages.
|
||||
- `npm audit --omit=dev` reported **28 production dependency vulnerabilities**: 19 high, 8 moderate, 1 low, 0 critical. Dependency upgrades were intentionally not performed.
|
||||
- Broken local import scan found active-looking `documentJob.util.js -> ../queues/pdf.queue`; the commented future `email.worker` reference is not an active defect.
|
||||
- Targeted dependency observations: Supertest, Swagger/OpenAPI tooling, Helmet, rate limiting, FCM, payment SDKs, and OpenAI SDK are missing. `nodemon` should be dev-only; `nodeman` and `pdfmake` appear unused. Confirm with runtime coverage before removal.
|
||||
|
||||
## Phase 0 Completion Update
|
||||
|
||||
**Date:** 2026-09-03
|
||||
**Revised Day 1 completion:** approximately **92%**.
|
||||
|
||||
Phase 0 stabilized the existing foundation without adding commerce modules. Node/Docker now target Node 22; Zod validates required startup configuration and feature-gated mail/S3 configuration; unsafe JWT secret fallbacks are removed. Express construction is independent from listening, and `server.js` waits for successful MySQL authentication and Redis connectivity before accepting traffic. Runtime `sequelize.sync()` was removed and Sequelize CLI plus a non-destructive current-model baseline migration were added.
|
||||
|
||||
New `/health/live` and `/health/ready` routes provide real liveness/readiness behavior, while `/health` remains a liveness compatibility alias. Request IDs, Helmet, explicit body limits, general and sensitive rate limits, centralized 404/error handling, safer production request logging, configurable cron startup, and API/worker graceful shutdown are now present. Bull Board requires an authenticated `admin` or `superadmin` and displays all three existing queues. The existing router is available on both `/api` and `/api/v1`.
|
||||
|
||||
Deployment additions include a hardened Node 22/Chromium/non-root Dockerfile with healthcheck, API/worker/MySQL/Redis Compose configuration, an Nginx reverse-proxy example, and a Gitea Actions CI baseline. Jest/Supertest tests now cover environment validation, liveness/readiness, errors/404, protected routes, Bull Board denial, and request correlation. The first test run exposed incompatible ESM-only `uuid@13`; it was safely pinned to CommonJS-compatible v11. `nodemon` moved to devDependencies.
|
||||
|
||||
Remaining foundation-adjacent work is intentionally deferred: production database baseline verification, distributed cron locking, full queue policy/idempotency, stronger documentation sessions, permission-router/role integration, and the Phase 1 authentication/ownership/security issues. The original audit above remains the historical baseline; statements such as “missing tests/Helmet/migrations” are superseded by this update and `Documentation/PHASE_0_FOUNDATION_STABILIZATION.md`.
|
||||
|
||||
## Phase 1 Completion Update
|
||||
|
||||
**Date:** 2026-09-03
|
||||
**Authentication completion:** approximately **91%**.
|
||||
**Revised Day 2 completion:** approximately **90%**.
|
||||
|
||||
Phase 1 replaced process-memory OTP and refresh sessions with Redis hash-only login challenges and durable MySQL auth-session families. OTP now uses `crypto.randomInt`, challenge UUIDs, TTL, atomic verification, bounded attempts, and no plaintext logging. Refresh tokens are opaque random values stored only by SHA-256 hash; row-locked transaction rotation detects replay and revokes the family. Access JWTs are short-lived, issuer/audience/algorithm constrained, and bound to `sub`, live session ID, and User token version. Middleware enforces current account state and session revocation.
|
||||
|
||||
New `auth_sessions`, `user_identities`, and `user_roles` models/tables support devices, Remember Me, Google/Apple stable subjects, and configurable roles. The User security migration adds token version/last login, expands canonical account types, and adds RBAC unique indexes. Permission routes are mounted behind SUPER_ADMIN, permission resolution now uses UserRole, and cache invalidation covers assignments/grants. Customer self-service update is allowlisted and ID-based mutation is privileged, closing the audited identity IDOR/mass-assignment path.
|
||||
|
||||
Auth endpoints now cover customer registration, verification/resend, password-to-OTP challenge, OTP completion, refresh rotation, current/all-device logout, forgot/reset/change password, Google, Apple, `/me`, and shared admin/rider compatibility flows. Both `/api` and `/api/v1` remain. Security-focused unit/integration tests were added without real providers/email/database/Redis.
|
||||
|
||||
Remaining identity work is operational: validate/deduplicate deployed RBAC data before migration, run staging MySQL/Redis concurrency tests, seed initial privileged assignments securely, configure provider audiences/mail, and design manual privileged OAuth linking and email change if required. Module 01 is now approximately 91%; migration/staging validation prevents claiming 100% production completion.
|
||||
## Phase 5 Completion Update
|
||||
|
||||
Date: 2026-09-03. Inventory/reservation foundations, business pricing, promotions/coupons, and localized scheduled banners are implemented behind new permissions and a forward-only migration. Phase 5 adds 13 models, secured administration routes, public availability/banner projections, audit calls, a bounded expiry reconciliation cron, and Phase 6 pricing/reservation service boundaries. Module 05 is 85%, Module 07 is 78%, and Module 13 is 75%. Inventory is 90%, reservations 90%, business pricing 82%, promotions/coupons 78%, and banners 82%. Automated verification: 18 suites and 89 tests passed; syntax passed for 232 JavaScript files. Migration was not executed. Staging must precheck legacy stock/pricing/promotion/banner data, apply the migration, seed permissions/default warehouse, and test genuine MySQL locking plus cron behavior before Phase 6 production use.
|
||||
## Phase 6 Completion Update
|
||||
|
||||
Date: 2026-09-03. Module 06 is 91%, Module 08 is 84%, and Module 07 is revised to 86%. Authenticated cart is 92%, wishlist 92%, shipping 86%, checkout 88%, and reservation integration 90%. Nine models, a forward-only migration, owner-scoped shopping APIs, permission-protected shipping administration, server-authoritative totals, atomic inventory reservation composition, idempotent checkout creation, and bounded expiry/cancellation release are implemented. Verification passes 20 suites/95 tests and 258 JavaScript syntax checks. The migration was not executed. Before Phase 7, staging must precheck legacy shopping/shipping tables and address/currency compatibility, seed shipping configuration/permissions, apply migrations, and validate real multi-connection InnoDB concurrency plus multi-instance expiry behavior.
|
||||
## Phase 7 Completion Update
|
||||
|
||||
Date: 2026-09-09. Module 09 is 86%, Module 10 is 76%, and Module 13 is revised to 82%. Orders are 90%, payments 78%, invoices 72%, refunds 68%, returns 82%, coupon redemption 80%, and verified-purchase reviews 90%. Eleven commerce models, a forward-only migration, transactional checkout conversion, owner/admin APIs, explicit state machines, verified/idempotent webhook processing, payment-time inventory consumption, invoice sequencing, itemized refund validation, RMA handling, and return-only restocking were added. Migration and real provider/MySQL/document-worker validation remain staging requirements. Module 16 AI Customer Support Chatbot is intentionally excluded from this backend and planned as a separate service.
|
||||
## Phase 8 Completion Update
|
||||
|
||||
Date: 2026-09-09. Module 11 is 88%; shipments are 90%, riders 86%, assignment/dispatch 86%, tracking 88%, proof of delivery 82%, and return logistics 84%. Module 09 is revised to 90% through physical fulfillment integration. Six logistics models, a forward-only migration, explicit shipment states/actions, partial fulfillment, locked rider capacity/assignment, append-only events, proof media validation, safe tracking, and approved-return pickup were added. Automated checks pass 25 suites/123 tests with 305 JavaScript files syntax-checked. The migration was not executed. Staging must validate real InnoDB concurrency, multi-instance idempotency, proof storage, notification fan-out, permission seeding, and return handoff before production or Phase 9 rollout. Module 16 AI Customer Support Chatbot remains excluded from this backend and will be developed separately.
|
||||
## Phase 9 Completion Update
|
||||
|
||||
Date: 2026-09-09. Module 12 is 86% and Module 13 is revised to 88%. Loyalty accounts are 92%, points ledger 90%, earning rules 86%, membership 88%, rewards/redemption 86%, referrals 82%, birthday rewards 80%, and expiry 80%. Business tiers are 84%, business credit 84%, settlements 78%, wholesale dashboard 82%, and wholesale analytics 76%. Module 07 is revised to 82% through coupon-entitlement foundations; Modules 09/10 are unchanged except for paid-event loyalty and internal-credit integration. Fourteen models, a forward-only migration, bounded cron reconciliation, new owner/admin APIs, and immutable concurrency-safe ledgers were added. Migration and real MySQL/cron/document/notification validation remain staging requirements. Module 16 AI Customer Support Chatbot remains intentionally excluded and will be developed separately.
|
||||
## Phase 10 Completion Update
|
||||
|
||||
Date: 2026-09-09
|
||||
|
||||
- Module 15 Support Ticket: **84%**
|
||||
- Module 17 Product Recommendations: **78%**
|
||||
- Support tickets 90%; messages 88%; assignment 86%; SLA/escalation 74%; attachments 82%; related-resource integration 78%.
|
||||
- Help center 84%; help localization 86%; help search 76%; newsletter consent 84%.
|
||||
- Recommendation events 80%; related 86%; recently viewed 84%; trending 78%; popular 74%; personalized deterministic 70%.
|
||||
- Module 14 notifications is unchanged: event/template delivery fanout remains outstanding.
|
||||
- Module 03 catalogue relationships are reused without a revised completion claim.
|
||||
- Module 04 localization infrastructure is reused without a revised completion claim.
|
||||
- Verification: **28 suites / 145 tests passing**; syntax passed for **373 JavaScript files**; `npm ls --depth=0` reports no dependency problems.
|
||||
- Migration: `20260909100000-phase-10-support-recommendations.js` created but **not executed**. Phase 0–9 migrations were not changed.
|
||||
- Staging requirements: legacy-data precheck; real MySQL migration/FK/query-plan and row-lock validation; Redis/BullMQ/cron multi-instance validation; S3 signed-download and file-content validation; SMTP notification wiring; business-price projection; authoritative shopping/commerce recommendation events; IDOR/E2E/concurrency tests.
|
||||
- Phase 11 readiness: safe to begin hardening after the Phase 10 migration and infrastructure checks are scheduled; Phase 10 is not a production-readiness claim.
|
||||
|
||||
Module 16 AI Customer Support Chatbot is intentionally excluded from this backend. It will be developed as a separate service.
|
||||
|
||||
## Phase 11 Completion Update
|
||||
|
||||
Date: 2026-09-15
|
||||
|
||||
Overall backend code completion is estimated at **90%**; production readiness is **64%**. Module 16 is excluded from both calculations.
|
||||
|
||||
- 01 Authentication/Authorization 92%; 02 Customer/Profile/Address 90%; 03 Catalogue 88%; 04 Localization 86%; 05 Inventory 88%; 06 Cart/Wishlist 92%; 07 Merchandising 84%; 08 Checkout 90%.
|
||||
- 09 Orders 91%; 10 Payments 82%; 11 Delivery/Rider 88%; 12 Loyalty 86%; 13 Wholesale 88%; 14 Notifications 74%; 15 Support 84%; 17 Recommendations 79%.
|
||||
- 18 Admin Dashboard/Analytics 82%; 19 File/Media 84%; 20 Audit/System Configuration 74%; 21 Jobs/Queues 80%; 22 Security 82%; 23 Testing/QA 76%; 24 API Documentation 82%; 25 Deployment/Infrastructure 78%.
|
||||
- Added permission-separated dashboard/sales/order/customer/product/inventory/payment/delivery/loyalty/support/recommendation analytics, bounded UTC date ranges, self-only account overview, protected low-cardinality metrics, OpenAPI 3.1 baseline, smoke test, and operational/recovery documentation.
|
||||
- Final automated verification: **29 suites / 150 tests passing**, **383 JavaScript files** syntax checked, OpenAPI JSON valid, and `npm ls --depth=0` clean.
|
||||
- Security upgrades reduced `npm audit` from 30 findings (20 high/9 moderate/1 low) to **19 findings (13 high/5 moderate/1 low)**. Remaining Puppeteer/transitive and major-version remediations require controlled upgrade/risk review.
|
||||
- Security findings fixed: vulnerable current-major versions of AWS SDK, Axios, BullMQ, Morgan, Multer, MySQL2, Sequelize and related packages were upgraded; analytics/metrics permissions and bounded query inputs were added.
|
||||
- Security findings remaining: full live IDOR/CSRF/proxy verification, antivirus defense, dependency majors/transitives, full route-matrix drift automation, and provider/infrastructure threat validation.
|
||||
- Migrations: Phase 0–10 migrations remain unchanged; no Phase 11 migration was necessary and **no migration was executed**.
|
||||
- Real infrastructure: MySQL migration/concurrency, Redis, BullMQ, multi-instance cron, S3, SMTP, Stripe, PayHere, Nginx/TLS, backup restore and load behavior are **IMPLEMENTED_UNVERIFIED**.
|
||||
- E2E: mocked/unit regression is VERIFIED; real retail, failure, cancellation, delivery, return/refund, loyalty, wholesale, support, authorization and concurrency flows are UNVERIFIED.
|
||||
- Deployment decision: **STAGING-READY**, not production-candidate. Complete the production checklist and generate real staging evidence before go-live.
|
||||
|
||||
Module 16 AI Customer Support Chatbot remains intentionally excluded. No AI/LLM, prompt, RAG, embedding, vector database, generated reply, or agent implementation was added.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Notification Event Matrix
|
||||
|
||||
| Domain | Event | In-App | Email | Push | Mandatory | Recipient | Implemented | Tested |
|
||||
|---|---|---:|---:|---:|---:|---|---|---|
|
||||
| Identity | Email verification/password reset/security | Yes | Yes | No | Yes | User | Yes | Unit/mocked |
|
||||
| Business | Application received/approved/rejected/status | Yes | Yes | No | Mixed | Applicant | Yes | Mocked |
|
||||
| Orders | ORDER_PLACED / ORDER_CANCELLED | Partial | No | No | Yes | Customer | Partial | Audit tests only |
|
||||
| Payments | PAYMENT_CONFIRMED / PAYMENT_FAILED | Partial | No | No | Yes | Customer | Partial | Provider unit tests |
|
||||
| Refunds/returns | completion/status | No | No | No | Yes | Customer | No | No |
|
||||
| Shipment | dispatched/delivered/failed | No | No | No | Yes | Customer | No | No |
|
||||
| Loyalty | reward redeemed | No | No | No | Optional | Customer | No | Policy tests |
|
||||
| Support | created/replied/resolved | No | No | No | Mixed | Customer/agent | No | State tests |
|
||||
| Wholesale | credit/settlement due | No | No | No | Mixed | Business contact | No | Ledger tests |
|
||||
|
||||
Push is **NOT_IMPLEMENTED** because no device-token/FCM infrastructure exists. Phase 2 provides persisted in-app/email delivery primitives, preferences, BullMQ retry and failure storage. Domain fanout above remains staging work; it was not faked in Phase 11.
|
||||
@@ -0,0 +1,140 @@
|
||||
# ZUMRI Phase 0 Foundation Stabilization
|
||||
|
||||
## Objective
|
||||
|
||||
Stabilize the existing modular monolith so later identity and commerce work can build on deterministic startup, explicit schema management, observable health, baseline security, testability, and controlled shutdown. This phase does not add e-commerce domain behavior or intentionally redesign existing modules.
|
||||
|
||||
## Starting Problems
|
||||
|
||||
The API listener started before asynchronous database authentication, runtime `sequelize.sync()` was the schema strategy, `/health` returned before its database check and hardcoded other dependencies as healthy, Redis clients connected during imports, and there was no environment validation, global error/404 handling, request correlation, security headers, rate limiting, graceful shutdown, migration system, or tests. Bull Board was public. Docker targeted Node 20 while the architecture targets Node 22. See `Documentation/CURRENT_BACKEND_STATUS.md` for the full baseline audit.
|
||||
|
||||
## Changes Implemented
|
||||
|
||||
- Aligned package and container runtime to Node 22.
|
||||
- Added centralized Zod environment validation with safe error messages.
|
||||
- Separated Express construction (`app.js`) from dependency initialization and listening (`server.js`).
|
||||
- Added database, Redis, queue, cron, API, and worker lifecycle handling.
|
||||
- Removed runtime `sequelize.sync()` and introduced a Sequelize CLI baseline migration.
|
||||
- Added liveness/readiness endpoints, request IDs, Helmet, body limits, general/sensitive rate limits, centralized errors, and centralized 404 behavior.
|
||||
- Protected Bull Board with the existing JWT middleware and `admin`/`superadmin` account guard; registered activity, document, and log queues.
|
||||
- Kept `/api` and added `/api/v1` as a backward-compatible alias.
|
||||
- Added Jest/Supertest baseline tests and a portable JavaScript syntax-check command.
|
||||
- Added Node 22 Docker hardening, development Compose, an Nginx example, and Gitea Actions CI.
|
||||
- Restored the existing S3 utility interface through a feature-gated S3 client.
|
||||
- Removed unsafe JWT/refresh-secret fallbacks and moved `nodemon` to development dependencies.
|
||||
- Pinned `uuid` to the CommonJS-compatible v11 line after tests exposed that v13 could not be loaded by this CommonJS application.
|
||||
|
||||
## Application Startup Lifecycle
|
||||
|
||||
The API sequence is now:
|
||||
|
||||
1. Load `.env`.
|
||||
2. Validate critical configuration without displaying values.
|
||||
3. Authenticate Sequelize (no schema mutation).
|
||||
4. connect to and ping Redis.
|
||||
5. Load the Express app and queue resources.
|
||||
6. Start cron only when `RUN_CRON=true`.
|
||||
7. Start the HTTP listener.
|
||||
8. On SIGTERM/SIGINT or fatal process error, stop accepting traffic, stop cron, close queues, Redis, and Sequelize, with a timeout guard.
|
||||
|
||||
Tests can import `app.js` without opening a TCP port. Startup failures prevent the listener from opening.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Required for API/worker startup: `NODE_ENV`, `PORT`, `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `JWT_SECRET`, `REDIS_HOST`, `REDIS_PORT`, and `FRONTEND_URL`. The JWT secret must contain at least 32 characters. Phase 1 replaced refresh JWTs with opaque random refresh tokens, so no refresh-token signing secret is required. `REDIS_PASSWORD` is optional at schema level for deployments without Redis authentication.
|
||||
|
||||
Runtime controls: `TRUST_PROXY` (numeric trusted proxy hop count; keep `0` when directly exposed), `JSON_BODY_LIMIT`, `API_RATE_LIMIT_WINDOW_MS`, `API_RATE_LIMIT_MAX`, `SENSITIVE_RATE_LIMIT_WINDOW_MS`, `SENSITIVE_RATE_LIMIT_MAX`, `RUN_CRON`, `CACHE`, and `SHUTDOWN_TIMEOUT_MS`.
|
||||
|
||||
Optional mail variables are required as a complete group only when `ENABLE_MAIL=true`: `MAIL_HOST`, `MAIL_PORT`, `MAIL_USER`, `MAIL_PASS`, `MAIL_FROM`; `MAIL_SECURE` is optional. Optional S3 variables are required as a complete group only when `ENABLE_S3=true`: `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_S3_BUCKET_NAME`. Docs credentials remain optional. See `.env.sample`; never commit real values.
|
||||
|
||||
## Database Migration Strategy
|
||||
|
||||
Sequelize CLI uses `.sequelizerc`, `app/config/sequelize-cli.config.js`, and `migrations/20260903000000-current-schema-baseline.js`. Commands:
|
||||
|
||||
```text
|
||||
npm run db:migrate:status
|
||||
npm run db:migrate
|
||||
npm run db:migrate:undo
|
||||
```
|
||||
|
||||
The baseline represents all currently registered models and creates only tables whose names are absent. It does not drop, alter, or validate columns during `up`. For an existing deployment: take a backup, compare its schema to the migration/model definitions, test against a restored copy, resolve drift explicitly, then run the migration so Sequelize records it. Do not blindly run the baseline undo in an existing environment; its standard `down` removes baseline tables. No migration was executed during this phase.
|
||||
|
||||
Future schema changes require new forward migrations. Production no longer calls `sequelize.sync()`.
|
||||
|
||||
## Health Endpoints
|
||||
|
||||
- `GET /health/live`: process-only liveness; no dependencies queried.
|
||||
- `GET /health/ready`: checks MySQL and Redis concurrently; returns 200/`ready` or 503/`not_ready`, exposing only `ok`/`error` states.
|
||||
- `GET /health`: backward-compatible alias to liveness so existing monitors are not broken.
|
||||
|
||||
Container orchestration should normally use liveness for process restart and readiness for traffic admission.
|
||||
|
||||
## Security Middleware
|
||||
|
||||
Helmet is enabled; CSP is disabled globally for compatibility with the existing Bull Board/static documentation, while the board itself is authorization-protected. The API disables `X-Powered-By`, limits JSON and URL-encoded bodies to 1 MB by default, retains current credentialed CORS behavior, and accepts `X-Request-ID` only in a constrained safe format. Unknown routes and uncaught request errors use a standard response. Production 500 responses hide internal details.
|
||||
|
||||
JWT helpers have no public fallback secrets. Startup rejects missing/weak secrets. Morgan does not log Authorization, Cookie, or request bodies. Error logs contain request ID plus error name/message, not arbitrary error objects.
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
Both `/api` and `/api/v1` use the configurable general limiter. The entire authentication router uses the stricter limiter, as do password reset requests/submissions and docs login. Health endpoints are outside limiters. `TRUST_PROXY` is an explicit numeric hop count rather than universal trust; set it to the exact Nginx hop count in deployment.
|
||||
|
||||
## Bull Board Security
|
||||
|
||||
`/admin/queues` now runs through the existing authentication middleware and accepts only actual User model administrator values: `admin` and `superadmin`. It has no hardcoded secondary credentials. Activity, document, and log queues are registered.
|
||||
|
||||
## Logging / Request IDs
|
||||
|
||||
Every request receives `req.id` and `X-Request-ID`; a safe incoming ID may be preserved. Development retains Morgan `dev`; production uses a concise method/path/status/timing line with request ID and no auth headers. The existing log queue remains in place. Phase 1 must remove remaining OTP/reset/session logging inside legacy authentication flows.
|
||||
|
||||
## Graceful Shutdown
|
||||
|
||||
The API handles SIGTERM, SIGINT, unhandled rejections, and uncaught exceptions. It closes the HTTP listener, cron tasks, API-owned queues, shared Redis, and Sequelize. Workers initialize required dependencies before accepting jobs and close worker instances, Redis, and Sequelize on the same signals/fatal conditions. `SHUTDOWN_TIMEOUT_MS` protects against indefinitely stuck shutdown.
|
||||
|
||||
## Cron Deployment Model
|
||||
|
||||
Cron is disabled unless `RUN_CRON=true`; tests do not start it. Until a distributed scheduler lock is added, enable it on exactly one API/scheduler instance. The cron launcher returns a stopper used during graceful shutdown.
|
||||
|
||||
## Testing
|
||||
|
||||
```text
|
||||
npm run check:syntax
|
||||
npm test -- --runInBand
|
||||
npm run test:unit
|
||||
npm run test:integration
|
||||
```
|
||||
|
||||
Tests mock external infrastructure. Coverage includes liveness and readiness success/failure, `/health` compatibility, centralized 404/error responses, environment validation and optional features, authentication-required rejection, Bull Board rejection, and request IDs. No real MySQL, Redis, email, or S3 is required by the baseline suite.
|
||||
|
||||
## Docker
|
||||
|
||||
The existing image now uses `node:22-slim`, keeps system Chromium/Puppeteer support, installs production dependencies, copies files as the unprivileged `node` user, and includes a `/health/live` healthcheck. Build and configuration are still environment-driven.
|
||||
|
||||
## Local Development
|
||||
|
||||
Copy `.env.sample` to an ignored `.env`, replace all placeholder credentials/secrets, then run migrations explicitly before starting the API. `compose.yaml` provides API, worker, MySQL 8.4, and Redis 7.4 using the same application image and named data volumes. It does not auto-run migrations. Only the API port is published; MySQL/Redis remain internal.
|
||||
|
||||
## CI
|
||||
|
||||
`.gitea/workflows/ci.yml` uses checkout/setup-node actions, Node 22, `npm ci`, syntax checks, and Jest. It performs no deployment and requires no production credentials. Runner action mirroring/network policy remains an installation-specific Gitea concern.
|
||||
|
||||
## API Versioning Strategy
|
||||
|
||||
The existing router is mounted at both `/api` and `/api/v1`. Existing frontend calls remain valid, while new consumers should adopt `/api/v1`. A later compatibility window can deprecate `/api`; no route was mass-renamed in Phase 0.
|
||||
|
||||
## Known Remaining Issues
|
||||
|
||||
- Authentication contains in-memory OTP/refresh-session behavior and needs the dedicated Phase 1 security/session design; no Phase 1 feature was implemented here.
|
||||
- Ownership/IDOR and role/account naming inconsistencies remain in legacy controllers/routes.
|
||||
- Permission routes remain imported but unmounted and role linkage/cache behavior needs repair.
|
||||
- Existing authentication utilities may still log OTP/reset/session material; remove and test during Phase 1.
|
||||
- Docs authentication still uses a weak boolean cookie and should receive a server-authenticated session design.
|
||||
- Activity/log queues need standardized retry, retention, idempotency, and sensitive-data sanitation.
|
||||
- S3 is now correctly constructed only when enabled, but object authorization/content validation and lifecycle remain later work.
|
||||
- The dependency audit still reports transitive vulnerabilities; forced/major upgrades were intentionally avoided.
|
||||
- Migration baseline schema drift must be reviewed against any deployed database before first use.
|
||||
- A distributed cron lock is not yet present.
|
||||
|
||||
## Phase 1 Prerequisites
|
||||
|
||||
The foundation is ready to begin Phase 1 once the baseline migration has been reviewed/tested against a copy of the deployment database and deployment secrets are configured. Phase 1 should focus on authentication/session durability, secure OTP/reset behavior, account status, role/permission integration, and ownership authorization without starting commerce modules.
|
||||
@@ -0,0 +1,53 @@
|
||||
# ZUMRI Phase 10 Support, Help Center, Newsletter and Recommendation Foundations
|
||||
|
||||
## Objective
|
||||
|
||||
Provide practical customer support, localized self-service content, explicit newsletter consent, and deterministic recommendations while preserving Phase 0–9 domain ownership.
|
||||
|
||||
## Existing Components Reused
|
||||
|
||||
Phase 1 identity/RBAC; Phase 2 upload, signed S3 access, notification/audit infrastructure; Phase 4 locale, product relations and product DTO; Phase 5 inventory boundary; Phase 7 order/payment truth; Phase 8 shipment truth; Phase 9 loyalty/business ownership; atomic reference numbers and existing cron lifecycle.
|
||||
|
||||
## Support Architecture
|
||||
|
||||
`SupportTicket` owns case lifecycle, `SupportMessage` conversation, `SupportTicketEvent` append-only history, `SupportTicketLink` polymorphic links, and `SupportAttachment` upload references. Ticket creation is transactional. The status graph is explicit; generic status mutation is absent. Agent claim/assignment locks the row. Internal notes and attachments are filtered from customer reads. Resource links validate domain ownership, and signed downloads require ticket authorization.
|
||||
|
||||
## SLA and Escalation
|
||||
|
||||
An active per-priority `SupportSlaPolicy` snapshots first-response and resolution deadlines using `CLOCK_TIME`. The bounded cron detects overdue open work and uses deterministic event keys to create each `SupportEscalation` once. Full calendars, warning tiers, acknowledgement APIs, and verified multi-instance scheduling remain outstanding.
|
||||
|
||||
## Support Notifications, Permissions and Audit
|
||||
|
||||
Phase 2 notification infrastructure is retained as the delivery boundary; full template/fanout wiring is outstanding. New support/help/newsletter/recommendation permissions are explicit. Support lifecycle history is durable in ticket events, and privileged general audit calls are intentionally limited pending real queue integration testing.
|
||||
|
||||
## Help Center Architecture
|
||||
|
||||
Help categories and articles have `en`/`si`/`ta` translation tables and English fallback. Articles implement draft, publish, and archive lifecycle; public APIs query published content only. FAQ is an article type, avoiding a parallel engine. Search uses bounded MySQL `LIKE` queries. HTML tags are removed and bodies are treated as untrusted Markdown-like text.
|
||||
|
||||
## Newsletter Consent
|
||||
|
||||
Newsletter subscriptions are unique by normalized email, source/locale aware, idempotent, independently revocable, and linked to a user when known. Immediate opt-in is the documented policy. Unsubscribe authorization uses a random token with hash-only persistence. Profile marketing preferences never override explicit unsubscribe.
|
||||
|
||||
## Recommendation Architecture
|
||||
|
||||
Privacy-conscious interaction events retain meaningful signals only. Client ingestion is restricted to product views and protected by authentication, validation, rate limiting, and event-id uniqueness. Explicit product relations lead related results. Recently viewed is de-duplicated; trending uses weighted 30-day SQL aggregation; popular uses paid order items net of refunds; for-you deterministically prioritizes recent interests with a featured fallback. Queries and response sizes are bounded, inactive/hidden products are excluded, and the existing localized serializer is reused.
|
||||
|
||||
## Future External Recommendation Service Boundary
|
||||
|
||||
The current implementation is `LOCAL_DETERMINISTIC`. A future external service may implement the same recommendation input/output contract using scoped machine credentials. There is no insecure internal bypass or placeholder service URL.
|
||||
|
||||
## Explicit AI Exclusion
|
||||
|
||||
Module 16 AI Customer Support Chatbot is intentionally excluded. No OpenAI/LLM integration, prompts, embeddings, RAG, vector database, generated replies, agent, or ML training pipeline was implemented.
|
||||
|
||||
## Security and Database Changes
|
||||
|
||||
Customer ownership is server-derived; strict request schemas reject unknown fields; internal visibility is enforced; uploads and linked resources receive independent authorization; newsletter tokens are hash-only; recommendation telemetry cannot spoof purchases. Migration `20260909100000-phase-10-support-recommendations.js` is forward-only and creates 14 tables with uniqueness and query indexes. Precheck legacy support/FAQ/newsletter/event tables and duplicate normalized email addresses before staging. No backfill is fabricated.
|
||||
|
||||
## Tests and Known Limitations
|
||||
|
||||
Unit coverage exercises strict fields, priority/event spoofing, transition policy, token hashing, source allowlists, body safety, and permissions. Real MySQL FK/migration/concurrency, Redis/BullMQ, S3 signed downloads, SMTP notifications, business-price projection, authoritative event hooks, rich CMS update APIs, and multi-instance cron behavior require Phase 11 staging work.
|
||||
|
||||
## Phase 11 Prerequisites
|
||||
|
||||
Apply migrations only after schema/data prechecks and a verified backup. Seed categories/SLA policies and permissions, validate real infrastructure, add concurrency/IDOR/E2E coverage, wire support notifications and authoritative recommendation signals, and measure aggregate query plans before production readiness assessment.
|
||||
@@ -0,0 +1,57 @@
|
||||
# ZUMRI Phase 11 Analytics and Production Readiness
|
||||
|
||||
## Objective and Baseline
|
||||
|
||||
The final main backend phase adds bounded operational analytics, account overview, security/dependency hardening, observability, API/deployment operations, and an evidence-based readiness assessment. Baseline: Node 22.12.0, npm 10.9.0, 28 suites/145 tests, 373 syntax files, and 30 audit advisories before upgrades.
|
||||
|
||||
## Architecture Reviewed
|
||||
|
||||
Current source comprises identity/RBAC, customer/business, catalogue/localization, inventory/pricing/merchandising, shopping/checkout, commerce, logistics, loyalty/wholesale, support/help/newsletter/recommendations, shared uploads/notifications/audit/documents, four queues/workers, five cron families, eleven forward-only phase migrations, Docker/Compose and Nginx samples. Mounted routes retain legacy `/api` and preferred `/api/v1` prefixes.
|
||||
|
||||
## Admin Analytics and Account Overview
|
||||
|
||||
Permission-separated endpoints cover dashboard, sales, orders, customers, products, inventory, payments, delivery, loyalty, support, and recommendation signals. All use SQL aggregation and bounded results; date-based reports default to 30 days UTC and reject ranges over 366 days. Revenue means paid/captured payment amount; refunds mean completed refunds; net is their difference. Profit/valuation and recommendation conversion are explicitly unavailable. `/account/overview` is self-only and combines recent orders, wishlist, default address, loyalty, vouchers, and open support count. Existing wholesale dashboard remains authoritative.
|
||||
|
||||
## Notification Completion
|
||||
|
||||
The matrix documents actual Phase 2 delivery and domain gaps. Push is not implemented. High-value commerce/logistics/support fanout remains implemented-unverified or absent and is not falsely claimed.
|
||||
|
||||
## Security, Authorization, IDOR and Validation Audit
|
||||
|
||||
Central authentication checks issuer/audience-signed JWT claims, active user/session, rotation/token version and permissions. Admin analytics/metrics are permission gated. Customer domains derive identity or constrain queries by owner. Phase 10 support attachments, messages, internal visibility and resource links have independent authorization. Strict Zod schemas protect new mutations. Existing route source remains authoritative; full live authenticated IDOR and concurrency execution is outstanding.
|
||||
|
||||
Cookies plus Bearer tokens serve browser/mobile clients. CORS is restricted to `FRONTEND_URL` with credentials; cookie flags remain part of the Phase 1 implementation. Helmet defaults are retained except CSP (disabled for compatibility) and non-production HSTS. Production must validate same-site/origin behavior behind the exact proxy topology. Logs use request IDs and avoid request bodies; error middleware suppresses production stack/internal details. No secret values were copied into this report. `.env` is ignored; rotate any credential ever committed outside the reviewed history.
|
||||
|
||||
## Authentication, Payment and File Security
|
||||
|
||||
Shared password policy/session invalidation and provider verification/idempotency remain covered by prior tests. Stripe/PayHere cryptographic and refund behavior is IMPLEMENTED_UNVERIFIED without sandbox credentials. Uploads enforce size, allowed type, content sniffing/checksum, ownership, private storage and signed expiry; antivirus is not implemented and is a recommended defense-in-depth control.
|
||||
|
||||
## Dependency Audit
|
||||
|
||||
Current-major upgrades were applied for AWS SDK, Bull Board, Axios, BullMQ, dotenv, ioredis, Jest, Morgan, Multer, MySQL2, node-cron, Nodemailer 8, pdfmake, Sequelize and Zod. The audit fell from 30 to 19 advisories. Remaining findings are primarily transitive and include Puppeteer/extract-zip plus packages requiring major/breaking remediation; they must be triaged or accepted before production.
|
||||
|
||||
## E2E and Concurrency Testing
|
||||
|
||||
Mocked/unit regression verifies domain state, authorization/validation policies and deterministic calculations. Real retail, failed-payment, cancellation, delivery, return/refund, loyalty/referral, business, support, authorization, InnoDB contention and duplicate webhook/claim flows remain UNVERIFIED. Use isolated staging identities and provider sandboxes; never run destructive commerce smoke against production.
|
||||
|
||||
## Migration and Infrastructure Validation
|
||||
|
||||
Phase 0–10 migrations were left unchanged. No migration was executed. Fresh and restored-upgrade MySQL procedures, FK/index/query-plan validation, Redis, BullMQ, single-scheduler cron, S3, SMTP, Stripe and PayHere are IMPLEMENTED_UNVERIFIED pending credentials/services. Forward correction or verified restore is the rollback model after live writes.
|
||||
|
||||
## Queue/Cron Operations and Observability
|
||||
|
||||
Operational inventories are in `QUEUE_OPERATIONS.md` and `CRON_OPERATIONS.md`. `/health/live` tests process life; `/health/ready` tests required MySQL/Redis. Protected `/api/v1/admin/metrics` exports process memory, uptime, HTTP counts and duration sums using only method/status-class labels. Recommended alerts cover readiness, 5xx, DB/Redis, queues, webhooks, cron, latency and resource pressure.
|
||||
|
||||
## OpenAPI, Docker, Nginx and CI/CD
|
||||
|
||||
`openapi.json` provides a valid OpenAPI 3.1 security/error and critical-route baseline, not a claim of complete route coverage. Docker uses Node 22, Chromium, production installs, non-root execution and liveness healthcheck. Compose separates API/worker and health-gates MySQL/Redis; production secrets must not use `.env` files. Nginx forwards standard proxy headers and bounds upload/timeouts; production TLS/trusted-proxy validation is required. Existing Gitea CI plus syntax/tests/dependency/OpenAPI validation should gate releases.
|
||||
|
||||
## Backup/Restore and Production Runbook
|
||||
|
||||
See `BACKUP_RESTORE.md`, `PRODUCTION_RUNBOOK.md`, and `PRODUCTION_CHECKLIST.md`. Database and object storage must be recoverable together; Redis loss semantics, RPO/RTO, restore drills and rollback authority require approval.
|
||||
|
||||
## Remaining Unverified Items and Assessment
|
||||
|
||||
Code implementation is approximately 91%; security 82%; automated tests 76%; real integration validation 25%; observability 72%; deployment 78%; documentation 88%; backup/recovery 68%. Overall code completion is **90%**; production readiness is **64%**. Decision: **STAGING-READY**, not production-candidate, because migrations, real dependencies, provider sandboxes, concurrency/E2E, notification fanout, remaining dependency advisories, and restore drills lack evidence.
|
||||
|
||||
Module 16 AI Customer Support Chatbot is excluded and was not implemented. No LLM, prompt, RAG, embedding, vector database, generated reply, or AI agent code was added.
|
||||
@@ -0,0 +1,119 @@
|
||||
# ZUMRI Phase 1 Identity and Authorization
|
||||
|
||||
## Objective
|
||||
|
||||
Complete the identity security boundary without beginning commerce modules: verified registration, password+OTP authentication, durable revocable sessions, social identity verification, account-state enforcement, repaired configurable RBAC, and ownership-safe self service.
|
||||
|
||||
## Existing Components Reused
|
||||
|
||||
User/Profile and customer-extension models, Sequelize registration, Redis client, bcrypt helper, email templates/transport, hashed verification/reset concepts, activity queue, permission/role/grant models, permission cache, Phase 0 error/rate-limit/request-ID middleware, dual API mounting, tests, and migrations were extended rather than replaced.
|
||||
|
||||
## Identity Model
|
||||
|
||||
`User` is the account and contains broad `accountType`, state, password hash (nullable for social-only customers), verification/password timestamps, `tokenVersion`, and `lastLoginAt`. `UserIdentity` maps Google/Apple stable subjects to User. `AuthSession` persists refresh credentials/device context and rotation state. `UserRole` assigns configurable Roles independently from account type.
|
||||
|
||||
## Account Types
|
||||
|
||||
Canonical application constants map to persisted lowercase values: `SUPER_ADMIN=superadmin`, `ADMIN=admin`, `MANAGER=manager`, `CUSTOMER=customer`, `BUSINESS_CUSTOMER=business_customer`, `RIDER=rider`, `SUPPORT_AGENT=support_agent`. Existing lowercase values remain valid.
|
||||
|
||||
## Role vs Account Type
|
||||
|
||||
Account type is a broad trusted identity category used for hard security boundaries. Role is configurable authorization grouping. Users may have multiple roles through `UserRole`; effective permissions are the union of role grants and direct additive `UserPermission` grants. This removes dependence on undeclared `User.roleID`.
|
||||
|
||||
## Session Architecture
|
||||
|
||||
```text
|
||||
Password -> OTP Challenge -> Verify OTP -> AuthSession
|
||||
-> Access JWT + opaque Refresh Token
|
||||
-> transactional rotation -> logout/revocation
|
||||
```
|
||||
|
||||
Sessions support multiple devices, user-agent/IP/device metadata, Remember Me, token families, last use, expiry, revocation reason, and replacement linkage. Raw refresh tokens exist only at issuance/transport.
|
||||
|
||||
## Access Token
|
||||
|
||||
HS256 JWTs are short-lived and contain `sub`, `sid`, and `tokenVersion`, plus `iss`, `aud`, `iat`, and `exp`. Middleware enforces algorithm, signature, issuer, audience, expiry, live User state/token version, and live non-revoked session state. It loads permissions server-side rather than embedding them.
|
||||
|
||||
## Refresh Token Rotation
|
||||
|
||||
Refresh tokens are opaque `session-id.random-secret` values. The database stores only SHA-256 hashes. Rotation locks the current session row and User in a transaction, creates a same-family replacement, and revokes/links the old row.
|
||||
|
||||
## Reuse Detection
|
||||
|
||||
Presentation of a revoked/replaced or hash-mismatched known session token revokes all active members of that token family and returns the same invalid-session boundary. Row locks ensure two concurrent refresh calls cannot both succeed.
|
||||
|
||||
## Remember Me
|
||||
|
||||
Remember Me changes only refresh-session lifetime: `REFRESH_TOKEN_TTL_DAYS` versus `REMEMBER_ME_REFRESH_TOKEN_TTL_DAYS`. Access lifetime remains `ACCESS_TOKEN_TTL`.
|
||||
|
||||
## Account Status Enforcement
|
||||
|
||||
Only `ACTIVE` can finish login, refresh, or use protected endpoints. Pending, suspended, and deactivated accounts are rejected using current database state, not stale claims. Password/security administration increments token version and revokes sessions.
|
||||
|
||||
## Password Policy
|
||||
|
||||
One Zod policy requires at least 12 characters, uppercase, lowercase, a symbol, and four digits. It is used by registration, reset, and change-password flows. Reset/change require confirmation and reject reuse of the current password.
|
||||
|
||||
## Email Verification
|
||||
|
||||
Verification tokens are cryptographically random and hash-only in Redis. They expire, are consumed atomically, and activate the account. Per-user pointers let resend invalidate an earlier token. Resend responses are generic and rate limited.
|
||||
|
||||
## Password Recovery
|
||||
|
||||
Forgot-password normalizes/validates email and returns a generic response. Reset tokens are random, stored hashed with TTL, atomically consumed using Redis `GETDEL`, and never logged. Successful reset updates the hash/timestamp/tokenVersion and revokes every session.
|
||||
|
||||
## Google Authentication
|
||||
|
||||
The backend verifies Google ID tokens against configured audience and uses Google's `sub`; verified email is required. Provider access tokens are not stored. Automated tests mock Google's verifier.
|
||||
|
||||
## Apple Authentication
|
||||
|
||||
The backend obtains/caches Apple's JWKS, selects the signed key, and verifies RS256 signature, Apple issuer, configured audience, expiry, and `sub`. First-login email is used only when verified. Tests use a locally signed RSA token and mocked JWKS response.
|
||||
|
||||
## OAuth Account Linking Rules
|
||||
|
||||
Existing provider subject wins. Otherwise a strongly verified provider email may link/create a customer. Email-only automatic linking is denied for super admins, admins, managers, support agents, and riders. Provider subject is unique and provider tokens/secrets are not persisted. Manual privileged linking remains deferred.
|
||||
|
||||
## Role and Permission Architecture
|
||||
|
||||
`/api/v1/permissions` is now mounted and default-denied to SUPER_ADMIN at router level. Existing CRUD is retained behind that boundary. Role names and role/user grant pairs have unique constraints. UserRole pairs are unique. Model hooks and assignment controllers invalidate affected permission caches.
|
||||
|
||||
## Ownership Authorization
|
||||
|
||||
`GET/PATCH /user/me` provides self service. Self update allowlists only first and last name; account type/status/roles/security fields are rejected. ID-based legacy mutation is restricted to SUPER_ADMIN, and no arbitrary ID read route is exposed. Profile image access uses reusable self-or-admin ownership middleware.
|
||||
|
||||
## New Database Tables
|
||||
|
||||
- `auth_sessions`
|
||||
- `user_identities`
|
||||
- `user_roles`
|
||||
|
||||
User adds `tokenVersion` and `lastLoginAt`; password becomes nullable for verified social-only users; the account-type ENUM expands to all canonical types.
|
||||
|
||||
## New Migrations
|
||||
|
||||
`20260903010000-phase-1-identity-security.js` is forward-only relative to the Phase 0 baseline and was not executed. Before applying to an existing database, diagnose duplicate `roles.roleName`, `(role_id,permission_id)`, and `(user_id,permission_id)` rows because unique indexes intentionally fail on dirty data. Back up and test a restored database first.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
Auth: register, login challenge, verify OTP, refresh, logout, logout-all, forgot/reset/change password, verify/resend email, Google, Apple, and me. Thin shared-flow admin and rider login routes exist. Admin User status/account-type endpoints and protected permission/UserRole endpoints are mounted. See `Documentation/API_AUTHENTICATION.md`.
|
||||
|
||||
## Security Controls
|
||||
|
||||
Hash-only OTP/reset/verification/refresh persistence; cryptographic random generation; bounded OTP attempts; single-use challenges; short access TTL; durable revocation; replay-family revocation; DB row locks; account-state/token-version checks; strict Zod bodies; generic enumeration responses; provider signature/audience checks; customer-only public registration; field allowlists; ownership checks; and no token/OTP credential logging.
|
||||
|
||||
## Tests
|
||||
|
||||
Unit coverage includes password policy, JWT claims/wrong issuer-audience/expiry/malformed input, OTP format/hash-only persistence/failure states, refresh hash-only persistence/rotation/replay, account-status/password-login behavior, and Google/Apple verification. Integration coverage preserves Phase 0 health/error behavior and checks RBAC denial, self mass-assignment, other-user mutation, suspended access, and Bull Board admin access. External DB/Redis/email/OAuth services are mocked.
|
||||
|
||||
## Legacy Compatibility
|
||||
|
||||
Both `/api` and `/api/v1` remain. `/auth/req-otp` aliases new login challenge creation; `/profile` password endpoints delegate to the same hardened controllers. The old email+OTP `/auth/login` second step is intentionally replaced by `/verify-otp` with challenge IDs because the old email-keyed flow could not meet security requirements.
|
||||
|
||||
## Remaining Known Issues
|
||||
|
||||
Manual privileged OAuth linking and secure email-change confirmation are deferred. Full email queue/delivery tracking remains Phase 2 infrastructure work. Existing business registration/account approval is not part of this phase. Role/grant uniqueness migration requires deployed-data diagnostics. The baseline test suite mocks MySQL/Redis; staging integration tests must run after migration review. Existing non-auth legacy routes may still contain account-name/ownership debt outside Phase 1 scope.
|
||||
|
||||
## Phase 2 Prerequisites
|
||||
|
||||
Review and run both migrations on a restored environment, configure mail plus Google/Apple client audiences where those flows are enabled, run staging MySQL/Redis integration tests, and seed the initial SUPER_ADMIN/roles/permissions through a controlled operational process. Once complete, identity is ready for the next non-commerce phase requested by the development roadmap.
|
||||
@@ -0,0 +1,93 @@
|
||||
# ZUMRI Phase 2 Cross-Cutting Services
|
||||
|
||||
## Objective
|
||||
|
||||
Harden the shared storage, messaging, queue, audit, logging, and document infrastructure without starting commerce modules.
|
||||
|
||||
## Existing Components Reused
|
||||
|
||||
The existing AWS SDK client, Upload/Notification/UserNotification/Document models, Redis/BullMQ topology, generators, templates, reference utility, workers, cron lifecycle, Phase 0 operations, and Phase 1 identity/RBAC remain the foundation.
|
||||
|
||||
## Storage Architecture
|
||||
|
||||
`storage.service.js` is the sole AWS SDK boundary. It supports AWS S3 and endpoint/path-style compatible providers, buffer upload, delete, HEAD existence checks, and signed downloads. Objects remain private.
|
||||
|
||||
## Upload Security
|
||||
|
||||
Multer performs an early allowlist/size check. The service then rejects empty content and validates JPEG, PNG, WebP, PDF, and XLSX magic bytes against the claimed MIME. It derives the extension, sanitizes display filenames, and stores SHA-256 checksums. Object keys contain a controlled owner identifier, UTC year/month, UUID, and detected extension; original filenames and personal data are excluded.
|
||||
|
||||
## File Ownership
|
||||
|
||||
Uploads record uploader, owner type/id, purpose, visibility, and lifecycle status. Signed URL and deletion endpoints load metadata and enforce owner or explicit permission access. DB persistence failure after upload triggers best-effort object deletion. Deletion marks metadata DELETED before object removal.
|
||||
|
||||
## Signed URL Policy
|
||||
|
||||
Downloads use `S3_SIGNED_URL_TTL_SECONDS` (default 900 seconds). URLs are generated after each authorization decision and are not globally cached or exposed with bucket/key details.
|
||||
|
||||
## Email Architecture
|
||||
|
||||
Auth email enters `email.service.js`, creates a minimal delivery record, and queues a template-keyed job. The worker alone calls Nodemailer. OTPs/links may exist transiently in job data, so jobs have aggressive completion retention and payloads must never be logged.
|
||||
|
||||
## Email Retry Strategy
|
||||
|
||||
Email uses five exponential attempts. Envelope/message and SMTP 5xx failures are treated as permanent; transient provider/network errors retry. Final state is persisted without storing message bodies or variables.
|
||||
|
||||
## Notification Architecture
|
||||
|
||||
Notification aliases are explicit. Admin publication can assign one or many users atomically; announcements need no join rows. Self-service listing/read/read-all always derives the user from the access token.
|
||||
|
||||
## Notification Preferences
|
||||
|
||||
The central policy permits mandatory login OTP, password reset/change, and email verification even when marketing notifications are disabled. Optional external-channel messages honor profile preferences. In-app messages remain available.
|
||||
|
||||
## Queue Architecture
|
||||
|
||||
Activity, log, document, and email queues define retry, backoff, success retention, and failure retention appropriate to each workload. Bull Board includes all four and retains Phase 0 admin protection.
|
||||
|
||||
## Idempotency
|
||||
|
||||
Activity uses an event ID, email uses event/delivery ID, and documents use the generation record ID as BullMQ job ID. Workers check persisted state where duplicate execution could create a second artifact.
|
||||
|
||||
## Failed Job Handling
|
||||
|
||||
Email and document final failures update their associated database record with a bounded error code and timestamp. Stack traces and job payloads are not returned by APIs.
|
||||
|
||||
## Audit Logging
|
||||
|
||||
Activity events now support event ID, nullable actor, target, action/type, request/IP/user-agent context, sanitized JSON metadata, and occurrence time. APIs expose read operations only; inserts are idempotent by event ID.
|
||||
|
||||
## Logging Security
|
||||
|
||||
Queued file logs are JSON lines. Error stacks, request bodies, Authorization/Cookie values, and fields named like passwords, OTPs, tokens, or secrets are excluded/redacted. Log jobs have bounded retention; `LOG_RETENTION_DAYS` documents the intended operational file-retention window.
|
||||
|
||||
## Document Generation Lifecycle
|
||||
|
||||
Generation validates a controlled registry and PDF/XLSX format before queueing, creates an owner-bound record, and moves through QUEUED, PROCESSING, COMPLETED, or FAILED. Successful output becomes an owned Upload. Downloads create a signed URL and never delete the object.
|
||||
|
||||
## Document Ownership
|
||||
|
||||
Creator and owner may view status/data/download. SUPER_ADMIN bypasses; other administrative access requires the appropriate document permission. Cancellation follows the same ownership boundary.
|
||||
|
||||
## Migrations
|
||||
|
||||
`20260903020000-phase-2-cross-cutting-services.js` is new and forward-only. Before execution, back up and test a restored database. Pre-check duplicate `(user_id, notification_id)`, duplicate activity event IDs, duplicate document job IDs, duplicate document type names, and legacy uploads/documents without resolvable owners. Resolve duplicates explicitly; the migration intentionally does not delete data.
|
||||
|
||||
## Permissions
|
||||
|
||||
Shared names are `media.read/upload/delete`, `documents.read/create/delete`, `notifications.manage/read`, `audit.read`, and `queues.read`. SUPER_ADMIN retains the Phase 1 bypass. Production permission rows/grants must be seeded through the environment's controlled authorization process.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Added: `S3_ENDPOINT`, `S3_FORCE_PATH_STYLE`, `S3_SIGNED_URL_TTL_SECONDS`, `S3_MAX_UPLOAD_BYTES`, `EMAIL_QUEUE_CONCURRENCY`, `DOCUMENT_QUEUE_CONCURRENCY`, `NOTIFICATION_RETENTION_DAYS`, and `LOG_RETENTION_DAYS`. Optional integrations remain optional unless enabled.
|
||||
|
||||
## Tests
|
||||
|
||||
Unit coverage verifies magic bytes, mismatch/empty rejection, checksums, safe keys, HTML escaping, security-notification preference policy, and email queue retry/retention. Existing Phase 0/1 tests remain in the full suite. AWS, SMTP, Redis, and MySQL are not contacted.
|
||||
|
||||
## Remaining Known Issues
|
||||
|
||||
The migration has not been run against staging data. Real S3-compatible provider, SMTP, MySQL migration, Redis concurrency, large notification retention, and actual PDF/browser generation need staging validation. File-log deletion/rotation still belongs to deployment logrotate or a future controlled maintenance worker. Push delivery is intentionally an architecture placeholder only.
|
||||
|
||||
## Phase 3 Prerequisites
|
||||
|
||||
Complete the documented data pre-checks, apply all migrations to a restored database, seed shared permissions, run API and worker processes against staging Redis/MySQL, and exercise one upload/email/document lifecycle with non-production provider credentials.
|
||||
@@ -0,0 +1,99 @@
|
||||
# ZUMRI Phase 3 Customer and Business Foundations
|
||||
|
||||
## Objective
|
||||
|
||||
Complete customer and business account foundations needed before catalogue and commerce work, without implementing commerce.
|
||||
|
||||
## Existing Components Reused
|
||||
|
||||
Phase 1 authentication, account states, session revocation, canonical `business_customer`, permissions, and ReferenceNumber are reused. Phase 2 owned uploads, signed media, audit events, notification persistence, email queue, and templates remain shared boundaries.
|
||||
|
||||
## Customer Profile
|
||||
|
||||
Profile retains its existing phone/date/media/theme data and adds locale plus separate marketing email, marketing push, and in-app preferences. First/last name and email remain canonical User identity fields. Strict self endpoints derive identity from authentication and return no security or storage internals.
|
||||
|
||||
## Address Architecture
|
||||
|
||||
Address is structured and may belong to exactly one User or approved business profile. It supports international ISO alpha-2 country codes while retaining district/province fields useful in Sri Lanka. An ownership CHECK constraint and foreign keys enforce integrity.
|
||||
|
||||
## Default Address Rules
|
||||
|
||||
A User/business profile may use one shipping and one billing default, and one row may be both. Mutations serialize on the owner row and clear the prior default in the same transaction. Deletion leaves the default unset. Future commerce records must snapshot address values.
|
||||
|
||||
## Preferences
|
||||
|
||||
Marketing email and push choices are distinct from in-app preference and Phase 2 mandatory security-message policy. Security messages cannot be disabled through these fields.
|
||||
|
||||
## Account Deactivation
|
||||
|
||||
Self-deactivation is a soft identity transition. Password accounts confirm the current password; social-only accounts provide the explicit DEACTIVATE confirmation. The transaction increments token version and revokes every session. Login remains denied and self-reactivation is deferred.
|
||||
|
||||
## Business Application Architecture
|
||||
|
||||
Applications preserve review history independently of the approved profile. Only verified ACTIVE customers can submit, and the applicant row lock prevents concurrent active submissions. States are PENDING, UNDER_REVIEW, APPROVED, REJECTED, and CANCELLED.
|
||||
|
||||
## Business Approval Workflow
|
||||
|
||||
Permission-gated approval locks the application and applicant, validates transition, generates one partner sequence, creates BusinessCustomer and a disabled zero-limit credit account, changes the canonical account type, increments token version/revokes sessions, and records approval. Double approval fails safely.
|
||||
|
||||
## Business Profile
|
||||
|
||||
The existing BusinessCustomer table is retained as the approved business profile. It now includes legal/tax/web data, immutable unique partner ID, separate ACTIVE/SUSPENDED/CLOSED domain status, approval metadata, and settlement reference. Self-editing is allowlisted.
|
||||
|
||||
## Partner Identity
|
||||
|
||||
Partner identity is generated transactionally from ReferenceNumber as `ZUM-BIZ-######`. It is unique, immutable through public APIs, safe to expose, and never previewed through GET.
|
||||
|
||||
## Business Contacts
|
||||
|
||||
BusinessContact holds non-authenticating contact people. A transaction serializes primary-contact changes without creating User identities or organization/team accounts.
|
||||
|
||||
## Business Documents
|
||||
|
||||
Applications reuse private Phase 2 Upload records and its MIME/signature/checksum controls. Defined purposes identify registration, tax, identity, and supporting records. Review permission extends signed-read access; storage keys remain private.
|
||||
|
||||
## Business Status
|
||||
|
||||
Business domain status is separate from User account status. Suspending a business capability does not automatically suspend login identity.
|
||||
|
||||
## Credit Foundation
|
||||
|
||||
BusinessCreditAccount stores currency, `DECIMAL(15,2)` limit, and DISABLED/ACTIVE/SUSPENDED configuration. Only privileged administration may change it and every change is audited. No used-credit field or fabricated availability is present.
|
||||
|
||||
## Settlement Terms
|
||||
|
||||
SettlementTerm provides code, display name, day count, and active state. PREPAID, NET_7, NET_14, and NET_30 initial records are migration data, not hardcoded behavior. No billing, invoice, settlement, or product-pricing logic exists.
|
||||
|
||||
## Ownership
|
||||
|
||||
Customer profile/address/application queries derive the authenticated User and constrain IDs at query time. Business self-service resolves the profile by authenticated owner. Reviewer and administration routes require explicit permissions.
|
||||
|
||||
## Permissions
|
||||
|
||||
Added `business.applications.read`, `business.applications.review`, `business.accounts.read`, `business.accounts.update`, `business.credit.manage`, and `business.settlement.manage`. SUPER_ADMIN retains the established bypass.
|
||||
|
||||
## Audit Events
|
||||
|
||||
Profile, address, deactivation, application review, profile/status, credit, and settlement changes emit Phase 2 events with stable names and IDs. Full addresses, documents, credentials, and reviewer internals are not placed in metadata.
|
||||
|
||||
## Notifications
|
||||
|
||||
Application receipt/approval/rejection and business status templates use Notification/UserNotification and the Phase 2 email queue. Controllers do not call SMTP. Push delivery remains deferred.
|
||||
|
||||
## Database Changes
|
||||
|
||||
The new forward-only Phase 3 migration extends Profile and BusinessCustomer, creates addresses, business applications/contacts/credit accounts and settlement terms, adds targeted indexes/constraints, and seeds initial settlement configuration.
|
||||
|
||||
Pre-check existing `business_customer` users, duplicate registration or partner IDs, invalid/null Profile relationships, legacy Customer address strings requiring manual migration, existing BusinessCustomer records that need partner/approval backfill, and duplicate profiles. No legacy data is silently deleted.
|
||||
|
||||
## Tests
|
||||
|
||||
New unit tests cover strict profile/business mass assignment, locale/address validation, deactivation confirmation, business application eligibility/duplicate prevention, rejection requirements, and credit validation. Existing Phase 0-2 suites remain mandatory.
|
||||
|
||||
## Remaining Known Issues
|
||||
|
||||
The migration has not run against staging. Legacy BusinessCustomer rows require an explicit partner/approval backfill before making new columns universally non-null. Real MySQL lock/concurrency behavior, Redis queues, SMTP notifications, S3 documents, and migration constraints need staging tests. Business document-to-application linking and administrative settlement-term CRUD may be added when real operational requirements are known.
|
||||
|
||||
## Phase 4 Prerequisites
|
||||
|
||||
Back up and restore staging data, complete pre-check/backfill decisions, apply migrations in order, seed/grant Phase 3 permissions, run concurrent application/default-address tests on MySQL, and verify one application approval plus notification flow with non-production integrations.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,84 @@
|
||||
# ZUMRI Phase 7 Orders and Payments
|
||||
|
||||
## Objective
|
||||
Convert checkout snapshots into durable orders and establish authoritative payment, invoice, refund, and return lifecycles without delivery or rewards.
|
||||
|
||||
## Existing Components Reused
|
||||
Checkout snapshots, inventory reservations, pricing money helpers, coupons, ReferenceNumber, document infrastructure, audit queue, authentication, and permissions.
|
||||
|
||||
## Order Architecture
|
||||
Order/payment/fulfillment states are independent. Commercial and address/shipping details are immutable snapshots.
|
||||
|
||||
## Checkout Conversion
|
||||
The centralized transaction locks an owned READY checkout, verifies reservations, returns any existing order, copies items, and converts checkout/cart.
|
||||
|
||||
## Order State Machine
|
||||
Explicit transition sets reject illegal terminal-state transitions and arbitrary status patches.
|
||||
|
||||
## Order Snapshots
|
||||
Items retain product/variant IDs, reservation key, SKU, names, quantity, unit price, discount, total, currency, and limited metadata.
|
||||
|
||||
## Payment Architecture
|
||||
Payments retain immutable attempts. Only server/provider reconciliation changes authoritative financial state.
|
||||
|
||||
## Provider Adapters
|
||||
Generic payment logic delegates verification/parsing/initiation/refund/status behavior to provider modules.
|
||||
|
||||
## PayHere
|
||||
Merchant secrets are environmental. Browser redirects are non-authoritative; the notify hash, reference, amount, and currency must validate.
|
||||
|
||||
## Stripe
|
||||
An explicit disabled boundary is present. Integration awaits official SDK/configuration rather than accepting unverified callbacks.
|
||||
|
||||
## Webhook Verification
|
||||
Invalid signatures, currencies, amounts, and references are rejected.
|
||||
|
||||
## Webhook Idempotency
|
||||
Unique provider/event records and locked payment/order rows ensure duplicate success cannot repeat side effects.
|
||||
|
||||
## Inventory Consumption
|
||||
Checkout reserves; confirmed online payment consumes. Failed payment does not consume. Unpaid cancellation releases.
|
||||
|
||||
## Payment Reconciliation
|
||||
Webhook persistence supports reconciliation, but remote status polling is deferred until the enabled provider supplies a validated status API.
|
||||
|
||||
## Business Credit
|
||||
Disabled pending an approved immutable credit-ledger/accounting policy; concurrent unsafe balance mutation was not introduced.
|
||||
|
||||
## Invoice Architecture
|
||||
One invoice per order uses transaction-safe ReferenceNumber sequencing. The existing document worker remains the PDF integration point.
|
||||
|
||||
## Refund Architecture
|
||||
Idempotent itemized requests validate remaining quantity and captured amount. Provider processing is separate from request approval.
|
||||
|
||||
## Return/RMA Architecture
|
||||
Owner-scoped requests and explicit admin transitions track physical receipt/condition independently from refunds.
|
||||
|
||||
## Exchange Boundary
|
||||
EXCHANGE is recorded as resolution intent; no replacement order or fulfillment is created.
|
||||
|
||||
## Coupon Redemption
|
||||
Redemption occurs once on confirmed payment while the coupon row is locked. Full-refund restoration is deferred by policy.
|
||||
|
||||
## Verified Purchase Reviews
|
||||
Review creation derives verification only from an actual paid order containing the product.
|
||||
|
||||
## Permissions
|
||||
Orders read/manage/cancel, payments read/manage/refund, invoices read, and returns read/manage were added.
|
||||
|
||||
## Audit Events
|
||||
Order, payment creation, cancellation, refund request, return lifecycle, and admin operations reuse sanitized Phase 2 activity logging.
|
||||
|
||||
## Database Changes
|
||||
Forward-only migration `20260909070000` creates eleven commerce tables with unique references/idempotency and lifecycle indexes.
|
||||
|
||||
## Tests
|
||||
Unit tests cover order transitions, PayHere signature validation, forged notifications, secure return/refund inputs, and configurable return eligibility.
|
||||
|
||||
## Remaining Known Issues
|
||||
Real PayHere, refunds, document generation, migration, webhook delivery, and MySQL concurrency were not exercised. Refund approval/provider completion APIs and reconciliation polling require provider policy/configuration.
|
||||
|
||||
## Phase 8 Prerequisites
|
||||
Apply all migrations on restored staging, seed permissions, configure/validate PayHere sandbox, test concurrent duplicate webhooks, final-stock payment consumption, coupon quotas, invoice jobs, refund callbacks, and return restocking.
|
||||
|
||||
Module 16 AI Customer Support Chatbot is intentionally excluded and planned as a separate service.
|
||||
@@ -0,0 +1,87 @@
|
||||
# ZUMRI Phase 8 Delivery and Rider Management
|
||||
|
||||
## Objective
|
||||
Add physical delivery and approved-return transportation after the paid Order boundary.
|
||||
|
||||
## Existing Components Reused
|
||||
User/RIDER authentication, Order/OrderItem snapshots, ReturnRequest, ReferenceNumber, Upload, audit, notification foundations, and permissions.
|
||||
|
||||
## Shipment Architecture
|
||||
Orders can have many CUSTOMER_DELIVERY shipments; returns have one RETURN_PICKUP. Shipment owns immutable destination and operational timestamps, never prices or payments.
|
||||
|
||||
## Shipment Items
|
||||
ShipmentItem references OrderItem and supports partial allocation. Locked allocation checks prevent non-cancelled totals exceeding purchases.
|
||||
|
||||
## Shipment State Machine
|
||||
Central legal transitions cover readiness, assignment, pickup, transit, delivery, failure, rescheduling, and cancellation. No generic status patch exists.
|
||||
|
||||
## Order Fulfillment Integration
|
||||
Delivered shipment quantities derive UNFULFILLED, PARTIALLY_FULFILLED, or FULFILLED. Delivery never changes payment or initiates a refund.
|
||||
|
||||
## Rider Profile
|
||||
Operational data attaches one-to-one to an existing RIDER User; authentication and identity remain on User.
|
||||
|
||||
## Rider Availability
|
||||
ACTIVE/INACTIVE/SUSPENDED is separate from AVAILABLE/BUSY/OFFLINE. Assignment locks the rider and checks active capacity.
|
||||
|
||||
## Assignment Architecture
|
||||
Append-preserved assignment rows record assignment, acceptance/rejection, unassignment, and completion. Shipment caches only the current rider.
|
||||
|
||||
## Dispatch
|
||||
The dispatch view queries ready, active, failed, and rescheduled shipments without adding redundant persistence.
|
||||
|
||||
## Rider Workflow
|
||||
Riders see and act only on their own current assignments through explicit legal action endpoints.
|
||||
|
||||
## Delivery Events
|
||||
ShipmentEvent is append-only. Unique event IDs provide retry idempotency and chronological customer timelines.
|
||||
|
||||
## Proof of Delivery
|
||||
Structured proof supports photo, signature, recipient confirmation, and return-pickup photo. Existing private Upload records are ownership/status/MIME checked.
|
||||
|
||||
## Failed Deliveries
|
||||
Failure requires a reason code and increments attempts. It does not refund, cancel the Order, or restock.
|
||||
|
||||
## Rescheduling
|
||||
Admin may schedule a failed delivery with bounded window/reason fields; no calendar optimizer is introduced.
|
||||
|
||||
## Customer Tracking
|
||||
Authenticated ownership is mandatory. Safe projections exclude private rider/location and financial data.
|
||||
|
||||
## Location Privacy
|
||||
Only latest optional coordinates are retained. They are operational data, absent from customer responses and broad audit metadata.
|
||||
|
||||
## Return Logistics
|
||||
Physical transport remains separate from Phase 7 inspection, refund, and inventory decisions.
|
||||
|
||||
## Return Pickup
|
||||
Only approved returns create an idempotent pickup. Delivery marks the request RECEIVED without premature refund/restock.
|
||||
|
||||
## Notifications
|
||||
Existing Phase 2 infrastructure remains the integration boundary. Automated delivery notification fan-out needs durable outbox/worker hardening before production.
|
||||
|
||||
## Permissions
|
||||
Shipment read/create/manage/assign, rider read/manage, dispatch read/manage, proof read, and return-logistics read/manage permissions were added.
|
||||
|
||||
## Audit
|
||||
Creation, assignment, rider actions, cancellation, reschedule, and return pickup use stable Phase 2 activity types without precise location.
|
||||
|
||||
## Idempotency
|
||||
Creation, assignment, and rider transitions use client keys mapped to unique ShipmentEvent IDs. Repeated delivery cannot duplicate proof/event/fulfillment changes.
|
||||
|
||||
## Concurrency
|
||||
Transactions and row locks protect creation allocations, assignment, capacity, transitions, completion, and return-pickup uniqueness. Real multi-connection InnoDB tests remain required.
|
||||
|
||||
## Database Changes
|
||||
Forward-only migration `20260909080000` creates six logistics tables with reference, event, lookup, and uniqueness indexes.
|
||||
|
||||
## Tests
|
||||
Unit tests cover all primary legal/illegal transitions, owner-derived rider authorization, address injection rejection, failure reason validation, and location ranges.
|
||||
|
||||
## Remaining Known Issues
|
||||
Real migrations/concurrency, durable notification fan-out, upload storage, pagination tuning, and assignment race testing require staging. No external courier or real-time GPS system exists.
|
||||
|
||||
## Phase 9 Prerequisites
|
||||
Apply migrations on restored staging, seed permissions/rider profiles, validate real concurrent allocation/assignment/delivery requests, proof uploads, notification idempotency, and return receipt handoff.
|
||||
|
||||
Module 16 AI Customer Support Chatbot remains intentionally outside this backend and will be developed separately.
|
||||
@@ -0,0 +1,90 @@
|
||||
# ZUMRI Phase 9 Loyalty and Wholesale Completion
|
||||
|
||||
## Objective
|
||||
Connect immutable loyalty rewards and wholesale credit/settlement accounting to existing users, paid orders, reviews, coupons, payments, and invoices.
|
||||
|
||||
## Existing Components Reused
|
||||
User/Profile DOB, BusinessCustomer/Tier/CreditAccount/SettlementTerm, Orders, Payments, Refunds, Invoices, Reviews, Coupons, pricing money helpers, ReferenceNumber, cron, notifications, audit, and RBAC.
|
||||
|
||||
## Loyalty Architecture
|
||||
One account caches balances while an append-only ledger remains authoritative. All mutations lock the account and use unique events.
|
||||
|
||||
## Loyalty Account
|
||||
ACTIVE/SUSPENDED/CLOSED state, spendable/pending/debt balances, lifetime totals, tier, and last activity are maintained transactionally.
|
||||
|
||||
## Points Ledger
|
||||
Integer EARN, REDEEM, EXPIRE, ADJUSTMENT, and REVERSAL entries store source, resulting balance, expiry, and sanitized metadata.
|
||||
|
||||
## Earning Rules
|
||||
Data-driven effective rules support amount-based purchases and fixed review/referral/birthday awards.
|
||||
|
||||
## Purchase Rewards
|
||||
Confirmed PAID processing awards discounted merchandise value only, with integer-safe floor rounding and deterministic Order event identity.
|
||||
|
||||
## Refund Reversals
|
||||
Reversals append history. Insufficient available points produce tracked debt rather than silently discarding value.
|
||||
|
||||
## Verified Review Rewards
|
||||
Only APPROVED verified-purchase reviews earn, once per review.
|
||||
|
||||
## Referral Rewards
|
||||
Opaque random codes prevent self/multiple referral claims; signup alone does not reward, while a qualifying paid Order does.
|
||||
|
||||
## Birthday Rewards
|
||||
Daily bounded reconciliation uses a unique user/calendar-year event, preventing DOB edits or retries from duplicating rewards.
|
||||
|
||||
## Points Expiry
|
||||
Earn rules may assign expiry. Allocation records support earliest-expiring-first redemption and idempotent expiry debits.
|
||||
|
||||
## Membership Tiers
|
||||
Active data-driven LIFETIME_POINTS thresholds select the highest rank and append tier history.
|
||||
|
||||
## Reward Catalogue
|
||||
Scheduled, limited COUPON or MANUAL rewards use integer point costs.
|
||||
|
||||
## Reward Redemption
|
||||
Account/reward/allocation locks enforce balance, stock, per-user limits, FIFO spending, and idempotency.
|
||||
|
||||
## Voucher Integration
|
||||
COUPON rewards reference Phase 5 Coupon and issue customer entitlements rather than creating another coupon engine.
|
||||
|
||||
## Loyalty Notifications and Permissions
|
||||
Phase 2 notification/audit remain the delivery boundaries. Accounts read, points adjustment, management, and referral-read permissions were added.
|
||||
|
||||
## Wholesale Architecture
|
||||
Existing business identity/pricing remain authoritative. Phase 9 adds financial ledgers and period snapshots.
|
||||
|
||||
## Business Credit Ledger
|
||||
Unique immutable events store amount, balance delta/after, currency, Order/Payment references, and event time.
|
||||
|
||||
## Credit Utilization
|
||||
Account row locks prevent limits being exceeded. Cached `usedCredit` was not added to BusinessCreditAccount; the latest ledger balance is authoritative.
|
||||
|
||||
## Credit Purchase Integration
|
||||
An eligible pending wholesale Order is captured, inventory-consumed, paid, and invoiced in one transaction.
|
||||
|
||||
## Settlement Terms and Lifecycle
|
||||
Existing term days derive due dates. DRAFT, ISSUED, PARTIALLY_PAID, PAID, OVERDUE, and CANCELLED use explicit transitions.
|
||||
|
||||
## Business Statements, Invoice Integration, Payment Allocation
|
||||
Settlement items reference source Orders/Invoices/ledger entries. Existing Invoice and Payment are reused; allocation persistence prepares later bank matching.
|
||||
|
||||
## Wholesale Dashboard and Analytics
|
||||
Owner-scoped SQL aggregates provide monthly/lifetime volume, discount totals, credit utilization, next settlement, and recent Orders without loading all history.
|
||||
|
||||
## Security, Audit, Idempotency, Concurrency
|
||||
No IDs select another organization on business routes. Strict schemas reject balance/tier/client financial fields. Unique event keys plus account/settlement/reward locks protect retries and competing writes.
|
||||
|
||||
## Database Changes
|
||||
Forward-only `20260909090000` creates fourteen focused loyalty/wholesale tables. Earlier migrations are unchanged.
|
||||
|
||||
## Tests
|
||||
Policy tests cover integer money conversion, configured caps, referral entropy, strict inputs, terminal settlement state, and permission denial, alongside Phase 0–8 regression.
|
||||
|
||||
## Remaining Known Issues
|
||||
Refund-completion reversal wiring, coupon-entitlement enforcement in the Phase 6 quote path, payment allocation APIs, settlement document generation, notification fan-out, and broader integration/concurrency tests require hardening.
|
||||
|
||||
## Phase 10 Prerequisites
|
||||
Run legacy loyalty/credit/voucher/DOB/reference prechecks, migrate restored staging, seed rules/tiers/rewards/permissions, and test real concurrent redemptions, credit purchases, settlements, webhook rewards, refunds, birthday, and expiry jobs.
|
||||
|
||||
Module 16 AI Customer Support Chatbot is intentionally excluded and will be developed as a separate service.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Production Checklist
|
||||
|
||||
- [ ] Infrastructure sized, isolated, patched, TLS-enabled
|
||||
- [ ] Secrets generated, injected, access-limited, rotation tested
|
||||
- [ ] MySQL fresh/upgrade migrations verified on restored data
|
||||
- [ ] Backup and restore drill meets approved RPO/RTO
|
||||
- [ ] Redis auth/persistence/loss behavior validated
|
||||
- [ ] Private S3 upload, signed download, expiry, delete, versioning tested
|
||||
- [ ] SMTP security and non-customer test recipients verified
|
||||
- [ ] Stripe and PayHere sandbox flows/webhooks/refunds validated
|
||||
- [ ] Roles and all Phase 1–11 permissions seeded/reviewed
|
||||
- [ ] Authorization matrix and IDOR E2E suite approved
|
||||
- [ ] Dependency/image scan risk accepted; remaining majors planned
|
||||
- [ ] API, workers, and exactly one cron scheduler deployed
|
||||
- [ ] Queue retry/backlog/failure alerts tested
|
||||
- [ ] Health and protected metrics scraped/alerted
|
||||
- [ ] Nginx proxy trust, size, timeout, webhook, HTTPS behavior tested
|
||||
- [ ] Frontend/mobile uses `/api/v1` and handles standard errors
|
||||
- [ ] Full retail/business/payment/delivery/return/loyalty/support E2E passes
|
||||
- [ ] Concurrency/idempotency tests pass on real InnoDB/Redis
|
||||
- [ ] Non-destructive production smoke passes
|
||||
- [ ] Go-live owner, rollback authority and incident contacts assigned
|
||||
- [ ] Post-go-live reconciliation and monitoring window scheduled
|
||||
@@ -0,0 +1,18 @@
|
||||
# Production Runbook
|
||||
|
||||
## Deployment
|
||||
|
||||
Provision MySQL 8.4, authenticated Redis, private encrypted S3-compatible storage, SMTP, payment-provider credentials, TLS reverse proxy, API replicas, independent worker replicas, and exactly one cron scheduler. Inject secrets; never bake them into images.
|
||||
|
||||
1. Validate `.env` with `node server.js` in a sealed staging environment.
|
||||
2. Take database/object-storage backups and run legacy prechecks.
|
||||
3. Run `npx sequelize-cli db:migrate` against staging first; verify `SequelizeMeta`, constraints, indexes and counts.
|
||||
4. Seed required roles/permissions, document types, shipping/configuration, SLA/help data using approved idempotent procedures.
|
||||
5. Build the Node 22 image, scan it, deploy workers, API, and one `RUN_CRON=true` scheduler.
|
||||
6. Configure Nginx/TLS/proxy trust; verify `/health/live` and `/health/ready`.
|
||||
7. Run `npm run smoke`; run the staging-only commerce sequence with designated test identities/provider sandbox.
|
||||
8. Monitor 5xx rate, readiness, DB/Redis, queue failures/backlog, webhook failures, cron failures, latency, memory and disk/log pressure.
|
||||
|
||||
Commands: API `npm start`; worker `node app/workers/index.js`; syntax `npm run check:syntax`; tests `npm test -- --runInBand`; dependencies `npm ls --depth=0`; audit `npm audit`; smoke `npm run smoke`; OpenAPI `npm run validate:openapi`.
|
||||
|
||||
Incident basics: stop hazardous writers, preserve logs/request IDs/provider event IDs, assess customer impact, rotate exposed credentials, prefer forward fixes, reconcile payments/inventory/ledgers, and communicate from verified database/provider truth. Roll back application images only when schema compatibility is proven; restore data only through the approved recovery procedure.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Queue Operations
|
||||
|
||||
| Queue | Producer | Consumer | Idempotency/failure |
|
||||
|---|---|---|---|
|
||||
| activity | audit service | activity worker | event-based job ID; database activity record |
|
||||
| email | email service | email worker | event job ID; retry/backoff; delivery status |
|
||||
| document | document service | document worker | persisted document state; bounded concurrency |
|
||||
| log | structured log utility | log worker | redacted payload; retained operationally |
|
||||
|
||||
Workers run independently with `node app/workers/index.js`; the API does not consume jobs. Validate Redis authentication, queue prefixes, retries, retention, dead/final failures, and Bull Board access in staging. Alert on failed jobs and sustained backlog. Redis/BullMQ were **IMPLEMENTED_UNVERIFIED** in this environment.
|
||||
+129
-16
@@ -1,44 +1,157 @@
|
||||
# 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**
|
||||
|
||||
```
|
||||
```text
|
||||
POST: http://localhost:3070/api/user
|
||||
```
|
||||
|
||||
**Request Body customer**
|
||||
|
||||
accontType = customer and bussiness_customer
|
||||
|
||||
```json
|
||||
{
|
||||
"firstName": "Isuru",
|
||||
"lastName": "Bimsara",
|
||||
"email": "ibimsara00@gmail.com",
|
||||
"password": "Hello@12346"
|
||||
"accountType": "customer",
|
||||
|
||||
"address": "Colombo, Sri Lanka",
|
||||
"phoneNumber": "0771234567"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "User Created Successfully",
|
||||
"data": {
|
||||
"id": "usr_ei6i4n49",
|
||||
"firstName": "Isuru",
|
||||
"lastName": "Bimsara",
|
||||
"email": "ibimsara00@gmail.com",
|
||||
"accountType": "customer"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
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
|
||||
{
|
||||
"firstName": "Jhon",
|
||||
"lastName": "Doe",
|
||||
"email": "kalanajayasekara@niolla.lk",
|
||||
"role": "System Developer",
|
||||
"accountType": "admin",
|
||||
"department": "IT Department"
|
||||
"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 Created Successfully",
|
||||
"message": "User profile retrieved successfully",
|
||||
"data": {
|
||||
"id": "usr_763107d9-b56f-4b81-9d86-1889f98a6e6c",
|
||||
"firstName": "Jhon",
|
||||
"lastName": "Doe",
|
||||
"email": "kalanajayasekara@niolla.lk",
|
||||
"accountType": "admin",
|
||||
"role": "System Developer",
|
||||
"department": "IT Department"
|
||||
"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
|
||||
|
||||
**Endpoint**
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"openapi":"3.1.0","info":{"title":"ZUMRI Backend API","version":"1.0.0","description":"Phase 11 maintained baseline. Module 16 AI support is intentionally excluded."},"servers":[{"url":"/api/v1"}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT"},"cookieAuth":{"type":"apiKey","in":"cookie","name":"access_token"}},"schemas":{"Error":{"type":"object","required":["success","error"],"properties":{"success":{"const":false},"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"requestId":{"type":"string"}}}}}}},"paths":{"/products":{"get":{"summary":"List public products","responses":{"200":{"description":"Product list"}}}},"/help/categories":{"get":{"summary":"List published help categories","responses":{"200":{"description":"Help categories"}}}},"/auth/me":{"get":{"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"responses":{"200":{"description":"Current identity"},"401":{"description":"Unauthenticated"}}}},"/account/overview":{"get":{"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"responses":{"200":{"description":"Self-owned account overview"}}}},"/support/tickets":{"get":{"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"responses":{"200":{"description":"Self-owned tickets"}}},"post":{"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"responses":{"201":{"description":"Ticket created"}}}},"/admin/dashboard/overview":{"get":{"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"responses":{"200":{"description":"Requires analytics.dashboard.read"},"403":{"description":"Permission denied"}}}},"/admin/analytics/sales":{"get":{"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"from","in":"query","schema":{"type":"string","format":"date"}},{"name":"to","in":"query","schema":{"type":"string","format":"date"}}],"responses":{"200":{"description":"Requires analytics.sales.read"}}}},"/recommendations/trending":{"get":{"responses":{"200":{"description":"Deterministic trending products"}}}},"/admin/metrics":{"get":{"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"responses":{"200":{"description":"Prometheus text; requires system.metrics.read"}}}}}}
|
||||
@@ -1,99 +1,71 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Niolla
|
||||
* All rights reserved.
|
||||
*
|
||||
* This source code is proprietary and confidential.
|
||||
* Unauthorized copying, modification, distribution, or use
|
||||
* of this file, via any medium, is strictly prohibited.
|
||||
*/
|
||||
|
||||
// app.js
|
||||
|
||||
const express = require("express");
|
||||
const cors = require("cors");
|
||||
const helmet = require("helmet");
|
||||
const morgan = require("morgan");
|
||||
const routes = require("./app/routes");
|
||||
const cookieParser = require("cookie-parser");
|
||||
const { bullBoardRouter } = require("./app/config/bullBoard.config");
|
||||
const path = require("path");
|
||||
const startAllCrons = require("./cron");
|
||||
|
||||
const db = require("./app/models");
|
||||
|
||||
// Test DB connection and sync models
|
||||
(async () => {
|
||||
try {
|
||||
await db.sequelize.authenticate();
|
||||
console.log("Database connected.");
|
||||
|
||||
await db.sequelize.sync();
|
||||
console.log("Tables synced.");
|
||||
|
||||
// Start all cron jobs
|
||||
startAllCrons();
|
||||
|
||||
} catch (error) {
|
||||
console.error("DB error:", error);
|
||||
}
|
||||
})();
|
||||
const routes = require("./app/routes");
|
||||
const { healthRouter, live } = require("./app/routes/health.routes");
|
||||
const { bullBoardRouter } = require("./app/config/bullBoard.config");
|
||||
const { authenticate } = require("./app/middleware/auth.middleware");
|
||||
const { authorizedAccountType } = require("./app/middleware/permission.middleware");
|
||||
const requestId = require("./app/middleware/requestId.middleware");
|
||||
const { generalApiLimiter } = require("./app/middleware/rateLimit.middleware");
|
||||
const { notFound, errorHandler } = require("./app/middleware/error.middleware");
|
||||
const metrics = require("./app/services/metrics.service");
|
||||
|
||||
const app = express();
|
||||
const trustProxy = Number(process.env.TRUST_PROXY || 0);
|
||||
if (trustProxy > 0) app.set("trust proxy", trustProxy);
|
||||
|
||||
app.disable("x-powered-by");
|
||||
app.use(requestId);
|
||||
app.use(metrics.middleware);
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: false,
|
||||
hsts: process.env.NODE_ENV === "production" ? undefined : false,
|
||||
}));
|
||||
app.use(cookieParser());
|
||||
|
||||
// CORS configuration
|
||||
const corsOptions = {
|
||||
origin: process.env.FRONTEND_URL || "https://oceanic-demo.vercel.app",
|
||||
app.use(cors({
|
||||
origin: process.env.FRONTEND_URL,
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization"],
|
||||
allowedHeaders: ["Content-Type", "Authorization", "X-Request-ID"],
|
||||
exposedHeaders: ["X-Request-ID"],
|
||||
credentials: true,
|
||||
};
|
||||
app.use(cors(corsOptions));
|
||||
app.options(/.*/, cors(corsOptions));
|
||||
}));
|
||||
app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || "1mb" }));
|
||||
app.use(express.urlencoded({ extended: false, limit: process.env.JSON_BODY_LIMIT || "1mb" }));
|
||||
|
||||
app.use(express.json());
|
||||
app.use(morgan("dev"));
|
||||
morgan.token("request-id", (req) => req.id);
|
||||
app.use(morgan(process.env.NODE_ENV === "production" ? ':remote-addr - :method :url :status :response-time ms req-id=:request-id' : "dev"));
|
||||
|
||||
app.get("/health", (req, res) => {
|
||||
let dbStatus = "N/A";
|
||||
let emailStatus = "N/A";
|
||||
let redisStatus = "N/A";
|
||||
app.get("/health", live);
|
||||
app.use("/health", healthRouter);
|
||||
|
||||
db.sequelize
|
||||
.authenticate()
|
||||
.then(() => {
|
||||
console.log("DB connection successful.");
|
||||
dbStatus = "OK";
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("DB connection error:", err);
|
||||
res.status(500).send("Internal Server Error");
|
||||
});
|
||||
// Keep the legacy path while all new clients migrate to the versioned path.
|
||||
app.use("/api", generalApiLimiter, routes);
|
||||
app.use("/api/v1", generalApiLimiter, routes);
|
||||
|
||||
emailStatus = "OK";
|
||||
|
||||
redisStatus = "OK";
|
||||
|
||||
res.send({
|
||||
status: "Online ✅",
|
||||
database: dbStatus,
|
||||
emailService: emailStatus,
|
||||
redis: redisStatus,
|
||||
});
|
||||
});
|
||||
|
||||
app.use("/api", routes);
|
||||
app.use(
|
||||
"/Documentation",
|
||||
(req, res, next) => {
|
||||
if (req.path.endsWith(".md")) {
|
||||
return res.status(403).send("Forbidden");
|
||||
}
|
||||
|
||||
next();
|
||||
},
|
||||
(req, res, next) => req.path.endsWith(".md") ? res.status(403).send("Forbidden") : next(),
|
||||
express.static(path.join(__dirname, "Documentation")),
|
||||
);
|
||||
app.use("/admin/queues", bullBoardRouter);
|
||||
|
||||
app.use(
|
||||
"/admin/queues",
|
||||
authenticate,
|
||||
authorizedAccountType(["admin", "superadmin"]),
|
||||
bullBoardRouter,
|
||||
);
|
||||
|
||||
app.use(notFound);
|
||||
app.use(errorHandler);
|
||||
|
||||
module.exports = app;
|
||||
|
||||
@@ -14,13 +14,16 @@ const { ExpressAdapter } = require("@bull-board/express");
|
||||
const { BullMQAdapter } = require("@bull-board/api/bullMQAdapter");
|
||||
|
||||
const activityQueue = require("../queues/activity.queue");
|
||||
const documentQueue = require("../queues/document.queue");
|
||||
const logQueue = require("../queues/log.queue");
|
||||
const emailQueue = require("../queues/email.queue");
|
||||
|
||||
const serverAdapter = new ExpressAdapter();
|
||||
serverAdapter.setBasePath("/admin/queues");
|
||||
|
||||
const { addQueue, removeQueue, setQueues, replaceQueues } =
|
||||
createBullBoard({
|
||||
queues: [new BullMQAdapter(activityQueue)],
|
||||
queues: [activityQueue, documentQueue, logQueue, emailQueue].map((queue) => new BullMQAdapter(queue)),
|
||||
serverAdapter,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -12,10 +12,10 @@
|
||||
require("dotenv").config();
|
||||
|
||||
module.exports = {
|
||||
HOST: process.env.DB_HOST || "localhost",
|
||||
USER: process.env.DB_USER || "root",
|
||||
PASSWORD: process.env.DB_PASSWORD || "",
|
||||
DB: process.env.DB_NAME || "oceanic-db",
|
||||
HOST: process.env.DB_HOST,
|
||||
USER: process.env.DB_USER,
|
||||
PASSWORD: process.env.DB_PASSWORD,
|
||||
DB: process.env.DB_NAME,
|
||||
PORT: process.env.DB_PORT || 3306,
|
||||
DIALECT: "mysql",
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const booleanString = z.enum(["true", "false"]).default("false").transform((value) => value === "true");
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||
PORT: z.coerce.number().int().min(1).max(65535).default(3070),
|
||||
DB_HOST: z.string().min(1),
|
||||
DB_PORT: z.coerce.number().int().min(1).max(65535).default(3306),
|
||||
DB_NAME: z.string().min(1),
|
||||
DB_USER: z.string().min(1),
|
||||
DB_PASSWORD: z.string(),
|
||||
JWT_SECRET: z.string().min(32, "JWT_SECRET must contain at least 32 characters"),
|
||||
JWT_ISSUER: z.string().min(1).default("zumri-api"),
|
||||
JWT_AUDIENCE: z.string().min(1).default("zumri-clients"),
|
||||
ACCESS_TOKEN_TTL: z.string().default("15m"),
|
||||
REFRESH_TOKEN_TTL_DAYS: z.coerce.number().int().positive().default(7),
|
||||
REMEMBER_ME_REFRESH_TOKEN_TTL_DAYS: z.coerce.number().int().positive().default(30),
|
||||
LOGIN_OTP_TTL_SECONDS: z.coerce.number().int().positive().default(900),
|
||||
LOGIN_OTP_MAX_ATTEMPTS: z.coerce.number().int().min(1).max(10).default(5),
|
||||
REDIS_HOST: z.string().min(1),
|
||||
REDIS_PORT: z.coerce.number().int().min(1).max(65535).default(6379),
|
||||
REDIS_PASSWORD: z.string().optional(),
|
||||
FRONTEND_URL: z.string().url(),
|
||||
TRUST_PROXY: z.coerce.number().int().min(0).max(10).default(0),
|
||||
JSON_BODY_LIMIT: z.string().default("1mb"),
|
||||
API_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(900000),
|
||||
API_RATE_LIMIT_MAX: z.coerce.number().int().positive().default(300),
|
||||
SENSITIVE_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(900000),
|
||||
SENSITIVE_RATE_LIMIT_MAX: z.coerce.number().int().positive().default(20),
|
||||
RUN_CRON: booleanString,
|
||||
ENABLE_MAIL: booleanString,
|
||||
ENABLE_S3: booleanString,
|
||||
SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().positive().default(10000),
|
||||
CACHE: booleanString,
|
||||
MAIL_HOST: z.string().min(1).optional(), MAIL_PORT: z.coerce.number().int().positive().optional(),
|
||||
MAIL_SECURE: z.enum(["true", "false"]).optional(), MAIL_USER: z.string().optional(),
|
||||
MAIL_PASS: z.string().optional(), MAIL_FROM: z.string().optional(),
|
||||
AWS_REGION: z.string().optional(), AWS_ACCESS_KEY_ID: z.string().optional(),
|
||||
AWS_SECRET_ACCESS_KEY: z.string().optional(), AWS_S3_BUCKET_NAME: z.string().optional(),
|
||||
S3_ENDPOINT: z.string().url().optional(), S3_FORCE_PATH_STYLE: booleanString,
|
||||
S3_SIGNED_URL_TTL_SECONDS: z.coerce.number().int().min(60).max(86400).default(900),
|
||||
S3_MAX_UPLOAD_BYTES: z.coerce.number().int().positive().default(5242880),
|
||||
EMAIL_QUEUE_CONCURRENCY: z.coerce.number().int().positive().default(5),
|
||||
DOCUMENT_QUEUE_CONCURRENCY: z.coerce.number().int().positive().default(2),
|
||||
NOTIFICATION_RETENTION_DAYS: z.coerce.number().int().positive().default(90),
|
||||
INVENTORY_RESERVATION_TTL_MINUTES: z.coerce.number().int().positive().default(15),
|
||||
CHECKOUT_TTL_MINUTES: z.coerce.number().int().positive().default(15),
|
||||
CART_MAX_ITEM_QUANTITY: z.coerce.number().int().positive().default(100),
|
||||
RETURN_WINDOW_DAYS: z.coerce.number().int().positive().default(30),
|
||||
PAYHERE_MERCHANT_ID: z.string().optional(), PAYHERE_MERCHANT_SECRET: z.string().optional(),
|
||||
PAYHERE_NOTIFY_URL: z.string().url().optional(), PAYHERE_RETURN_URL: z.string().url().optional(), PAYHERE_CANCEL_URL: z.string().url().optional(),
|
||||
LOYALTY_RECONCILIATION_BATCH_SIZE: z.coerce.number().int().positive().max(1000).default(100),
|
||||
SUPPORT_SLA_RECONCILIATION_BATCH_SIZE: z.coerce.number().int().positive().max(1000).default(100),
|
||||
RECOMMENDATION_EVENT_RETENTION_DAYS: z.coerce.number().int().positive().default(90),
|
||||
LOG_RETENTION_DAYS: z.coerce.number().int().positive().default(30),
|
||||
DOCS_USER: z.string().optional(), DOCS_PASS: z.string().optional(),
|
||||
GOOGLE_CLIENT_ID: z.string().optional(), APPLE_CLIENT_ID: z.string().optional(),
|
||||
}).superRefine((env, context) => {
|
||||
const requireFeature = (enabled, names) => {
|
||||
if (!enabled) return;
|
||||
for (const name of names) {
|
||||
if (!env[name]) context.addIssue({ code: "custom", path: [name], message: `${name} is required when enabled` });
|
||||
}
|
||||
};
|
||||
requireFeature(env.ENABLE_MAIL, ["MAIL_HOST", "MAIL_PORT", "MAIL_USER", "MAIL_PASS", "MAIL_FROM"]);
|
||||
requireFeature(env.ENABLE_S3, ["AWS_REGION", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_S3_BUCKET_NAME"]);
|
||||
});
|
||||
|
||||
let validatedEnv;
|
||||
const validateEnvironment = (source = process.env) => {
|
||||
const result = envSchema.safeParse(source);
|
||||
if (!result.success) {
|
||||
const names = [...new Set(result.error.issues.map((issue) => issue.path.join(".") || "environment"))];
|
||||
throw new Error(`Invalid environment configuration: ${names.join(", ")}`);
|
||||
}
|
||||
validatedEnv = result.data;
|
||||
return validatedEnv;
|
||||
};
|
||||
const getEnvironment = () => validatedEnv || validateEnvironment();
|
||||
const getOptionalFeatureStatus = (env = process.env) => ({
|
||||
mail: Boolean(env.MAIL_HOST && env.MAIL_PORT && env.MAIL_USER && env.MAIL_PASS && env.MAIL_FROM),
|
||||
s3: Boolean(env.AWS_REGION && env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY && env.AWS_S3_BUCKET_NAME),
|
||||
docs: Boolean(env.DOCS_USER && env.DOCS_PASS),
|
||||
});
|
||||
|
||||
module.exports = { validateEnvironment, getEnvironment, getOptionalFeatureStatus };
|
||||
@@ -20,5 +20,5 @@ module.exports = {
|
||||
user: process.env.MAIL_USER,
|
||||
pass: process.env.MAIL_PASS,
|
||||
},
|
||||
from: `Oceanic Maritime Solutions <${process.env.MAIL_FROM}>`,
|
||||
from: `ZUMRI <${process.env.MAIL_FROM}>`,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
@@ -11,13 +11,14 @@
|
||||
|
||||
const { Redis } = require("ioredis");
|
||||
|
||||
const createRedisConnection = () => {
|
||||
const createRedisConnection = (options = {}) => {
|
||||
const redis = new Redis({
|
||||
host: process.env.REDIS_HOST || "redis",
|
||||
port: process.env.REDIS_PORT || 6379,
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
maxRetriesPerRequest: null,
|
||||
enableReadyCheck: false,
|
||||
lazyConnect: options.lazyConnect ?? true,
|
||||
});
|
||||
|
||||
// Connection events
|
||||
|
||||
@@ -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 };
|
||||
@@ -12,6 +12,6 @@
|
||||
|
||||
const createRedisConnection = require("./redis.config");
|
||||
|
||||
const redis = createRedisConnection();
|
||||
const redis = createRedisConnection({ lazyConnect: true });
|
||||
|
||||
module.exports = redis;
|
||||
+6
-15
@@ -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 s3 = new S3Client({
|
||||
module.exports = process.env.ENABLE_S3 === "true"
|
||||
? new S3Client({
|
||||
region: process.env.AWS_REGION,
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
module.exports = s3;
|
||||
...(process.env.S3_ENDPOINT ? { endpoint: process.env.S3_ENDPOINT } : {}),
|
||||
forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true",
|
||||
})
|
||||
: { send: async () => { throw new Error("S3 functionality is not enabled"); } };
|
||||
|
||||
@@ -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 };
|
||||
@@ -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) };
|
||||
@@ -0,0 +1 @@
|
||||
const SUPPORTED_LOCALES=Object.freeze(["en","si","ta"]);const DEFAULT_LOCALE="en";module.exports={SUPPORTED_LOCALES,DEFAULT_LOCALE};
|
||||
@@ -13,4 +13,22 @@
|
||||
module.exports = {
|
||||
FINANCE_BASIS: "finance.basis",
|
||||
PRECOST: "precost.precost",
|
||||
MEDIA_READ: "media.read", MEDIA_UPLOAD: "media.upload", MEDIA_DELETE: "media.delete",
|
||||
DOCUMENTS_READ: "documents.read", DOCUMENTS_CREATE: "documents.create", DOCUMENTS_DELETE: "documents.delete",
|
||||
NOTIFICATIONS_MANAGE: "notifications.manage", NOTIFICATIONS_READ: "notifications.read",
|
||||
AUDIT_READ: "audit.read", QUEUES_READ: "queues.read",
|
||||
BUSINESS_APPLICATIONS_READ: "business.applications.read", BUSINESS_APPLICATIONS_REVIEW: "business.applications.review",
|
||||
BUSINESS_ACCOUNTS_READ: "business.accounts.read", BUSINESS_ACCOUNTS_UPDATE: "business.accounts.update",
|
||||
BUSINESS_CREDIT_MANAGE: "business.credit.manage", BUSINESS_SETTLEMENT_MANAGE: "business.settlement.manage",
|
||||
CATALOGUE_PRODUCTS_READ:"catalogue.products.read",CATALOGUE_PRODUCTS_CREATE:"catalogue.products.create",CATALOGUE_PRODUCTS_UPDATE:"catalogue.products.update",CATALOGUE_PRODUCTS_DELETE:"catalogue.products.delete",
|
||||
CATALOGUE_CATEGORIES_MANAGE:"catalogue.categories.manage",CATALOGUE_BRANDS_MANAGE:"catalogue.brands.manage",CATALOGUE_COLLECTIONS_MANAGE:"catalogue.collections.manage",CATALOGUE_SIZE_GUIDES_MANAGE:"catalogue.size-guides.manage",CATALOGUE_REVIEWS_READ:"catalogue.reviews.read",CATALOGUE_REVIEWS_MODERATE:"catalogue.reviews.moderate",
|
||||
INVENTORY_READ:"inventory.read",INVENTORY_ADJUST:"inventory.adjust",INVENTORY_TRANSFER:"inventory.transfer",INVENTORY_RESERVATIONS_READ:"inventory.reservations.read",INVENTORY_WAREHOUSES_MANAGE:"inventory.warehouses.manage",
|
||||
BUSINESS_PRICING_READ:"pricing.business.read",BUSINESS_PRICING_MANAGE:"pricing.business.manage",PROMOTIONS_READ:"promotions.read",PROMOTIONS_MANAGE:"promotions.manage",BANNERS_MANAGE:"merchandising.banners.manage",
|
||||
SHIPPING_ZONES_READ:"shipping.zones.read",SHIPPING_ZONES_MANAGE:"shipping.zones.manage",SHIPPING_METHODS_READ:"shipping.methods.read",SHIPPING_METHODS_MANAGE:"shipping.methods.manage",SHIPPING_RATES_READ:"shipping.rates.read",SHIPPING_RATES_MANAGE:"shipping.rates.manage",
|
||||
ORDERS_READ:"orders.read",ORDERS_MANAGE:"orders.manage",ORDERS_CANCEL:"orders.cancel",PAYMENTS_READ:"payments.read",PAYMENTS_MANAGE:"payments.manage",PAYMENTS_REFUND:"payments.refund",INVOICES_READ:"invoices.read",RETURNS_READ:"returns.read",RETURNS_MANAGE:"returns.manage",
|
||||
SHIPMENTS_READ:"shipments.read",SHIPMENTS_CREATE:"shipments.create",SHIPMENTS_MANAGE:"shipments.manage",SHIPMENTS_ASSIGN:"shipments.assign",RIDERS_READ:"riders.read",RIDERS_MANAGE:"riders.manage",DISPATCH_READ:"dispatch.read",DISPATCH_MANAGE:"dispatch.manage",DELIVERY_PROOF_READ:"delivery.proof.read",RETURNS_LOGISTICS_READ:"returns.logistics.read",RETURNS_LOGISTICS_MANAGE:"returns.logistics.manage",
|
||||
LOYALTY_ACCOUNTS_READ:"loyalty.accounts.read",LOYALTY_POINTS_ADJUST:"loyalty.points.adjust",LOYALTY_MANAGE:"loyalty.manage",LOYALTY_REFERRALS_READ:"loyalty.referrals.read",WHOLESALE_CREDIT_READ:"wholesale.credit.read",WHOLESALE_CREDIT_MANAGE:"wholesale.credit.manage",WHOLESALE_SETTLEMENTS_READ:"wholesale.settlements.read",WHOLESALE_SETTLEMENTS_MANAGE:"wholesale.settlements.manage",WHOLESALE_ANALYTICS_READ:"wholesale.analytics.read",
|
||||
SUPPORT_TICKETS_READ:"support.tickets.read",SUPPORT_TICKETS_ASSIGN:"support.tickets.assign",SUPPORT_TICKETS_REPLY:"support.tickets.reply",SUPPORT_TICKETS_STATUS:"support.tickets.status",SUPPORT_TICKETS_PRIORITY:"support.tickets.priority",SUPPORT_TICKETS_INTERNAL_NOTES:"support.tickets.internal_notes",SUPPORT_TICKETS_ESCALATE:"support.tickets.escalate",SUPPORT_CATEGORIES_MANAGE:"support.categories.manage",SUPPORT_SLA_MANAGE:"support.sla.manage",HELP_READ:"help.read",HELP_MANAGE:"help.manage",HELP_PUBLISH:"help.publish",NEWSLETTER_SUBSCRIBERS_READ:"newsletter.subscribers.read",NEWSLETTER_SUBSCRIBERS_EXPORT:"newsletter.subscribers.export",NEWSLETTER_SUBSCRIBERS_MANAGE:"newsletter.subscribers.manage",RECOMMENDATIONS_READ:"recommendations.read",RECOMMENDATIONS_MANAGE:"recommendations.manage",
|
||||
ANALYTICS_DASHBOARD_READ:"analytics.dashboard.read",ANALYTICS_SALES_READ:"analytics.sales.read",ANALYTICS_ORDERS_READ:"analytics.orders.read",ANALYTICS_CUSTOMERS_READ:"analytics.customers.read",ANALYTICS_PRODUCTS_READ:"analytics.products.read",ANALYTICS_INVENTORY_READ:"analytics.inventory.read",ANALYTICS_PAYMENTS_READ:"analytics.payments.read",ANALYTICS_DELIVERY_READ:"analytics.delivery.read",ANALYTICS_LOYALTY_READ:"analytics.loyalty.read",ANALYTICS_WHOLESALE_READ:"analytics.wholesale.read",ANALYTICS_SUPPORT_READ:"analytics.support.read",ANALYTICS_RECOMMENDATIONS_READ:"analytics.recommendations.read",
|
||||
SYSTEM_METRICS_READ:"system.metrics.read",
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
const db=require("../models");exports.overview=async(req,res,next)=>{try{const userId=req.user.id,[recentOrders,orderCount,wishlistCount,defaultAddress,loyalty,openTickets,vouchers]=await Promise.all([db.Order.findAll({where:{user_id:userId},attributes:["id","order_number","status","payment_status","grand_total","currency","placed_at"],order:[["placed_at","DESC"]],limit:5}),db.Order.count({where:{user_id:userId}}),db.WishlistItem.count({where:{user_id:userId}}),db.Address.findOne({where:{user_id:userId,is_default:true},attributes:{exclude:["user_id"]}}),db.LoyaltyAccount.findOne({where:{user_id:userId},attributes:["available_points","current_tier_id","lifetime_points_earned"]}),db.SupportTicket.count({where:{customer_user_id:userId,status:["OPEN","ASSIGNED","WAITING_FOR_CUSTOMER","WAITING_FOR_SUPPORT"]}}),db.CustomerCouponEntitlement.count({where:{user_id:userId,status:"ACTIVE"}})]);res.json({success:true,data:{recentOrders,orderCount,wishlistCount,defaultAddress,loyalty:loyalty&&{availablePoints:loyalty.available_points,lifetimePointsEarned:loyalty.lifetime_points_earned,tierId:loyalty.current_tier_id},availableVoucherCount:vouchers,openSupportTicketCount:openTickets}});}catch(e){next(e);}};
|
||||
@@ -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);}};
|
||||
@@ -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");
|
||||
@@ -0,0 +1 @@
|
||||
const a=require("../services/analytics/analytics.service");const run=fn=>async(req,res,next)=>{try{res.json({success:true,data:await fn(req.query)});}catch(e){next(e);}};exports.overview=run(a.overview);exports.sales=run(a.sales);exports.orders=run(a.orders);exports.customers=run(a.customers);exports.products=run(a.products);exports.inventory=run(a.inventory);exports.payments=run(a.payments);exports.delivery=run(a.delivery);exports.loyalty=run(a.loyalty);exports.support=run(a.support);exports.recommendations=run(a.recommendations);
|
||||
+179
-119
@@ -1,132 +1,192 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Niolla
|
||||
* All rights reserved.
|
||||
*
|
||||
* This source code is proprietary and confidential.
|
||||
* Unauthorized copying, modification, distribution, or use
|
||||
* of this file, via any medium, is strictly prohibited.
|
||||
*/
|
||||
const db = require("../models");
|
||||
const authService = require("../services/auth/auth.service");
|
||||
const sessionService = require("../services/auth/session.service");
|
||||
const { hashPassword, checkPassword } = require("../utils/hashPassword.util");
|
||||
const { createPasswordReset, consumePasswordResetToken, sendPasswordResetEmail } = require("../utils/passwordReset.utill");
|
||||
const { createEmailVerification, consumeEmailVerificationToken, sendVerificationEmail } = require("../utils/emailVerification.util");
|
||||
const { sendPasswordChanged } = require("../services/auth/email.service");
|
||||
const { logActivity } = require("../services/activity.service");
|
||||
const { verifyToken } = require("../utils/jwt.util");
|
||||
|
||||
// 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");
|
||||
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) => {
|
||||
exports.login = async (req, res, next) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
const result = await authService.beginPasswordLogin(req.validated.body, req);
|
||||
res.status(202).json({ success: true, data: result, message: "If the credentials are valid, a verification code has been sent" });
|
||||
} catch (error) { next(error); }
|
||||
};
|
||||
exports.loginReq = exports.login;
|
||||
|
||||
const user = await getCachedUser(email);
|
||||
if (!user) {
|
||||
return res
|
||||
.status(404)
|
||||
.send({ success: false, message: "User Not Found" });
|
||||
}
|
||||
exports.verifyOtp = async (req, res, next) => {
|
||||
try {
|
||||
const input = req.validated.body;
|
||||
const result = await authService.completeOtpLogin(input, req);
|
||||
const tokens = deliverTokens(req, res, result, input.clientType);
|
||||
await logActivity({ user: result.user, description: "Authentication session created", type: "LOGIN_SUCCEEDED", module: "Authentication" });
|
||||
res.json({ success: true, data: { user: projectUser(result.user), ...tokens } });
|
||||
} catch (error) { next(error); }
|
||||
};
|
||||
|
||||
const passwordIsValid = await checkPassword(password, user.password);
|
||||
if (!passwordIsValid) {
|
||||
return res
|
||||
.status(401)
|
||||
.send({ success: false, message: "Invalid Password" });
|
||||
}
|
||||
|
||||
const otp = generateOTP(email);
|
||||
|
||||
await sendMail({
|
||||
to: email,
|
||||
subject: `OTP for Your ${appName} Account`,
|
||||
templateName: "otp",
|
||||
templateVars: {
|
||||
firstName: user.firstName,
|
||||
otp: otp,
|
||||
},
|
||||
text: `Hello ${user.firstName}, your otp is ${otp}`,
|
||||
});
|
||||
|
||||
log(`OTP for ${email}: ${otp}`);
|
||||
log(`OTP sent to ${email} successfully.`);
|
||||
|
||||
res.status(201).send({ success: true, message: "OTP Sent Successfully" });
|
||||
exports.refreshToken = async (req, res, next) => {
|
||||
try {
|
||||
const input = req.validated.body;
|
||||
const token = input.refreshToken || req.cookies?.refresh_token;
|
||||
if (!token) throw Object.assign(new Error("Invalid session"), { status: 401, code: "INVALID_SESSION" });
|
||||
const result = await authService.refresh(token, req);
|
||||
res.json({ success: true, data: deliverTokens(req, res, result, input.clientType) });
|
||||
} catch (error) {
|
||||
log("Error occurred while sending OTP:", error);
|
||||
res.status(500).send({ success: false, message: error.message });
|
||||
clearCookies(res);
|
||||
if (error.code === "REFRESH_TOKEN_REUSE") console.warn(`[${req.id}] Refresh token replay detected; token family revoked`);
|
||||
next(Object.assign(new Error("Invalid session"), { status: 401, code: "INVALID_SESSION" }));
|
||||
}
|
||||
};
|
||||
|
||||
// Login Step 2: Verify OTP and issue JWT
|
||||
exports.login = async (req, res) => {
|
||||
exports.logout = async (req, res, next) => {
|
||||
try {
|
||||
const { email, otp } = req.body;
|
||||
|
||||
const user = await getCachedUser(email);
|
||||
|
||||
if (!user) {
|
||||
return res
|
||||
.status(404)
|
||||
.send({ success: false, message: "User Not Found" });
|
||||
const token = req.body?.refreshToken || req.cookies?.refresh_token;
|
||||
let id = sessionService.tokenId(token);
|
||||
if (!id) {
|
||||
const accessToken = req.cookies?.access_token || (req.headers.authorization?.startsWith("Bearer ") ? req.headers.authorization.slice(7) : null);
|
||||
try { id = accessToken ? verifyToken(accessToken).sid : null; } catch (_error) { id = null; }
|
||||
}
|
||||
|
||||
const isValidOTP = validateOTP(email, otp);
|
||||
|
||||
if (!isValidOTP) {
|
||||
return res
|
||||
.status(401)
|
||||
.send({ success: false, message: "Invalid or Expired OTP" });
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
const token = generateToken({
|
||||
id: user.id,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
email: user.email,
|
||||
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) {
|
||||
log("Error occurred during login:", error);
|
||||
res.status(500).send({ success: false, message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// Logout: Clear the JWT cookie
|
||||
exports.logout = (req, res) => {
|
||||
res.clearCookie("access_token", {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax",
|
||||
});
|
||||
|
||||
if (id) await sessionService.revokeSession(id, "LOGOUT");
|
||||
clearCookies(res);
|
||||
res.json({ success: true, message: "Logged out successfully" });
|
||||
} catch (error) { next(error); }
|
||||
};
|
||||
|
||||
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); }
|
||||
};
|
||||
|
||||
exports.me = async (req, res, next) => {
|
||||
try {
|
||||
const user = await db.User.findByPk(req.user.id, { attributes: { exclude: ["password", "tokenVersion", "passwordChangedAt"] }, include: [{ model: db.Profile, as: "profile" }] });
|
||||
res.json({ success: true, data: { ...projectUser(user), profile: user.profile, effectivePermissions: req.user.permissions || [] } });
|
||||
} catch (error) { next(error); }
|
||||
};
|
||||
|
||||
exports.forgotPassword = async (req, res, next) => {
|
||||
const message = "If an account exists for this email, a password reset link has been sent";
|
||||
try {
|
||||
const user = await db.User.findOne({ where: { email: req.validated.body.email } });
|
||||
if (user) {
|
||||
const token = await createPasswordReset(user.id);
|
||||
await sendPasswordResetEmail(user.email, user.firstName, token);
|
||||
}
|
||||
res.json({ success: true, message });
|
||||
} catch (error) {
|
||||
console.error(`[${req.id}] Password reset request failed`, { name: error.name, message: error.message });
|
||||
res.json({ success: true, message });
|
||||
}
|
||||
};
|
||||
|
||||
exports.resetPassword = async (req, res, next) => {
|
||||
try {
|
||||
const input = req.validated.body;
|
||||
const userId = await consumePasswordResetToken(input.token);
|
||||
if (!userId) throw Object.assign(new Error("Reset token is invalid or expired"), { status: 400, code: "INVALID_RESET_TOKEN" });
|
||||
let changedUser;
|
||||
await db.sequelize.transaction(async (transaction) => {
|
||||
const user = await db.User.findByPk(userId, { transaction, lock: transaction.LOCK.UPDATE });
|
||||
if (!user || (user.password && await checkPassword(input.newPassword, user.password))) throw Object.assign(new Error("Invalid password change"), { status: 400, code: "INVALID_PASSWORD_CHANGE" });
|
||||
user.password = await hashPassword(input.newPassword); user.passwordChangedAt = new Date(); user.tokenVersion += 1;
|
||||
await user.save({ transaction }); await sessionService.revokeAllUserSessions(user.id, "PASSWORD_RESET", transaction); changedUser = user;
|
||||
});
|
||||
sendPasswordChanged(changedUser).catch(() => {});
|
||||
await logActivity({ user: changedUser, description: "Password reset and sessions revoked", type: "PASSWORD_RESET", module: "Authentication" });
|
||||
res.json({ success: true, message: "Password reset successfully. Please login again" });
|
||||
} catch (error) { next(error); }
|
||||
};
|
||||
|
||||
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); }
|
||||
};
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
const crypto=require("crypto"); const {Op}=require("sequelize"); const db=require("../models"); const service=require("../services/business/business.service"); const {logActivity}=require("../services/activity.service"); const notifications=require("../services/notification/notification.service");
|
||||
const safeApp=a=>({id:a.id,businessName:a.business_name,legalName:a.legal_name,registrationNumber:a.registration_number,taxNumber:a.tax_number,businessType:a.business_type,contactEmail:a.contact_email,contactPhone:a.contact_phone,website:a.website,status:a.status,reviewedAt:a.reviewed_at,rejectionReason:a.rejection_reason,createdAt:a.createdAt});
|
||||
const safeProfile=p=>({id:p.business_customer_id,businessName:p.businessName,legalName:p.legalName,registrationNumber:p.businessRegistrationNumber,taxNumber:p.taxNumber,businessType:p.businessType,contactEmail:p.businessEmail,contactPhone:p.phoneNumber,website:p.website,partnerId:p.partnerId,status:p.status,approvedAt:p.approvedAt,settlementTermId:p.settlement_term_id});
|
||||
exports.apply=async(req,res,next)=>{try{const app=await service.apply(req.user.id,req.body);await logActivity({user:req.user,type:"BUSINESS_APPLICATION_SUBMITTED",module:"Business",description:"Business application submitted",targetType:"BUSINESS_APPLICATION",targetId:app.id,requestId:req.id});await notifications.publish({type:"BUSINESS_APPLICATION_SUBMITTED",user:req.user,headline:"Business application received",description:"Your application is awaiting review.",templateKey:"businessApplicationReceived",variables:{firstName:req.user.firstName,businessName:app.business_name,applicationId:app.id},correlationId:req.id}).catch(()=>undefined);return res.status(201).json({success:true,data:safeApp(app)});}catch(e){return next(e);}};
|
||||
exports.myApplications=async(req,res,next)=>{try{return res.json({success:true,data:(await db.BusinessApplication.findAll({where:{user_id:req.user.id},order:[["createdAt","DESC"]]})).map(safeApp)});}catch(e){return next(e);}};
|
||||
exports.getApplication=async(req,res,next)=>{try{const a=await db.BusinessApplication.findOne({where:{id:req.params.id,user_id:req.user.id}});if(!a)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Application not found"}});return res.json({success:true,data:safeApp(a)});}catch(e){return next(e);}};
|
||||
exports.getMe=async(req,res,next)=>{try{const p=await db.BusinessCustomer.findOne({where:{user_id:req.user.id},include:[{model:db.BusinessContact,as:"contacts"},{model:db.Address,as:"addresses"},{model:db.BusinessCreditAccount,as:"creditAccount",attributes:["currency","credit_limit","status"]},{model:db.SettlementTerm,as:"settlementTerm"}]});if(!p)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Business profile not found"}});return res.json({success:true,data:{...safeProfile(p),contacts:p.contacts,addresses:p.addresses,creditAccount:p.creditAccount,settlementTerm:p.settlementTerm}});}catch(e){return next(e);}};
|
||||
exports.updateMe=async(req,res,next)=>{try{const p=await db.BusinessCustomer.findOne({where:{user_id:req.user.id}});if(!p)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Business profile not found"}});const b=req.body;await p.update({...(b.businessName!==undefined&&{businessName:b.businessName}),...(b.legalName!==undefined&&{legalName:b.legalName}),...(b.taxNumber!==undefined&&{taxNumber:b.taxNumber}),...(b.businessType!==undefined&&{businessType:b.businessType}),...(b.contactEmail!==undefined&&{businessEmail:b.contactEmail}),...(b.contactPhone!==undefined&&{phoneNumber:b.contactPhone}),...(b.website!==undefined&&{website:b.website})});await logActivity({user:req.user,type:"BUSINESS_PROFILE_UPDATED",module:"Business",description:"Business profile updated",targetType:"BUSINESS_PROFILE",targetId:p.business_customer_id,requestId:req.id});return res.json({success:true,data:safeProfile(p)});}catch(e){return next(e);}};
|
||||
exports.addContact=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{const p=await db.BusinessCustomer.findOne({where:{user_id:req.user.id},transaction:t,lock:t.LOCK.UPDATE});if(!p)throw Object.assign(new Error("Business profile not found"),{status:404,code:"NOT_FOUND"});if(req.body.isPrimary)await db.BusinessContact.update({is_primary:false},{where:{business_profile_id:p.business_customer_id},transaction:t});row=await db.BusinessContact.create({id:crypto.randomUUID(),business_profile_id:p.business_customer_id,name:req.body.name,title:req.body.title,email:req.body.email,phone:req.body.phone,is_primary:Boolean(req.body.isPrimary)},{transaction:t});});return res.status(201).json({success:true,data:row});}catch(e){return next(e);}};
|
||||
exports.addAddress=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{const p=await db.BusinessCustomer.findOne({where:{user_id:req.user.id},transaction:t,lock:t.LOCK.UPDATE});if(!p)throw Object.assign(new Error("Business profile not found"),{status:404,code:"NOT_FOUND"});const b=req.body;if(b.isDefaultShipping)await db.Address.update({is_default_shipping:false},{where:{business_profile_id:p.business_customer_id},transaction:t});if(b.isDefaultBilling)await db.Address.update({is_default_billing:false},{where:{business_profile_id:p.business_customer_id},transaction:t});row=await db.Address.create({id:crypto.randomUUID(),business_profile_id:p.business_customer_id,label:b.label,recipient_name:b.recipientName,phone_number:b.phoneNumber,address_line_1:b.addressLine1,address_line_2:b.addressLine2,city:b.city,district:b.district,province:b.province,postal_code:b.postalCode,country_code:b.countryCode,address_type:"DELIVERY",is_default_shipping:Boolean(b.isDefaultShipping),is_default_billing:Boolean(b.isDefaultBilling)},{transaction:t});});return res.status(201).json({success:true,data:row});}catch(e){return next(e);}};
|
||||
const paging=q=>({limit:Math.min(Number(q.limit)||20,100),offset:(Math.max(Number(q.page)||1,1)-1)*Math.min(Number(q.limit)||20,100)});
|
||||
exports.adminListApplications=async(req,res,next)=>{try{const where={};if(req.query.status)where.status=req.query.status;if(req.query.businessName)where.business_name={[Op.like]:`%${req.query.businessName.slice(0,100)}%`};const {limit,offset}=paging(req.query);const result=await db.BusinessApplication.findAndCountAll({where,limit,offset,order:[[req.query.sort==="status"?"status":"createdAt",req.query.direction==="asc"?"ASC":"DESC"]]});return res.json({success:true,data:result.rows.map(safeApp),pagination:{total:result.count,limit,offset}});}catch(e){return next(e);}};
|
||||
exports.adminGetApplication=async(req,res,next)=>{try{const a=await db.BusinessApplication.findByPk(req.params.id);if(!a)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Application not found"}});return res.json({success:true,data:safeApp(a)});}catch(e){return next(e);}};
|
||||
exports.approve=async(req,res,next)=>{try{const {application,profile}=await service.approve(req.params.id,req.user.id);const applicant=await db.User.findByPk(application.user_id);await logActivity({user:req.user,type:"BUSINESS_APPLICATION_APPROVED",module:"Business",description:"Business application approved",targetType:"BUSINESS_APPLICATION",targetId:application.id,requestId:req.id,metadata:{partnerId:profile.partnerId}});await notifications.publish({type:"BUSINESS_APPLICATION_APPROVED",user:applicant,headline:"Business application approved",description:"Your business account is active.",templateKey:"businessApplicationApproved",variables:{firstName:applicant.firstName,businessName:profile.businessName,partnerId:profile.partnerId},correlationId:req.id}).catch(()=>undefined);return res.json({success:true,data:{application:safeApp(application),profile:safeProfile(profile)}});}catch(e){return next(e);}};
|
||||
exports.reject=async(req,res,next)=>{try{const a=await service.reject(req.params.id,req.user.id,req.body.reason);const applicant=await db.User.findByPk(a.user_id);await logActivity({user:req.user,type:"BUSINESS_APPLICATION_REJECTED",module:"Business",description:"Business application rejected",targetType:"BUSINESS_APPLICATION",targetId:a.id,requestId:req.id});await notifications.publish({type:"BUSINESS_APPLICATION_REJECTED",user:applicant,headline:"Business application reviewed",description:"Your application was not approved.",templateKey:"businessApplicationRejected",variables:{firstName:applicant.firstName,businessName:a.business_name,reason:a.rejection_reason},correlationId:req.id}).catch(()=>undefined);return res.json({success:true,data:safeApp(a)});}catch(e){return next(e);}};
|
||||
exports.adminListBusinesses=async(req,res,next)=>{try{const where={};if(req.query.status)where.status=req.query.status;if(req.query.partnerId)where.partnerId=req.query.partnerId;if(req.query.businessName)where.businessName={[Op.like]:`%${req.query.businessName.slice(0,100)}%`};const {limit,offset}=paging(req.query);const x=await db.BusinessCustomer.findAndCountAll({where,limit,offset,order:[["createdAt","DESC"]]});return res.json({success:true,data:x.rows.map(safeProfile),pagination:{total:x.count,limit,offset}});}catch(e){return next(e);}};
|
||||
exports.setCredit=async(req,res,next)=>{try{let row,old;await db.sequelize.transaction(async t=>{const p=await db.BusinessCustomer.findByPk(req.params.id,{transaction:t,lock:t.LOCK.UPDATE});if(!p)throw Object.assign(new Error("Business not found"),{status:404,code:"NOT_FOUND"});row=await db.BusinessCreditAccount.findOne({where:{business_profile_id:p.business_customer_id},transaction:t,lock:t.LOCK.UPDATE});old=row.credit_limit;await row.update({credit_limit:req.body.creditLimit,currency:req.body.currency,status:req.body.status},{transaction:t});});await logActivity({user:req.user,type:"BUSINESS_CREDIT_LIMIT_CHANGED",module:"Business",description:"Business credit configuration changed",targetType:"BUSINESS_PROFILE",targetId:req.params.id,requestId:req.id,metadata:{oldValue:String(old),newValue:String(req.body.creditLimit),currency:req.body.currency}});return res.json({success:true,data:row});}catch(e){return next(e);}};
|
||||
exports.setSettlement=async(req,res,next)=>{try{const term=await db.SettlementTerm.findOne({where:{id:req.body.settlementTermId,is_active:true}});if(!term)return res.status(400).json({success:false,error:{code:"INVALID_SETTLEMENT_TERM",message:"Settlement term unavailable"}});const [count]=await db.BusinessCustomer.update({settlement_term_id:term.id},{where:{business_customer_id:req.params.id}});if(!count)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Business not found"}});await logActivity({user:req.user,type:"BUSINESS_SETTLEMENT_TERM_CHANGED",module:"Business",description:"Business settlement term changed",targetType:"BUSINESS_PROFILE",targetId:req.params.id,requestId:req.id,metadata:{settlementTermId:term.id}});return res.json({success:true});}catch(e){return next(e);}};
|
||||
exports.setStatus=async(req,res,next)=>{try{const p=await db.BusinessCustomer.findByPk(req.params.id);if(!p)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Business not found"}});await p.update({status:req.body.status});await logActivity({user:req.user,type:"BUSINESS_STATUS_CHANGED",module:"Business",description:"Business domain status changed",targetType:"BUSINESS_PROFILE",targetId:p.business_customer_id,requestId:req.id,metadata:{status:req.body.status}});const owner=await db.User.findByPk(p.user_id);if(owner)await notifications.publish({type:"BUSINESS_STATUS_CHANGED",user:owner,headline:"Business account status changed",description:`Your business account is now ${p.status}.`,templateKey:"businessAccountStatusChanged",variables:{firstName:owner.firstName,status:p.status},correlationId:req.id}).catch(()=>undefined);return res.json({success:true,data:safeProfile(p)});}catch(e){return next(e);}};
|
||||
module.exports.safeApp=safeApp;module.exports.safeProfile=safeProfile;
|
||||
@@ -0,0 +1,18 @@
|
||||
const crypto=require("crypto");const db=require("../../models");const service=require("../../services/catalogue/catalogue.service");const {logActivity}=require("../../services/activity.service");
|
||||
const audit=(req,type,targetType,targetId,description)=>logActivity({user:req.user,type,module:"Catalogue",description,targetType,targetId,requestId:req.id});
|
||||
exports.listProducts=async(req,res,next)=>{try{const page=Math.max(Number(req.query.page)||1,1),limit=Math.min(Number(req.query.limit)||20,100),where={};if(req.query.status)where.status=req.query.status;return res.json({success:true,...await db.Product.findAndCountAll({where,limit,offset:(page-1)*limit,include:[{model:db.ProductTranslation,as:"translations"},{model:db.ProductVariant,as:"variants"}],order:[["createdAt","DESC"]]})});}catch(e){return next(e);}};
|
||||
exports.createProduct=async(req,res,next)=>{try{const p=await service.createProduct(req.user,req.body);await audit(req,"PRODUCT_CREATED","PRODUCT",p.id,"Product created");return res.status(201).json({success:true,data:{id:p.id,slug:p.slug,status:p.status}});}catch(e){return next(e);}};
|
||||
exports.getProduct=async(req,res,next)=>{try{const p=await db.Product.findByPk(req.params.id,{include:[{model:db.ProductTranslation,as:"translations"},{model:db.ProductVariant,as:"variants"},{model:db.Category,as:"categories",through:{attributes:[]}},{model:db.ProductMedia,as:"media"}]});if(!p)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Product not found"}});return res.json({success:true,data:p});}catch(e){return next(e);}};
|
||||
exports.updateProduct=async(req,res,next)=>{try{const p=await service.setProductState(req.params.id,req.user,req.body);await audit(req,p.status==="ACTIVE"?"PRODUCT_PUBLISHED":p.status==="ARCHIVED"?"PRODUCT_ARCHIVED":"PRODUCT_UPDATED","PRODUCT",p.id,"Product updated");return res.json({success:true,data:{id:p.id,slug:p.slug,status:p.status,visibility:p.visibility}});}catch(e){return next(e);}};
|
||||
exports.archiveProduct=async(req,res,next)=>{try{const p=await service.setProductState(req.params.id,req.user,{status:"ARCHIVED",visibility:"HIDDEN"});await audit(req,"PRODUCT_ARCHIVED","PRODUCT",p.id,"Product archived");return res.json({success:true,data:{id:p.id,status:p.status}});}catch(e){return next(e);}};
|
||||
exports.createVariant=async(req,res,next)=>{try{const p=await db.Product.findByPk(req.params.productId);if(!p)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Product not found"}});const b=req.body,v=await db.ProductVariant.create({id:crypto.randomUUID(),product_id:p.id,sku:b.sku,barcode:b.barcode,base_price:b.basePrice,compare_at_price:b.compareAtPrice,currency:b.currency,weight:b.weight,sort_order:b.sortOrder});await audit(req,"VARIANT_CREATED","PRODUCT_VARIANT",v.id,"Product variant created");return res.status(201).json({success:true,data:v});}catch(e){return next(e);}};
|
||||
exports.updateVariant=async(req,res,next)=>{try{let v;await db.sequelize.transaction(async t=>{v=await db.ProductVariant.findOne({where:{id:req.params.variantId,product_id:req.params.productId},transaction:t,lock:t.LOCK.UPDATE});if(!v)throw Object.assign(new Error("Variant not found"),{status:404,code:"NOT_FOUND"});const b=req.body;await v.update({...(b.status!==undefined&&{status:b.status}),...(b.basePrice!==undefined&&{base_price:b.basePrice}),...(b.compareAtPrice!==undefined&&{compare_at_price:b.compareAtPrice}),...(b.currency!==undefined&&{currency:b.currency}),...(b.weight!==undefined&&{weight:b.weight}),...(b.sortOrder!==undefined&&{sort_order:b.sortOrder})},{transaction:t});if(b.optionValueIds){const values=await db.ProductOptionValue.findAll({where:{id:b.optionValueIds},include:[{model:db.ProductOption,as:"option",where:{product_id:req.params.productId}}],transaction:t});if(values.length!==new Set(b.optionValueIds).size)throw Object.assign(new Error("Option values must belong to this product"),{status:400,code:"CROSS_PRODUCT_OPTION"});await db.VariantOptionValue.destroy({where:{variant_id:v.id},transaction:t});await db.VariantOptionValue.bulkCreate(values.map(x=>({id:crypto.randomUUID(),variant_id:v.id,option_value_id:x.id})),{transaction:t});}});await audit(req,"VARIANT_UPDATED","PRODUCT_VARIANT",v.id,"Product variant updated");return res.json({success:true,data:v});}catch(e){return next(e);}};
|
||||
exports.createBrand=async(req,res,next)=>{try{if(req.body.logoUploadId)await service.validateMedia([req.body.logoUploadId],req.user.id);const b=req.body,x=await db.Brand.create({id:crypto.randomUUID(),name:b.name,slug:b.slug,description:b.description,logo_upload_id:b.logoUploadId,website:b.website,status:b.status,sort_order:b.sortOrder});await audit(req,"BRAND_CREATED","BRAND",x.id,"Brand created");return res.status(201).json({success:true,data:x});}catch(e){return next(e);}};
|
||||
exports.updateBrand=async(req,res,next)=>{try{const x=await db.Brand.findByPk(req.params.id);if(!x)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Brand not found"}});await x.update(req.body);await audit(req,"BRAND_UPDATED","BRAND",x.id,"Brand updated");return res.json({success:true,data:x});}catch(e){return next(e);}};
|
||||
exports.createCategory=async(req,res,next)=>{try{let x;await db.sequelize.transaction(async t=>{await service.assertCategoryParent(null,req.body.parentId,t);const b=req.body;x=await db.Category.create({id:crypto.randomUUID(),parent_id:b.parentId,code:b.code,slug:b.slug,status:b.status,sort_order:b.sortOrder,image_upload_id:b.imageUploadId},{transaction:t});await db.CategoryTranslation.bulkCreate(b.translations.map(y=>({id:crypto.randomUUID(),category_id:x.id,locale:y.locale,name:y.name,description:y.description,meta_title:y.metaTitle,meta_description:y.metaDescription})),{transaction:t});});await audit(req,"CATEGORY_CREATED","CATEGORY",x.id,"Category created");return res.status(201).json({success:true,data:x});}catch(e){return next(e);}};
|
||||
exports.updateCategory=async(req,res,next)=>{try{let x;await db.sequelize.transaction(async t=>{x=await db.Category.findByPk(req.params.id,{transaction:t,lock:t.LOCK.UPDATE});if(!x)throw Object.assign(new Error("Category not found"),{status:404,code:"NOT_FOUND"});await service.assertCategoryParent(x.id,req.body.parentId,t);await x.update({parent_id:req.body.parentId,code:req.body.code,slug:req.body.slug,status:req.body.status,sort_order:req.body.sortOrder,image_upload_id:req.body.imageUploadId},{transaction:t});});await audit(req,"CATEGORY_UPDATED","CATEGORY",x.id,"Category updated");return res.json({success:true,data:x});}catch(e){return next(e);}};
|
||||
exports.createCollection=async(req,res,next)=>{try{let x;await db.sequelize.transaction(async t=>{const b=req.body;if(b.endsAt&&b.startsAt&&b.endsAt<=b.startsAt)throw Object.assign(new Error("Collection end must follow start"),{status:400,code:"INVALID_WINDOW"});x=await db.Collection.create({id:crypto.randomUUID(),slug:b.slug,type:b.type,status:b.status,starts_at:b.startsAt,ends_at:b.endsAt,hero_upload_id:b.heroUploadId,sort_order:b.sortOrder},{transaction:t});await db.CollectionTranslation.bulkCreate(b.translations.map(y=>({id:crypto.randomUUID(),collection_id:x.id,locale:y.locale,name:y.name,description:y.description,headline:y.headline,subheadline:y.subheadline})),{transaction:t});await db.CollectionProduct.bulkCreate([...new Set(b.productIds)].map((product_id,i)=>({id:crypto.randomUUID(),collection_id:x.id,product_id,sort_order:i})),{transaction:t});});await audit(req,"COLLECTION_CREATED","COLLECTION",x.id,"Collection created");return res.status(201).json({success:true,data:x});}catch(e){return next(e);}};
|
||||
exports.createSizeGuide=async(req,res,next)=>{try{const b=req.body,x=await db.SizeGuide.create({id:crypto.randomUUID(),code:b.code,name:b.name,locale:b.locale,data:b.data,category_id:b.categoryId,status:b.status});return res.status(201).json({success:true,data:x});}catch(e){return next(e);}};
|
||||
exports.createOption=async(req,res,next)=>{try{let option;await db.sequelize.transaction(async t=>{const product=await db.Product.findByPk(req.params.productId,{transaction:t});if(!product)throw Object.assign(new Error("Product not found"),{status:404,code:"NOT_FOUND"});option=await db.ProductOption.create({id:crypto.randomUUID(),product_id:product.id,name:req.body.name,sort_order:req.body.sortOrder},{transaction:t});await db.ProductOptionValue.bulkCreate([...new Set(req.body.values)].map((value,i)=>({id:crypto.randomUUID(),option_id:option.id,value,sort_order:i})),{transaction:t});});return res.status(201).json({success:true,data:option});}catch(e){return next(e);}};
|
||||
exports.attachMedia=async(req,res,next)=>{try{let media;await db.sequelize.transaction(async t=>{const product=await db.Product.findByPk(req.params.productId,{transaction:t,lock:t.LOCK.UPDATE});if(!product)throw Object.assign(new Error("Product not found"),{status:404,code:"NOT_FOUND"});await service.validateMedia([req.body.uploadId],req.user.id,t);if(req.body.variantId){const variant=await db.ProductVariant.findOne({where:{id:req.body.variantId,product_id:product.id},transaction:t});if(!variant)throw Object.assign(new Error("Variant does not belong to product"),{status:400,code:"CROSS_PRODUCT_VARIANT"});}if(req.body.isPrimary)await db.ProductMedia.update({is_primary:false},{where:{product_id:product.id},transaction:t});media=await db.ProductMedia.create({id:crypto.randomUUID(),product_id:product.id,variant_id:req.body.variantId,upload_id:req.body.uploadId,type:"IMAGE",sort_order:req.body.sortOrder,alt_text:req.body.altText,is_primary:req.body.isPrimary},{transaction:t});await db.Upload.update({owner_type:"CATALOGUE",owner_id:product.id,use_for:"PRODUCT_MEDIA"},{where:{id:req.body.uploadId},transaction:t});});return res.status(201).json({success:true,data:{id:media.id,isPrimary:media.is_primary}});}catch(e){return next(e);}};
|
||||
exports.createRelation=async(req,res,next)=>{try{if(req.params.productId===req.body.targetProductId)return res.status(400).json({success:false,error:{code:"SELF_RELATION",message:"A product cannot relate to itself"}});const count=await db.Product.count({where:{id:[req.params.productId,req.body.targetProductId]}});if(count!==2)return res.status(400).json({success:false,error:{code:"INVALID_PRODUCT",message:"Both products must exist"}});const x=await db.ProductRelation.create({id:crypto.randomUUID(),source_product_id:req.params.productId,target_product_id:req.body.targetProductId,relation_type:req.body.relationType,sort_order:req.body.sortOrder});return res.status(201).json({success:true,data:x});}catch(e){return next(e);}};
|
||||
@@ -0,0 +1,10 @@
|
||||
const {Op,fn,col,literal}=require("sequelize");const db=require("../../models");const {resolveLocale,selectTranslation}=require("../../services/catalogue/locale.service");const serializer=require("../../services/catalogue/serializer.service");
|
||||
const productIncludes=()=>[{model:db.ProductTranslation,as:"translations",required:true},{model:db.Brand,as:"brand",where:{status:"ACTIVE"},required:true},{model:db.ProductVariant,as:"variants",where:{status:"ACTIVE"},required:true},{model:db.ProductMedia,as:"media",required:false,include:[{model:db.Upload,as:"upload",where:{status:"AVAILABLE"},required:true}]}];
|
||||
exports.products=async(req,res,next)=>{try{const page=Math.max(Number(req.query.page)||1,1),limit=Math.min(Math.max(Number(req.query.limit)||20,1),100),where={status:"ACTIVE",visibility:"PUBLIC"};if(req.query.brand)where.brand_id=req.query.brand;if(req.query.featured!==undefined)where.featured=req.query.featured==="true";if(req.query.newArrival==="true")where.new_arrival_until={[Op.gt]:new Date()};if(req.query.search)where[Op.or]=[{"$translations.name$":{[Op.like]:`%${req.query.search.slice(0,100)}%`}},{product_code:{[Op.like]:`%${req.query.search.slice(0,100)}%`}},{"$brand.name$":{[Op.like]:`%${req.query.search.slice(0,100)}%`}}];const allowed={newest:[["published_at","DESC"]],price_asc:[[{model:db.ProductVariant,as:"variants"},"base_price","ASC"]],price_desc:[[{model:db.ProductVariant,as:"variants"},"base_price","DESC"]],name:[[{model:db.ProductTranslation,as:"translations"},"name","ASC"]],featured:[["featured","DESC"],["published_at","DESC"]]};if(req.query.sort&&!allowed[req.query.sort])return res.status(400).json({success:false,error:{code:"INVALID_SORT",message:"Unsupported sort"}});const include=productIncludes();if(req.query.category)include.push({model:db.Category,as:"categories",where:{id:req.query.category,status:"ACTIVE"},through:{attributes:[]},required:true});if(req.query.minPrice||req.query.maxPrice){const v=include.find(x=>x.as==="variants");v.where.base_price={...(req.query.minPrice&&{[Op.gte]:req.query.minPrice}),...(req.query.maxPrice&&{[Op.lte]:req.query.maxPrice})};}const result=await db.Product.findAndCountAll({where,include,distinct:true,limit,offset:(page-1)*limit,order:allowed[req.query.sort||"newest"]});const locale=resolveLocale(req);return res.json({success:true,data:await Promise.all(result.rows.map(x=>serializer.summary(x,locale))),pagination:{page,limit,total:result.count,pages:Math.ceil(result.count/limit)}});}catch(e){return next(e);}};
|
||||
exports.product=async(req,res,next)=>{try{const p=await db.Product.findOne({where:{slug:req.params.slug,status:"ACTIVE",visibility:"PUBLIC"},include:[...productIncludes(),{model:db.Category,as:"categories",where:{status:"ACTIVE"},through:{attributes:[]},required:false},{model:db.ProductOption,as:"options",include:[{model:db.ProductOptionValue,as:"values"}]},{model:db.ProductAttribute,as:"attributes"},{model:db.SizeGuide,as:"sizeGuide",required:false},{model:db.ProductReview,as:"reviews",where:{status:"APPROVED"},required:false,include:[{model:db.User,as:"author",attributes:["id","firstName"]}]}]});if(!p)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Product not found"}});const locale=resolveLocale(req),tr=selectTranslation(p.translations,locale),summary=await serializer.summary(p,locale),reviews=p.reviews||[],average=reviews.length?(reviews.reduce((n,r)=>n+r.rating,0)/reviews.length).toFixed(2):null;return res.json({success:true,data:{...summary,content:tr&&{shortDescription:tr.short_description,description:tr.description,careInstructions:tr.care_instructions,materials:tr.materials,origin:tr.origin},seo:tr&&{title:tr.meta_title,description:tr.meta_description},categories:p.categories?.map(c=>({id:c.id,slug:c.slug,name:selectTranslation(c.translations,locale)?.name})),variants:p.variants?.map(v=>({id:v.id,sku:v.sku,basePrice:String(v.base_price),compareAtPrice:v.compare_at_price&&String(v.compare_at_price),currency:v.currency,optionValues:v.optionValues})),options:p.options,attributes:p.attributes?.filter(a=>a.locale===locale||a.locale==="en"),media:await serializer.mediaDto(p.media),sizeGuide:p.sizeGuide,rating:{averageRating:average,reviewCount:reviews.length},reviews:reviews.slice(0,10).map(r=>({id:r.id,rating:r.rating,title:r.title,body:r.body,verifiedPurchase:r.verified_purchase,author:r.author&&{firstName:r.author.firstName},createdAt:r.createdAt}))}});}catch(e){return next(e);}};
|
||||
exports.categories=async(req,res,next)=>{try{const locale=resolveLocale(req),rows=await db.Category.findAll({where:{status:"ACTIVE"},include:[{model:db.CategoryTranslation,as:"translations"}],order:[["sort_order","ASC"]]});const nodes=rows.map(x=>({id:x.id,parentId:x.parent_id,slug:x.slug,name:selectTranslation(x.translations,locale)?.name,children:[]})),byId=new Map(nodes.map(x=>[x.id,x]));for(const n of nodes)if(n.parentId&&byId.has(n.parentId))byId.get(n.parentId).children.push(n);return res.json({success:true,data:req.query.flat==="true"?nodes:nodes.filter(x=>!x.parentId)});}catch(e){return next(e);}};
|
||||
exports.category=async(req,res,next)=>{try{const x=await db.Category.findOne({where:{slug:req.params.slug,status:"ACTIVE"},include:[{model:db.CategoryTranslation,as:"translations"}]});if(!x)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Category not found"}});const t=selectTranslation(x.translations,resolveLocale(req));return res.json({success:true,data:{id:x.id,slug:x.slug,parentId:x.parent_id,name:t?.name,description:t?.description,seo:{title:t?.meta_title,description:t?.meta_description}}});}catch(e){return next(e);}};
|
||||
exports.brands=async(req,res,next)=>{try{return res.json({success:true,data:await db.Brand.findAll({where:{status:"ACTIVE"},attributes:["id","name","slug","description","website"],order:[["sort_order","ASC"]]})});}catch(e){return next(e);}};
|
||||
exports.brand=async(req,res,next)=>{try{const x=await db.Brand.findOne({where:{slug:req.params.slug,status:"ACTIVE"},attributes:["id","name","slug","description","website"]});if(!x)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Brand not found"}});return res.json({success:true,data:x});}catch(e){return next(e);}};
|
||||
exports.collections=async(req,res,next)=>{try{const now=new Date(),rows=await db.Collection.findAll({where:{status:"ACTIVE",[Op.and]:[{[Op.or]:[{starts_at:null},{starts_at:{[Op.lte]:now}}]},{[Op.or]:[{ends_at:null},{ends_at:{[Op.gte]:now}}]}]},include:[{model:db.CollectionTranslation,as:"translations"}],order:[["sort_order","ASC"]]});const locale=resolveLocale(req);return res.json({success:true,data:rows.map(x=>({id:x.id,slug:x.slug,type:x.type,...selectTranslation(x.translations,locale)}))});}catch(e){return next(e);}};
|
||||
exports.collection=async(req,res,next)=>{try{const now=new Date(),x=await db.Collection.findOne({where:{slug:req.params.slug,status:"ACTIVE",[Op.and]:[{[Op.or]:[{starts_at:null},{starts_at:{[Op.lte]:now}}]},{[Op.or]:[{ends_at:null},{ends_at:{[Op.gte]:now}}]}]},include:[{model:db.CollectionTranslation,as:"translations"},{model:db.Product,as:"products",where:{status:"ACTIVE",visibility:"PUBLIC"},required:false,through:{attributes:["sort_order"]},include:productIncludes()}]});if(!x)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Collection not found"}});const locale=resolveLocale(req);return res.json({success:true,data:{id:x.id,slug:x.slug,...selectTranslation(x.translations,locale),products:await Promise.all((x.products||[]).map(p=>serializer.summary(p,locale)))}});}catch(e){return next(e);}};
|
||||
@@ -0,0 +1,4 @@
|
||||
const crypto=require("crypto");const db=require("../../models");const loyalty=require("../../services/loyalty/loyalty.service");const {logActivity}=require("../../services/activity.service");
|
||||
exports.create=async(req,res,next)=>{try{if(!["customer","business_customer"].includes(req.user.accountType))return res.status(403).json({success:false,error:{code:"FORBIDDEN",message:"Customer account required"}});const product=await db.Product.findOne({where:{id:req.params.productId,status:"ACTIVE",visibility:"PUBLIC"}});if(!product)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Product not found"}});const purchased=await db.Order.count({where:{user_id:req.user.id,payment_status:"PAID"},include:[{model:db.OrderItem,as:"items",where:{product_id:product.id},attributes:[]}]});const review=await db.ProductReview.create({id:crypto.randomUUID(),product_id:product.id,user_id:req.user.id,rating:req.body.rating,title:req.body.title,body:req.body.body,status:"PENDING",verified_purchase:purchased>0});await logActivity({user:req.user,type:"REVIEW_SUBMITTED",module:"Catalogue",description:"Product review submitted",targetType:"PRODUCT_REVIEW",targetId:review.id,requestId:req.id});return res.status(201).json({success:true,data:{id:review.id,status:review.status,verifiedPurchase:review.verified_purchase}});}catch(e){return next(e);}};
|
||||
exports.listAdmin=async(req,res,next)=>{try{const page=Math.max(Number(req.query.page)||1,1),limit=Math.min(Number(req.query.limit)||20,100),where={};if(req.query.status)where.status=req.query.status;const x=await db.ProductReview.findAndCountAll({where,limit,offset:(page-1)*limit,order:[["createdAt","DESC"]]});return res.json({success:true,data:x.rows,pagination:{page,limit,total:x.count}});}catch(e){return next(e);}};
|
||||
exports.moderate=async(req,res,next)=>{try{const r=await db.ProductReview.findByPk(req.params.id);if(!r)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Review not found"}});await r.update({status:req.body.status,moderated_by:req.user.id,moderated_at:new Date()});if(r.status==="APPROVED")await loyalty.earnReview(r);await logActivity({user:req.user,type:req.body.status==="APPROVED"?"REVIEW_APPROVED":"REVIEW_REJECTED",module:"Catalogue",description:"Product review moderated",targetType:"PRODUCT_REVIEW",targetId:r.id,requestId:req.id});return res.json({success:true,data:{id:r.id,status:r.status}});}catch(e){return next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const db=require("../../models"),orders=require("../../services/commerce/order.service"),returns=require("../../services/commerce/return.service"),refunds=require("../../services/commerce/refund.service"),state=require("../../services/commerce/orderState.service"),{logActivity}=require("../../services/activity.service");exports.orders=async(req,res,next)=>{try{res.json({success:true,data:await db.Order.findAll({include:[{model:db.OrderItem,as:"items"}],order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.order=async(req,res,next)=>{try{const row=await db.Order.findByPk(req.params.id,{include:[{model:db.OrderItem,as:"items"},{model:db.Payment,as:"payments"}]});if(!row)throw Object.assign(new Error("Order not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:row});}catch(e){next(e);}};exports.cancel=async(req,res,next)=>{try{const row=await orders.cancel({orderId:req.params.id,admin:true,requestId:req.id});await logActivity({user:req.user,type:"ORDER_CANCELLED",module:"Orders",targetId:row.id,requestId:req.id});res.json({success:true,data:row});}catch(e){next(e);}};exports.processing=async(req,res,next)=>{try{const row=await db.Order.findByPk(req.params.id);if(!row)throw Object.assign(new Error("Order not found"),{status:404,code:"NOT_FOUND"});state.assertTransition(row.status,"PROCESSING");await row.update({status:"PROCESSING"});res.json({success:true,data:row});}catch(e){next(e);}};exports.returns=async(req,res,next)=>{try{res.json({success:true,data:await db.ReturnRequest.findAll({order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.returnAction=status=>async(req,res,next)=>{try{const row=await returns.transition({id:req.params.id,status,actorUserId:req.user.id,conditions:req.body.conditions||[]});await logActivity({user:req.user,type:`RETURN_${status}`,module:"Returns",targetId:row.id,requestId:req.id});res.json({success:true,data:row});}catch(e){next(e);}};exports.refund=async(req,res,next)=>{try{const result=await refunds.request({orderId:req.params.id,actorUserId:req.user.id,operationKey:req.get("Idempotency-Key"),...req.body});await logActivity({user:req.user,type:"REFUND_REQUESTED",module:"Refunds",targetId:result.refund.id,requestId:req.id});res.status(result.idempotent?200:201).json({success:true,data:result.refund});}catch(e){next(e);}};
|
||||
@@ -0,0 +1,2 @@
|
||||
const db=require("../../models"),orders=require("../../services/commerce/order.service"),payments=require("../../services/commerce/payment.service"),{logActivity}=require("../../services/activity.service");const audit=(req,type,id)=>logActivity({user:req.user,type,module:"Orders",targetType:"ORDER",targetId:id,requestId:req.id});exports.create=async(req,res,next)=>{try{const result=await orders.createFromCheckout({checkoutId:req.body.checkoutId,userId:req.user.id});if(!result.idempotent)await audit(req,"ORDER_CREATED",result.order.id);res.status(result.idempotent?200:201).json({success:true,data:result.order});}catch(e){next(e);}};
|
||||
exports.list=async(req,res,next)=>{try{const page=Math.max(Number(req.query.page)||1,1),limit=Math.min(Number(req.query.limit)||20,100),where={user_id:req.user.id};if(req.query.status)where.status=req.query.status;const x=await db.Order.findAndCountAll({where,limit,offset:(page-1)*limit,order:[["createdAt","DESC"]]});res.json({success:true,data:x.rows,pagination:{page,limit,total:x.count}});}catch(e){next(e);}};exports.get=async(req,res,next)=>{try{const row=await db.Order.findOne({where:{id:req.params.id,user_id:req.user.id},include:[{model:db.OrderItem,as:"items"},{model:db.Payment,as:"payments",attributes:{exclude:["failure_message"]}},{model:db.Invoice,as:"invoice"}]});if(!row)throw Object.assign(new Error("Order not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:row});}catch(e){next(e);}};exports.cancel=async(req,res,next)=>{try{const row=await orders.cancel({orderId:req.params.id,userId:req.user.id,requestId:req.id});await audit(req,"ORDER_CANCELLED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};exports.payment=async(req,res,next)=>{try{const row=await db.Payment.findOne({include:[{model:db.Order,as:"order",where:{id:req.params.id,user_id:req.user.id},attributes:[]}],attributes:{exclude:["failure_message"]}});res.json({success:true,data:row});}catch(e){next(e);}};exports.invoice=async(req,res,next)=>{try{const row=await db.Invoice.findOne({include:[{model:db.Order,as:"order",where:{id:req.params.id,user_id:req.user.id},attributes:[]}]});if(!row)throw Object.assign(new Error("Invoice not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:row});}catch(e){next(e);}};exports.startPayment=async(req,res,next)=>{try{const row=await payments.create({orderId:req.params.id,userId:req.user.id,...req.body});await audit(req,"PAYMENT_CREATED",row.id);res.status(201).json({success:true,data:{id:row.id,paymentReference:row.payment_reference,provider:row.provider,status:row.status,amount:String(row.amount),currency:row.currency}});}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const db=require("../../models"),service=require("../../services/commerce/return.service"),{logActivity}=require("../../services/activity.service");exports.request=async(req,res,next)=>{try{const row=await service.request({orderId:req.params.orderId,userId:req.user.id,...req.body});await logActivity({user:req.user,type:"RETURN_REQUESTED",module:"Returns",targetId:row.id,requestId:req.id});res.status(201).json({success:true,data:row});}catch(e){next(e);}};exports.list=async(req,res,next)=>{try{res.json({success:true,data:await db.ReturnRequest.findAll({where:{user_id:req.user.id},order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.get=async(req,res,next)=>{try{const row=await db.ReturnRequest.findOne({where:{id:req.params.id,user_id:req.user.id}});if(!row)throw Object.assign(new Error("Return not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:row});}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const service=require("../../services/commerce/payment.service");exports.payhere=async(req,res,next)=>{try{await service.handleWebhook("PAYHERE",req.body);res.status(200).send("OK");}catch(e){next(e);}};
|
||||
@@ -0,0 +1,11 @@
|
||||
const db = require("../models");
|
||||
const { checkPassword } = require("../utils/hashPassword.util");
|
||||
const { revokeAllUserSessions } = require("../services/auth/session.service");
|
||||
const { logActivity } = require("../services/activity.service");
|
||||
const storage = require("../services/storage/storage.service");
|
||||
|
||||
const media = async (id, userId) => { if (!id || id === "N/A") return null; const upload = await db.Upload.findOne({ where: { id, owner_id: userId, status: "AVAILABLE" } }); if (!upload) return null; const expiresIn = Number(process.env.S3_SIGNED_URL_TTL_SECONDS || 900); return { id: upload.id, url: await storage.createSignedDownloadUrl(upload.file_path, expiresIn), expiresIn }; };
|
||||
const response = async (user, profile) => ({ id: user.id, firstName: user.firstName, lastName: user.lastName, email: user.email, phoneNumber: profile.phone_number, dateOfBirth: profile.dob, preferredLanguage: profile.preferred_language, accountType: user.accountType, accountStatus: user.accountStatus, emailVerified: Boolean(user.emailVerifiedAt), avatar: await media(profile.profilePicture_id, user.id), background: await media(profile.backgroundImage_id, user.id), preferences: { theme: profile.theme, marketingEmailEnabled: profile.marketing_email_enabled, marketingPushEnabled: profile.marketing_push_enabled, inAppNotificationsEnabled: profile.in_app_notifications_enabled } });
|
||||
exports.getMe = async (req, res, next) => { try { const user = await db.User.findByPk(req.user.id, { include: [{ model: db.Profile, as: "profile" }] }); if (!user?.profile) return res.status(404).json({ success: false, error: { code: "PROFILE_NOT_FOUND", message: "Profile not found" } }); return res.json({ success: true, data: await response(user, user.profile) }); } catch (e) { return next(e); } };
|
||||
exports.updateMe = async (req, res, next) => { try { let user, profile; await db.sequelize.transaction(async transaction => { user = await db.User.findByPk(req.user.id, { transaction, lock: transaction.LOCK.UPDATE }); profile = await db.Profile.findOne({ where: { user_id: req.user.id }, transaction, lock: transaction.LOCK.UPDATE }); const b=req.body; for(const id of [b.avatarUploadId,b.backgroundUploadId].filter(Boolean)){const owned=await db.Upload.findOne({where:{id,owner_id:req.user.id,status:"AVAILABLE"},transaction});if(!owned)throw Object.assign(new Error("Profile media must be an available owned upload"),{status:400,code:"INVALID_PROFILE_MEDIA"});} await user.update({ ...(b.firstName !== undefined && { firstName:b.firstName }), ...(b.lastName !== undefined && { lastName:b.lastName }) }, { transaction }); await profile.update({ ...(b.phoneNumber !== undefined && { phone_number:b.phoneNumber }), ...(b.dateOfBirth !== undefined && { dob:b.dateOfBirth }), ...(b.preferredLanguage !== undefined && { preferred_language:b.preferredLanguage }), ...(b.theme !== undefined && { theme:b.theme }), ...(b.marketingEmailEnabled !== undefined && { marketing_email_enabled:b.marketingEmailEnabled }), ...(b.marketingPushEnabled !== undefined && { marketing_push_enabled:b.marketingPushEnabled }), ...(b.inAppNotificationsEnabled !== undefined && { in_app_notifications_enabled:b.inAppNotificationsEnabled, notificationsEnabled:b.inAppNotificationsEnabled }), ...(b.avatarUploadId !== undefined && { profilePicture_id:b.avatarUploadId }), ...(b.backgroundUploadId !== undefined && { backgroundImage_id:b.backgroundUploadId }) }, { transaction }); }); await logActivity({ user:req.user, type:"PROFILE_UPDATED", module:"Customer", description:"Customer profile updated", targetType:"USER", targetId:req.user.id, requestId:req.id }); return res.json({ success:true, data:await response(user,profile) }); } catch(e){ return next(e); } };
|
||||
exports.deactivate = async (req,res,next) => { try { await db.sequelize.transaction(async transaction => { const user=await db.User.findByPk(req.user.id,{transaction,lock:transaction.LOCK.UPDATE}); if(user.password && (!req.body.password || !(await checkPassword(req.body.password,user.password)))) throw Object.assign(new Error("Password confirmation failed"),{status:403,code:"INVALID_CONFIRMATION"}); await user.update({accountStatus:"DEACTIVATED",tokenVersion:user.tokenVersion+1},{transaction}); await revokeAllUserSessions(user.id,"SELF_DEACTIVATED",transaction); }); await logActivity({user:req.user,type:"ACCOUNT_DEACTIVATED",module:"Identity",description:"Customer deactivated account",targetType:"USER",targetId:req.user.id,requestId:req.id}); res.clearCookie("access_token"); res.clearCookie("refresh_token",{path:"/api"}); return res.json({success:true,message:"Account deactivated"}); } catch(e){return next(e);} };
|
||||
@@ -1,417 +1,21 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Niolla
|
||||
* All rights reserved.
|
||||
*
|
||||
* This source code is proprietary and confidential.
|
||||
* Unauthorized copying, modification, distribution, or use
|
||||
* of this file, via any medium, is strictly prohibited.
|
||||
*/
|
||||
|
||||
// app/controllers/generateDocument.controller.js
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const documentQueue = require("../queues/document.queue");
|
||||
const { createDocumentData } = require("../utils/document.utill");
|
||||
const {
|
||||
generateId,
|
||||
generateDocumentReferenceNo,
|
||||
} = require("../utils/idGen.util");
|
||||
|
||||
const { GetObjectCommand, DeleteObjectCommand } = require("@aws-sdk/client-s3");
|
||||
|
||||
const {log} = require("../utils/consoleLog.utill");
|
||||
|
||||
const s3 = require("../config/s3.config");
|
||||
const crypto = require("crypto");
|
||||
const { z } = require("zod");
|
||||
const db = require("../models");
|
||||
const Document = db.Document;
|
||||
const DocumentType = db.DocumentType;
|
||||
const documentQueue = require("../queues/document.queue");
|
||||
const registry = require("../logic/documents/registry");
|
||||
const storage = require("../services/storage/storage.service");
|
||||
|
||||
const normalizeDocumentData = (value) => {
|
||||
if (!value) {
|
||||
return {};
|
||||
}
|
||||
const requestSchema = z.object({ document: z.string().min(1).max(64), documentType: z.enum(["pdf", "excel"]), documentData: z.record(z.string(), z.unknown()).optional(), data: z.record(z.string(), z.unknown()).optional() }).passthrough();
|
||||
const normalize = (value) => String(value).toLowerCase().replace(/[\s_-]+/g, "");
|
||||
const allowed = (user, doc, permission = "documents.read") => doc.owner_id === user.id || doc.created_by === user.id || user.accountType === "super_admin" || (user.permissions || []).includes(permission);
|
||||
const publicDoc = (doc) => ({ id: doc.doc_id, referenceNo: doc.reference_no, documentType: doc.doc_type, status: doc.status, jobId: doc.job_id, generatedAt: doc.generated_at, failedAt: doc.failed_at, failureCode: doc.failure_code, createdAt: doc.createdAt });
|
||||
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get available document types
|
||||
*/
|
||||
exports.getAvailableDocumentTypes = async (req, res) => {
|
||||
try {
|
||||
|
||||
const doc_types = await DocumentType.findAll({
|
||||
attributes: ["doc_type_name"],
|
||||
});
|
||||
|
||||
const types = doc_types.map((t) => t.doc_type_name);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
availableDocumentTypes: types,
|
||||
note: "Use these document type names in your requests (case-insensitive)",
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
log("Error fetching document types:", error.message);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get Saved documents
|
||||
exports.getSavedDocuments = async (req, res) => {
|
||||
try {
|
||||
const { documentType } = req.body;
|
||||
|
||||
if (!documentType) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "documentType query parameter is required",
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch saved documents based on documentType
|
||||
const savedDocuments = await Document.findAll({
|
||||
where: { doc_type: documentType.toUpperCase() },
|
||||
order: [["createdAt", "DESC"]],
|
||||
exclude: ["data"],
|
||||
});
|
||||
|
||||
// extract only necessary fields to return
|
||||
const formattedDocuments = savedDocuments.map((doc) => ({
|
||||
doc_id: doc.doc_id,
|
||||
reference_no: doc.reference_no,
|
||||
doc_type: doc.doc_type,
|
||||
status: doc.status,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
}));
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
savedDocuments: formattedDocuments,
|
||||
});
|
||||
} catch (error) {
|
||||
log("Error fetching saved documents:", error.message);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get specific document data by doc_id
|
||||
exports.getDocumentData = async (req, res) => {
|
||||
try {
|
||||
const { docId } = req.params;
|
||||
|
||||
if (!docId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "docId parameter is required",
|
||||
});
|
||||
}
|
||||
|
||||
const document = await Document.findOne({
|
||||
where: { doc_id: docId },
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "Document not found",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
document: {
|
||||
doc_id: document.doc_id,
|
||||
reference_no: document.reference_no,
|
||||
doc_type: document.doc_type,
|
||||
data: document.data,
|
||||
status: document.status,
|
||||
createdAt: document.createdAt,
|
||||
updatedAt: document.updatedAt,
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
log("Error fetching document data:", error.message);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
exports.generateReferenceNo = async (req, res) => {
|
||||
try {
|
||||
const { documentType } = req.params;
|
||||
|
||||
if (!documentType) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "documentType is required",
|
||||
});
|
||||
}
|
||||
|
||||
// Generate reference number
|
||||
const reference_no = await generateDocumentReferenceNo(documentType);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
reference_no,
|
||||
});
|
||||
} catch (error) {
|
||||
log("Error generating reference number:", error.message);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.generateDraftDocument = async (req, res) => {
|
||||
try {
|
||||
const { document } = req.body;
|
||||
const documentData = normalizeDocumentData(req.body.documentData || req.body.data || req.body);
|
||||
|
||||
// Validation
|
||||
if (!document || !documentData) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "document and documentData are required",
|
||||
});
|
||||
}
|
||||
|
||||
let documentDetails;
|
||||
|
||||
if (document !== "PRECOST") {
|
||||
// Save document details before generating
|
||||
documentDetails = await createDocumentData(
|
||||
document,
|
||||
documentData,
|
||||
"DRAFT",
|
||||
);
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"Invalid document type, This document type is not allowed to be generated as draft",
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
message: "Draft document created successfully",
|
||||
documentDetails,
|
||||
});
|
||||
} catch (error) {
|
||||
log("Error generating draft document:", error.message);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate document asynchronously
|
||||
* Returns jobId immediately
|
||||
*/
|
||||
exports.generateDocument = async (req, res) => {
|
||||
try {
|
||||
const { document, documentType } = req.body;
|
||||
const documentData = normalizeDocumentData(req.body.documentData || req.body.data || req.body);
|
||||
|
||||
// Validation
|
||||
if (!document || !documentType || !documentData) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "document, documentType, and documentData are required",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(
|
||||
`📨 generateDocument request: document="${document}", documentType="${documentType}"`,
|
||||
);
|
||||
|
||||
if (document !== "PRECOST") {
|
||||
// Save document details before generating
|
||||
// If doc_id exists, finalize (update existing); otherwise create as DRAFT
|
||||
const status = documentData.doc_id ? "FINAL" : "DRAFT";
|
||||
await createDocumentData(document, documentData, status);
|
||||
}
|
||||
|
||||
// Add job to queue
|
||||
const job = await documentQueue.add("generate-document", {
|
||||
document,
|
||||
documentType,
|
||||
data: documentData,
|
||||
});
|
||||
|
||||
log(
|
||||
`📋 Document generation job queued: ${job.id} (document="${document}", type="${documentType}")`,
|
||||
);
|
||||
|
||||
return res.status(202).json({
|
||||
success: true,
|
||||
message: "Document generation started",
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
log("Error queuing document generation:", error.message);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get job status and result
|
||||
*/
|
||||
exports.getJobStatus = async (req, res) => {
|
||||
try {
|
||||
const { jobId } = req.params;
|
||||
|
||||
if (!jobId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "jobId is required",
|
||||
});
|
||||
}
|
||||
|
||||
// Get job from queue
|
||||
const job = await documentQueue.getJob(jobId);
|
||||
|
||||
if (!job) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "Job not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Get job state
|
||||
const state = await job.getState();
|
||||
const result = job.returnvalue;
|
||||
const failedReason = job.failedReason;
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
jobId: job.id,
|
||||
state, // "waiting" | "active" | "completed" | "failed" | "delayed"
|
||||
result: state === "completed" ? result : null,
|
||||
error: state === "failed" ? failedReason : null,
|
||||
attempts: job.attemptsMade,
|
||||
stacktrace: job.stacktrace,
|
||||
});
|
||||
} catch (error) {
|
||||
log("Error fetching job status:", error.message);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Download generated document
|
||||
*/
|
||||
exports.downloadDocument = async (req, res) => {
|
||||
try {
|
||||
const { uuid } = req.params;
|
||||
|
||||
const key = `uploads/${uuid}.pdf`;
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: process.env.AWS_S3_BUCKET_NAME,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
const response = await s3.send(command);
|
||||
|
||||
res.setHeader(
|
||||
"Content-Type",
|
||||
response.ContentType || "application/octet-stream",
|
||||
);
|
||||
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${uuid}.pdf"`);
|
||||
|
||||
response.Body.pipe(res);
|
||||
|
||||
res.on("finish", async () => {
|
||||
try {
|
||||
await s3.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: process.env.AWS_S3_BUCKET_NAME,
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
|
||||
log(`Deleted from S3: ${key}`);
|
||||
} catch (err) {
|
||||
log(`Failed to delete ${key}:`, err.message);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
log("Download error:", error.message);
|
||||
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "Document not found",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Cancel/delete a job
|
||||
*/
|
||||
exports.cancelJob = async (req, res) => {
|
||||
try {
|
||||
const { jobId } = req.params;
|
||||
|
||||
if (!jobId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "jobId is required",
|
||||
});
|
||||
}
|
||||
|
||||
const job = await documentQueue.getJob(jobId);
|
||||
|
||||
if (!job) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "Job not found",
|
||||
});
|
||||
}
|
||||
|
||||
await job.remove();
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "Job cancelled successfully",
|
||||
jobId,
|
||||
});
|
||||
} catch (error) {
|
||||
log("Error cancelling job:", error.message);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
exports.getAvailableDocumentTypes = async (_req, res) => res.json({ success: true, data: Object.keys(registry) });
|
||||
exports.getSavedDocuments = async (req, res, next) => { try { const docs = await db.Document.findAll({ where: { owner_id: req.user.id }, attributes: { exclude: ["data"] }, order: [["createdAt", "DESC"]] }); return res.json({ success: true, data: docs.map(publicDoc) }); } catch (e) { return next(e); } };
|
||||
exports.getDocumentData = async (req, res, next) => { try { const doc = await db.Document.findByPk(req.params.docId); if (!doc) return res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Document not found" } }); if (!allowed(req.user, doc)) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } }); return res.json({ success: true, data: { ...publicDoc(doc), documentData: doc.data } }); } catch (e) { return next(e); } };
|
||||
exports.generateReferenceNo = (_req, res) => res.status(410).json({ success: false, error: { code: "DEPRECATED", message: "Reference numbers are assigned during document creation" } });
|
||||
exports.generateDraftDocument = async (req, res, next) => { try { const parsed = requestSchema.safeParse({ ...req.body, documentType: req.body.documentType || "pdf" }); if (!parsed.success) return res.status(400).json({ success: false, error: { code: "VALIDATION_ERROR", message: "Invalid document request" } }); const key = normalize(parsed.data.document); if (!registry[key]) return res.status(400).json({ success: false, error: { code: "INVALID_DOCUMENT_TYPE", message: "Unsupported document type" } }); const doc = await db.Document.create({ doc_id: crypto.randomUUID(), reference_no: "N/A", doc_type: key.toUpperCase(), data: parsed.data.documentData || parsed.data.data || {}, status: "DRAFT", created_by: req.user.id, owner_type: "USER", owner_id: req.user.id }); return res.status(201).json({ success: true, data: publicDoc(doc) }); } catch (e) { return next(e); } };
|
||||
exports.generateDocument = async (req, res, next) => { try { const parsed = requestSchema.safeParse(req.body); if (!parsed.success) return res.status(400).json({ success: false, error: { code: "VALIDATION_ERROR", message: "Invalid document request", details: parsed.error.issues.map(i => ({ path: i.path.join("."), message: i.message })) } }); const key = normalize(parsed.data.document); const entry = registry[key]; if (!entry || (parsed.data.documentType === "excel" && !entry.excelBuilder)) return res.status(400).json({ success: false, error: { code: "INVALID_DOCUMENT_TYPE", message: "Document generator is unavailable" } }); const id = crypto.randomUUID(); const data = parsed.data.documentData || parsed.data.data || {}; const doc = await db.Document.create({ doc_id: id, reference_no: data.reference_no || "N/A", doc_type: key.toUpperCase(), data, status: "QUEUED", created_by: req.user.id, owner_type: "USER", owner_id: req.user.id, job_id: `document-${id}` }); try { await documentQueue.add("generate-document", { documentId: id, document: key, documentType: parsed.data.documentType, data }, { jobId: `document-${id}` }); } catch (e) { await doc.update({ status: "FAILED", failed_at: new Date(), failure_code: "QUEUE_FAILED" }); throw e; } return res.status(202).json({ success: true, data: publicDoc(doc) }); } catch (e) { return next(e); } };
|
||||
exports.getJobStatus = async (req, res, next) => { try { const doc = await db.Document.findOne({ where: { job_id: req.params.jobId } }); if (!doc) return res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Job not found" } }); if (!allowed(req.user, doc)) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } }); return res.json({ success: true, data: publicDoc(doc) }); } catch (e) { return next(e); } };
|
||||
exports.downloadDocument = async (req, res, next) => { try { const doc = await db.Document.findByPk(req.params.docId, { include: [{ model: db.Upload, as: "storageUpload" }] }); if (!doc || doc.status !== "COMPLETED" || !doc.storageUpload) return res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Document not available" } }); if (!allowed(req.user, doc)) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } }); const expiresIn = Number(process.env.S3_SIGNED_URL_TTL_SECONDS || 900); return res.json({ success: true, data: { url: await storage.createSignedDownloadUrl(doc.storageUpload.file_path, expiresIn), expiresIn } }); } catch (e) { return next(e); } };
|
||||
exports.cancelJob = async (req, res, next) => { try { const doc = await db.Document.findOne({ where: { job_id: req.params.jobId } }); if (!doc) return res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Job not found" } }); if (!allowed(req.user, doc, "documents.delete")) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } }); const job = await documentQueue.getJob(doc.job_id); if (job) await job.remove(); await doc.update({ status: "FAILED", failed_at: new Date(), failure_code: "CANCELLED" }); return res.json({ success: true }); } catch (e) { return next(e); } };
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
const db=require("../models"),help=require("../services/help/help.service"),{resolveLocale}=require("../services/catalogue/locale.service");const locale=req=>resolveLocale(req);exports.categories=async(req,res,next)=>{try{res.json({success:true,data:await help.categories(locale(req))});}catch(e){next(e);}};exports.category=async(req,res,next)=>{try{const row=await db.HelpCategory.findOne({where:{slug:req.params.slug,status:"ACTIVE"}});if(!row)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Help category not found"}});res.json({success:true,data:{category:(await help.categories(locale(req))).find(x=>x.id===row.id),articles:await help.articles({locale:locale(req),categoryId:row.id})}});}catch(e){next(e);}};exports.articles=async(req,res,next)=>{try{res.json({success:true,data:await help.articles({locale:locale(req),categoryId:req.query.category,featured:req.query.featured==null?undefined:req.query.featured==="true",limit:Math.min(+req.query.limit||20,50)})});}catch(e){next(e);}};exports.article=async(req,res,next)=>{try{const a=await db.HelpArticle.findOne({where:{slug:req.params.slug,status:"PUBLISHED"}});if(!a)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Help article not found"}});const tr=help.pick(await db.HelpArticleTranslation.findAll({where:{article_id:a.id}}),locale(req));res.json({success:true,data:{id:a.id,slug:a.slug,categoryId:a.category_id,type:a.article_type,title:tr?.title||null,summary:tr?.summary||null,body:tr?.body||null,locale:tr?.locale||locale(req),isFeatured:a.is_featured,publishedAt:a.published_at}});}catch(e){next(e);}};exports.search=async(req,res,next)=>{try{const q=String(req.query.q||"").trim();if(q.length<2||q.length>100)return res.status(400).json({success:false,error:{code:"INVALID_QUERY",message:"q must contain 2-100 characters"}});res.json({success:true,data:await help.articles({locale:locale(req),q,limit:Math.min(+req.query.limit||20,50)})});}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const db=require("../models"),help=require("../services/help/help.service");exports.categories=async(req,res,next)=>{try{res.json({success:true,data:await db.HelpCategory.findAll({order:[["sort_order","ASC"]]})});}catch(e){next(e);}};exports.createCategory=async(req,res,next)=>{try{res.status(201).json({success:true,data:await help.createCategory(req.body)});}catch(e){next(e);}};exports.articles=async(req,res,next)=>{try{res.json({success:true,data:await db.HelpArticle.findAll({order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.createArticle=async(req,res,next)=>{try{res.status(201).json({success:true,data:await help.createArticle(req.user,req.body)});}catch(e){next(e);}};exports.publish=status=>async(req,res,next)=>{try{res.json({success:true,data:await help.publish(req.params.id,req.user,status)});}catch(e){next(e);}};
|
||||
@@ -0,0 +1,11 @@
|
||||
const crypto=require("crypto"),db=require("../../models"),inventory=require("../../services/inventory/inventory.service"),{logActivity}=require("../../services/activity.service");
|
||||
const audit=(req,type,id)=>logActivity({user:req.user,type,module:"Inventory",description:type,targetType:"INVENTORY",targetId:id,requestId:req.id});
|
||||
exports.list=async(req,res,next)=>{try{const rows=await db.InventoryBalance.findAll({include:[{model:db.Warehouse,as:"warehouse",attributes:["id","code","name"]},{model:db.ProductVariant,as:"variant",include:[{model:db.Product,as:"product",attributes:["id","slug"]}]}]});res.json({success:true,data:rows});}catch(e){next(e);}};
|
||||
exports.detail=async(req,res,next)=>{try{const rows=await db.InventoryBalance.findAll({where:{variant_id:req.params.variantId},include:[{model:db.Warehouse,as:"warehouse"}]});res.json({success:true,data:rows});}catch(e){next(e);}};
|
||||
exports.ledger=async(req,res,next)=>{try{res.json({success:true,data:await db.InventoryTransaction.findAll({where:req.query.variantId?{variant_id:req.query.variantId}:{},limit:Math.min(Number(req.query.limit)||50,200),order:[["occurred_at","DESC"]]})});}catch(e){next(e);}};
|
||||
exports.lowStock=async(req,res,next)=>{try{const rows=await db.InventoryBalance.findAll();res.json({success:true,data:rows.filter(x=>Number(x.on_hand)-Number(x.reserved)<=Number(x.low_stock_threshold))});}catch(e){next(e);}};
|
||||
exports.adjust=async(req,res,next)=>{try{const eventId=req.get("Idempotency-Key")||crypto.randomUUID(),result=await inventory.adjustStock({...req.body,eventId,actorUserId:req.user.id,requestId:req.id});await audit(req,"INVENTORY_ADJUSTED",eventId);res.status(result.idempotent?200:201).json({success:true,data:result});}catch(e){next(e);}};
|
||||
exports.transfer=async(req,res,next)=>{try{const eventId=req.get("Idempotency-Key")||crypto.randomUUID(),result=await inventory.transferStock({...req.body,eventId,actorUserId:req.user.id,requestId:req.id});await audit(req,"INVENTORY_TRANSFERRED",eventId);res.status(result.idempotent?200:201).json({success:true,data:result});}catch(e){next(e);}};
|
||||
exports.warehouses=async(req,res,next)=>{try{res.json({success:true,data:await db.Warehouse.findAll({order:[["code","ASC"]]})});}catch(e){next(e);}};
|
||||
exports.createWarehouse=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{if(req.body.isDefault)await db.Warehouse.update({is_default:false},{where:{},transaction:t});row=await db.Warehouse.create({id:crypto.randomUUID(),code:req.body.code.toUpperCase(),name:req.body.name,status:req.body.status,is_default:req.body.isDefault,timezone:req.body.timezone,city:req.body.city,country_code:req.body.countryCode},{transaction:t});});await audit(req,"WAREHOUSE_CREATED",row.id);res.status(201).json({success:true,data:row});}catch(e){next(e);}};
|
||||
exports.updateWarehouse=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{row=await db.Warehouse.findByPk(req.params.id,{transaction:t,lock:t.LOCK.UPDATE});if(!row)throw Object.assign(new Error("Warehouse not found"),{status:404,code:"NOT_FOUND"});if(req.body.isDefault)await db.Warehouse.update({is_default:false},{where:{},transaction:t});await row.update({name:req.body.name,status:req.body.status,is_default:req.body.isDefault,timezone:req.body.timezone,city:req.body.city,country_code:req.body.countryCode},{transaction:t});});await audit(req,"WAREHOUSE_UPDATED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const crypto=require("crypto"),{Op}=require("sequelize"),db=require("../../models"),service=require("../../services/logistics/shipment.service"),state=require("../../services/logistics/shipmentState.service"),{logActivity}=require("../../services/activity.service");const key=req=>req.get("Idempotency-Key")||crypto.randomUUID(),audit=(req,type,id)=>logActivity({user:req.user,type,module:"Logistics",targetType:"SHIPMENT",targetId:id,requestId:req.id});exports.shipments=async(req,res,next)=>{try{const where={};if(req.query.status)where.status=req.query.status;res.json({success:true,data:await db.Shipment.findAll({where,include:[{model:db.ShipmentItem,as:"items"},{model:db.ShipmentAssignment,as:"assignments"}],order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.shipment=async(req,res,next)=>{try{const row=await db.Shipment.findByPk(req.params.id,{include:[{model:db.ShipmentItem,as:"items"},{model:db.ShipmentAssignment,as:"assignments"},{model:db.ShipmentEvent,as:"events"},{model:db.ShipmentProof,as:"proofs"}]});if(!row)throw Object.assign(new Error("Shipment not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:row});}catch(e){next(e);}};exports.create=async(req,res,next)=>{try{const result=await service.createDelivery({...req.body,actorUserId:req.user.id,eventId:`create:${key(req)}`});await audit(req,"SHIPMENT_CREATED",result.shipment.id);res.status(result.idempotent?200:201).json({success:true,data:result.shipment});}catch(e){next(e);}};exports.returnPickup=async(req,res,next)=>{try{const result=await service.createReturnPickup({...req.body,actorUserId:req.user.id,eventId:`return-pickup:${key(req)}`});res.status(result.idempotent?200:201).json({success:true,data:result.shipment});}catch(e){next(e);}};exports.assign=async(req,res,next)=>{try{const result=await service.assign({shipmentId:req.params.id,riderId:req.body.riderId,actorUserId:req.user.id,eventId:`assign:${key(req)}`});await audit(req,"RIDER_ASSIGNED",result.shipment.id);res.json({success:true,data:result.shipment});}catch(e){next(e);}};exports.unassign=async(req,res,next)=>{try{const row=await service.unassign({shipmentId:req.params.id,actorUserId:req.user.id,eventId:`unassign:${key(req)}`,reason:req.body.note});await audit(req,"RIDER_UNASSIGNED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};exports.reschedule=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{row=await db.Shipment.findByPk(req.params.id,{transaction:t,lock:t.LOCK.UPDATE});if(!row)throw Object.assign(new Error("Shipment not found"),{status:404,code:"NOT_FOUND"});state.assertTransition(row.status,"RESCHEDULED");await row.update({status:"RESCHEDULED",scheduled_date:req.body.scheduledDate,time_window_start:req.body.timeWindowStart,time_window_end:req.body.timeWindowEnd,notes:req.body.reason},{transaction:t});await db.ShipmentEvent.create({id:crypto.randomUUID(),shipment_id:row.id,event_id:`reschedule:${key(req)}`,type:"SHIPMENT_RESCHEDULED",status:"RESCHEDULED",actor_user_id:req.user.id,note:req.body.reason,occurred_at:new Date()},{transaction:t});});await audit(req,"SHIPMENT_RESCHEDULED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};exports.cancel=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{row=await db.Shipment.findByPk(req.params.id,{transaction:t,lock:t.LOCK.UPDATE});state.assertTransition(row.status,"CANCELLED");await row.update({status:"CANCELLED",cancelled_at:new Date()},{transaction:t});});await audit(req,"SHIPMENT_CANCELLED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};exports.dispatch=async(req,res,next)=>{try{res.json({success:true,data:{unassigned:await db.Shipment.findAll({where:{status:"READY_FOR_ASSIGNMENT"}}),active:await db.Shipment.findAll({where:{status:{[Op.in]:["ASSIGNED","PICKUP_PENDING","PICKED_UP","IN_TRANSIT","OUT_FOR_DELIVERY"]}}}),attention:await db.Shipment.findAll({where:{status:{[Op.in]:["DELIVERY_FAILED","RESCHEDULED"]}}})}});}catch(e){next(e);}};
|
||||
@@ -0,0 +1,4 @@
|
||||
const crypto=require("crypto"),{Op}=require("sequelize"),db=require("../../models"),service=require("../../services/logistics/shipment.service"),{logActivity}=require("../../services/activity.service");const key=req=>req.get("Idempotency-Key")||crypto.randomUUID();exports.list=async(req,res,next)=>{try{const rider=await db.RiderProfile.findOne({where:{user_id:req.user.id,status:"ACTIVE"}});if(!rider)throw Object.assign(new Error("Active rider required"),{status:403,code:"RIDER_UNAVAILABLE"});res.json({success:true,data:await db.Shipment.findAll({where:{assigned_rider_id:rider.id},include:[{model:db.ShipmentItem,as:"items"}],order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.get=async(req,res,next)=>{try{const rider=await db.RiderProfile.findOne({where:{user_id:req.user.id,status:"ACTIVE"}}),row=rider&&await db.Shipment.findOne({where:{id:req.params.id,assigned_rider_id:rider.id},include:[{model:db.ShipmentItem,as:"items"},{model:db.ShipmentEvent,as:"events"}]});if(!row)throw Object.assign(new Error("Assigned shipment not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:{id:row.id,shipmentNumber:row.shipment_number,recipientName:row.recipient_name,recipientPhone:row.recipient_phone,address:row.address_snapshot,status:row.status,items:row.items,events:row.events}});}catch(e){next(e);}};const action=to=>async(req,res,next)=>{try{const result=await service.riderAction({shipmentId:req.params.id,userId:req.user.id,to,eventId:`${to}:${key(req)}`,note:req.body.note,proof:req.body.type?req.body:undefined});await logActivity({user:req.user,type:`SHIPMENT_${to}`,module:"Logistics",targetId:result.shipment.id,requestId:req.id});res.json({success:true,data:result.shipment});}catch(e){next(e);}};exports.accept=action("PICKUP_PENDING");exports.pickup=action("PICKED_UP");exports.inTransit=action("IN_TRANSIT");exports.outForDelivery=action("OUT_FOR_DELIVERY");exports.deliver=action("DELIVERED");exports.fail=action("DELIVERY_FAILED");exports.location=async(req,res,next)=>{try{const rider=await db.RiderProfile.findOne({where:{user_id:req.user.id,status:"ACTIVE"}});if(!rider)throw Object.assign(new Error("Active rider required"),{status:403,code:"RIDER_UNAVAILABLE"});await rider.update({current_latitude:req.body.latitude,current_longitude:req.body.longitude,last_location_at:new Date()});res.status(204).end();}catch(e){next(e);}};
|
||||
exports.reject=async(req,res,next)=>{try{const rider=await db.RiderProfile.findOne({where:{user_id:req.user.id,status:"ACTIVE"}}),shipment=rider&&await db.Shipment.findOne({where:{id:req.params.id,assigned_rider_id:rider.id}});if(!shipment)throw Object.assign(new Error("Assigned shipment not found"),{status:404,code:"NOT_FOUND"});const row=await service.unassign({shipmentId:shipment.id,actorUserId:req.user.id,eventId:`reject:${key(req)}`,reason:req.body.note});await db.ShipmentAssignment.update({status:"REJECTED",rejected_at:new Date()},{where:{shipment_id:shipment.id,rider_id:rider.id,status:"UNASSIGNED"}});res.json({success:true,data:row});}catch(e){next(e);}};
|
||||
exports.adminUpdate=async(req,res,next)=>{try{const row=await db.RiderProfile.findByPk(req.params.id);if(!row)throw Object.assign(new Error("Rider not found"),{status:404,code:"NOT_FOUND"});const b=req.body;await row.update({employee_code:b.employeeCode,status:b.status,availability_status:b.availabilityStatus,phone_override:b.phoneOverride,vehicle_type:b.vehicleType,vehicle_registration:b.vehicleRegistration,max_active_assignments:b.maxActiveAssignments});await logActivity({user:req.user,type:"RIDER_UPDATED",module:"Logistics",targetId:row.id,requestId:req.id});res.json({success:true,data:row});}catch(e){next(e);}};
|
||||
exports.adminList=async(req,res,next)=>{try{res.json({success:true,data:await db.RiderProfile.findAll({include:[{model:db.User,as:"user",attributes:["id","firstName","lastName","accountStatus"]}]})});}catch(e){next(e);}};exports.adminGet=async(req,res,next)=>{try{const row=await db.RiderProfile.findByPk(req.params.id,{include:[{model:db.User,as:"user",attributes:["id","firstName","lastName","accountStatus"]}]});if(!row)throw Object.assign(new Error("Rider not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:row});}catch(e){next(e);}};exports.adminCreate=async(req,res,next)=>{try{const user=await db.User.findOne({where:{id:req.body.userId,accountType:"rider"}});if(!user)throw Object.assign(new Error("Existing RIDER user required"),{status:400,code:"RIDER_USER_REQUIRED"});const b=req.body,row=await db.RiderProfile.create({id:crypto.randomUUID(),user_id:user.id,employee_code:b.employeeCode,status:b.status,availability_status:b.availabilityStatus,phone_override:b.phoneOverride,vehicle_type:b.vehicleType,vehicle_registration:b.vehicleRegistration,max_active_assignments:b.maxActiveAssignments});await logActivity({user:req.user,type:"RIDER_CREATED",module:"Logistics",targetId:row.id,requestId:req.id});res.status(201).json({success:true,data:row});}catch(e){next(e);}};exports.assignments=async(req,res,next)=>{try{res.json({success:true,data:await db.ShipmentAssignment.findAll({where:{rider_id:req.params.id},order:[["assigned_at","DESC"]]})});}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const db=require("../../models");const project=s=>({shipmentNumber:s.shipment_number,type:s.type,status:s.status,shippingMethod:s.shipping_method_name,deliveredAt:s.delivered_at,events:s.events.map(e=>({type:e.type,status:e.status,note:e.note,occurredAt:e.occurred_at}))});exports.order=async(req,res,next)=>{try{const order=await db.Order.findOne({where:{id:req.params.orderId,user_id:req.user.id}});if(!order)throw Object.assign(new Error("Order not found"),{status:404,code:"NOT_FOUND"});const shipments=await db.Shipment.findAll({where:{order_id:order.id,type:"CUSTOMER_DELIVERY"},include:[{model:db.ShipmentEvent,as:"events",attributes:["type","status","note","occurred_at"]}],order:[["createdAt","ASC"]]});res.json({success:true,data:{orderNumber:order.order_number,shipments:shipments.map(project)}});}catch(e){next(e);}};exports.return=async(req,res,next)=>{try{const request=await db.ReturnRequest.findOne({where:{id:req.params.id,user_id:req.user.id}});if(!request)throw Object.assign(new Error("Return not found"),{status:404,code:"NOT_FOUND"});const shipment=await db.Shipment.findOne({where:{return_request_id:request.id,type:"RETURN_PICKUP"},include:[{model:db.ShipmentEvent,as:"events",attributes:["type","status","note","occurred_at"]}]});res.json({success:true,data:{returnNumber:request.return_number,shipment:shipment&&project(shipment)}});}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const crypto=require("crypto"),db=require("../../models"),loyalty=require("../../services/loyalty/loyalty.service"),{logActivity}=require("../../services/activity.service");const list=model=>async(req,res,next)=>{try{res.json({success:true,data:await model.findAll({order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.accounts=list(db.LoyaltyAccount);exports.tiers=list(db.LoyaltyTier);exports.rules=list(db.LoyaltyEarnRule);exports.rewards=list(db.LoyaltyReward);exports.referrals=list(db.Referral);exports.adjust=async(req,res,next)=>{try{const result=await loyalty.post({userId:req.body.userId,eventId:`LOYALTY:ADMIN:${req.get("Idempotency-Key")}`,type:"ADJUSTMENT",sourceType:"ADMIN_ADJUSTMENT",points:req.body.pointsDelta,descriptionCode:req.body.reasonCode,metadata:{note:req.body.note,actorUserId:req.user.id}});await logActivity({user:req.user,type:"LOYALTY_POINTS_ADJUSTED",module:"Loyalty",targetId:result.entry.id,requestId:req.id});res.status(result.idempotent?200:201).json({success:true,data:result.entry});}catch(e){next(e);}};exports.createTier=async(req,res,next)=>{try{const b=req.body,row=await db.LoyaltyTier.create({id:crypto.randomUUID(),code:b.code.toUpperCase(),name:b.name,status:b.status,rank:b.rank,qualification_threshold:b.qualificationThreshold,qualification_metric:"LIFETIME_POINTS",benefits_json:b.benefits,sort_order:b.sortOrder});res.status(201).json({success:true,data:row});}catch(e){next(e);}};exports.createRule=async(req,res,next)=>{try{const b=req.body,row=await db.LoyaltyEarnRule.create({id:crypto.randomUUID(),source_type:b.sourceType,status:b.status,points_per_amount:b.pointsPerAmount,amount_unit:b.amountUnit,fixed_points:b.fixedPoints,minimum_amount:b.minimumAmount,maximum_points:b.maximumPoints,expiry_days:b.expiryDays,effective_from:b.effectiveFrom,effective_to:b.effectiveTo});res.status(201).json({success:true,data:row});}catch(e){next(e);}};exports.createReward=async(req,res,next)=>{try{const b=req.body,row=await db.LoyaltyReward.create({id:crypto.randomUUID(),code:b.code.toUpperCase(),name:b.name,description:b.description,type:b.type,points_cost:b.pointsCost,status:b.status,starts_at:b.startsAt,ends_at:b.endsAt,stock_limit:b.stockLimit,per_user_limit:b.perUserLimit,configuration:b.configuration});res.status(201).json({success:true,data:row});}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const{Op}=require("sequelize"),db=require("../../models"),loyalty=require("../../services/loyalty/loyalty.service"),referrals=require("../../services/loyalty/referral.service");exports.summary=async(req,res,next)=>{try{const account=await db.sequelize.transaction(t=>loyalty.accountFor(req.user.id,t)),tier=account.current_tier_id&&await db.LoyaltyTier.findByPk(account.current_tier_id),next=await db.LoyaltyTier.findOne({where:{status:"ACTIVE",qualification_threshold:{[Op.gt]:account.lifetime_points_earned}},order:[["qualification_threshold","ASC"]]});res.json({success:true,data:{status:account.status,availablePoints:account.available_points,pendingPoints:account.pending_points,pointsDebt:account.points_debt,lifetimePointsEarned:account.lifetime_points_earned,tier:tier&&{code:tier.code,name:tier.name,benefits:tier.benefits_json},nextTier:next&&{name:next.name,pointsRequired:next.qualification_threshold-account.lifetime_points_earned}}});}catch(e){next(e);}};exports.history=async(req,res,next)=>{try{const account=await db.LoyaltyAccount.findOne({where:{user_id:req.user.id}});if(!account)return res.json({success:true,data:[],pagination:{page:1,total:0}});const page=Math.max(Number(req.query.page)||1,1),limit=Math.min(Number(req.query.limit)||20,100),x=await db.LoyaltyLedgerEntry.findAndCountAll({where:{loyalty_account_id:account.id},limit,offset:(page-1)*limit,order:[["occurred_at","DESC"]]});res.json({success:true,data:x.rows,pagination:{page,limit,total:x.count}});}catch(e){next(e);}};exports.tiers=async(req,res,next)=>{try{res.json({success:true,data:await db.LoyaltyTier.findAll({where:{status:"ACTIVE"},order:[["rank","ASC"]]})});}catch(e){next(e);}};exports.rewards=async(req,res,next)=>{try{res.json({success:true,data:await db.LoyaltyReward.findAll({where:{status:"ACTIVE"},order:[["points_cost","ASC"]]})});}catch(e){next(e);}};exports.redeem=async(req,res,next)=>{try{const result=await loyalty.redeem({userId:req.user.id,rewardId:req.body.rewardId,idempotencyKey:req.get("Idempotency-Key")});res.status(result.idempotent?200:201).json({success:true,data:result.redemption});}catch(e){next(e);}};exports.vouchers=async(req,res,next)=>{try{res.json({success:true,data:await db.CustomerCouponEntitlement.findAll({where:{user_id:req.user.id,status:"ACTIVE"},attributes:{exclude:["user_id"]}})});}catch(e){next(e);}};exports.referral=async(req,res,next)=>{try{res.json({success:true,data:await referrals.getOrCreate(req.user.id)});}catch(e){next(e);}};exports.claimReferral=async(req,res,next)=>{try{res.status(201).json({success:true,data:await referrals.claim({code:req.body.code,userId:req.user.id})});}catch(e){next(e);}};
|
||||
@@ -0,0 +1,5 @@
|
||||
const crypto=require("crypto"),db=require("../../models"),{logActivity}=require("../../services/activity.service");const map=(b,u)=>({name:b.name,type:b.type,status:b.status,starts_at:b.startsAt,ends_at:b.endsAt,priority:b.priority,stackable:b.stackable,discount_percent:b.discountPercent,discount_amount:b.discountAmount,fixed_price:b.fixedPrice,minimum_quantity:b.minimumQuantity,updated_by:u});
|
||||
const crud=model=>async(req,res,next)=>{try{res.json({success:true,data:await model.findAll({order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.promotions=crud(db.Promotion);exports.coupons=crud(db.Coupon);exports.banners=crud(db.Banner);
|
||||
exports.createPromotion=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{row=await db.Promotion.create({id:crypto.randomUUID(),...map(req.body,req.user.id),created_by:req.user.id},{transaction:t});await db.PromotionTarget.bulkCreate(req.body.targets.map(x=>({id:crypto.randomUUID(),promotion_id:row.id,target_type:x.type,target_id:x.id||null})),{transaction:t});});await logActivity({user:req.user,type:"PROMOTION_CREATED",module:"Merchandising",targetId:row.id});res.status(201).json({success:true,data:row});}catch(e){next(e);}};
|
||||
exports.createCoupon=async(req,res,next)=>{try{const b=req.body,row=await db.Coupon.create({id:crypto.randomUUID(),code:b.code,promotion_id:b.promotionId,status:b.status,starts_at:b.startsAt,expires_at:b.expiresAt});await logActivity({user:req.user,type:"COUPON_CREATED",module:"Merchandising",targetId:row.id});res.status(201).json({success:true,data:row});}catch(e){next(e);}};
|
||||
exports.createBanner=async(req,res,next)=>{try{const b=req.body;let row;await db.sequelize.transaction(async t=>{const upload=await db.Upload.findOne({where:{id:b.imageUploadId,status:"AVAILABLE"},transaction:t});if(!upload||!String(upload.mime_type||upload.mimeType).startsWith("image/"))throw Object.assign(new Error("Available image upload required"),{status:400,code:"INVALID_BANNER_MEDIA"});row=await db.Banner.create({id:crypto.randomUUID(),placement:b.placement,status:b.status,starts_at:b.startsAt,ends_at:b.endsAt,image_upload_id:b.imageUploadId,mobile_image_upload_id:b.mobileImageUploadId,target_url:b.targetUrl,audience_type:b.audienceType,business_tier_id:b.businessTierId,sort_order:b.sortOrder,created_by:req.user.id},{transaction:t});await db.BannerTranslation.bulkCreate(b.translations.map(x=>({id:crypto.randomUUID(),banner_id:row.id,locale:x.locale,headline:x.headline,subheadline:x.subheadline,cta_text:x.ctaText,alt_text:x.altText})),{transaction:t});});await logActivity({user:req.user,type:"BANNER_CREATED",module:"Merchandising",targetId:row.id});res.status(201).json({success:true,data:row});}catch(e){next(e);}};
|
||||
@@ -0,0 +1,2 @@
|
||||
const {Op}=require("sequelize"),db=require("../../models");exports.banners=async(req,res,next)=>{try{const now=new Date(),audience=req.user?.accountType==="BUSINESS_CUSTOMER"?["ALL","BUSINESS_CUSTOMER"]:req.user?["ALL","CUSTOMER"]:["ALL"];const where={status:"ACTIVE",audience_type:{[Op.in]:audience},[Op.and]:[{[Op.or]:[{starts_at:null},{starts_at:{[Op.lte]:now}}]},{[Op.or]:[{ends_at:null},{ends_at:{[Op.gt]:now}}]}]};if(req.query.placement)where.placement=req.query.placement;const rows=await db.Banner.findAll({where,include:[{model:db.BannerTranslation,as:"translations"}],order:[["sort_order","ASC"],["id","ASC"]]});res.json({success:true,data:rows.map(x=>({id:x.id,placement:x.placement,targetUrl:x.target_url,image:{uploadId:x.image_upload_id},translation:(x.translations.find(t=>t.locale===(req.query.locale||"en"))||x.translations.find(t=>t.locale==="en")||x.translations[0])}))});}catch(e){next(e);}};
|
||||
exports.availability=async(req,res,next)=>{try{const rows=await db.InventoryBalance.findAll({where:{variant_id:req.params.variantId},attributes:["on_hand","reserved","low_stock_threshold"]}),available=rows.reduce((n,x)=>n+Number(x.on_hand)-Number(x.reserved),0),threshold=rows.reduce((n,x)=>n+Number(x.low_stock_threshold),0),status=available<=0?"OUT_OF_STOCK":available<=threshold?"LOW_STOCK":"IN_STOCK";res.json({success:true,data:{variantId:req.params.variantId,status,availableForSale:available>0}});}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const metrics=require("../services/metrics.service");exports.read=(_req,res)=>res.type("text/plain; version=0.0.4").send(metrics.render());
|
||||
@@ -0,0 +1 @@
|
||||
const db=require("../models"),service=require("../services/marketing/newsletter.service");exports.subscribe=async(req,res,next)=>{try{const x=await service.subscribe({...req.body,userId:req.user?.id});res.status(x.idempotent?200:201).json({success:true,message:"Subscription request processed",data:{status:x.subscription.status}});}catch(e){next(e);}};exports.unsubscribe=async(req,res,next)=>{try{await service.unsubscribe(req.params.token);res.json({success:true,message:"Unsubscribe request processed"});}catch(e){next(e);}};exports.mine=async(req,res,next)=>{try{res.json({success:true,data:await db.NewsletterSubscription.findOne({where:{user_id:req.user.id},attributes:{exclude:["unsubscribe_token_hash"]}})});}catch(e){next(e);}};exports.list=async(req,res,next)=>{try{const page=Math.max(+req.query.page||1,1),limit=Math.min(+req.query.limit||50,100),x=await db.NewsletterSubscription.findAndCountAll({attributes:{exclude:["unsubscribe_token_hash"]},limit,offset:(page-1)*limit,order:[["createdAt","DESC"]]});res.json({success:true,data:x.rows,pagination:{page,limit,total:x.count}});}catch(e){next(e);}};
|
||||
@@ -1,138 +1,20 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Niolla
|
||||
* All rights reserved.
|
||||
*
|
||||
* This source code is proprietary and confidential.
|
||||
* Unauthorized copying, modification, distribution, or use
|
||||
* of this file, via any medium, is strictly prohibited.
|
||||
*/
|
||||
|
||||
// app/controllers/notification.controller.js
|
||||
|
||||
const crypto = require("crypto");
|
||||
const db = require("../models");
|
||||
const Notification = db.notification;
|
||||
const UserNotification = db.userNotification;
|
||||
const { Op } = require("sequelize");
|
||||
|
||||
const log = require("../utils/consoleLog.utill").log;
|
||||
|
||||
// Controller for managing notifications
|
||||
exports.createNotification = async (req, res) => {
|
||||
exports.createNotification = async (req, res, next) => {
|
||||
const { notificationHeadline, notificationDescription, notificationType, userIds = [] } = req.body;
|
||||
if (!notificationHeadline || !["USER", "ANNOUNCEMENT"].includes(notificationType)) return res.status(400).json({ success: false, error: { code: "VALIDATION_ERROR", message: "Valid headline and notificationType are required" } });
|
||||
if (notificationType === "USER" && (!Array.isArray(userIds) || !userIds.length)) return res.status(400).json({ success: false, error: { code: "VALIDATION_ERROR", message: "USER notifications require userIds" } });
|
||||
const transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
const { notificationHeadline, notificationDescription, notificationType } =
|
||||
req.body;
|
||||
|
||||
const id = Date.now().toString(); // Generate a unique ID based on the current timestamp
|
||||
const notification_id = `notif_${id}`; // Prefix the ID with "notif_"
|
||||
|
||||
// Create a new notification
|
||||
const newNotification = await Notification.create({
|
||||
notification_id,
|
||||
notificationHeadline,
|
||||
notificationDescription,
|
||||
notificationType,
|
||||
dateCreated: new Date(),
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: "Notification created successfully",
|
||||
notification: newNotification,
|
||||
});
|
||||
} catch (error) {
|
||||
log("Error creating notification:", error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "Internal server error",
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Mark a notification as read for a user
|
||||
exports.markAsRead = async (req, res) => {
|
||||
try {
|
||||
const { userId, notificationId } = req.params;
|
||||
|
||||
// Find the user notification entry
|
||||
const userNotification = await UserNotification.findOne({
|
||||
where: { user_id: userId, notification_id: notificationId },
|
||||
});
|
||||
|
||||
if (!userNotification) {
|
||||
return res.status(404).json({ success: false, error: "User notification not found" });
|
||||
}
|
||||
|
||||
// Mark the notification as read
|
||||
userNotification.isRead = true;
|
||||
await userNotification.save();
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "Notification marked as read",
|
||||
});
|
||||
}
|
||||
|
||||
catch (error) {
|
||||
log("Error marking notification as read:", error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "Internal server error",
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get announcements for all users
|
||||
exports.getAnnouncements = async (req, res) => {
|
||||
try {
|
||||
|
||||
// Fetch all announcements
|
||||
const announcements = await Notification.findAll({
|
||||
where: { notificationType: "ANNOUNCEMENT", isActive: true },
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
announcements,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
log("Error fetching announcements:", error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "Internal server error",
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get all notifications for a user
|
||||
exports.getUserNotifications = async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
|
||||
// Fetch notifications for the user
|
||||
const notifications = await UserNotification.findAll({
|
||||
where: { user_id: userId, isRead: false },
|
||||
include: [
|
||||
{
|
||||
model: Notification,
|
||||
as: "notification",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
notifications,
|
||||
});
|
||||
}
|
||||
|
||||
catch (error) {
|
||||
log("Error fetching user notifications:", error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "Internal server error",
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
const notification = await db.notification.create({ notification_id: `notif_${crypto.randomUUID()}`, notificationHeadline, notificationDescription, notificationType, dateCreated: new Date() }, { transaction });
|
||||
const uniqueUsers = [...new Set(userIds)];
|
||||
if (uniqueUsers.length) await db.userNotification.bulkCreate(uniqueUsers.map((user_id) => ({ user_id, notification_id: notification.notification_id })), { transaction, ignoreDuplicates: true });
|
||||
await transaction.commit(); return res.status(201).json({ success: true, data: { notification, assignedUsers: uniqueUsers.length } });
|
||||
} catch (error) { await transaction.rollback(); return next(error); }
|
||||
};
|
||||
exports.getAnnouncements = async (_req, res, next) => { try { return res.json({ success: true, data: await db.notification.findAll({ where: { notificationType: "ANNOUNCEMENT", isActive: true }, order: [["createdAt", "DESC"]] }) }); } catch (e) { return next(e); } };
|
||||
exports.getMyNotifications = async (req, res, next) => { try { const rows = await db.userNotification.findAll({ where: { user_id: req.user.id }, include: [{ model: db.notification, as: "notification", where: { isActive: true } }], order: [["createdAt", "DESC"]] }); return res.json({ success: true, data: rows }); } catch (e) { return next(e); } };
|
||||
exports.markAsRead = async (req, res, next) => { try { const [count] = await db.userNotification.update({ isRead: true }, { where: { user_id: req.user.id, notification_id: req.params.notificationId } }); if (!count) return res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "Notification not found" } }); return res.json({ success: true }); } catch (e) { return next(e); } };
|
||||
exports.markAllAsRead = async (req, res, next) => { try { const [count] = await db.userNotification.update({ isRead: true }, { where: { user_id: req.user.id, isRead: false } }); return res.json({ success: true, data: { updated: count } }); } catch (e) { return next(e); } };
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
const crypto=require("crypto"),db=require("../../models"),{logActivity}=require("../../services/activity.service");
|
||||
exports.list=async(req,res,next)=>{try{res.json({success:true,data:await db.VariantBusinessPrice.findAll({include:[{model:db.BusinessPriceTier,as:"tiers"}],order:[["createdAt","DESC"]]})});}catch(e){next(e);}};
|
||||
exports.create=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{const b=req.body;for(let i=0;i<b.tiers.length;i++){const a=b.tiers[i];if(a.maxQuantity!==null&&a.maxQuantity!==undefined&&a.maxQuantity<a.minQuantity)throw Object.assign(new Error("Invalid volume range"),{status:400,code:"INVALID_VOLUME_RANGE"});for(let j=i+1;j<b.tiers.length;j++){const c=b.tiers[j],amax=a.maxQuantity??Infinity,cmax=c.maxQuantity??Infinity;if(a.minQuantity<=cmax&&c.minQuantity<=amax)throw Object.assign(new Error("Volume tiers overlap"),{status:400,code:"OVERLAPPING_VOLUME_TIERS"});}}row=await db.VariantBusinessPrice.create({id:crypto.randomUUID(),variant_id:b.variantId,business_tier_id:b.businessTierId,business_customer_id:b.businessCustomerId,currency:b.currency,price:b.price,minimum_quantity:b.minimumQuantity,status:b.status,starts_at:b.startsAt,ends_at:b.endsAt},{transaction:t});await db.BusinessPriceTier.bulkCreate(b.tiers.map(x=>({id:crypto.randomUUID(),business_price_id:row.id,min_quantity:x.minQuantity,max_quantity:x.maxQuantity,unit_price:x.unitPrice})),{transaction:t});});await logActivity({user:req.user,type:"BUSINESS_PRICE_CREATED",module:"Pricing",targetId:row.id});res.status(201).json({success:true,data:row});}catch(e){next(e);}};
|
||||
@@ -28,20 +28,21 @@ const { log } = require("../utils/consoleLog.utill");
|
||||
// Get Profile avatar by user ID
|
||||
exports.getProfileAvatar = async (req, res) => {
|
||||
try {
|
||||
const userId = req.params.userId;
|
||||
const userId = req.params.userId || req.user.id;
|
||||
const profile = await Profile.findOne({ where: { user_id: userId } });
|
||||
if (!profile) {
|
||||
return res.status(404).json({ error: "Profile not found" });
|
||||
}
|
||||
|
||||
const uploadRecord = await Upload.findByPk(profile.profilePicture_id);
|
||||
if (!uploadRecord || uploadRecord.status !== "AVAILABLE" || (uploadRecord.visibility !== "PUBLIC" && uploadRecord.owner_id !== userId)) return res.status(404).json({ success: false, message: "Profile image not found" });
|
||||
|
||||
const fileUrl = await getSignedFileUrl(uploadRecord.file_path);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
userId: profile.userId,
|
||||
userId: profile.user_id,
|
||||
profilePictureUrl: fileUrl,
|
||||
},
|
||||
});
|
||||
@@ -58,7 +59,7 @@ exports.getProfileAvatar = async (req, res) => {
|
||||
// Get profile background image by user ID
|
||||
exports.getProfileBackgroundImage = async (req, res) => {
|
||||
try {
|
||||
const userId = req.params.userId;
|
||||
const userId = req.params.userId || req.user.id;
|
||||
const profile = await Profile.findOne({ where: { user_id: userId } });
|
||||
if (!profile) {
|
||||
return res
|
||||
@@ -67,13 +68,14 @@ exports.getProfileBackgroundImage = async (req, res) => {
|
||||
}
|
||||
|
||||
const uploadRecord = await Upload.findByPk(profile.backgroundImage_id);
|
||||
if (!uploadRecord || uploadRecord.status !== "AVAILABLE" || (uploadRecord.visibility !== "PUBLIC" && uploadRecord.owner_id !== userId)) return res.status(404).json({ success: false, message: "Profile background not found" });
|
||||
|
||||
const fileUrl = await getSignedFileUrl(uploadRecord.file_path);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
userId: profile.userId,
|
||||
userId: profile.user_id,
|
||||
backgroundImageUrl: fileUrl,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
const service=require("../services/recommendation/recommendation.service"),{resolveLocale}=require("../services/catalogue/locale.service");const loc=req=>resolveLocale(req),limit=req=>Math.min(Math.max(+req.query.limit||12,1),24);exports.event=async(req,res,next)=>{try{const x=await service.recordView({...req.body,userId:req.user?.id});res.status(x.created?201:200).json({success:true,data:{accepted:true,idempotent:!x.created}});}catch(e){next(e);}};exports.related=async(req,res,next)=>{try{res.json({success:true,data:await service.related(req.params.productId,loc(req),limit(req))});}catch(e){next(e);}};exports.trending=async(req,res,next)=>{try{res.json({success:true,data:await service.ranked({type:"trending",locale:loc(req),limit:limit(req)})});}catch(e){next(e);}};exports.popular=async(req,res,next)=>{try{res.json({success:true,data:await service.ranked({type:"popular",locale:loc(req),limit:limit(req)})});}catch(e){next(e);}};exports.recent=async(req,res,next)=>{try{res.json({success:true,data:await service.recent(req.user.id,loc(req),limit(req))});}catch(e){next(e);}};exports.forYou=async(req,res,next)=>{try{res.json({success:true,data:await service.ranked({type:"for-you",userId:req.user.id,locale:loc(req),limit:limit(req)})});}catch(e){next(e);}};
|
||||
@@ -0,0 +1,21 @@
|
||||
const db = require("../models");
|
||||
const { clearPermissionCache } = require("../utils/cache.util");
|
||||
const { logActivity } = require("../services/activity.service");
|
||||
|
||||
exports.assignRole = async (req, res, next) => {
|
||||
try {
|
||||
const [assignment] = await db.UserRole.findOrCreate({ where: { user_id: req.params.userId, role_id: req.params.roleId } });
|
||||
await clearPermissionCache(req.params.userId);
|
||||
await logActivity({ user: req.user, description: `Role ${req.params.roleId} assigned to user ${req.params.userId}`, type: "ROLE_ASSIGNED", module: "Authorization" });
|
||||
res.status(201).json({ success: true, data: { id: assignment.id, userId: assignment.user_id, roleId: assignment.role_id } });
|
||||
} catch (error) { next(error); }
|
||||
};
|
||||
|
||||
exports.removeRole = async (req, res, next) => {
|
||||
try {
|
||||
await db.UserRole.destroy({ where: { user_id: req.params.userId, role_id: req.params.roleId } });
|
||||
await clearPermissionCache(req.params.userId);
|
||||
await logActivity({ user: req.user, description: `Role ${req.params.roleId} removed from user ${req.params.userId}`, type: "ROLE_REMOVED", module: "Authorization" });
|
||||
res.json({ success: true, message: "Role removed" });
|
||||
} catch (error) { next(error); }
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
const crypto=require("crypto"),db=require("../../models"),service=require("../../services/shipping/shipping.service"),cart=require("../../services/shopping/cart.service"),{logActivity}=require("../../services/activity.service");const audit=(req,type,id)=>logActivity({user:req.user,type,module:"Shipping",targetId:id,requestId:req.id});exports.quote=async(req,res,next)=>{try{const address=await db.Address.findOne({where:{id:req.body.addressId,user_id:req.user.id}});if(!address)throw Object.assign(new Error("Address not found"),{status:404,code:"NOT_FOUND"});const projection=await cart.project(req.user.id);res.json({success:true,data:await service.getAvailableMethods({address,subtotal:projection.merchandiseTotal,currency:projection.currency})});}catch(e){next(e);}};const list=model=>async(req,res,next)=>{try{res.json({success:true,data:await model.findAll()});}catch(e){next(e);}};exports.zones=list(db.ShippingZone);exports.methods=list(db.ShippingMethod);exports.rates=list(db.ShippingRate);exports.createZone=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{row=await db.ShippingZone.create({id:crypto.randomUUID(),code:req.body.code.toUpperCase(),name:req.body.name,status:req.body.status,duty_mode:req.body.dutyMode},{transaction:t});await db.ShippingZoneRegion.bulkCreate(req.body.regions.map(x=>({id:crypto.randomUUID(),shipping_zone_id:row.id,country_code:x.countryCode.toUpperCase(),province:x.province,district:x.district})),{transaction:t});});await audit(req,"SHIPPING_ZONE_CREATED",row.id);res.status(201).json({success:true,data:row});}catch(e){next(e);}};exports.createMethod=async(req,res,next)=>{try{const b=req.body,row=await db.ShippingMethod.create({id:crypto.randomUUID(),code:b.code.toUpperCase(),name:b.name,description:b.description,status:b.status,estimated_min_days:b.estimatedMinDays,estimated_max_days:b.estimatedMaxDays,tracking_supported:b.trackingSupported});await audit(req,"SHIPPING_METHOD_CREATED",row.id);res.status(201).json({success:true,data:row});}catch(e){next(e);}};exports.createRate=async(req,res,next)=>{try{const b=req.body,row=await db.ShippingRate.create({id:crypto.randomUUID(),shipping_zone_id:b.shippingZoneId,shipping_method_id:b.shippingMethodId,currency:b.currency.toUpperCase(),base_amount:b.baseAmount,free_shipping_threshold:b.freeShippingThreshold,minimum_subtotal:b.minimumSubtotal,maximum_subtotal:b.maximumSubtotal,status:b.status,starts_at:b.startsAt,ends_at:b.endsAt});await audit(req,"SHIPPING_RATE_CREATED",row.id);res.status(201).json({success:true,data:row});}catch(e){next(e);}};
|
||||
exports.updateZone=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{row=await db.ShippingZone.findByPk(req.params.id,{transaction:t,lock:t.LOCK.UPDATE});if(!row)throw Object.assign(new Error("Zone not found"),{status:404,code:"NOT_FOUND"});await row.update({code:req.body.code.toUpperCase(),name:req.body.name,status:req.body.status,duty_mode:req.body.dutyMode},{transaction:t});await db.ShippingZoneRegion.destroy({where:{shipping_zone_id:row.id},transaction:t});await db.ShippingZoneRegion.bulkCreate(req.body.regions.map(x=>({id:crypto.randomUUID(),shipping_zone_id:row.id,country_code:x.countryCode.toUpperCase(),province:x.province,district:x.district})),{transaction:t});});await audit(req,"SHIPPING_ZONE_UPDATED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};
|
||||
exports.updateMethod=async(req,res,next)=>{try{const b=req.body,row=await db.ShippingMethod.findByPk(req.params.id);if(!row)throw Object.assign(new Error("Method not found"),{status:404,code:"NOT_FOUND"});await row.update({code:b.code.toUpperCase(),name:b.name,description:b.description,status:b.status,estimated_min_days:b.estimatedMinDays,estimated_max_days:b.estimatedMaxDays,tracking_supported:b.trackingSupported});await audit(req,"SHIPPING_METHOD_UPDATED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};
|
||||
exports.updateRate=async(req,res,next)=>{try{const b=req.body,row=await db.ShippingRate.findByPk(req.params.id);if(!row)throw Object.assign(new Error("Rate not found"),{status:404,code:"NOT_FOUND"});await row.update({shipping_zone_id:b.shippingZoneId,shipping_method_id:b.shippingMethodId,currency:b.currency.toUpperCase(),base_amount:b.baseAmount,free_shipping_threshold:b.freeShippingThreshold,minimum_subtotal:b.minimumSubtotal,maximum_subtotal:b.maximumSubtotal,status:b.status,starts_at:b.startsAt,ends_at:b.endsAt});await audit(req,"SHIPPING_RATE_UPDATED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const db=require("../../models"),service=require("../../services/shopping/cart.service"),{logActivity}=require("../../services/activity.service");const audit=(req,type,id)=>logActivity({user:req.user,type,module:"Shopping",description:type,targetType:"CART",targetId:id,requestId:req.id});exports.get=async(req,res,next)=>{try{res.json({success:true,data:await service.project(req.user.id)});}catch(e){next(e);}};exports.add=async(req,res,next)=>{try{const x=await service.add(req.user.id,req.body.variantId,req.body.quantity);await audit(req,"CART_ITEM_ADDED",x.id);res.status(201).json({success:true,data:await service.project(req.user.id)});}catch(e){next(e);}};exports.update=async(req,res,next)=>{try{const x=await service.mutate(req.user.id,req.params.itemId,req.body.quantity);await audit(req,"CART_ITEM_UPDATED",x.id);res.json({success:true,data:await service.project(req.user.id)});}catch(e){next(e);}};exports.remove=async(req,res,next)=>{try{await service.mutate(req.user.id,req.params.itemId,0);await audit(req,"CART_ITEM_REMOVED",req.params.itemId);res.status(204).end();}catch(e){next(e);}};exports.clear=async(req,res,next)=>{try{const cart=await service.activeCart(req.user.id);await db.CartItem.destroy({where:{cart_id:cart.id}});await cart.increment("version");await audit(req,"CART_CLEARED",cart.id);res.status(204).end();}catch(e){next(e);}};exports.coupon=async(req,res,next)=>{try{const cart=await service.activeCart(req.user.id);await cart.update({coupon_code:req.body.code});res.json({success:true,data:await service.project(req.user.id)});}catch(e){next(e);}};exports.removeCoupon=async(req,res,next)=>{try{const cart=await service.activeCart(req.user.id);await cart.update({coupon_code:null});res.status(204).end();}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const service=require("../../services/shopping/checkout.service"),{logActivity}=require("../../services/activity.service");exports.create=async(req,res,next)=>{try{const result=await service.create({userId:req.user.id,...req.body,idempotencyKey:req.get("Idempotency-Key"),requestId:req.id});if(!result.idempotent)await logActivity({user:req.user,type:"CHECKOUT_CREATED",module:"Checkout",targetId:result.checkout.id,requestId:req.id});res.status(result.idempotent?200:201).json({success:true,data:result.checkout});}catch(e){next(e);}};exports.get=async(req,res,next)=>{try{res.json({success:true,data:await service.getOwned(req.params.id,req.user.id)});}catch(e){next(e);}};exports.active=async(req,res,next)=>{try{const db=require("../../models"),row=await db.CheckoutSession.findOne({where:{user_id:req.user.id,status:["PENDING","READY"]},include:[{model:db.CheckoutItem,as:"items"}],order:[["createdAt","DESC"]]});res.json({success:true,data:row});}catch(e){next(e);}};exports.cancel=async(req,res,next)=>{try{const row=await service.close({id:req.params.id,userId:req.user.id,status:"CANCELLED",requestId:req.id});await logActivity({user:req.user,type:"CHECKOUT_CANCELLED",module:"Checkout",targetId:row.id,requestId:req.id});res.json({success:true,data:row});}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const crypto=require("crypto"),db=require("../../models"),{logActivity}=require("../../services/activity.service");exports.list=async(req,res,next)=>{try{res.json({success:true,data:await db.WishlistItem.findAll({where:{user_id:req.user.id},include:[{model:db.Product,as:"product",where:{status:"ACTIVE",visibility:"PUBLIC"}}]})});}catch(e){next(e);}};exports.add=async(req,res,next)=>{try{const product=await db.Product.findOne({where:{id:req.body.productId,status:"ACTIVE",visibility:"PUBLIC"}});if(!product)throw Object.assign(new Error("Product not found"),{status:404,code:"NOT_FOUND"});const[row,created]=await db.WishlistItem.findOrCreate({where:{user_id:req.user.id,product_id:product.id},defaults:{id:crypto.randomUUID()}});if(created)await logActivity({user:req.user,type:"WISHLIST_ITEM_ADDED",module:"Shopping",targetId:row.id});res.status(created?201:200).json({success:true,data:row});}catch(e){next(e);}};exports.remove=async(req,res,next)=>{try{const n=await db.WishlistItem.destroy({where:{user_id:req.user.id,product_id:req.params.productId}});if(!n)throw Object.assign(new Error("Wishlist item not found"),{status:404,code:"NOT_FOUND"});await logActivity({user:req.user,type:"WISHLIST_ITEM_REMOVED",module:"Shopping",targetId:req.params.productId});res.status(204).end();}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const{Op}=require("sequelize"),db=require("../../models"),support=require("../../services/support/support.service");exports.list=async(req,res,next)=>{try{const page=Math.max(+req.query.page||1,1),limit=Math.min(+req.query.limit||20,100),where={...(req.query.status&&{status:req.query.status}),...(req.query.priority&&{priority:req.query.priority}),...(req.query.category&&{category_id:req.query.category}),...(req.query.assignedAgentId&&{assigned_agent_id:req.query.assignedAgentId}),...(req.query.business&&{business_customer_id:req.query.business}),...(req.query.unassigned==="true"&&{assigned_agent_id:null}),...(req.query.overdue==="true"&&{resolution_due_at:{[Op.lt]:new Date()},status:{[Op.notIn]:["RESOLVED","CLOSED","CANCELLED"]}})},x=await db.SupportTicket.findAndCountAll({where,order:[["createdAt","DESC"]],limit,offset:(page-1)*limit});res.json({success:true,data:x.rows,pagination:{page,limit,total:x.count}});}catch(e){next(e);}};exports.detail=async(req,res,next)=>{try{const ticket=await db.SupportTicket.findByPk(req.params.id);if(!ticket)return res.status(404).json({success:false,error:{code:"TICKET_NOT_FOUND",message:"Ticket not found"}});const[messages,attachments,links,events,escalations]=await Promise.all([db.SupportMessage.findAll({where:{ticket_id:ticket.id},order:[["createdAt","ASC"]]}),db.SupportAttachment.findAll({where:{ticket_id:ticket.id}}),db.SupportTicketLink.findAll({where:{ticket_id:ticket.id}}),db.SupportTicketEvent.findAll({where:{ticket_id:ticket.id},order:[["occurred_at","ASC"]]}),db.SupportEscalation.findAll({where:{ticket_id:ticket.id}})]);res.json({success:true,data:{ticket,messages,attachments,links,events,escalations}});}catch(e){next(e);}};exports.assign=async(req,res,next)=>{try{res.json({success:true,data:await support.assign(req.params.id,req.body.agentId,req.user)});}catch(e){next(e);}};exports.claim=async(req,res,next)=>{try{res.json({success:true,data:await support.assign(req.params.id,req.user.id,req.user,true)});}catch(e){next(e);}};exports.reply=async(req,res,next)=>{try{res.status(201).json({success:true,data:await support.agentMessage(req.user,req.params.id,req.body)});}catch(e){next(e);}};exports.note=async(req,res,next)=>{try{res.status(201).json({success:true,data:await support.agentMessage(req.user,req.params.id,req.body,true)});}catch(e){next(e);}};exports.action=to=>async(req,res,next)=>{try{res.json({success:true,data:await support.transition(req.params.id,to,req.user)});}catch(e){next(e);}};exports.priority=async(req,res,next)=>{try{const row=await db.SupportTicket.findByPk(req.params.id);if(!row)return res.status(404).json({success:false,error:{code:"TICKET_NOT_FOUND",message:"Ticket not found"}});const from=row.priority;await row.update({priority:req.body.priority});await db.SupportTicketEvent.create({id:require("crypto").randomUUID(),event_id:require("crypto").randomUUID(),ticket_id:row.id,actor_user_id:req.user.id,type:"PRIORITY_CHANGED",from_value:from,to_value:row.priority,occurred_at:new Date()});res.json({success:true,data:row});}catch(e){next(e);}};exports.attachment=async(req,res,next)=>{try{res.json({success:true,data:{url:await support.attachmentUrl(req.params.id,req.params.attachmentId,req.user,true)}});}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const db=require("../../models"),support=require("../../services/support/support.service");const page=req=>({page:Math.max(Number(req.query.page)||1,1),limit:Math.min(Math.max(Number(req.query.limit)||20,1),100)});exports.create=async(req,res,next)=>{try{res.status(201).json({success:true,data:await support.createTicket(req.user,req.body)});}catch(e){next(e);}};exports.list=async(req,res,next)=>{try{const p=page(req),where={customer_user_id:req.user.id,...(req.query.status&&{status:req.query.status}),...(req.query.category&&{category_id:req.query.category})},x=await db.SupportTicket.findAndCountAll({where,order:[["createdAt","DESC"]],limit:p.limit,offset:(p.page-1)*p.limit});res.json({success:true,data:x.rows,pagination:{...p,total:x.count}});}catch(e){next(e);}};exports.detail=async(req,res,next)=>{try{const ticket=await support.ownTicket(req.params.id,req.user.id),[messages,attachments,links,events]=await Promise.all([db.SupportMessage.findAll({where:{ticket_id:ticket.id,visibility:"CUSTOMER_VISIBLE"},order:[["createdAt","ASC"]]}),db.SupportAttachment.findAll({where:{ticket_id:ticket.id,visibility:"CUSTOMER_VISIBLE"}}),db.SupportTicketLink.findAll({where:{ticket_id:ticket.id}}),db.SupportTicketEvent.findAll({where:{ticket_id:ticket.id,type:["TICKET_CREATED","TICKET_ASSIGNED","TICKET_RESOLVED","TICKET_CLOSED","TICKET_REOPENED"]},attributes:{exclude:["metadata"]},order:[["occurred_at","ASC"]]})]);res.json({success:true,data:{ticket,messages,attachments,links,events}});}catch(e){next(e);}};exports.reply=async(req,res,next)=>{try{res.status(201).json({success:true,data:await support.customerReply(req.user,req.params.id,req.body)});}catch(e){next(e);}};exports.close=async(req,res,next)=>{try{await support.ownTicket(req.params.id,req.user.id);res.json({success:true,data:await support.transition(req.params.id,"CLOSED",req.user)});}catch(e){next(e);}};exports.attachment=async(req,res,next)=>{try{res.json({success:true,data:{url:await support.attachmentUrl(req.params.id,req.params.attachmentId,req.user,false)}});}catch(e){next(e);}};
|
||||
@@ -1,131 +1,45 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Niolla
|
||||
* All rights reserved.
|
||||
*
|
||||
* This source code is proprietary and confidential.
|
||||
* Unauthorized copying, modification, distribution, or use
|
||||
* of this file, via any medium, is strictly prohibited.
|
||||
*/
|
||||
|
||||
// app/controllers/activity.controller.js
|
||||
|
||||
const { uploadToS3, getSignedFileUrl } = require("../utils/s3Upload.utill");
|
||||
const { log } = require("../utils/consoleLog.utill");
|
||||
|
||||
const db = require("../models");
|
||||
const Upload = db.Upload;
|
||||
const Assets = db.Assets;
|
||||
const storage = require("../services/storage/storage.service");
|
||||
const { validateUpload } = require("../services/storage/file-validation.service");
|
||||
|
||||
// Constants
|
||||
const MAX_IMAGE_SIZE = 3 * 1024 * 1024; // 3MB
|
||||
const MAX_PDF_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const BUSINESS_DOCUMENT_PURPOSES = new Set(["BUSINESS_REGISTRATION", "TAX_DOCUMENT", "IDENTITY_DOCUMENT", "OTHER_SUPPORTING_DOCUMENT"]);
|
||||
const canAccess = (user, upload) => upload.visibility === "PUBLIC" || upload.owner_id === user.id || user.accountType === "superadmin" || (user.permissions || []).includes("media.read") || (BUSINESS_DOCUMENT_PURPOSES.has(upload.use_for) && (user.permissions || []).includes("business.applications.review"));
|
||||
|
||||
const isValidFileType = (mimetype) => {
|
||||
return mimetype.startsWith("image/") || mimetype === "application/pdf";
|
||||
};
|
||||
|
||||
const isValidFileSize = (mimetype, size) => {
|
||||
if (mimetype.startsWith("image/")) return size <= MAX_IMAGE_SIZE;
|
||||
if (mimetype === "application/pdf") return size <= MAX_PDF_SIZE;
|
||||
return false;
|
||||
};
|
||||
|
||||
exports.uploadFile = async (req, res) => {
|
||||
exports.uploadFile = async (req, res, next) => {
|
||||
let objectKey;
|
||||
try {
|
||||
// 1. Check file exists
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "No file uploaded",
|
||||
});
|
||||
}
|
||||
|
||||
const { mimetype, size, originalname } = req.file;
|
||||
|
||||
// 2. Validate file type
|
||||
if (!isValidFileType(mimetype)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Only images and PDFs are allowed",
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Validate file size
|
||||
if (!isValidFileSize(mimetype, size)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: mimetype.startsWith("image/")
|
||||
? "Image too large (max 1MB)"
|
||||
: "PDF too large (max 5MB)",
|
||||
});
|
||||
}
|
||||
|
||||
log("File validation passed:", {
|
||||
mimetype,
|
||||
size,
|
||||
originalname,
|
||||
use_for: req.body.use_for,
|
||||
});
|
||||
|
||||
// 4. Upload to S3
|
||||
const fileKey = await uploadToS3(req.file, req.body.use_for);
|
||||
|
||||
const fileUrl = await getSignedFileUrl(fileKey);
|
||||
|
||||
// 5. Save to DB
|
||||
const uploadData = {
|
||||
file_path: fileKey,
|
||||
file_type: mimetype,
|
||||
file_size: size,
|
||||
original_name: originalname,
|
||||
use_for: req.body.use_for || null,
|
||||
uploaded_by: req.user?.id || null,
|
||||
};
|
||||
|
||||
const newUpload = await Upload.create(uploadData);
|
||||
|
||||
newUpload.dataValues.file_url = fileUrl; // Add URL to response
|
||||
|
||||
// 6. Response
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
message: "File uploaded successfully",
|
||||
data: newUpload,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Upload Controller Error:", error);
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "Failed to upload file",
|
||||
error: process.env.NODE_ENV === "development" ? error.message : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get Uploaded File URL
|
||||
exports.getFileUrl = async (req, res) => {
|
||||
const details = validateUpload(req.file);
|
||||
const purpose = String(req.body.use_for || "generic").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 60);
|
||||
objectKey = storage.createObjectKey({ ownerId: req.user.id, mimeType: details.mimeType, purpose });
|
||||
await storage.uploadBuffer({ buffer: req.file.buffer, objectKey, mimeType: details.mimeType, checksum: details.checksum });
|
||||
let record;
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const uploadRecord = await Upload.findByPk(id);
|
||||
|
||||
if (!uploadRecord) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "File not found",
|
||||
});
|
||||
}
|
||||
|
||||
const fileUrl = await getSignedFileUrl(uploadRecord.file_path);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: "File URL retrieved successfully",
|
||||
data: {
|
||||
id: uploadRecord.id,
|
||||
file_url: fileUrl,
|
||||
},
|
||||
});
|
||||
} catch (error) {}
|
||||
record = await db.Upload.create({ file_path: objectKey, file_type: details.mimeType, file_size: details.size, original_name: req.file.originalname, safe_name: details.safeName, checksum: details.checksum, use_for: purpose, uploaded_by: req.user.id, owner_type: "USER", owner_id: req.user.id, visibility: "PRIVATE", status: "AVAILABLE" });
|
||||
} catch (error) { await storage.deleteObject(objectKey).catch(() => undefined); throw error; }
|
||||
const expiresIn = Number(process.env.S3_SIGNED_URL_TTL_SECONDS || 900);
|
||||
return res.status(201).json({ success: true, data: { id: record.id, originalName: record.original_name, mimeType: record.file_type, size: record.file_size, purpose: record.use_for, status: record.status, url: await storage.createSignedDownloadUrl(objectKey, expiresIn), expiresIn } });
|
||||
} catch (error) { return next(error); }
|
||||
};
|
||||
|
||||
exports.getFileUrl = async (req, res, next) => {
|
||||
try {
|
||||
const upload = await db.Upload.findByPk(req.params.id);
|
||||
if (!upload || upload.status === "DELETED") return res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "File not found" } });
|
||||
if (!canAccess(req.user, upload)) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } });
|
||||
if (upload.status !== "AVAILABLE") return res.status(409).json({ success: false, error: { code: "FILE_UNAVAILABLE", message: "File is not available" } });
|
||||
const expiresIn = Number(process.env.S3_SIGNED_URL_TTL_SECONDS || 900);
|
||||
return res.json({ success: true, data: { id: upload.id, url: await storage.createSignedDownloadUrl(upload.file_path, expiresIn), expiresIn } });
|
||||
} catch (error) { return next(error); }
|
||||
};
|
||||
|
||||
exports.deleteFile = async (req, res, next) => {
|
||||
try {
|
||||
const upload = await db.Upload.findByPk(req.params.id);
|
||||
if (!upload || upload.status === "DELETED") return res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "File not found" } });
|
||||
const allowed = upload.owner_id === req.user.id || req.user.accountType === "super_admin" || (req.user.permissions || []).includes("media.delete");
|
||||
if (!allowed) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } });
|
||||
await upload.update({ status: "DELETED", deletedAt: new Date() });
|
||||
await storage.deleteObject(upload.file_path).catch(() => undefined);
|
||||
return res.status(204).end();
|
||||
} catch (error) { return next(error); }
|
||||
};
|
||||
|
||||
@@ -11,9 +11,26 @@
|
||||
|
||||
const db = require("../models");
|
||||
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 {
|
||||
createCustomerDetails,
|
||||
} = require("../utils/users/createCustomerDetails.util");
|
||||
const {
|
||||
createBusinessCustomerDetails,
|
||||
} = require("../utils/users/createBusinessCustomer.util");
|
||||
const { logActivity } = require("../services/activity.service");
|
||||
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 Profile = db.Profile;
|
||||
|
||||
@@ -26,14 +43,48 @@ exports.createNewUser = async (req, res) => {
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
role,
|
||||
roleID,
|
||||
accountType,
|
||||
department,
|
||||
password,
|
||||
address,
|
||||
phoneNumber,
|
||||
businessName,
|
||||
businessRegistrationNumber,
|
||||
businessType,
|
||||
contactName,
|
||||
businessEmail,
|
||||
expectedMonthlyVolume,
|
||||
note,
|
||||
} = req.body;
|
||||
|
||||
const hashedPassword = await hashPassword(process.env.DEFAULT_PASSWORD);
|
||||
const userID = generateUserId();
|
||||
if (!firstName || !lastName || !email || !password) {
|
||||
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 } });
|
||||
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(
|
||||
{
|
||||
id: userID,
|
||||
@@ -52,10 +117,9 @@ exports.createNewUser = async (req, res) => {
|
||||
lastName,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
accountType,
|
||||
role,
|
||||
roleID: roleID || "N/A",
|
||||
department: department || null,
|
||||
accountType: "customer",
|
||||
accountStatus: "PENDING_VERIFICATION",
|
||||
emailVerifiedAt: null,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
@@ -74,22 +138,35 @@ exports.createNewUser = async (req, res) => {
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
await sendMail({
|
||||
to: email,
|
||||
subject: "Welcome - Ocenic Titan",
|
||||
templateName: "welcome",
|
||||
templateVars: {
|
||||
firstName: firstName,
|
||||
email: email,
|
||||
password: process.env.DEFAULT_PASSWORD,
|
||||
},
|
||||
text: `Hello ${firstName}, your account has been created successfully.`,
|
||||
});
|
||||
// Create the customer identity extension for public registration.
|
||||
{
|
||||
const customerData = {
|
||||
address,phoneNumber,
|
||||
};
|
||||
|
||||
await createCustomerDetails(
|
||||
newUser.id,
|
||||
customerData,
|
||||
transaction,
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
user: req.user,
|
||||
user: newUser,
|
||||
description: `Created New User with ID: ${newUser.id}`,
|
||||
type: "CREATE_USER",
|
||||
module: "User Management",
|
||||
@@ -104,12 +181,15 @@ exports.createNewUser = async (req, res) => {
|
||||
lastName: newUser.lastName,
|
||||
email: newUser.email,
|
||||
accountType: newUser.accountType,
|
||||
role: newUser.role,
|
||||
department: newUser.department,
|
||||
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (!transaction.finished) {
|
||||
await transaction.rollback();
|
||||
}
|
||||
|
||||
console.error("CREATE USER ERROR:", error);
|
||||
|
||||
res.status(500).send({
|
||||
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)
|
||||
exports.getAllUsers = async (req, res) => {
|
||||
try {
|
||||
@@ -152,36 +328,81 @@ exports.getAllUsers = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 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" }],
|
||||
});
|
||||
//get user profile details
|
||||
exports.userProfile = async (req, res) => {
|
||||
|
||||
if (!user) {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
|
||||
const profile =
|
||||
await getUserProfile(userId);
|
||||
|
||||
|
||||
if (!profile) {
|
||||
return res.status(404).send({
|
||||
success: false,
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).send({
|
||||
|
||||
return res.status(200).send({
|
||||
success: true,
|
||||
data: user,
|
||||
message:
|
||||
"User profile retrieved successfully",
|
||||
|
||||
data: profile,
|
||||
});
|
||||
|
||||
|
||||
} catch (error) {
|
||||
res.status(500).send({
|
||||
|
||||
console.error(
|
||||
"GET USER PROFILE ERROR:",
|
||||
error
|
||||
);
|
||||
|
||||
|
||||
return res.status(500).send({
|
||||
success: false,
|
||||
message: "Failed to retrieve user",
|
||||
error: error.message,
|
||||
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
|
||||
exports.updateUser = async (req, res) => {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
@@ -210,6 +431,7 @@ exports.updateUser = async (req, res) => {
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
await transaction.rollback();
|
||||
return res.status(404).send({
|
||||
success: false,
|
||||
message: "User not found",
|
||||
@@ -217,6 +439,7 @@ exports.updateUser = async (req, res) => {
|
||||
}
|
||||
|
||||
if (!UserProfile) {
|
||||
await transaction.rollback();
|
||||
return res.status(404).send({
|
||||
success: false,
|
||||
message: "User profile not found",
|
||||
@@ -294,6 +517,7 @@ exports.deleteUser = async (req, res) => {
|
||||
where: { id },
|
||||
});
|
||||
if (!user) {
|
||||
await transaction.rollback();
|
||||
return res.status(404).send({
|
||||
success: false,
|
||||
message: "User not found",
|
||||
@@ -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); }
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
const db=require("../../models"),credit=require("../../services/wholesale/credit.service"),settlements=require("../../services/wholesale/settlement.service"),{logActivity}=require("../../services/activity.service");exports.credit=async(req,res,next)=>{try{if(!req.get("Idempotency-Key"))throw Object.assign(new Error("Idempotency-Key is required"),{status:400,code:"IDEMPOTENCY_KEY_REQUIRED"});const result=await credit.post({...req.body,currency:req.body.currency.toUpperCase(),eventId:`CREDIT:${req.get("Idempotency-Key")}`,actorUserId:req.user.id});await logActivity({user:req.user,type:`CREDIT_${req.body.type}`,module:"Wholesale",targetId:result.entry.id,requestId:req.id});res.status(result.idempotent?200:201).json({success:true,data:result});}catch(e){next(e);}};exports.creditHistory=async(req,res,next)=>{try{res.json({success:true,data:await db.BusinessCreditLedgerEntry.findAll({where:{business_customer_id:req.params.businessId},order:[["occurred_at","DESC"]],limit:200})});}catch(e){next(e);}};exports.generate=async(req,res,next)=>{try{const result=await settlements.generate({...req.body,currency:req.body.currency.toUpperCase()});res.status(result.idempotent?200:201).json({success:true,data:result.settlement});}catch(e){next(e);}};exports.settlements=async(req,res,next)=>{try{res.json({success:true,data:await db.BusinessSettlement.findAll({order:[["period_end","DESC"]]})});}catch(e){next(e);}};exports.action=to=>async(req,res,next)=>{try{res.json({success:true,data:await settlements.transition(req.params.id,to,req.user.id)});}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const service=require("../../services/wholesale/creditPurchase.service"),{logActivity}=require("../../services/activity.service");exports.purchase=async(req,res,next)=>{try{const key=req.get("Idempotency-Key");if(!key)throw Object.assign(new Error("Idempotency-Key is required"),{status:400,code:"IDEMPOTENCY_KEY_REQUIRED"});const result=await service.purchase({orderId:req.params.id,userId:req.user.id,eventId:`CREDIT:ORDER:${key}`});if(!result.idempotent)await logActivity({user:req.user,type:"CREDIT_CAPTURED",module:"Wholesale",targetId:result.credit.entry.id,requestId:req.id});res.status(result.idempotent?200:201).json({success:true,data:{orderId:result.order.id,paymentStatus:result.order.payment_status,credit:result.credit}});}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const{QueryTypes}=require("sequelize"),db=require("../../models"),money=require("../../services/pricing/money");async function business(userId){const row=await db.BusinessCustomer.findOne({where:{user_id:userId,status:"ACTIVE"}});if(!row)throw Object.assign(new Error("Active business account required"),{status:403,code:"BUSINESS_REQUIRED"});return row;}exports.dashboard=async(req,res,next)=>{try{const b=await business(req.user.id),credit=await db.BusinessCreditAccount.findOne({where:{business_profile_id:b.business_customer_id}}),last=credit&&await db.BusinessCreditLedgerEntry.findOne({where:{business_credit_account_id:credit.id},order:[["occurred_at","DESC"]]}),used=last?String(last.balance_after):"0.00",tier=b.business_tier_id&&await db.BusinessTier.findByPk(b.business_tier_id),settlement=await db.BusinessSettlement.findOne({where:{business_customer_id:b.business_customer_id,status:["ISSUED","PARTIALLY_PAID","OVERDUE"]},order:[["due_date","ASC"]]}),stats=(await db.sequelize.query("SELECT COALESCE(SUM(grand_total),0) AS lifetimeVolume, COALESCE(SUM(CASE WHEN placed_at >= DATE_FORMAT(UTC_DATE(), '%Y-%m-01') THEN grand_total ELSE 0 END),0) AS monthlyVolume, COALESCE(SUM(discount_total),0) AS discountTotal, COALESCE(SUM(subtotal),0) AS subtotalTotal FROM orders WHERE business_customer_id = :id AND payment_status = 'PAID'",{replacements:{id:b.business_customer_id},type:QueryTypes.SELECT}))[0];res.json({success:true,data:{partnerId:b.partnerId,tier:tier&&{code:tier.code,name:tier.name},credit:credit&&{status:credit.status,currency:credit.currency,limit:String(credit.credit_limit),used,available:money.subtractFloor(credit.credit_limit,used)},monthlyVolume:String(stats.monthlyVolume),lifetimeVolume:String(stats.lifetimeVolume),discountTotal:String(stats.discountTotal),subtotalTotal:String(stats.subtotalTotal),nextSettlement:settlement&&{number:settlement.settlement_number,dueDate:settlement.due_date,balanceDue:String(settlement.balance_due)}}});}catch(e){next(e);}};exports.credit=async(req,res,next)=>{try{const b=await business(req.user.id),account=await db.BusinessCreditAccount.findOne({where:{business_profile_id:b.business_customer_id}}),rows=account?await db.BusinessCreditLedgerEntry.findAll({where:{business_credit_account_id:account.id},order:[["occurred_at","DESC"]],limit:100}):[];res.json({success:true,data:rows});}catch(e){next(e);}};exports.settlements=async(req,res,next)=>{try{const b=await business(req.user.id);res.json({success:true,data:await db.BusinessSettlement.findAll({where:{business_customer_id:b.business_customer_id},order:[["period_end","DESC"]]})});}catch(e){next(e);}};exports.settlement=async(req,res,next)=>{try{const b=await business(req.user.id),row=await db.BusinessSettlement.findOne({where:{id:req.params.id,business_customer_id:b.business_customer_id}});if(!row)throw Object.assign(new Error("Settlement not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:row});}catch(e){next(e);}};exports.orders=async(req,res,next)=>{try{const b=await business(req.user.id);res.json({success:true,data:await db.Order.findAll({where:{business_customer_id:b.business_customer_id},order:[["createdAt","DESC"]],limit:20})});}catch(e){next(e);}};
|
||||
@@ -69,4 +69,8 @@ const registry = {
|
||||
|
||||
console.log("📋 Registry initialized with keys:", Object.keys(registry));
|
||||
|
||||
for (const [key, entry] of Object.entries(registry)) {
|
||||
if (typeof entry.pdfTemplate !== "function") delete registry[key];
|
||||
}
|
||||
|
||||
module.exports = registry;
|
||||
@@ -1,93 +1,26 @@
|
||||
/**
|
||||
* Copyright (c) 2026 Niolla
|
||||
* All rights reserved.
|
||||
*
|
||||
* This source code is proprietary and confidential.
|
||||
* Unauthorized copying, modification, distribution, or use
|
||||
* of this file, via any medium, is strictly prohibited.
|
||||
*/
|
||||
|
||||
// app/middleware/auth.middleware.js
|
||||
|
||||
const { verifyToken } = require("../utils/jwt.util");
|
||||
const { getEffectivePermissions } = require("../services/permission.service");
|
||||
const db = require("../models");
|
||||
|
||||
const authenticate = async (req, res, next) => {
|
||||
try {
|
||||
let token = null;
|
||||
|
||||
// Get token from cookie
|
||||
if (req.cookies?.access_token) {
|
||||
token = req.cookies.access_token;
|
||||
}
|
||||
|
||||
// Fallback to Bearer token
|
||||
if (!token && req.headers.authorization?.startsWith("Bearer ")) {
|
||||
token = req.headers.authorization.split(" ")[1];
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: "Unauthorized",
|
||||
});
|
||||
}
|
||||
|
||||
// Verify token
|
||||
const token = req.cookies?.access_token || (req.headers.authorization?.startsWith("Bearer ") ? req.headers.authorization.slice(7) : null);
|
||||
if (!token) return res.status(401).json({ success: false, error: { code: "UNAUTHORIZED", message: "Authentication required" } });
|
||||
const decoded = verifyToken(token);
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.log("DECODED:", decoded);
|
||||
if (!decoded.sub || !decoded.sid || !Number.isInteger(decoded.tokenVersion)) throw new Error("Required claims missing");
|
||||
const [user, session] = await Promise.all([
|
||||
db.User.findByPk(decoded.sub),
|
||||
db.AuthSession.findByPk(decoded.sid),
|
||||
]);
|
||||
if (!user || user.accountStatus !== "ACTIVE" || user.tokenVersion !== decoded.tokenVersion || !session || session.user_id !== user.id || session.revoked_at || session.expires_at <= new Date() || session.token_version !== user.tokenVersion) {
|
||||
return res.status(401).json({ success: false, error: { code: "SESSION_INVALID", message: "Session is no longer valid" } });
|
||||
}
|
||||
|
||||
const userId = decoded.sub || decoded.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new Error("User ID missing in token");
|
||||
}
|
||||
|
||||
// Load permissions
|
||||
const permissions = await getEffectivePermissions(userId);
|
||||
|
||||
req.user = {
|
||||
...decoded,
|
||||
id: userId,
|
||||
permissions,
|
||||
};
|
||||
|
||||
req.user = { id: user.id, sessionId: session.id, firstName: user.firstName, lastName: user.lastName, email: user.email, accountType: user.accountType, accountStatus: user.accountStatus, permissions: await getEffectivePermissions(user.id) };
|
||||
next();
|
||||
} catch (err) {
|
||||
console.error("AUTH ERROR:", err);
|
||||
|
||||
// Clear invalid/expired cookie
|
||||
res.clearCookie("access_token", {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax",
|
||||
});
|
||||
|
||||
// Token expired
|
||||
if (err.name === "TokenExpiredError") {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: "Session expired. Please login again.",
|
||||
});
|
||||
}
|
||||
|
||||
// Invalid token
|
||||
if (err.name === "JsonWebTokenError") {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: "Invalid token",
|
||||
});
|
||||
}
|
||||
|
||||
// Default
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: "Authentication failed",
|
||||
});
|
||||
} catch (error) {
|
||||
res.clearCookie("access_token");
|
||||
return res.status(401).json({ success: false, error: { code: "UNAUTHORIZED", message: "Invalid or expired access token" } });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { authenticate };
|
||||
module.exports = { authenticate, requireAuth: authenticate };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user