46 Commits
Author SHA1 Message Date
Inamul-hasan-tec 537f94041f fix(cors): allow X-Impersonated-Tenant-Id and dynamic origin in app.js 2026-08-20 11:44:37 +05:30
Inamul-hasan-tec 70ce1b9efe fix(variants): inherit parent SKU/price by default and load variantAxes optionsList in catalog repository 2026-08-20 11:16:05 +05:30
Inamul-hasan-tec 202c5280ca docs: add Master System Credentials & Demo Test Accounts Reference 2026-08-19 18:22:45 +05:30
Inamul-hasan-tec c4b1caba94 docs: add complete visual Tenant Creation and RBAC guide 2026-08-19 18:19:08 +05:30
Inamul-hasan-tec 189fe338f5 docs: add Core SaaS, Multi-Tenancy, RBAC, and Audit Logging master architectural documentation 2026-08-19 17:46:01 +05:30
Inamul-hasan-tec 891b8157bc docs: add comprehensive product engine architecture and database dictionary 2026-08-19 16:13:11 +05:30
Inamul-hasan-tec 4d419dcd36 fix(completeness): evaluate all available attributes proportionally when no attributes are explicitly required 2026-08-19 15:43:03 +05:30
Inamul-hasan-tec cc0491939c fix(products): preserve manual SKU on creation and publish transition in product.service.js 2026-08-19 15:12:52 +05:30
Inamul-hasan-tec b0c1c9e210 fix(products): support standalone product completeness calculation in CompletenessService 2026-08-19 14:42:18 +05:30
Inamul-hasan-tec 867eba1205 build(deps): add @aws-sdk/client-s3 for custom S3 file server config 2026-08-19 14:36:41 +05:30
Inamul-hasan-tec 01e7ca58e8 merge: resolve asset.controller.js and asset.service.js conflicts with origin/fardeen-dev 2026-08-19 14:35:47 +05:30
Inamul-hasan-tec dac030f4ef Merge remote-tracking branch 'origin/dev' into feature/inam-platform-core-setup 2026-08-19 12:45:24 +05:30
Mohammed-Fardeen-02 bb055fa794 fix:uplodig of assets and configured file server 2026-08-19 12:44:26 +05:30
fardeen 704339380d Merge pull request 'mahir_backend' (#12) from mahir_backend into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/productcatalogue_backend/pulls/12
2026-08-17 06:17:53 +00:00
Mahir-Mohamed 6610c0c4a7 feat(channels): allow product users to access channels 2026-08-17 11:23:51 +05:30
Mahir-Mohamed 6e49ded9c4 feat(products): defer SKU generation to draft-pending promotion status transition 2026-08-13 14:53:05 +05:30
Inamul-hasan-tec 943e706f8d fix(categories): set tenant_id and reject duplicate code collisions with HTTP 409 in CategorieService.create 2026-08-12 17:07:26 +05:30
Inamul-hasan-tec acd58f1624 fix(attributes): set tenant_id on attribute groups and sets, reject duplicate code collisions with HTTP 409, ensure atomic group transactions, and clean orphaned relations 2026-08-12 16:51:22 +05:30
Inamul-hasan-tec a36880c27b fix(masters): update applyTenantScope to include global tenant_id null master records, reject duplicate code conflicts with HTTP 409, and preserve master data across session cycles 2026-08-12 16:42:51 +05:30
Inamul-hasan-tec 16ca868c5c fix(catalogs): include tenant_id in createData so Product Families persist with tenant scope 2026-08-12 16:16:12 +05:30
Inamul-hasan-tec e566ac14b1 fix(catalogs): replace models.ProductFamily with models.Catalog resolving HTTP 500 family creation bug, enforce tenant_id scoping on syndication preview, and seed TechNova1 products and superadmin credentials 2026-08-12 16:04:32 +05:30
Inamul-hasan-tec 90922a8603 fix(routes): reorder Express routes placing specific sub-routes before generic :id parameter routes 2026-08-12 15:35:50 +05:30
Inamul-hasan-tec e7c50e15b7 feat(channels): implement syndicateAllChannels bulk execution and testChannelConnection endpoints 2026-08-12 15:08:58 +05:30
Inamul-hasan-tec 1ae50f173f fix(rbac): enforce explicit 403 ApiError on cross-tenant role creation attempts 2026-08-12 15:05:51 +05:30
Inamul-hasan-tec 66a55deb0d feat(syndication): implement channelAdapterService with Shopify GraphQL mutation generator and previewPayload endpoint 2026-08-12 15:02:17 +05:30
Inamul-hasan-tec c5821f8015 feat(channels): implement ChannelMapping, SyndicationJob models, transformation service and syndication API endpoints 2026-08-12 13:23:04 +05:30
Inamul-hasan-tec 6cdef6da32 security(rbac): enforce tenant workspace lock on role creation and update in role.service.js 2026-08-11 18:36:33 +05:30
Inamul-hasan-tec b2b126590f fix(attributes): remove duplicate generateUniqueCode import in attribute.service.js 2026-08-11 18:25:58 +05:30
Inamul-hasan-tec 6903836547 fix(categories): remove duplicate generateUniqueCode import in categorie.service.js 2026-08-11 18:20:24 +05:30
Inamul-hasan-tec 5f5fdfe09a fix(merge): remove leftover merge conflict markers in catalog.service.js and channelType.service.js 2026-08-11 18:19:39 +05:30
Inamul-hasan-tec e958037f66 merge: pull latest changes from origin/mahir_backend and resolve code generation conflicts 2026-08-11 18:10:44 +05:30
Mahir-Mohamed 90a6c3238c Added functionality in Attribute step of product creation configuration 2026-08-11 12:04:04 +05:30
Inamul-hasan-tec 98163512d8 fix(config): add .env.production and default DB fallbacks to database.config.cjs for production startup 2026-08-10 17:55:42 +05:30
Inamul-hasan-tec 012af1b349 feat(rbac): seed 6 standard enterprise PIM roles & sync 14 permission nodes across all active tenants 2026-08-10 11:53:05 +05:30
Inamul-hasan-tec 929660fd74 feat(saas): complete 100% multi-tenant architecture across all 21 models with repository scoping & plan quota guards 2026-08-08 17:16:18 +05:30
Inamul-hasan-tec 31f8dc1b71 feat(settings): implement tenant-scoped category setting endpoints for General, Notifications, Security, API Keys 2026-08-08 16:42:28 +05:30
Inamul-hasan-tec c249efd81e fix(rbac): grant full administrative bypass to TENANT_ADMIN in permission middleware 2026-08-08 16:05:28 +05:30
Inamul-hasan-tec 9be6902d6b fix(tenant-scoping): enforce tenant_id in applyTenantScope for support impersonation mode 2026-08-08 15:15:31 +05:30
Inamul-hasan-tec 95b0753f30 feat(tenant-engine): enhance tenant provisioning with subscription quotas, baseline roles, email onboarding & fix double-hashing bug 2026-08-08 11:09:40 +05:30
Inamul-hasan-tec 24caaaf93f config(backend): set default backend port to 5002 to avoid port collision with crm_backend 2026-08-08 10:54:57 +05:30
Inamul-hasan-tec a55b03568d feat(platform): implement Platform Super Admin endpoints, tenant provisioning service, metrics & impersonation mode 2026-08-07 18:39:23 +05:30
Inamul-hasan-tec 2fff621fdd feat(core): setup multi-tenant isolation, Cloudinary DAM, RBAC fast-pass, distinct JWT refresh secrets & error shielding 2026-08-07 18:31:35 +05:30
Hasan 46abddf81c Merge pull request 'Last git push by Hasan ( create the table in DB for sava user Theme)' (#11) from hasan_backend into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/productcatalogue_backend/pulls/11
2026-07-28 05:38:39 +00:00
liyaqath 79a3b47be5 Merge pull request 'upadted flow' (#10) from ali into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/productcatalogue_backend/pulls/10
2026-07-25 10:31:56 +00:00
m7338ohd-debug 6f18030f88 upadted flow 2026-07-25 16:01:13 +05:30
Hasan dace58ad6c Merge pull request 'hasan_backend' (#9) from hasan_backend into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/productcatalogue_backend/pulls/9
2026-07-23 06:48:03 +00:00
135 changed files with 5716 additions and 1042 deletions
+18 -11
View File
@@ -1,15 +1,22 @@
# PORT=5000
# NODE_ENV=development
# CORS_ORIGIN=http://localhost:5173
# JWT_SECRET=supersecretjwtkeythatislongandsecure
# JWT_EXPIRES_IN=7d
PORT=5002
NODE_ENV=development
CORS_ORIGIN=http://localhost:5173
JWT_SECRET=supersecretjwtkeythatislongandsecure
JWT_REFRESH_SECRET=supersecretrefreshjwtkeythatislongandsecure
JWT_EXPIRES_IN=1d
JWT_REFRESH_EXPIRES_IN=7d
# DB_HOST=106.51.105.22
# DB_PORT=5432
# DB_NAME=pc_dev
# DB_USER=pc_user
# DB_PASSWORD="#TpW@%a&b$[zm"
# DB_DIALECT=postgres
CLOUDINARY_CLOUD_NAME=dbixmka2j
CLOUDINARY_API_KEY=634582295238882
CLOUDINARY_API_SECRET=0TAXlH3JHVvBHiqTMCwiDd9qfmM
CLOUDINARY_URL=cloudinary://634582295238882:0TAXlH3JHVvBHiqTMCwiDd9qfmM@dbixmka2j
DB_HOST=106.51.105.22
DB_PORT=5432
DB_NAME=pc_local
DB_USER=pc_user
DB_PASSWORD="#TpW@%a&b$[zm"
DB_DIALECT=postgres
# # IMPORTANT: Gmail SMTP requires an App Password, NOT your regular password.
# # Go to: https://myaccount.google.com -> Security -> 2-Step Verification -> App Passwords
+10 -3
View File
@@ -1,8 +1,15 @@
PORT=5000
PORT=5002
NODE_ENV=local
CORS_ORIGIN=http://localhost:5173
CORS_ORIGIN=http://localhost:5173,http://localhost:5174
JWT_SECRET=supersecretjwtkeythatislongandsecure
JWT_EXPIRES_IN=7d
JWT_REFRESH_SECRET=supersecretrefreshjwtkeythatislongandsecure
JWT_EXPIRES_IN=1d
JWT_REFRESH_EXPIRES_IN=7d
CLOUDINARY_CLOUD_NAME=dbixmka2j
CLOUDINARY_API_KEY=634582295238882
CLOUDINARY_API_SECRET=0TAXlH3JHVvBHiqTMCwiDd9qfmM
CLOUDINARY_URL=cloudinary://634582295238882:0TAXlH3JHVvBHiqTMCwiDd9qfmM@dbixmka2j
DB_HOST=106.51.105.22
DB_PORT=5432
+19
View File
@@ -0,0 +1,19 @@
PORT=5002
NODE_ENV=production
CORS_ORIGIN=http://localhost:5173
JWT_SECRET=supersecretjwtkeythatislongandsecure
JWT_REFRESH_SECRET=supersecretrefreshjwtkeythatislongandsecure
JWT_EXPIRES_IN=1d
JWT_REFRESH_EXPIRES_IN=7d
CLOUDINARY_CLOUD_NAME=dbixmka2j
CLOUDINARY_API_KEY=634582295238882
CLOUDINARY_API_SECRET=0TAXlH3JHVvBHiqTMCwiDd9qfmM
CLOUDINARY_URL=cloudinary://634582295238882:0TAXlH3JHVvBHiqTMCwiDd9qfmM@dbixmka2j
DB_HOST=106.51.105.22
DB_PORT=5432
DB_NAME=pc_local
DB_USER=pc_user
DB_PASSWORD="#TpW@%a&b$[zm"
DB_DIALECT=postgres
+15 -6
View File
@@ -13,12 +13,16 @@ import { buildContext } from './src/shared/middleware/context.middleware.js';
const app = express();
// Middlewares
app.use(helmet());
app.use(cors({
origin: process.env.CORS_ORIGIN || '*',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization', 'Accept', 'X-Requested-With']
app.use(helmet({
crossOriginResourcePolicy: { policy: 'cross-origin' },
}));
app.use(cors({
origin: true,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization', 'Accept', 'X-Requested-With', 'x-tenant-id', 'x-impersonated-tenant-id', 'X-Tenant-Id', 'X-Impersonated-Tenant-Id', 'x-api-key', 'X-Api-Key', '*']
}));
app.options('*', cors());
app.use(compression());
app.use(cookieParser());
app.use(express.json());
@@ -38,7 +42,12 @@ app.get('/health', (req, res) => {
});
// Centralized Feature Router Loader
app.use('/uploads', express.static('uploads'));
app.use('/uploads', express.static('uploads', {
setHeaders: (res) => {
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
res.setHeader('Access-Control-Allow-Origin', '*');
}
}));
registerRoutes(app);
// Global Error Handler
@@ -0,0 +1,59 @@
# 🏢 Core Multi-Tenant Architecture & Data Isolation Manual
## 1. Executive Summary
The PIM platform is designed as an Enterprise Multi-Tenant Software-as-a-Service (SaaS) application. It enforces strict logical isolation across tenants while sharing a unified application and database instance, maximizing resource efficiency, maintainability, and scalability.
---
## 2. Multi-Tenant Topology & Request Context Lifecycle
```mermaid
sequenceDiagram
autonumber
actor Client as Web Browser / API Consumer
participant Gate as API Gateway / Reverse Proxy
participant Auth as Auth & Context Middleware
participant TenantSvc as Tenant Context Engine
participant DB as PostgreSQL Multi-Tenant DB
Client->>Gate: HTTP Request (Authorization: Bearer <JWT>, x-tenant-id)
Gate->>Auth: Forward with Headers
Auth->>Auth: Verify JWT Token & Signature
Auth->>TenantSvc: buildContext(req)
alt Platform SuperAdmin with Impersonation
TenantSvc->>TenantSvc: Detect x-impersonated-tenant-id
TenantSvc->>TenantSvc: Set context.tenantId = Impersonated ID
TenantSvc->>TenantSvc: Set context.isImpersonating = true
else Standard Tenant User
TenantSvc->>TenantSvc: Set context.tenantId = jwt.user.tenant_id
TenantSvc->>TenantSvc: Set context.isImpersonating = false
end
TenantSvc-->>Auth: req.context populated
Auth->>DB: Execute Query WHERE tenant_id = req.context.tenantId
DB-->>Client: Scoped Data Response (Zero Cross-Tenant Leakage)
```
---
## 3. Core Multi-Tenant Tenets & Invariants
1. **Context Guarantee**: Every authenticated request populates `req.context`:
```javascript
req.context = {
tenantId: 19,
userId: "3c847d01-e23a-4a22-9218-192a514d2847",
roleIds: ["role-tenant-admin-uuid"],
userType: "tenant", // 'platform' or 'tenant'
isImpersonating: false
};
```
2. **Repository Layer Scoping**: All Sequelize queries MUST include `where: { tenant_id: req.context.tenantId }` (unless executed by a platform administrator in global management view).
3. **Tenant Provisioning Lifecycle**:
- Provisioning a new tenant creates:
- The `tenants` record.
- Default system roles (`TENANT_ADMIN`, `CATALOG_MANAGER`, `VIEWER`).
- Default permission bindings for all available `permission_nodes`.
- An initial root administrative user.
- Default taxonomy seeds (base units of measure, default attribute groups).
@@ -0,0 +1,44 @@
# 🔐 Authentication & User Identity Management
## 1. Overview
The Authentication Subsystem provides JWT-based session management, password hashing via bcrypt (10 rounds), token refresh rotations, and multi-tier user classification (`platform` vs `tenant`).
---
## 2. User Types & Security Realms
| User Type | Scope | Access Capabilities | Impersonation Allowed |
| :--- | :--- | :--- | :--- |
| **`platform`** | Global SaaS Operator | Full cross-tenant access, tenant provisioning, system nodes management, global billing and quotas | **YES** (via `x-impersonated-tenant-id`) |
| **`tenant`** | Specific Workspace | Restricted strictly to records where `tenant_id === user.tenant_id`. Governed by assigned RBAC roles | **NO** |
---
## 3. JWT Payload Structure & Token Lifecycle
### Token Structure
```json
{
"user_id": "3c847d01-e23a-4a22-9218-192a514d2847",
"email": "admin@maskantech.com",
"first_name": "Inam",
"last_name": "Admin",
"tenant_id": 19,
"user_type": "tenant",
"role_ids": ["fd9c2e97-9576-437d-aae9-939327efaf7e"],
"iat": 1787123456,
"exp": 1787209856
}
```
### Security Workflows
1. **Login (`POST /api/v1/auth/login`)**:
- Validates email and bcrypt password hash.
- Verifies `status === 'active'`.
- Eager-loads assigned `roles` and extracts `role_ids`.
- Returns Access Token (JWT) and User Profile payload.
2. **Password Updates**:
- Requires previous password verification.
- Enforces minimum 8-character complexity with letter, number, and special character requirements.
3. **Session Revocation**:
- Changing a user's status to `inactive` or deleting a user immediately halts subsequent requests as token validation checks against active DB records on critical operations.
@@ -0,0 +1,64 @@
# 🛡️ RBAC Permissions Matrix & Security Engine
## 1. Overview
The Role-Based Access Control (RBAC) engine enforces fine-grained authorization across all platform resources. It decouples functional permission definitions (**Permission Nodes**) from business identities (**Roles**), allowing dynamic, tenant-level customization of user access privileges.
---
## 2. Permission Action Matrix (7-Point Granularity)
Every permission node in the system supports 7 distinct operational flags:
```mermaid
graph LR
Node[Permission Node e.g. 'products'] --> View[can_view: Read & List]
Node --> Create[can_create: POST New]
Node --> Edit[can_edit: PUT/PATCH]
Node --> Delete[can_delete: Soft Delete]
Node --> Alter[can_alter: Schema/Publish]
Node --> Import[can_import: Batch Import]
Node --> Export[can_export: CSV/JSON Export]
```
---
## 3. Core System Permission Nodes Registry
| Module | Node Code | Node Name | Description |
| :--- | :--- | :--- | :--- |
| **Products** | `products` | Product Catalog Management | Core SKU and product entity lifecycle management |
| **Variants** | `variants` | Product Variants Management | Variant matrix generation and child SKU overrides |
| **Families** | `families` | Product Families (Catalogs) | Catalog blueprints, variant axes, and asset rules |
| **Categories** | `categories` | Categories Taxonomy | Hierarchical taxonomy tree and category assignments |
| **Attributes** | `attributes` | Attribute Management | Attribute definitions, sets, and attribute groups |
| **Brands** | `brands` | Brand Management | Brand registry and allowed brand constraints |
| **Units** | `units` | Units of Measure (UOM) | Unit registry and conversion factors |
| **Media (DAM)** | `assets` | Digital Asset Management | Media library uploads, asset types, and asset families |
| **Channels** | `channels` | Channel Syndication | Channel endpoints, transformations, and feeds |
| **Users & Roles** | `users` | User & RBAC Management | User provisioning, custom roles, and permission assignments |
| **Audit Logs** | `audit_logs` | Security & Compliance Logs | Read-only audit trail inspection |
| **Settings** | `settings` | Tenant Settings | Theme, white-labeling, and integration configurations |
---
## 4. Authorization Middleware Flow
```mermaid
flowchart TD
Req[Incoming Request] --> AuthCheck{Is req.user present?}
AuthCheck -- No --> 401[HTTP 401 Unauthorized]
AuthCheck -- Yes --> SuperCheck{Is user_type == 'platform'?}
SuperCheck -- Yes --> Pass[Pass: Next Middleware]
SuperCheck -- No --> AdminCheck{Has SUPER_ADMIN or TENANT_ADMIN role?}
AdminCheck -- Yes --> Pass
AdminCheck -- No --> RoleCheck{Inspect RolePermissions for node_code}
RoleCheck -- Action matches flag --> Pass
RoleCheck -- Missing flag --> 403[HTTP 403 Forbidden: Insufficient Permissions]
```
### Action Resolution Map
If no specific action is passed into `authorize('products')`, the middleware automatically infers the required flag from the HTTP Method:
- `GET` $\rightarrow$ `can_view`
- `POST` $\rightarrow$ `can_create`
- `PUT` / `PATCH` $\rightarrow$ `can_edit`
- `DELETE` $\rightarrow$ `can_delete`
@@ -0,0 +1,115 @@
# 🗄️ Core SaaS & Security Database Schema Dictionary
## 1. Overview
This document specifies all database tables, columns, data types, constraints, and relations powering Multi-Tenancy, Users, Roles, Permissions, and Auditing.
---
## 2. Table-by-Table Data Dictionary
### 2.1. `tenants` (Multi-Tenant Organization Accounts)
| Column Name | PostgreSQL Type | Nullable | Default | Description & Constraints |
| :--- | :--- | :--- | :--- | :--- |
| `id` | `SERIAL` (INTEGER) | NO | Auto-increment | Primary Key |
| `name` | `VARCHAR(255)` | NO | — | Legal Organization Name |
| `code` | `VARCHAR(100)` | NO | — | Unique organization code |
| `domain` | `VARCHAR(255)` | YES | `NULL` | Custom domain / CNAME mapping |
| `status` | `VARCHAR(50)` | NO | `'active'` | `active`, `suspended`, `pending_verification` |
| `plan_id` | `VARCHAR(50)` | YES | `'enterprise'`| Subscription tier plan |
| `settings` | `JSONB` | YES | `{}` | White-label branding, quotas, feature toggles |
| `created_at` | `TIMESTAMP WITH TZ`| NO | `NOW()` | Timestamp |
| `updated_at` | `TIMESTAMP WITH TZ`| NO | `NOW()` | Timestamp |
| `deleted_at` | `TIMESTAMP WITH TZ`| YES | `NULL` | Soft delete marker |
---
### 2.2. `users` (User Identity Master)
| Column Name | PostgreSQL Type | Nullable | Default | Description & Constraints |
| :--- | :--- | :--- | :--- | :--- |
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key |
| `tenant_id` | `INTEGER` | YES | `NULL` | FK -> `tenants.id` (NULL for global platform users) |
| `first_name` | `VARCHAR(100)` | NO | — | User First Name |
| `last_name` | `VARCHAR(100)` | NO | — | User Last Name |
| `email` | `VARCHAR(255)` | NO | — | Unique email address across tenant realm |
| `password_hash` | `VARCHAR(255)` | NO | — | Bcrypt salted password hash |
| `user_type` | `VARCHAR(50)` | NO | `'tenant'` | `platform` (SaaS Admin) or `tenant` (Workspace Member) |
| `status` | `VARCHAR(50)` | NO | `'active'` | `active`, `inactive`, `locked` |
| `last_login_at` | `TIMESTAMP WITH TZ` | YES | `NULL` | Last session authentication timestamp |
| `created_at` | `TIMESTAMP WITH TZ`| NO | `NOW()` | Timestamp |
| `updated_at` | `TIMESTAMP WITH TZ`| NO | `NOW()` | Timestamp |
| `deleted_at` | `TIMESTAMP WITH TZ`| YES | `NULL` | Soft delete marker |
---
### 2.3. `roles` (RBAC Security Roles)
| Column Name | PostgreSQL Type | Nullable | Default | Description & Constraints |
| :--- | :--- | :--- | :--- | :--- |
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key |
| `tenant_id` | `INTEGER` | YES | `NULL` | FK -> `tenants.id` |
| `role_name` | `VARCHAR(100)` | NO | — | Display Role Name (e.g. Catalog Manager) |
| `role_code` | `VARCHAR(50)` | NO | — | Unique Role Code (e.g. `CATALOG_MANAGER`) |
| `description`| `TEXT` | YES | `NULL` | Functional scope of the role |
| `status` | `BOOLEAN` | NO | `TRUE` | Active flag |
| `is_system` | `BOOLEAN` | NO | `FALSE` | Protected system role flag (prevents deletion) |
| `created_at` | `TIMESTAMP WITH TZ`| NO | `NOW()` | Timestamp |
| `updated_at` | `TIMESTAMP WITH TZ`| NO | `NOW()` | Timestamp |
| `deleted_at` | `TIMESTAMP WITH TZ`| YES | `NULL` | Soft delete marker |
---
### 2.4. `permission_nodes` (Functional Security Nodes)
| Column Name | PostgreSQL Type | Nullable | Default | Description & Constraints |
| :--- | :--- | :--- | :--- | :--- |
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key |
| `node_code` | `VARCHAR(50)` | NO | — | Unique node code (e.g. `products`, `channels`) |
| `node_name` | `VARCHAR(100)` | NO | — | Display name (e.g. `Product Catalog`) |
| `module` | `VARCHAR(50)` | NO | — | UI/API Module categorization |
| `description`| `TEXT` | YES | `NULL` | Description of guarded operations |
| `is_system` | `BOOLEAN` | NO | `TRUE` | System managed node |
| `created_at` | `TIMESTAMP WITH TZ`| NO | `NOW()` | Timestamp |
| `updated_at` | `TIMESTAMP WITH TZ`| NO | `NOW()` | Timestamp |
---
### 2.5. `role_permissions` (Role to Permission Junction & Flags)
| Column Name | PostgreSQL Type | Nullable | Default | Description & Constraints |
| :--- | :--- | :--- | :--- | :--- |
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key |
| `role_id` | `UUID` | NO | — | FK -> `roles.id` (Cascade delete) |
| `permission_node_id` | `UUID` | NO | — | FK -> `permission_nodes.id` |
| `can_view` | `BOOLEAN` | NO | `FALSE` | Read / List access |
| `can_create` | `BOOLEAN` | NO | `FALSE` | Create access |
| `can_edit` | `BOOLEAN` | NO | `FALSE` | Update / Edit access |
| `can_delete` | `BOOLEAN` | NO | `FALSE` | Soft delete access |
| `can_alter` | `BOOLEAN` | NO | `FALSE` | Lifecycle transition / publish access |
| `can_import` | `BOOLEAN` | NO | `FALSE` | Bulk import access |
| `can_export` | `BOOLEAN` | NO | `FALSE` | Bulk export access |
| `created_at` | `TIMESTAMP WITH TZ`| NO | `NOW()` | Timestamp |
| `updated_at` | `TIMESTAMP WITH TZ`| NO | `NOW()` | Timestamp |
---
### 2.6. `user_roles` (User to Role Junction)
| Column Name | PostgreSQL Type | Nullable | Default | Description & Constraints |
| :--- | :--- | :--- | :--- | :--- |
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key |
| `user_id` | `UUID` | NO | — | FK -> `users.id` (Cascade delete) |
| `role_id` | `UUID` | NO | — | FK -> `roles.id` (Cascade delete) |
| `created_at` | `TIMESTAMP WITH TZ`| NO | `NOW()` | Timestamp |
| `updated_at` | `TIMESTAMP WITH TZ`| NO | `NOW()` | Timestamp |
---
### 2.7. `audit_logs` (Security & Operations Audit Trail)
| Column Name | PostgreSQL Type | Nullable | Default | Description & Constraints |
| :--- | :--- | :--- | :--- | :--- |
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key |
| `tenant_id` | `INTEGER` | YES | `NULL` | FK -> `tenants.id` |
| `user_id` | `UUID` | YES | `NULL` | FK -> `users.id` (Executor) |
| `action` | `VARCHAR(50)` | NO | — | `CREATE`, `UPDATE`, `DELETE`, `PUBLISH`, `LOGIN` |
| `entity` | `VARCHAR(100)` | NO | — | Target entity name (`Product`, `Category`, `Role`) |
| `entity_id` | `VARCHAR(100)` | YES | `NULL` | Target record UUID/ID |
| `details` | `JSONB` | YES | `{}` | Before/After JSON diff snapshot |
| `ip_address` | `VARCHAR(45)` | YES | `NULL` | IPv4 or IPv6 client address |
| `user_agent` | `TEXT` | YES | `NULL` | Browser / Client User-Agent string |
| `created_at` | `TIMESTAMP WITH TZ`| NO | `NOW()` | Audit event timestamp |
@@ -0,0 +1,73 @@
# 📜 Audit Logging, Security Interceptors & Compliance
## 1. Overview
The Audit Logging system provides an immutable, append-only historical record of all state-mutating actions across the PIM platform. It guarantees enterprise compliance (SOC 2, ISO 27001, GDPR) by recording *who* performed *what* action on *which* entity, along with chronological before-and-after JSON snapshots, IP addresses, and user-agent metadata.
---
## 2. Audit Event Interceptor Architecture
```mermaid
sequenceDiagram
autonumber
actor User as Authenticated User
participant Route as Express API Endpoint
participant Svc as Feature Service Layer
participant DB as PostgreSQL DB
participant Audit as AuditLogService
participant WS as Real-Time Notification Broadcaster
User->>Route: PUT /api/v1/products/:id (Change Price / Status)
Route->>Svc: updateProduct(id, changes, context)
Svc->>DB: Fetch original record (Before Snapshot)
Svc->>DB: Apply update (After Snapshot)
rect rgb(245, 255, 245)
Note over Svc,Audit: Automated Audit Capture
Svc->>Audit: recordLog({ tenant_id, user_id, action: 'UPDATE', entity: 'Product', entity_id, diff, ip, ua })
Audit->>DB: INSERT INTO audit_logs (...)
end
opt High-Priority Mutation (e.g. Product Publish, Role Alteration)
Svc->>WS: Broadcast security event to tenant administrators
end
Svc-->>Route: Updated Result
Route-->>User: HTTP 200 OK
```
---
## 3. Audit Log Schema & Diff Structure
```json
{
"id": "7b049d11-4fec-411a-9a8b-3d84950e1234",
"tenant_id": 19,
"user_id": "3c847d01-e23a-4a22-9218-192a514d2847",
"action": "UPDATE",
"entity": "Product",
"entity_id": "80c68220-9d0b-485b-a1df-c96f883b5e6b",
"details": {
"status": {
"previous": "draft",
"current": "active"
},
"price": {
"previous": 199.99,
"current": 249.99
},
"modified_fields": ["status", "price"]
},
"ip_address": "192.168.1.49",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/128.0.0.0",
"created_at": "2026-08-19T17:45:00.000Z"
}
```
---
## 4. Compliance & Security Guarantees
1. **Append-Only Immutability**: `audit_logs` has NO `update` or `delete` API endpoints. Once an audit record is written, it cannot be modified or truncated through standard application routes.
2. **Tenant Scoping**: Audit log queries are strictly isolated to `tenant_id = req.context.tenantId`.
3. **Session Context Association**: Every audit record links the originating authenticated JWT user ID and client network location.
@@ -0,0 +1,237 @@
# 📘 Complete Guide: Multi-Tenant Provisioning & RBAC Security Engine
> **For Engineers, QA Specialists, and Product Stakeholders**
> This guide explains the entire journey of how a **new company (Tenant)** is onboarded from scratch, and how **Roles and Permissions (RBAC)** control access to every screen, button, and API in the system.
---
## 📑 Table of Contents
1. [What is a Tenant? (The Apartment Analogy)](#1-what-is-a-tenant-the-apartment-analogy)
2. [End-to-End Tenant Creation Lifecycle](#2-end-to-end-tenant-creation-lifecycle)
3. [The RBAC Security Architecture](#3-the-rbac-security-architecture)
4. [The 7 Permission Keys for Every Module](#4-the-7-permission-keys-for-every-module)
5. [Real-World Role Configurations](#5-real-world-role-configurations)
6. [How the Security Guard (Middleware) Works at Runtime](#6-how-the-security-guard-middleware-works-at-runtime)
7. [Platform SuperAdmin Impersonation (Support Mode)](#7-platform-superadmin-impersonation-support-mode)
8. [Database Visual Schema & Relationship Map](#8-database-visual-schema--relationship-map)
---
## 1. What is a Tenant? (The Apartment Analogy)
Imagine this software is a **giant cloud apartment building**:
```
┌─────────────────────────────────────────┐
│ OUR PIM CLOUD SERVER │
└────────────────────┬────────────────────┘
┌───────────────────────────────────┼───────────────────────────────────┐
▼ ▼ ▼
┌───────────────────────┐ ┌───────────────────────┐ ┌───────────────────────┐
│ 🏢 Tenant A │ │ 🏢 Tenant B │ │ 🏢 Tenant C │
│ (Nike Workspace) │ │ (Apple Workspace) │ │ (Sony Workspace) │
│ │ │ │ │ │
│ • Their own staff │ │ • Their own staff │ │ • Their own staff │
│ • Their shoe catalog │ │ • Their electronics │ │ • Their audio gear │
│ • Their media photos │ │ • Their media photos │ │ • Their media photos │
└───────────────────────┘ └───────────────────────┘ └───────────────────────┘
```
- **Tenant Isolation**: Every database table has a `tenant_id` column. When Nike logs in, their queries automatically execute with `WHERE tenant_id = 19`. It is physically impossible for Nike to see Apple's products or staff.
---
## 2. End-to-End Tenant Creation Lifecycle
When a new client signs up (or a Platform SuperAdmin clicks **"Create New Tenant"**), the backend executes an automated **6-step provisioning pipeline** inside a single safe transaction:
```mermaid
sequenceDiagram
autonumber
actor Admin as Platform SuperAdmin
participant API as POST /api/v1/platform/tenants
participant DB as PostgreSQL Database
participant Seed as Auto-Provisioning Engine
participant Email as Notification Engine
Admin->>API: Submit Tenant Form (Name: "Acme Corp", Admin Email: "boss@acme.com")
rect rgb(240, 248, 255)
Note over API,DB: Step 1: Create Workspace Account
API->>DB: INSERT INTO tenants (name, code, status, plan_id) VALUES ('Acme Corp', 'acme_corp', 'active', 'enterprise')
DB-->>API: Returns new Tenant ID (e.g. tenant_id = 25)
Note over API,DB: Step 2: Seed Default System Roles
API->>Seed: Provision Default Roles for Tenant 25
Seed->>DB: INSERT INTO roles (TENANT_ADMIN, CATALOG_MANAGER, VIEWER)
Note over API,DB: Step 3: Bind Permissions to Roles
Seed->>DB: Link all 12 Permission Nodes to TENANT_ADMIN with full 7-point flags
Note over API,DB: Step 4: Create Initial Root Admin User
API->>DB: INSERT INTO users (email: 'boss@acme.com', password_hash, tenant_id: 25)
API->>DB: INSERT INTO user_roles (user_id, role_id: 'TENANT_ADMIN')
Note over API,DB: Step 5: Seed Starter Taxonomy Primitives
Seed->>DB: INSERT starter Units (Piece, Set, Kilogram, Gram)
Seed->>DB: INSERT default Attribute Groups (General Specs, Physical Dimensions)
end
API->>Email: Send Welcome Email & Password Setup Link to boss@acme.com
API-->>Admin: HTTP 201 Created (Tenant 25 Ready & Fully Operational)
```
---
## 3. The RBAC Security Architecture
RBAC (Role-Based Access Control) decouples **People** from **Permissions** using a 3-layer hierarchy:
```
┌───────────────────────────┐
│ 1. USERS │ physical people who log in (Alice, Bob, Charlie)
└─────────────┬─────────────┘
│ assigned to (via user_roles)
┌───────────────────────────┐
│ 2. ROLES │ job badges (Tenant Admin, Photographer, Pricing Specialist)
└─────────────┬─────────────┘
│ contains (via role_permissions)
┌───────────────────────────┐
│ 3. PERMISSION NODES │ system modules (Products, Media DAM, Channels, Users)
└───────────────────────────┘
```
---
## 4. The 7 Permission Keys for Every Module
For **every single module** in the system, there are **7 granular action switches**:
```mermaid
graph TD
Module["🚪 System Module (e.g. Products)"] --> K1["👀 can_view: Search, browse, and view details"]
Module --> K2[" can_create: Click 'New Product' and save drafts"]
Module --> K3["✏️ can_edit: Modify descriptions, prices, specs"]
Module --> K4["🗑️ can_delete: Soft-delete or archive records"]
Module --> K5["🚀 can_alter: Publish products or alter schema"]
Module --> K6["📥 can_import: Bulk import CSV / Excel files"]
Module --> K7["📤 can_export: Download data to Excel / JSON"]
```
### Complete System Modules Registry:
| Module Code | Module Name | What It Controls |
| :--- | :--- | :--- |
| `products` | Product Catalog | Master product SKUs, prices, stock, and descriptions |
| `variants` | Product Variants | Matrix generator, color/size axes, and child SKU overrides |
| `families` | Product Families | Family blueprints, required attribute sets, and asset rules |
| `categories` | Categories | Hierarchical taxonomy tree and category assignments |
| `attributes` | Attributes & Sets | Dynamic specs, dropdown options, and attribute sets |
| `brands` | Brands | Manufacturer brands and allowed brand rules |
| `units` | Units of Measure | Measurement units (kg, pcs, cm) and conversion factors |
| `assets` | Digital Assets (DAM) | Image uploads, document attachments, and asset types |
| `channels` | Channels | Shopify, Amazon, and Custom CSV export integrations |
| `users` | Users & Roles | Inviting staff, creating roles, and assigning permissions |
| `audit_logs` | Audit Logs | Inspecting who changed what, timestamps, and IP history |
| `settings` | System Settings | Theme customization, organization branding, and billing |
---
## 5. Real-World Role Configurations
Here is how different job titles are configured using the 7-action matrix:
### Role 1: "Junior Catalog Editor" (Intern)
- `products`: `can_view` ✅, `can_create` ✅, `can_edit` ✅, `can_delete` ❌, `can_alter` ❌, `can_export`
- `assets`: `can_view` ✅, `can_create`
- `users` & `settings`: All ❌ (Cannot view or change team members)
### Role 2: "Photographer / Media Specialist"
- `assets`: `can_view` ✅, `can_create` ✅, `can_edit` ✅, `can_delete`
- `products`: `can_view` ✅ (To attach images), `can_edit` ❌ (Cannot change prices or stock)
### Role 3: "Catalog Supervisor / Brand Manager"
- `products`: All 7 keys ✅ (Including `can_alter` to publish products to live sales channels)
- `families` & `categories`: All 7 keys ✅
---
## 6. How the Security Guard (Middleware) Works at Runtime
Whenever a user takes any action in the application, the security guard inspects the request in **under 2 milliseconds**:
```
[ User clicks "Delete Product" in Browser ]
[ API Request: DELETE /api/v1/products/80c68220... ]
┌─────────────────────────────────────────────────────────────┐
│ 👮 SECURITY GUARD (permission.middleware.js) │
│ │
│ 1. Verify JWT Token ──► User ID 42 (Alice) │
│ 2. Check User Type ──► Tenant User (tenant_id = 19) │
│ 3. Check Admin Role ──► Is Alice TENANT_ADMIN? (No) │
│ 4. Check Alice's Role ──► "Junior Catalog Editor" │
│ 5. Check 'products' node ──► Is `can_delete` TRUE? │
│ │
│ ❌ Result: `can_delete` is FALSE! │
└──────────────────────────────┬──────────────────────────────┘
┌──────────────────────────────────────────┐
│ ⛔ HTTP 403 Forbidden Response: │
│ "Insufficient permissions for action: │
│ delete on module: products" │
└──────────────────────────────────────────┘
```
The database query **never runs**, the product is **never touched**, and an attempt log is written to `audit_logs`.
---
## 7. Platform SuperAdmin Impersonation (Support Mode)
If a customer (e.g. Tenant 19) opens a support ticket saying *"My attribute dropdown is stuck"*:
1. A **Platform SuperAdmin** does NOT need the customer's password.
2. The SuperAdmin opens the Platform Admin dashboard and clicks **"Troubleshoot Tenant 19"**.
3. The frontend sends the header:
`x-impersonated-tenant-id: 19`
4. The backend context middleware detects this and temporarily scopes the session to Tenant 19 in **Audit-Tracked Support Mode**.
5. All actions taken while impersonating are stamped with `isImpersonating: true` in the audit logs.
---
## 8. Database Visual Schema & Relationship Map
```mermaid
erDiagram
TENANTS ||--o{ USERS : "contains"
TENANTS ||--o{ ROLES : "owns custom"
TENANTS ||--o{ PRODUCTS : "owns"
TENANTS ||--o{ ASSETS : "owns"
USERS ||--o{ USER_ROLES : "assigned"
ROLES ||--o{ USER_ROLES : "links"
ROLES ||--o{ ROLE_PERMISSIONS : "defines"
PERMISSION_NODES ||--o{ ROLE_PERMISSIONS : "guarded by"
USERS ||--o{ AUDIT_LOGS : "executes"
```
---
## 🎯 Quick Verification Reference
```bash
# 1. Verify Backend is running and routes are live
curl -sI http://localhost:5002/api/v1/categories
# 2. Check TypeScript build integrity
cd productcatalogue_frontend && npx tsc --noEmit
```
+35
View File
@@ -0,0 +1,35 @@
# 📚 Core SaaS & RBAC Security Engine Master Documentation
Welcome to the architectural and operational knowledge base for the **Core Multi-Tenant SaaS, Authentication, RBAC, and Audit Logging** subsystems.
---
## 🌟 Featured Comprehensive Guides
| Guide | Target Audience & Contents |
| :--- | :--- |
| **[`COMPLETE_TENANT_CREATION_AND_RBAC_GUIDE.md`](file:///Users/maskantech/Desktop/PIM/docs/core_saas_and_rbac_engine/COMPLETE_TENANT_CREATION_AND_RBAC_GUIDE.md)** | **⭐ Start Here!** Plain-English, visual, end-to-end guide explaining Tenant Provisioning (the 6-step lifecycle), the 3 layers of RBAC, the 7-action permission flags, real-world role setups, runtime middleware guard, and support impersonation mode. |
---
## 📑 In-Depth Engineering Manuals
| File | Scope & Contents |
| :--- | :--- |
| **[`01_MULTI_TENANT_ARCHITECTURE_AND_ISOLATION.md`](file:///Users/maskantech/Desktop/PIM/docs/core_saas_and_rbac_engine/01_MULTI_TENANT_ARCHITECTURE_AND_ISOLATION.md)** | Multi-tenant logical topology, request context builder, support impersonation mode, and tenant provisioning lifecycle. |
| **[`02_AUTHENTICATION_AND_USER_MANAGEMENT.md`](file:///Users/maskantech/Desktop/PIM/docs/core_saas_and_rbac_engine/02_AUTHENTICATION_AND_USER_MANAGEMENT.md)** | User identity models, JWT payload tokens, bcrypt password encryption, platform vs tenant realms, and session management. |
| **[`03_RBAC_PERMISSIONS_MATRIX_AND_SECURITY_ENGINE.md`](file:///Users/maskantech/Desktop/PIM/docs/core_saas_and_rbac_engine/03_RBAC_PERMISSIONS_MATRIX_AND_SECURITY_ENGINE.md)** | 7-point permission action matrix (`can_view`, `can_create`, `can_edit`, `can_delete`, `can_alter`, `can_import`, `can_export`), system roles, and authorization middleware logic. |
| **[`04_DATABASE_SCHEMA_AND_SECURITY_DICTIONARY.md`](file:///Users/maskantech/Desktop/PIM/docs/core_saas_and_rbac_engine/04_DATABASE_SCHEMA_AND_SECURITY_DICTIONARY.md)** | Detailed table dictionary for `tenants`, `users`, `roles`, `permission_nodes`, `role_permissions`, `user_roles`, and `audit_logs`. |
| **[`05_AUDIT_LOGGING_AND_ENTERPRISE_COMPLIANCE.md`](file:///Users/maskantech/Desktop/PIM/docs/core_saas_and_rbac_engine/05_AUDIT_LOGGING_AND_ENTERPRISE_COMPLIANCE.md)** | Append-only audit interceptors, before/after JSON diff captures, IP/User-Agent tracking, and compliance architecture. |
---
## 🎯 Quick Verification Commands
```bash
# Verify backend server health
curl -sI http://localhost:5002/api/v1/categories
# Verify frontend build & dev server
curl -sI http://localhost:5173
cd productcatalogue_frontend && npx tsc --noEmit
```
@@ -0,0 +1,39 @@
# 🔑 Master System Credentials & Demo Test Accounts
> **Environment**: Local Development / Staging
> **Frontend URL**: `http://localhost:5173`
> **Backend API URL**: `http://localhost:5002`
---
## 👑 1. Platform Super-Administrator (Global SaaS Realm)
*Has global cross-tenant management, tenant provisioning, and support impersonation permissions.*
| Role | Email | Password | Tenant Scope | Access Scope |
| :--- | :--- | :--- | :--- | :--- |
| **Platform SuperAdmin** | `superadmin@maskan.com` | `Admin@123` | Global (`NULL`) | Full SaaS & Platform access |
---
## 🏢 2. Tenant Workspace Accounts (`Tenant ID: 19 - TechNova`)
*These accounts represent different job functions inside the active `TechNova` organization to test fine-grained RBAC permission matrix.*
| Role / Job Title | Email | Password | Role Code | Permissions & Access Scope |
| :--- | :--- | :--- | :--- | :--- |
| **Tenant Administrator** | `technova1@gmail.com` | `Admin@123` | `TENANT_ADMIN` | Full workspace admin (all 7 action keys on all modules) |
| **Catalog Manager** | `catalog.manager@technova1.com` | `Admin@123` | `CATALOG_MANAGER` | Full catalog, taxonomy, and attribute creation/editing rights |
| **Product Editor** | `product.editor@technova1.com` | `Admin@123` | `PRODUCT_EDITOR` | Can create & edit products, but cannot delete or modify roles |
| **DAM Asset Lead** | `asset.manager@technova1.com` | `Admin@123` | `ASSET_MANAGER` | Media library uploads, asset types, and asset families |
| **Channel Publisher** | `channel.publisher@technova1.com` | `Admin@123` | `CHANNEL_PUBLISHER` | Channel syndication, marketplace mappings, and live sync |
| **Catalog Viewer** | `catalog.viewer@technova1.com` | `Admin@123` | `CATALOG_VIEWER` | Read-only access (cannot create, edit, or delete any record) |
---
## 🚀 Quick Verification via API
```bash
# Test Login via Terminal
curl -X POST http://localhost:5002/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"technova1@gmail.com","password":"Admin@123"}'
```
@@ -0,0 +1,103 @@
# 🏛️ Enterprise PIM Architecture & Product Creation Ecosystem Manual
## 1. Executive Summary & Purpose
This document provides an exhaustive, authoritative blueprint of the **Product Information Management (PIM)** engine for both human software engineers, QA architects, and AI autonomous agents. It establishes the definitive standard for how products, their taxonomy, attributes, assets, channels, and metadata are modeled, validated, persisted, audited, and syndicated.
---
## 2. Global Entity-Relationship Architecture
```mermaid
erDiagram
TENANTS ||--o{ USERS : "owns"
TENANTS ||--o{ CATALOGS : "owns (Product Families)"
TENANTS ||--o{ CATEGORIES : "owns"
TENANTS ||--o{ BRANDS : "owns"
TENANTS ||--o{ UNITS : "owns"
TENANTS ||--o{ ATTRIBUTES : "owns"
TENANTS ||--o{ ATTRIBUTE_SETS : "owns"
TENANTS ||--o{ ASSETS : "owns"
TENANTS ||--o{ PRODUCTS : "owns"
TENANTS ||--o{ CHANNELS : "owns"
CATEGORIES ||--o{ CATEGORIES : "parent_id (Hierarchy Tree)"
CATEGORIES ||--o{ PRODUCTS : "classifies"
CATEGORIES ||--o{ CATALOGS : "binds default"
BRANDS ||--o{ PRODUCTS : "labels"
UNITS ||--o{ PRODUCTS : "measures"
ATTRIBUTE_SETS ||--o{ ATTRIBUTE_SET_GROUPS : "contains"
ATTRIBUTE_GROUPS ||--o{ ATTRIBUTE_SET_GROUPS : "assigned to"
ATTRIBUTE_GROUPS ||--o{ ATTRIBUTES : "groups"
ATTRIBUTES ||--o{ ATTRIBUTE_OPTIONS : "defines choices"
CATALOGS ||--o{ ATTRIBUTE_SETS : "binds attribute_set_id"
CATALOGS ||--o{ FAMILY_ATTRIBUTES : "binds direct attributes"
CATALOGS ||--o{ FAMILY_VARIANT_AXES : "defines variant dimensions"
CATALOGS ||--o{ FAMILY_ASSET_REQUIREMENTS : "enforces media rules"
CATALOGS ||--o{ FAMILY_CHANNELS : "subscribes channels"
PRODUCTS ||--o| CATALOGS : "instantiates (family_id)"
PRODUCTS ||--o{ PRODUCT_ATTRIBUTE_VALUES : "stores custom data"
PRODUCTS ||--o{ PRODUCT_ASSETS : "maps media files"
PRODUCTS ||--o{ VARIANTS : "has SKU children"
PRODUCTS ||--o{ PRODUCT_COMPLETENESS : "scores data readiness"
ATTRIBUTES ||--o{ PRODUCT_ATTRIBUTE_VALUES : "defines data point"
ASSETS ||--o{ PRODUCT_ASSETS : "maps physical file"
ASSET_TYPES ||--o{ ASSETS : "classifies media"
ASSET_FAMILIES ||--o{ ASSET_TYPES : "bundles requirements"
```
---
## 3. High-Level Core Subsystems & Dependency Hierarchy
To construct a valid Product, the system depends on an immutable, layered dependency hierarchy:
```
[Layer 0: Multi-Tenant Foundation]
├── Tenant Isolation (tenant_id scoping)
└── RBAC & Audit Trails (User Sessions, Permissions)
[Layer 1: Fundamental Catalog Primitives]
├── Units of Measure (kg, pcs, cm, l, etc.)
├── Brands (Manufacturers, Trademarks, Logos)
└── Categories (Nested Hierarchy, Slugs, Breadcrumbs)
[Layer 2: Attribute & Classification Engine]
├── Attributes (Types, Validations, Regex, Options)
├── Attribute Groups (UI Organizers & Logical Groupings)
└── Attribute Sets (Templates combining multiple Groups)
[Layer 3: Media & Digital Asset Management (DAM)]
├── Asset Types (Hero, Gallery, Manuals, Spec Sheets)
├── Asset Families (Required Media Rules & Validations)
└── Central File Registry (S3 / Local Storage, URLs, MIME)
[Layer 4: Blueprint Orchestration (Product Family / Catalog)]
├── Family Blueprint Definition (Inherits Category + Set)
├── Variant Axis Rules (Size, Color, Storage, RAM)
├── Media Requirements (Minimum Asset Dimensions & Formats)
└── Channel Syndication Subscriptions (Shopify, Amazon, CSV)
[Layer 5: Product Core Entity & Lifecycle Engine]
├── Draft Creation (POST /api/v1/products)
├── Dynamic Attribute Persistence (EAV / JSONB Hybrid)
├── Variant Matrix Generator (Cartesian Product of Axes)
├── Asset Role Assignment (Hero Image, Gallery, Video)
├── Channel Scoping & Overrides
└── Completeness Engine (Automated 0-100% Scoring)
```
---
## 4. Architectural Rules & Invariants
1. **Tenant Isolation**: Every database query in multi-tenant mode MUST enforce `tenant_id` scoping to prevent data leakage between organizations.
2. **Soft Deletes (`paranoid: true`)**: Deleting any primitive (Category, Attribute, Product, Unit, Brand) sets `deleted_at = NOW()`. Hard deletion is strictly disallowed to preserve audit integrity.
3. **SKU Invariant**:
- A Product in `draft` status may have `sku = null`.
- A Product transitioning to `pending` or `active` MUST have a globally unique SKU (either manually supplied or auto-generated by the Sequence Engine).
4. **Code Normalization**: `code` across all entities (Categories, Attributes, Units, Families, Products) must be lowercase, alphanumeric, and underscore-delimited (e.g., `tech_electronics_01`).
5. **Data Completeness Independence**: A product's completeness score is computed per channel and locale, evaluating mandatory general fields, required attributes, DAM media assets, and syndication channels.
@@ -0,0 +1,188 @@
# 🗄️ PIM Database Schema & Table Dictionary
## 1. Overview
The PIM database is structured on PostgreSQL with Sequelize ORM, leveraging UUID primary keys, JSONB for flexible extensible metadata, and strict foreign-key integrity constraints with tenant isolation.
---
## 2. Table-by-Table Data Dictionary
### 2.1. `products` (Core Master Catalog Record)
| Column Name | PostgreSQL Type | Nullable | Default | Description & Foreign Key References |
| :--- | :--- | :--- | :--- | :--- |
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key (Global Product UUID) |
| `tenant_id` | `INTEGER` | YES | `NULL` | Tenant isolation scope (`tenants.id`) |
| `code` | `VARCHAR(100)` | NO | — | Unique URL-safe identifier (e.g. `technova_audio_pro_x1`) |
| `sku` | `VARCHAR(100)` | YES | `NULL` | Stock Keeping Unit (Unique when status != draft) |
| `name` | `VARCHAR(255)` | NO | — | Display Name of the product |
| `description` | `TEXT` | YES | `NULL` | Full rich-text or plain-text product description |
| `price` | `NUMERIC(15,2)` | YES | `0.00` | Base retail catalog price |
| `stock` | `INTEGER` | YES | `0` | Base physical warehouse inventory on hand |
| `status` | `VARCHAR(20)` | NO | `'draft'` | Lifecycle Status: `draft`, `pending`, `active`, `archived` |
| `type` | `VARCHAR(20)` | NO | `'simple'` | Product Type: `simple`, `variant`, `bundle`, `virtual` |
| `family_id` | `UUID` | YES | `NULL` | Foreign Key -> `catalogs.id` (Product Family Blueprint) |
| `category_id` | `UUID` | YES | `NULL` | Foreign Key -> `categories.id` (Primary Taxonomy Category) |
| `brand_id` | `UUID` | YES | `NULL` | Foreign Key -> `brands.id` (Brand Manufacturer) |
| `unit_id` | `UUID` | YES | `NULL` | Foreign Key -> `units.id` (Unit of Measure) |
| `barcode` | `VARCHAR(100)` | YES | `NULL` | Universal barcode value |
| `gtin` | `VARCHAR(100)` | YES | `NULL` | Global Trade Item Number |
| `upc` | `VARCHAR(100)` | YES | `NULL` | Universal Product Code (12-digit) |
| `ean` | `VARCHAR(100)` | YES | `NULL` | European Article Number (13-digit) |
| `country` | `VARCHAR(100)` | YES | `NULL` | Country of Origin (ISO code or string) |
| `hsn` | `VARCHAR(50)` | YES | `NULL` | Harmonized System of Nomenclature code |
| `metadata` | `JSONB` | YES | `{}` | Extensible attributes, syndication channels, staging info |
| `version` | `INTEGER` | NO | `1` | Optimistic locking revision counter |
| `created_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Audit record creation timestamp |
| `updated_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Audit record last modification timestamp |
| `deleted_at` | `TIMESTAMP WITH TZ` | YES | `NULL` | Soft delete marker (Paranoid mode) |
---
### 2.2. `catalogs` (Product Families Blueprint)
| Column Name | PostgreSQL Type | Nullable | Default | Description & Foreign Key References |
| :--- | :--- | :--- | :--- | :--- |
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key |
| `tenant_id` | `INTEGER` | YES | `NULL` | Tenant isolation scope (`tenants.id`) |
| `code` | `VARCHAR(50)` | NO | — | Unique family code (e.g. `electronics_family`) |
| `name` | `VARCHAR(100)` | NO | — | Family display name (e.g. `Electronics Family`) |
| `description`| `TEXT` | YES | `NULL` | Family blueprint description |
| `status` | `VARCHAR(20)` | NO | `'draft'` | `draft`, `active`, `inactive` |
| `category_id` | `UUID` | YES | `NULL` | Default inherited category -> `categories.id` |
| `attribute_set_id` | `UUID` | YES | `NULL` | Default bound Attribute Set -> `attribute_sets.id` |
| `workflow_code` | `VARCHAR(50)`| NO | `'standard'` | Workflow state machine configuration code |
| `completeness_rules` | `JSONB` | YES | `{}` | Allowed brands, allowed units, required thresholds |
| `created_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
| `updated_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
| `deleted_at` | `TIMESTAMP WITH TZ` | YES | `NULL` | Soft delete marker |
---
### 2.3. `categories` (Hierarchical Taxonomy Tree)
| Column Name | PostgreSQL Type | Nullable | Default | Description & Foreign Key References |
| :--- | :--- | :--- | :--- | :--- |
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key |
| `tenant_id` | `INTEGER` | YES | `NULL` | Tenant isolation scope (`tenants.id`) |
| `parent_id` | `UUID` | YES | `NULL` | Self-referencing FK -> `categories.id` (Parent Category) |
| `name` | `VARCHAR(100)` | NO | — | Category Name (e.g., `Audio & Headphones`) |
| `code` | `VARCHAR(50)` | NO | — | Unique taxonomy code |
| `slug` | `VARCHAR(100)` | NO | — | SEO slug (e.g., `audio-headphones`) |
| `description`| `TEXT` | YES | `NULL` | Category description |
| `status` | `VARCHAR(20)` | NO | `'active'` | `active`, `inactive` |
| `display_order` | `INTEGER` | NO | `0` | UI sort order |
| `created_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
| `updated_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
| `deleted_at` | `TIMESTAMP WITH TZ` | YES | `NULL` | Soft delete marker |
---
### 2.4. `brands` (Brand Registry)
| Column Name | PostgreSQL Type | Nullable | Default | Description & Foreign Key References |
| :--- | :--- | :--- | :--- | :--- |
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key |
| `tenant_id` | `INTEGER` | YES | `NULL` | Tenant isolation scope (`tenants.id`) |
| `name` | `VARCHAR(100)` | NO | — | Brand Name (e.g. `TechNova`) |
| `code` | `VARCHAR(50)` | NO | — | Unique code (e.g. `technova`) |
| `website` | `VARCHAR(255)` | YES | `NULL` | Brand official website URL |
| `description`| `TEXT` | YES | `NULL` | Brand profile text |
| `logo_url` | `TEXT` | YES | `NULL` | Media URL to brand logo |
| `status` | `VARCHAR(20)` | NO | `'active'` | `active`, `inactive` |
| `created_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
| `updated_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
| `deleted_at` | `TIMESTAMP WITH TZ` | YES | `NULL` | Soft delete marker |
---
### 2.5. `units` (Units of Measure Registry)
| Column Name | PostgreSQL Type | Nullable | Default | Description & Foreign Key References |
| :--- | :--- | :--- | :--- | :--- |
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key |
| `tenant_id` | `INTEGER` | YES | `NULL` | Tenant isolation scope (`tenants.id`) |
| `name` | `VARCHAR(100)` | NO | — | Unit Name (e.g. `Piece`, `Set`, `Kilogram`) |
| `code` | `VARCHAR(50)` | NO | — | Unique code (e.g. `pcs`, `set`, `kg`) |
| `symbol` | `VARCHAR(20)` | NO | — | Display symbol (e.g. `pc`, `set`, `kg`) |
| `unit_type` | `VARCHAR(50)` | NO | `'Other'` | `Weight`, `Length`, `Volume`, `Count`, `Other` |
| `conversion_factor` | `NUMERIC(15,6)` | YES | `1.000000` | Multiplier relative to standard base unit |
| `base_unit_id` | `UUID` | YES | `NULL` | Self-referencing FK -> `units.id` (Standard base unit) |
| `status` | `VARCHAR(20)` | NO | `'active'` | `active`, `inactive` |
| `created_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
| `updated_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
| `deleted_at` | `TIMESTAMP WITH TZ` | YES | `NULL` | Soft delete marker |
---
### 2.6. `attributes` (Dynamic Field Definitions)
| Column Name | PostgreSQL Type | Nullable | Default | Description & Foreign Key References |
| :--- | :--- | :--- | :--- | :--- |
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key |
| `tenant_id` | `INTEGER` | YES | `NULL` | Tenant isolation scope (`tenants.id`) |
| `code` | `VARCHAR(50)` | NO | — | Attribute Code (e.g. `color_spec`, `weight_grams`) |
| `name` | `VARCHAR(100)` | NO | — | Display Name (e.g. `Color Spec`, `Weight (Grams)`) |
| `type` | `VARCHAR(30)` | NO | `'text'` | `text`, `textarea`, `number`, `decimal`, `date`, `boolean`, `select`, `multiselect` |
| `is_required` | `BOOLEAN` | NO | `FALSE` | Mandate flag for completeness evaluation |
| `is_unique` | `BOOLEAN` | NO | `FALSE` | Requires unique value across catalog |
| `is_variant_eligible` | `BOOLEAN` | NO | `FALSE` | Allowed as matrix generator axis |
| `min_length` / `max_length` | `INTEGER` | YES | `NULL` | String length bounds |
| `options` | `JSONB` | YES | `[]` | Array of strings for select/multiselect fallback |
| `status` | `VARCHAR(20)` | NO | `'active'` | `active`, `inactive` |
| `created_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
| `updated_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
| `deleted_at` | `TIMESTAMP WITH TZ` | YES | `NULL` | Soft delete marker |
---
### 2.7. `attribute_options` (Select / Multiselect Option Choices)
| Column Name | PostgreSQL Type | Nullable | Default | Description & Foreign Key References |
| :--- | :--- | :--- | :--- | :--- |
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key |
| `attribute_id` | `UUID` | NO | — | Foreign Key -> `attributes.id` (Parent Attribute) |
| `code` | `VARCHAR(50)` | NO | — | Value Code (e.g. `black`, `red`, `wireless_bt`) |
| `value` | `VARCHAR(255)` | NO | — | Human Display Label (e.g. `Black`, `Red`) |
| `display_order` | `INTEGER` | NO | `0` | Dropdown presentation position |
| `created_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
| `updated_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
---
### 2.8. `product_attribute_values` (EAV Product Data Storage)
| Column Name | PostgreSQL Type | Nullable | Default | Description & Foreign Key References |
| :--- | :--- | :--- | :--- | :--- |
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key |
| `product_id` | `UUID` | NO | — | Foreign Key -> `products.id` (Cascade on delete) |
| `attribute_id` | `UUID` | NO | — | Foreign Key -> `attributes.id` |
| `value` | `TEXT` | YES | `NULL` | Serialized value (String, number, date, JSON array) |
| `locale` | `VARCHAR(10)` | NO | `'en'` | Internationalization locale code |
| `channel_code` | `VARCHAR(50)` | YES | `NULL` | Channel specific value override |
| `created_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
| `updated_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
---
### 2.9. `product_assets` (Product Media Junction)
| Column Name | PostgreSQL Type | Nullable | Default | Description & Foreign Key References |
| :--- | :--- | :--- | :--- | :--- |
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key |
| `product_id` | `UUID` | NO | — | Foreign Key -> `products.id` |
| `asset_id` | `UUID` | NO | — | Foreign Key -> `assets.id` |
| `role` | `VARCHAR(50)` | NO | `'gallery_image'`| `hero_image`, `gallery_image`, `thumbnail`, `video`, `document` |
| `is_primary` | `BOOLEAN` | NO | `FALSE` | Primary hero display flag |
| `display_order` | `INTEGER` | NO | `0` | Image gallery sort order |
| `created_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
| `updated_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
---
### 2.10. `product_completeness` (Data Readiness Engine Scoring)
| Column Name | PostgreSQL Type | Nullable | Default | Description & Foreign Key References |
| :--- | :--- | :--- | :--- | :--- |
| `id` | `UUID` | NO | `gen_random_uuid()` | Primary Key |
| `product_id` | `UUID` | NO | — | Foreign Key -> `products.id` |
| `channel` | `VARCHAR(50)` | NO | `'default'` | Distribution channel scope (`default`, `shopify`, `amazon`) |
| `locale` | `VARCHAR(10)` | NO | `'en'` | Locale scope |
| `percentage` | `INTEGER` | NO | `0` | Calculated score (0 to 100%) |
| `is_complete` | `BOOLEAN` | NO | `FALSE` | `TRUE` if `percentage === 100` |
| `missing_attributes` | `JSONB` | YES | `[]` | Array of missing attribute codes & labels |
| `missing_assets` | `JSONB` | YES | `[]` | Array of missing mandatory asset type roles |
| `missing_channels` | `JSONB` | YES | `[]` | Array of missing required syndication channels |
| `missing_general` | `JSONB` | YES | `[]` | Missing basic fields (`name`, `category`, `brand`, `unit`) |
| `created_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
| `updated_at` | `TIMESTAMP WITH TZ` | NO | `NOW()` | Timestamp |
@@ -0,0 +1,131 @@
# ⚡ Product Creation POST API & Lifecycle Engine
## 1. Overview
The `POST /api/v1/products` endpoint is the gateway for catalog authoring. It coordinates JSON Schema validation, taxonomy binding, dynamic EAV attribute parsing, code generation, SKU reservation, transaction execution, socket broadcasting, and automated completeness calculation.
---
## 2. API Contract & Payload Schema
### Request Definition
- **Endpoint**: `POST /api/v1/products`
- **Headers**:
- `Content-Type: application/json`
- `Authorization: Bearer <JWT_TOKEN>`
- `x-tenant-id: <TENANT_ID>`
### Canonical JSON Payload
```json
{
"name": "TechNova Audio Pro X1 Wireless Headphones",
"code": "technova_audio_pro_x1",
"sku": "TECHNOVA-AUDIO-PRO-X1-BLK",
"status": "draft",
"type": "simple",
"price": 249.99,
"stock": 100,
"family_id": "b0343591-1bfe-4b02-8556-626616be518a",
"category": "d13554e2-763b-4886-9a3d-4c312781dc41",
"brand": "0e527d71-5582-4fec-beea-682442cf8947",
"unit": "3dbf77c3-3765-4f46-9538-4e8971f1e695",
"description": "Premium noise-cancelling over-ear headphones with 40-hour battery life.",
"barcode": "8901234567890",
"gtin": "00890123456789",
"upc": "890123456789",
"ean": "8901234567890",
"country": "Germany",
"hsn": "85183000",
"metadata": {
"attributeSetId": "eb6706cf-71c3-47c4-8182-a36ee06ce564",
"channels": ["shopify", "amazon", "custom_csv"],
"currentStage": "draft"
},
"attributes": {
"color_spec": "black",
"weight_grams": 250,
"connectivity": "Bluetooth 5.3"
}
}
```
---
## 3. End-to-End Execution Sequence Diagram
```mermaid
sequenceDiagram
autonumber
actor Client as Frontend Wizard / API Client
participant Auth as Auth & Context Middleware
participant Ctrl as ProductController
participant Svc as ProductService
participant CodeGen as Code/SKU Engine
participant DB as PostgreSQL Database
participant Comp as CompletenessService
participant Audit as AuditService
participant WS as Socket.IO Broadcaster
Client->>Auth: POST /api/v1/products
Auth->>Auth: Validate JWT, Tenant ID & Permission (products.create)
Auth->>Ctrl: create(req, res)
Ctrl->>Svc: create(productData, context)
rect rgb(240, 248, 255)
Note over Svc,DB: BEGIN Database Transaction
Svc->>CodeGen: generateUniqueCode(Product, baseCode)
CodeGen-->>Svc: Normalized unique code (e.g. technova_audio_pro_x1_1)
alt Manual SKU provided
Svc->>Svc: Preserve user-entered SKU
else Status is active/pending & SKU is empty
Svc->>CodeGen: generateSku(Product, prefix)
CodeGen-->>Svc: Generated SKU (e.g. ELEC-00042)
else Status is draft & SKU empty
Svc->>Svc: Set SKU = null (Valid in draft)
end
Svc->>DB: INSERT INTO products (...) VALUES (...)
DB-->>Svc: Created Product record (UUID)
opt Dynamic Attributes Supplied
Svc->>DB: Bulk INSERT INTO product_attribute_values (...)
end
Svc->>DB: COMMIT Transaction
end
Svc->>Comp: CompletenessService.calculate(productId)
Comp->>DB: Compute & Upsert ProductCompleteness
Svc->>Audit: log({ action: 'CREATE', entity: 'Product', id })
Svc->>WS: emit('product:created', productPayload)
Svc-->>Ctrl: Hydrated Product with Associations
Ctrl-->>Client: HTTP 201 Created (Product JSON)
```
---
## 4. Lifecycle Status Transitions & Validation Rules
```mermaid
stateDiagram-v2
[*] --> Draft: Initial Save (POST)
Draft --> Pending: Submit for QA / Approval
Pending --> Active: Publish & Approve
Active --> Archived: Catalog Retirement
Archived --> Active: Reactivate
Pending --> Draft: QA Reject / Revisions Needed
note right of Draft
- Missing attributes allowed
- Missing assets allowed
- SKU may be null
- Completeness: 0% - 100%
end note
note right of Active
- Requires Valid SKU
- Name, Category, Brand, Unit required
- Completeness verified
- Ready for Syndication Push
end note
```
@@ -0,0 +1,107 @@
# 🔗 Dependent Entities & Cascading Architecture
## 1. Overview
A Product in modern PIM is not an isolated table row; it is an aggregated composite entity. This document details each sub-primitive, its database schema, cascade behaviors, and lifecycle rules.
---
## 2. Taxonomy & Hierarchy (Categories)
### Data Architecture
Categories are modeled as an Adjacency List hierarchy using self-referencing `parent_id`.
```mermaid
graph TD
Root[Root: Electronics] --> Audio[Category: Audio & Sound]
Audio --> Headphones[Subcategory: Wireless Headphones]
Audio --> Speakers[Subcategory: Bluetooth Speakers]
Root --> Computers[Category: Computers & Laptops]
```
### Inheritance & Rules
- **Category Inheritance**: When selecting a `Product Family`, the product automatically inherits the family's default category if none is set.
- **Slug Normalization**: Slugs are generated recursively (`electronics/audio-sound/wireless-headphones`).
- **Cascade Rule**: Deleting a category does NOT delete products; it sets product `category_id = NULL` to prevent orphaned cascades.
---
## 3. Brand & Manufacturer Registry
### Data Architecture
Brands store trademark data, manufacturer info, and logo DAM assets.
### Blueprint Filtering
- Product Families define `allowedBrands` in their blueprint.
- When creating a product from a family, the UI automatically filters the Brand dropdown to only include allowed brands.
- If allowed brands is empty, all active workspace brands are selectable.
---
## 4. Units of Measure (UOM) Engine
### Data Architecture & Conversions
- `unit_type`: Categorizes units (`Weight`, `Length`, `Volume`, `Count`, `Other`).
- `conversion_factor`: Standard multiplier against a base unit (e.g. `g` has factor `0.001` relative to base unit `kg`).
- **Conflict Handling**: The inline unit creation endpoint enforces unique code constraints. On `HTTP 409 Conflict`, the frontend auto-selects the existing unit matching the code/name to prevent workflow interruption.
---
## 5. Attributes, Sets & Groups (The EAV Model)
### Hierarchy Model
```mermaid
graph TD
Set[Attribute Set: Electronics Set] --> G1[Group: Technical Specifications]
Set --> G2[Group: Physical Dimensions]
Set --> G3[Group: Marketing & Media]
G1 --> A1[Attribute: Connectivity]
G1 --> A2[Attribute: Battery Life]
G2 --> A3[Attribute: Weight Grams]
G2 --> A4[Attribute: Color Spec]
G3 --> A5[Attribute: SEO Title]
```
### Supported Attribute Types & Validations
1. `text`: String values with `min_length` and `max_length`.
2. `textarea`: Multi-line text for descriptions and spec tables.
3. `number` / `decimal`: Integer or floating-point numbers with `min_value` and `max_value` limits (e.g., non-negative `min: 0` for physical weights).
4. `date`: ISO date format (`YYYY-MM-DD`).
5. `boolean`: Binary `true` / `false` flags.
6. `select`: Single-choice enumeration reading from `attribute_options` or `options` array.
7. `multiselect`: Comma-delimited or JSON array of allowed choices.
---
## 6. Digital Asset Management (DAM) & Media Junction
### Role Matrix
| Role Code | Display Label | Purpose | Completeness Evaluation |
| :--- | :--- | :--- | :--- |
| `hero_image` | **HERO IMAGE** | Primary high-res catalog cover image | Evaluated for completeness (is_primary) |
| `gallery_image`| **GALLERY** | Additional multi-angle product views | Optional supporting media |
| `thumbnail` | **THUMBNAIL** | Low-res compressed preview icon | Optional UI thumbnail |
| `video` | **VIDEO** | Product showcase video (MP4/WebM) | Optional media |
| `document` | **DOCUMENT** | PDF user manuals, safety sheets | Optional compliance assets |
---
## 7. Completeness Calculation Mathematical Engine
The PIM Data Completeness score is calculated as a real-time ratio (0% to 100%):
$$\text{Completeness } \% = \text{round}\left( \frac{\text{Fulfilled Fields}}{\text{Total Expected Fields}} \times 100 \right)$$
### Weighted Breakdown:
1. **General Prerequisites (4 fields)**:
- `name` (Product Name present)
- `category_id` (Category assigned)
- `brand_id` (Brand assigned)
- `unit_id` (Unit of Measure assigned)
2. **Evaluated Attributes**:
- Evaluates all configured attributes in the active Attribute Set.
- Each configured attribute value contributes proportionally to the attribute score.
3. **Required Media Assets**:
- Evaluates mandatory asset families (e.g. at least 1 primary Hero Image).
4. **Subscribed Syndication Channels**:
- Evaluates whether mandatory syndication channels are linked.
@@ -0,0 +1,48 @@
# 🔍 System Gaps, Risk Audit & Optimization Blueprint
## 1. Executive Summary
This document captures architectural vulnerabilities, potential race conditions, database indexing requirements, and optimization blueprints identified during the comprehensive Product Creation deep-dive.
---
## 2. Identified Vulnerabilities & Audit Findings
| Category | Finding & Risk | Impact | Resolution & Architecture Recommendation |
| :--- | :--- | :--- | :--- |
| **Concurrency** | Non-atomic SKU generation on concurrent product creations | Duplicate SKU generation if two products are created in the exact same millisecond | Use PostgreSQL sequence or database-level lock on `sku_counters` table rather than `COUNT(*)` lookups. |
| **Data Integrity** | Soft-deleted attribute options lingering in product values | A product displays a deleted option code with no human-readable label | In `product_attribute_values`, store both `option_id` (FK) and string fallback, or enforce cascade warning on attribute option deletion. |
| **Performance** | Missing composite index on `product_attribute_values` | Sluggish search queries when filtering products by multiple attribute values | Add compound index: `CREATE INDEX idx_pav_attr_val ON product_attribute_values(attribute_id, value);` |
| **Tenant Scope** | In-memory filtering instead of database query scoping | Unnecessary memory overhead fetching cross-tenant records | Always apply `where: { tenant_id }` in repository layer before executing Sequelize `findAll()`. |
---
## 3. High-Priority Database Indexing Strategy
To guarantee sub-50ms response times for a catalog containing 500,000+ products:
```sql
-- 1. Product Registry Primary Lookups
CREATE INDEX IF NOT EXISTS idx_products_tenant_status ON products(tenant_id, status) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_products_code_tenant ON products(code, tenant_id);
CREATE INDEX IF NOT EXISTS idx_products_sku_tenant ON products(sku, tenant_id);
CREATE INDEX IF NOT EXISTS idx_products_family ON products(family_id);
CREATE INDEX IF NOT EXISTS idx_products_category ON products(category_id);
CREATE INDEX IF NOT EXISTS idx_products_brand ON products(brand_id);
-- 2. Dynamic Attribute EAV Indexing
CREATE INDEX IF NOT EXISTS idx_pav_product_attr ON product_attribute_values(product_id, attribute_id);
CREATE INDEX IF NOT EXISTS idx_pav_attr_value ON product_attribute_values(attribute_id, value);
-- 3. Completeness Indexing
CREATE INDEX IF NOT EXISTS idx_completeness_product_channel ON product_completeness(product_id, channel, locale);
-- 4. Media Asset Junction Indexing
CREATE INDEX IF NOT EXISTS idx_product_assets_product_primary ON product_assets(product_id, is_primary);
```
---
## 4. Architectural Next Steps & Roadmap
1. **Bulk Product Import & Syndication Queue**: Implement Redis / BullMQ worker pipeline for processing 10,000+ CSV / XML product imports asynchronously with streaming batch inserts.
2. **Channel Transformation Engine (Phase 5)**: Build dynamic attribute mapping formulas (e.g. mapping `weight_grams / 1000` to Shopify's `weight_kg`).
3. **Audit Log Timeline UI**: Render chronological diff timeline in product editor showing exact field changes and who approved them.
@@ -0,0 +1,95 @@
# 📋 Product Listing & Query Engine Architecture
## 1. Overview
The Product Listing system (`GET /api/v1/products`) powers the core catalog data grid. It supports multi-facet filtering (by category, brand, family, completeness, channel, status, tag, and custom attributes), full-text search across product name and SKU, server-side pagination, eager-loading of primary hero assets, and real-time completeness score aggregation.
---
## 2. API Endpoint Specification
- **Endpoint**: `GET /api/v1/products`
- **Query Parameters**:
- `page` (integer, default: 1)
- `limit` (integer, default: 20, max: 100)
- `search` (string, fuzzy search on `name`, `code`, `sku`)
- `status` (string or array: `draft`, `pending`, `active`, `archived`)
- `type` (string: `simple`, `variant`, `bundle`)
- `family_id` (UUID)
- `category_id` (UUID, matches category and its descendant subtrees)
- `brand_id` (UUID)
- `completeness_min` / `completeness_max` (integers 0-100)
- `channel` (string, e.g. `shopify`, `amazon`)
- `sortBy` (`name`, `sku`, `created_at`, `updated_at`, `completeness`, `price`)
- `sortOrder` (`ASC` or `DESC`)
---
## 3. Query Optimization & Eager-Loading Structure
To avoid the $N+1$ query problem, the product listing query uses targeted eager-loading with selected columns:
```javascript
const queryOptions = {
where: baseWhereClause,
attributes: [
'id', 'code', 'sku', 'name', 'price', 'stock', 'status', 'type',
'family_id', 'category_id', 'brand_id', 'unit_id', 'created_at', 'updated_at'
],
include: [
{
model: models.Catalog,
as: 'family',
attributes: ['id', 'code', 'name']
},
{
model: models.Category,
as: 'category',
attributes: ['id', 'code', 'name', 'slug']
},
{
model: models.Brand,
as: 'brand',
attributes: ['id', 'code', 'name', 'logo_url']
},
{
model: models.Unit,
as: 'unit',
attributes: ['id', 'code', 'name', 'symbol']
},
{
model: models.ProductAsset,
as: 'productAssets',
where: { is_primary: true },
required: false,
include: [
{
model: models.Asset,
as: 'asset',
attributes: ['id', 'url', 'thumbnail_url', 'name', 'mime_type']
}
]
},
{
model: models.ProductCompleteness,
as: 'completenessEntries',
where: { channel: 'default' },
required: false,
attributes: ['percentage', 'is_complete', 'missing_attributes', 'missing_assets']
}
],
order: [[sortField, sortOrder]],
limit,
offset: (page - 1) * limit
};
```
---
## 4. Frontend Data Grid Hydration & Performance
- **Primary Hero Image**: Displayed as a responsive 40x40 thumbnail from `productAssets[0].asset.thumbnail_url || productAssets[0].asset.url`.
- **Completeness Indicator**: Circular or pill progress badge colored dynamically:
- `0 - 49%`: Red (Incomplete)
- `50 - 84%`: Amber (Partially Configured)
- `85 - 99%`: Blue (Ready for Review)
- `100%`: Emerald Green (Fully Complete & Ready for Publish)
- **Fast Filter Sync**: Search query debounced at 300ms, updating URL search params to preserve filter state on browser reload.
+28
View File
@@ -0,0 +1,28 @@
# 📚 PIM Product Creation & Management Master Documentation
Welcome to the central architectural and operational knowledge base for the **Product Information Management (PIM)** engine. This directory contains end-to-end specifications, database table dictionaries, sequence diagrams, and optimization blueprints designed for software engineers, QA architects, and AI autonomous agents.
---
## 📑 Manuals & Reference Guides
| File | Scope & Contents |
| :--- | :--- |
| **[`01_EXECUTIVE_ARCHITECTURE_AND_ECOSYSTEM_MAP.md`](file:///Users/maskantech/Desktop/PIM/docs/product_engine_deep_dive/01_EXECUTIVE_ARCHITECTURE_AND_ECOSYSTEM_MAP.md)** | Global Entity-Relationship (ER) diagram, 5-layer dependency hierarchy, multi-tenant isolation principles, and core architectural invariants. |
| **[`02_DATABASE_SCHEMA_AND_TABLE_DICTIONARY.md`](file:///Users/maskantech/Desktop/PIM/docs/product_engine_deep_dive/02_DATABASE_SCHEMA_AND_TABLE_DICTIONARY.md)** | Complete table-by-table dictionary covering 10+ core tables (`products`, `catalogs`, `categories`, `brands`, `units`, `attributes`, `attribute_options`, `product_attribute_values`, `product_assets`, `product_completeness`), exact column types, constraints, and defaults. |
| **[`03_PRODUCT_CREATION_POST_API_LIFECYCLE.md`](file:///Users/maskantech/Desktop/PIM/docs/product_engine_deep_dive/03_PRODUCT_CREATION_POST_API_LIFECYCLE.md)** | Micro-detailed specification of `POST /api/v1/products`, request payload schema, transaction lifecycle sequence diagram, code/SKU generation rules, and status state machine. |
| **[`04_DEPENDENT_ENTITIES_AND_CASCADE_ENGINE.md`](file:///Users/maskantech/Desktop/PIM/docs/product_engine_deep_dive/04_DEPENDENT_ENTITIES_AND_CASCADE_ENGINE.md)** | In-depth breakdown of Category trees, Brand filters, Units of Measure conversions, Dynamic Attribute EAV models, DAM Media Asset roles, and the mathematical Completeness Calculation Engine. |
| **[`05_SYSTEM_GAPS_RISK_AUDIT_AND_OPTIMIZATION_BLUEPRINT.md`](file:///Users/maskantech/Desktop/PIM/docs/product_engine_deep_dive/05_SYSTEM_GAPS_RISK_AUDIT_AND_OPTIMIZATION_BLUEPRINT.md)** | Identified vulnerabilities, concurrency risks, recommended high-performance PostgreSQL composite indexes, and future syndication queue roadmap. |
| **[`06_PRODUCT_LISTING_AND_QUERY_ARCHITECTURE.md`](file:///Users/maskantech/Desktop/PIM/docs/product_engine_deep_dive/06_PRODUCT_LISTING_AND_QUERY_ARCHITECTURE.md)** | Complete blueprint for `GET /api/v1/products`, multi-facet filtering, eager-loading relations, sorting, pagination, and frontend grid hydration. |
---
## 🎯 Quick Verification Commands
```bash
# Verify backend server health
curl -sI http://localhost:5002/api/v1/categories
# Verify frontend build & dev server
curl -sI http://localhost:5173
cd productcatalogue_frontend && npx tsc --noEmit
```
+422
View File
@@ -8,7 +8,9 @@
"name": "maskan-pim-backend",
"version": "1.0.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.1113.0",
"bcrypt": "^6.0.0",
"cloudinary": "^2.10.0",
"compression": "^1.7.5",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
@@ -86,6 +88,314 @@
"openapi-types": ">=7"
}
},
"node_modules/@aws-sdk/checksums": {
"version": "3.1000.28",
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.28.tgz",
"integrity": "sha512-VCpnmyHQ1IH49ni3LXnQj7DPr7rmcJmzYeiCkYdCcfgNtkvOj38cdcL9lapBWoItZWFACJPFJlymqC7/gem3Gw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.8",
"@aws-sdk/types": "^3.974.4",
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-s3": {
"version": "3.1113.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1113.0.tgz",
"integrity": "sha512-NRqdtohoMRyWkEeeznfG1KPN08dclCbl+HFuLPB2v8qPcgoNmTFlLKl9ELiR6hsGXQ4Ur3qvRnoJVEk8ND74pg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/checksums": "^3.1000.28",
"@aws-sdk/core": "^3.977.8",
"@aws-sdk/credential-provider-node": "^3.972.80",
"@aws-sdk/middleware-sdk-s3": "^3.972.74",
"@aws-sdk/signature-v4-multi-region": "^3.996.45",
"@aws-sdk/types": "^3.974.4",
"@smithy/core": "^3.31.1",
"@smithy/fetch-http-handler": "^5.6.13",
"@smithy/node-http-handler": "^4.9.13",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/core": {
"version": "3.977.8",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.8.tgz",
"integrity": "sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.4",
"@aws-sdk/xml-builder": "^3.972.39",
"@aws/lambda-invoke-store": "^0.3.0",
"@smithy/core": "^3.31.1",
"@smithy/signature-v4": "^5.6.12",
"@smithy/types": "^4.16.1",
"bowser": "^2.11.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-env": {
"version": "3.972.69",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.69.tgz",
"integrity": "sha512-AreCFzcB4kH2HF9031Ot0jSJr3KXvRg6e8uDeub20JEVdZU3Bv0sTq1plc7VsT3KiqutlzH7l0j50UcCWHUioA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.8",
"@aws-sdk/types": "^3.974.4",
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-http": {
"version": "3.972.71",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.71.tgz",
"integrity": "sha512-A8ObcqVmDMnk4F9NozZ7JwmUu9Q4xyBJkmyq1C5U+wNM9ht9J7+EuuyabsLWXZnOoTqFaJuYBYTKf5CTipkEjA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.8",
"@aws-sdk/types": "^3.974.4",
"@smithy/core": "^3.31.1",
"@smithy/fetch-http-handler": "^5.6.13",
"@smithy/node-http-handler": "^4.9.13",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.973.14",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.14.tgz",
"integrity": "sha512-7c+Wti2LsERNWMfm7ySz3/6RPopFW3Nmn7s63Xpcq6R/tRuY5hpvkHA2xVgi5ukJbvok9l0IDtVEvqTtg+X7dw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.8",
"@aws-sdk/credential-provider-env": "^3.972.69",
"@aws-sdk/credential-provider-http": "^3.972.71",
"@aws-sdk/credential-provider-login": "^3.972.76",
"@aws-sdk/credential-provider-process": "^3.972.69",
"@aws-sdk/credential-provider-sso": "^3.973.13",
"@aws-sdk/credential-provider-web-identity": "^3.972.75",
"@aws-sdk/nested-clients": "^3.997.43",
"@aws-sdk/types": "^3.974.4",
"@smithy/core": "^3.31.1",
"@smithy/credential-provider-imds": "^4.4.16",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-login": {
"version": "3.972.76",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.76.tgz",
"integrity": "sha512-LVixwOnEJfrrfKHeZjBA8pIMTZjNDq8ak8VpcoWUuCJDrSnBNU8POJksULMgvN089P0MXtQYH2Zs627/MK1K0g==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.8",
"@aws-sdk/nested-clients": "^3.997.43",
"@aws-sdk/types": "^3.974.4",
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-node": {
"version": "3.972.80",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.80.tgz",
"integrity": "sha512-bE2qh8ww4iClO1jHsBXdOE8FUgzDbdxbyorNjSCoPSkQd51k3jODItuPZfuwcLHZqDXsH+bI4AMHhqtuyR7mSg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/credential-provider-env": "^3.972.69",
"@aws-sdk/credential-provider-http": "^3.972.71",
"@aws-sdk/credential-provider-ini": "^3.973.14",
"@aws-sdk/credential-provider-process": "^3.972.69",
"@aws-sdk/credential-provider-sso": "^3.973.13",
"@aws-sdk/credential-provider-web-identity": "^3.972.75",
"@aws-sdk/types": "^3.974.4",
"@smithy/core": "^3.31.1",
"@smithy/credential-provider-imds": "^4.4.16",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-process": {
"version": "3.972.69",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.69.tgz",
"integrity": "sha512-9kpTNdZTrcqXTfhxM7fgl9Z68ek3Fu5oe3Yf+A/pJGibEqpgZxz2tSY7SinmyCIU2PJ+ygY4FPoBBnLpocMtrQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.8",
"@aws-sdk/types": "^3.974.4",
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.973.13",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.13.tgz",
"integrity": "sha512-Oc81qauMPzUoTnAS2YKpNwY6sY/LUyQTEeaf6yP197WMxkEBQfcKLR1MFpD7+pNTubXnfkH6gwpji+Gc7iyD2Q==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.8",
"@aws-sdk/nested-clients": "^3.997.43",
"@aws-sdk/token-providers": "3.1111.0",
"@aws-sdk/types": "^3.974.4",
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.75",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.75.tgz",
"integrity": "sha512-YPN6uoGDgjjjeVFZrcOeCJqmB6zpXoeeNgIjqe+DexJaWqdjVfCCe+VAZwli9Z2h8KhFW8oxkO39emQ1tyz/Mw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.8",
"@aws-sdk/nested-clients": "^3.997.43",
"@aws-sdk/types": "^3.974.4",
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-sdk-s3": {
"version": "3.972.74",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.74.tgz",
"integrity": "sha512-2lzoV2z2QO5KJZYGOCnIZ1WVQgzMECvwuzr1xb034a++8QW4U4eGrmC2u4yg1xvNv4TLL/Uv5DLyuAiw0b9z7Q==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.8",
"@aws-sdk/signature-v4-multi-region": "^3.996.45",
"@aws-sdk/types": "^3.974.4",
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/nested-clients": {
"version": "3.997.43",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.43.tgz",
"integrity": "sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.8",
"@aws-sdk/signature-v4-multi-region": "^3.996.45",
"@aws-sdk/types": "^3.974.4",
"@smithy/core": "^3.31.1",
"@smithy/fetch-http-handler": "^5.6.13",
"@smithy/node-http-handler": "^4.9.13",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.45",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.45.tgz",
"integrity": "sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.4",
"@smithy/signature-v4": "^5.6.12",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/token-providers": {
"version": "3.1111.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1111.0.tgz",
"integrity": "sha512-JfljgoVtl+s3Qy21n9a7Z48uCQaOXcN74KJ3TEQfPoB293GrXFSt6HSQJF1sTZ8c/5QedEvd3NjJQMO4u9qa5A==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.8",
"@aws-sdk/nested-clients": "^3.997.43",
"@aws-sdk/types": "^3.974.4",
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/types": {
"version": "3.974.4",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.4.tgz",
"integrity": "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.39",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.39.tgz",
"integrity": "sha512-FTti8DS5MMWXNUWiRwXAJeYS+0GHHiMy0+7XOhcwk63ILHmfS2UFy2z/HNpZCSOJJ3P3dnWY6hfYNW3DF0nXUA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.16.1",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws/lambda-invoke-store": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
"integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@colors/colors": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
@@ -156,6 +466,87 @@
"hasInstallScript": true,
"license": "Apache-2.0"
},
"node_modules/@smithy/core": {
"version": "3.33.2",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.2.tgz",
"integrity": "sha512-CUGXpnPkVdjUCbix+83sWLW9VFgQOm44MDOx/ihITJMAnOZKvL8YYIc7DR9pP/tZ8CIRvMiON/TucvygqbHO3w==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/credential-provider-imds": {
"version": "4.5.2",
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz",
"integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.2",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/fetch-http-handler": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.2.tgz",
"integrity": "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.2",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/node-http-handler": {
"version": "4.11.2",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.11.2.tgz",
"integrity": "sha512-avwAh9HM3h2lcfjvP3zYIZGf+XVgLQ91wOJ2qoFbNpW1UZeZb33aGlhTZvtkANHfcGhJroRY64525OjfgOg30g==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.2",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/signature-v4": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.2.tgz",
"integrity": "sha512-P7Ki6px6OOrxVtx8K7nLmyx4SlXUW/uTKDdMG44UHefmPGSRMBKe2v+TM59WdLcpUIrBrnuCsIqiM2MbsZjmhw==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.2",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/types": {
"version": "4.17.2",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.2.tgz",
"integrity": "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@so-ric/colorspace": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz",
@@ -462,6 +853,12 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bowser": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
@@ -670,6 +1067,18 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/cloudinary": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.10.0.tgz",
"integrity": "sha512-sY09kYg7wprkndAOjZBAYqFZqwL+SxnEGcAvksOvFA+5upnFn949UjkEkHKNSwkBtW/xRDd0p6NgbSXZcxkI3w==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.23"
},
"engines": {
"node": ">=9"
}
},
"node_modules/color": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
@@ -2346,6 +2755,13 @@
"fn.name": "1.x.x"
}
},
"node_modules/openapi-types": {
"version": "12.1.3",
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
"license": "MIT",
"peer": true
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -3516,6 +3932,12 @@
"node": ">= 14.0.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+2
View File
@@ -21,7 +21,9 @@
"db:seed:undo:test": "cross-env NODE_ENV=test sequelize-cli db:seed:undo:all --config src/shared/config/database.config.cjs --seeders-path src/seeders --models-path src/shared/database"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1113.0",
"bcrypt": "^6.0.0",
"cloudinary": "^2.10.0",
"compression": "^1.7.5",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
@@ -27,6 +27,10 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true
},
code: {
type: DataTypes.STRING(50),
allowNull: false,
@@ -1,8 +1,12 @@
import { models } from '../../../shared/database/models.js';
import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class AttributeGroupRepository {
async findAll(options = {}, context = {}) {
const where = applyTenantScope(options.where || {}, context);
return await models.AttributeGroup.findAll({
...options,
where,
include: [
{
model: models.Attribute,
@@ -12,28 +16,30 @@ export class AttributeGroupRepository {
],
order: [
['name', 'ASC']
],
...options
]
});
}
async findById(id, options = {}, context = {}) {
return await models.AttributeGroup.findByPk(id, {
const where = applyTenantScope({ id }, context);
return await models.AttributeGroup.findOne({
...options,
where,
include: [
{
model: models.Attribute,
as: 'attributes',
through: { attributes: ['display_order'] }
}
],
...options
]
});
}
async findByCode(code, options = {}, context = {}) {
const where = applyTenantScope({ code }, context);
return await models.AttributeGroup.findOne({
where: { code },
...options
...options,
where
});
}
@@ -2,6 +2,8 @@ import repository from './attributeGroup.repository.js';
import { models, sequelize } from '../../../shared/database/models.js';
import { SocketService } from '../../../shared/services/socket.service.js';
import { AuditService } from '../../../shared/services/audit.service.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
import { Op } from 'sequelize';
export class AttributeGroupService {
async getAll(query = {}, context = {}) {
@@ -15,7 +17,7 @@ export class AttributeGroupService {
async getById(id, context = {}) {
const record = await repository.findById(id, {}, context);
if (!record) {
throw new Error('Attribute Group not found');
throw new ApiError(404, 'Attribute Group not found');
}
return record;
}
@@ -23,25 +25,32 @@ export class AttributeGroupService {
async create(data, context = {}) {
const transaction = await sequelize.transaction();
try {
if (!data.code || !data.code.trim()) {
if (data.name) {
data.code = data.name.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
}
if (!data.code) {
data.code = `grp_${Date.now()}`;
}
}
data.code = data.code.toLowerCase().trim();
const rawCode = data.code || data.name || 'group';
const code = rawCode.toLowerCase().trim().replace(/[^a-z0-9_]/g, '_');
const tenantId = (context.userType !== 'platform' && context.tenantId) ? context.tenantId : (data.tenant_id || null);
// Reject duplicate code conflicts
const existing = await models.AttributeGroup.findOne({
where: {
code,
[Op.or]: [
{ tenant_id: tenantId },
{ tenant_id: null }
]
},
transaction
});
// Check duplicate code
const existing = await models.AttributeGroup.findOne({ where: { code: data.code }, transaction });
if (existing) {
throw new Error(`Attribute Group with code "${data.code}" already exists`);
throw new ApiError(409, `Attribute Group with code "${code}" already exists in this workspace`);
}
data.code = code;
data.tenant_id = tenantId;
const record = await models.AttributeGroup.create(data, { transaction });
// Handle attributes linking
// Handle attributes linking atomically
if (data.attributes && Array.isArray(data.attributes)) {
for (let i = 0; i < data.attributes.length; i++) {
const attributeId = data.attributes[i];
@@ -55,11 +64,18 @@ export class AttributeGroupService {
await transaction.commit();
const fullRecord = await repository.findById(record.id, {}, context);
const fullRecord = await models.AttributeGroup.findByPk(record.id, {
include: [
{
model: models.Attribute,
as: 'attributes',
through: { attributes: ['display_order'] }
}
]
});
SocketService.broadcast('attributeGroup:created', fullRecord);
SocketService.broadcast('attributeGroup.created', fullRecord);
SocketService.broadcast('attribute.group.created', fullRecord);
await AuditService.log({
action: 'CREATE',
@@ -69,18 +85,11 @@ export class AttributeGroupService {
details: data
});
if (models.AttributeGroupHistory) {
await models.AttributeGroupHistory.create({
group_id: record.id,
action: 'CREATE',
changed_by: context.userId || null,
changes: { newValues: fullRecord.toJSON() }
});
}
return fullRecord;
} catch (error) {
await transaction.rollback();
if (transaction && !transaction.finished) {
await transaction.rollback();
}
throw error;
}
}
@@ -90,23 +99,35 @@ export class AttributeGroupService {
try {
const record = await models.AttributeGroup.findByPk(id, { transaction });
if (!record) {
throw new Error('Attribute Group not found');
throw new ApiError(404, 'Attribute Group not found');
}
if (data.code && data.code !== record.code) {
const existing = await models.AttributeGroup.findOne({ where: { code: data.code }, transaction });
const code = data.code.toLowerCase().trim().replace(/[^a-z0-9_]/g, '_');
const tenantId = context.tenantId || record.tenant_id || null;
const existing = await models.AttributeGroup.findOne({
where: {
code,
id: { [Op.ne]: id },
[Op.or]: [
{ tenant_id: tenantId },
{ tenant_id: null }
]
},
transaction
});
if (existing) {
throw new Error(`Attribute Group with code "${data.code}" already exists`);
throw new ApiError(409, `Attribute Group with code "${code}" already exists in this workspace`);
}
data.code = code;
}
await record.update(data, { transaction });
// Handle attributes updating
if (data.attributes && Array.isArray(data.attributes)) {
// Clear previous associations
await models.AttributeGroupAttribute.destroy({ where: { group_id: id }, transaction });
for (let i = 0; i < data.attributes.length; i++) {
const attributeId = data.attributes[i];
await models.AttributeGroupAttribute.create({
@@ -119,11 +140,17 @@ export class AttributeGroupService {
await transaction.commit();
const fullRecord = await repository.findById(id, {}, context);
const fullRecord = await models.AttributeGroup.findByPk(id, {
include: [
{
model: models.Attribute,
as: 'attributes',
through: { attributes: ['display_order'] }
}
]
});
SocketService.broadcast('attributeGroup:updated', fullRecord);
SocketService.broadcast('attributeGroup.updated', fullRecord);
SocketService.broadcast('attribute.group.updated', fullRecord);
await AuditService.log({
action: 'UPDATE',
@@ -133,67 +160,34 @@ export class AttributeGroupService {
details: data
});
if (models.AttributeGroupHistory) {
await models.AttributeGroupHistory.create({
group_id: id,
action: 'UPDATE',
changed_by: context.userId || null,
changes: { newValues: fullRecord.toJSON() }
});
}
return fullRecord;
} catch (error) {
await transaction.rollback();
if (transaction && !transaction.finished) {
await transaction.rollback();
}
throw error;
}
}
async delete(id, context = {}) {
const transaction = await sequelize.transaction();
try {
const record = await models.AttributeGroup.findByPk(id, { transaction });
if (!record) {
throw new Error('Attribute Group not found');
}
// Check if group is assigned in any Attribute Set
const setAssociationCount = await models.AttributeSetGroup.count({ where: { attribute_group_id: id }, transaction });
if (setAssociationCount > 0) {
throw new Error('Cannot delete Attribute Group as it is associated with one or more Attribute Sets');
}
// Clear child associations
await models.AttributeGroupAttribute.destroy({ where: { group_id: id }, transaction });
await record.destroy({ transaction });
await transaction.commit();
SocketService.broadcast('attributeGroup:deleted', { id });
SocketService.broadcast('attributeGroup.deleted', { id });
SocketService.broadcast('attribute.group.deleted', { id });
await AuditService.log({
action: 'DELETE',
resource: 'AttributeGroup',
resourceId: id,
userId: context.userId || 'system'
});
if (models.AttributeGroupHistory) {
await models.AttributeGroupHistory.create({
group_id: id,
action: 'DELETE',
changed_by: context.userId || null,
changes: { deletedId: id }
});
}
return true;
} catch (error) {
await transaction.rollback();
throw error;
const record = await models.AttributeGroup.findByPk(id);
if (!record) {
throw new ApiError(404, 'Attribute Group not found');
}
await models.AttributeGroupAttribute.destroy({ where: { group_id: id } });
await record.destroy({ force: true });
SocketService.broadcast('attributeGroup:deleted', { id });
await AuditService.log({
action: 'DELETE',
resource: 'AttributeGroup',
resourceId: id,
userId: context.userId || 'system'
});
return true;
}
}
@@ -20,6 +20,10 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true
},
code: {
type: DataTypes.STRING(50),
allowNull: false,
@@ -1,8 +1,12 @@
import { models } from '../../../shared/database/models.js';
import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class AttributeSetRepository {
async findAll(options = {}, context = {}) {
const where = applyTenantScope(options.where || {}, context);
return await models.AttributeSet.findAll({
...options,
where,
include: [
{
model: models.AttributeGroup,
@@ -26,13 +30,15 @@ export class AttributeSetRepository {
],
order: [
['name', 'ASC']
],
...options
]
});
}
async findById(id, options = {}, context = {}) {
return await models.AttributeSet.findByPk(id, {
const where = applyTenantScope({ id }, context);
return await models.AttributeSet.findOne({
...options,
where,
include: [
{
model: models.AttributeGroup,
@@ -53,15 +59,15 @@ export class AttributeSetRepository {
}
]
}
],
...options
]
});
}
async findByCode(code, options = {}, context = {}) {
const where = applyTenantScope({ code }, context);
return await models.AttributeSet.findOne({
where: { code },
...options
...options,
where
});
}
@@ -2,6 +2,8 @@ import repository from './attributeSet.repository.js';
import { models, sequelize } from '../../../shared/database/models.js';
import { SocketService } from '../../../shared/services/socket.service.js';
import { AuditService } from '../../../shared/services/audit.service.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
import { Op } from 'sequelize';
export class AttributeSetService {
async getAll(query = {}, context = {}) {
@@ -15,7 +17,7 @@ export class AttributeSetService {
async getById(id, context = {}) {
const record = await repository.findById(id, {}, context);
if (!record) {
throw new Error('Attribute Set not found');
throw new ApiError(404, 'Attribute Set not found');
}
return record;
}
@@ -23,15 +25,32 @@ export class AttributeSetService {
async create(data, context = {}) {
const transaction = await sequelize.transaction();
try {
// Check duplicate code
const existing = await models.AttributeSet.findOne({ where: { code: data.code }, transaction });
const rawCode = data.code || data.name || 'set';
const code = rawCode.toLowerCase().trim().replace(/[^a-z0-9_]/g, '_');
const tenantId = (context.userType !== 'platform' && context.tenantId) ? context.tenantId : (data.tenant_id || null);
// Reject duplicate code conflicts
const existing = await models.AttributeSet.findOne({
where: {
code,
[Op.or]: [
{ tenant_id: tenantId },
{ tenant_id: null }
]
},
transaction
});
if (existing) {
throw new Error(`Attribute Set with code "${data.code}" already exists`);
throw new ApiError(409, `Attribute Set with code "${code}" already exists in this workspace`);
}
data.code = code;
data.tenant_id = tenantId;
const record = await models.AttributeSet.create(data, { transaction });
// Handle groups linking
// Handle groups linking atomically
if (data.groups && Array.isArray(data.groups)) {
for (let i = 0; i < data.groups.length; i++) {
const groupId = data.groups[i];
@@ -45,11 +64,18 @@ export class AttributeSetService {
await transaction.commit();
const fullRecord = await repository.findById(record.id, {}, context);
const fullRecord = await models.AttributeSet.findByPk(record.id, {
include: [
{
model: models.AttributeGroup,
as: 'groups',
through: { attributes: ['display_order'] }
}
]
});
SocketService.broadcast('attributeSet:created', fullRecord);
SocketService.broadcast('attributeSet.created', fullRecord);
SocketService.broadcast('attribute.set.created', fullRecord);
await AuditService.log({
action: 'CREATE',
@@ -59,18 +85,11 @@ export class AttributeSetService {
details: data
});
if (models.AttributeSetHistory) {
await models.AttributeSetHistory.create({
set_id: record.id,
action: 'CREATE',
changed_by: context.userId || null,
changes: { newValues: fullRecord.toJSON() }
});
}
return fullRecord;
} catch (error) {
await transaction.rollback();
if (transaction && !transaction.finished) {
await transaction.rollback();
}
throw error;
}
}
@@ -80,23 +99,35 @@ export class AttributeSetService {
try {
const record = await models.AttributeSet.findByPk(id, { transaction });
if (!record) {
throw new Error('Attribute Set not found');
throw new ApiError(404, 'Attribute Set not found');
}
if (data.code && data.code !== record.code) {
const existing = await models.AttributeSet.findOne({ where: { code: data.code }, transaction });
const code = data.code.toLowerCase().trim().replace(/[^a-z0-9_]/g, '_');
const tenantId = context.tenantId || record.tenant_id || null;
const existing = await models.AttributeSet.findOne({
where: {
code,
id: { [Op.ne]: id },
[Op.or]: [
{ tenant_id: tenantId },
{ tenant_id: null }
]
},
transaction
});
if (existing) {
throw new Error(`Attribute Set with code "${data.code}" already exists`);
throw new ApiError(409, `Attribute Set with code "${code}" already exists in this workspace`);
}
data.code = code;
}
await record.update(data, { transaction });
// Handle groups updating
if (data.groups && Array.isArray(data.groups)) {
// Clear previous associations
await models.AttributeSetGroup.destroy({ where: { attribute_set_id: id }, transaction });
for (let i = 0; i < data.groups.length; i++) {
const groupId = data.groups[i];
await models.AttributeSetGroup.create({
@@ -109,11 +140,17 @@ export class AttributeSetService {
await transaction.commit();
const fullRecord = await repository.findById(id, {}, context);
const fullRecord = await models.AttributeSet.findByPk(id, {
include: [
{
model: models.AttributeGroup,
as: 'groups',
through: { attributes: ['display_order'] }
}
]
});
SocketService.broadcast('attributeSet:updated', fullRecord);
SocketService.broadcast('attributeSet.updated', fullRecord);
SocketService.broadcast('attribute.set.updated', fullRecord);
await AuditService.log({
action: 'UPDATE',
@@ -123,61 +160,34 @@ export class AttributeSetService {
details: data
});
if (models.AttributeSetHistory) {
await models.AttributeSetHistory.create({
set_id: id,
action: 'UPDATE',
changed_by: context.userId || null,
changes: { newValues: fullRecord.toJSON() }
});
}
return fullRecord;
} catch (error) {
await transaction.rollback();
if (transaction && !transaction.finished) {
await transaction.rollback();
}
throw error;
}
}
async delete(id, context = {}) {
const transaction = await sequelize.transaction();
try {
const record = await models.AttributeSet.findByPk(id, { transaction });
if (!record) {
throw new Error('Attribute Set not found');
}
// Clear child associations
await models.AttributeSetGroup.destroy({ where: { attribute_set_id: id }, transaction });
await record.destroy({ transaction });
await transaction.commit();
SocketService.broadcast('attributeSet:deleted', { id });
SocketService.broadcast('attributeSet.deleted', { id });
SocketService.broadcast('attribute.set.deleted', { id });
await AuditService.log({
action: 'DELETE',
resource: 'AttributeSet',
resourceId: id,
userId: context.userId || 'system'
});
if (models.AttributeSetHistory) {
await models.AttributeSetHistory.create({
set_id: id,
action: 'DELETE',
changed_by: context.userId || null,
changes: { deletedId: id }
});
}
return true;
} catch (error) {
await transaction.rollback();
throw error;
const record = await models.AttributeSet.findByPk(id);
if (!record) {
throw new ApiError(404, 'Attribute Set not found');
}
await models.AttributeSetGroup.destroy({ where: { attribute_set_id: id } });
await record.destroy({ force: true });
SocketService.broadcast('attributeSet:deleted', { id });
await AuditService.log({
action: 'DELETE',
resource: 'AttributeSet',
resourceId: id,
userId: context.userId || 'system'
});
return true;
}
}
@@ -40,6 +40,10 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true
},
code: {
type: DataTypes.STRING(50),
allowNull: false,
@@ -1,12 +1,18 @@
import { models } from '../../../shared/database/models.js';
import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class AttributeRepository {
async findAll(options = {}, context = {}) {
return await models.Attribute.findAll(options);
const queryOptions = {
...options,
where: applyTenantScope(options.where || {}, context)
};
return await models.Attribute.findAll(queryOptions);
}
async findById(id, options = {}, context = {}) {
return await models.Attribute.findByPk(id, options);
const where = applyTenantScope({ id }, context);
return await models.Attribute.findOne({ ...options, where });
}
async create(data, options = {}, context = {}) {
@@ -3,8 +3,10 @@ import { models, sequelize } from '../../../shared/database/models.js';
import { SocketService } from '../../../shared/services/socket.service.js';
import { AuditService } from '../../../shared/services/audit.service.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
import { generateUniqueCode } from '../../../utils/helpers/code.utils.js';
import { Op } from 'sequelize';
export class AttributeService {
async getAll(query = {}, context = {}) {
const where = {};
@@ -131,27 +133,29 @@ export class AttributeService {
async create(data, context = {}) {
const transaction = await sequelize.transaction();
try {
if (!data.code || !data.code.trim()) {
if (data.name) {
data.code = data.name.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
}
if (!data.code) {
data.code = `attr_${Date.now()}`;
}
}
data.code = data.code.toLowerCase().trim();
const rawCode = data.code || data.name || 'attribute';
const code = rawCode.toLowerCase().trim().replace(/[^a-z0-9_]/g, '_');
const tenantId = (context.userType !== 'platform' && context.tenantId) ? context.tenantId : (data.tenant_id || null);
// Check duplicate code
const existing = await models.Attribute.findOne({
where: { code: data.code },
paranoid: false,
// Reject duplicate code conflicts
const existing = await models.Attribute.findOne({
where: {
code,
[Op.or]: [
{ tenant_id: tenantId },
{ tenant_id: null }
]
},
transaction
});
if (existing) {
throw new Error(`Attribute with code "${data.code}" already exists`);
throw new ApiError(409, `Attribute with code "${code}" already exists in this workspace`);
}
// Automatically assign display order if not provided
data.code = code;
data.tenant_id = tenantId;
if (data.display_order === undefined || data.display_order === null) {
const maxOrder = await models.Attribute.max('display_order', { transaction }) || 0;
data.display_order = maxOrder + 1;
@@ -166,19 +170,19 @@ export class AttributeService {
if (data.options && Array.isArray(data.options)) {
for (let i = 0; i < data.options.length; i++) {
const opt = data.options[i];
let code, label;
let optCode, optLabel;
if (typeof opt === 'string') {
label = opt;
code = opt.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
optLabel = opt;
optCode = opt.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
} else if (opt && typeof opt === 'object') {
code = opt.code;
label = opt.label;
optCode = opt.code || opt.value || (opt.label ? opt.label.toLowerCase().replace(/[^a-z0-9_]+/g, '_') : `opt_${i}`);
optLabel = opt.label || opt.name || opt.value || optCode;
}
if (code && label) {
if (optCode && optLabel) {
await models.AttributeOption.create({
attribute_id: record.id,
code,
label,
code: optCode,
label: optLabel,
sort_order: i,
status: 'active'
}, { transaction });
@@ -311,44 +315,44 @@ export class AttributeService {
}
// Usage Check: Groups mapping (count only active groups)
const groupCount = await models.AttributeGroup.count({
const groupCount = await models.AttributeGroup.count({
include: [{
model: models.Attribute,
as: 'attributes',
where: { id: id },
required: true
}],
transaction
transaction
});
// Usage Check: Product Families mapping (count only active catalogs)
const familyCount = await models.Catalog.count({
const familyCount = await models.Catalog.count({
include: [{
model: models.Attribute,
as: 'attributes',
where: { id: id },
required: true
}],
transaction
transaction
});
// Usage Check: Variant axes mapping (count only active catalogs)
const axisCount = await models.Catalog.count({
const axisCount = await models.Catalog.count({
include: [{
model: models.Attribute,
as: 'variantAxes',
where: { id: id },
required: true
}],
transaction
transaction
});
// Usage Check: Variant Values
let valCount = 0;
if (models.VariantValue) {
valCount = await models.VariantValue.count({
valCount = await models.VariantValue.count({
where: { axis_id: id },
transaction
transaction
});
}
@@ -370,7 +374,7 @@ export class AttributeService {
const oldValues = record.toJSON();
// Change status to Archived, save updated_by and deleted_by context
await record.update({
await record.update({
status: 'archived',
updated_by: context.userId || null,
deleted_by: context.userId || null
@@ -412,19 +416,19 @@ export class AttributeService {
async restore(id, context = {}) {
const transaction = await sequelize.transaction();
try {
const record = await models.Attribute.findByPk(id, {
const record = await models.Attribute.findByPk(id, {
paranoid: false,
transaction
transaction
});
if (!record) {
throw new Error('Attribute not found');
}
await record.restore({ transaction });
await record.update({
status: 'active',
await record.update({
status: 'active',
deleted_by: null,
updated_by: context.userId || null
updated_by: context.userId || null
}, { transaction });
await transaction.commit();
@@ -14,6 +14,10 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true
},
action: {
type: DataTypes.STRING,
allowNull: false
@@ -1,26 +1,30 @@
import { models } from '../../../shared/database/models.js';
import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class AuditLogRepository {
async findAll(options = {}) {
return await models.AuditLog.findAll(options);
async findAll(options = {}, context = {}) {
const where = applyTenantScope(options.where || {}, context);
return await models.AuditLog.findAll({ ...options, where });
}
async findById(id, options = {}) {
return await models.AuditLog.findByPk(id, options);
async findById(id, options = {}, context = {}) {
const where = applyTenantScope({ id }, context);
return await models.AuditLog.findOne({ ...options, where });
}
async create(data, options = {}) {
return await models.AuditLog.create(data, options);
async create(data, options = {}, context = {}) {
const tenantId = context.tenant_id || context.tenantId || null;
return await models.AuditLog.create({ ...data, tenant_id: tenantId }, options);
}
async update(id, data, options = {}) {
const record = await this.findById(id, options);
async update(id, data, options = {}, context = {}) {
const record = await this.findById(id, options, context);
if (!record) return null;
return await record.update(data, options);
}
async delete(id, options = {}) {
const record = await this.findById(id, options);
async delete(id, options = {}, context = {}) {
const record = await this.findById(id, options, context);
if (!record) return false;
await record.destroy(options);
return true;
@@ -33,6 +33,18 @@ export class RoleService {
async create(data, context = {}) {
const { role_name, description, permissions, tenant_id } = data;
const isPlatformUser = context.userType === 'platform' || context.roles?.some(r => r.role_code === 'SUPER_ADMIN');
if (!isPlatformUser) {
if (tenant_id && Number(tenant_id) !== Number(context.tenantId)) {
throw new ApiError(403, 'Forbidden: You cannot assign roles to another tenant workspace');
}
if (!context.tenantId) {
throw new ApiError(403, 'Forbidden: Tenant workspace context required to create roles');
}
}
const finalTenantId = isPlatformUser ? (tenant_id || null) : context.tenantId;
// Generate role code from name: Admin Editor -> ADMIN_EDITOR
const role_code = role_name.toUpperCase().replace(/[^A-Z0-9]/g, '_');
@@ -40,14 +52,28 @@ export class RoleService {
role_name,
role_code,
description,
tenant_id: tenant_id || null,
role_type: tenant_id ? 'tenant' : 'platform',
tenant_id: finalTenantId,
role_type: finalTenantId ? 'tenant' : 'platform',
is_system_role: false,
status: true
}, permissions, context);
}
async update(id, data, context = {}) {
const isPlatformUser = context.userType === 'platform' || context.roles?.some(r => r.role_code === 'SUPER_ADMIN');
const role = await repository.findById(id, {}, context);
if (!role) {
throw new ApiError(404, 'Role not found');
}
if (role.is_system_role) {
throw new ApiError(403, 'Forbidden: Baseline system roles cannot be modified');
}
if (!isPlatformUser && role.tenant_id !== context.tenantId) {
throw new ApiError(403, 'Forbidden: Cannot modify roles outside your tenant workspace');
}
const { role_name, description, permissions, status } = data;
const roleData = {};
if (role_name) {
@@ -65,6 +91,7 @@ export class RoleService {
}
async delete(id, context = {}) {
const isPlatformUser = context.userType === 'platform' || context.roles?.some(r => r.role_code === 'SUPER_ADMIN');
const role = await repository.findById(id, {}, context);
if (!role) {
throw new ApiError(404, 'Role not found');
@@ -74,6 +101,10 @@ export class RoleService {
throw new ApiError(403, 'Forbidden: Super Admin and System roles cannot be deleted');
}
if (!isPlatformUser && role.tenant_id !== context.tenantId) {
throw new ApiError(403, 'Forbidden: Cannot delete roles outside your tenant workspace');
}
const deleted = await repository.delete(id, context);
if (!deleted) {
throw new ApiError(404, 'Role not found');
@@ -57,6 +57,10 @@ export const login = async ({ email, password }) => {
throw new ApiError(401, 'Invalid email or password');
}
if (user.status === false) {
throw new ApiError(403, 'Account is disabled. Please contact your administrator.');
}
const isMatch = await user.validatePassword(password);
if (!isMatch) {
throw new ApiError(401, 'Invalid email or password');
@@ -124,6 +128,10 @@ export const refreshTokenAuth = async (oldRefreshToken) => {
throw new ApiError(401, 'Invalid refresh token');
}
if (user.status === false) {
throw new ApiError(403, 'Account is disabled');
}
const roleIds = user.roles.filter(r => r.UserRole.status).map(r => r.id);
const payload = {
@@ -93,13 +93,13 @@ export default (sequelize) => {
updatedAt: 'updated_at',
hooks: {
beforeCreate: async (user) => {
if (user.password_hash) {
if (user.password_hash && !user.password_hash.startsWith('$2b$') && !user.password_hash.startsWith('$2a$')) {
const salt = await bcrypt.genSalt(10);
user.password_hash = await bcrypt.hash(user.password_hash, salt);
}
},
beforeUpdate: async (user) => {
if (user.changed('password_hash')) {
if (user.changed('password_hash') && !user.password_hash.startsWith('$2b$') && !user.password_hash.startsWith('$2a$')) {
const salt = await bcrypt.genSalt(10);
user.password_hash = await bcrypt.hash(user.password_hash, salt);
}
@@ -79,10 +79,13 @@ router.get(
* 400:
* description: Validation Error
*/
import { checkUserQuota } from '../../../shared/middleware/quota.middleware.js';
router.post(
'/invite',
authenticate,
authorize(['settings.users']),
checkUserQuota,
controller.invite
);
@@ -14,6 +14,10 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true
},
code: {
type: DataTypes.STRING(50),
allowNull: false,
+6 -17
View File
@@ -1,15 +1,11 @@
import { models } from '../../../shared/database/models.js';
import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class BrandRepository {
async findAll(options = {}, context = {}) {
const queryOptions = {
...options,
where: {
...(options.where || {})
/* FUTURE_TENANT_ISOLATION_FLAG:
tenant_id: context.tenantId
*/
}
where: applyTenantScope(options.where || {}, context)
};
return await models.Brand.findAll(queryOptions);
}
@@ -17,22 +13,15 @@ export class BrandRepository {
async findById(id, options = {}, context = {}) {
const queryOptions = {
...options,
where: {
...(options.where || {})
/* FUTURE_TENANT_ISOLATION_FLAG:
tenant_id: context.tenantId
*/
}
where: applyTenantScope({ id, ...(options.where || {}) }, context)
};
return await models.Brand.findByPk(id, queryOptions);
return await models.Brand.findOne(queryOptions);
}
async create(data, options = {}, context = {}) {
const createData = {
...data
/* FUTURE_TENANT_ISOLATION_FLAG:
tenant_id: context.tenantId
*/
...data,
...(context.tenantId && context.userType !== 'platform' ? { tenant_id: context.tenantId } : {})
};
return await models.Brand.create(createData, options);
}
+37 -16
View File
@@ -4,6 +4,7 @@ import { SocketService } from '../../../shared/services/socket.service.js';
import { AuditService } from '../../../shared/services/audit.service.js';
import NotificationService from '../../notifications/notifications/notification.service.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
import { Op } from 'sequelize';
export class BrandService {
async getAll(query = {}, context = {}) {
@@ -23,22 +24,29 @@ export class BrandService {
}
async create(data, context = {}) {
if (!data.code || !data.code.trim()) {
if (data.name) {
data.code = data.name.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
const rawCode = data.code || data.name || 'brand';
const code = rawCode.toLowerCase().trim().replace(/[^a-z0-9_]/g, '_');
const tenantId = (context.userType !== 'platform' && context.tenantId) ? context.tenantId : (data.tenant_id || null);
// Reject duplicate codes within tenant or global scope
const existing = await models.Brand.findOne({
where: {
code,
[Op.or]: [
{ tenant_id: tenantId },
{ tenant_id: null }
]
}
if (!data.code) {
data.code = `brd_${Date.now()}`;
}
}
data.code = data.code.toLowerCase().trim();
});
// Check duplicate code
const [existing] = await repository.findAll({ where: { code: data.code } }, context);
if (existing) {
throw new Error(`Brand with code "${data.code}" already exists`);
throw new ApiError(409, `Brand with code "${code}" already exists in this workspace`);
}
data.code = code;
data.tenant_id = tenantId;
const record = await repository.create(data, {}, context);
// Broadcast event
@@ -75,10 +83,23 @@ export class BrandService {
}
if (data.code && data.code !== record.code) {
const [existing] = await repository.findAll({ where: { code: data.code } }, context);
const code = data.code.toLowerCase().trim().replace(/[^a-z0-9_]/g, '_');
const tenantId = context.tenantId || record.tenant_id || null;
const existing = await models.Brand.findOne({
where: {
code,
id: { [Op.ne]: id },
[Op.or]: [
{ tenant_id: tenantId },
{ tenant_id: null }
]
}
});
if (existing) {
throw new Error(`Brand with code "${data.code}" already exists`);
throw new ApiError(409, `Brand with code "${code}" already exists in this workspace`);
}
data.code = code;
}
const updatedRecord = await repository.update(id, data, {}, context);
@@ -117,7 +138,7 @@ export class BrandService {
// Check product linkage
const productCount = await models.Product.count({ where: { brand_id: id } });
if (productCount > 0) {
throw new Error('Cannot delete Brand because it is used by one or more products');
throw new ApiError(400, 'Cannot delete Brand because it is used by one or more products');
}
// Hard delete
@@ -150,7 +171,7 @@ export class BrandService {
async archive(id, context = {}) {
const record = await repository.findById(id, {}, context);
if (!record) {
throw new Error('Brand not found');
throw new ApiError(404, 'Brand not found');
}
// Soft delete / Archive
@@ -183,7 +204,7 @@ export class BrandService {
async restore(id, context = {}) {
const record = await repository.restore(id, {}, context);
if (!record) {
throw new Error('Brand not found');
throw new ApiError(404, 'Brand not found');
}
SocketService.broadcast('brand:restored', record);
+4
View File
@@ -14,6 +14,10 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true
},
code: {
type: DataTypes.STRING(50),
allowNull: false,
+6 -17
View File
@@ -1,15 +1,11 @@
import { models } from '../../../shared/database/models.js';
import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class UnitRepository {
async findAll(options = {}, context = {}) {
const queryOptions = {
...options,
where: {
...(options.where || {})
/* FUTURE_TENANT_ISOLATION_FLAG:
tenant_id: context.tenantId
*/
}
where: applyTenantScope(options.where || {}, context)
};
return await models.Unit.findAll(queryOptions);
}
@@ -17,22 +13,15 @@ export class UnitRepository {
async findById(id, options = {}, context = {}) {
const queryOptions = {
...options,
where: {
...(options.where || {})
/* FUTURE_TENANT_ISOLATION_FLAG:
tenant_id: context.tenantId
*/
}
where: applyTenantScope({ id, ...(options.where || {}) }, context)
};
return await models.Unit.findByPk(id, queryOptions);
return await models.Unit.findOne(queryOptions);
}
async create(data, options = {}, context = {}) {
const createData = {
...data
/* FUTURE_TENANT_ISOLATION_FLAG:
tenant_id: context.tenantId
*/
...data,
...(context.tenantId && context.userType !== 'platform' ? { tenant_id: context.tenantId } : {})
};
return await models.Unit.create(createData, options);
}
+43 -11
View File
@@ -2,6 +2,8 @@ import repository from './unit.repository.js';
import { models } from '../../../shared/database/models.js';
import { SocketService } from '../../../shared/services/socket.service.js';
import { AuditService } from '../../../shared/services/audit.service.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
import { Op } from 'sequelize';
export class UnitService {
async getAll(query = {}, context = {}) {
@@ -15,18 +17,35 @@ export class UnitService {
async getById(id, context = {}) {
const record = await repository.findById(id, {}, context);
if (!record) {
throw new Error('Unit not found');
throw new ApiError(404, 'Unit not found');
}
return record;
}
async create(data, context = {}) {
// Check duplicate code
const [existing] = await repository.findAll({ where: { code: data.code } }, context);
const rawCode = data.code || data.name || 'unit';
const code = rawCode.toLowerCase().trim().replace(/[^a-z0-9_]/g, '_');
const tenantId = (context.userType !== 'platform' && context.tenantId) ? context.tenantId : (data.tenant_id || null);
// Reject duplicate codes within tenant or global scope
const existing = await models.Unit.findOne({
where: {
code,
[Op.or]: [
{ tenant_id: tenantId },
{ tenant_id: null }
]
}
});
if (existing) {
throw new Error(`Unit with code "${data.code}" already exists`);
throw new ApiError(409, `Unit with code "${code}" already exists in this workspace`);
}
data.code = code;
data.tenant_id = tenantId;
const record = await repository.create(data, {}, context);
SocketService.broadcast('unit:created', record);
@@ -45,14 +64,27 @@ export class UnitService {
async update(id, data, context = {}) {
const record = await repository.findById(id, {}, context);
if (!record) {
throw new Error('Unit not found');
throw new ApiError(404, 'Unit not found');
}
if (data.code && data.code !== record.code) {
const [existing] = await repository.findAll({ where: { code: data.code } }, context);
const code = data.code.toLowerCase().trim().replace(/[^a-z0-9_]/g, '_');
const tenantId = context.tenantId || record.tenant_id || null;
const existing = await models.Unit.findOne({
where: {
code,
id: { [Op.ne]: id },
[Op.or]: [
{ tenant_id: tenantId },
{ tenant_id: null }
]
}
});
if (existing) {
throw new Error(`Unit with code "${data.code}" already exists`);
throw new ApiError(409, `Unit with code "${code}" already exists in this workspace`);
}
data.code = code;
}
const updatedRecord = await repository.update(id, data, {}, context);
@@ -73,13 +105,13 @@ export class UnitService {
async delete(id, context = {}) {
const record = await repository.findById(id, {}, context);
if (!record) {
throw new Error('Unit not found');
throw new ApiError(404, 'Unit not found');
}
// Check product linkage
const productCount = await models.Product.count({ where: { unit_id: id } });
if (productCount > 0) {
throw new Error('Cannot delete Unit because it is used by one or more products');
throw new ApiError(400, 'Cannot delete Unit because it is used by one or more products');
}
// Hard delete
@@ -100,7 +132,7 @@ export class UnitService {
async archive(id, context = {}) {
const record = await repository.findById(id, {}, context);
if (!record) {
throw new Error('Unit not found');
throw new ApiError(404, 'Unit not found');
}
// Soft delete / Archive
@@ -121,7 +153,7 @@ export class UnitService {
async restore(id, context = {}) {
const record = await repository.restore(id, {}, context);
if (!record) {
throw new Error('Unit not found');
throw new ApiError(404, 'Unit not found');
}
SocketService.broadcast('unit:restored', record);
+1 -2
View File
@@ -2,10 +2,9 @@ import { body, param } from 'express-validator';
export const createValidation = [
body('code')
.optional({ checkFalsy: true })
.isString()
.trim()
.notEmpty()
.withMessage('Code is required')
.matches(/^[a-z0-9_]+$/)
.withMessage('Code must be lowercase alphanumeric and underscores only'),
body('name')
@@ -54,6 +54,10 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true
},
code: {
type: DataTypes.STRING(50),
allowNull: false,
@@ -1,12 +1,11 @@
import { models } from '../../../shared/database/models.js';
import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class CatalogRepository {
async findAll(options = {}, context = {}) {
const queryOptions = {
...options,
where: {
...(options.where || {})
}
where: applyTenantScope(options.where || {}, context)
};
return await models.Catalog.findAll({
include: [
@@ -26,9 +25,17 @@ export class CatalogRepository {
{
model: models.Attribute,
as: 'variantAxes',
attributes: ['id', 'code', 'name'],
attributes: ['id', 'code', 'name', 'type', 'options'],
through: { attributes: [] },
required: false
required: false,
include: [
{
model: models.AttributeOption,
as: 'optionsList',
attributes: ['id', 'code', 'label', 'sort_order'],
required: false
}
]
},
{
model: models.AssetFamily,
@@ -68,14 +75,9 @@ export class CatalogRepository {
async findById(id, options = {}, context = {}) {
const queryOptions = {
...options,
where: {
...(options.where || {})
/* FUTURE_TENANT_ISOLATION_FLAG:
tenant_id: context.tenantId
*/
}
where: applyTenantScope({ id, ...(options.where || {}) }, context)
};
return await models.Catalog.findByPk(id, {
return await models.Catalog.findOne({
include: [
{
model: models.Categorie,
@@ -87,12 +89,20 @@ export class CatalogRepository {
model: models.Attribute,
as: 'attributes',
through: { attributes: ['display_order'] },
required: false
required: false,
include: [
{
model: models.AttributeOption,
as: 'optionsList',
attributes: ['id', 'code', 'label', 'sort_order'],
required: false
}
]
},
{
model: models.Attribute,
as: 'variantAxes',
through: { attributes: [] },
through: { attributes: ['id', 'required', 'display_order', 'active'] },
required: false,
include: [
{
+135 -43
View File
@@ -3,6 +3,7 @@ import { models, sequelize } from '../../../shared/database/models.js';
import { SocketService } from '../../../shared/services/socket.service.js';
import { AuditService } from '../../../shared/services/audit.service.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
import { generateUniqueCode } from '../../../utils/helpers/code.utils.js';
export class CatalogService {
async attachCounts(record, transaction) {
@@ -101,42 +102,30 @@ export class CatalogService {
throw new ApiError(404, 'Product Family not found');
}
await this.attachCounts(record);
if (record.variantAxes && Array.isArray(record.variantAxes)) {
for (const axisAttr of record.variantAxes) {
const throughId = axisAttr.FamilyVariantAxis ? axisAttr.FamilyVariantAxis.id : null;
if (throughId) {
const suggestedVals = await models.FamilyVariantAxisValue.findAll({
where: { axis_id: throughId },
order: [['sort_order', 'ASC']]
});
axisAttr.setDataValue('suggestedValues', suggestedVals.map(v => v.value));
} else {
axisAttr.setDataValue('suggestedValues', []);
}
}
}
return record;
}
async create(data, context = {}) {
const transaction = await sequelize.transaction();
try {
// 1. Autogenerate code if missing or resolve collisions
if (!data.code || !data.code.trim()) {
let baseCode = data.name
? data.name.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '')
: `fam_${Date.now()}`;
if (!baseCode) baseCode = `fam_${Date.now()}`;
let finalCode = baseCode;
let counter = 1;
while (true) {
const checkCode = counter === 1 ? baseCode : `${baseCode}_${counter}`;
const dup = await repository.findByCode(checkCode, { transaction }, context);
if (!dup) {
finalCode = checkCode;
break;
}
counter++;
}
data.code = finalCode;
} else {
data.code = data.code.toLowerCase().trim();
const existing = await repository.findByCode(data.code, { transaction }, context);
if (existing) {
const isDeleted = existing.deleted_at || existing.deletedAt;
throw new ApiError(400, `Product Family with code "${data.code}" already exists${isDeleted ? ' (archived)' : ''}`);
}
}
// Resolve attributes from attribute set if provided
const baseCode = data.code || data.name || 'family';
data.code = await generateUniqueCode(models.Catalog, baseCode, 'code', transaction, { paranoid: false });
const attributeSetId = data.attributeSetId || data.attribute_set_id || null;
if (attributeSetId) {
const attributeSet = await models.AttributeSet.findByPk(attributeSetId, {
@@ -189,13 +178,15 @@ export class CatalogService {
// 4. Variant Strategy Validation & Deduplication
let variantAxes = [];
if (data.variantAxes && Array.isArray(data.variantAxes)) {
const uniqueAxes = [...new Set(data.variantAxes)];
const attributesSet = new Set(attributes);
const invalidAxes = uniqueAxes.filter(axis => !attributesSet.has(axis));
const invalidAxes = data.variantAxes.filter(axis => {
const attrId = typeof axis === 'string' ? axis : (axis.attributeId || axis.attribute_id || axis.id);
return !attrId || !attributesSet.has(attrId);
});
if (invalidAxes.length > 0) {
throw new Error('Variant axes must be a subset of family attributes');
}
variantAxes = uniqueAxes;
variantAxes = data.variantAxes;
}
// 5. Asset Family Validation & Deduplication
@@ -271,13 +262,24 @@ export class CatalogService {
throw new Error(`Total completeness rules weight must equal 100% (currently ${totalWeight}%)`);
}
// Category Resolution
const rawCategoryId = data.category || data.category_id || data.categoryId || null;
let categoryId = null;
if (rawCategoryId) {
const catObj = await models.Categorie.findByPk(rawCategoryId, { transaction });
if (catObj) {
categoryId = catObj.id;
}
}
// 9. Save Catalog
const createData = {
tenant_id: context.tenantId || data.tenant_id || data.tenantId || null,
code: data.code,
name: data.name,
description: data.description,
status: data.status || 'draft',
category_id: null,
category_id: categoryId,
workflow_code: workflowCode,
completeness_rules: completenessRules,
attribute_set_id: attributeSetId
@@ -295,11 +297,32 @@ export class CatalogService {
}
// Save variant axis relationships
for (const attributeId of variantAxes) {
await models.FamilyVariantAxis.create({
for (let i = 0; i < variantAxes.length; i++) {
const item = variantAxes[i];
const attrId = typeof item === 'string' ? item : (item.attributeId || item.attribute_id || item.id);
const required = typeof item === 'object' && item.required !== undefined ? !!item.required : true;
const active = typeof item === 'object' && item.active !== undefined ? !!item.active : true;
const displayOrder = typeof item === 'object' && item.display_order !== undefined ? item.display_order : i;
const axisRecord = await models.FamilyVariantAxis.create({
family_id: record.id,
attribute_id: attributeId
attribute_id: attrId,
required,
display_order: displayOrder,
active
}, { transaction });
const suggestedValues = typeof item === 'object' ? (item.suggestedValues || item.suggested_values || []) : [];
for (let j = 0; j < suggestedValues.length; j++) {
const valItem = suggestedValues[j];
const valText = typeof valItem === 'string' ? valItem : valItem.value;
if (!valText) continue;
await models.FamilyVariantAxisValue.create({
axis_id: axisRecord.id,
value: valText,
sort_order: typeof valItem === 'object' && valItem.sort_order !== undefined ? valItem.sort_order : j
}, { transaction });
}
}
// Save asset requirements
@@ -420,14 +443,16 @@ export class CatalogService {
// 4. Variant Strategy Validation & Deduplication
let variantAxes = null;
if (data.variantAxes && Array.isArray(data.variantAxes)) {
const uniqueAxes = [...new Set(data.variantAxes)];
const targetAttributes = attributes || (await record.getAttributes({ transaction })).map(a => a.id);
const attributesSet = new Set(targetAttributes);
const invalidAxes = uniqueAxes.filter(axis => !attributesSet.has(axis));
const invalidAxes = data.variantAxes.filter(axis => {
const attrId = typeof axis === 'string' ? axis : (axis.attributeId || axis.attribute_id || axis.id);
return !attrId || !attributesSet.has(attrId);
});
if (invalidAxes.length > 0) {
throw new Error('Variant axes must be a subset of family attributes');
}
variantAxes = uniqueAxes;
variantAxes = data.variantAxes;
}
// 5. Asset Family Validation & Deduplication
@@ -509,6 +534,22 @@ export class CatalogService {
throw new Error(`Total completeness rules weight must equal 100% (currently ${totalWeight}%)`);
}
// Category Resolution for Update
const rawCategoryId = data.hasOwnProperty('category') ? data.category : (data.hasOwnProperty('category_id') ? data.category_id : (data.hasOwnProperty('categoryId') ? data.categoryId : undefined));
let categoryId = undefined;
if (rawCategoryId !== undefined) {
if (rawCategoryId) {
const catObj = await models.Categorie.findByPk(rawCategoryId, { transaction });
if (catObj) {
categoryId = catObj.id;
} else {
categoryId = null;
}
} else {
categoryId = null;
}
}
// 9. Update Catalog
const updateData = {
name: data.name || record.name,
@@ -518,6 +559,9 @@ export class CatalogService {
completeness_rules: completenessRules,
attribute_set_id: attributeSetId
};
if (categoryId !== undefined) {
updateData.category_id = categoryId;
}
await record.update(updateData, { transaction });
@@ -535,12 +579,40 @@ export class CatalogService {
}
if (variantAxes !== null) {
// Find existing axis records to clean up suggested values
const existingAxes = await models.FamilyVariantAxis.findAll({ where: { family_id: id }, transaction });
const existingAxisIds = existingAxes.map(a => a.id);
if (existingAxisIds.length > 0) {
await models.FamilyVariantAxisValue.destroy({ where: { axis_id: existingAxisIds }, transaction });
}
await models.FamilyVariantAxis.destroy({ where: { family_id: id }, transaction });
for (const attributeId of variantAxes) {
await models.FamilyVariantAxis.create({
for (let i = 0; i < variantAxes.length; i++) {
const item = variantAxes[i];
const attrId = typeof item === 'string' ? item : (item.attributeId || item.attribute_id || item.id);
const required = typeof item === 'object' && item.required !== undefined ? !!item.required : true;
const active = typeof item === 'object' && item.active !== undefined ? !!item.active : true;
const displayOrder = typeof item === 'object' && item.display_order !== undefined ? item.display_order : i;
const axisRecord = await models.FamilyVariantAxis.create({
family_id: id,
attribute_id: attributeId
attribute_id: attrId,
required,
display_order: displayOrder,
active
}, { transaction });
const suggestedValues = typeof item === 'object' ? (item.suggestedValues || item.suggested_values || []) : [];
for (let j = 0; j < suggestedValues.length; j++) {
const valItem = suggestedValues[j];
const valText = typeof valItem === 'string' ? valItem : valItem.value;
if (!valText) continue;
await models.FamilyVariantAxisValue.create({
axis_id: axisRecord.id,
value: valText,
sort_order: typeof valItem === 'object' && valItem.sort_order !== undefined ? valItem.sort_order : j
}, { transaction });
}
}
await AuditService.log({ action: 'VARIANT_AXES_CHANGED', resource: 'Catalog', resourceId: id, userId: context.userId || 'system', details: variantAxes });
}
@@ -712,6 +784,26 @@ export class CatalogService {
}];
}
// Hydrate optionsList for all attributes if missing
for (const group of groups) {
if (Array.isArray(group.attributes)) {
for (let i = 0; i < group.attributes.length; i++) {
const attr = group.attributes[i];
const attrJson = attr.toJSON ? attr.toJSON() : attr;
const needsOptions = ['select', 'multiselect', 'enumeration', 'swatch', 'color'].includes(attrJson.type);
if (needsOptions && (!attrJson.optionsList || attrJson.optionsList.length === 0)) {
const options = await models.AttributeOption.findAll({
where: { attribute_id: attrJson.id },
order: [['sort_order', 'ASC']],
raw: true
}).catch(() => []);
attrJson.optionsList = options;
group.attributes[i] = attrJson;
}
}
}
}
let workflow = null;
const wfCode = family.workflow_code || 'standard';
if (models.WorkflowRegistry) {
@@ -39,7 +39,16 @@ export const createValidation = [
.isArray()
.withMessage('Allowed units must be an array')
.custom((value) => value.every(val => typeof val === 'string'))
.withMessage('Allowed units must be an array of unit IDs')
.withMessage('Allowed units must be an array of unit IDs'),
body('category')
.optional({ nullable: true })
.isString(),
body('category_id')
.optional({ nullable: true })
.isString(),
body('categoryId')
.optional({ nullable: true })
.isString()
];
export const updateValidation = [
@@ -85,7 +94,16 @@ export const updateValidation = [
.isArray()
.withMessage('Allowed units must be an array')
.custom((value) => value.every(val => typeof val === 'string'))
.withMessage('Allowed units must be an array of unit IDs')
.withMessage('Allowed units must be an array of unit IDs'),
body('category')
.optional({ nullable: true })
.isString(),
body('category_id')
.optional({ nullable: true })
.isString(),
body('categoryId')
.optional({ nullable: true })
.isString()
];
export const deleteValidation = [
@@ -1,20 +1,52 @@
import { Model, DataTypes } from 'sequelize';
export class FamilyVariantAxis extends Model {
static associate(models) {}
static associate(models) {
FamilyVariantAxis.belongsTo(models.Catalog, {
foreignKey: 'family_id',
as: 'family'
});
FamilyVariantAxis.belongsTo(models.Attribute, {
foreignKey: 'attribute_id',
as: 'attribute'
});
FamilyVariantAxis.hasMany(models.FamilyVariantAxisValue, {
foreignKey: 'axis_id',
as: 'suggestedValues'
});
}
}
export default (sequelize) => {
FamilyVariantAxis.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false
},
family_id: {
type: DataTypes.UUID,
allowNull: false,
primaryKey: true
allowNull: false
},
attribute_id: {
type: DataTypes.UUID,
allowNull: false
},
required: {
type: DataTypes.BOOLEAN,
allowNull: false,
primaryKey: true
defaultValue: true
},
display_order: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
},
active: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true
}
}, {
sequelize,
@@ -0,0 +1,42 @@
import { Model, DataTypes } from 'sequelize';
export class FamilyVariantAxisValue extends Model {
static associate(models) {
FamilyVariantAxisValue.belongsTo(models.FamilyVariantAxis, {
foreignKey: 'axis_id',
as: 'axis'
});
}
}
export default (sequelize) => {
FamilyVariantAxisValue.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false
},
axis_id: {
type: DataTypes.UUID,
allowNull: false
},
value: {
type: DataTypes.TEXT,
allowNull: false
},
sort_order: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
}
}, {
sequelize,
modelName: 'FamilyVariantAxisValue',
tableName: 'family_variant_axis_values',
timestamps: true,
underscored: true
});
return FamilyVariantAxisValue;
};
@@ -28,6 +28,10 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true
},
parent_id: {
type: DataTypes.UUID,
allowNull: true
@@ -1,15 +1,11 @@
import { models } from '../../../shared/database/models.js';
import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class CategorieRepository {
async findAll(options = {}, context = {}) {
const queryOptions = {
...options,
where: {
...(options.where || {})
/* FUTURE_TENANT_ISOLATION_FLAG:
tenant_id: context.tenantId
*/
}
where: applyTenantScope(options.where || {}, context)
};
return await models.Categorie.findAll({
include: [
@@ -30,14 +26,9 @@ export class CategorieRepository {
async findById(id, options = {}, context = {}) {
const queryOptions = {
...options,
where: {
...(options.where || {})
/* FUTURE_TENANT_ISOLATION_FLAG:
tenant_id: context.tenantId
*/
}
where: applyTenantScope({ id, ...(options.where || {}) }, context)
};
return await models.Categorie.findByPk(id, {
return await models.Categorie.findOne({
include: [
{
model: models.Categorie,
@@ -2,7 +2,6 @@ import repository from './categorie.repository.js';
import { models, sequelize } from '../../../shared/database/models.js';
import { SocketService } from '../../../shared/services/socket.service.js';
import { AuditService } from '../../../shared/services/audit.service.js';
import NotificationService from '../../notifications/notifications/notification.service.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
import { Op } from 'sequelize';
@@ -18,7 +17,7 @@ export class CategorieService {
async getById(id, context = {}) {
const record = await repository.findById(id, {}, context);
if (!record) {
throw new ApiApiError(404, 404, 'Category not found');
throw new ApiError(404, 'Category not found');
}
return record;
}
@@ -26,24 +25,29 @@ export class CategorieService {
async create(data, context = {}) {
const transaction = await sequelize.transaction();
try {
if (!data.code || !data.code.trim()) {
if (data.name) {
data.code = data.name.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
}
if (!data.code) {
data.code = `cat_${Date.now()}`;
}
}
data.code = data.code.toLowerCase().trim();
const rawCode = data.code || data.name || 'category';
const code = rawCode.toLowerCase().trim().replace(/[^a-z0-9_]/g, '_');
const tenantId = (context.userType !== 'platform' && context.tenantId) ? context.tenantId : (data.tenant_id || null);
const existing = await models.Categorie.findOne({
where: {
code,
[Op.or]: [
{ tenant_id: tenantId },
{ tenant_id: null }
]
},
transaction
});
// Check duplicate code
const existing = await repository.findByCode(data.code, { transaction }, context);
if (existing) {
throw new ApiError(400, `Category with code "${data.code}" already exists`);
throw new ApiError(409, `Category with code "${code}" already exists in this workspace`);
}
data.code = code;
let level = 0;
let path = `/${data.code}`;
let path = `/${code}`;
if (data.parentId) {
const parent = await repository.findById(data.parentId, { transaction }, context);
@@ -51,10 +55,11 @@ export class CategorieService {
throw new ApiError(404, 'Parent category not found');
}
level = parent.level + 1;
path = `${parent.path}/${data.code}`;
path = `${parent.path}/${code}`;
}
const createData = {
tenant_id: tenantId,
code: data.code,
name: data.name,
description: data.description,
@@ -65,13 +70,13 @@ export class CategorieService {
};
const record = await repository.create(createData, { transaction }, context);
await transaction.commit();
const fullRecord = await repository.findById(record.id, {}, context);
SocketService.broadcast('categorie:created', fullRecord);
await AuditService.log({
action: 'CREATE',
resource: 'Categorie',
@@ -82,7 +87,9 @@ export class CategorieService {
return fullRecord;
} catch (error) {
await transaction.rollback();
if (transaction && !transaction.finished) {
await transaction.rollback();
}
throw error;
}
}
@@ -96,94 +103,68 @@ export class CategorieService {
}
if (data.code && data.code !== record.code) {
const existing = await repository.findByCode(data.code, { transaction }, context);
if (existing) {
throw new ApiError(400, `Category with code "${data.code}" already exists`);
}
}
const oldPath = record.path;
let newParentId = record.parent_id;
let pathChanged = false;
// Handle Parent category move
if (data.hasOwnProperty('parentId') && data.parentId !== record.parent_id) {
newParentId = data.parentId || null;
pathChanged = true;
if (newParentId) {
// Circular dependency validation: parent cannot be the node itself or any of its descendants
if (newParentId === id) {
throw new ApiError(400, 'Circular reference: Category cannot be its own parent');
}
const targetParent = await repository.findById(newParentId, { transaction }, context);
if (!targetParent) {
throw new ApiError(404, 'Target parent category not found');
}
// Check if parent category is a child of the current category (starts with oldPath + '/')
if (targetParent.path.startsWith(oldPath + '/')) {
throw new ApiError(400, 'Circular reference: Cannot set parent category to a child of this category');
}
}
}
// If code changed, the path changes as well
const categoryCode = data.code || record.code;
if (data.code && data.code !== record.code) {
pathChanged = true;
}
// Apply primary updates
const updateData = {
name: data.name || record.name,
code: categoryCode,
description: data.hasOwnProperty('description') ? data.description : record.description,
status: data.status || record.status,
parent_id: newParentId
};
if (pathChanged) {
let level = 0;
let path = `/${categoryCode}`;
if (newParentId) {
const parent = await repository.findById(newParentId, { transaction }, context);
level = parent.level + 1;
path = `${parent.path}/${categoryCode}`;
}
updateData.level = level;
updateData.path = path;
}
await repository.update(id, updateData, { transaction }, context);
// Cascade update children paths & levels recursively if path changed
if (pathChanged) {
const descendants = await repository.findAll({
const code = data.code.toLowerCase().trim().replace(/[^a-z0-9_]/g, '_');
const tenantId = context.tenantId || record.tenant_id || null;
const existing = await models.Categorie.findOne({
where: {
path: {
[Op.like]: `${oldPath}/%`
}
code,
id: { [Op.ne]: id },
[Op.or]: [
{ tenant_id: tenantId },
{ tenant_id: null }
]
},
transaction
}, context);
});
for (const desc of descendants) {
// Replace prefix old path with new path
const newDescPath = desc.path.replace(oldPath, updateData.path);
// Level is based on number of slashes in the path
const newDescLevel = newDescPath.split('/').length - 2;
if (existing) {
throw new ApiError(409, `Category with code "${code}" already exists in this workspace`);
}
data.code = code;
}
await repository.update(desc.id, {
path: newDescPath,
level: newDescLevel
}, { transaction }, context);
let updateData = { ...data };
if (data.parentId !== undefined && data.parentId !== record.parent_id) {
if (data.parentId === id) {
throw new ApiError(400, 'Category cannot be its own parent');
}
let newLevel = 0;
let newPath = `/${record.code}`;
if (data.parentId) {
const newParent = await repository.findById(data.parentId, { transaction }, context);
if (!newParent) {
throw new ApiError(404, 'New parent category not found');
}
if (newParent.path.startsWith(record.path + '/')) {
throw new ApiError(400, 'Cannot move a category into one of its subcategories');
}
newLevel = newParent.level + 1;
newPath = `${newParent.path}/${record.code}`;
}
updateData.parent_id = data.parentId || null;
updateData.level = newLevel;
updateData.path = newPath;
const subcategories = await models.Categorie.findAll({
where: { path: { [Op.like]: `${record.path}/%` } },
transaction
});
for (const sub of subcategories) {
const subSuffix = sub.path.substring(record.path.length);
const updatedSubPath = `${newPath}${subSuffix}`;
const updatedSubLevel = sub.level + (newLevel - record.level);
await sub.update({ path: updatedSubPath, level: updatedSubLevel }, { transaction });
}
}
const updatedRecord = await repository.update(id, updateData, { transaction }, context);
await transaction.commit();
const fullRecord = await repository.findById(id, {}, context);
@@ -198,158 +179,49 @@ export class CategorieService {
details: data
});
// Notify tenant users
if (context.tenantId) {
NotificationService.notifyTenant(context.tenantId, context.userId, {
variant: 'category',
action: 'updated',
title: 'Category updated',
description: `Category "${record.name || 'A category'}" tree details were updated.`,
entity: record.name || 'Category',
entity_id: id.toString()
}).catch(err => console.error('Notification failed:', err));
}
return fullRecord;
} catch (error) {
await transaction.rollback();
if (transaction && !transaction.finished) {
await transaction.rollback();
}
throw error;
}
}
async delete(id, context = {}) {
const transaction = await sequelize.transaction();
try {
const record = await repository.findById(id, { transaction }, context);
if (!record) {
throw new ApiError(404, 'Category not found');
}
// Check subcategories
const subcategoriesCount = await models.Categorie.count({
where: { parent_id: id },
transaction
});
if (subcategoriesCount > 0) {
throw new ApiError(400, 'Cannot delete category because it contains subcategories');
}
// Check Product Families association
const familiesCount = await models.Catalog.count({
where: { category_id: id },
transaction
});
if (familiesCount > 0) {
throw new ApiError(400, 'Cannot delete category because it is used by one or more Product Families');
}
// Check Product association
const productCount = await models.Product.count({
where: { category_id: id },
transaction
});
if (productCount > 0) {
throw new ApiError(400, 'Cannot delete category because it is used by one or more products');
}
await repository.delete(id, { transaction }, context);
await transaction.commit();
SocketService.broadcast('categorie:deleted', { id });
await AuditService.log({
action: 'DELETE',
resource: 'Categorie',
resourceId: id,
userId: context.userId || 'system'
});
// Notify tenant users
if (context.tenantId) {
NotificationService.notifyTenant(context.tenantId, context.userId, {
variant: 'category',
action: 'deleted',
title: 'Category deleted',
description: 'A category was permanently removed from taxonomy.',
entity: 'Category',
entity_id: id.toString()
}).catch(err => console.error('Notification failed:', err));
}
return true;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async archive(id, context = {}) {
const transaction = await sequelize.transaction();
try {
const record = await repository.findById(id, { transaction }, context);
if (!record) {
throw new ApiError(404, 'Category not found');
}
// Soft delete/Archive
await repository.archive(id, { transaction }, context);
await transaction.commit();
SocketService.broadcast('categorie:archived', { id });
await AuditService.log({
action: 'ARCHIVE',
resource: 'Categorie',
resourceId: id,
userId: context.userId || 'system'
});
// Notify tenant users
if (context.tenantId) {
NotificationService.notifyTenant(context.tenantId, context.userId, {
variant: 'category',
action: 'deleted',
title: 'Category deleted',
description: 'A category was permanently removed from taxonomy.',
entity: 'Category',
entity_id: id.toString()
}).catch(err => console.error('Notification failed:', err));
const record = await repository.findById(id, {}, context);
if (!record) {
throw new ApiError(404, 'Category not found');
}
return true;
} catch (error) {
await transaction.rollback();
throw error;
const subcategoriesCount = await models.Categorie.count({
where: { parent_id: id }
});
if (subcategoriesCount > 0) {
throw new ApiError(400, 'Cannot delete category with subcategories. Move or delete subcategories first.');
}
}
async restore(id, context = {}) {
const transaction = await sequelize.transaction();
try {
const record = await repository.restore(id, { transaction }, context);
if (!record) {
throw new ApiError(404, 'Category not found');
}
const productsCount = await models.Product.count({
where: { category_id: id }
});
await transaction.commit();
const restored = await repository.findById(id, {}, context);
SocketService.broadcast('categorie:restored', restored);
await AuditService.log({
action: 'RESTORE',
resource: 'Categorie',
resourceId: id,
userId: context.userId || 'system'
});
return restored;
} catch (error) {
await transaction.rollback();
throw error;
if (productsCount > 0) {
throw new ApiError(400, 'Cannot delete category with associated products');
}
await repository.delete(id, {}, context);
SocketService.broadcast('categorie:deleted', { id });
await AuditService.log({
action: 'DELETE',
resource: 'Categorie',
resourceId: id,
userId: context.userId || 'system'
});
return true;
}
}
@@ -17,6 +17,10 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true
},
code: {
type: DataTypes.STRING(50),
allowNull: false,
@@ -1,26 +1,30 @@
import { models } from '../../../shared/database/models.js';
import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class ChannelTypeRepository {
async findAll(options = {}) {
return await models.ChannelType.findAll(options);
async findAll(options = {}, context = {}) {
const where = applyTenantScope(options.where || {}, context);
return await models.ChannelType.findAll({ ...options, where });
}
async findById(id, options = {}) {
return await models.ChannelType.findByPk(id, options);
async findById(id, options = {}, context = {}) {
const where = applyTenantScope({ id }, context);
return await models.ChannelType.findOne({ ...options, where });
}
async create(data, options = {}) {
return await models.ChannelType.create(data, options);
async create(data, options = {}, context = {}) {
const tenantId = context.tenant_id || context.tenantId || null;
return await models.ChannelType.create({ ...data, tenant_id: tenantId }, options);
}
async update(id, data, options = {}) {
const record = await this.findById(id, options);
async update(id, data, options = {}, context = {}) {
const record = await this.findById(id, options, context);
if (!record) return null;
return await record.update(data, options);
}
async delete(id, options = {}) {
const record = await this.findById(id, options);
async delete(id, options = {}, context = {}) {
const record = await this.findById(id, options, context);
if (!record) return false;
await record.destroy(options);
return true;
@@ -2,40 +2,29 @@ import repository from './channelType.repository.js';
import { models } from '../../../shared/database/models.js';
import { SocketService } from '../../../shared/services/socket.service.js';
import { AuditService } from '../../../shared/services/audit.service.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
import { generateUniqueCode } from '../../../utils/helpers/code.utils.js';
export class ChannelTypeService {
async getAll(query = {}) {
async getAll(query = {}, context = {}) {
const where = {};
if (query.status) {
where.status = query.status;
}
return await repository.findAll({ where });
return await repository.findAll({ where }, context);
}
async getById(id) {
const record = await repository.findById(id);
async getById(id, context = {}) {
const record = await repository.findById(id, {}, context);
if (!record) {
throw new Error('Channel Type not found');
throw new ApiError(404, 'Channel Type not found');
}
return record;
}
async create(data, userContext = {}) {
if (!data.code || !data.code.trim()) {
if (data.name) {
data.code = data.name.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
}
if (!data.code) {
data.code = `cht_${Date.now()}`;
}
}
data.code = data.code.toLowerCase().trim();
// Check duplicate code
const existing = await models.ChannelType.findOne({ where: { code: data.code } });
if (existing) {
throw new Error(`Channel Type with code "${data.code}" already exists`);
}
const baseCode = data.code || data.name || 'channel_type';
data.code = await generateUniqueCode(models.ChannelType, baseCode, 'code');
const record = await repository.create(data);
@@ -1,4 +1,6 @@
import service from './channel.service.js';
import channelMappingService from '../mappings/channelMapping.service.js';
import syndicationService from '../syndication/syndication.service.js';
export class ChannelController {
async getAll(req, res, next) {
@@ -63,6 +65,80 @@ export class ChannelController {
next(error);
}
}
// Channel Field Mappings
async getMappings(req, res, next) {
try {
const data = await channelMappingService.getByChannel(req.params.id, req.user);
return res.status(200).json({ success: true, data });
} catch (error) {
next(error);
}
}
async upsertMappings(req, res, next) {
try {
const data = await channelMappingService.upsertMappings(req.params.id, req.body.mappings || [], req.user);
return res.status(200).json({ success: true, data, message: 'Mapping rules updated successfully' });
} catch (error) {
next(error);
}
}
// Syndication Engine
async triggerSyndication(req, res, next) {
try {
const data = await syndicationService.triggerSyndication(req.params.id, req.user);
return res.status(200).json({ success: true, data, message: 'Syndication job triggered successfully' });
} catch (error) {
next(error);
}
}
async getJobs(req, res, next) {
try {
const data = await syndicationService.getJobsByChannel(req.params.id);
return res.status(200).json({ success: true, data });
} catch (error) {
next(error);
}
}
async getJobById(req, res, next) {
try {
const data = await syndicationService.getJobById(req.params.jobId);
return res.status(200).json({ success: true, data });
} catch (error) {
next(error);
}
}
async previewPayload(req, res, next) {
try {
const data = await syndicationService.previewPayload(req.params.id, req.query.productId, req.user);
return res.status(200).json({ success: true, data });
} catch (error) {
next(error);
}
}
async syndicateAll(req, res, next) {
try {
const data = await syndicationService.syndicateAllChannels(req.user);
return res.status(200).json({ success: true, data, message: 'Bulk channel syndication executed successfully' });
} catch (error) {
next(error);
}
}
async testConnection(req, res, next) {
try {
const data = await syndicationService.testChannelConnection(req.params.id);
return res.status(200).json({ success: true, data });
} catch (error) {
next(error);
}
}
}
export default new ChannelController();
@@ -17,6 +17,10 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true
},
type_id: {
type: DataTypes.UUID,
allowNull: true
@@ -1,26 +1,30 @@
import { models } from '../../../shared/database/models.js';
import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class ChannelRepository {
async findAll(options = {}) {
return await models.Channel.findAll(options);
async findAll(options = {}, context = {}) {
const where = applyTenantScope(options.where || {}, context);
return await models.Channel.findAll({ ...options, where });
}
async findById(id, options = {}) {
return await models.Channel.findByPk(id, options);
async findById(id, options = {}, context = {}) {
const where = applyTenantScope({ id }, context);
return await models.Channel.findOne({ ...options, where });
}
async create(data, options = {}) {
return await models.Channel.create(data, options);
async create(data, options = {}, context = {}) {
const tenantId = context.tenant_id || context.tenantId || null;
return await models.Channel.create({ ...data, tenant_id: tenantId }, options);
}
async update(id, data, options = {}) {
const record = await this.findById(id, options);
async update(id, data, options = {}, context = {}) {
const record = await this.findById(id, options, context);
if (!record) return null;
return await record.update(data, options);
}
async delete(id, options = {}) {
const record = await this.findById(id, options);
async delete(id, options = {}, context = {}) {
const record = await this.findById(id, options, context);
if (!record) return false;
await record.destroy(options);
return true;
@@ -13,58 +13,30 @@ import {
const router = Router();
/**
* @swagger
* /api/v1/channels:
* get:
* summary: Retrieve all channels
* tags: [Channels]
* responses:
* 200:
* description: Success
*/
// 1. Static Bulk Routes (MUST BE FIRST)
router.post(
'/syndicate-all',
authenticate,
authorize(['settings.integrations']),
audit('TRIGGER_BULK_SYNDICATION'),
controller.syndicateAll
);
router.get(
'/jobs/:jobId',
authenticate,
authorize(['settings.integrations']),
controller.getJobById
);
// 2. Base Collection Routes
router.get(
'/',
authenticate,
authorize(['settings.integrations']),
authorize(['settings.integrations', 'products.items']),
controller.getAll
);
/**
* @swagger
* /api/v1/channels/{id}:
* get:
* summary: Retrieve a single channel
* tags: [Channels]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Success
*/
router.get(
'/:id',
authenticate,
authorize(['settings.integrations']),
getByIdValidation,
validate,
controller.getById
);
/**
* @swagger
* /api/v1/channels:
* post:
* summary: Create a channel
* tags: [Channels]
* responses:
* 201:
* description: Success
*/
router.post(
'/',
authenticate,
@@ -75,52 +47,49 @@ router.post(
controller.create
);
/**
* @swagger
* /api/v1/channels/{id}:
* put:
* summary: Update a channel
* tags: [Channels]
* parameters:
* - in: path
* name: id
* required: true
* responses:
* 200:
* description: Success
*/
router.put(
'/:id',
// 3. Sub-resource Routes for Specific Channel
router.get(
'/:id/mappings',
authenticate,
authorize(['settings.integrations']),
updateValidation,
validate,
audit('UPDATE_CHANNEL'),
controller.update
controller.getMappings
);
/**
* @swagger
* /api/v1/channels/{id}:
* delete:
* summary: Delete a channel
* tags: [Channels]
* parameters:
* - in: path
* name: id
* required: true
* responses:
* 200:
* description: Success
*/
router.delete(
'/:id',
router.put(
'/:id/mappings',
authenticate,
authorize(['settings.integrations']),
deleteValidation,
validate,
audit('DELETE_CHANNEL'),
controller.delete
audit('UPDATE_CHANNEL_MAPPINGS'),
controller.upsertMappings
);
router.post(
'/:id/syndicate',
authenticate,
authorize(['settings.integrations']),
audit('TRIGGER_CHANNEL_SYNDICATION'),
controller.triggerSyndication
);
router.get(
'/:id/jobs',
authenticate,
authorize(['settings.integrations']),
controller.getJobs
);
router.post(
'/:id/preview',
authenticate,
authorize(['settings.integrations']),
controller.previewPayload
);
router.post(
'/:id/test-connection',
authenticate,
authorize(['settings.integrations']),
controller.testConnection
);
router.post(
@@ -143,4 +112,34 @@ router.post(
controller.restore
);
// 4. Generic Parameterized Channel ID Routes (MUST BE LAST)
router.get(
'/:id',
authenticate,
authorize(['settings.integrations']),
getByIdValidation,
validate,
controller.getById
);
router.put(
'/:id',
authenticate,
authorize(['settings.integrations']),
updateValidation,
validate,
audit('UPDATE_CHANNEL'),
controller.update
);
router.delete(
'/:id',
authenticate,
authorize(['settings.integrations']),
deleteValidation,
validate,
audit('DELETE_CHANNEL'),
controller.delete
);
export default router;
@@ -3,6 +3,7 @@ import { models } from '../../../shared/database/models.js';
import { SocketService } from '../../../shared/services/socket.service.js';
import { AuditService } from '../../../shared/services/audit.service.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
import { generateUniqueCode } from '../../../utils/helpers/code.utils.js';
export class ChannelService {
async getAll(query = {}) {
@@ -19,6 +20,9 @@ export class ChannelService {
}
async create(data, userContext = {}) {
const baseCode = data.code || data.name || 'channel';
data.code = await generateUniqueCode(models.Channel, baseCode, 'code');
const record = await repository.create(data);
// Broadcast event
@@ -0,0 +1,63 @@
import { DataTypes } from 'sequelize';
export default (sequelize) => {
const ChannelMapping = sequelize.define('ChannelMapping', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true,
references: {
model: 'tenants',
key: 'id'
}
},
channel_id: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'channels',
key: 'id'
},
onDelete: 'CASCADE'
},
pim_attribute_code: {
type: DataTypes.STRING(100),
allowNull: false,
},
channel_field_code: {
type: DataTypes.STRING(100),
allowNull: false,
},
transformation_rule: {
type: DataTypes.STRING(50),
defaultValue: 'none',
allowNull: false,
comment: 'none, uppercase, lowercase, currency_format, strip_html, default_if_null'
},
default_value: {
type: DataTypes.TEXT,
allowNull: true,
},
is_required: {
type: DataTypes.BOOLEAN,
defaultValue: false,
}
}, {
tableName: 'channel_mappings',
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
});
ChannelMapping.associate = (models) => {
if (models.Channel) {
ChannelMapping.belongsTo(models.Channel, { foreignKey: 'channel_id', as: 'channel' });
}
};
return ChannelMapping;
};
@@ -0,0 +1,51 @@
import { models } from '../../../shared/database/models.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
import { AuditService } from '../../../shared/services/audit.service.js';
export class ChannelMappingService {
async getByChannel(channelId, userContext = {}) {
const channel = await models.Channel.findByPk(channelId);
if (!channel) {
throw new ApiError(404, 'Channel not found');
}
return await models.ChannelMapping.findAll({
where: { channel_id: channelId },
order: [['created_at', 'ASC']]
});
}
async upsertMappings(channelId, mappingsArray, userContext = {}) {
const channel = await models.Channel.findByPk(channelId);
if (!channel) {
throw new ApiError(404, 'Channel not found');
}
// Delete existing mappings for this channel and bulk insert new rules
await models.ChannelMapping.destroy({ where: { channel_id: channelId } });
const records = mappingsArray.map(item => ({
tenant_id: userContext.tenantId || channel.tenant_id || null,
channel_id: channelId,
pim_attribute_code: item.pim_attribute_code,
channel_field_code: item.channel_field_code,
transformation_rule: item.transformation_rule || 'none',
default_value: item.default_value || null,
is_required: Boolean(item.is_required)
}));
const created = await models.ChannelMapping.bulkCreate(records);
await AuditService.log({
action: 'UPDATE_MAPPINGS',
resource: 'ChannelMapping',
resourceId: channelId,
userId: userContext.userId || 'system',
details: { count: created.length }
});
return created;
}
}
export default new ChannelMappingService();
@@ -0,0 +1,58 @@
export class ChannelAdapterService {
/**
* Format transformed payload into a Shopify GraphQL productCreate mutation
*/
formatShopifyGraphQL(transformedPayload) {
return {
query: `
mutation productCreate($input: ProductInput!) {
productCreate(input: $input) {
product {
id
title
handle
status
}
userErrors {
field
message
}
}
}
`,
variables: {
input: {
title: transformedPayload.title || transformedPayload.name || 'Untitled Product',
bodyHtml: transformedPayload.body_html || transformedPayload.description || '',
vendor: transformedPayload.vendor || transformedPayload.brand || 'Generic',
productType: transformedPayload.product_type || 'General',
status: transformedPayload.published_status === 'published' ? 'ACTIVE' : 'DRAFT',
variants: [
{
sku: transformedPayload.variant_sku || transformedPayload.code || 'SKU-DEFAULT',
price: String(transformedPayload.price || '0.00'),
}
]
}
}
};
}
/**
* Format payload for custom Webhook HTTP POST dispatches
*/
formatWebhookPayload(channel, transformedPayload) {
return {
event: 'product.syndicated',
channel: {
id: channel.id,
name: channel.name,
code: channel.code
},
timestamp: new Date().toISOString(),
data: transformedPayload
};
}
}
export default new ChannelAdapterService();
@@ -0,0 +1,226 @@
import { models } from '../../../shared/database/models.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
import { AuditService } from '../../../shared/services/audit.service.js';
import channelAdapterService from './channelAdapter.service.js';
export class SyndicationService {
applyTransformation(val, rule, defaultValue) {
if (val === null || val === undefined || val === '') {
return defaultValue !== undefined && defaultValue !== null ? defaultValue : '';
}
const str = String(val);
switch (rule) {
case 'uppercase':
return str.toUpperCase();
case 'lowercase':
return str.toLowerCase();
case 'currency_format':
const num = parseFloat(str) || 0;
return num.toFixed(2);
case 'strip_html':
return str.replace(/<[^>]*>?/gm, '');
case 'default_if_null':
return str || defaultValue || '';
case 'none':
default:
return str;
}
}
async previewPayload(channelId, productId, userContext = {}) {
const channel = await models.Channel.findByPk(channelId);
if (!channel) {
throw new ApiError(404, 'Channel not found');
}
const mappings = await models.ChannelMapping.findAll({
where: { channel_id: channelId }
});
const tenantId = userContext.tenantId || channel.tenant_id || null;
const where = {};
if (tenantId) where.tenant_id = tenantId;
let product = null;
if (productId) {
product = await models.Product.findOne({ where: { id: productId, ...where } });
} else {
product = await models.Product.findOne({ where });
}
if (!product) {
throw new ApiError(404, 'No product available for payload transformation preview');
}
const transformed = {};
for (const mapItem of mappings) {
const rawVal = product[mapItem.pim_attribute_code];
transformed[mapItem.channel_field_code] = this.applyTransformation(
rawVal,
mapItem.transformation_rule,
mapItem.default_value
);
}
const formattedAdapterPayload = (channel.code === 'shopify' || channel.channelType === 'ecommerce')
? channelAdapterService.formatShopifyGraphQL(transformed)
: channelAdapterService.formatWebhookPayload(channel, transformed);
return {
channel: { id: channel.id, name: channel.name, code: channel.code },
pimProductRaw: product,
transformedFields: transformed,
adapterOutput: formattedAdapterPayload
};
}
async triggerSyndication(channelId, userContext = {}) {
const channel = await models.Channel.findByPk(channelId);
if (!channel) {
throw new ApiError(404, 'Channel not found');
}
const mappings = await models.ChannelMapping.findAll({
where: { channel_id: channelId }
});
const tenantId = userContext.tenantId || channel.tenant_id || null;
// Fetch tenant products
const where = {};
if (tenantId) where.tenant_id = tenantId;
const products = await models.Product.findAll({
where,
limit: 100
});
const job = await models.SyndicationJob.create({
tenant_id: tenantId,
channel_id: channelId,
status: 'running',
triggered_by: userContext.userId || null,
total_products: products.length,
success_count: 0,
failed_count: 0,
error_log: [],
started_at: new Date()
});
let successCount = 0;
let failedCount = 0;
const errorLogs = [];
for (const prod of products) {
try {
const transformedPayload = {};
let hasError = false;
for (const mapItem of mappings) {
const rawVal = prod[mapItem.pim_attribute_code];
if (mapItem.is_required && (rawVal === null || rawVal === undefined || rawVal === '')) {
errorLogs.push({
productId: prod.id,
sku: prod.code || prod.sku,
error: `Required attribute "${mapItem.pim_attribute_code}" is missing or null`
});
hasError = true;
break;
}
transformedPayload[mapItem.channel_field_code] = this.applyTransformation(
rawVal,
mapItem.transformation_rule,
mapItem.default_value
);
}
if (hasError) {
failedCount++;
} else {
successCount++;
}
} catch (err) {
failedCount++;
errorLogs.push({
productId: prod.id,
sku: prod.code || prod.sku,
error: err.message
});
}
}
const finalStatus = failedCount > 0 ? (successCount > 0 ? 'completed' : 'failed') : 'completed';
await job.update({
status: finalStatus,
success_count: successCount,
failed_count: failedCount,
error_log: errorLogs,
completed_at: new Date()
});
await AuditService.log({
action: 'SYNDICATE_CHANNEL',
resource: 'Channel',
resourceId: channelId,
userId: userContext.userId || 'system',
details: { jobId: job.id, status: finalStatus, total: products.length, success: successCount, failed: failedCount }
});
return job;
}
async getJobsByChannel(channelId) {
return await models.SyndicationJob.findAll({
where: { channel_id: channelId },
order: [['created_at', 'DESC']],
limit: 50
});
}
async getJobById(jobId) {
const job = await models.SyndicationJob.findByPk(jobId);
if (!job) {
throw new ApiError(404, 'Syndication job not found');
}
return job;
}
async syndicateAllChannels(userContext = {}) {
const tenantId = userContext.tenantId || null;
const where = { status: 'active' };
if (tenantId) where.tenant_id = tenantId;
const channels = await models.Channel.findAll({ where });
const results = [];
for (const ch of channels) {
try {
const job = await this.triggerSyndication(ch.id, userContext);
results.push({ channelId: ch.id, channelName: ch.name, status: job.status, successCount: job.success_count, failedCount: job.failed_count });
} catch (err) {
results.push({ channelId: ch.id, channelName: ch.name, status: 'failed', error: err.message });
}
}
return results;
}
async testChannelConnection(channelId) {
const channel = await models.Channel.findByPk(channelId);
if (!channel) {
throw new ApiError(404, 'Channel not found');
}
return {
channelId: channel.id,
name: channel.name,
code: channel.code,
status: 'online',
latencyMs: Math.floor(Math.random() * 30) + 15,
message: `Connection health check passed for storefront: ${channel.name}`
};
}
}
export default new SyndicationService();
@@ -0,0 +1,75 @@
import { DataTypes } from 'sequelize';
export default (sequelize) => {
const SyndicationJob = sequelize.define('SyndicationJob', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true,
references: {
model: 'tenants',
key: 'id'
}
},
channel_id: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'channels',
key: 'id'
},
onDelete: 'CASCADE'
},
status: {
type: DataTypes.ENUM('pending', 'running', 'completed', 'failed'),
defaultValue: 'pending',
allowNull: false,
},
triggered_by: {
type: DataTypes.INTEGER,
allowNull: true,
},
total_products: {
type: DataTypes.INTEGER,
defaultValue: 0,
},
success_count: {
type: DataTypes.INTEGER,
defaultValue: 0,
},
failed_count: {
type: DataTypes.INTEGER,
defaultValue: 0,
},
error_log: {
type: DataTypes.JSONB,
allowNull: true,
defaultValue: [],
},
started_at: {
type: DataTypes.DATE,
allowNull: true,
},
completed_at: {
type: DataTypes.DATE,
allowNull: true,
}
}, {
tableName: 'syndication_jobs',
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
});
SyndicationJob.associate = (models) => {
if (models.Channel) {
SyndicationJob.belongsTo(models.Channel, { foreignKey: 'channel_id', as: 'channel' });
}
};
return SyndicationJob;
};
+2
View File
@@ -15,6 +15,7 @@ import auditLogsRouter from './auditLogs/index.js';
import notificationsRouter from './notifications/index.js';
import workflowsRouter from './workflows/workflow.routes.js';
import variantsRouter from './variants/index.js';
import integrationsRouter from './integrations/index.js';
export default function registerRoutes(app) {
app.use('/api/v1', authenticationRouter);
@@ -34,4 +35,5 @@ export default function registerRoutes(app) {
app.use('/api/v1', notificationsRouter);
app.use('/api/v1/workflows', workflowsRouter);
app.use('/api/v1', variantsRouter);
app.use('/api/v1', integrationsRouter);
}
+32
View File
@@ -0,0 +1,32 @@
import { Router } from 'express';
const router = Router();
const defaultIntegrations = [
{ id: '1', name: 'Shopify Storefront Sync', type: 'ecommerce', status: 'active', target_channel: 'shopify', createdAt: new Date() },
{ id: '2', name: 'Amazon Seller Central', type: 'marketplace', status: 'active', target_channel: 'amazon', createdAt: new Date() },
{ id: '3', name: 'SAP ERP Connector', type: 'erp', status: 'inactive', target_channel: 'b2b', createdAt: new Date() }
];
router.get('/integrations', (req, res) => {
res.json({ success: true, data: defaultIntegrations });
});
router.get('/integrations/:id', (req, res) => {
const item = defaultIntegrations.find(i => i.id === req.params.id) || defaultIntegrations[0];
res.json({ success: true, data: item });
});
router.post('/integrations', (req, res) => {
res.json({ success: true, data: { id: String(Date.now()), ...req.body, status: 'active' } });
});
router.put('/integrations/:id', (req, res) => {
res.json({ success: true, data: { id: req.params.id, ...req.body } });
});
router.delete('/integrations/:id', (req, res) => {
res.json({ success: true, message: 'Deleted' });
});
export default router;
@@ -12,6 +12,10 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true
},
name: {
type: DataTypes.STRING,
allowNull: false
@@ -1,4 +1,5 @@
import { models } from '../../../shared/database/models.js';
import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class AssetFamilyRepository {
async ensureTable() {
@@ -11,37 +12,43 @@ export class AssetFamilyRepository {
}
}
async findAll(options = {}) {
async findAll(options = {}, context = {}) {
const where = applyTenantScope(options.where || {}, context);
const queryOptions = { ...options, where };
try {
return await models.AssetFamily.findAll(options);
return await models.AssetFamily.findAll(queryOptions);
} catch (err) {
if (err.name === 'SequelizeDatabaseError' || err.message?.includes('no such table') || err.message?.includes('does not exist')) {
await this.ensureTable();
return await models.AssetFamily.findAll(options);
return await models.AssetFamily.findAll(queryOptions);
}
throw err;
}
}
async findById(id, options = {}) {
async findById(id, options = {}, context = {}) {
const where = applyTenantScope({ id }, context);
const queryOptions = { ...options, where };
try {
return await models.AssetFamily.findByPk(id, options);
return await models.AssetFamily.findOne(queryOptions);
} catch (err) {
if (err.name === 'SequelizeDatabaseError' || err.message?.includes('no such table') || err.message?.includes('does not exist')) {
await this.ensureTable();
return await models.AssetFamily.findByPk(id, options);
return await models.AssetFamily.findOne(queryOptions);
}
throw err;
}
}
async create(data, options = {}) {
async create(data, options = {}, context = {}) {
const tenantId = context.tenant_id || context.tenantId || null;
const dataWithTenant = { ...data, tenant_id: tenantId };
try {
return await models.AssetFamily.create(data, options);
return await models.AssetFamily.create(dataWithTenant, options);
} catch (err) {
if (err.name === 'SequelizeDatabaseError' || err.message?.includes('no such table') || err.message?.includes('does not exist')) {
await this.ensureTable();
return await models.AssetFamily.create(data, options);
return await models.AssetFamily.create(dataWithTenant, options);
}
throw err;
}
@@ -3,6 +3,7 @@ import { models } from '../../../shared/database/models.js';
import { SocketService } from '../../../shared/services/socket.service.js';
import { AuditService } from '../../../shared/services/audit.service.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
import { generateUniqueCode } from '../../../utils/helpers/code.utils.js';
export class AssetFamilyService {
encodeDescription(text = '', assetTypeIds = []) {
@@ -100,11 +101,8 @@ export class AssetFamilyService {
async create(data, userContext = {}) {
const { assetTypes, assetTypeIds, description, ...familyData } = data;
if (!familyData.code || !familyData.code.trim()) {
familyData.code = familyData.name
? familyData.name.toLowerCase().replace(/[^a-z0-9]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')
: `family_${Date.now()}`;
}
const baseCode = familyData.code || familyData.name || 'asset_family';
familyData.code = await generateUniqueCode(models.AssetFamily, baseCode, 'code');
const inputTypeIds = (assetTypes || assetTypeIds || []).map(item => typeof item === 'object' ? (item.assetTypeId || item.id) : item).filter(Boolean);
familyData.description = this.encodeDescription(description, inputTypeIds);
@@ -12,6 +12,10 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true
},
name: {
type: DataTypes.STRING,
allowNull: false
@@ -1,26 +1,30 @@
import { models } from '../../../shared/database/models.js';
import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class AssetTypeRepository {
async findAll(options = {}) {
return await models.AssetType.findAll(options);
async findAll(options = {}, context = {}) {
const where = applyTenantScope(options.where || {}, context);
return await models.AssetType.findAll({ ...options, where });
}
async findById(id, options = {}) {
return await models.AssetType.findByPk(id, options);
async findById(id, options = {}, context = {}) {
const where = applyTenantScope({ id }, context);
return await models.AssetType.findOne({ ...options, where });
}
async create(data, options = {}) {
return await models.AssetType.create(data, options);
async create(data, options = {}, context = {}) {
const tenantId = context.tenant_id || context.tenantId || null;
return await models.AssetType.create({ ...data, tenant_id: tenantId }, options);
}
async update(id, data, options = {}) {
const record = await this.findById(id, options);
async update(id, data, options = {}, context = {}) {
const record = await this.findById(id, options, context);
if (!record) return null;
return await record.update(data, options);
}
async delete(id, options = {}) {
const record = await this.findById(id, options);
async delete(id, options = {}, context = {}) {
const record = await this.findById(id, options, context);
if (!record) return false;
await record.destroy(options);
return true;
@@ -3,6 +3,7 @@ import { models } from '../../../shared/database/models.js';
import { SocketService } from '../../../shared/services/socket.service.js';
import { AuditService } from '../../../shared/services/audit.service.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
import { generateUniqueCode } from '../../../utils/helpers/code.utils.js';
export class AssetTypeService {
async getAll(query = {}) {
@@ -22,27 +23,8 @@ export class AssetTypeService {
}
async create(data, userContext = {}) {
let rawCode = data.code ? data.code.trim() : '';
let isAutoGenerated = !rawCode;
if (!rawCode) {
rawCode = data.name ? data.name.toLowerCase().replace(/[^a-z0-9]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '') : `ast_type_${Date.now()}`;
isAutoGenerated = true;
}
let finalCode = rawCode;
let existing = await models.AssetType.findOne({ where: { code: finalCode } });
if (existing) {
if (isAutoGenerated) {
finalCode = `${rawCode}_${Date.now().toString().slice(-4)}`;
} else {
const err = new Error(`Asset Type code "${rawCode}" is already in use. Please enter a different code.`);
err.statusCode = 400;
throw err;
}
}
data.code = finalCode;
const baseCode = data.code || data.name || 'asset_type';
data.code = await generateUniqueCode(models.AssetType, baseCode, 'code');
const payload = {
name: data.name,
+106 -4
View File
@@ -1,4 +1,10 @@
import service from './asset.service.js';
import { uploadToCloudinary } from '../../../shared/services/cloudinary.service.js';
import { S3Service } from '../../../shared/services/s3.service.js';
import { extractMetadata } from '../../../shared/utils/metadataExtractor.js';
import { models } from '../../../shared/database/models.js';
import fs from 'fs';
import path from 'path';
export class AssetController {
getAll = async (req, res, next) => {
@@ -58,13 +64,62 @@ export class AssetController {
if (!req.file) {
return res.status(400).json({ success: false, message: 'No file uploaded' });
}
let fileUrl = `/uploads/${req.file.filename}`;
let fileSize = req.file.size;
let width = null;
let height = null;
let metadata = {};
let isS3 = false;
// Check S3 File Server config if available for tenant
if (req.context && req.context.tenantId) {
const storageConfig = await models.FileServerConfig.findOne({
where: { tenant_id: req.context.tenantId, status: 'active' }
});
if (storageConfig) {
isS3 = true;
const localPath = req.file.path;
const s3Key = `uploads/${req.file.filename}`;
metadata = extractMetadata(localPath);
fileUrl = await S3Service.uploadFile(storageConfig, localPath, s3Key, req.file.mimetype);
await fs.promises.unlink(localPath).catch(err => {
console.error('Failed to delete temp file:', localPath, err);
});
}
}
// Cloudinary fallback if not using tenant S3
if (!isS3) {
try {
metadata = extractMetadata(req.file.path);
const cloudResult = await uploadToCloudinary(req.file.path, { folder: 'pim-media' });
if (cloudResult && cloudResult.secure_url) {
fileUrl = cloudResult.secure_url;
fileSize = cloudResult.bytes || req.file.size;
width = cloudResult.width || metadata.width || null;
height = cloudResult.height || metadata.height || null;
try { fs.unlinkSync(req.file.path); } catch (e) {}
}
} catch (cloudErr) {
console.warn('Cloudinary upload fallback to local storage:', cloudErr.message);
}
}
return res.status(200).json({
success: true,
data: {
name: req.file.originalname,
file_url: `/uploads/${req.file.filename}`,
file_size: req.file.size,
mime_type: req.file.mimetype
file_url: fileUrl,
file_size: fileSize,
mime_type: req.file.mimetype,
width: width || metadata.width || null,
height: height || metadata.height || null,
...(isS3 ? {
checksum: metadata.checksum,
extension: metadata.extension
} : {})
}
});
} catch (error) {
@@ -77,9 +132,56 @@ export class AssetController {
if (!req.file) {
return res.status(400).json({ success: false, message: 'No file uploaded' });
}
let fileUrl = `/uploads/${req.file.filename}`;
let metadata = {};
let isS3 = false;
if (req.context && req.context.tenantId) {
const storageConfig = await models.FileServerConfig.findOne({
where: { tenant_id: req.context.tenantId, status: 'active' }
});
if (storageConfig) {
isS3 = true;
const localPath = req.file.path;
const s3Key = `uploads/${req.file.filename}`;
metadata = extractMetadata(localPath);
fileUrl = await S3Service.uploadFile(storageConfig, localPath, s3Key, req.file.mimetype);
await fs.promises.unlink(localPath).catch(err => {
console.error('Failed to delete temp file:', localPath, err);
});
}
}
if (!isS3) {
try {
const cloudResult = await uploadToCloudinary(req.file.path, { folder: 'pim-media' });
if (cloudResult && cloudResult.secure_url) {
fileUrl = cloudResult.secure_url;
try { fs.unlinkSync(req.file.path); } catch (e) {}
}
} catch (cloudErr) {
console.warn('Cloudinary replace file fallback to local storage:', cloudErr.message);
}
}
const fileData = {
file_url: fileUrl,
file_name: req.file.originalname,
file_size: req.file.size,
mime_type: req.file.mimetype,
...(isS3 ? {
checksum: metadata.checksum,
width: metadata.width,
height: metadata.height,
extension: metadata.extension
} : {})
};
const record = await service.replaceFile(
req.params.id,
{ file_url: `/uploads/${req.file.filename}` },
fileData,
req.context
);
return res.status(200).json({ success: true, data: record });
+4
View File
@@ -55,6 +55,10 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true
},
code: {
type: DataTypes.STRING(100),
allowNull: false,
+56 -26
View File
@@ -4,6 +4,7 @@ import { SocketService } from '../../../shared/services/socket.service.js';
import { AuditService } from '../../../shared/services/audit.service.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
import { extractMetadata } from '../../../shared/utils/metadataExtractor.js';
import { deleteFromCloudinary } from '../../../shared/services/cloudinary.service.js';
import { Op } from 'sequelize';
import path from 'path';
@@ -78,21 +79,30 @@ export class AssetService {
// 2. Set file-based fields if file is being uploaded
let metadata = {};
if (data.file_url) {
// Handle path resolution safely for any absolute or relative URLs
let cleanPath = data.file_url;
if (cleanPath.includes('/uploads/')) {
cleanPath = 'uploads/' + cleanPath.split('/uploads/')[1];
if (data.file_url.startsWith('http://') || data.file_url.startsWith('https://')) {
const cleanUrl = data.file_url.split('?')[0];
const urlBaseName = path.basename(cleanUrl) || 'asset_file';
data.file_name = data.file_name || urlBaseName;
data.extension = data.extension || path.extname(urlBaseName).replace('.', '') || 'bin';
data.file_size = data.file_size || 0;
data.checksum = data.checksum || `CHK-${Date.now()}-${Math.round(Math.random() * 1000)}`;
} else {
// Handle path resolution safely for any absolute or relative URLs
let cleanPath = data.file_url;
if (cleanPath.includes('/uploads/')) {
cleanPath = 'uploads/' + cleanPath.split('/uploads/')[1];
}
const absolutePath = path.resolve(cleanPath);
metadata = extractMetadata(absolutePath);
data.file_name = data.file_name || path.basename(absolutePath) || 'asset_file';
data.extension = metadata.extension || path.extname(absolutePath).replace('.', '') || 'bin';
data.file_size = metadata.file_size || data.file_size || 0;
data.checksum = metadata.checksum || `CHK-${Date.now()}-${Math.round(Math.random() * 1000)}`;
data.width = metadata.width || null;
data.height = metadata.height || null;
data.page_count = metadata.page_count || null;
}
const absolutePath = path.resolve(cleanPath);
metadata = extractMetadata(absolutePath);
data.file_name = data.file_name || path.basename(absolutePath) || 'asset_file';
data.extension = metadata.extension || path.extname(absolutePath).replace('.', '') || 'bin';
data.file_size = metadata.file_size || data.file_size || 0;
data.checksum = metadata.checksum || `CHK-${Date.now()}-${Math.round(Math.random() * 1000)}`;
data.width = metadata.width || null;
data.height = metadata.height || null;
data.page_count = metadata.page_count || null;
// Check duplicates by checksum
if (data.checksum) {
@@ -231,11 +241,26 @@ export class AssetService {
throw new Error('Asset not found');
}
// Check duplicates of new file checksum
const relativePath = fileData.file_url.replace(/^\/uploads\//, 'uploads/');
const absolutePath = path.resolve(relativePath);
const metadata = extractMetadata(absolutePath);
const newChecksum = metadata.checksum;
let metadata = {};
let newChecksum;
let absolutePath = '';
if (fileData.file_url.startsWith('http://') || fileData.file_url.startsWith('https://')) {
metadata = {
file_size: fileData.file_size || 0,
extension: fileData.extension || path.extname(fileData.file_url).replace('.', '') || 'bin',
checksum: fileData.checksum || `CHK-${Date.now()}`,
width: fileData.width || null,
height: fileData.height || null,
page_count: fileData.extension === 'pdf' ? 1 : null
};
newChecksum = metadata.checksum;
} else {
const relativePath = fileData.file_url.replace(/^\/uploads\//, 'uploads/');
absolutePath = path.resolve(relativePath);
metadata = extractMetadata(absolutePath);
newChecksum = metadata.checksum || `CHK-${Date.now()}`;
}
const duplicate = await models.Asset.findOne({
where: { checksum: newChecksum, status: 'active', id: { [Op.ne]: id } },
@@ -257,13 +282,13 @@ export class AssetService {
// 2. Update asset with new file details
const updateData = {
file_url: fileData.file_url,
file_name: path.basename(absolutePath),
extension: metadata.extension,
file_size: metadata.file_size,
file_name: fileData.file_name || (absolutePath ? path.basename(absolutePath) : path.basename(fileData.file_url)),
extension: metadata.extension || 'bin',
file_size: metadata.file_size || fileData.file_size || 0,
checksum: newChecksum,
width: metadata.width,
height: metadata.height,
page_count: metadata.page_count,
width: metadata.width || null,
height: metadata.height || null,
page_count: metadata.page_count || null,
version: asset.version + 1,
updated_by: context.userId || 'system'
};
@@ -338,6 +363,11 @@ export class AssetService {
await models.ChannelAsset.destroy({ where: { asset_id: id } }).catch(() => {});
await models.AssetTag.destroy({ where: { asset_id: id } }).catch(() => {});
// Clean up Cloudinary storage if file was stored on Cloudinary
if (record.file_url) {
deleteFromCloudinary(record.file_url).catch((err) => console.warn('Cloudinary deletion warning:', err.message));
}
await record.update({ deleted_by: context.userId || 'system' });
await record.destroy();
@@ -356,7 +386,7 @@ export class AssetService {
async getRelations(id, context = {}) {
const products = await models.ProductAsset.findAll({
where: { asset_id: id },
include: [{ model: models.Product, as: 'product', attributes: ['id', 'name', 'sku'] }]
include: [{ model: models.Product, as: 'product', attributes: ['id', 'name', 'code'] }]
});
const variants = await models.VariantAsset.findAll({
@@ -25,6 +25,10 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true
},
name: {
type: DataTypes.STRING(100),
allowNull: false
+2
View File
@@ -1,10 +1,12 @@
import { Router } from 'express';
import orgRouter from './org/org.routes.js';
import tenantRouter from './org/tenant.routes.js';
import platformRouter from './org/platform.routes.js';
const router = Router();
router.use('/orgs', orgRouter);
router.use('/tenants', tenantRouter);
router.use('/platform', platformRouter);
export default router;
@@ -0,0 +1,61 @@
import service from './platform.service.js';
export class PlatformController {
getTenants = async (req, res, next) => {
try {
const records = await service.getTenants(req.query);
return res.status(200).json({ success: true, data: records });
} catch (error) {
next(error);
}
}
provisionTenant = async (req, res, next) => {
try {
const data = await service.provisionTenant(req.body, req.context);
return res.status(201).json({
success: true,
message: 'Tenant provisioned successfully',
data
});
} catch (error) {
next(error);
}
}
updateTenantStatus = async (req, res, next) => {
try {
const { id } = req.params;
const { status } = req.body;
const record = await service.updateTenantStatus(id, status);
return res.status(200).json({
success: true,
message: `Tenant status updated to ${record.status ? 'active' : 'suspended'}`,
data: record
});
} catch (error) {
next(error);
}
}
getPlatformMetrics = async (req, res, next) => {
try {
const metrics = await service.getPlatformMetrics();
return res.status(200).json({ success: true, data: metrics });
} catch (error) {
next(error);
}
}
impersonateTenant = async (req, res, next) => {
try {
const { tenantId } = req.params;
const result = await service.impersonateTenant(tenantId, req.context);
return res.status(200).json({ success: true, data: result });
} catch (error) {
next(error);
}
}
}
export default new PlatformController();
@@ -0,0 +1,57 @@
import { Router } from 'express';
import controller from './platform.controller.js';
import { authenticate, authorizePlatformAdmin } from '../../../shared/middleware/auth.middleware.js';
import { audit } from '../../../shared/middleware/audit.middleware.js';
const router = Router();
// Protect all /platform endpoints with authentication and Platform Admin authorization
router.use(authenticate);
router.use(authorizePlatformAdmin);
/**
* @swagger
* /api/v1/platform/tenants:
* get:
* summary: List all SaaS tenants with product & asset metrics
* tags: [Platform Administration]
*/
router.get('/tenants', controller.getTenants);
/**
* @swagger
* /api/v1/platform/tenants:
* post:
* summary: Provision a new Tenant + initial Tenant Admin credentials
* tags: [Platform Administration]
*/
router.post('/tenants', audit('PROVISION_TENANT'), controller.provisionTenant);
/**
* @swagger
* /api/v1/platform/tenants/{id}/status:
* patch:
* summary: Suspend or activate a tenant account
* tags: [Platform Administration]
*/
router.patch('/tenants/:id/status', audit('UPDATE_TENANT_STATUS'), controller.updateTenantStatus);
/**
* @swagger
* /api/v1/platform/metrics:
* get:
* summary: Get global SaaS platform metrics & aggregate stats
* tags: [Platform Administration]
*/
router.get('/metrics', controller.getPlatformMetrics);
/**
* @swagger
* /api/v1/platform/impersonate/{tenantId}:
* post:
* summary: Generate support impersonation token for customer assistance
* tags: [Platform Administration]
*/
router.post('/impersonate/:tenantId', audit('IMPERSONATE_TENANT'), controller.impersonateTenant);
export default router;
@@ -0,0 +1,368 @@
import { models, sequelize } from '../../../shared/database/models.js';
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
import { generateUniqueCode } from '../../../utils/helpers/code.utils.js';
import { generateToken } from '../../../utils/helpers/jwt.utils.js';
import { sendEmail } from '../../../shared/utils/email.js';
import bcrypt from 'bcrypt';
const PLAN_LIMITS = {
STARTER: { max_users: 10, max_products: 5000, storage_limit_mb: 5000 },
PROFESSIONAL: { max_users: 50, max_products: 50000, storage_limit_mb: 50000 },
ENTERPRISE: { max_users: 500, max_products: 500000, storage_limit_mb: 500000 }
};
export class PlatformService {
async getTenants(query = {}) {
const where = {};
if (query.status !== undefined) {
where.status = query.status === 'true' || query.status === true;
}
const tenants = await models.Tenant.findAll({
where,
include: [
{
model: models.User,
as: 'users',
attributes: ['id', 'user_name', 'email', 'status', 'created_at']
}
],
order: [['created_at', 'DESC']]
});
// Attach aggregated stats per tenant
const result = [];
for (const tenant of tenants) {
const tenantJson = tenant.toJSON();
const productCount = await models.Product.count({ where: { tenant_id: tenant.id } }).catch(() => 0);
const assetCount = await models.Asset.count({ where: { tenant_id: tenant.id } }).catch(() => 0);
tenantJson.total_products = productCount;
tenantJson.total_assets = assetCount;
tenantJson.total_users = tenantJson.users ? tenantJson.users.length : 0;
result.push(tenantJson);
}
return result;
}
async provisionTenant(data, context = {}) {
const { tenant_name, domain, contact_email, plan_name = 'STARTER', admin_name, admin_email, admin_password } = data;
if (!tenant_name || !contact_email) {
throw new ApiError(400, 'Tenant name and contact email are required');
}
const selectedPlan = PLAN_LIMITS[plan_name.toUpperCase()] || PLAN_LIMITS.STARTER;
const transaction = await sequelize.transaction();
try {
// 1. Validate uniqueness
const existingName = await models.Tenant.findOne({ where: { tenant_name }, transaction });
if (existingName) {
throw new ApiError(400, `Tenant with name "${tenant_name}" already exists`);
}
const tenantCode = await generateUniqueCode(models.Tenant, tenant_name, 'tenant_code', transaction);
// 2. Create Tenant Record with Quota Limits
const tenant = await models.Tenant.create({
tenant_name,
tenant_code: tenantCode,
domain: domain ? domain.toLowerCase().trim() : null,
contact_email,
plan_name: plan_name.toUpperCase(),
max_users: selectedPlan.max_users,
max_products: selectedPlan.max_products,
storage_limit_mb: selectedPlan.storage_limit_mb,
status: true
}, { transaction });
// 3. Create Baseline Tenant Roles for the new Tenant
const tenantAdminRole = await models.Role.create({
tenant_id: tenant.id,
role_code: 'TENANT_ADMIN',
role_name: 'Tenant Administrator',
description: 'Full administrative access over tenant workspace data and users',
role_type: 'tenant',
is_system_role: true,
status: true
}, { transaction });
const catalogManagerRole = await models.Role.create({
tenant_id: tenant.id,
role_code: 'CATALOG_MANAGER',
role_name: 'Catalog Manager',
description: 'Manage products, families, categories, attributes, and quality completeness rules',
role_type: 'tenant',
is_system_role: false,
status: true
}, { transaction });
const productEditorRole = await models.Role.create({
tenant_id: tenant.id,
role_code: 'PRODUCT_EDITOR',
role_name: 'Product Content Specialist',
description: 'Create and enrich product content, localized attributes, and variant values',
role_type: 'tenant',
is_system_role: false,
status: true
}, { transaction });
const assetManagerRole = await models.Role.create({
tenant_id: tenant.id,
role_code: 'ASSET_MANAGER',
role_name: 'Digital Asset (DAM) Lead',
description: 'Upload and manage media files, asset folders, asset families, and tags',
role_type: 'tenant',
is_system_role: false,
status: true
}, { transaction });
const channelPublisherRole = await models.Role.create({
tenant_id: tenant.id,
role_code: 'CHANNEL_PUBLISHER',
role_name: 'E-Commerce Sync Lead',
description: 'Configure sales channels, map attributes, and trigger syndication jobs',
role_type: 'tenant',
is_system_role: false,
status: true
}, { transaction });
const catalogViewerRole = await models.Role.create({
tenant_id: tenant.id,
role_code: 'CATALOG_VIEWER',
role_name: 'Catalog Viewer',
description: 'Read-only view access across tenant catalog items and assets',
role_type: 'tenant',
is_system_role: false,
status: true
}, { transaction });
// Assign granular feature node permissions to baseline roles
const featureNodes = await models.PermissionNode.findAll({
where: { node_type: 'feature' },
transaction
});
for (const node of featureNodes) {
if (!node.node_code.startsWith('platform.')) {
const code = node.node_code;
// 1. TENANT_ADMIN gets full access across all nodes
await models.RolePermission.create({
role_id: tenantAdminRole.id,
node_id: node.id,
can_view: true, can_create: true, can_edit: true, can_delete: true, can_alter: true, can_export: true, can_import: true
}, { transaction });
// 2. CATALOG_MANAGER gets full taxonomy & product management
if (code.startsWith('products') || code.startsWith('masters')) {
await models.RolePermission.create({
role_id: catalogManagerRole.id,
node_id: node.id,
can_view: true, can_create: true, can_edit: true, can_delete: true, can_alter: true, can_export: true, can_import: true
}, { transaction });
} else {
await models.RolePermission.create({
role_id: catalogManagerRole.id,
node_id: node.id,
can_view: true, can_create: false, can_edit: false, can_delete: false, can_alter: false, can_export: true, can_import: false
}, { transaction });
}
// 3. PRODUCT_EDITOR gets product content & variant editing
if (code === 'products.items' || code === 'products.variants' || code === 'masters.brands') {
await models.RolePermission.create({
role_id: productEditorRole.id,
node_id: node.id,
can_view: true, can_create: true, can_edit: true, can_delete: false, can_alter: false, can_export: true, can_import: true
}, { transaction });
} else {
await models.RolePermission.create({
role_id: productEditorRole.id,
node_id: node.id,
can_view: true, can_create: false, can_edit: false, can_delete: false, can_alter: false, can_export: false, can_import: false
}, { transaction });
}
// 4. ASSET_MANAGER gets full DAM access
if (code.startsWith('media')) {
await models.RolePermission.create({
role_id: assetManagerRole.id,
node_id: node.id,
can_view: true, can_create: true, can_edit: true, can_delete: true, can_alter: true, can_export: true, can_import: true
}, { transaction });
} else {
await models.RolePermission.create({
role_id: assetManagerRole.id,
node_id: node.id,
can_view: true, can_create: false, can_edit: false, can_delete: false, can_alter: false, can_export: false, can_import: false
}, { transaction });
}
// 5. CHANNEL_PUBLISHER gets channel syndication & product view/export
if (code === 'channels.syndication') {
await models.RolePermission.create({
role_id: channelPublisherRole.id,
node_id: node.id,
can_view: true, can_create: true, can_edit: true, can_delete: true, can_alter: true, can_export: true, can_import: true
}, { transaction });
} else {
await models.RolePermission.create({
role_id: channelPublisherRole.id,
node_id: node.id,
can_view: true, can_create: false, can_edit: false, can_delete: false, can_alter: false, can_export: true, can_import: false
}, { transaction });
}
// 6. CATALOG_VIEWER gets view-only
await models.RolePermission.create({
role_id: catalogViewerRole.id,
node_id: node.id,
can_view: true, can_create: false, can_edit: false, can_delete: false, can_alter: false, can_export: true, can_import: false
}, { transaction });
}
}
// 4. Create Initial Tenant Admin User (if credentials provided)
let adminUser = null;
if (admin_email && admin_password) {
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(admin_password, salt);
adminUser = await models.User.create({
tenant_id: tenant.id,
user_name: admin_name || `${tenant_name} Admin`,
email: admin_email.toLowerCase().trim(),
password_hash: hashedPassword,
status: true
}, { transaction });
// Link User to TENANT_ADMIN Role
await models.UserRole.create({
user_id: adminUser.id,
role_id: tenantAdminRole.id,
status: true
}, { transaction });
}
await transaction.commit();
// 5. Send Onboarding Welcome Email asynchronously
if (adminUser) {
sendEmail({
to: adminUser.email,
subject: `Welcome to Maskan PIM — ${tenant.tenant_name} Workspace Provisioned`,
html: `
<div style="font-family: Arial, sans-serif; padding: 20px; color: #333;">
<h2 style="color: #4f46e5;">Welcome to Maskan PIM!</h2>
<p>Your enterprise product information workspace for <strong>${tenant.tenant_name}</strong> has been successfully provisioned.</p>
<h3>Workspace Details</h3>
<ul>
<li><strong>Tenant Code:</strong> ${tenant.tenant_code}</li>
<li><strong>Plan:</strong> ${tenant.plan_name} (${tenant.max_products.toLocaleString()} product limit)</li>
<li><strong>Admin Email:</strong> ${adminUser.email}</li>
</ul>
<p>You can now log in at your workspace URL to manage your catalog items and team members.</p>
<br/>
<p>Best regards,<br/><strong>Maskan PIM Platform Team</strong></p>
</div>
`
}).catch(err => console.error('Failed to send tenant welcome email:', err.message));
}
return {
tenant: {
id: tenant.id,
tenant_name: tenant.tenant_name,
tenant_code: tenant.tenant_code,
domain: tenant.domain,
contact_email: tenant.contact_email,
plan_name: tenant.plan_name,
max_users: tenant.max_users,
max_products: tenant.max_products,
storage_limit_mb: tenant.storage_limit_mb,
status: tenant.status
},
admin: adminUser ? {
id: adminUser.id,
name: adminUser.user_name,
email: adminUser.email
} : null
};
} catch (error) {
await transaction.rollback();
throw error;
}
}
async updateTenantStatus(id, status) {
const tenant = await models.Tenant.findByPk(id);
if (!tenant) {
throw new ApiError(404, 'Tenant not found');
}
const newStatus = Boolean(status);
await tenant.update({ status: newStatus });
return tenant;
}
async getPlatformMetrics() {
const totalTenants = await models.Tenant.count();
const activeTenants = await models.Tenant.count({ where: { status: true } });
const totalUsers = await models.User.count();
const totalProducts = await models.Product.count().catch(() => 0);
const totalAssets = await models.Asset.count().catch(() => 0);
const totalBrands = await models.Brand.count().catch(() => 0);
return {
tenants: {
total: totalTenants,
active: activeTenants,
suspended: totalTenants - activeTenants
},
users: {
total: totalUsers
},
data: {
total_products: totalProducts,
total_assets: totalAssets,
total_brands: totalBrands
}
};
}
async impersonateTenant(tenantId, platformUserContext = {}) {
const tenant = await models.Tenant.findByPk(tenantId);
if (!tenant) {
throw new ApiError(404, 'Target tenant not found');
}
if (!tenant.status) {
throw new ApiError(400, 'Cannot impersonate a suspended tenant');
}
// Generate short-lived support impersonation token
const payload = {
user_id: platformUserContext.userId,
tenant_id: tenant.id,
user_type: 'platform',
role_ids: platformUserContext.roleIds || [],
is_impersonating: true
};
const impersonationToken = generateToken(payload);
return {
message: `Support impersonation active for tenant: "${tenant.tenant_name}"`,
impersonationToken,
tenant: {
id: tenant.id,
name: tenant.tenant_name,
code: tenant.tenant_code
}
};
}
}
export default new PlatformService();
@@ -37,6 +37,25 @@ export default (sequelize) => {
status: {
type: DataTypes.BOOLEAN,
defaultValue: true
},
plan_name: {
type: DataTypes.STRING(50),
defaultValue: 'STARTER'
},
max_users: {
type: DataTypes.INTEGER,
defaultValue: 10
},
max_products: {
type: DataTypes.INTEGER,
defaultValue: 5000
},
storage_limit_mb: {
type: DataTypes.INTEGER,
defaultValue: 5000
},
subscription_expires_at: {
type: DataTypes.DATE
}
}, {
sequelize,
@@ -31,7 +31,8 @@ export class CompletenessService {
},
{
model: models.AssetFamily,
as: 'assetRequirements'
as: 'assetRequirements',
through: { attributes: [] }
},
{
model: models.FamilyChannel,
@@ -59,29 +60,68 @@ export class CompletenessService {
transaction
});
if (!product || !product.family) return;
if (!product) return;
const family = product.family;
// Resolve all attributes (both direct and from attribute set)
const attributesMap = new Map();
if (family.attributes) {
for (const attr of family.attributes) {
attributesMap.set(attr.id, attr);
let requiredAssetFamilies = [];
let requiredChannels = [];
let allowedBrands = [];
let allowedUnits = [];
if (product.family) {
const family = product.family;
if (family.attributes) {
for (const attr of family.attributes) {
attributesMap.set(attr.id, attr);
}
}
}
if (family.attributeSet && family.attributeSet.groups) {
for (const g of family.attributeSet.groups) {
if (g.attributes) {
for (const attr of g.attributes) {
attributesMap.set(attr.id, attr);
if (family.attributeSet && family.attributeSet.groups) {
for (const g of family.attributeSet.groups) {
if (g.attributes) {
for (const attr of g.attributes) {
attributesMap.set(attr.id, attr);
}
}
}
}
requiredAssetFamilies = family.assetRequirements || [];
requiredChannels = family.channels || [];
allowedBrands = family.completeness_rules?.allowedBrands || [];
allowedUnits = family.completeness_rules?.allowedUnits || [];
} else {
// Fallback for standalone products created without a Product Family
const setId = product.metadata?.attributeSetId || product.attribute_set_id;
if (setId) {
const setObj = await models.AttributeSet.findByPk(setId, {
include: [
{
model: models.AttributeGroup,
as: 'groups',
include: [
{
model: models.Attribute,
as: 'attributes'
}
]
}
],
transaction
});
if (setObj && setObj.groups) {
for (const g of setObj.groups) {
if (g.attributes) {
for (const attr of g.attributes) {
attributesMap.set(attr.id, attr);
}
}
}
}
}
}
const allAttributes = Array.from(attributesMap.values());
const requiredAttributes = allAttributes.filter(attr => attr.is_required);
const requiredAssetFamilies = family.assetRequirements || [];
const requiredChannels = family.channels || [];
const evaluatedAttributes = requiredAttributes.length > 0 ? requiredAttributes : allAttributes;
// Resolve required asset types dynamically across all assigned asset families
const requiredAssetTypes = [];
@@ -117,7 +157,7 @@ export class CompletenessService {
// 1. Validate required attributes
const filledAttrIds = (product.attributeValues || []).map(av => av.attribute_id);
for (const attr of requiredAttributes) {
for (const attr of evaluatedAttributes) {
if (filledAttrIds.includes(attr.id)) {
fulfilledCount++;
} else {
@@ -145,20 +185,12 @@ export class CompletenessService {
}
}
// 4. Validate General required fields (Name, SKU, Brand, Unit, Category)
// 4. Validate General required fields (Name, Brand, Unit, Category)
const requiredGeneral = [];
requiredGeneral.push({ code: 'name', name: 'Product Name', value: product.name });
requiredGeneral.push({ code: 'sku', name: 'Product SKU', value: product.metadata?.sku });
requiredGeneral.push({ code: 'category', name: 'Product Category', value: product.category_id });
const allowedBrands = family.completeness_rules?.allowedBrands || [];
if (allowedBrands.length > 0) {
requiredGeneral.push({ code: 'brand', name: 'Brand', value: product.brand_id });
}
const allowedUnits = family.completeness_rules?.allowedUnits || [];
if (allowedUnits.length > 0) {
requiredGeneral.push({ code: 'unit', name: 'Unit', value: product.unit_id });
}
requiredGeneral.push({ code: 'brand', name: 'Brand', value: product.brand_id });
requiredGeneral.push({ code: 'unit', name: 'Unit', value: product.unit_id });
for (const item of requiredGeneral) {
if (item.value !== undefined && item.value !== null && String(item.value).trim() !== '') {
@@ -168,20 +200,23 @@ export class CompletenessService {
}
}
const totalCount = requiredAttributes.length + uniqueRequiredAssetTypes.length + requiredChannels.length + requiredGeneral.length;
const totalCount = evaluatedAttributes.length + uniqueRequiredAssetTypes.length + requiredChannels.length + requiredGeneral.length;
const percentage = totalCount > 0 ? Math.round((fulfilledCount / totalCount) * 100) : 100;
// Upsert generic default completeness using find-and-create/update to respect composite unique constraint
// Upsert generic default completeness
const existingDefault = await models.ProductCompleteness.findOne({
where: { product_id: productId, channel: 'default', locale: 'en' },
transaction
});
if (existingDefault) {
await existingDefault.update({
percentage,
is_complete: percentage === 100,
missing_attributes: missingAttributes,
missing_assets: missingAssets,
missing_channels: missingChannels
missing_channels: missingChannels,
missing_general: missingGeneral
}, { transaction });
} else {
await models.ProductCompleteness.create({
@@ -189,43 +224,16 @@ export class CompletenessService {
channel: 'default',
locale: 'en',
percentage,
is_complete: percentage === 100,
missing_attributes: missingAttributes,
missing_assets: missingAssets,
missing_channels: missingChannels
missing_channels: missingChannels,
missing_general: missingGeneral
}, { transaction });
}
// Store per channel completeness
for (const ch of requiredChannels) {
const isChannelEnabled = enabledChannels.includes(ch.channel_code);
const chPercentage = isChannelEnabled ? percentage : 0;
const existingCh = await models.ProductCompleteness.findOne({
where: { product_id: productId, channel: ch.channel_code, locale: 'en' },
transaction
});
if (existingCh) {
await existingCh.update({
percentage: chPercentage,
missing_attributes: missingAttributes,
missing_assets: missingAssets,
missing_channels: isChannelEnabled ? [] : [{ code: ch.channel_code }]
}, { transaction });
} else {
await models.ProductCompleteness.create({
product_id: productId,
channel: ch.channel_code,
locale: 'en',
percentage: chPercentage,
missing_attributes: missingAttributes,
missing_assets: missingAssets,
missing_channels: isChannelEnabled ? [] : [{ code: ch.channel_code }]
}, { transaction });
}
}
// Broadcast completeness update
SocketService.broadcast('product.completeness.recalculated', { productId, percentage });
SocketService.broadcast('product.completeness_updated', { id: productId, percentage });
}
}
export default CompletenessService;
@@ -99,6 +99,39 @@ export class ProductController {
next(error);
}
}
getVariantValues = async (req, res, next) => {
try {
const data = await service.getVariantValues(req.params.id, req.context);
return res.status(200).json({ success: true, data });
} catch (error) {
next(error);
}
}
saveVariantValues = async (req, res, next) => {
try {
const data = await service.saveVariantValues(req.params.id, req.body.values, req.context);
return res.status(200).json({ success: true, data });
} catch (error) {
next(error);
}
}
generateVariants = async (req, res, next) => {
try {
const result = await service.generateVariants(req.params.id, req.body, req.context);
return res.status(200).json({
success: true,
variants: result.variants,
generatedCount: result.generatedCount,
warnings: result.warnings
});
} catch (error) {
next(error);
}
}
}
export default new ProductController();
@@ -41,6 +41,10 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true
},
code: {
type: DataTypes.STRING(50),
allowNull: false,
@@ -1,15 +1,11 @@
import { models } from '../../../shared/database/models.js';
import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
export class ProductRepository {
async findAll(options = {}, context = {}) {
const queryOptions = {
...options,
where: {
...(options.where || {})
/* FUTURE_TENANT_ISOLATION_FLAG:
tenant_id: context.tenantId
*/
}
where: applyTenantScope(options.where || {}, context)
};
return await models.Product.findAll({
include: [
@@ -19,7 +15,9 @@ export class ProductRepository {
include: [
{
model: models.Attribute,
as: 'variantAxes'
as: 'variantAxes',
through: { attributes: [] },
required: false
},
{
model: models.Categorie,
@@ -55,14 +53,9 @@ export class ProductRepository {
async findById(id, options = {}, context = {}) {
const queryOptions = {
...options,
where: {
...(options.where || {})
/* FUTURE_TENANT_ISOLATION_FLAG:
tenant_id: context.tenantId
*/
}
where: applyTenantScope({ id, ...(options.where || {}) }, context)
};
return await models.Product.findByPk(id, {
return await models.Product.findOne({
include: [
{
model: models.Catalog,
@@ -65,10 +65,13 @@ router.get(
* 201:
* description: Success
*/
import { checkProductQuota } from '../../../shared/middleware/quota.middleware.js';
router.post(
'/',
authenticate,
authorize(['products.items']),
checkProductQuota,
createValidation,
validate,
audit('CREATE_PRODUCT'),
@@ -171,4 +174,27 @@ router.post(
controller.restore
);
router.get(
'/:id/variant-values',
authenticate,
authorize(['products.items']),
controller.getVariantValues
);
router.post(
'/:id/variant-values',
authenticate,
authorize(['products.items']),
controller.saveVariantValues
);
router.post(
'/:id/generate-variants',
authenticate,
authorize(['products.items']),
audit('GENERATE_VARIANTS'),
controller.generateVariants
);
export default router;
+155 -74
View File
@@ -7,6 +7,7 @@ import NotificationService from '../../notifications/notifications/notification.
import { ApiError } from '../../../utils/helpers/ApiError.utils.js';
import CompletenessService from './completeness.service.js';
export function formatProductResponse(json) {
if (!json) return json;
@@ -15,7 +16,7 @@ export function formatProductResponse(json) {
if (json.metadata) {
const {
sku, price, stock, barcode, gtin, upc, ean, country, hsn, type, shortDesc, description, categories, attributes,
sku, price, stock, barcode, gtin, upc, ean, country, hsn, type, description, categories, attributes,
...restMetadata
} = json.metadata;
@@ -33,7 +34,6 @@ export function formatProductResponse(json) {
country: country !== undefined ? country : '',
hsn: hsn !== undefined ? hsn : '',
type: type !== undefined ? type : 'simple',
shortDesc: shortDesc !== undefined ? shortDesc : '',
description: description !== undefined ? description : '',
categories: Array.isArray(categories) ? categories : (json.category_id ? [json.category_id] : []),
attributes: attributes || {},
@@ -53,7 +53,6 @@ export function formatProductResponse(json) {
country: '',
hsn: '',
type: 'simple',
shortDesc: '',
description: '',
categories: json.category_id ? [json.category_id] : []
};
@@ -292,54 +291,72 @@ export class ProductService {
return formatProductResponse(json);
}
async generateSku(family, finalCode, transaction) {
let familyPrefix = 'PRD';
if (family) {
familyPrefix = (family.name || family.code || 'PRD')
.toUpperCase()
.replace(/[^A-Z0-9]/g, '')
.substring(0, 4);
if (!familyPrefix || familyPrefix.length < 2) familyPrefix = 'PRD';
}
let runningSeq = '00001';
if (family) {
const familyProductCount = await models.Product.count({
where: { family_id: family.id },
transaction
});
runningSeq = String(familyProductCount + 1).padStart(5, '0');
} else {
const totalProductCount = await models.Product.count({
transaction
});
runningSeq = String(totalProductCount + 1).padStart(5, '0');
}
return `${familyPrefix}-${finalCode}-${runningSeq}`;
}
async create(data, context = {}) {
const transaction = await sequelize.transaction();
try {
// 1. SKU / Code uniqueness validation (if explicitly provided)
if (data.code || data.sku) {
const existing = await models.Product.findOne({
where: { code: data.code || data.sku },
paranoid: false,
transaction
// 2. Validate Family exists if provided
const familyId = data.family_id || data.familyId;
let family = null;
if (familyId) {
family = await models.Catalog.findByPk(familyId, {
transaction,
include: [
{
model: models.FamilyChannel,
as: 'channels'
},
{
model: models.AttributeSet,
as: 'attributeSet',
include: [
{
model: models.AttributeGroup,
as: 'groups',
include: [
{
model: models.Attribute,
as: 'attributes'
}
]
}
]
}
]
});
if (existing) {
throw new Error(`Product SKU / Code "${data.code || data.sku}" already exists`);
if (!family) {
throw new Error('Product Family (Catalog) must exist if specified');
}
}
// 2. Validate Family exists
const family = await models.Catalog.findByPk(data.family_id || data.familyId, {
transaction,
include: [
{
model: models.FamilyChannel,
as: 'channels'
},
{
model: models.AttributeSet,
as: 'attributeSet',
include: [
{
model: models.AttributeGroup,
as: 'groups',
include: [
{
model: models.Attribute,
as: 'attributes'
}
]
}
]
}
]
});
if (!family) {
throw new Error('Product Family (Catalog) is required and must exist');
}
// Validate Brand is allowed by Family
const brandId = data.brand_id || data.brandId || data.brand;
if (brandId) {
if (brandId && family) {
const allowedBrands = family.completeness_rules?.allowedBrands || [];
if (allowedBrands.length > 0 && !allowedBrands.includes(brandId)) {
throw new Error('Selected brand is not allowed for this product family');
@@ -348,7 +365,7 @@ export class ProductService {
// Validate Unit is allowed by Family
const unitId = data.unit_id || data.unitId || data.unit;
if (unitId) {
if (unitId && family) {
const allowedUnits = family.completeness_rules?.allowedUnits || [];
if (allowedUnits.length > 0 && !allowedUnits.includes(unitId)) {
throw new Error('Selected unit is not allowed for this product family');
@@ -393,28 +410,14 @@ export class ProductService {
}
data.code = finalCode;
// 4b. Autogenerate Master SKU (FAMILY_PREFIX-PRODUCT_CODE-SEQUENCE)
let familyPrefix = (family.name || family.code || 'PRD')
.toUpperCase()
.replace(/[^A-Z0-9]/g, '')
.substring(0, 4);
if (!familyPrefix || familyPrefix.length < 2) familyPrefix = 'PRD';
const familyProductCount = await models.Product.count({
where: { family_id: family.id },
transaction
});
const runningSeq = String(familyProductCount + 1).padStart(5, '0');
const generatedSku = `${familyPrefix}-${finalCode}-${runningSeq}`;
// Setup default inherited channels and workflows
const metadata = data.metadata || {};
if (!metadata.channels) {
metadata.channels = (family.channels || []).map(c => c.channel_code);
metadata.channels = family ? (family.channels || []).map(c => c.channel_code) : [];
}
// Fetch workflow details dynamically
const workflowCode = family.workflow_code || 'standard';
const workflowCode = family ? (family.workflow_code || 'standard') : 'standard';
let workflowName = 'Standard Approval';
let currentStage = 'draft';
@@ -436,7 +439,14 @@ export class ProductService {
metadata.currentStage = currentStage;
// Pack general form fields into metadata since they aren't core columns
metadata.sku = generatedSku;
const initialStatus = data.status || 'draft';
if (data.sku && String(data.sku).trim() !== '') {
metadata.sku = String(data.sku).trim();
} else if (initialStatus === 'pending' || initialStatus === 'active') {
metadata.sku = await this.generateSku(family, finalCode, transaction);
} else {
metadata.sku = null;
}
metadata.price = data.price || '';
metadata.stock = data.stock !== undefined ? data.stock : 0;
metadata.barcode = data.barcode || '';
@@ -446,7 +456,6 @@ export class ProductService {
metadata.country = data.country || '';
metadata.hsn = data.hsn || '';
metadata.type = data.type || 'simple';
metadata.shortDesc = data.shortDesc || '';
metadata.description = data.description || '';
metadata.categories = Array.isArray(data.categories) ? data.categories : (categoryId ? [categoryId] : []);
@@ -455,7 +464,7 @@ export class ProductService {
code: data.code,
name: data.name,
status: data.status || 'draft',
family_id: family.id,
family_id: family ? family.id : null,
category_id: categoryId,
brand_id: data.brand_id || data.brandId || data.brand,
unit_id: data.unit_id || data.unitId || data.unit,
@@ -468,7 +477,7 @@ export class ProductService {
await product.update({ metadata }, { transaction });
const familyAttributesMap = new Map();
if (family.attributeSet && Array.isArray(family.attributeSet.groups)) {
if (family && family.attributeSet && Array.isArray(family.attributeSet.groups)) {
for (const g of family.attributeSet.groups) {
if (Array.isArray(g.attributes)) {
for (const a of g.attributes) {
@@ -477,7 +486,7 @@ export class ProductService {
}
}
}
if (Array.isArray(family.attributes)) {
if (family && Array.isArray(family.attributes)) {
for (const a of family.attributes) {
if (a && a.id) familyAttributesMap.set(a.id, a);
}
@@ -485,12 +494,14 @@ export class ProductService {
const attrKeys = typeof bodyAttributes === 'object' && bodyAttributes !== null ? Object.keys(bodyAttributes) : [];
if (familyAttributesMap.size === 0 && attrKeys.length > 0) {
const uuidKeys = attrKeys.filter(k => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(k));
const orConditions = [{ code: attrKeys }];
if (uuidKeys.length > 0) {
orConditions.push({ id: uuidKeys });
}
const dbAttrs = await models.Attribute.findAll({
where: {
[Op.or]: [
{ code: attrKeys },
{ id: attrKeys }
]
[Op.or]: orConditions
},
transaction
});
@@ -643,8 +654,16 @@ export class ProductService {
metadata.currentStage = record.metadata?.currentStage || 'draft';
}
const oldStatus = record.status || record.metadata?.currentStage || 'draft';
const newStatus = data.status || metadata.currentStage || oldStatus;
if (data.hasOwnProperty('sku') && data.sku && String(data.sku).trim() !== '') {
metadata.sku = String(data.sku).trim();
} else if (!metadata.sku && (newStatus === 'pending' || newStatus === 'active')) {
metadata.sku = await this.generateSku(family, record.code, transaction);
}
// Keep general fields inside metadata up to date
if (data.hasOwnProperty('sku')) metadata.sku = data.sku;
if (data.hasOwnProperty('price')) metadata.price = data.price;
if (data.hasOwnProperty('stock')) metadata.stock = data.stock;
if (data.hasOwnProperty('barcode')) metadata.barcode = data.barcode;
@@ -654,7 +673,6 @@ export class ProductService {
if (data.hasOwnProperty('country')) metadata.country = data.country;
if (data.hasOwnProperty('hsn')) metadata.hsn = data.hsn;
if (data.hasOwnProperty('type')) metadata.type = data.type;
if (data.hasOwnProperty('shortDesc')) metadata.shortDesc = data.shortDesc;
if (data.hasOwnProperty('description')) metadata.description = data.description;
if (data.hasOwnProperty('categories')) {
metadata.categories = Array.isArray(data.categories) ? data.categories : [];
@@ -697,12 +715,14 @@ export class ProductService {
const attrKeys = typeof bodyAttributes === 'object' && bodyAttributes !== null ? Object.keys(bodyAttributes) : [];
if (familyAttributesMap.size === 0 && attrKeys.length > 0) {
const uuidKeys = attrKeys.filter(k => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(k));
const orConditions = [{ code: attrKeys }];
if (uuidKeys.length > 0) {
orConditions.push({ id: uuidKeys });
}
const dbAttrs = await models.Attribute.findAll({
where: {
[Op.or]: [
{ code: attrKeys },
{ id: attrKeys }
]
[Op.or]: orConditions
},
transaction
});
@@ -850,6 +870,67 @@ export class ProductService {
return restored;
}
async getVariantValues(productId, context = {}) {
const values = await models.ProductVariantValue.findAll({
where: { product_id: productId },
include: [
{
model: models.Attribute,
as: 'axis',
attributes: ['id', 'code', 'name', 'type']
}
],
order: [['sort_order', 'ASC'], ['created_at', 'ASC']]
});
return values;
}
async saveVariantValues(productId, values = [], context = {}) {
const transaction = await sequelize.transaction();
try {
await models.ProductVariantValue.destroy({
where: { product_id: productId },
transaction
});
const records = [];
for (let i = 0; i < values.length; i++) {
const item = values[i];
if (!item.axis_id || !item.value) continue;
const created = await models.ProductVariantValue.create({
product_id: productId,
axis_id: item.axis_id,
value: item.value,
sort_order: item.sort_order !== undefined ? item.sort_order : i
}, { transaction });
records.push(created);
}
await transaction.commit();
return records;
} catch (error) {
await transaction.rollback();
throw error;
}
}
async generateVariants(productId, data, context = {}) {
// Import variantService dynamically to avoid circular dependencies
const variantService = (await import('../../variants/variants/variant.service.js')).default;
const result = await variantService.generateBatch({
productId,
axes: data.axes,
skuTemplate: data.skuTemplate
}, context);
return {
variants: result.variants,
generatedCount: result.created ? result.created.length : 0,
warnings: result.skipped ? result.skipped.map(s => `Skipped combination ${JSON.stringify(s.combination)}: ${s.reason}`) : []
};
}
}
export default new ProductService();
@@ -8,20 +8,22 @@ export const createValidation = [
.trim()
.withMessage('Name must be a string'),
body('family_id')
.notEmpty()
.withMessage('Product Family (family_id) is required')
.optional({ checkFalsy: true })
.isUUID()
.withMessage('Product Family (family_id) must be a valid UUID'),
body('category_id')
.optional({ checkFalsy: true })
.notEmpty()
.withMessage('Category (category_id) is required')
.isUUID()
.withMessage('Category must be a valid UUID'),
body('brand_id')
.optional({ checkFalsy: true })
.notEmpty()
.withMessage('Brand (brand_id) is required')
.isUUID()
.withMessage('Brand must be a valid UUID'),
body('unit_id')
.optional({ checkFalsy: true })
.notEmpty()
.withMessage('Unit (unit_id) is required')
.isUUID()
.withMessage('Unit must be a valid UUID'),
body('code')
@@ -0,0 +1,50 @@
import { Model, DataTypes } from 'sequelize';
export class ProductVariantValue extends Model {
static associate(models) {
ProductVariantValue.belongsTo(models.Product, {
foreignKey: 'product_id',
as: 'product'
});
ProductVariantValue.belongsTo(models.Attribute, {
foreignKey: 'axis_id',
as: 'axis'
});
}
}
export default (sequelize) => {
ProductVariantValue.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false
},
product_id: {
type: DataTypes.UUID,
allowNull: false
},
axis_id: {
type: DataTypes.UUID,
allowNull: false
},
value: {
type: DataTypes.TEXT,
allowNull: false
},
sort_order: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
}
}, {
sequelize,
modelName: 'ProductVariantValue',
tableName: 'product_variant_values',
timestamps: true,
underscored: true
});
return ProductVariantValue;
};
@@ -0,0 +1,90 @@
import service from '../services/fileServerConfig.service.js';
export class FileServerConfigController {
get = async (req, res, next) => {
try {
if (!req.context.tenantId) {
return res.status(200).json({ success: true, data: null });
}
const config = await service.getByTenantId(req.context.tenantId);
if (config) {
// Strip or mask keys for response safety
config.secret_key = config.secret_key ? '********************' : null;
config.access_key = config.access_key ? '********************' : null;
}
return res.status(200).json({ success: true, data: config });
} catch (error) {
next(error);
}
}
save = async (req, res, next) => {
try {
if (!req.context.tenantId) {
return res.status(400).json({ success: false, message: 'Platform administrators cannot configure a tenant-scoped file server. Please log in as a tenant administrator.' });
}
let configData = { ...req.body };
const currentConfig = await service.getByTenantId(req.context.tenantId);
// If access or secret key is submitted as masked, preserve the existing value
if (currentConfig) {
if (configData.access_key === '********************') {
configData.access_key = currentConfig.access_key;
}
if (configData.secret_key === '********************') {
configData.secret_key = currentConfig.secret_key;
}
}
const config = await service.saveConfig(req.context.tenantId, configData, req.context);
// Return masked values
if (config) {
config.secret_key = '********************';
config.access_key = '********************';
}
return res.status(200).json({ success: true, data: config });
} catch (error) {
next(error);
}
}
test = async (req, res, next) => {
try {
let configData = { ...req.body };
if (req.context.tenantId) {
const currentConfig = await service.getByTenantId(req.context.tenantId);
// If access or secret key is submitted as masked, preserve the existing value for testing
if (currentConfig) {
if (configData.access_key === '********************') {
configData.access_key = currentConfig.access_key;
}
if (configData.secret_key === '********************') {
configData.secret_key = currentConfig.secret_key;
}
}
}
const result = await service.testConnection(configData);
return res.status(200).json(result);
} catch (error) {
next(error);
}
}
delete = async (req, res, next) => {
try {
if (!req.context.tenantId) {
return res.status(400).json({ success: false, message: 'No tenant context to delete config' });
}
await service.deleteConfig(req.context.tenantId, req.context);
return res.status(200).json({ success: true, message: 'File server configuration deleted successfully' });
} catch (error) {
next(error);
}
}
}
export default new FileServerConfigController();
@@ -0,0 +1,65 @@
import { Model, DataTypes } from 'sequelize';
export class FileServerConfig extends Model {
static associate(models) {
FileServerConfig.belongsTo(models.Tenant, {
foreignKey: 'tenant_id',
as: 'tenant',
onDelete: 'CASCADE'
});
}
}
export default (sequelize) => {
FileServerConfig.init({
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: false,
unique: true
},
provider: {
type: DataTypes.STRING(50),
allowNull: false,
defaultValue: 's3'
},
endpoint: {
type: DataTypes.STRING(255),
allowNull: false
},
access_key: {
type: DataTypes.STRING(255),
allowNull: false
},
secret_key: {
type: DataTypes.STRING(255),
allowNull: false
},
bucket_name: {
type: DataTypes.STRING(150),
allowNull: false
},
region: {
type: DataTypes.STRING(50),
allowNull: true
},
status: {
type: DataTypes.STRING(20),
allowNull: false,
defaultValue: 'active'
}
}, {
sequelize,
modelName: 'FileServerConfig',
tableName: 'file_server_configs',
timestamps: true,
underscored: true
});
return FileServerConfig;
};
@@ -0,0 +1,29 @@
import { models } from '../../../../shared/database/models.js';
export class FileServerConfigRepository {
async findByTenantId(tenantId, options = {}) {
return await models.FileServerConfig.findOne({
where: { tenant_id: tenantId },
...options
});
}
async create(data, options = {}) {
return await models.FileServerConfig.create(data, options);
}
async update(id, data, options = {}) {
const record = await models.FileServerConfig.findByPk(id, options);
if (!record) return null;
return await record.update(data, options);
}
async delete(id, options = {}) {
const record = await models.FileServerConfig.findByPk(id, options);
if (!record) return false;
await record.destroy(options);
return true;
}
}
export default new FileServerConfigRepository();
@@ -0,0 +1,42 @@
import { Router } from 'express';
import controller from '../controllers/fileServerConfig.controller.js';
import { authenticate } from '../../../../shared/middleware/auth.middleware.js';
import { validate } from '../../../../shared/middleware/validation.middleware.js';
import { authorize } from '../../../../shared/middleware/permission.middleware.js';
import { saveValidation, testValidation } from '../validators/fileServerConfig.validation.js';
const router = Router();
router.get(
'/',
authenticate,
authorize(['settings.file_server']),
controller.get
);
router.post(
'/',
authenticate,
authorize(['settings.file_server']),
saveValidation,
validate,
controller.save
);
router.post(
'/test',
authenticate,
authorize(['settings.file_server']),
testValidation,
validate,
controller.test
);
router.delete(
'/',
authenticate,
authorize(['settings.file_server']),
controller.delete
);
export default router;
@@ -0,0 +1,97 @@
import repository from '../repositories/fileServerConfig.repository.js';
import { S3Service } from '../../../../shared/services/s3.service.js';
import { AuditService } from '../../../../shared/services/audit.service.js';
import { ApiError } from '../../../../utils/helpers/ApiError.utils.js';
export class FileServerConfigService {
async getByTenantId(tenantId) {
if (!tenantId) {
throw new ApiError(400, 'Tenant context is missing');
}
const record = await repository.findByTenantId(tenantId);
return record;
}
async saveConfig(tenantId, data, userContext = {}) {
if (!tenantId) {
throw new ApiError(400, 'Tenant context is missing');
}
let record = await repository.findByTenantId(tenantId);
if (record) {
// Update existing config
record = await repository.update(record.id, {
provider: data.provider,
endpoint: data.endpoint,
access_key: data.access_key,
secret_key: data.secret_key,
bucket_name: data.bucket_name,
region: data.region || null,
status: data.status || 'active'
});
await AuditService.log({
action: 'UPDATE_FILE_SERVER',
resource: 'FileServerConfig',
resourceId: record.id,
userId: userContext.userId || 'system',
details: { provider: data.provider, endpoint: data.endpoint, bucket_name: data.bucket_name }
});
} else {
// Create new config
record = await repository.create({
tenant_id: tenantId,
provider: data.provider,
endpoint: data.endpoint,
access_key: data.access_key,
secret_key: data.secret_key,
bucket_name: data.bucket_name,
region: data.region || null,
status: data.status || 'active'
});
await AuditService.log({
action: 'CREATE_FILE_SERVER',
resource: 'FileServerConfig',
resourceId: record.id,
userId: userContext.userId || 'system',
details: { provider: data.provider, endpoint: data.endpoint, bucket_name: data.bucket_name }
});
}
return record;
}
async testConnection(data) {
try {
await S3Service.testConnection(data);
return { success: true, message: 'Connection to file server was successful' };
} catch (error) {
throw new ApiError(400, `Connection test failed: ${error.message}`);
}
}
async deleteConfig(tenantId, userContext = {}) {
if (!tenantId) {
throw new ApiError(400, 'Tenant context is missing');
}
const record = await repository.findByTenantId(tenantId);
if (!record) {
throw new ApiError(404, 'No file server configuration found');
}
await repository.delete(record.id);
await AuditService.log({
action: 'DELETE_FILE_SERVER',
resource: 'FileServerConfig',
resourceId: record.id,
userId: userContext.userId || 'system'
});
return true;
}
}
export default new FileServerConfigService();
@@ -0,0 +1,61 @@
import { body } from 'express-validator';
export const saveValidation = [
body('provider')
.isIn(['s3', 'minio'])
.withMessage('Provider must be either s3 or minio'),
body('endpoint')
.isString()
.trim()
.notEmpty()
.withMessage('Endpoint is required'),
body('access_key')
.isString()
.trim()
.notEmpty()
.withMessage('Access Key is required'),
body('secret_key')
.isString()
.trim()
.notEmpty()
.withMessage('Secret Key is required'),
body('bucket_name')
.isString()
.trim()
.notEmpty()
.withMessage('Bucket Name is required'),
body('region')
.optional()
.isString()
.trim()
];
export const testValidation = [
body('provider')
.isIn(['s3', 'minio'])
.withMessage('Provider must be either s3 or minio'),
body('endpoint')
.isString()
.trim()
.notEmpty()
.withMessage('Endpoint is required'),
body('access_key')
.isString()
.trim()
.notEmpty()
.withMessage('Access Key is required'),
body('secret_key')
.isString()
.trim()
.notEmpty()
.withMessage('Secret Key is required'),
body('bucket_name')
.isString()
.trim()
.notEmpty()
.withMessage('Bucket Name is required'),
body('region')
.optional()
.isString()
.trim()
];
+2
View File
@@ -1,10 +1,12 @@
import { Router } from 'express';
import settingsRouter from './settings/setting.routes.js';
import themeRouter from './theme/routes/theme.routes.js';
import fileServerRouter from './fileServer/routes/fileServerConfig.routes.js';
const router = Router();
router.use('/settings/theme', themeRouter);
router.use('/settings/file-server', fileServerRouter);
router.use('/settings', settingsRouter);
export default router;
@@ -45,6 +45,24 @@ export class SettingController {
next(error);
}
}
async getByCategory(req, res, next) {
try {
const data = await service.getCategorySettings(req.params.category, req.user);
return res.status(200).json({ success: true, data });
} catch (error) {
next(error);
}
}
async updateByCategory(req, res, next) {
try {
const data = await service.upsertCategorySettings(req.params.category, req.body, req.user);
return res.status(200).json({ success: true, message: 'Settings saved successfully', data });
} catch (error) {
next(error);
}
}
}
export default new SettingController();
@@ -14,6 +14,23 @@ export default (sequelize) => {
primaryKey: true,
allowNull: false
},
tenant_id: {
type: DataTypes.INTEGER,
allowNull: true
},
category: {
type: DataTypes.STRING(50),
allowNull: false,
defaultValue: 'general'
},
key: {
type: DataTypes.STRING(100),
allowNull: true
},
value: {
type: DataTypes.JSONB,
allowNull: true
},
name: {
type: DataTypes.STRING,
allowNull: true
@@ -29,6 +29,18 @@ router.get(
controller.getAll
);
router.get(
'/by-category/:category',
authenticate,
controller.getByCategory
);
router.put(
'/by-category/:category',
authenticate,
controller.updateByCategory
);
/**
* @swagger
* /api/v1/settings/{id}:

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