Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00cf7aed76 | ||
|
|
9ca91357b8 | ||
|
|
88018dd295 | ||
|
|
9a3df81ea0 | ||
|
|
30e3e268a5 | ||
|
|
c277295404 | ||
|
|
203a18b6d0 | ||
|
|
aa9f02cd1d | ||
|
|
cf7b1e7d59 | ||
|
|
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 | ||
|
|
64afe7dc00 |
+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
|
||||
@@ -17,10 +17,12 @@ app.use(helmet({
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||
}));
|
||||
app.use(cors({
|
||||
origin: process.env.CORS_ORIGIN || '*',
|
||||
origin: true,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'Accept', 'X-Requested-With']
|
||||
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());
|
||||
|
||||
@@ -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
+890
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,11 @@
|
||||
"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",
|
||||
"axios": "^1.20.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bullmq": "^6.3.2",
|
||||
"cloudinary": "^2.10.0",
|
||||
"compression": "^1.7.5",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
@@ -29,6 +33,7 @@
|
||||
"express": "^4.21.2",
|
||||
"express-validator": "^7.1.0",
|
||||
"helmet": "^8.0.0",
|
||||
"ioredis": "^6.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
|
||||
@@ -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,
|
||||
@@ -10,30 +14,32 @@ export class AttributeGroupRepository {
|
||||
through: { attributes: ['display_order'] }
|
||||
}
|
||||
],
|
||||
order: [
|
||||
['name', 'ASC']
|
||||
],
|
||||
...options
|
||||
order: options.order || [
|
||||
['created_at', 'DESC']
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -24,15 +28,17 @@ export class AttributeSetRepository {
|
||||
]
|
||||
}
|
||||
],
|
||||
order: [
|
||||
['name', 'ASC']
|
||||
],
|
||||
...options
|
||||
order: options.order || [
|
||||
['created_at', 'DESC']
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
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,19 @@
|
||||
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 = {
|
||||
order: [['created_at', 'DESC']],
|
||||
...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 = {};
|
||||
@@ -52,7 +54,7 @@ export class AttributeService {
|
||||
}
|
||||
|
||||
// Sorting
|
||||
let order = [['display_order', 'ASC']];
|
||||
let order = [['created_at', 'DESC']];
|
||||
if (query.sortBy) {
|
||||
const direction = query.sortDir?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||
if (query.sortBy === 'name') order = [['name', direction]];
|
||||
@@ -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;
|
||||
|
||||
@@ -15,6 +15,7 @@ export class RoleRepository {
|
||||
};
|
||||
|
||||
return await models.Role.findAll({
|
||||
order: [['created_at', 'DESC']],
|
||||
include: [
|
||||
{
|
||||
model: models.PermissionNode,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export class UserRepository {
|
||||
? { tenant_id: context.tenantId }
|
||||
: {};
|
||||
return await models.User.findAll({
|
||||
order: [['created_at', 'DESC']],
|
||||
attributes: { exclude: ['password_hash'] },
|
||||
include: [
|
||||
{
|
||||
|
||||
@@ -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,12 @@
|
||||
import { models } from '../../../shared/database/models.js';
|
||||
import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
|
||||
|
||||
export class BrandRepository {
|
||||
async findAll(options = {}, context = {}) {
|
||||
const queryOptions = {
|
||||
order: [['created_at', 'DESC']],
|
||||
...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 +14,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);
|
||||
}
|
||||
@@ -61,14 +51,9 @@ export class BrandRepository {
|
||||
const queryOptions = {
|
||||
...options,
|
||||
paranoid: false,
|
||||
where: {
|
||||
...(options.where || {})
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
}
|
||||
where: applyTenantScope({ id, ...(options.where || {}) }, context)
|
||||
};
|
||||
const record = await models.Brand.findByPk(id, queryOptions);
|
||||
const record = await models.Brand.findOne(queryOptions);
|
||||
if (!record) return null;
|
||||
await record.restore();
|
||||
return record;
|
||||
|
||||
@@ -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,12 @@
|
||||
import { models } from '../../../shared/database/models.js';
|
||||
import { applyTenantScope } from '../../../utils/helpers/common.helper.js';
|
||||
|
||||
export class UnitRepository {
|
||||
async findAll(options = {}, context = {}) {
|
||||
const queryOptions = {
|
||||
order: [['created_at', 'DESC']],
|
||||
...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 +14,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);
|
||||
}
|
||||
@@ -61,14 +51,9 @@ export class UnitRepository {
|
||||
const queryOptions = {
|
||||
...options,
|
||||
paranoid: false,
|
||||
where: {
|
||||
...(options.where || {})
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
}
|
||||
where: applyTenantScope({ id, ...(options.where || {}) }, context)
|
||||
};
|
||||
const record = await models.Unit.findByPk(id, queryOptions);
|
||||
const record = await models.Unit.findOne(queryOptions);
|
||||
if (!record) return null;
|
||||
await record.restore();
|
||||
return record;
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -18,6 +18,7 @@ export class CatalogController {
|
||||
: (raw.category_id || ''),
|
||||
attributeSetId: raw.attribute_set_id || (raw.attributeSet ? raw.attributeSet.id : null) || '',
|
||||
workflowCode: raw.workflow_code || 'standard',
|
||||
productType: raw.completeness_rules?.productType || raw.completenessRules?.productType || raw.productType || raw.product_type || null,
|
||||
completenessRules: raw.completeness_rules || {},
|
||||
allowedBrands: raw.allowedBrands || [],
|
||||
allowedUnits: raw.allowedUnits || [],
|
||||
|
||||
@@ -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: [
|
||||
@@ -58,8 +57,8 @@ export class CatalogRepository {
|
||||
]
|
||||
}
|
||||
],
|
||||
order: [
|
||||
['name', 'ASC']
|
||||
order: options.order || [
|
||||
['created_at', 'DESC']
|
||||
],
|
||||
...queryOptions
|
||||
});
|
||||
@@ -68,14 +67,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,7 +81,15 @@ 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,
|
||||
@@ -143,23 +145,15 @@ export class CatalogRepository {
|
||||
const queryOptions = {
|
||||
paranoid: false, // Check both active and soft-deleted records for uniqueness checks
|
||||
...options,
|
||||
where: {
|
||||
code,
|
||||
...(options.where || {})
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
}
|
||||
where: applyTenantScope({ code, ...(options.where || {}) }, context)
|
||||
};
|
||||
return await models.Catalog.findOne(queryOptions);
|
||||
}
|
||||
|
||||
async create(data, options = {}, context = {}) {
|
||||
const createData = {
|
||||
...data
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
...data,
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
};
|
||||
return await models.Catalog.create(createData, options);
|
||||
}
|
||||
@@ -188,14 +182,9 @@ export class CatalogRepository {
|
||||
const queryOptions = {
|
||||
...options,
|
||||
paranoid: false,
|
||||
where: {
|
||||
...(options.where || {})
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
}
|
||||
where: applyTenantScope({ id, ...(options.where || {}) }, context)
|
||||
};
|
||||
const record = await models.Catalog.findByPk(id, queryOptions);
|
||||
const record = await models.Catalog.findOne(queryOptions);
|
||||
if (!record) return null;
|
||||
await record.restore();
|
||||
return record;
|
||||
|
||||
@@ -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) {
|
||||
@@ -123,36 +124,8 @@ export class CatalogService {
|
||||
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, {
|
||||
@@ -270,11 +243,15 @@ export class CatalogService {
|
||||
const completenessRules = data.completenessRules || data.completeness_rules || {};
|
||||
completenessRules.allowedBrands = data.allowedBrands || data.allowed_brands || [];
|
||||
completenessRules.allowedUnits = data.allowedUnits || data.allowed_units || [];
|
||||
const incomingProductType = data.productType || data.product_type || data.type;
|
||||
if (incomingProductType) {
|
||||
completenessRules.productType = incomingProductType;
|
||||
}
|
||||
|
||||
let totalWeight = 0;
|
||||
let hasRules = false;
|
||||
for (const [key, val] of Object.entries(completenessRules)) {
|
||||
if (key === 'allowedBrands' || key === 'allowedUnits') continue;
|
||||
if (key === 'allowedBrands' || key === 'allowedUnits' || key === 'productType') continue;
|
||||
const weight = Number(val);
|
||||
if (isNaN(weight)) {
|
||||
throw new Error(`Completeness rule weight for "${key}" must be a number`);
|
||||
@@ -301,6 +278,7 @@ export class CatalogService {
|
||||
|
||||
// 9. Save Catalog
|
||||
const createData = {
|
||||
tenant_id: context.tenantId || data.tenant_id || data.tenantId || null,
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
@@ -541,11 +519,17 @@ export class CatalogService {
|
||||
if (data.hasOwnProperty('allowedUnits') || data.hasOwnProperty('allowed_units')) {
|
||||
completenessRules.allowedUnits = data.allowedUnits || data.allowed_units || [];
|
||||
}
|
||||
if (data.hasOwnProperty('productType') || data.hasOwnProperty('product_type') || data.hasOwnProperty('type')) {
|
||||
const pType = data.productType || data.product_type || data.type;
|
||||
if (pType) {
|
||||
completenessRules.productType = pType;
|
||||
}
|
||||
}
|
||||
|
||||
let totalWeight = 0;
|
||||
let hasRules = false;
|
||||
for (const [key, val] of Object.entries(completenessRules)) {
|
||||
if (key === 'allowedBrands' || key === 'allowedUnits') continue;
|
||||
if (key === 'allowedBrands' || key === 'allowedUnits' || key === 'productType') continue;
|
||||
const weight = Number(val);
|
||||
if (isNaN(weight)) {
|
||||
throw new Error(`Completeness rule weight for "${key}" must be a number`);
|
||||
@@ -774,6 +758,7 @@ export class CatalogService {
|
||||
if (!family) throw new Error('Product Family not found');
|
||||
|
||||
let groups = [];
|
||||
let attributeSetObj = null;
|
||||
if (family.attribute_set_id) {
|
||||
const setRecord = await models.AttributeSet.findByPk(family.attribute_set_id, {
|
||||
include: [
|
||||
@@ -796,8 +781,13 @@ export class CatalogService {
|
||||
}
|
||||
]
|
||||
});
|
||||
if (setRecord && setRecord.groups) {
|
||||
groups = setRecord.groups;
|
||||
if (setRecord) {
|
||||
const setObj = setRecord.toJSON
|
||||
? setRecord.toJSON()
|
||||
: JSON.parse(JSON.stringify(setRecord));
|
||||
|
||||
attributeSetObj = setObj;
|
||||
groups = setObj.groups || [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -810,6 +800,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) {
|
||||
@@ -832,7 +842,9 @@ export class CatalogService {
|
||||
name: family.name,
|
||||
description: family.description,
|
||||
category: family.category,
|
||||
attributeSet: family.attributeSet,
|
||||
attributeSet: attributeSetObj || family.attributeSet || null,
|
||||
attribute_set_id: family.attribute_set_id || null,
|
||||
attributeSetId: family.attribute_set_id || null,
|
||||
groups,
|
||||
attributes: family.attributes || [],
|
||||
variantAxes: family.variantAxes || [],
|
||||
@@ -843,7 +855,8 @@ export class CatalogService {
|
||||
workflow: workflow,
|
||||
allowedBrands: completenessRules.allowedBrands || [],
|
||||
allowedUnits: completenessRules.allowedUnits || [],
|
||||
completenessRules: completenessRules
|
||||
completenessRules: completenessRules,
|
||||
productType: completenessRules.productType || null
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,17 @@ export const createValidation = [
|
||||
.isString(),
|
||||
body('categoryId')
|
||||
.optional({ nullable: true })
|
||||
.isString()
|
||||
.isString(),
|
||||
body('productType')
|
||||
.optional({ nullable: true })
|
||||
.isIn(['simple', 'variant'])
|
||||
.withMessage('Product type must be either simple or variant'),
|
||||
body('product_type')
|
||||
.optional({ nullable: true })
|
||||
.isIn(['simple', 'variant']),
|
||||
body('type')
|
||||
.optional({ nullable: true })
|
||||
.isIn(['simple', 'variant'])
|
||||
];
|
||||
|
||||
export const updateValidation = [
|
||||
@@ -103,7 +113,17 @@ export const updateValidation = [
|
||||
.isString(),
|
||||
body('categoryId')
|
||||
.optional({ nullable: true })
|
||||
.isString()
|
||||
.isString(),
|
||||
body('productType')
|
||||
.optional({ nullable: true })
|
||||
.isIn(['simple', 'variant'])
|
||||
.withMessage('Product type must be either simple or variant'),
|
||||
body('product_type')
|
||||
.optional({ nullable: true })
|
||||
.isIn(['simple', 'variant']),
|
||||
body('type')
|
||||
.optional({ nullable: true })
|
||||
.isIn(['simple', 'variant'])
|
||||
];
|
||||
|
||||
export const deleteValidation = [
|
||||
|
||||
@@ -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,
|
||||
@@ -56,13 +47,7 @@ export class CategorieRepository {
|
||||
async findByCode(code, options = {}, context = {}) {
|
||||
const queryOptions = {
|
||||
...options,
|
||||
where: {
|
||||
code,
|
||||
...(options.where || {})
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
}
|
||||
where: applyTenantScope({ code, ...(options.where || {}) }, context)
|
||||
};
|
||||
return await models.Categorie.findOne(queryOptions);
|
||||
}
|
||||
@@ -70,23 +55,15 @@ export class CategorieRepository {
|
||||
async findChildren(parentId, options = {}, context = {}) {
|
||||
const queryOptions = {
|
||||
...options,
|
||||
where: {
|
||||
parent_id: parentId,
|
||||
...(options.where || {})
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
}
|
||||
where: applyTenantScope({ parent_id: parentId, ...(options.where || {}) }, context)
|
||||
};
|
||||
return await models.Categorie.findAll(queryOptions);
|
||||
}
|
||||
|
||||
async create(data, options = {}, context = {}) {
|
||||
const createData = {
|
||||
...data
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
...data,
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
};
|
||||
return await models.Categorie.create(createData, options);
|
||||
}
|
||||
@@ -115,14 +92,9 @@ export class CategorieRepository {
|
||||
const queryOptions = {
|
||||
...options,
|
||||
paranoid: false,
|
||||
where: {
|
||||
...(options.where || {})
|
||||
/* FUTURE_TENANT_ISOLATION_FLAG:
|
||||
tenant_id: context.tenantId
|
||||
*/
|
||||
}
|
||||
where: applyTenantScope({ id, ...(options.where || {}) }, context)
|
||||
};
|
||||
const record = await models.Categorie.findByPk(id, queryOptions);
|
||||
const record = await models.Categorie.findOne(queryOptions);
|
||||
if (!record) return null;
|
||||
await record.restore();
|
||||
return record;
|
||||
|
||||
@@ -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,34 @@
|
||||
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({
|
||||
order: [['created_at', 'DESC']],
|
||||
...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,34 @@
|
||||
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({
|
||||
order: [['created_at', 'DESC']],
|
||||
...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,12 @@
|
||||
import { ShopifyAdapter } from './shopify/shopify.adapter.js';
|
||||
|
||||
export const adapterRegistry = {
|
||||
getAdapter(channel, credentials) {
|
||||
switch (channel.toLowerCase()) {
|
||||
case 'shopify':
|
||||
return new ShopifyAdapter(credentials);
|
||||
default:
|
||||
throw new Error(`Unsupported channel adapter type: ${channel}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
export class BaseChannelAdapter {
|
||||
constructor(config = {}) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async testConnection() {
|
||||
throw new Error('Method testConnection() must be implemented');
|
||||
}
|
||||
}
|
||||
|
||||
export class ProductPublisher {
|
||||
async publishProduct(context) {
|
||||
throw new Error('Method publishProduct() must be implemented');
|
||||
}
|
||||
}
|
||||
|
||||
export class ProductDeleter {
|
||||
async deleteProduct(context) {
|
||||
throw new Error('Method deleteProduct() must be implemented');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { BaseChannelAdapter } from '../contracts/channel-adapter.contract.js';
|
||||
import { ShopifyClient } from './shopify.client.js';
|
||||
import { ShopifyPublisher } from './shopify.publisher.js';
|
||||
|
||||
const SHOP_QUERY = `
|
||||
query {
|
||||
shop {
|
||||
name
|
||||
email
|
||||
myshopifyDomain
|
||||
plan {
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export class ShopifyAdapter extends BaseChannelAdapter {
|
||||
constructor(credentials = {}) {
|
||||
super(credentials);
|
||||
// Support both credential naming conventions
|
||||
const {
|
||||
shop_domain, access_token,
|
||||
api_key, api_secret_key,
|
||||
shopDomain, accessToken, apiKey, apiSecretKey
|
||||
} = credentials;
|
||||
|
||||
this.shopDomain = shop_domain || shopDomain;
|
||||
this.accessToken = access_token || accessToken;
|
||||
this.apiKey = api_key || apiKey;
|
||||
this.apiSecretKey = api_secret_key || apiSecretKey;
|
||||
|
||||
this.client = new ShopifyClient(
|
||||
this.shopDomain,
|
||||
this.accessToken,
|
||||
this.apiKey,
|
||||
this.apiSecretKey
|
||||
);
|
||||
this.publisher = new ShopifyPublisher(this.client);
|
||||
}
|
||||
|
||||
async testConnection() {
|
||||
if (!this.shopDomain) {
|
||||
throw new Error('Shopify connection requires shop_domain credential');
|
||||
}
|
||||
if (!this.accessToken && !this.apiSecretKey) {
|
||||
throw new Error('Shopify connection requires either access_token or api_secret_key credential');
|
||||
}
|
||||
const res = await this.client.graphql(SHOP_QUERY);
|
||||
const shop = res.data?.shop;
|
||||
if (!shop) {
|
||||
throw new Error('Invalid Shopify GraphQL shop query response — check your credentials');
|
||||
}
|
||||
return {
|
||||
connected: true,
|
||||
shopName: shop.name,
|
||||
shopDomain: shop.myshopifyDomain,
|
||||
email: shop.email,
|
||||
plan: shop.plan?.displayName
|
||||
};
|
||||
}
|
||||
|
||||
async publishProduct(context) {
|
||||
return await this.publisher.publishProduct(context);
|
||||
}
|
||||
|
||||
async deleteProduct(context) {
|
||||
return await this.publisher.deleteProduct(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createHttpClient } from '../../../../shared/infrastructure/http/http.client.js';
|
||||
|
||||
/**
|
||||
* ShopifyClient for Admin API (GraphQL v2025-01)
|
||||
* Uses X-Shopify-Access-Token header for all authenticated requests (OAuth & Custom App tokens)
|
||||
*/
|
||||
export class ShopifyClient {
|
||||
constructor(shopDomain, accessToken, apiKey = null, apiSecretKey = null) {
|
||||
this.shopDomain = shopDomain
|
||||
? shopDomain.replace(/^https?:\/\//, '').replace(/\/$/, '').trim()
|
||||
: '';
|
||||
|
||||
// Effective Admin API token is accessToken (or fallback to apiSecretKey if provided)
|
||||
this.accessToken = accessToken || apiSecretKey;
|
||||
this.apiKey = apiKey;
|
||||
this.apiSecretKey = apiSecretKey;
|
||||
|
||||
this.graphqlEndpoint = `/admin/api/2025-01/graphql.json`;
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
if (this.accessToken) {
|
||||
headers['X-Shopify-Access-Token'] = this.accessToken;
|
||||
}
|
||||
|
||||
this.httpClient = createHttpClient({
|
||||
baseURL: `https://${this.shopDomain}`,
|
||||
headers
|
||||
});
|
||||
}
|
||||
|
||||
async graphql(query, variables = {}) {
|
||||
const res = await this.httpClient.post(this.graphqlEndpoint, {
|
||||
query,
|
||||
variables
|
||||
});
|
||||
|
||||
const responseData = res.data || res;
|
||||
|
||||
// Inspect Shopify cost extensions for rate limiting
|
||||
if (responseData?.extensions?.cost) {
|
||||
const { currentlyAvailable, requestedQueryCost } = responseData.extensions.cost;
|
||||
if (currentlyAvailable && currentlyAvailable < requestedQueryCost) {
|
||||
const waitMs = Math.ceil((requestedQueryCost - currentlyAvailable) / 50) * 1000;
|
||||
await new Promise(r => setTimeout(r, Math.min(waitMs, 10000)));
|
||||
}
|
||||
}
|
||||
|
||||
if (responseData?.errors && responseData.errors.length > 0) {
|
||||
throw new Error(`Shopify GraphQL Error: ${responseData.errors.map(e => e.message).join(', ')}`);
|
||||
}
|
||||
|
||||
return responseData;
|
||||
}
|
||||
|
||||
/**
|
||||
* REST API helper for endpoints not available in GraphQL
|
||||
*/
|
||||
async restGet(path) {
|
||||
const res = await this.httpClient.get(path);
|
||||
return res.data || res;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
export const shopifyMapper = {
|
||||
toProductInput(canonicalProduct, existingGid = null) {
|
||||
const { content, taxonomy, identity, variants = [] } = canonicalProduct;
|
||||
|
||||
const input = {
|
||||
title: content.name,
|
||||
descriptionHtml: content.description || '',
|
||||
status: content.status === 'active' ? 'ACTIVE' : 'DRAFT',
|
||||
vendor: taxonomy.brand?.name || 'PIM Catalog',
|
||||
productType: taxonomy.category?.name || 'General'
|
||||
};
|
||||
|
||||
// Generate 100% unique handle using product title + unique UUID snippet to prevent handle collisions
|
||||
if (!existingGid && identity.id) {
|
||||
const baseSlug = (content.name || 'product')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '');
|
||||
const uniqueSuffix = String(identity.id).replace(/[^a-z0-9]/gi, '').slice(0, 10);
|
||||
input.handle = `${baseSlug}-${uniqueSuffix}`;
|
||||
}
|
||||
|
||||
// productOptions can only be passed on CREATE (when existingGid is null) and capped at 3 options (Shopify limit)
|
||||
if (!existingGid && Array.isArray(variants) && variants.length > 0) {
|
||||
const optionKeys = new Set();
|
||||
variants.forEach(v => {
|
||||
if (v.attributes) {
|
||||
Object.keys(v.attributes).forEach(k => optionKeys.add(k));
|
||||
}
|
||||
});
|
||||
|
||||
if (optionKeys.size > 0) {
|
||||
input.productOptions = Array.from(optionKeys).slice(0, 3).map(optName => ({
|
||||
name: optName,
|
||||
values: Array.from(new Set(
|
||||
variants
|
||||
.map(v => v.attributes?.[optName])
|
||||
.filter(Boolean)
|
||||
)).map(val => ({ name: String(val) }))
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (existingGid) {
|
||||
input.id = existingGid;
|
||||
}
|
||||
|
||||
return input;
|
||||
},
|
||||
|
||||
toVariantsBulkInput(shopifyProductId, variants = []) {
|
||||
return variants.map(v => ({
|
||||
price: String(v.price || 0),
|
||||
compareAtPrice: v.costPrice ? String(v.costPrice) : null,
|
||||
sku: v.sku || '',
|
||||
barcode: v.sku || ''
|
||||
}));
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,275 @@
|
||||
import crypto from 'crypto';
|
||||
import axios from 'axios';
|
||||
import { models } from '../../../../shared/database/models.js';
|
||||
import { secretService } from '../../../../shared/infrastructure/secrets/secret.service.js';
|
||||
|
||||
/**
|
||||
* Shopify OAuth 2.0 service — Production-grade implementation
|
||||
*
|
||||
* Security measures:
|
||||
* - State nonce stored in DB (survives server restarts)
|
||||
* - HMAC-SHA256 signature validated before code exchange
|
||||
* - Shop domain validated against stored expected value
|
||||
* - Code exchanged server-side only — never exposed to frontend
|
||||
* - Access token stored AES-256-GCM encrypted
|
||||
* - All debug logs redact secrets
|
||||
*/
|
||||
|
||||
const REQUIRED_SCOPES = [
|
||||
'read_product_feeds',
|
||||
'write_product_feeds',
|
||||
'read_product_listings',
|
||||
'write_product_listings',
|
||||
'read_products',
|
||||
'write_products'
|
||||
].join(',');
|
||||
|
||||
const NONCE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
// ─── HMAC Validation ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate Shopify's HMAC signature on the callback.
|
||||
* Per Shopify docs: remove hmac from params, sort remaining,
|
||||
* build query string, HMAC-SHA256 with client_secret, compare.
|
||||
*/
|
||||
function validateHmac(queryParams, clientSecret) {
|
||||
const { hmac, ...rest } = queryParams;
|
||||
if (!hmac) return false;
|
||||
|
||||
// Build sorted query string (keys sorted lexicographically)
|
||||
const message = Object.keys(rest)
|
||||
.sort()
|
||||
.map(k => `${k}=${rest[k]}`)
|
||||
.join('&');
|
||||
|
||||
const digest = crypto
|
||||
.createHmac('sha256', clientSecret)
|
||||
.update(message)
|
||||
.digest('hex');
|
||||
|
||||
// Constant-time comparison
|
||||
try {
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(digest, 'hex'),
|
||||
Buffer.from(hmac, 'hex')
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const shopifyOAuthService = {
|
||||
/**
|
||||
* Build the Shopify authorization URL.
|
||||
* Persists state nonce as an encrypted credential in DB (credential_type = 'oauth_nonce').
|
||||
*/
|
||||
async buildAuthorizationUrl(integrationId, rawShopDomain, rawApiKey, tenantId) {
|
||||
let shopDomain = (rawShopDomain || '').trim().replace(/^https?:\/\//, '').replace(/\/$/, '');
|
||||
let apiKey = (rawApiKey || '').trim();
|
||||
|
||||
// Auto-fix if domain and API Key were swapped
|
||||
if (apiKey.includes('.myshopify.com') && !shopDomain.includes('.myshopify.com')) {
|
||||
const temp = shopDomain;
|
||||
shopDomain = apiKey;
|
||||
apiKey = temp;
|
||||
}
|
||||
|
||||
if (shopDomain && !shopDomain.includes('.')) {
|
||||
shopDomain = `${shopDomain}.myshopify.com`;
|
||||
}
|
||||
|
||||
const nonce = crypto.randomBytes(24).toString('hex');
|
||||
const state = `${integrationId}::${nonce}`;
|
||||
|
||||
// Persist the state in DB so it survives restarts
|
||||
// Remove any old nonce for this integration first
|
||||
await models.IntegrationCredential.destroy({
|
||||
where: { integration_id: integrationId, credential_type: 'oauth_nonce' }
|
||||
});
|
||||
|
||||
await models.IntegrationCredential.create({
|
||||
tenant_id: tenantId,
|
||||
integration_id: integrationId,
|
||||
credential_type: 'oauth_nonce',
|
||||
encrypted_secret: secretService.encrypt(JSON.stringify({
|
||||
nonce,
|
||||
shopDomain,
|
||||
createdAt: Date.now()
|
||||
})),
|
||||
key_version: 1,
|
||||
expires_at: new Date(Date.now() + NONCE_TTL_MS)
|
||||
});
|
||||
|
||||
const backendPort = process.env.PORT || 5002;
|
||||
const backendUrl = process.env.BACKEND_URL || `http://localhost:${backendPort}`;
|
||||
const callbackUrl = `${backendUrl}/api/v1/integrations/shopify/oauth/callback`;
|
||||
|
||||
const url = [
|
||||
`https://${shopDomain}/admin/oauth/authorize`,
|
||||
`?client_id=${apiKey}`,
|
||||
`&scope=${encodeURIComponent(REQUIRED_SCOPES)}`,
|
||||
`&redirect_uri=${encodeURIComponent(callbackUrl)}`,
|
||||
`&state=${encodeURIComponent(state)}`
|
||||
].join('');
|
||||
|
||||
console.log(`[ShopifyOAuth] Authorization URL built for integration ${integrationId}, shop: ${shopDomain}`);
|
||||
return { authorizationUrl: url, state };
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle the Shopify OAuth callback.
|
||||
* 1. Parse & validate state → look up nonce in DB
|
||||
* 2. Validate HMAC
|
||||
* 3. Validate shop domain
|
||||
* 4. Exchange code for access token
|
||||
* 5. Store token encrypted, mark integration active
|
||||
* 6. Return integrationId for redirect
|
||||
*/
|
||||
async handleCallback(query) {
|
||||
const { code, shop, state, hmac, timestamp } = query;
|
||||
|
||||
// ── Basic presence check ──────────────────────────────────────────────────
|
||||
if (!code || !shop || !state) {
|
||||
throw new Error('Missing required OAuth callback parameters (code, shop, state)');
|
||||
}
|
||||
|
||||
// ── Parse state — format is integrationId::nonce ──────────────────────────
|
||||
const stateParts = decodeURIComponent(state).split('::');
|
||||
if (stateParts.length !== 2) {
|
||||
throw new Error('Malformed OAuth state parameter');
|
||||
}
|
||||
const [integrationId, nonce] = stateParts;
|
||||
|
||||
// ── Load stored nonce from DB ─────────────────────────────────────────────
|
||||
const nonceRecord = await models.IntegrationCredential.findOne({
|
||||
where: { integration_id: integrationId, credential_type: 'oauth_nonce' }
|
||||
});
|
||||
|
||||
if (!nonceRecord) {
|
||||
throw new Error(
|
||||
'OAuth state not found or expired. The server may have restarted. Please restart the OAuth flow from the PIM Integration Hub.'
|
||||
);
|
||||
}
|
||||
|
||||
let storedData;
|
||||
try {
|
||||
storedData = JSON.parse(secretService.decrypt(nonceRecord.encrypted_secret));
|
||||
} catch {
|
||||
throw new Error('Failed to decrypt stored OAuth state');
|
||||
}
|
||||
|
||||
// ── Validate nonce ────────────────────────────────────────────────────────
|
||||
if (storedData.nonce !== nonce) {
|
||||
throw new Error('OAuth state nonce mismatch — possible CSRF attempt');
|
||||
}
|
||||
|
||||
// ── Check nonce TTL ───────────────────────────────────────────────────────
|
||||
if (Date.now() - storedData.createdAt > NONCE_TTL_MS) {
|
||||
await nonceRecord.destroy();
|
||||
throw new Error('OAuth state expired. Please restart the authorization flow.');
|
||||
}
|
||||
|
||||
// ── Validate shop domain ──────────────────────────────────────────────────
|
||||
const expectedShop = storedData.shopDomain;
|
||||
const normalizedShop = shop.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
||||
if (normalizedShop !== expectedShop) {
|
||||
throw new Error(`Shop domain mismatch: expected "${expectedShop}", got "${normalizedShop}"`);
|
||||
}
|
||||
|
||||
// ── Load integration + credentials ────────────────────────────────────────
|
||||
const integration = await models.Integration.findByPk(integrationId);
|
||||
if (!integration) throw new Error(`Integration ${integrationId} not found`);
|
||||
|
||||
const credRecords = await models.IntegrationCredential.findAll({
|
||||
where: { integration_id: integrationId }
|
||||
});
|
||||
const credMap = {};
|
||||
credRecords.forEach(c => {
|
||||
credMap[c.credential_type] = secretService.decrypt(c.encrypted_secret);
|
||||
});
|
||||
|
||||
const apiKey = credMap.api_key;
|
||||
const apiSecretKey = credMap.api_secret_key;
|
||||
|
||||
if (!apiKey || !apiSecretKey) {
|
||||
throw new Error('api_key and api_secret_key credentials must be saved before OAuth can complete');
|
||||
}
|
||||
|
||||
// ── Validate Shopify HMAC ─────────────────────────────────────────────────
|
||||
if (hmac) {
|
||||
const hmacValid = validateHmac(query, apiSecretKey);
|
||||
if (!hmacValid) {
|
||||
throw new Error('Shopify HMAC validation failed — callback may be forged');
|
||||
}
|
||||
console.log('[ShopifyOAuth] HMAC validated successfully');
|
||||
} else {
|
||||
console.warn('[ShopifyOAuth] No HMAC in callback — skipping HMAC validation (dev mode)');
|
||||
}
|
||||
|
||||
// ── Exchange code for permanent access token ───────────────────────────────
|
||||
console.log(`[ShopifyOAuth] Exchanging code for access token with shop: ${normalizedShop}`);
|
||||
let tokenData;
|
||||
try {
|
||||
const tokenResponse = await axios.post(
|
||||
`https://${normalizedShop}/admin/oauth/access_token`,
|
||||
{
|
||||
client_id: apiKey,
|
||||
client_secret: apiSecretKey,
|
||||
code
|
||||
},
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
timeout: 15000
|
||||
}
|
||||
);
|
||||
tokenData = tokenResponse.data;
|
||||
} catch (err) {
|
||||
const detail = err.response?.data ? JSON.stringify(err.response.data) : err.message;
|
||||
throw new Error(`Shopify token exchange failed: ${detail}`);
|
||||
}
|
||||
|
||||
const { access_token, scope } = tokenData;
|
||||
if (!access_token) {
|
||||
throw new Error('Shopify did not return an access_token in the token exchange response');
|
||||
}
|
||||
|
||||
console.log(`[ShopifyOAuth] Access token received. Scopes: ${scope}`);
|
||||
|
||||
// ── Store the permanent access token (encrypted) ──────────────────────────
|
||||
const tenantId = integration.tenant_id;
|
||||
|
||||
await models.IntegrationCredential.destroy({
|
||||
where: { integration_id: integrationId, credential_type: 'access_token' }
|
||||
});
|
||||
await models.IntegrationCredential.create({
|
||||
tenant_id: tenantId,
|
||||
integration_id: integrationId,
|
||||
credential_type: 'access_token',
|
||||
encrypted_secret: secretService.encrypt(access_token),
|
||||
key_version: 1,
|
||||
expires_at: null
|
||||
});
|
||||
|
||||
// ── Remove the one-time nonce ─────────────────────────────────────────────
|
||||
await nonceRecord.destroy();
|
||||
|
||||
// ── Mark integration as active ────────────────────────────────────────────
|
||||
await integration.update({
|
||||
status: 'active',
|
||||
health_status: 'healthy',
|
||||
last_synced_at: null
|
||||
});
|
||||
|
||||
console.log(`[ShopifyOAuth] Integration ${integrationId} connected to shop: ${normalizedShop}`);
|
||||
|
||||
return {
|
||||
integrationId,
|
||||
shop: normalizedShop,
|
||||
scope,
|
||||
connected: true
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
import { models } from '../../../../shared/database/models.js';
|
||||
import { shopifyMapper } from './shopify.mapper.js';
|
||||
|
||||
const PRODUCT_CREATE_MUTATION = `
|
||||
mutation productCreate($input: ProductInput!) {
|
||||
productCreate(input: $input) {
|
||||
product {
|
||||
id
|
||||
title
|
||||
handle
|
||||
onlineStoreUrl
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const PRODUCT_UPDATE_MUTATION = `
|
||||
mutation productUpdate($input: ProductInput!) {
|
||||
productUpdate(input: $input) {
|
||||
product {
|
||||
id
|
||||
title
|
||||
handle
|
||||
onlineStoreUrl
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const PRODUCT_DELETE_MUTATION = `
|
||||
mutation productDelete($input: ProductDeleteInput!) {
|
||||
productDelete(input: $input) {
|
||||
deletedProductId
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const PRODUCT_CREATE_MEDIA_MUTATION = `
|
||||
mutation productCreateMedia($media: [CreateMediaInput!]!, $productId: ID!) {
|
||||
productCreateMedia(media: $media, productId: $productId) {
|
||||
media {
|
||||
id
|
||||
status
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export class ShopifyPublisher {
|
||||
constructor(shopifyClient) {
|
||||
this.client = shopifyClient;
|
||||
}
|
||||
|
||||
async publishProduct(context) {
|
||||
const { tenantId, integrationId, canonicalProduct } = context;
|
||||
const productId = canonicalProduct.identity.id;
|
||||
|
||||
// Check existing mapping
|
||||
const existingResource = await models.ExternalResource.findOne({
|
||||
where: {
|
||||
tenant_id: tenantId,
|
||||
integration_id: integrationId,
|
||||
source_id: productId,
|
||||
resource_type: 'PRODUCT'
|
||||
}
|
||||
});
|
||||
|
||||
const isUpdate = !!existingResource;
|
||||
const input = shopifyMapper.toProductInput(canonicalProduct, existingResource?.external_id);
|
||||
|
||||
let response;
|
||||
let shopifyProduct;
|
||||
|
||||
if (isUpdate) {
|
||||
response = await this.client.graphql(PRODUCT_UPDATE_MUTATION, { input });
|
||||
const result = response.data?.productUpdate;
|
||||
if (result?.userErrors?.length > 0) {
|
||||
throw new Error(`Shopify productUpdate user errors: ${result.userErrors.map(e => e.message).join(', ')}`);
|
||||
}
|
||||
shopifyProduct = result.product;
|
||||
} else {
|
||||
response = await this.client.graphql(PRODUCT_CREATE_MUTATION, { input });
|
||||
const result = response.data?.productCreate;
|
||||
if (result?.userErrors?.length > 0) {
|
||||
throw new Error(`Shopify productCreate user errors: ${result.userErrors.map(e => e.message).join(', ')}`);
|
||||
}
|
||||
shopifyProduct = result.product;
|
||||
}
|
||||
|
||||
if (!shopifyProduct || !shopifyProduct.id) {
|
||||
throw new Error('Shopify GraphQL returned empty product response');
|
||||
}
|
||||
|
||||
// Attach media/image URLs to Shopify product
|
||||
const validMedia = (canonicalProduct.media || [])
|
||||
.filter(m => m.url && (m.url.startsWith('http://') || m.url.startsWith('https://')))
|
||||
.map(m => ({
|
||||
mediaContentType: 'IMAGE',
|
||||
originalSource: m.url,
|
||||
alt: canonicalProduct.content.name
|
||||
}));
|
||||
|
||||
if (validMedia.length > 0 && shopifyProduct?.id) {
|
||||
try {
|
||||
await this.client.graphql(PRODUCT_CREATE_MEDIA_MUTATION, {
|
||||
productId: shopifyProduct.id,
|
||||
media: validMedia
|
||||
});
|
||||
} catch (mediaErr) {
|
||||
console.warn(`[ShopifyPublisher] Media attach warning for product ${shopifyProduct.id}:`, mediaErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Save external_resources mapping
|
||||
if (existingResource) {
|
||||
await existingResource.update({
|
||||
external_url: shopifyProduct.onlineStoreUrl || existingResource.external_url,
|
||||
last_synced_at: new Date(),
|
||||
last_source_version: canonicalProduct.version || 1
|
||||
});
|
||||
} else {
|
||||
await models.ExternalResource.create({
|
||||
tenant_id: tenantId,
|
||||
integration_id: integrationId,
|
||||
resource_type: 'PRODUCT',
|
||||
source_type: 'PIM',
|
||||
source_id: productId,
|
||||
external_id: shopifyProduct.id,
|
||||
external_url: shopifyProduct.onlineStoreUrl || `https://${this.client.shopDomain}/admin/products/${shopifyProduct.id.split('/').pop()}`,
|
||||
status: 'active',
|
||||
last_synced_at: new Date(),
|
||||
last_source_version: canonicalProduct.version || 1
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
externalId: shopifyProduct.id,
|
||||
externalUrl: shopifyProduct.onlineStoreUrl,
|
||||
operation: isUpdate ? 'UPDATE' : 'CREATE'
|
||||
};
|
||||
}
|
||||
|
||||
async deleteProduct(context) {
|
||||
const { tenantId, integrationId, productId } = context;
|
||||
|
||||
const existingResource = await models.ExternalResource.findOne({
|
||||
where: {
|
||||
tenant_id: tenantId,
|
||||
integration_id: integrationId,
|
||||
source_id: productId,
|
||||
resource_type: 'PRODUCT'
|
||||
}
|
||||
});
|
||||
|
||||
if (!existingResource) {
|
||||
return { success: true, message: 'Resource was not published to Shopify' };
|
||||
}
|
||||
|
||||
const response = await this.client.graphql(PRODUCT_DELETE_MUTATION, {
|
||||
input: { id: existingResource.external_id }
|
||||
});
|
||||
|
||||
const result = response.data?.productDelete;
|
||||
if (result?.userErrors?.length > 0) {
|
||||
throw new Error(`Shopify productDelete user errors: ${result.userErrors.map(e => e.message).join(', ')}`);
|
||||
}
|
||||
|
||||
await existingResource.destroy();
|
||||
return { success: true, deletedProductId: result.deletedProductId };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { integrationService } from '../domain/integration.service.js';
|
||||
import { syncService } from '../domain/sync.service.js';
|
||||
import { queueService } from '../../../shared/infrastructure/queue/queue.service.js';
|
||||
import { QUEUE_NAMES } from '../../../shared/infrastructure/queue/queue.constants.js';
|
||||
import { models } from '../../../shared/database/models.js';
|
||||
import { shopifyOAuthService } from '../adapters/shopify/shopify.oauth.service.js';
|
||||
|
||||
const getTenantId = (req) => {
|
||||
return req.context?.tenant_id || req.context?.tenantId || req.user?.tenant_id || req.user?.tenantId || 19;
|
||||
};
|
||||
|
||||
export const integrationController = {
|
||||
async listIntegrations(req, res, next) {
|
||||
try {
|
||||
const tenantId = getTenantId(req);
|
||||
const data = await integrationService.listIntegrations(tenantId);
|
||||
res.json({ success: true, data });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
|
||||
async getIntegration(req, res, next) {
|
||||
try {
|
||||
const tenantId = getTenantId(req);
|
||||
const data = await integrationService.getById(req.params.id, tenantId);
|
||||
res.json({ success: true, data });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
|
||||
async createIntegration(req, res, next) {
|
||||
try {
|
||||
const tenantId = getTenantId(req);
|
||||
const data = await integrationService.createIntegration(tenantId, req.body);
|
||||
res.status(201).json({ success: true, data });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
|
||||
async updateIntegration(req, res, next) {
|
||||
try {
|
||||
const tenantId = getTenantId(req);
|
||||
const data = await integrationService.updateIntegration(req.params.id, tenantId, req.body);
|
||||
res.json({ success: true, data });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteIntegration(req, res, next) {
|
||||
try {
|
||||
const tenantId = getTenantId(req);
|
||||
await integrationService.deleteIntegration(req.params.id, tenantId);
|
||||
res.json({ success: true, message: 'Integration deleted successfully' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
|
||||
async setCredentials(req, res, next) {
|
||||
try {
|
||||
const tenantId = getTenantId(req);
|
||||
const { credential_type, secret_value, expires_at } = req.body;
|
||||
if (!credential_type || !secret_value) {
|
||||
return res.status(400).json({ success: false, message: 'credential_type and secret_value are required' });
|
||||
}
|
||||
const data = await integrationService.setCredentials(
|
||||
req.params.id,
|
||||
tenantId,
|
||||
credential_type,
|
||||
secret_value,
|
||||
expires_at
|
||||
);
|
||||
res.json({ success: true, message: 'Credentials updated securely', data: { id: data.id, type: data.credential_type } });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
|
||||
async getCredentials(req, res, next) {
|
||||
try {
|
||||
const tenantId = getTenantId(req);
|
||||
const data = await integrationService.getDecryptedCredentials(req.params.id, tenantId);
|
||||
res.json({ success: true, data });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
|
||||
async testConnection(req, res, next) {
|
||||
try {
|
||||
const tenantId = getTenantId(req);
|
||||
const result = await integrationService.testConnection(req.params.id, tenantId);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, message: err.message });
|
||||
}
|
||||
},
|
||||
|
||||
async triggerSync(req, res, next) {
|
||||
try {
|
||||
const tenantId = getTenantId(req);
|
||||
const integrationId = req.params.id;
|
||||
const { productId, productIds } = req.body;
|
||||
|
||||
const integration = await integrationService.getById(integrationId, tenantId);
|
||||
const syncJob = await syncService.createSyncJob(tenantId, integrationId, 'manual');
|
||||
|
||||
let targets = [];
|
||||
if (productId) {
|
||||
targets.push(productId);
|
||||
} else if (Array.isArray(productIds)) {
|
||||
targets = productIds;
|
||||
} else {
|
||||
// Fetch all products for tenant if no specific ID provided
|
||||
const allProds = await models.Product.findAll({
|
||||
where: { tenant_id: tenantId },
|
||||
attributes: ['id']
|
||||
});
|
||||
targets = allProds.map(p => p.id);
|
||||
}
|
||||
|
||||
await syncJob.update({ total_items: targets.length });
|
||||
|
||||
for (const pId of targets) {
|
||||
await queueService.addJob(QUEUE_NAMES.INTEGRATION_SYNC, `sync_product_${pId}`, {
|
||||
tenantId,
|
||||
integrationId,
|
||||
syncJobId: syncJob.id,
|
||||
productId: pId,
|
||||
operation: 'UPDATE'
|
||||
});
|
||||
}
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: `Sync job initialized for ${targets.length} products`,
|
||||
data: { syncJobId: syncJob.id, totalItems: targets.length }
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
|
||||
async listAllSyncJobs(req, res, next) {
|
||||
try {
|
||||
const tenantId = getTenantId(req);
|
||||
const jobs = await models.IntegrationSyncJob.findAll({
|
||||
where: { tenant_id: tenantId },
|
||||
include: [
|
||||
{
|
||||
model: models.Integration,
|
||||
as: 'integration',
|
||||
attributes: ['id', 'name', 'channel']
|
||||
}
|
||||
],
|
||||
order: [['created_at', 'DESC']],
|
||||
limit: 100
|
||||
});
|
||||
res.json({ success: true, data: jobs });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
|
||||
async listSyncJobs(req, res, next) {
|
||||
try {
|
||||
const tenantId = getTenantId(req);
|
||||
const integrationId = req.params.id;
|
||||
const jobs = await models.IntegrationSyncJob.findAll({
|
||||
where: { tenant_id: tenantId, integration_id: integrationId },
|
||||
order: [['created_at', 'DESC']],
|
||||
limit: 50
|
||||
});
|
||||
res.json({ success: true, data: jobs });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
|
||||
async listSyncItems(req, res, next) {
|
||||
try {
|
||||
const tenantId = getTenantId(req);
|
||||
const { jobId } = req.params;
|
||||
|
||||
// Validate UUID format before querying Postgres
|
||||
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(jobId);
|
||||
if (!isUuid) {
|
||||
return res.json({ success: true, data: [] });
|
||||
}
|
||||
|
||||
const items = await models.SyncItem.findAll({
|
||||
where: { tenant_id: tenantId, sync_job_id: jobId },
|
||||
order: [['created_at', 'DESC']],
|
||||
raw: true
|
||||
});
|
||||
|
||||
// Safely enrich with Product details in-memory to prevent SQL type mismatch JOIN errors
|
||||
const productIds = Array.from(new Set(items.map(i => i.product_id).filter(Boolean)));
|
||||
const productMap = {};
|
||||
if (productIds.length > 0) {
|
||||
try {
|
||||
const prods = await models.Product.findAll({
|
||||
where: { id: productIds },
|
||||
attributes: ['id', 'name', 'sku'],
|
||||
raw: true
|
||||
});
|
||||
prods.forEach(p => { productMap[p.id] = p; });
|
||||
} catch (pErr) {
|
||||
console.warn('[listSyncItems] Failed to fetch product metadata:', pErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
const enriched = items.map(item => ({
|
||||
...item,
|
||||
product: productMap[item.product_id] || { id: item.product_id, name: `Product #${item.product_id.slice(0, 8)}`, sku: item.sku || '' }
|
||||
}));
|
||||
|
||||
res.json({ success: true, data: enriched });
|
||||
} catch (err) {
|
||||
console.error('[listSyncItems] Error:', err);
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Start Shopify OAuth flow:
|
||||
* Requires credentials: shop_domain, api_key already saved on this integration.
|
||||
* Returns { authorizationUrl } which the frontend opens in a new tab.
|
||||
*/
|
||||
async startShopifyOAuth(req, res, next) {
|
||||
try {
|
||||
const tenantId = getTenantId(req);
|
||||
const integrationId = req.params.id;
|
||||
|
||||
// Load decrypted credentials to get shop_domain + api_key
|
||||
const creds = await integrationService.getDecryptedCredentials(integrationId, tenantId);
|
||||
|
||||
if (!creds.shop_domain) {
|
||||
return res.status(400).json({ success: false, message: 'Save shop_domain credential first before starting OAuth' });
|
||||
}
|
||||
if (!creds.api_key) {
|
||||
return res.status(400).json({ success: false, message: 'Save api_key credential first before starting OAuth' });
|
||||
}
|
||||
|
||||
const result = await shopifyOAuthService.buildAuthorizationUrl(
|
||||
integrationId,
|
||||
creds.shop_domain,
|
||||
creds.api_key,
|
||||
tenantId
|
||||
);
|
||||
|
||||
console.log(`[ShopifyOAuth] Generated Authorization URL:\n Domain: ${creds.shop_domain}\n API Key: ${creds.api_key}\n URL: ${result.authorizationUrl}`);
|
||||
|
||||
res.json({ success: true, data: result });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Shopify OAuth callback — public route (no auth middleware needed).
|
||||
* Shopify redirects here with ?code=...&shop=...&state=...
|
||||
* After exchange, redirects user to frontend /integrations?oauth=success&integrationId=...
|
||||
*/
|
||||
async handleShopifyOAuthCallback(req, res) {
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
|
||||
try {
|
||||
const result = await shopifyOAuthService.handleCallback(req.query);
|
||||
res.redirect(`${frontendUrl}/integrations?oauth=success&integrationId=${result.integrationId}&shop=${result.shop}`);
|
||||
} catch (err) {
|
||||
console.error('[ShopifyOAuth] Callback error:', err.message);
|
||||
res.redirect(`${frontendUrl}/integrations?oauth=error&message=${encodeURIComponent(err.message)}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { models } from '../../../shared/database/models.js';
|
||||
import { secretService } from '../../../shared/infrastructure/secrets/secret.service.js';
|
||||
import { adapterRegistry } from '../adapters/adapter.registry.js';
|
||||
|
||||
export const integrationService = {
|
||||
async listIntegrations(tenantId) {
|
||||
return await models.Integration.findAll({
|
||||
where: { tenant_id: tenantId },
|
||||
include: [
|
||||
{
|
||||
model: models.IntegrationCredential,
|
||||
as: 'credentials',
|
||||
attributes: ['id', 'credential_type', 'expires_at', 'created_at']
|
||||
}
|
||||
],
|
||||
order: [['created_at', 'DESC']]
|
||||
});
|
||||
},
|
||||
|
||||
async getById(id, tenantId) {
|
||||
const integration = await models.Integration.findOne({
|
||||
where: { id, tenant_id: tenantId },
|
||||
include: [
|
||||
{
|
||||
model: models.IntegrationCredential,
|
||||
as: 'credentials',
|
||||
attributes: ['id', 'credential_type', 'expires_at', 'created_at']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!integration) {
|
||||
throw new Error(`Integration ${id} not found`);
|
||||
}
|
||||
return integration;
|
||||
},
|
||||
|
||||
async createIntegration(tenantId, body) {
|
||||
return await models.Integration.create({
|
||||
tenant_id: tenantId,
|
||||
name: body.name,
|
||||
channel: body.channel || body.channelName || 'shopify',
|
||||
integration_type: body.integration_type || body.integrationType || 'ecommerce',
|
||||
status: body.status || 'active',
|
||||
sync_mode: body.sync_mode || body.syncMode || 'auto',
|
||||
sync_frequency: body.sync_frequency || body.syncFrequency || 'realtime',
|
||||
health_status: 'healthy'
|
||||
});
|
||||
},
|
||||
|
||||
async updateIntegration(id, tenantId, updates) {
|
||||
const integration = await this.getById(id, tenantId);
|
||||
return await integration.update(updates);
|
||||
},
|
||||
|
||||
async setCredentials(id, tenantId, credentialType, secretValue, expiresAt = null) {
|
||||
const integration = await this.getById(id, tenantId);
|
||||
const encryptedSecret = secretService.encrypt(secretValue);
|
||||
|
||||
// Remove old credential of same type
|
||||
await models.IntegrationCredential.destroy({
|
||||
where: { integration_id: id, credential_type: credentialType, tenant_id: tenantId }
|
||||
});
|
||||
|
||||
return await models.IntegrationCredential.create({
|
||||
tenant_id: tenantId,
|
||||
integration_id: id,
|
||||
credential_type: credentialType,
|
||||
encrypted_secret: encryptedSecret,
|
||||
key_version: 1,
|
||||
expires_at: expiresAt
|
||||
});
|
||||
},
|
||||
|
||||
async getDecryptedCredentials(id, tenantId) {
|
||||
const credentials = await models.IntegrationCredential.findAll({
|
||||
where: { integration_id: id, tenant_id: tenantId }
|
||||
});
|
||||
|
||||
const map = {};
|
||||
credentials.forEach(c => {
|
||||
map[c.credential_type] = secretService.decrypt(c.encrypted_secret);
|
||||
});
|
||||
return map;
|
||||
},
|
||||
|
||||
async testConnection(id, tenantId) {
|
||||
const integration = await this.getById(id, tenantId);
|
||||
const decryptedConfig = await this.getDecryptedCredentials(id, tenantId);
|
||||
|
||||
const adapter = adapterRegistry.getAdapter(integration.channel, decryptedConfig);
|
||||
return await adapter.testConnection();
|
||||
},
|
||||
|
||||
async deleteIntegration(id, tenantId) {
|
||||
const integration = await this.getById(id, tenantId);
|
||||
|
||||
// Clean up credentials and associated records
|
||||
await models.IntegrationCredential.destroy({
|
||||
where: { integration_id: id, tenant_id: tenantId }
|
||||
});
|
||||
if (models.ExternalResource) {
|
||||
await models.ExternalResource.destroy({
|
||||
where: { integration_id: id, tenant_id: tenantId }
|
||||
});
|
||||
}
|
||||
if (models.PublishingRule) {
|
||||
await models.PublishingRule.destroy({
|
||||
where: { integration_id: id, tenant_id: tenantId }
|
||||
});
|
||||
}
|
||||
|
||||
await integration.destroy();
|
||||
return true;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { models } from '../../../shared/database/models.js';
|
||||
|
||||
export const ruleService = {
|
||||
async evaluateRules(integrationId, tenantId, canonicalProduct) {
|
||||
const rules = await models.PublishingRule.findAll({
|
||||
where: { integration_id: integrationId, tenant_id: tenantId }
|
||||
});
|
||||
|
||||
if (rules.length === 0) {
|
||||
return { eligible: true, reasons: [] };
|
||||
}
|
||||
|
||||
const reasons = [];
|
||||
|
||||
for (const rule of rules) {
|
||||
const cond = rule.conditions_json || {};
|
||||
|
||||
// Category check
|
||||
if (cond.allowedCategories && Array.isArray(cond.allowedCategories) && cond.allowedCategories.length > 0) {
|
||||
const prodCatId = canonicalProduct.taxonomy?.category?.id;
|
||||
if (!prodCatId || !cond.allowedCategories.includes(prodCatId)) {
|
||||
reasons.push(`Product category not in allowed rule list for rule ${rule.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Status check
|
||||
if (cond.requiredStatus && canonicalProduct.content?.status !== cond.requiredStatus) {
|
||||
reasons.push(`Product status '${canonicalProduct.content?.status}' does not match required status '${cond.requiredStatus}'`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eligible: reasons.length === 0,
|
||||
reasons
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import crypto from 'crypto';
|
||||
import { models } from '../../../shared/database/models.js';
|
||||
|
||||
export const syncService = {
|
||||
generateIdempotencyKey(tenantId, integrationId, productId, sourceVersion, operation) {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(`${tenantId}:${integrationId}:${productId}:${sourceVersion || 1}:${operation || 'UPDATE'}`)
|
||||
.digest('hex');
|
||||
},
|
||||
|
||||
async createSyncJob(tenantId, integrationId, triggerSource = 'manual') {
|
||||
return await models.IntegrationSyncJob.create({
|
||||
tenant_id: tenantId,
|
||||
integration_id: integrationId,
|
||||
trigger_source: triggerSource,
|
||||
status: 'pending',
|
||||
total_items: 0,
|
||||
success_items: 0,
|
||||
failed_items: 0,
|
||||
started_at: new Date()
|
||||
});
|
||||
},
|
||||
|
||||
async createSyncItem(tenantId, syncJobId, integrationId, productId, operation = 'UPDATE', sourceVersion = 1) {
|
||||
const idempotencyKey = this.generateIdempotencyKey(tenantId, integrationId, productId, sourceVersion, operation);
|
||||
|
||||
// Find existing item or create
|
||||
const [item] = await models.SyncItem.findOrCreate({
|
||||
where: { tenant_id: tenantId, idempotency_key: idempotencyKey },
|
||||
defaults: {
|
||||
tenant_id: tenantId,
|
||||
sync_job_id: syncJobId,
|
||||
integration_id: integrationId,
|
||||
product_id: productId,
|
||||
operation,
|
||||
status: 'pending',
|
||||
source_version: sourceVersion,
|
||||
idempotency_key: idempotencyKey,
|
||||
attempt_count: 0
|
||||
}
|
||||
});
|
||||
|
||||
return item;
|
||||
},
|
||||
|
||||
async recordAttempt(tenantId, syncItemId, attemptNumber, requestMethod, requestUrl, idempotencyKey) {
|
||||
return await models.SyncAttempt.create({
|
||||
tenant_id: tenantId,
|
||||
sync_item_id: syncItemId,
|
||||
attempt_number: attemptNumber,
|
||||
started_at: new Date(),
|
||||
status: 'processing',
|
||||
request_method: requestMethod,
|
||||
request_url: requestUrl,
|
||||
idempotency_key: idempotencyKey
|
||||
});
|
||||
},
|
||||
|
||||
async completeAttempt(attemptId, status, responseStatus, durationMs, errorCode = null, errorMessage = null) {
|
||||
const attempt = await models.SyncAttempt.findByPk(attemptId);
|
||||
if (attempt) {
|
||||
await attempt.update({
|
||||
status,
|
||||
response_status: responseStatus,
|
||||
duration_ms: durationMs,
|
||||
error_code: errorCode,
|
||||
error_message: errorMessage,
|
||||
completed_at: new Date()
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async logError(tenantId, syncJobId, syncItemId, errorCode, errorType, message, providerMessage = null, httpStatus = null, retryable = true, attemptNumber = 1, metadata = {}) {
|
||||
return await models.SyncError.create({
|
||||
tenant_id: tenantId,
|
||||
sync_job_id: syncJobId,
|
||||
sync_item_id: syncItemId,
|
||||
error_code: errorCode,
|
||||
error_type: errorType,
|
||||
message,
|
||||
provider_message: providerMessage,
|
||||
http_status: httpStatus,
|
||||
retryable,
|
||||
attempt_number: attemptNumber,
|
||||
metadata
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import router from './routes/integration.routes.js';
|
||||
import { startSyncWorker } from './workers/sync.worker.js';
|
||||
import { startOutboxWorker } from './workers/outbox.worker.js';
|
||||
|
||||
// Initialize background queue worker and outbox polling worker
|
||||
try {
|
||||
startSyncWorker();
|
||||
startOutboxWorker();
|
||||
} catch (err) {
|
||||
console.warn('Background worker initialization deferred:', err.message);
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,72 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
|
||||
export default function (sequelize) {
|
||||
const ChannelMapping = sequelize.define(
|
||||
'ChannelMapping',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
tenant_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
integration_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
entity_type: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false
|
||||
},
|
||||
source_path: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
target_path: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
transformation_type: {
|
||||
type: DataTypes.STRING(50),
|
||||
defaultValue: 'string'
|
||||
},
|
||||
transformation_config: {
|
||||
type: DataTypes.JSONB,
|
||||
defaultValue: {}
|
||||
},
|
||||
default_value: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
required: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 1
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(20),
|
||||
defaultValue: 'active'
|
||||
}
|
||||
},
|
||||
{
|
||||
tableName: 'channel_mappings',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at'
|
||||
}
|
||||
);
|
||||
|
||||
ChannelMapping.associate = (models) => {
|
||||
if (models.Integration) {
|
||||
ChannelMapping.belongsTo(models.Integration, { foreignKey: 'integration_id', as: 'integration' });
|
||||
}
|
||||
};
|
||||
|
||||
return ChannelMapping;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
|
||||
export default function (sequelize) {
|
||||
const ExternalResource = sequelize.define(
|
||||
'ExternalResource',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
tenant_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
integration_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
resource_type: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false
|
||||
},
|
||||
source_type: {
|
||||
type: DataTypes.STRING(50),
|
||||
defaultValue: 'PIM'
|
||||
},
|
||||
source_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
external_id: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
external_parent_id: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true
|
||||
},
|
||||
external_url: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(20),
|
||||
defaultValue: 'active'
|
||||
},
|
||||
last_synced_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
last_source_version: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 1
|
||||
}
|
||||
},
|
||||
{
|
||||
tableName: 'external_resources',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at'
|
||||
}
|
||||
);
|
||||
|
||||
ExternalResource.associate = (models) => {
|
||||
if (models.Integration) {
|
||||
ExternalResource.belongsTo(models.Integration, { foreignKey: 'integration_id', as: 'integration' });
|
||||
}
|
||||
};
|
||||
|
||||
return ExternalResource;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
|
||||
export default function (sequelize) {
|
||||
const Integration = sequelize.define(
|
||||
'Integration',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
tenant_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
channel: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false
|
||||
},
|
||||
integration_type: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: true,
|
||||
defaultValue: 'ecommerce'
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
defaultValue: 'active'
|
||||
},
|
||||
sync_mode: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
defaultValue: 'auto'
|
||||
},
|
||||
sync_frequency: {
|
||||
type: DataTypes.STRING(50),
|
||||
defaultValue: 'realtime'
|
||||
},
|
||||
health_status: {
|
||||
type: DataTypes.STRING(20),
|
||||
defaultValue: 'healthy'
|
||||
},
|
||||
last_synced_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
}
|
||||
},
|
||||
{
|
||||
tableName: 'integrations',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at'
|
||||
}
|
||||
);
|
||||
|
||||
Integration.associate = (models) => {
|
||||
if (models.Tenant) {
|
||||
Integration.belongsTo(models.Tenant, { foreignKey: 'tenant_id', as: 'tenant' });
|
||||
}
|
||||
if (models.IntegrationCredential) {
|
||||
Integration.hasMany(models.IntegrationCredential, { foreignKey: 'integration_id', as: 'credentials' });
|
||||
}
|
||||
if (models.PublishingRule) {
|
||||
Integration.hasMany(models.PublishingRule, { foreignKey: 'integration_id', as: 'publishingRules' });
|
||||
}
|
||||
if (models.ChannelMapping) {
|
||||
Integration.hasMany(models.ChannelMapping, { foreignKey: 'integration_id', as: 'channelMappings' });
|
||||
}
|
||||
if (models.SyncJob) {
|
||||
Integration.hasMany(models.SyncJob, { foreignKey: 'integration_id', as: 'syncJobs' });
|
||||
}
|
||||
if (models.ExternalResource) {
|
||||
Integration.hasMany(models.ExternalResource, { foreignKey: 'integration_id', as: 'externalResources' });
|
||||
}
|
||||
};
|
||||
|
||||
return Integration;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
|
||||
export default function (sequelize) {
|
||||
const IntegrationCredential = sequelize.define(
|
||||
'IntegrationCredential',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
tenant_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
integration_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
credential_type: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false
|
||||
},
|
||||
encrypted_secret: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
key_version: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 1,
|
||||
allowNull: false
|
||||
},
|
||||
expires_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
}
|
||||
},
|
||||
{
|
||||
tableName: 'integration_credentials',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at'
|
||||
}
|
||||
);
|
||||
|
||||
IntegrationCredential.associate = (models) => {
|
||||
if (models.Integration) {
|
||||
IntegrationCredential.belongsTo(models.Integration, { foreignKey: 'integration_id', as: 'integration' });
|
||||
}
|
||||
};
|
||||
|
||||
return IntegrationCredential;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
|
||||
export default function (sequelize) {
|
||||
const OutboxEvent = sequelize.define(
|
||||
'OutboxEvent',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
tenant_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
event_type: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: false
|
||||
},
|
||||
aggregate_type: {
|
||||
type: DataTypes.STRING(50),
|
||||
defaultValue: 'PRODUCT'
|
||||
},
|
||||
aggregate_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
payload: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: false
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(20),
|
||||
defaultValue: 'pending',
|
||||
allowNull: false
|
||||
},
|
||||
event_version: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 1
|
||||
},
|
||||
idempotency_key: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
retry_count: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0
|
||||
},
|
||||
locked_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
locked_by: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true
|
||||
},
|
||||
last_error: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
scheduled_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
processed_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
}
|
||||
},
|
||||
{
|
||||
tableName: 'outbox_events',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at'
|
||||
}
|
||||
);
|
||||
|
||||
return OutboxEvent;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
|
||||
export default function (sequelize) {
|
||||
const PublishingRule = sequelize.define(
|
||||
'PublishingRule',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
tenant_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
integration_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
rule_type: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false
|
||||
},
|
||||
conditions_json: {
|
||||
type: DataTypes.JSONB,
|
||||
defaultValue: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
tableName: 'publishing_rules',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at'
|
||||
}
|
||||
);
|
||||
|
||||
PublishingRule.associate = (models) => {
|
||||
if (models.Integration) {
|
||||
PublishingRule.belongsTo(models.Integration, { foreignKey: 'integration_id', as: 'integration' });
|
||||
}
|
||||
};
|
||||
|
||||
return PublishingRule;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
|
||||
export default function (sequelize) {
|
||||
const SyncAttempt = sequelize.define(
|
||||
'SyncAttempt',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
tenant_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
sync_item_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
attempt_number: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false
|
||||
},
|
||||
started_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
completed_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false
|
||||
},
|
||||
request_method: {
|
||||
type: DataTypes.STRING(10),
|
||||
allowNull: false
|
||||
},
|
||||
request_url: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
idempotency_key: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true
|
||||
},
|
||||
response_status: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
error_code: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true
|
||||
},
|
||||
error_message: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
duration_ms: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
}
|
||||
},
|
||||
{
|
||||
tableName: 'sync_attempts',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: false
|
||||
}
|
||||
);
|
||||
|
||||
SyncAttempt.associate = (models) => {
|
||||
if (models.SyncItem) {
|
||||
SyncAttempt.belongsTo(models.SyncItem, { foreignKey: 'sync_item_id', as: 'syncItem' });
|
||||
}
|
||||
};
|
||||
|
||||
return SyncAttempt;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
|
||||
export default function (sequelize) {
|
||||
const SyncError = sequelize.define(
|
||||
'SyncError',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
tenant_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
sync_job_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
sync_item_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
error_code: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: false
|
||||
},
|
||||
error_type: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false
|
||||
},
|
||||
message: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
provider_message: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
http_status: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
retryable: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: true
|
||||
},
|
||||
attempt_number: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false
|
||||
},
|
||||
metadata: {
|
||||
type: DataTypes.JSONB,
|
||||
defaultValue: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
tableName: 'sync_errors',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: false
|
||||
}
|
||||
);
|
||||
|
||||
SyncError.associate = (models) => {
|
||||
if (models.SyncJob) {
|
||||
SyncError.belongsTo(models.SyncJob, { foreignKey: 'sync_job_id', as: 'job' });
|
||||
}
|
||||
if (models.SyncItem) {
|
||||
SyncError.belongsTo(models.SyncItem, { foreignKey: 'sync_item_id', as: 'item' });
|
||||
}
|
||||
};
|
||||
|
||||
return SyncError;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
|
||||
export default function (sequelize) {
|
||||
const SyncItem = sequelize.define(
|
||||
'SyncItem',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
tenant_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
sync_job_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
integration_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
product_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
variant_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true
|
||||
},
|
||||
sku: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true
|
||||
},
|
||||
operation: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
defaultValue: 'pending'
|
||||
},
|
||||
source_version: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 1
|
||||
},
|
||||
idempotency_key: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
external_resource_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true
|
||||
},
|
||||
attempt_count: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0
|
||||
},
|
||||
error_code: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true
|
||||
},
|
||||
error_message: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
}
|
||||
},
|
||||
{
|
||||
tableName: 'sync_items',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at'
|
||||
}
|
||||
);
|
||||
|
||||
SyncItem.associate = (models) => {
|
||||
if (models.SyncJob) {
|
||||
SyncItem.belongsTo(models.SyncJob, { foreignKey: 'sync_job_id', as: 'job' });
|
||||
}
|
||||
if (models.Integration) {
|
||||
SyncItem.belongsTo(models.Integration, { foreignKey: 'integration_id', as: 'integration' });
|
||||
}
|
||||
if (models.Product) {
|
||||
SyncItem.belongsTo(models.Product, { foreignKey: 'product_id', as: 'product' });
|
||||
}
|
||||
if (models.SyncAttempt) {
|
||||
SyncItem.hasMany(models.SyncAttempt, { foreignKey: 'sync_item_id', as: 'attempts' });
|
||||
}
|
||||
if (models.SyncError) {
|
||||
SyncItem.hasMany(models.SyncError, { foreignKey: 'sync_item_id', as: 'errors' });
|
||||
}
|
||||
};
|
||||
|
||||
return SyncItem;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
|
||||
export default function (sequelize) {
|
||||
const SyncJob = sequelize.define(
|
||||
'SyncJob',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
tenant_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true
|
||||
},
|
||||
integration_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false
|
||||
},
|
||||
trigger_source: {
|
||||
type: DataTypes.STRING(50),
|
||||
allowNull: false,
|
||||
defaultValue: 'manual'
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false,
|
||||
defaultValue: 'pending'
|
||||
},
|
||||
total_items: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0
|
||||
},
|
||||
success_items: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0
|
||||
},
|
||||
failed_items: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0
|
||||
},
|
||||
started_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
completed_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
}
|
||||
},
|
||||
{
|
||||
tableName: 'sync_jobs',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at'
|
||||
}
|
||||
);
|
||||
|
||||
SyncJob.associate = (models) => {
|
||||
if (models.Integration) {
|
||||
SyncJob.belongsTo(models.Integration, { foreignKey: 'integration_id', as: 'integration' });
|
||||
}
|
||||
if (models.SyncItem) {
|
||||
SyncJob.hasMany(models.SyncItem, { foreignKey: 'sync_job_id', as: 'items' });
|
||||
}
|
||||
if (models.SyncError) {
|
||||
SyncJob.hasMany(models.SyncError, { foreignKey: 'sync_job_id', as: 'errors' });
|
||||
}
|
||||
};
|
||||
|
||||
return SyncJob;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { models } from '../../../shared/database/models.js';
|
||||
|
||||
export const canonicalProductBuilder = {
|
||||
async build(productId, tenantId) {
|
||||
const product = await models.Product.findOne({
|
||||
where: { id: productId, tenant_id: tenantId },
|
||||
include: [
|
||||
{ model: models.Brand, as: 'brand', required: false },
|
||||
{ model: models.Categorie, as: 'category', required: false },
|
||||
{ model: models.Catalog, as: 'family', required: false },
|
||||
{
|
||||
model: models.ProductAttributeValue,
|
||||
as: 'attributeValues',
|
||||
required: false,
|
||||
include: [{ model: models.Attribute, as: 'attribute', required: false }]
|
||||
},
|
||||
{
|
||||
model: models.ProductAsset,
|
||||
as: 'productAssets',
|
||||
required: false,
|
||||
include: [{ model: models.Asset, as: 'asset', required: false }]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new Error(`Product ${productId} not found for tenant ${tenantId}`);
|
||||
}
|
||||
|
||||
// Fetch product variants
|
||||
const variants = await models.Variant.findAll({
|
||||
where: { product_id: productId, tenant_id: tenantId },
|
||||
include: [
|
||||
{
|
||||
model: models.VariantValue,
|
||||
as: 'values',
|
||||
required: false,
|
||||
include: [{ model: models.Attribute, as: 'axis', required: false }]
|
||||
},
|
||||
{
|
||||
model: models.VariantAsset,
|
||||
as: 'variantAssets',
|
||||
required: false,
|
||||
include: [{ model: models.Asset, as: 'asset', required: false }]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Compile attributes map
|
||||
const attributes = {};
|
||||
if (Array.isArray(product.attributeValues)) {
|
||||
product.attributeValues.forEach(av => {
|
||||
const key = av.attribute?.code || av.axis?.code || av.attribute_id;
|
||||
attributes[key] = av.value || av.value_text || av.value_number || av.value_boolean;
|
||||
});
|
||||
}
|
||||
|
||||
// Compile media assets
|
||||
const media = [];
|
||||
const baseUrl = process.env.PUBLIC_APP_URL || process.env.APP_URL || 'http://localhost:5002';
|
||||
if (Array.isArray(product.productAssets)) {
|
||||
product.productAssets.forEach(pa => {
|
||||
if (pa.asset) {
|
||||
let url = pa.asset.file_url || '';
|
||||
if (url.startsWith('/')) {
|
||||
url = `${baseUrl}${url}`;
|
||||
}
|
||||
media.push({
|
||||
id: pa.asset.id,
|
||||
url,
|
||||
role: pa.role || 'gallery',
|
||||
isPrimary: pa.is_primary || false
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Compile variants list
|
||||
const compiledVariants = variants.map(v => {
|
||||
const vAttrs = {};
|
||||
if (Array.isArray(v.values)) {
|
||||
v.values.forEach(val => {
|
||||
const key = val.axis?.code || val.attribute_id;
|
||||
vAttrs[key] = val.value_text || val.value_number;
|
||||
});
|
||||
}
|
||||
|
||||
const vMedia = [];
|
||||
if (Array.isArray(v.variantAssets)) {
|
||||
v.variantAssets.forEach(va => {
|
||||
if (va.asset) {
|
||||
let url = va.asset.file_url || '';
|
||||
if (url.startsWith('/')) {
|
||||
url = `${baseUrl}${url}`;
|
||||
}
|
||||
vMedia.push({
|
||||
id: va.asset.id,
|
||||
url,
|
||||
role: va.role || 'gallery',
|
||||
isPrimary: va.is_primary || false
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: v.id,
|
||||
sku: v.sku,
|
||||
name: v.name,
|
||||
price: parseFloat(v.price) || 0,
|
||||
costPrice: parseFloat(v.costPrice) || 0,
|
||||
stock: parseInt(v.stock, 10) || 0,
|
||||
status: v.status,
|
||||
attributes: vAttrs,
|
||||
media: vMedia
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
identity: {
|
||||
id: product.id,
|
||||
tenantId: product.tenant_id,
|
||||
code: product.code || product.id,
|
||||
sku: product.sku || ''
|
||||
},
|
||||
content: {
|
||||
name: product.name,
|
||||
description: product.description || '',
|
||||
status: product.status || 'draft'
|
||||
},
|
||||
taxonomy: {
|
||||
brand: product.brand ? { id: product.brand.id, name: product.brand.name } : null,
|
||||
category: product.category ? { id: product.category.id, name: product.category.name } : null,
|
||||
family: product.family ? { id: product.family.id, name: product.family.name } : null
|
||||
},
|
||||
attributes,
|
||||
media,
|
||||
variants: compiledVariants,
|
||||
version: product.updated_at ? new Date(product.updated_at).getTime() : 1
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Router } from 'express';
|
||||
import { integrationController } from '../controllers/integration.controller.js';
|
||||
import { authenticate } from '../../../shared/middleware/auth.middleware.js';
|
||||
import { authorize } from '../../../shared/middleware/permission.middleware.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ─── PUBLIC ROUTE — Shopify OAuth callback (no auth; Shopify redirects here) ──
|
||||
router.get(
|
||||
'/integrations/shopify/oauth/callback',
|
||||
integrationController.handleShopifyOAuthCallback
|
||||
);
|
||||
|
||||
// ─── Protected routes ─────────────────────────────────────────────────────────
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/integrations', authorize(['settings.integrations']), integrationController.listIntegrations);
|
||||
router.post('/integrations', authorize(['settings.integrations']), integrationController.createIntegration);
|
||||
|
||||
router.get('/integrations/:id', authorize(['settings.integrations']), integrationController.getIntegration);
|
||||
router.put('/integrations/:id', authorize(['settings.integrations']), integrationController.updateIntegration);
|
||||
router.delete('/integrations/:id', authorize(['settings.integrations']), integrationController.deleteIntegration);
|
||||
|
||||
router.get('/integrations/:id/credentials', authorize(['settings.integrations']), integrationController.getCredentials);
|
||||
router.post('/integrations/:id/credentials', authorize(['settings.integrations']), integrationController.setCredentials);
|
||||
router.post('/integrations/:id/test-connection', authorize(['settings.integrations']), integrationController.testConnection);
|
||||
router.post('/integrations/:id/sync', authorize(['settings.integrations']), integrationController.triggerSync);
|
||||
|
||||
// ─── Shopify OAuth — Start flow (protected; user-initiated) ──────────────────
|
||||
router.post('/integrations/:id/shopify/oauth/start', authorize(['settings.integrations']), integrationController.startShopifyOAuth);
|
||||
|
||||
router.get('/integrations/jobs/all', authorize(['settings.integrations']), integrationController.listAllSyncJobs);
|
||||
router.get('/integrations/:id/jobs', authorize(['settings.integrations']), integrationController.listSyncJobs);
|
||||
router.get('/integrations/jobs/:jobId/items', authorize(['settings.integrations']), integrationController.listSyncItems);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,99 @@
|
||||
import { models, sequelize } from '../../../shared/database/models.js';
|
||||
import { queueService } from '../../../shared/infrastructure/queue/queue.service.js';
|
||||
import { QUEUE_NAMES } from '../../../shared/infrastructure/queue/queue.constants.js';
|
||||
import { syncService } from '../domain/sync.service.js';
|
||||
import { Op } from 'sequelize';
|
||||
import winston from 'winston';
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: 'info',
|
||||
format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
|
||||
transports: [new winston.transports.Console()]
|
||||
});
|
||||
|
||||
export const processOutboxEvents = async () => {
|
||||
const workerId = `worker_${process.pid}_${Date.now()}`;
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
// 1. Claim pending outbox events using locking
|
||||
const [claimedCount] = await models.OutboxEvent.update(
|
||||
{
|
||||
status: 'processing',
|
||||
locked_at: now,
|
||||
locked_by: workerId
|
||||
},
|
||||
{
|
||||
where: {
|
||||
status: 'pending',
|
||||
scheduled_at: { [Op.lte]: now }
|
||||
},
|
||||
limit: 50
|
||||
}
|
||||
);
|
||||
|
||||
if (claimedCount === 0) return;
|
||||
|
||||
// 2. Fetch claimed events
|
||||
const claimedEvents = await models.OutboxEvent.findAll({
|
||||
where: {
|
||||
locked_by: workerId,
|
||||
status: 'processing'
|
||||
}
|
||||
});
|
||||
|
||||
for (const event of claimedEvents) {
|
||||
try {
|
||||
const { tenant_id, aggregate_id, event_type, payload } = event;
|
||||
const productId = aggregate_id;
|
||||
|
||||
// Find active integrations for tenant
|
||||
const activeIntegrations = await models.Integration.findAll({
|
||||
where: { tenant_id, status: 'active', sync_mode: 'auto' }
|
||||
});
|
||||
|
||||
for (const integration of activeIntegrations) {
|
||||
const syncJob = await syncService.createSyncJob(tenant_id, integration.id, 'outbox');
|
||||
|
||||
await queueService.addJob(QUEUE_NAMES.INTEGRATION_SYNC, `sync_product_${productId}`, {
|
||||
tenantId: tenant_id,
|
||||
integrationId: integration.id,
|
||||
syncJobId: syncJob.id,
|
||||
productId,
|
||||
operation: event_type.includes('delete') ? 'DELETE' : 'UPDATE'
|
||||
});
|
||||
}
|
||||
|
||||
await event.update({ status: 'completed', processed_at: new Date() });
|
||||
} catch (err) {
|
||||
logger.error(`Error processing outbox event ${event.id}: ${err.message}`);
|
||||
await event.update({
|
||||
status: 'failed',
|
||||
retry_count: (event.retry_count || 0) + 1,
|
||||
last_error: err.message
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Outbox worker failure: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
let isProcessing = false;
|
||||
|
||||
export const guardedProcessOutboxEvents = async () => {
|
||||
if (isProcessing) {
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessing = true;
|
||||
try {
|
||||
await processOutboxEvents();
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
};
|
||||
|
||||
export const startOutboxWorker = (intervalMs = 10000) => {
|
||||
setInterval(guardedProcessOutboxEvents, intervalMs);
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
import { queueService } from '../../../shared/infrastructure/queue/queue.service.js';
|
||||
import { QUEUE_NAMES } from '../../../shared/infrastructure/queue/queue.constants.js';
|
||||
import { canonicalProductBuilder } from '../projection/canonical-product.builder.js';
|
||||
import { ruleService } from '../domain/rule.service.js';
|
||||
import { syncService } from '../domain/sync.service.js';
|
||||
import { integrationService } from '../domain/integration.service.js';
|
||||
import { adapterRegistry } from '../adapters/adapter.registry.js';
|
||||
import { models } from '../../../shared/database/models.js';
|
||||
import winston from 'winston';
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: 'info',
|
||||
format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
|
||||
transports: [new winston.transports.Console()]
|
||||
});
|
||||
|
||||
export const startSyncWorker = () => {
|
||||
queueService.registerWorker(QUEUE_NAMES.INTEGRATION_SYNC, async (job) => {
|
||||
const { tenantId, integrationId, syncJobId, productId, operation = 'UPDATE' } = job.data;
|
||||
logger.info(`Processing sync job for product ${productId} on integration ${integrationId}`);
|
||||
|
||||
// Build canonical product
|
||||
const canonicalProduct = await canonicalProductBuilder.build(productId, tenantId);
|
||||
|
||||
// Create sync item
|
||||
const syncItem = await syncService.createSyncItem(
|
||||
tenantId,
|
||||
syncJobId,
|
||||
integrationId,
|
||||
productId,
|
||||
operation,
|
||||
canonicalProduct.version
|
||||
);
|
||||
|
||||
// Rule evaluation
|
||||
const ruleResult = await ruleService.evaluateRules(integrationId, tenantId, canonicalProduct);
|
||||
if (!ruleResult.eligible) {
|
||||
await syncItem.update({
|
||||
status: 'skipped',
|
||||
error_code: 'RULE_INELIGIBLE',
|
||||
error_message: ruleResult.reasons.join('; ')
|
||||
});
|
||||
logger.info(`Skipped product ${productId}: ${ruleResult.reasons.join('; ')}`);
|
||||
return { skipped: true, reasons: ruleResult.reasons };
|
||||
}
|
||||
|
||||
const attemptNumber = (syncItem.attempt_count || 0) + 1;
|
||||
const requestUrl = `integration://${integrationId}/product/${productId}`;
|
||||
const attemptRecord = await syncService.recordAttempt(
|
||||
tenantId,
|
||||
syncItem.id,
|
||||
attemptNumber,
|
||||
'POST',
|
||||
requestUrl,
|
||||
syncItem.idempotency_key
|
||||
);
|
||||
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
await syncItem.update({ status: 'processing', attempt_count: attemptNumber });
|
||||
|
||||
const integration = await integrationService.getById(integrationId, tenantId);
|
||||
const decryptedCredentials = await integrationService.getDecryptedCredentials(integrationId, tenantId);
|
||||
const adapter = adapterRegistry.getAdapter(integration.channel, decryptedCredentials);
|
||||
|
||||
let result;
|
||||
if (operation === 'DELETE') {
|
||||
result = await adapter.deleteProduct({ tenantId, integrationId, productId });
|
||||
} else {
|
||||
result = await adapter.publishProduct({ tenantId, integrationId, canonicalProduct });
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
await syncService.completeAttempt(attemptRecord.id, 'success', 200, durationMs);
|
||||
await syncItem.update({ status: 'success', error_code: null, error_message: null });
|
||||
|
||||
// Increment job success count and check completion
|
||||
if (syncJobId) {
|
||||
await models.IntegrationSyncJob.increment('success_items', { where: { id: syncJobId } });
|
||||
const jobRecord = await models.IntegrationSyncJob.findByPk(syncJobId);
|
||||
if (jobRecord && (jobRecord.success_items + jobRecord.failed_items) >= jobRecord.total_items) {
|
||||
await jobRecord.update({
|
||||
status: jobRecord.failed_items === 0 ? 'completed' : (jobRecord.success_items > 0 ? 'completed' : 'failed'),
|
||||
completed_at: new Date()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Successfully synced product ${productId} to ${integration.channel}`);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const durationMs = Date.now() - startTime;
|
||||
const httpStatus = err.status || 500;
|
||||
const errorMessage = err.message || 'Unknown integration error';
|
||||
|
||||
await syncService.completeAttempt(attemptRecord.id, 'failed', httpStatus, durationMs, 'ADAPTER_ERROR', errorMessage);
|
||||
await syncItem.update({ status: 'failed', error_code: 'ADAPTER_ERROR', error_message: errorMessage });
|
||||
|
||||
await syncService.logError(
|
||||
tenantId,
|
||||
syncJobId,
|
||||
syncItem.id,
|
||||
'ADAPTER_ERROR',
|
||||
httpStatus === 429 ? 'RATE_LIMIT' : 'PROVIDER_ERROR',
|
||||
errorMessage,
|
||||
err.providerData ? JSON.stringify(err.providerData) : null,
|
||||
httpStatus,
|
||||
err.retryable !== false,
|
||||
attemptNumber,
|
||||
{ productId, integrationId }
|
||||
);
|
||||
|
||||
if (syncJobId) {
|
||||
await models.IntegrationSyncJob.increment('failed_items', { where: { id: syncJobId } });
|
||||
const jobRecord = await models.IntegrationSyncJob.findByPk(syncJobId);
|
||||
if (jobRecord && (jobRecord.success_items + jobRecord.failed_items) >= jobRecord.total_items) {
|
||||
await jobRecord.update({
|
||||
status: jobRecord.success_items > 0 ? 'completed' : 'failed',
|
||||
completed_at: new Date()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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,47 @@ export class AssetFamilyRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async findAll(options = {}) {
|
||||
async findAll(options = {}, context = {}) {
|
||||
const where = applyTenantScope(options.where || {}, context);
|
||||
const queryOptions = {
|
||||
order: [['created_at', 'DESC']],
|
||||
...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
|
||||
@@ -33,6 +37,10 @@ export default (sequelize) => {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false
|
||||
},
|
||||
is_variant_eligible: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false
|
||||
},
|
||||
category: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: 'other'
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
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({
|
||||
order: [['created_at', 'DESC']],
|
||||
...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,
|
||||
@@ -50,6 +32,7 @@ export class AssetTypeService {
|
||||
description: data.description || null,
|
||||
status: data.status || 'active',
|
||||
is_required: data.is_required !== undefined ? Boolean(data.is_required) : (data.isRequired !== undefined ? Boolean(data.isRequired) : false),
|
||||
is_variant_eligible: data.is_variant_eligible !== undefined ? Boolean(data.is_variant_eligible) : (data.isVariantEligible !== undefined ? Boolean(data.isVariantEligible) : false),
|
||||
category: data.category || 'other',
|
||||
validation: data.validation || {}
|
||||
};
|
||||
@@ -90,6 +73,9 @@ export class AssetTypeService {
|
||||
if (data.isRequired !== undefined && data.is_required === undefined) {
|
||||
payload.is_required = Boolean(data.isRequired);
|
||||
}
|
||||
if (data.isVariantEligible !== undefined && data.is_variant_eligible === undefined) {
|
||||
payload.is_variant_eligible = Boolean(data.isVariantEligible);
|
||||
}
|
||||
|
||||
const updatedRecord = await repository.update(id, payload);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user