Compare commits
46
Commits
hasan_backend
...
zaid
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
537f94041f | ||
|
|
70ce1b9efe | ||
|
|
202c5280ca | ||
|
|
c4b1caba94 | ||
|
|
189fe338f5 | ||
|
|
891b8157bc | ||
|
|
4d419dcd36 | ||
|
|
cc0491939c | ||
|
|
b0c1c9e210 | ||
|
|
867eba1205 | ||
|
|
01e7ca58e8 | ||
|
|
dac030f4ef | ||
|
|
bb055fa794 | ||
|
|
704339380d | ||
|
|
6610c0c4a7 | ||
|
|
6e49ded9c4 | ||
|
|
943e706f8d | ||
|
|
acd58f1624 | ||
|
|
a36880c27b | ||
|
|
16ca868c5c | ||
|
|
e566ac14b1 | ||
|
|
90922a8603 | ||
|
|
e7c50e15b7 | ||
|
|
1ae50f173f | ||
|
|
66a55deb0d | ||
|
|
c5821f8015 | ||
|
|
6cdef6da32 | ||
|
|
b2b126590f | ||
|
|
6903836547 | ||
|
|
5f5fdfe09a | ||
|
|
e958037f66 | ||
|
|
90a6c3238c | ||
|
|
98163512d8 | ||
|
|
012af1b349 | ||
|
|
929660fd74 | ||
|
|
31f8dc1b71 | ||
|
|
c249efd81e | ||
|
|
9be6902d6b | ||
|
|
95b0753f30 | ||
|
|
24caaaf93f | ||
|
|
a55b03568d | ||
|
|
2fff621fdd | ||
|
|
46abddf81c | ||
|
|
79a3b47be5 | ||
|
|
6f18030f88 | ||
|
|
dace58ad6c |
+18
-11
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
Generated
+422
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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()
|
||||
];
|
||||
@@ -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
Reference in New Issue
Block a user